0005: Session traces and a decision model for run classification¶
Design record
Written 2026-10-12; deferred. The current behaviour is on api/traces.md (trace export); the status line below says what shipped.
Status: deferred (2026-10-15): the DecisionService and Decision-1 are not scheduled; trace export moved to 0014, and evaluation to flightdeck. Was: draft. · Date: 2026-10-12 · Builds on: 0001 (run events), 0002 (verification, approvals), 0003 (gateway, egress) · Consumed by: 0004 (supervisor)
Part of a set. Ownership, cross-spec decisions and the combined build order are in docs/README.md; where this spec conflicts with that page, the page wins.
1. Summary¶
- Collect OpenTelemetry from Claude Code sessions through an OTLP endpoint on the gateway listener. Tag every signal with hangar's sandbox, run and task ids on the server side.
- 0001's event stream stays the spine. OTel adds what the stream lacks (API latency, retries, errors, permission decisions, span timing) as extra rows in the same table, not a second timeline.
- A provider-neutral
DecisionServicescores fixed options for six decision tasks. Microsoft Decision-1 is the first backend. A rules baseline and a local or cheap-LLM backend are the fallbacks. - Decisions are advisory and logged next to ground truth (0002 verification, human approvals) until measured calibration says otherwise. Nothing acts on a decision in v1.
- 0004 uses two calls:
classify_runand an event/span query. A separate loop turns traces, decisions and outcomes into dashboards, environment fixes and paired evaluations.
2. Problem and evidence¶
From the 2026-10-11 GKE run, where two Claude Code agents in gVisor sandboxes opened PRs #13 and #14:
- An agent was dead for 10+ minutes while
agent_statussaidrunning. Status today is "no.exitfile yet" (fleet.py:agent_status). Nothing watched the process. 0001 fixes this with a pid probe. That is a deterministic check and stays deterministic here. A model must never be the thing that decides "the process is gone". - Plan usage at 91% was visible only in the raw log. A
rate_limit_eventline sat in the stream-json output. No code read it. - The orchestrator wrote its own stream-json parser to get anything useful out of
run_agent. 0001 moves that parser into hangar. This spec makes sure a second orchestrator does not write a second parser for traces.
What is still missing after 0001:
- Stream-json says what the agent did. It does not say how the API behaved: request latency, retries, overload and 429 errors, or whether a tool call needed a permission decision. Those explain slow or failed runs.
- 0001 detects dead and waiting for input. It cannot say whether a live run is making progress, looping on the same failing command, or blocked by something in the environment. Those are judgement calls over text.
- Nobody can answer "which environment fails most, and why?" or "did the new instructions help?" Outcome data exists (0002 verification) but is not joined to run behaviour.
- Jarmak, "Engineering Reliable Coding Agents" (arXiv 2608.13867), reports that single-run results vary by 2–6 points and that one model "submitted" 100% of trials while resolving 44% under an external oracle. Any optimization claim built on one run, or on the agent's own claim, is noise.
What I verified for this spec:
| Item | Status |
|---|---|
Claude Code OTel: CLAUDE_CODE_ENABLE_TELEMETRY, OTEL_{METRICS,LOGS,TRACES}_EXPORTER, OTEL_EXPORTER_OTLP_*, OTEL_LOG_USER_PROMPTS / _TOOL_DETAILS / _TOOL_CONTENT, OTEL_RESOURCE_ATTRIBUTES, beta tracing via CLAUDE_CODE_ENHANCED_TELEMETRY_BETA, inbound TRACEPARENT |
Documented at code.claude.com/docs/en/monitoring-usage. The variable names also appear in the installed 2.1.296 binary. Behaviour not run. |
Metric and event names (claude_code.token.usage, cost.usage, commit.count, pull_request.count, user_prompt, tool_result, tool_decision, api_request, api_error) |
From the docs. Attribute names inside each are not copied here. M0 captures real payloads. |
Whether CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 (set in harnesses.py) blocks OTel export |
Unknown. Open question 1. |
pi --mode json |
Event types as given to me: session header, agent_start/end, turn_start/end, message_*, tool_execution_start/update/end. pi is not installed here. No OTel that I can verify. |
Codex codex exec --json |
JSONL, schema unverified (0001 OQ2). I did not find verified OTel support. Open question 2. |
Antigravity (agy), Gemini CLI |
Not installed. Unknown. Open question 2. |
| Microsoft Decision-1 | Facts as given in the brief (release date, size, price, latency). Not independently checked. The API request shape is unknown. Open question 6. |
3. Goals / non-goals¶
Goals
- Capture Claude Code OTel (metrics, logs, and the beta traces) per run, on docker, GKE and E2B, with ids that tie each signal to a sandbox, run and task.
- Join OTel to 0001's event stream without a second timeline or duplicated normalization.
- Keep prompts, tool output and secrets out of stored telemetry by default. Redact on the server, not only at the source.
- A
DecisionServicewith fixed, versioned decision tasks. Advisory first. Every decision logged with the ground truth that later arrives. - Extra model spend small, capped and visible. No new spend at all in no-egress mode.
- A loop from traces, decisions and outcomes to dashboards, environment fixes, instruction changes and routing, evaluated with repeats and paired comparisons.
- A small, stable interface for 0004.
Non-goals
- Replacing 0001's liveness watcher, 0002's verification, or 0003's enforcement. A model score is never a security control.
- Letting a decision merge, push, cancel or spend without a human in v1.
- Fine-tuning or hosting Decision-1. There are no open weights.
- Building a general observability product. We store enough to answer hangar's questions and forward the rest to an external backend if the owner runs one.
- Telemetry for Codex, pi, Antigravity in v1 beyond what 0001-style log parsers give. Parsers for pi are in scope (M3). OTel for them waits on open question 2.
- Using the Claude subscription token for decision calls. Whether that is allowed is 0003 OQ5, and it is a model-API call anyway. Decision backends use their own API keys.
4. User stories¶
- As the owner, I open a run and see a timeline: which tool calls were slow, which API requests retried, where 3 minutes went to a 529 overload error.
- As an orchestrator (Claude Code via MCP), I call
classify_runand get "looping, 0.81" with a pointer to the repeated command, before I wait out a timeout. - As the owner, I see on the fleet page that
claude-pythonfails 4 of 10 runs on "missing dependency", with the exact package, and a proposed spec change to review. - As the owner, I can see what I spent on decision calls this week (cents) and turn them off with one setting.
- As a security-conscious user, I can run with no data leaving the cluster. Decisions fall back to rules and a local model.
- As the owner, I can compare two instruction sets on 20 tasks with 3 repeats each. The report says effect size, interval and cost, and refuses to call a 3-point difference a win.
- As the 0004 supervisor author, I get one call to classify a run and one query for events and spans, with documented JSON.
5. Design¶
5.1 Overview¶
sandbox (claude -p ...) control plane pod
┌──────────────────────┐ OTLP/HTTP protobuf ┌──────────────────────────────────────────┐
│ OTEL_* env per run │ ─────────────────────► │ gateway listener :8788 /otlp/v1/* │
│ Authorization: run │ (same hop 0003 │ auth: run ingest key → run_id │
│ ingest key │ already allows) │ redact → map → write │
│ stream-json log ─────┼─ 0001 probe ─────────► │ :8787 supervisor/ingester (0001) │
└──────────────────────┘ │ │
│ run_events (0001, SQLite hangar.db) │
│ + OTel-derived rows (src="../otel") │
│ traces.db (separate SQLite file) │
│ spans, otel_logs, run_metrics │
│ DecisionService ──► backends │
│ rules · local · decision-1 · openrouter│
│ optional OTLP forward ─► external backend│
└──────────────────────────────────────────┘
Decisions and why:
- The collector is a route set in the control plane, not a new pod. It needs the run table to authenticate and tag, and the redactor. A stock OpenTelemetry Collector pod would need a copy of both. Cost: ingestion shares a process with the API. Mitigation: a bounded queue and a separate SQLite file (5.3). If volume outgrows this, the route set can move behind the same URL.
- It lives on the gateway listener (:8788), the one 0003 already opens to sandboxes. Under 0003's default-deny egress, sandboxes may reach "the matching proxy pods and the gateway Service". An OTLP route on the gateway needs no new NetworkPolicy rule and no new allowlist entry. Before 0003 M2 exists, the same router is mounted on :8787 with the same auth. Nothing in this spec waits on 0003.
- OTLP over HTTP/protobuf only (
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf). It passes through the gateway, plain HTTP proxies and E2B's public path. gRPC would need a second listener and HTTP/2 end to end. Whether Claude Code supportshttp/jsonas well is documented in the monitoring page; we pick protobuf because the library is smaller on the Python side. Revisit if protobuf parsing is a dependency problem (open question 9). - 0001 stays the spine. Every OTel item that has an equivalent in 0001's schema is used to enrich that event, not to emit a duplicate. Only items 0001 lacks become new rows.
5.2 Getting telemetry out of the sandbox¶
Fleet.run_agent already writes a per-run script (fleet.py, run_agent). It exports HANGAR_RUN_ID and sources ~/.hangar/env. Telemetry variables go in that script, per run, not into the image. The run id and ingest key do not exist at image build time.
For claude-code the script adds:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT="$HANGAR_OTLP_ENDPOINT" # per provider, see 5.6
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $HANGAR_OTLP_KEY"
export OTEL_RESOURCE_ATTRIBUTES="hangar.run_id=run-3fa91c20,hangar.sandbox_id=sb-1a2b3c4d,hangar.task_id=...,hangar.environment=claude-python,hangar.harness=claude-code"
# traces (beta), on by default only when the environment sets telemetry.traces: true
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 OTEL_TRACES_EXPORTER=otlp
export TRACEPARENT="00-<trace_id>-<root_span_id>-01"
# content stays redacted at the source
# OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_CONTENT: unset. OTEL_LOG_TOOL_DETAILS: see below.
Details:
- Resource attributes are a convenience, not the source of truth. The server tags every signal from the ingest key, not from attributes the sandbox sent. A compromised or confused agent could set any
OTEL_RESOURCE_ATTRIBUTES. The server drops client-senthangar.*keys and writes its own. The attributes are still set so an external backend that receives a forwarded copy can group by them. - Ingest key. A random
hgt_token per run, minted byrun_agent, stored hashed inkvasotlp_key:<sha256> → {run_id, sandbox_id, expires_at}. It authorizesPOST /otlp/v1/*and nothing else. It expirestimeout_minutes + 30 minafter start and is deleted when the run is terminal and drained. Under 0003 the key is a field on the sandbox's grant instead (SandboxGrant.scopes += "telemetry:write"); until then it is its own table. Open question 8: one key per run or per sandbox. TRACEPARENT. hangar generates the trace id atrun_agenttime and records a root spanhangar.run(start = run start, end = terminal state, attributes = run id, environment, harness). The docs say an inboundTRACEPARENTinclaude -pmakes Claude Code's spans children of the caller's trace, so the interaction tree hangs under the root. Resumed runs (0001resume_agent) get a new trace id and a span link to the parent run's root. Unverified: whether the id survives into the beta trace output in 2.1.296. M0 checks.OTEL_LOG_TOOL_DETAILS. Tool details (command lines, file paths) are what the guardrail and failure-triage tasks need, and 0001 already stores a 200-character redacted summary of the same thing. Default: on, then scrubbed server-side (5.4).OTEL_LOG_USER_PROMPTSandOTEL_LOG_TOOL_CONTENTstay off. A "debug capture" switch per run turns on tool content for 1 run, stores it for 24 h only, and is audit-logged.- Export interval. Metrics and logs flush on the SDK defaults. For
agent_eventslong-polls 0001 already probes every 2 s; OTel does not need to be faster than that. Whether Claude Code exposesOTEL_METRIC_EXPORT_INTERVAL/OTEL_LOGS_EXPORT_INTERVALis in the docs; I did not copy their defaults. M0 records real latency from event to receipt. - Failure of the sink must not hurt the agent. OTel SDK exporters drop on failure. If the control plane restarts, some telemetry is lost. That is acceptable. The stream-json log (0001) is the lossless path.
Other harnesses:
| Harness | Source in v1 | Notes |
|---|---|---|
| claude-code | stream-json (0001) + OTLP | Both. |
| pi | pi-json parser (--mode json), no OTel |
Maps tool_execution_start/end to tool.start/tool.end, turn_end to usage, agent_end to run.result. Field names must be captured from a real run first. |
| codex | codex-jsonl parser (0001 M7) |
OTel unknown. |
| antigravity (agy) | text only |
Unknown until installed. |
A harness without OTel still gets decisions: the decision windows are built from normalized 0001 events (5.5), not from OTel. OTel improves some windows, it is never required.
5.3 Data model and storage¶
Principle: one event timeline. 0001's run_events holds normalized events. OTel adds columns and event types to it. Spans and raw telemetry go in a separate store.
Changes to 0001's run_events (an amendment to 0001 M3, which has not shipped):
-- 0001: (run_id, seq, src_offset, ts, type, data), UNIQUE (run_id, src_offset)
ALTER ... ADD COLUMN src TEXT NOT NULL DEFAULT 'log'; -- 'log' | 'otel' | 'hangar'
ALTER ... ADD COLUMN src_key TEXT; -- otel: sha1(trace_id|span_id|name|ts_ns); else NULL
CREATE UNIQUE INDEX run_events_src_key ON run_events(run_id, src_key) WHERE src_key IS NOT NULL;
-- src_offset stays NULL for otel rows. SQLite UNIQUE allows many NULLs, so 0001's constraint still holds.
seq is still assigned per run in arrival order. Clients that poll by seq see log-derived and OTel-derived events in one ordered stream, each tagged src.
New event types (added to 0001 §5.4's table; no existing type changes meaning):
type |
data |
src |
Why 0001 lacks it |
|---|---|---|---|
api.request |
model, duration_ms, ttft_ms?, input_tokens, output_tokens, cache_read_tokens, cost_usd?, attempt, ok, status? |
otel (claude_code.api_request) |
Latency and retries. |
api.error |
status?, error_class, retryable, attempt, message (200 chars, scrubbed) |
otel (claude_code.api_error) |
0001 error only covers errors the harness prints to the stream. |
tool.decision |
tool_id?, name, decision ("accept"/"reject"), source (config/user/…) |
otel (claude_code.tool_decision) |
Permission outcomes. With --dangerously-skip-permissions these are all accepts, so low value on the current run command. |
decision |
decision_id, task, label, p_top, mode |
hangar | One row per DecisionService result, so cursors see it (5.5). |
Test results (implemented, diverging from the sketch above; jobs.md §8). The test.result this spec's failure_triage reads is not a run event: each run of a check hangar makes is a TestRun record (counts, failures with messages, every case's outcome by test id, coverage, commit and tree), and a progress event with data.action: "tests" and the counts goes into the job's and session's events. In traces it is a test.run span event and one test.case event per failing or flaky test (the kinds hangar.trace/v1 reserves, attributes under hangar.test.*), with OpenTelemetry's test semantic conventions alongside (test.suite.name, test.suite.run.status, test.case.name, test.case.result.status; development stability); GET /api/jobs/{id}/tests?format=otel serves the same events as data.
Enrichment rules, not new rows:
claude_code.tool_resultwith duration and success enriches the matchingtool.end(duration_ms,ok). Matching key:tool_idif the OTel event carries the tool-use id (unverified, open question 3), else(name, start time within 2 s). An unmatched OTel tool event is stored as span data only.claude_code.token.usage/cost.usagemetrics are folded intoRun.summary.input_tokens/output_tokens/cost_usdonly when the stream-jsonusageevent is absent. Stream-json wins on conflict, and a difference over 5% adds a warningusage_mismatch. Both are API-equivalent estimates on a subscription (0001 OQ13).claude_code.commit.countandclaude_code.pull_request.countgo intorun_metricsonly. They are the agent's tool counts, claims in the 0002 sense. PR truth comes from GitHub.
Spans and raw telemetry: traces.db (a second SQLite file in HANGAR_DATA_DIR, same WAL setup as store.py):
CREATE TABLE spans (run_id TEXT, trace_id TEXT, span_id TEXT, parent_id TEXT, name TEXT,
start_ns INTEGER, end_ns INTEGER, status TEXT, attrs TEXT, -- scrubbed JSON, <= 2 KiB
PRIMARY KEY (run_id, span_id));
CREATE INDEX spans_run ON spans(run_id, start_ns);
CREATE TABLE otel_logs (run_id TEXT, ts_ns INTEGER, name TEXT, attrs TEXT, src_key TEXT,
PRIMARY KEY (run_id, src_key));
CREATE TABLE run_metrics (run_id TEXT, name TEXT, attrs_key TEXT, window_start INTEGER,
value REAL, PRIMARY KEY (run_id, name, attrs_key, window_start));
CREATE TABLE decisions (...); -- 5.5
CREATE TABLE decision_labels (...); -- 5.5
CREATE TABLE decision_spend (day TEXT, backend TEXT, task TEXT, calls INTEGER, tokens INTEGER, usd REAL,
PRIMARY KEY (day, backend, task));
Why a second file: ingestion writes in bursts. WAL allows one writer. A busy trace writer must not make the main store's busy_timeout=5000 fire on put(Sandbox). A separate file also lets retention VACUUM and a "drop all traces" action run without touching state. Cost: joins across files happen in Python, not SQL. The joins are by run_id, so that is cheap.
Writer: one asyncio task drains a bounded queue (10,000 items), batches by 200 or 250 ms, one transaction per batch. When the queue is full, OTLP requests get 429 with Retry-After, and the SDK retries or drops. Counters otlp_dropped_total and otlp_queue_depth are on /metrics if 0001 adds one; otherwise in get_telemetry_summary.
What goes where
| Data | Store | Kept | Why |
|---|---|---|---|
| Normalized events (log + otel-derived + decisions) | run_events in hangar.db |
0001 default: 14 days / 20,000 per run | Spine. Small rows. |
| Run summary, metrics rollup, decision rows, labels | hangar.db / traces.db |
Until the sandbox record is purged; decisions and labels 180 days | These are the evaluation set. They are small. |
| Spans, OTel logs | traces.db |
14 days, 50,000 spans per run | Used for debugging and timelines. Disposable. |
| Raw OTLP payloads | Not stored | — | Re-derivable data only matters if the mapper has a bug. M0 keeps fixtures. |
| Long-term, high-volume, cross-run analytics | External OTLP backend, optional | Owner's policy | Not a hangar concern. |
Volume estimate, to be replaced by measurement in M0: a run with 300 tool calls yields roughly 900 spans and 2,000 log records. At about 0.5 KiB each after scrubbing that is on the order of 1.5 MiB per run. 100 runs a day for 14 days is about 2 GiB. The PVC is 20 GiB (deploy/k8s/deployment.yaml). That fits, but the estimate is a guess, so a size cap HANGAR_TRACE_MAX_MB (default 2048) deletes oldest-first.
External backend. HANGAR_OTLP_FORWARD_ENDPOINT (+ _HEADERS) makes the collector re-export the already scrubbed spans, logs and metrics over OTLP, with hangar resource attributes. Targets that speak OTLP (Tempo/Jaeger, Grafana Cloud, Honeycomb, Langfuse, Phoenix, ClickHouse-based stores) should work. I have not tested any of them. Forwarding is off by default, is egress from the control plane to a third party, and is listed in 6.2. hangar's own dashboard never depends on it.
5.4 Redaction and PII¶
Today broker.redact replaces only the literal values of vault secrets (length 8 or more). It does not catch a token the agent found in a file, a .env dump, or a key printed by env. Telemetry and decision windows need more.
Pipeline, applied at ingest before anything is stored, forwarded or sent to a decision backend:
- Attribute allowlist. Per signal name, a fixed list of attribute keys is kept. Unknown keys are dropped. This is the main control: new SDK attributes do not leak by default.
- Vault scrub.
broker.redacton every string. - Pattern scrub (new,
hangar/scrub.py):sk-ant-…,ghp_/github_pat_/gho_…,hgs_/hgt_/hgr_(hangar's own), AWSAKIA…, Google API keys (AIza…), PEM blocks, JWT-shaped strings,Authorization:header values, URL userinfo (https://user:pass@), andKEY=value/"key": "value"where the key name containstoken|secret|password|passwd|api_key|credential— replaced with***. - Truncate. Strings to 2 KiB in attributes. Command lines to 500 characters.
- Path normalization. Absolute paths under the sandbox home or repo become
~/…or<repo>/…. This also makes decision windows cacheable across sandboxes.
Limits, stated plainly: pattern scrubbing is best effort. An unrecognized secret format passes through. That is why the defaults avoid content at all (prompts and tool content off at the source; allowlist), why decision windows are built from summaries (5.5), and why features_only is the default egress profile for decisions (6.1). A test corpus of real-looking secrets (M1) pins the patterns. A scrubber miss is a defect with a regression test, not an accepted loss.
Personal data: prompts may contain names and emails. user_prompt content is off by default, so mostly absent. Email-like strings are masked in the pattern scrub. Account identifiers that Claude Code attaches (user/org ids) are dropped by the allowlist.
5.5 The decision model¶
Interface and types (hangar/decisions/)¶
class Option(BaseModel):
id: str # "stuck"
description: str # one line, goes to the model
class WindowSpec(BaseModel):
source: Literal["events", "spans", "result", "task"]
max_events: int = 40; max_tokens: int = 3000 # hard cap; the 32K context is not a target
include: list[str] = [] # event types
profile: Literal["features_only", "redacted_text"] = "features_only"
class Action(BaseModel):
kind: Literal["none", "annotate", "notify", "suggest"] # no "cancel", "merge", "push" in v1
target: str | None = None
class DecisionTask(BaseModel):
id: str; version: int
question: str
options: list[Option] # includes an explicit "unclear" option where it makes sense
window: WindowSpec
threshold: dict[str, float] # per-option min probability to count as "actionable"
min_margin: float = 0.2 # top1 - top2; below this the result abstains
action: Action
mode: Literal["off", "shadow", "advisory", "acting"] = "shadow"
ttl_s: int # cache lifetime
trigger: list[str] # when it is evaluated (below)
class DecisionResult(BaseModel):
decision_id: str; run_id: str; task: str; task_version: int
label: str | None # None when abstaining or unavailable
probabilities: dict[str, float]
abstain: bool; actionable: bool
source: Literal["model", "cache", "rules_fallback"]
model: dict # {backend, name, revision?}
decided_at: float; latency_ms: int; cost_usd: float
input: dict # {from_seq, to_seq, sha256, tokens}
class DecisionBackend(Protocol):
name: str
async def score(self, task: DecisionTask, text: str, *, timeout_s: float) -> dict[str, float]: ...
class DecisionService:
async def decide(self, run_id: str, task_id: str, *, max_age_s: int | None = None) -> DecisionResult: ...
score returns one probability per option. Backends:
| Backend | What it is | Egress | Cost | Notes |
|---|---|---|---|---|
rules |
Deterministic heuristics over events and summary (no events for N min, same tool+args repeated k times, last error class). Always on. | none | 0 | Baseline every other backend must beat (8.3). Also the failure fallback. |
decision1 |
Microsoft Decision-1 through Foundry | yes | Reported $0.042 per 1M input tokens, output free | Request/response shape and options-passing convention unknown (OQ 6). The adapter is the only code that knows it. |
openrouter |
Same model as microsoft/microsoft-decision-1 |
yes | Announced; price unverified | Alternate route. Useful if Foundry setup is heavy. |
local |
A small open model run locally, scored by comparing next-token log-probabilities of the option labels. | none | Compute only | Which model and runtime: OQ 7. CPU latency on the control-plane pod is likely seconds. Decision-1 is not available locally. |
llm |
A cheap hosted LLM with constrained output (an enum field, or logprobs) | yes | Per token, API key | Needs an API key, not the Pro subscription. Probabilities are not calibrated by training; they come from logprobs or sampling and must be calibrated by us. |
Selection: HANGAR_DECISION_BACKENDS='["decision1","local"]' is an ordered list; first healthy one wins; rules is always last. HANGAR_DECISION_EGRESS=none removes every backend that leaves the cluster.
The six decision tasks¶
Each task is data in hangar/decisions/tasks/*.yaml, versioned. Changing a question, an option or a window bumps version. Calibration and labels are per version.
| Task id | Question | Options | Window | When evaluated | Action (max) |
|---|---|---|---|---|---|
run_state |
What is this running agent doing right now? | progressing, stuck, looping, waiting_input, blocked_env, done, unclear |
Last 40 events as one-line features + seconds since last event + counters (edits, tests, errors in last 10 min) | A live run: no event for 3 min while the process is alive; the same tool.start summary repeated 3 times; every 5 min otherwise; on error events |
annotate the run; notify after 2 consecutive stuck/looping above threshold |
failure_triage |
Why did this run fail or this test fail? | test_failure, env_dependency, auth, rate_limit, harness_bug, task_unclear, other |
Last error/api.error events, last failing test.result and a 1.5 KiB scrubbed output tail |
Run enters failed/crashed/timed_out, or a failed verification (0002) |
annotate; feed the environment-fix aggregator (5.8) |
tool_risk |
Is this tool call risky? | routine, sensitive, destructive, exfiltration |
One tool.start (name, scrubbed command, path) + previous 3 for context |
Post hoc, asynchronously, on each Bash and write tool start |
annotate; notify on destructive/exfiltration. Not in the execution path. |
pr_readiness |
Is this change ready for human review? | rating 1–5 (one call per criterion: tests cover the change, scope matches the task, no unrelated files, description matches diff) |
Diff stat + file list, Verification, checks rollup, PR title/body. No diff text. |
When 0002 stage reaches done |
annotate on the land request page |
route_task |
What kind of task is this? (the label then selects from outcome statistics, 5.8) | bugfix, feature, refactor, test, docs, dependency, unknown |
Task prompt (first 1 KiB, scrubbed) | At run_agent |
suggest an environment/harness/model |
needs_human |
Does this run need a person? | yes, no |
Last assistant message, any input.requested event, last 10 events |
State is waiting_input, or the run ended with a question as its last message |
notify |
Design choices worth stating:
run_stateexcludes "dead". Dead and exited are 0001's deterministic states. The model is consulted only when 0001 says the process is alive. Rules decide first: if 0001 sayscrashed, no call is made.failure_triageis rules first.api.errorwith status 401/403 →auth; 429 or arate_limitevent →rate_limit. The model is called only if no rule fires. A rule match is stored as a decision withsource="rules_fallback"so labels line up.tool_riskis advisory and post hoc. A synchronous pre-tool check would need a Claude Code hook inside the sandbox calling hangar on every tool call. At ~0.5 s a call that is slow, makes agent progress depend on the service, and is a bypassable control anyway. 0003's grants, branch protection and egress limits enforce. This task finds patterns to feed back into those policies. A pre-execution variant is a later question (OQ 10).route_taskclassifies, statistics choose. Decision-1 is not trained on hangar's outcomes. It is good for "what type of task is this". The choice of environment/harness/model comes from outcome data keyed by task type (5.8). This also keeps routing explainable.pr_readinessnever gates a merge. Merge needs a human approval (0002). The score is a hint on the approval page. No diff text goes out.- An explicit
unclearoption where it makes sense, plus an abstain rule (min_margin). Scoring a fixed set of options forces a distribution even when the input fits none. Whether Decision-1 includes any "none of the above" behaviour is unknown (OQ 6).
Windows, features and caching¶
A window is built from run_events (and spans if needed) by a pure function. Same events → same text. Example run_state window (features_only profile):
task: run_state v1
elapsed_s: 1840 since_last_event_s: 212 turns: 31
last_10min: edits=0 tests_run=4 tests_failed=4 errors=2 api_errors=0
recent (oldest first):
[-212s] tool Bash "uv run pytest -q tests/test_sync.py" -> failed (exit 1) tests: 0 passed 1 failed
[-340s] tool Edit "src/sync.py"
[-402s] tool Bash "uv run pytest -q tests/test_sync.py" -> failed (exit 1) tests: 0 passed 1 failed
...
It contains tool names, scrubbed 200-character summaries, test counts and timings. It does not contain file contents or assistant prose. redacted_text additionally includes the last assistant message (500 chars) and output tails. It is opt-in per task and per environment.
Cache: key = sha256(task_id, version, backend, window_text). The window text drops absolute timestamps in favour of relative offsets rounded to 10 s, so an unchanged run hits the cache. TTL per task (run_state: 60 s). A cache hit is returned with source="cache" and costs nothing. The service also de-duplicates concurrent calls for the same key.
Advisory first: shadow, log, compare, promote¶
Modes per task: off → shadow (computed and logged, shown nowhere except the Decisions page) → advisory (shown to humans: badges, notifications) → acting (may trigger the task's one allowed action). Default for every task is shadow. acting exists in the schema; no task ships there in this spec. Promotion is a manual setting after the criteria below are met, and is audit-logged.
Ground truth, collected automatically wherever possible:
| Task | Label sources (arrive later) |
|---|---|
run_state |
Hindsight rule: window labelled stuck/looping if no edit, commit or test-pass followed for the next 10 min and the run ended cancelled/timed_out; progressing if a file change or passing test followed; done if the run ended within 2 min. Plus human labels from the dashboard ("was this right?"). Ambiguous windows are left unlabelled. |
failure_triage |
Human label on the run page; for env_dependency, a later fix to the environment that made the same signature stop. 0002 verification output tail classified by rules as a cross-check. |
tool_risk |
Human review of flagged calls (1 click); 0003 egress/gateway denials in the same run as a positive signal for exfiltration. |
pr_readiness |
0002 outcome: approved/rejected, PR merged or closed unmerged, CI conclusion, verification pass/fail. |
route_task |
Human label on a sample; later the outcome data itself. |
needs_human |
Did a human message or approval follow within 30 min? Did the run resume after input? |
Every DecisionResult is a row in decisions. Every label is a row in decision_labels(decision_id, label, source, at, by). The join is what 8.3 evaluates.
Promotion criteria for shadow → advisory (per task, per version): ≥ 150 labelled decisions from ≥ 30 distinct runs; macro-F1 beats the rules baseline by ≥ 0.05 on a held-out split by run, not by window; expected calibration error ≤ 0.08. For any option that will trigger notify: precision at the configured threshold ≥ 0.90 with the lower end of the 95% Wilson interval ≥ 0.80. Numbers are starting values to tune once real data exists, and I would rather they be too strict than loose. For acting (not in this spec): precision lower bound ≥ 0.95 and a human-reviewable undo.
Demotion: a rolling window of the last 50 labelled decisions drops below the bar → the task falls back to shadow and the owner is notified.
Calibration¶
Decision-1's probabilities are described as calibrated. Independent evaluation is thin, so we check on our own data and do not assume it.
- Reliability table: bin probabilities into 5 bins (not 10: the sample is small), report observed frequency and counts per bin, ECE, Brier score. Per task, per option for the top-1.
- If ECE > 0.08 with ≥ 200 labels, fit a per-task temperature (one parameter) on a training split. Apply it in
DecisionService. Store the calibrator version in the result'smodelfield. Platt/isotonic only if data is plentiful; with a few hundred labels isotonic overfits. - Thresholds are chosen on the precision target above, not on the 0.5 default.
- Drift: backend/model revision changes reset
acting/advisoryback toshadowuntil re-checked. The service records the model revision string it receives, if any (OQ 6). - Per-class counts are shown with intervals. A class with fewer than 20 labels is shown as "not enough data", not as an accuracy number.
Cost, latency, privacy and failures¶
Cost. Pricing as reported: $0.042 per 1M input tokens, output free. A run_state window of 3,000 tokens is about $0.000126. A 2-hour run evaluated every 5 minutes plus triggers, say 40 calls, is about 120,000 tokens, or $0.005. Six tasks on a typical run: under $0.02. 100 runs a day: a couple of dollars a month at most. That arithmetic is from the reported price. M4 measures the real bill, including whether Foundry charges anything beyond tokens (deployment or provisioned capacity: OQ 6).
Guardrails, because "small and visible" is a requirement:
HANGAR_DECISION_RUN_BUDGET_USD(default 0.05) andHANGAR_DECISION_DAILY_BUDGET_USD(default 1.00). When exceeded: backend calls stop,rulescontinue, and the result sayssource="rules_fallback", with a banner.- Token counts are estimated before the call (characters / 3.5, conservative) and recorded exactly after if the API reports them.
- Spend is in
decision_spend, shown on the Insights page and inget_telemetry_summary. A per-backend kill switch:HANGAR_DECISION_BACKENDS=[]. - Decision calls use API keys held in the vault (
harness="decision"credential kind), never the Pro subscription, so they do not consume the subscription's plan limit. - Telemetry itself costs no model spend.
Latency budget. An independent test measured about 459 ms median through Foundry. Vendor claims are lower. Budget: p50 < 1 s, hard timeout 3 s per call, no decision on any request path that a human or the supervisor blocks on. classify_run returns the cached result immediately if it is fresher than max_age_s and otherwise calls with the timeout (5.7). Call volume is low (a few per run-minute at most), so no batching in v1.
Privacy. What leaves the cluster, per profile (full discussion in 6.1): the task's question and option text (static), and the window text after scrubbing. Not sent: credentials, vault values, run prompts (unless route_task is enabled with redacted_text), diff text, file contents, repository URL, sandbox/run ids. The input.sha256 is stored locally; the window text is stored locally for evaluation (scrubbed, 90 days, can be disabled — then labels can't be re-scored offline). HANGAR_DECISION_EGRESS=none is the no-egress mode: only rules and local.
Failure behaviour. Service down, slow or erroring:
- Timeout or 5xx → one retry only if the remaining budget allows; otherwise next backend in the list; then
rules. - Circuit breaker per backend: 5 failures in 60 s opens it for 120 s. State is in memory.
- The result is still a
DecisionResult, withsource="rules_fallback",model.backend="rules", andlabelfrom the rules (ornullif rules abstain). Callers never get an exception fromclassify_runfor a backend problem. - 0004 and 0001 behave as before when every decision is
null. Decisions can only add warnings. A test pins that the supervisor's liveness path does not import or await the decision service. - Malformed or unparsable backend output (probabilities missing, not summing near 1) is treated as a failure, logged, and not cached.
5.6 Collector deployment per provider¶
The endpoint string HANGAR_OTLP_ENDPOINT is chosen per sandbox at claim time and passed through the run script env. It always ends in /otlp; exporters append /v1/traces etc.
| Provider | Endpoint the sandbox uses | Reachability under today's network | Under 0003 default-deny |
|---|---|---|---|
| kubernetes (GKE Agent Sandbox) | http://hangar-gateway.hangar.svc:8788/otlp (in-cluster Service) |
Egress is open today and DNS works. | 0003's NetworkPolicy allows the gateway Service on 8788. The OTLP route is on that listener, so allowed. 0003 puts the gateway host in NO_PROXY, so the OTLP exporter bypasses the egress proxy. The exporter's proxy behaviour is unverified (OQ 4); setting the in-cluster host in NO_PROXY is the safe route. DNS is not allowed under 0003 ("the proxy resolves names"), so the Service must be reached by ClusterIP or 0003 must inject the gateway address. Same dependency as the git gateway; whatever 0003 decides applies here. |
| docker | http://host.docker.internal:8788/otlp (Docker Desktop/OrbStack) or the bridge gateway IP on Linux |
Works with the default network. | 0003's per-policy internal network publishes the alias hangar-gateway that forwards to the host's 8788. Same route. |
| e2b | https://<public-gateway-host>/otlp |
Needs a publicly reachable hostname. 0003 already requires the gateway to be internet-reachable for E2B. | Auth is the ingest key, so no change. E2B egress is advisory today (0003 5.5), so telemetry reachability is not a new constraint. |
If the endpoint is unreachable the SDK drops data and the run is unaffected. get_telemetry_summary reports otlp_last_received_at per run, and a run with a log-source stream but no OTel for 5 minutes gets a warning telemetry_silent. That doubles as the check for open question 1 (does the disable-nonessential-traffic flag block export).
Control plane changes:
deploy/k8s: add port 8788 to the Service (shared with 0003), no new Deployment. Request limits: ingestion CPU is small; bumprequests.memoryby 256Mi for the queue and protobuf buffers (estimate).Settings:otlp_enabled(default false until M2 is verified),otlp_endpoint_override,trace_retention_days,trace_max_mb,decision_*as above.- Request limits on the OTLP route: body ≤ 4 MiB, 20 requests/s per run key, gzip accepted. Over the limit gets 429 or 413.
5.7 MCP tools and REST¶
Four new MCP tools. All are read-mostly. Scope tags follow 0001: runs:read, runs:write.
classify_run(run_id: str, task: str = "run_state", max_age_s: int = 60,
allow_stale: bool = True) -> dict # runs:read
get_run_trace(run_id: str, after_ns: int = 0, limit: int = 500,
name: str | None = None, include_attrs: bool = False) -> dict # runs:read
get_telemetry_summary(run_id: str | None = None) -> dict # runs:read
record_decision_label(decision_id: str, label: str, note: str | None = None) -> dict # runs:write
Events are read with 0001's agent_events, extended with an optional sources: list[str] | None filter (["log","otel","hangar"]) and the new event types. No new event tool.
classify_run example.
{"decision_id": "dec-9c41e07a", "run_id": "run-3fa91c20", "task": "run_state", "task_version": 1,
"label": "looping",
"probabilities": {"progressing": 0.04, "stuck": 0.12, "looping": 0.78, "waiting_input": 0.01,
"blocked_env": 0.03, "done": 0.00, "unclear": 0.02},
"abstain": false, "actionable": false, "mode": "shadow",
"source": "model", "model": {"backend": "decision1", "name": "microsoft-decision-1", "calibrator": null},
"decided_at": 1791766412.8, "latency_ms": 512, "cost_usd": 0.00013,
"input": {"from_seq": 112, "to_seq": 151, "sha256": "4be1…", "tokens": 3100},
"evidence": [{"seq": 142, "type": "tool.start", "summary": "uv run pytest -q tests/test_sync.py"},
{"seq": 148, "type": "tool.start", "summary": "uv run pytest -q tests/test_sync.py"}]}
actionable is true only when the task is in advisory or acting mode, the label is not an abstain, and the label's probability is at or above its configured threshold. 0004 reads this flag and does not re-implement the policy. evidence lists the events the rules or the window highlighted; for the model path it is the repeated-key heuristic's output, since the model gives no rationale.
Fallback example (service down):
{"decision_id": "dec-9c41e07b", "run_id": "run-3fa91c20", "task": "run_state", "task_version": 1,
"label": "unclear", "probabilities": {"unclear": 1.0}, "abstain": true, "actionable": false,
"mode": "shadow", "source": "rules_fallback",
"model": {"backend": "rules", "name": "run_state_rules", "calibrator": null},
"decided_at": 1791766420.1, "latency_ms": 3004, "cost_usd": 0,
"warnings": ["decision1: timeout after 3.0s", "circuit open until 1791766540"]}
get_run_trace
{"run_id": "run-3fa91c20", "trace_id": "5b8efff798038103d269b633813fc60c", "next_after_ns": 1791766013000000000,
"spans": [
{"span_id": "00f067aa0ba902b7", "parent_id": null, "name": "hangar.run",
"start_ns": 1791765890100000000, "end_ns": null, "status": "unset"},
{"span_id": "a1b2c3d4e5f60718", "parent_id": "00f067aa0ba902b7", "name": "claude_code.interaction",
"start_ns": 1791765891000000000, "end_ns": 1791766012000000000, "status": "ok"},
{"span_id": "b2c3d4e5f6071829", "parent_id": "a1b2c3d4e5f60718", "name": "claude_code.tool",
"start_ns": 1791765990000000000, "end_ns": 1791765996000000000, "status": "ok",
"attrs": {"tool_name": "Bash"}}],
"more": false}
Attribute names inside spans are placeholders until M0 captures real payloads.
get_telemetry_summary
{"run_id": "run-3fa91c20", "otlp_last_received_at": 1791766010.2, "telemetry": "ok",
"api": {"requests": 34, "errors": 2, "retries": 3, "p50_ms": 4100, "p95_ms": 11800},
"tokens": {"input": 51200, "output": 6100, "cache_read": 40100}, "cost_usd_estimate": 0.84,
"agent_counts": {"commits": 1, "pull_requests": 0},
"decision_spend": {"run_usd": 0.0021, "day_usd": 0.12, "day_budget_usd": 1.0}}
With no run_id it returns fleet-level OTLP health (queue depth, drops, last receipt per run) and decision spend.
REST (bearer or UI ticket, as in app.py):
| Route | Notes |
|---|---|
POST /otlp/v1/{traces,metrics,logs} |
Gateway listener (and :8787 before 0003 M2). Auth: run ingest key. Content-Type: application/x-protobuf, gzip ok. Returns OTLP ExportXxxServiceResponse; 429 when the queue is full. |
POST /api/runs/{id}/classify |
Body {task, max_age_s, allow_stale}. Same as classify_run. |
GET /api/runs/{id}/decisions?task=&after= |
History, newest last. |
GET /api/runs/{id}/trace?after_ns=&limit=&name= |
Spans. |
GET /api/runs/{id}/telemetry |
Summary. |
POST /api/decisions/{id}/label |
Body {label, note}. |
GET /api/decision-tasks / PATCH /api/decision-tasks/{id} |
List tasks with mode and metrics; admin can change mode and thresholds. |
GET /api/decisions/eval?task=&version= |
Calibration table, confusion matrix, comparison with rules, spend. |
GET /api/insights/{summary,failures,environments,routing} |
Optimization views (5.8). |
POST /api/evals, GET /api/evals/{id} |
Paired evaluations (5.8). |
Interface for 0004¶
0004 needs three things. They are the whole contract; anything else in this spec may change.
# Python, in-process (hangar.decisions.api)
async def classify_run(run_id: str, task: str = "run_state", *, max_age_s: int = 60) -> DecisionResult
def query_events(run_id: str, after: int = 0, limit: int = 200, types: list[str] | None = None,
sources: list[str] | None = None) -> EventPage # = 0001 agent_events
def query_spans(run_id: str, after_ns: int = 0, limit: int = 500, name: str | None = None) -> SpanPage
The same three are exposed as classify_run, agent_events, get_run_trace. Guarantees:
classify_runnever raises for a backend problem. It returns a result withsource: "rules_fallback"orlabel: null.classify_runis safe to call at the supervisor's tick rate: cached results return in under 5 ms (in-memory), fresh calls take up to the 3 s timeout.- Each result also appends a
decisionevent torun_events, so a supervisor following the 0001 cursor sees decisions in order. - Labels and probability keys are the task's option ids, stable within a
task_version. A version bump is visible in the result. - 0004 must treat
actionable: falseas "do not act". It must not infer an action from a label alone.
5.8 Optimization loop¶
runs ─► events + spans ─► decisions ─┐
│ ├─► insights (SQL views) ─► dashboard
└─► 0002 outcomes (verified, │ │
approved, merged) ──────────────┘ ├─► env-fix suggestions ─► human reviews spec diff
├─► instruction suggestions ─► paired eval ─► adopt
└─► routing stats ─► recommend config
Metrics (computed from stored data, no model calls).
| Metric | Source |
|---|---|
Cost per task: API-equivalent USD (summary.cost_usd), tokens, plan % consumed (0001 usage), decision spend |
0001 usage events, OTel metrics |
| Time to first edit, time to first test, time to green | 0001 time_to_first_test_s, events |
Verified resolution rate (0002 Verification.passed) per environment/harness/model/task type, with Wilson interval |
0002 |
| Agent-claimed success vs verified success (the Jarmak gap) | run.result.ok vs Verification.passed |
| Failure classes per environment and harness | failure_triage decisions + rules |
| API error and retry rate, p95 API latency | api.request, api.error |
| Tool failure rate by tool; repeated-command rate | tool.end, windows |
Queue wait, time in waiting_input |
0001 |
The headline metric is the verified resolution rate. "Agent said done" is shown but is never an optimization target.
Environment fixes. A nightly job (and on demand) groups failures by a normalized signature:
- Rules (free):
ModuleNotFoundError: No module named 'x',command not found,error: Unable to findstyle messages from package managers, and agents runningpip install/npm i/apt-get installat run time (seen intool.start). The last one is a strong signal that a dependency is missing from the image. - Model (
failure_triage = env_dependency): used to catch cases rules miss. Its output only affects grouping, not what gets proposed. - A suggestion appears when the same signature occurs in ≥ 3 runs on one environment within 14 days. It shows the evidence runs and a proposed YAML change (
dependencies.pip: [x], orrepo.setup) as a diff against the environment spec, with the expected effect ("would have avoided ~N minutes in M runs"). A human applies it by hand or with one click that opens a PR against the env spec repo path. hangar never rewrites a spec on its own. After the change ships, the same signature is tracked and the suggestion is marked "confirmed" or "not effective".
Prompt and instruction improvements. Aggregate patterns, not single runs: repeated waiting_input on the same question, the same wrong command (pytest instead of make test) before the right one, repeated discovery of repo layout. Output is a suggested line for agent_config.instructions with its evidence. Adoption is by evaluation (below), not by reading the suggestion.
Routing. Outcome table keyed (task_type, environment, harness, model). Task type comes from route_task (or a human label). For each cell: n, verified-pass count, mean cost, mean time. Recommendation only when the cell has ≥ 10 runs and the posterior probability that it beats the current choice is ≥ 0.9 (Beta-binomial, uniform prior). It is a recommendation in run_agent warnings and on the dashboard, never an automatic switch. Exploration (trying a non-incumbent config) happens only inside an explicit evaluation, because with one Pro subscription every exploratory run uses plan quota.
Evaluation done properly. POST /api/evals takes two configs (A, B), a task set, and repeats:
{"name": "instructions-v2", "a": {"environment": "claude-python"},
"b": {"environment": "claude-python", "instructions_patch": "…"},
"tasks": ["task-01", "…"], "repeats": 3, "credential": "claude-max",
"metric": "verified_resolved", "holdout": ["task-21", "…"]}
Rules the tool enforces:
- Outcome is external.
Verification.passedfrom a clean sandbox (0002), never the agent's claim. - Paired. The same task at the same
base_sharuns under A and B, with order randomized. The analysis is per task: difference in pass rate across repeats, then a paired bootstrap over tasks. Report effect size, 95% interval, and n. No p-value alone. - Repeats. Default 3 per cell, minimum 2. Single-run comparisons are rejected. Jarmak's 2–6 point run-to-run spread is the reason; our own repeat variance is measured and shown.
- Config fingerprint. Every run stores
environment image tag, harness version, model, instructions hash, hangar versioninRun.config_fingerprint(new field, small addition to 0001'sRun). Runs with different fingerprints are never pooled. - Holdout. Suggestions are developed on one task set and confirmed on another. The report marks results on tuning tasks as exploratory.
- Power, in plain terms. If the per-task difference has standard deviation s (assumed 0.3 here; measured after the first eval), the smallest effect detectable with ~80% power at 5% is about 2.8 × s / √n tasks. With 30 tasks that is about 15 points. Detecting 5 points would need about 280 tasks. The tool prints this for the chosen design before running and says "this design cannot detect effects under X". The 0.3 is a guess and should be replaced with a measured value.
- Cost gate. Before starting, the tool shows runs × expected tokens and expected plan % (from 0001 usage, once 91%-type data exists), and requires confirmation. Evals are queued behind normal work by the 0001 credential queue. On one Pro plan, 30 tasks × 3 repeats × 2 configs is 180 runs, which will not fit in one plan window. The honest options are fewer or cheaper tasks, or an API-key credential with a stated dollar cap. The design does not hide this.
- Evals that touch routing or instructions never auto-apply.
Decision tasks get the same discipline: their evaluation is 8.3, with run-level splits and intervals.
External tools, for the "other services" part of the brief. None is a dependency.
- An OTLP-compatible backend, if the owner runs one, receives forwarded telemetry (5.3).
- Notifier hooks from 0002 carry
needs_humanandstuckalerts. - Existing LLM-eval or tracing products (Langfuse, Phoenix and similar) accept OTLP, to my understanding; unverified (OQ 11). The offline eval harness in 8.3 is plain Python and runs without them.
5.9 Dashboard UX¶
Extends the combined dashboard 0001 M8 and 0002 M9 will design. It adds:
Run page → Trace tab.
run-3fa91c20 claude-code claude-python RUNNING 31 turns $0.84 est tokens 51k/6k
state (model, shadow): ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ progressing ░░ stuck? ▇▇ looping 78% [was this right? ✓ ✗]
──────────────────────────────────────────────────────────────────────────────
interaction ─┬ api ████ 4.1s
├ tool Bash uv run pytest … ██ 6.0s ✗ exit 1
├ api ███ 3.2s retry×2 (529)
└ tool Edit src/sync.py ▏
──────────────────────────────────────────────────────────────────────────────
telemetry: ok (last 4 s ago) api p95 11.8s errors 2 retries 3
State strip: decisions over time as colored bands with the probability shown on hover. A "Show raw telemetry" toggle is admin-only. When telemetry_silent, the tab says so and falls back to the 0001 event list.
Fleet page. Run cards show 0001's state first. A model-derived badge appears only in advisory mode and is labelled "suggested" with the probability ("looping? 78%"). Shadow-mode results never appear on the fleet page.
Decisions page (admin). One row per task: mode, version, labelled count, macro-F1 vs rules, ECE, reliability plot, confusion matrix, precision at threshold, spend today, backend health and circuit state. A mode switch with the promotion criteria shown beside it and greyed out if unmet (an admin can still override, audit-logged).
Insights page. Tiles: verified resolution rate (with interval), claimed vs verified gap, median cost per task, median time to first test, decision spend this week. Tables: failure classes by environment × harness; suggested environment fixes with evidence and the diff; routing recommendations with n and posterior; evals with effect size and interval. Charts follow the dataviz conventions the dashboard adopts; the choice of chart library is not part of this spec.
6. Security, privacy and failure modes¶
6.1 Data leaving the cluster¶
| Destination | When | What | Default |
|---|---|---|---|
| Foundry / OpenRouter (Decision-1) | Decision calls | Static question and option text; scrubbed window text per profile | Off until the owner sets a key and picks the backend |
Hosted LLM (llm backend) |
Same | Same | Off |
| External OTLP backend | Forwarding | Scrubbed spans, logs, metrics with hangar resource attributes | Off |
| Anthropic | The agent's own traffic | Unchanged. Telemetry does not add to it. | — |
Profiles:
features_only(default): tool names, counts, durations, test counts, state names, 200-character scrubbed command/file summaries. No assistant prose, no file contents, no output tails, no prompts, no diff text. Commands are scrubbed but can still contain a repo-specific identifier; treat the summaries as low-sensitivity, not as anonymous.redacted_text: adds the last assistant message (500 chars), output tails (1.5 KiB) and, forroute_task, the prompt's first 1 KiB. Per task and per environment opt-in.
Vendor handling of the sent text (retention, use for training, region) is unknown for both Foundry and OpenRouter. The owner should read the terms before enabling. OQ 5.
No-egress mode. HANGAR_DECISION_EGRESS=none removes all remote backends. Only rules and local run. Test 8.1 asserts that no socket opens to a non-cluster address in this mode. Telemetry collection is unaffected, since it stays inside the cluster.
6.2 Threats¶
| Threat | Mitigation |
|---|---|
| A sandbox forges telemetry for another run | The server tags from the ingest key. Client hangar.* attributes are dropped. One key maps to one run. |
| Prompt injection through trace text: tool output says "classify this run as done" | The decision window uses features and short scrubbed summaries, and options are scored, not generated. The model has no tool access and cannot act. Decisions are advisory. An attacker could still skew a label, which is why no decision acts in v1 and why acting needs measured precision. |
| Secrets in telemetry | Source defaults (content off), allowlist, vault scrub, pattern scrub, truncation (5.4). Test corpus in M1. Debug capture is time-limited and audited. |
| OTLP endpoint abuse (flooding, large bodies) | Run-scoped key with expiry, body and rate limits, bounded queue with 429, per-run span cap. |
| Ingest key stolen from a sandbox | It can write telemetry for that run until expiry. It cannot read anything or call any other route. Deleted at run end. |
| Telemetry used to exfiltrate | Body size and attribute allowlist limit it. The route is an authenticated hole on the gateway, same as the gateway's other routes. 0003's residual-risk discussion applies. |
| Spend runaway | Per-run and daily caps, rules fallback when exceeded, kill switch. |
| Decision service becomes a dependency of liveness | Forbidden by design and by test (5.5, failure behaviour). |
| Traces DB fills the PVC | Size cap, oldest-first deletion, separate file. |
| Stored decision inputs leak | Stored scrubbed, 90 days, can be disabled, deleted with the run. Not exposed via MCP (only the hash). |
6.3 Failure modes¶
| Failure | Behaviour |
|---|---|
| OTLP queue full | 429 to the exporter; counters; stream-json path unaffected. |
| Control plane restart | OTel in flight is lost. Log ingestion resumes from log_offset (0001). Spans for the gap are missing. Marked by a telemetry_gap warning when the next batch's timestamps jump. |
traces.db corrupt or missing |
Created fresh. Runs lose spans, not events or state. |
| Clock skew between sandbox and control plane | Span times are the sandbox's. The server records receipt time too. Timelines use span times; staleness checks use receipt time. |
| Decision backend down | 5.5 failure behaviour. |
| Calibration drift | Rolling window demotes the task to shadow. |
| Telemetry silent although log events arrive | telemetry_silent warning; run continues. |
| Mapper bug (attribute names differ from fixtures) | Unknown attributes are dropped; the event still records with fewer fields. Fixture tests in M1. |
7. Rollout¶
- M0: capture real OTLP payloads from a live run, decide the attribute mapping, answer OQ 1, 3. Flags default off.
- Telemetry on, decisions off.
HANGAR_OTLP_ENABLED=trueforclaude-codeonly, on the owner's GKE deployment. Check volume, scrub quality and thetelemetry_silentrate for a week. - Rules backend and shadow mode. Decisions from
rulesonly, logged with labels. This gives the baseline and the label pipeline for free, with no vendor and no spend. - Decision-1 in shadow on
run_stateandfailure_triage, with a $1/day cap, once the owner has chosen Foundry or OpenRouter and read the data terms. Others follow one at a time. - Advisory per task when the criteria in 5.5 are met. Nothing in this spec goes to
acting. - Insights and evals after at least 100 runs with verification results exist.
Migration: run_events gets two columns with defaults, so old rows stay valid. New files and tables are created on startup like the rest of store.py. Disabling everything (HANGAR_OTLP_ENABLED=false, HANGAR_DECISION_BACKENDS=[]) returns hangar to 0001 behaviour. Image rebuild is not needed, because env vars are in the per-run script.
8. Test plan¶
8.1 Unit tests (fake provider; no network)¶
- OTLP ingest: protobuf fixtures from M0 → expected
spans,otel_logs,run_metricsandrun_eventsrows. Idempotent: the same batch twice inserts once (src_key). Unknown attributes dropped. Bad key → 401; expired key → 401; key for run A cannot write run B; client-senthangar.run_idignored. - Join with 0001: a stream-json fixture plus an OTel fixture for the same run yields one ordered
seqtimeline.tool.endis enriched, not duplicated. Usage mismatch over 5% yieldsusage_mismatch. - Scrub: corpus of ~60 real-looking secrets and ~60 benign look-alikes (git SHAs, UUIDs, base64 test data). All secrets masked; benign strings unchanged except documented cases. Allowlist test: a span with an unknown attribute
x.secret=…stores without it. - Queue: full queue returns 429; no loss of already accepted items; writer crash restarts.
- Retention: size cap deletes oldest runs' spans first; events retention follows 0001.
- Decision windows: deterministic (same events → same text and hash); token cap enforced;
features_onlywindow contains no assistant text (assert on a fixture that has assistant prose and a planted secret). - DecisionService: with a fake backend: threshold/margin/abstain logic;
actionablefalse inshadow; cache hit on equal windows; concurrent calls dedupe; budget exceeded →rules_fallback; timeout → next backend; circuit opens after 5 failures and closes after the cool-down; malformed probabilities → failure. - No-egress mode: with
HANGAR_DECISION_EGRESS=noneand a socket-blocking fixture,classify_runcompletes viarulesand opens no connection. - Supervisor independence: liveness code path does not import or await
DecisionService(import-graph assertion). - Interface:
classify_run,get_run_trace,agent_events(sources=…)return the JSON shapes in 5.7 (golden files). - Evals: paired bootstrap on synthetic data with a known effect recovers it within tolerance; an eval with
repeats=1is rejected; mixed fingerprints are not pooled; the power message is printed. - Aggregator: three runs with the same
ModuleNotFoundErroron one environment produce one suggestion with a YAML diff; two runs produce none. - Pi parser: fixture from a real
pi --mode jsoncapture maps totool.start/end,usage,run.result. (Fixture required first; no capture = no test, and the parser doesn't ship.)
8.2 Live acceptance on GKE¶
Done in the phase-closing PR, recorded there. One cluster, the current claude-python environment, the owner's Pro credential, one control plane.
| # | Scenario | Pass criteria |
|---|---|---|
| T1 | One short Claude Code run with OTLP on (HTTP/protobuf to the gateway Service) | Within 30 s of the first tool call, get_telemetry_summary shows telemetry: ok; ≥ 1 api.request event; agent_events returns log and otel events in one seq order; no duplicate tool.end. |
| T2 | Same, with TRACEPARENT and beta traces |
get_run_trace returns a tree rooted at hangar.run with claude_code.interaction as a descendant. If not, record what 2.1.296 does and mark OQ 3 answered. |
| T3 | Disable-nonessential-traffic flag as in harnesses.py |
Record whether OTel arrives (answers OQ 1). Either result passes; the doc is updated. |
| T4 | Secrets canary: the task prompt makes the agent cat a file containing a fake ghp_… token and a fake sk-ant-… key |
Neither string appears in spans, otel_logs, run_events, any decision window, or the forwarded copy (test sink). Query all four stores with LIKE. |
| T5 | Forged identity: from inside the sandbox, send OTLP with a different hangar.run_id attribute and with another run's key |
First lands under the real run only. Second returns 401. |
| T6 | Control-plane restart mid-run | Run continues; log events resume without duplicates; telemetry resumes within one export interval; a telemetry_gap warning appears. |
| T7 | Under 0003 egress enforce (when shipped): default-deny policy active | OTLP still arrives through the gateway; a probe to an arbitrary public host from the same sandbox fails. If 0003 is not yet shipped, the test is recorded as pending. |
| T8 | Docker and E2B once each | Docker: T1 passes via host.docker.internal. E2B: T1 passes via the public gateway host, or the gap is documented. |
| T9 | Decision-1 in shadow on 10 real runs | Every run has ≥ 1 run_state decision; p50 latency < 1 s and no call over 3 s timeout; measured cost matches the estimate within 2× and the day total is under $0.10; ledger matches the vendor invoice/usage page within 10%. |
| T10 | Failure injection: block the backend host from the control plane | Within 5 s, results show rules_fallback; supervisor and agent_status unaffected; circuit opens; recovers after unblocking. |
| T11 | Stuck-run reproduction: a run told to retry an impossible test | run_state flips to looping or stuck in shadow within 10 min; the dashboard (advisory off) shows nothing on the fleet page, and the Decisions page shows it. |
| T12 | Volume | Measure spans, bytes and rows per run on 10 real runs; update the 5.3 estimate; trace store stays under the size cap. |
8.3 Offline evaluation of the decision tasks¶
A script scripts/eval_decisions.py (no cluster, no network for the rules and local backends) replays stored windows against any backend and scores them against labels.
Dataset:
- Seed set. The 0001 M0 fixtures from the 2026-10-11 live run, plus every run from the first two weeks of shadow mode. Windows are sampled at fixed times, at trigger events, and at run end.
- Labels. Automatic where the table in 5.5 allows. The rest are labelled by the owner in the dashboard (a labelling queue sorted by model disagreement with rules; budget about 10 minutes per 50 windows).
- Split by run. 60/40 train (for thresholds, calibrator) and test. No window from a test run is used for tuning. Class counts and run counts are printed. A class under 20 test labels is reported as "insufficient".
- Windows from the same run are highly correlated. Intervals are computed with a bootstrap over runs.
Per task, report: accuracy, per-class precision and recall with Wilson intervals, macro-F1, confusion matrix, Brier, reliability table (5 bins) and ECE, precision at the configured threshold, abstain rate, p50/p95 latency, cost per decision and per run, and the same for the rules baseline and for each backend side by side.
| Task | Minimum labelled test set | Pass to enter advisory |
|---|---|---|
run_state |
150 windows, ≥ 30 runs, each of progressing, stuck/looping ≥ 20 |
Macro-F1 ≥ rules + 0.05; ECE ≤ 0.08; for stuck/looping: precision ≥ 0.90 (Wilson lower ≥ 0.80) at threshold; false "stuck" on runs that later pass verification < 5% of runs |
failure_triage |
100 failed runs/tests | Macro-F1 ≥ rules + 0.05; ECE ≤ 0.08; env_dependency precision ≥ 0.85 |
tool_risk |
60 flagged + 200 unflagged tool calls | Recall on human-confirmed destructive/exfiltration ≥ 0.9 at ≤ 5% routine calls flagged per run |
pr_readiness |
60 runs with outcomes | Rating correlates with human approval (Spearman ≥ 0.4, interval excludes 0) and beats "verification passed" alone on AUC, or the task stays in shadow |
route_task |
100 prompts | Accuracy ≥ 0.75 vs human labels; unknown rate reported |
needs_human |
80 runs | Recall ≥ 0.85 at precision ≥ 0.7 |
These bars are starting values chosen before seeing data. They are expected to change; changes go through the PR that changes the task file, with the reason. The point is that the bar exists before the result.
Not testable offline: vendor latency and availability. T9/T10 cover those.
9. Milestones¶
PR-sized, in order. Sizes: S ≤ 1 day, M ≈ 2–3 days, L ≈ 4–6 days. "Phase" refers to the README build order.
| # | Task | Scope | Size | Depends on | Slot |
|---|---|---|---|---|---|
| M0 | Capture OTLP fixtures | Run Claude Code once with each exporter against a throwaway local receiver, with and without the nonessential-traffic flag and TRACEPARENT. Save protobuf/JSON fixtures. Answer OQ 1 and 3. Record the real attribute names. Doc update of 5.2–5.3. |
S | — | Phase 0, next to 0001 M0 |
| M1 | Scrub + allowlist | hangar/scrub.py, attribute allowlist per signal, secret corpus tests, wiring into 0001's ingester too (log events get the same pattern scrub). |
M | — | Phase 1; helps 0001 M3 |
| M2 | OTLP receiver + storage | /otlp/v1/* routes on :8787 (and gateway when it exists), run ingest keys, traces.db, queue and writer, retention and size cap, run_events columns (src, src_key), mapping to api.request/api.error/tool.decision, enrichment, run_agent env wiring for claude-code, get_telemetry_summary, get_run_trace. Live T1–T6. |
L | 0001 M3, M0, M1 | Phase 1 end / phase 2 start |
| M3 | pi parser | pi-json parser in runevents.py, from a real capture. |
S | 0001 M3, a pi capture | Phase 6 with 0001 M7 |
| M4 | Decision core + rules backend | DecisionService, task schema and YAML, windows, cache, budget, circuit breaker, decisions / decision_labels, rules backend for run_state and failure_triage, classify_run, REST, labelling endpoint, decision events, scripts/eval_decisions.py. Shadow only. |
L | 0001 M2–M3 | Phase 2; this is what 0004 integrates against |
| M5 | Decision-1 backend + spend ledger | Foundry adapter (and OpenRouter if easy), key in vault, decision_spend, caps, no-egress mode test. Live T9, T10. Needs OQ 6 answered. |
M | M4, owner's Foundry/OpenRouter account | Phase 2–3 |
| M6 | Remaining tasks | tool_risk, pr_readiness, route_task, needs_human task files, windows, labels from 0002 outcomes. |
M | M4, 0002 M3 for pr_readiness |
Phase 4 |
| M7 | Local / cheap-LLM backend | One fallback behind DecisionBackend, chosen after OQ 7. Eval vs rules and Decision-1. |
M | M4 | Phase 3–4, optional |
| M8 | Dashboard: trace tab and Decisions page | Waterfall, state strip, labelling UI, calibration view, mode switches. | M | M2, M4; part of the combined dashboard (0001 M8 / 0002 M9) | Phase 6 |
| M9 | Insights: metrics and env-fix suggestions | SQL views, aggregator, suggestions UI, Run.config_fingerprint. |
M | M2, 0002 M3 | Phase 6 |
| M10 | Eval runner and routing stats | POST /api/evals, paired analysis, power message, cost gate, routing table and recommendations. Live check with a small real eval. |
L | M9, 0002 M5 fan-out, 0001 M4 queue | Phase 6 |
| M11 | Docs and acceptance | docs/telemetry.md, docs/decisions.md, acceptance script for T1–T12, results in the PR. |
M | all | Phase-closing PRs |
What 0004 needs and when: classify_run against the rules backend lands with M4, so 0004 can develop against real JSON before any vendor is involved.
10. Open questions¶
- Does
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1(set inharnesses.py) stop OTel export? The docs I read did not say. T3 answers it. If it does, either drop the flag for telemetry runs or pick another way to keep traffic minimal. - OTel in other harnesses. Does Codex, pi, Antigravity or Gemini CLI export OpenTelemetry? I could not verify any. (My recollection is that Codex has an OTel section in
config.tomland Gemini CLI has telemetry settings. Both unchecked.) Install each and check. - Claude Code span and event attributes. Do events carry the tool-use id (for exact
tool.endjoins)? Is thesession.idattribute present and equal to the id hangar assigns with--session-id? Does an inboundTRACEPARENTproduce spans under the caller's trace id in 2.1.296? What are the real names for duration and success fields? M0. - OTLP exporter and
HTTPS_PROXY. Does Claude Code's exporter honor proxy variables, and doesNO_PROXYcover it? Matters for 0003's design where the proxy is mandatory. - Vendor data terms. Does Foundry or OpenRouter retain or train on submitted text? Is there a zero-retention option? Region? Unknown for both.
- Decision-1 API. Request shape, how options and instructions are passed, whether probabilities include an "other" mass, whether a model revision is returned, whether Foundry bills by token only (the brief says $0.042 per 1M input tokens) or also by deployment, rate limits, and OpenRouter pricing. Needs a trial deployment before M5.
- Local fallback model. Which small open model, which runtime, what latency on the control-plane pod's CPU (request 250m today), and whether the pod needs more resources. Do we accept a worse backend, or only rules, in no-egress mode?
- Ingest key granularity. One per run (current design) or per sandbox? Per-run keys are tighter. Per-sandbox keys fit 0003's grants and survive
resume_agent. - OTLP encoding. Protobuf needs the
opentelemetry-protopackage (or hand-rolled decoding). Is adding that dependency acceptable? JSON is simpler if Claude Code supportshttp/json. - Pre-execution
tool_risk. Is a synchronous Claude Code hook worth its latency and dependency, given 0003 enforces already? Probably not, but the post-hoc results will show how much it would have caught. - External backends. Is there one the owner already runs or would pick (Grafana/Tempo, Langfuse, Phoenix, Honeycomb)? I assumed OTLP compatibility without testing any.
- Cost field on subscriptions. Same as 0001 OQ13:
cost.usageis an API-equivalent estimate on Pro. The dashboard should label it so. Should plan % be the primary cost number for subscription credentials? - Labelling effort. The promotion bars need about 150 labels per task. Is the owner willing to label ~1 hour in total, or should human labels be limited to
run_stateandfailure_triagefirst? - Stored decision inputs. Keep scrubbed window text for 90 days to enable re-scoring, or keep only hashes for privacy and give up offline re-evaluation of new backends?
- Retention numbers. 14 days, 50,000 spans per run, 2 GiB are guesses. T12 measures them.
- Eval budget on one Pro plan. Even small paired evals use many runs. Is the owner willing to fund an API-key credential for evaluations, with a dollar cap?