Debug Companions production
Operator-machine skill for diagnosing the production Companions Runtime v2
stack: four Railway services (web, api, worker, runtime), PostgreSQL,
and box.ascii.dev Boxes running Pi. Everything here is read-only except
railway_restart.py, which is double-gated and never touches release.
Authoritative references: docs/runbooks/companions-runtime.md (operations),
docs/companions-runtime.md (state machine), deploy/railway/README.md
(credential boundary).
Hard safety rules
From the runbook — these are not negotiable:
- Never paste tokens, signed URLs, provider payloads, raw Pi lines, auth
files, or decrypted material into a transcript, ticket, or log search.
Every script pipes output through
redact(); do not bypass the scripts to run raw curl/psql with credentials in argv. - Never delete or archive a Box. Full Box restart is an explicit user action only; automatic repair may recycle Pi but never a healthy Box. This skill ships no Box mutation code at all.
- Never manually mark an ambiguous or interrupted attempt
queued, clear lease rows, or edit epochs. An ambiguous dispatch is resolved only by an Owner/Editor Retry (newretry_id) or Cancel in the product. - The kill switch (
companion_runtime_disable) is a human, migration-owner action. This skill only reads gate status viadb_query.py gate; it must never call enable/disable. - Queued durable work is not garbage. Do not delete rows to make a dashboard look clear.
PROD_DATABASE_READ_URLmust reference a read-only login. The scripts still wrap every query inBEGIN TRANSACTION READ ONLY; ... ROLLBACK;as defense in depth, and have no free-SQL mode.
Prerequisites
psqlon PATH (PostgreSQL 17 client).~/.companion-prod.env, mode exactly0600(scripts refuse otherwise):RAILWAY_API_TOKEN=... # Railway token (bearer or project token) RAILWAY_PROJECT_ID=... RAILWAY_ENVIRONMENT_ID=... COMPANION_BOX_API_KEY=... # box.ascii.dev key — runtime-only elsewhere COMPANION_BOX_API_BASE=https://ascii.dev/api/box/v1 # optional, this default PROD_DATABASE_READ_URL=postgres://... # READ-ONLY role DEBUG_PROD_ALLOW_RESTART=0 # set to 1 only while deliberately restartingchmod 600 ~/.companion-prod.env
Values are read only from this file, passed to subprocesses via the environment, and never printed or placed in argv.
First five minutes
Run from the repository root:
python3 .claude/skills/debug-companions-prod/scripts/railway_status.py
python3 .claude/skills/debug-companions-prod/scripts/db_query.py gate
python3 .claude/skills/debug-companions-prod/scripts/db_query.py health
python3 .claude/skills/debug-companions-prod/scripts/railway_logs.py \
--service runtime --since 30m --grep '"level":"error"|persisted|denial' --raw
Interpretation order:
railway_status— are all four services on the same commit and SUCCESS? A commit mismatch during an incident usually means a half-finished deploy.gate—enabled=falsemeans claims are fenced (kill switch or cutover in progress); nothing will run until a human re-enables with the observed epoch. Do not "fix" this from the skill.health— queued backlog per companion, active attempts, stale instance heartbeats, expired-but-claimed leases.- Runtime error logs — collect stable
codevalues, then follow the symptom playbooks below andreferences/triage-playbook.md.
Symptom playbooks
Box launches are failing
python3 scripts/db_query.py interrupted --since 6h
python3 scripts/db_query.py ops --companion <uuid>
python3 scripts/db_query.py instance --companion <uuid>
python3 scripts/box_list.py --companion <uuid>
python3 scripts/railway_logs.py --service runtime --companion <uuid> --since 6h
cold_start_deadline_exceededon turns + operations stuck aroundcreating_box/waiting_ready/installing_layout: the cold path (Box ready- Pi install) exceeded the 3-minute SQL deadline. Check
opscheckpoints andattempt_countto see where time went.
- Pi install) exceeded the 3-minute SQL deadline. Check
- A
cold_start_deadline_exceededoperation whosestarted_atis already later than the turn deadline, with no attempt and an existing warm idle Box, is the pre-0129 queued-follow-up bug: the send was misclassified while Pi was busy. It is not evidence of a Box cold start. box_create_ambiguous: create may have committed provider-side. Runbox_list.py --companion— two Boxes with the same generation is the evidence. Do NOT delete either; the runtime discovers the generation-qualified name and selects one canonical Box (runbook: Box lifecycle/provider outage).box_rate_limited(429) orbox_provider_unavailable/box_network_error: provider incident; count occurrences over the window before escalating to ascii.dev.
Chat dies or stalls while waiting for input
Three distinct signatures can produce "my chat died while I was away". Identify which one you have before touching anything:
python3 scripts/db_query.py turn --turn <uuid>
python3 scripts/db_query.py decisions --companion <uuid> --since 24h
python3 scripts/railway_logs.py --service runtime --turn <uuid> --since 24h
- Decision expiry (ask_user timeout, 10 minutes).
decisionsshows aquestion/confirmationrow withdecision_status=expiredand anexpires_atroughly ten minutes after creation;cancelledbefore that can mean a newer member message returned control to Pi. Neither state grants approval. On releases before migration 0129, a decision can instead be followed byturn_stalledafter ten minutes because the inactivity clock was not actually paused. pi_event_stream_interrupted. The attempt's error triplet names this code: the broker's event stream from Pi broke mid-turn. Look at the attempt'sunknown_event_count/malformed_event_countand runtime logs aroundlast_activity_at. This is transport loss, not member behavior; frequent occurrences are the provider-polling failure mode the direct transport work targets.turn_stalled(10-minute inactivity). Terminal statusinterruptedwith codeturn_stalled: Pi acknowledged the attempt but produced no correlated activity for ten minutes. Distinguish from case 1 by the absence of a pending/expired decision row; distinguish from case 2 by the error code. Check whether Pi is wedged (instanceshowspi_state) — Retry recycles Pi.
The decision expiry and the ten-minute running stall are different clocks.
After migration 0129, needs_input pauses inactivity; before it, trust the row
timestamps and error code over the expected state-machine semantics.
Turn interrupted or Pi silent
python3 scripts/db_query.py stuck
python3 scripts/db_query.py turn --turn <uuid>
dispatch_state=ambiguous/prompt_dispatch_ambiguous: the prompt may have reached Pi; it is deliberately never replayed. The queue is blocked until an Owner/Editor retries or cancels — that is the design, not a bug. Warn that earlier external effects may have succeeded.- An interrupted queue head with queued turns behind it (
stuck) needs an Owner/Editor decision in the product, not a database edit. turn_deadline_exceeded: two-hour absolute deadline; look at attempt history for what consumed it.- Retry creates a new attempt and recycles Pi; it never restarts the Box.
/healthz unhealthy (503)
Runtime healthz is private; diagnose via logs and the database, not curl:
python3 scripts/railway_logs.py --service runtime --since 15m --raw
python3 scripts/db_query.py health
database=false: private database path or restricted runtime login broken. Never substitute the API or owner URL.claim_loop=false: preserve the first stable error code from logs, then roll one replica (railway_restart.py, double-gated). If another replica cannot take over within 45 seconds, the kill switch is a human decision.sweep_fresh=false: event-loop starvation or stuck sweep. A process still accepting TCP is not healthy; roll it.
Error-code map
Stable codes from packages/companion-runtime/src/errors.ts and the runtime
adapters (persisted triplet: code, expurgated ≤500-char message, action):
| Code | Meaning | Typical action |
|---|---|---|
cold_start_deadline_exceeded |
Companion not started before the cold-start deadline | retry; inspect ops checkpoints |
turn_stalled |
10 min with no correlated Pi activity | retry (recycles Pi) |
turn_deadline_exceeded |
2 h absolute deadline reached | retry |
box_create_ambiguous |
Box create may have committed; not replayed | inspect box_list --companion; never delete manually |
prompt_dispatch_ambiguous |
prompt may have reached Pi; not replayed | Owner/Editor Retry or Cancel only |
decision_delivery_ambiguous |
decision response may have reached Pi | same explicit-resolution rule |
pi_event_stream_interrupted |
broker event stream from Pi broke | retry; count occurrences (transport health) |
pi_not_idle / pi_busy |
Pi had queued messages at dispatch time | retry after settle |
pi_invocation_changed |
Pi restarted under the attempt | retry |
pi_process_exited |
Pi process died mid-attempt | retry; check instance pi_state |
box_rate_limited |
provider 429 | wait/backoff; escalate volume |
box_provider_unavailable / box_network_error |
provider unreachable/5xx | provider incident path |
box_unavailable / box_not_found |
Box missing or not usable | inspect instance + box_list |
provider_unavailable / provider_access_revoked |
model provider connection broken/revoked | reconnect provider in Plugins |
mcp_access_revoked |
selected MCP account no longer authorized | reconnect account |
model_image_input_unsupported |
image sent to a text-only model | switch model; nothing reached the Box |
attachment_staging_failed |
staging writes refused before dispatch (proven negative) | retry; check object storage |
actor_not_authorized / companion_access_revoked / actor_access_revoked |
authority revoked before Box contact (fail closed) | none — expected security behavior |
settings_changed / settings_changed_since_claim |
settings raced the claim | retry |
invalid_model_selection |
selected model no longer valid | switch model |
runtime_shutting_down |
replica drained mid-work | should be reclaimed; investigate if it settled a turn |
runtime_execution_failed / runtime_failure |
generic fallback — the log line's thrown block has the real name |
search runtime logs for the same ts |
outbox_harvest_failed is a process-log event, not a persisted attempt error:
the turn succeeded and only reply images were partially recovered — search
logs, never reclassify the turn.
Escalation
- Provider-side (create failures, 429 storms, Boxes stuck
provisioning, resume instability): collect Box ids, timestamps, and counts (redacted output only) and escalate to ascii.dev. - Kill-switch-worthy (unsafe duplicate execution, credential exposure, broken
fencing, corrupt projection): stop; page the on-call owner. The fence is
companion_runtime_disable(<observed_epoch>, 'incident-<id>')run by the migration owner — not by this skill. - Suspected secret exposure: fence first, then rotate per runbook (Box key on runtime only, desktop HMAC on api+runtime together). Search logs only for stable identifiers and codes.
- Record environment, release commit, and operator/change id for every production change. Never record secret values.
references/triage-playbook.md has the full symptom → evidence → cause →
runbook-section map; references/railway-api.md documents the (UNVERIFIED)
GraphQL surface; references/redaction.md is the redaction contract.