CtrlK
BlogDocsLog inGet started
Tessl Logo

debug-companions-prod

Read-only production debugging for the Companions runtime on Railway and box.ascii.dev. Use when production box launches fail, a chat dies or stalls while waiting for input, turns sit queued or interrupted, a Companion appears to reply forever, /healthz is unhealthy, or an operator needs Railway deployment status, redacted runtime logs, provider Box inventory, or named read-only PostgreSQL runtime queries. Every script redacts credentials; the only mutation is a double-gated service restart.

71

Quality

87%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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:

  1. 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.
  2. 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.
  3. Never manually mark an ambiguous or interrupted attempt queued, clear lease rows, or edit epochs. Protocol 7 resolves an ambiguous dispatch with cleanup of its exact Pi invocation, records auto_abandoned, and releases the lane without replay. Retry is compatibility-only and may only observe or re-enqueue that cleanup; it never creates an attempt.
  4. The kill switch (companion_runtime_disable) is a human, migration-owner action. This skill only reads gate status via db_query.py gate; it must never call enable/disable.
  5. Queued durable work is not garbage. Do not delete rows to make a dashboard look clear.
  6. PROD_DATABASE_READ_URL must reference a read-only login. The scripts still wrap every query in BEGIN TRANSACTION READ ONLY; ... ROLLBACK; as defense in depth, and have no free-SQL mode.

Prerequisites

  • psql on PATH (PostgreSQL 17 client).

  • Credentials exposed as process variables, as in a Conductor cloud workspace. ~/.companion-prod.env is an optional local fallback and must be mode exactly 0600 when present:

    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 restarting
    chmod 600 ~/.companion-prod.env

Process variables override file values. Only the variables listed above are loaded; unrelated process secrets are ignored. Values passed to subprocesses stay in the environment and are 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:

  1. railway_status — are all four services on the same commit and SUCCESS? A commit mismatch during an incident usually means a half-finished deploy.
  2. gateenabled=false means 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.
  3. health — queued backlog per companion, active attempts, stale instance heartbeats, expired-but-claimed leases.
  4. Runtime error logs — collect stable code values, then follow the symptom playbooks below and references/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
  • A runtime.work.start_requeued / cold_start_deadline_exceeded log around creating_box/waiting_ready/installing_layout means one protocol-7 cold path cycle exceeded three minutes. The same operation must be pending with bounded backoff, started_at and its transient error cleared, while its source turn remains queued with no attempt. A terminal source turn carrying this code is pre-protocol-7 behavior.
  • A cold_start_deadline_exceeded operation whose started_at is 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. Run box_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) or box_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
  1. Decision expiry (ask_user timeout, 10 minutes). decisions shows a question/confirmation row with decision_status=expired and an expires_at roughly ten minutes after creation; cancelled before 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 by turn_stalled after ten minutes because the inactivity clock was not actually paused.
  2. 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's unknown_event_count/malformed_event_count and runtime logs around last_activity_at. This is transport loss, not member behavior; frequent occurrences are the provider-polling failure mode the direct transport work targets.
  3. turn_stalled (10-minute inactivity). Terminal status interrupted with code turn_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 (instance shows pi_state) and follow the automatic-cleanup operation through cleanup_complete.

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, so it is deliberately never replayed. Protocol 7 enqueues resource-free cleanup of only the exact invocation, then records auto_abandoned and resumes the next FIFO message. Warn that earlier external effects may have succeeded.
  • An interrupted queue head with queued turns behind it (stuck) should own one restart_pi operation with trigger=recovery. Inspect its lane, checkpoint, age, attempt_count, and live lease. cleanup_complete is the current terminal proof; pi_ready is accepted only for legacy recovery.
  • A recovery older than 15 minutes or with a climbing attempt count is a stalled automatic cleanup. Correlate runtime.recovery.stalled with its expurgated error and exact Box/Pi evidence; do not manufacture resolution.
  • turn_deadline_exceeded: two-hour absolute deadline; look at attempt history for what consumed it.
  • The compatibility Retry route never replays the prompt or creates an attempt; it only observes or re-enqueues the existing cleanup.

/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):

CodeMeaningTypical action
cold_start_deadline_exceededOne pre-dispatch Start cycle reached three minutesinspect Start checkpoint/backoff; protocol 7 requeues the same Start and keeps the message queued
turn_stalled10 min with no correlated Pi activityinspect exact automatic cleanup
turn_deadline_exceeded2 h absolute deadline reachedinspect exact automatic cleanup
box_create_ambiguousBox create may have committed; not replayedinspect box_list --companion; never delete manually
prompt_dispatch_ambiguousprompt may have reached Pi; not replayedautomatic exact-invocation cleanup; inspect recovery
decision_delivery_ambiguousdecision response may have reached Pisame automatic-cleanup rule
pi_event_stream_interruptedbroker event stream from Pi brokeinspect recovery; count occurrences (transport health)
pi_not_idle / pi_busyPi had queued messages at dispatch timeinspect settlement and recovery
pi_invocation_changedPi restarted under the attemptinspect exact-invocation proof
pi_process_exitedPi process died mid-attemptinspect recovery + instance pi_state
box_rate_limitedprovider 429wait/backoff; escalate volume
box_provider_unavailable / box_network_errorprovider unreachable/5xxprovider incident path
box_unavailable / box_not_foundBox missing or not usableinspect instance + box_list
provider_unavailable / provider_access_revokedmodel provider connection broken/revokedreconnect provider in Plugins
mcp_access_revokedselected MCP account no longer authorizedreconnect account
model_image_input_unsupportedimage sent to a text-only modelswitch model; nothing reached the Box
attachment_staging_failedstaging writes refused before dispatch (proven negative)check object storage; a later Send may retry ordinary work
actor_not_authorized / companion_access_revoked / actor_access_revokedauthority revoked before Box contact (fail closed)none — expected security behavior
settings_changed / settings_changed_since_claimsettings raced the claimlet normal prerequisite work reconcile settings
invalid_model_selectionselected model no longer validswitch model
runtime_shutting_downreplica drained mid-workshould be reclaimed; investigate if it settled a turn
runtime_execution_failed / runtime_failuregeneric fallback — the log line's thrown block has the real namesearch 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.

Repository
The-Vibe-Company/companion
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.