Investigate Run Telemetry

Investigate failed or suspicious agent runs using production telemetry in D1. Analyze run lifecycles and LLM calls to find root causes.

Sby Skills Guide Bot
DevelopmentIntermediate
207/23/2026
Claude Code
#telemetry#debugging#d1-database#agent-runs#llm-metrics

Recommended for


name: investigate-telemetry description: Investigate a failed or suspicious agent run (implementer/coder, bootstrap, reviewer, etc.) using the production telemetry in D1. Use when asked to look into a run failure, "no progress" abort, a stuck/looping agent, model output quality, token/truncation issues, or "what went wrong with the latest run". Pulls the run lifecycle from agent_runs and the per-LLM-call prompt/response/usage from llm_call_metrics, then reads the tool-call loop to find the root cause.

Investigate run telemetry (from D1)

Telemetry lives in the production D1 database cat_factory, NOT (primarily) in Cloudflare Workers Observability. Two tables carry everything you need:

  • agent_runs — one row per container-backed run (kind='execution' for the task pipeline / implementer, kind='bootstrap' for repo bootstrap). Holds status, the structured failure JSON (kind/message/hint/lastSubtasks), and a detail JSON with every pipeline step (agentKind, state, model, approvals, per-step metrics). This is the lifecycle + the failure verdict.
  • llm_call_metrics — one row per proxied LLM call (migration 0026). Holds agent_kind, provider, model, ok, http_status, finish_reason, token counts, request_max_tokens, the latency split (upstream_ms/overhead_ms), error_message, and the full prompt_text + response_text. Linked to a run by execution_id. This is what the model actually saw and produced.

Retention: llm_call_metrics is pruned aggressively (default 3 days, LLM_CALL_METRICS_RETENTION_DAYS) because the full bodies are heavy; agent_runs lives longer. Investigate recent runs promptly.

How to query

Run wrangler from deploy/backend (its wrangler.toml defines the cat_factory binding). Always pass --remote (production) and --json (parseable). Do NOT pre-check Cloudflare auth — assume the login is correct (see CLAUDE.md).

cd deploy/backend
npx wrangler d1 execute cat_factory --remote --json --command "SELECT ..."

Parse the JSON with node -e (Python is not on PATH here). The result shape is [{ results: [...rows], success, meta }].

Step 1 — find the run

Latest implementer/pipeline runs (drop the WHERE kind to see bootstrap too):

SELECT id, kind, status, block_id,
       datetime(created_at/1000,'unixepoch') AS created,
       datetime(updated_at/1000,'unixepoch') AS updated
FROM agent_runs
WHERE kind='execution'
ORDER BY created_at DESC LIMIT 10;

Take the id (e.g. exec_44c8387cac02) of the run in question.

Step 2 — read the failure verdict and step list

SELECT failure, detail FROM agent_runs WHERE id='<run id>';
  • failure (JSON): kind (job_failed, evicted, timeout, agent, …), message (the abort reason — e.g. the ProgressGuard text), hint, and lastSubtasks. This tells you HOW the run died.
  • detail.steps[] (JSON): which step was running at failure (state:'working'), the model each step used, and each step's rolled-up metrics (calls, truncatedCalls, errors, warnings, peakCompletionTokens, maxOutputTokens). The step with jobId === <run id> is the container step.

Step 3 — read the per-call LLM telemetry

Overview of every call for the failing step's kind (usually coder):

SELECT agent_kind, provider, model, ok, http_status, finish_reason,
       prompt_tokens, completion_tokens, request_max_tokens, upstream_ms,
       datetime(created_at/1000,'unixepoch') AS t, substr(error_message,1,300) AS err
FROM llm_call_metrics
WHERE execution_id='<run id>' AND agent_kind='coder'
ORDER BY created_at ASC;

Read the columns as signals:

  • ok=0 / non-2xx http_status / non-null error_message → transport, proxy, or spend-gate failure (an infra problem, not a model problem).
  • finish_reason='length' or completion_tokens near request_max_tokens → output truncation; the model was cut off mid-answer (raise the output limit or shrink the task). truncatedCalls in the step metrics counts these.
  • ok=1 + finish_reason='tool_calls' everywhere → the LLM side is healthy; the failure is in tool EXECUTION inside the container (see step 4). The ProgressGuard (harness pi.ts) counts failing tool calls from Pi's event stream — those tool errors are NOT rows here, only the LLM calls that drove them.

Step 4 — read the actual tool-call loop (the root cause)

prompt_text is stored as a DELTA vs the previous call (migration 0027), so each call's prompt_text contains the new assistant message(s) plus the tool RESULT messages returned to the model — including tool validation errors. response_text is the model's text content (tool-call arguments are echoed inside the assistant message in the next call's prompt delta).

Dump responses to see what the model was trying to do:

SELECT completion_tokens, response_text
FROM llm_call_metrics
WHERE execution_id='<run id>' AND agent_kind='coder'
ORDER BY created_at ASC;

Dump the tail of the final prompt to see the last tool result/error the model got:

SELECT prompt_text FROM llm_call_metrics
WHERE execution_id='<run id>' AND agent_kind='coder'
ORDER BY created_at DESC LIMIT 1;

Look for, in prompt_text: Validation failed for tool "<tool>" / must have required properties <field> (the model is emitting malformed tool args), repeated identical tool calls (a stuck loop), or <tool_call>…</tool_call> appearing as literal TEXT in response_text (the model emitted a tool call as prose instead of through the structured channel — fragile parsing, a model-quality smell). In response_text: garbled token-soup completions point at a Workers AI decode bug for that model (cf. the streaming token-doubling fix, commit 23b9fb6).

Step 5 — classify and report

Decide whether the failure is:

  • Model quality — malformed/looping tool calls, garbled output, ignored validation errors. Fix: don't default that role to that model; pin a stronger tool-calling model or denylist it for code steps.
  • Truncationfinish_reason='length'. Fix: raise output limit / split task.
  • Infraok=0, HTTP errors, spend-gate refusals, eviction/timeout (failure.kind). Fix: the proxy/runner/budget, not the prompt.
  • Working as designed — the ProgressGuard aborting a genuinely stuck run is the safety net doing its job; the bug (if any) is upstream of it.

Report: the run id, the failing step + model, the abort reason, the root-cause class with the evidence (quote the specific tool error or response), and a concrete fix. Then the run can be retried (POST /workspaces/:ws/agent-runs/:id/retry, or the board "retry" button) to spin a fresh container.

Notes

  • Helper one-liner to scan all deltas for tool validation failures: pipe the prompt_text rows through node and match(/must have required properties (\w+)/g).
  • The app also exposes this without SQL: GET /executions/:id/llm-metrics (per-call list) and GET /executions/:id/llm-metrics/export (LLM-friendly JSON bundle). Use D1 directly when you need cross-run queries or the app is unreachable.
Related skills