# Corvin — Troubleshooting

A quick-reference table of the most common problems, their causes, and how to fix them.
After the table, a section on reading bridge.sh tail output is provided.

---

## Symptom → Cause → Fix

| # | Symptom | Likely cause | Fix |
|---|---|---|---|
| 1 | Bot doesn't reply at all | Token missing or whitelist empty in settings.json | Check `operator/bridges/<channel>/settings.json`. Make sure `<channel>_token` is a real value (not the placeholder `"YOUR_TOKEN_HERE"`). Add your user ID to the `whitelist` array. Restart: `bash operator/bridges/bridge.sh restart`. |
| 2 | `claude: command not found` | Claude Code CLI not on PATH | Run: `export PATH="$HOME/.local/bin:$PATH"` and add that line to `~/.bashrc` (or `~/.zshrc`). Then: `source ~/.bashrc`. Or re-run `corvin-install` — it fixes the PATH automatically. |
| 3 | `No module named 'openai'` or `openai module not found` | pip install failed due to PEP 668 (externally managed environment) | Option A: `pip install openai --break-system-packages`. Option B (recommended): `python3 -m venv ~/.venv && source ~/.venv/bin/activate && pip install openai`. Setup.sh uses option B. |
| 4 | WhatsApp QR code expired before scanning | Session timed out (30-second window) | Re-run: `bash operator/voice/scripts/whatsapp_cli.sh pair`. Scan the new QR code before it expires. |
| 5 | Discord bot online but not responding to plain guild messages | Token-only mode (MESSAGE CONTENT INTENT not enabled) — the bot still answers DMs and @mentions | Mention the bot (`@botname …`) or DM it. For full guild-channel reading: [discord.com/developers/applications](https://discord.com/developers/applications) → your app → Bot → **Privileged Gateway Intents** → enable **Message Content Intent** → Save Changes → `bash operator/bridges/bridge.sh restart`. The daemon picks the portal state up automatically on the next boot. |
| 5b | WhatsApp: inbound messages reach Corvin after a re-pair, but replies never arrive | Re-pairing issues a new `@lid` identity; before 2026-07-22 the outbound gate compared the raw reply JID without alias expansion and dropped every reply as "disabled chat" | Fixed (chat_state.js alias-symmetric enable check). On older versions: send `/on` in the affected chat after re-pairing. If the daemon logs `Logged out — auth invalidated`, it now moves `auth/` aside automatically and the next start shows a fresh QR. |
| 6 | Reply contains `[rate limited]` or similar | OPENAI_API_KEY exhausted or missing | Check [platform.openai.com/usage](https://platform.openai.com/usage) for your quota. Ensure `OPENAI_API_KEY` is set in `~/.config/corvin-voice/service.env`. Restart the adapter after editing that file. |
| 7 | No TTS voice attachment — bot replies in text only | `ffmpeg` not found (rare — a bundled `imageio-ffmpeg` binary is used automatically when no system ffmpeg is on PATH) | Usually self-resolves via the bundled fallback. If it still fails: Linux: `sudo apt install ffmpeg`. macOS: `brew install ffmpeg`. Windows: `winget install ffmpeg`. Then restart: `bash operator/bridges/bridge.sh restart` (or `.\bridge.ps1 restart` on Windows). |
| 8 | Systemd service fails to start after moving the repo | WorkingDirectory in the unit file points to the old path | Run `bash operator/bridges/bridge.sh up` — it regenerates unit files with the current path. Then: `systemctl --user daemon-reload && systemctl --user restart corvin-voice-bridge-adapter.service`. |
| 9 | `bridge.sh status` shows all components as red / stopped | `npm install` was never completed | Run `bash operator/bridges/bridge.sh up` — this always runs `npm install` before starting daemons. |
| 10 | `voice-audit verify` fails with "chain broken at event N" | `audit.jsonl` was manually edited or truncated | Never edit `audit.jsonl` by hand. If this is a fresh install with no real data, you can delete the file and let Corvin create a new chain. If this is a production system, restore from backup. The break point is logged — events before it are still valid. |
| 11 | Forge tools not appearing inside Claude (no `mcp__forge__*` tools) | Plugin not registered with Claude Code | Run: `claude plugin install voice@corvin-voice-local`. Requires Claude Code CLI and active login. Then restart Claude Code. |
| 12 | Web console returns HTTP 401 | Owner token expired (90-day TTL) | Re-generate a token: `python -m corvin_gateway.cli token issue _default`. Paste the new `atlr_...` token at the login screen. Old tokens can be revoked via Settings → Tokens. |
| 13 | Signal bridge not connecting | `signal-cli` not installed or not on PATH | Install `signal-cli` (requires Java 17+). See `operator/bridges/signal/README.md` for detailed instructions. Verify with: `signal-cli --version`. |
| 14 | Email bridge not picking up new mail | Provider does not support IMAP IDLE (push) | Switch to polling mode. In `operator/bridges/email/settings.json`, set `"imap_idle": false` and optionally `"poll_interval_seconds": 60`. Hot-reload applies immediately — no restart needed. |
| 15 | Bot replies but no TTS audio attachment in any bridge | `OPENAI_API_KEY` not set in service.env | TTS uses OpenAI's TTS API. Set `OPENAI_API_KEY=sk-...` in `~/.config/corvin-voice/service.env` (mode 0600 required). Restart the adapter. |
| 16 | Installer fails at `npm install` with `Unsupported engine` | Node.js version is below 20 | Use nvm: `nvm install 20 && nvm use 20 && nvm alias default 20`. If nvm is not installed: [github.com/nvm-sh/nvm](https://github.com/nvm-sh/nvm). |
| 17 | Warning: `ANTHROPIC_API_KEY not set` at startup | Anthropic API key not provided | This is advisory, not an error. Claude Code's browser login (`claude auth login`) is sufficient for the main agent. The key is only needed for optional background helpers (summarizer, dialectic judge). You can safely ignore this warning. |
| 18 | `bridge.sh up` says "no channels configured" | All bridge tokens are still the placeholder values | Edit at least one `operator/bridges/<channel>/settings.json` with a real token. See the per-bridge credential guide in [setup.md](setup.md). |
| 19 | Telegram bot replies but ignores all commands | Your Telegram user ID is not in the whitelist | Find your user ID via `@userinfobot` in Telegram. Add it to `operator/bridges/telegram/settings.json` under `whitelist`. Hot-reload applies — no restart needed. |
| 20 | Adapter crashes with `ModuleNotFoundError: No module named 'yaml'` | Python dependencies not installed | Run: `pip install -e ".[all]"` (use the venv if you set one up: `source ~/.venv/corvin/bin/activate && pip install -e ".[all]"`). |
| 21 | WhatsApp daemon says `Error: Session not found` on startup | Session files were deleted or never created | Re-run the pairing flow: `bash operator/voice/scripts/whatsapp_cli.sh pair`. |
| 22 | Slack bot receives messages but `chat:write` permission error appears | Bot was installed before adding `chat:write` scope | In the Slack app settings, go to OAuth & Permissions, confirm `chat:write` is listed, then reinstall the app to the workspace. |
| 23 | `bridge.sh doctor` reports `engine.claude_cli: CRITICAL` | Claude Code CLI not installed or not executable | Run `claude --version` manually to confirm. Re-install if needed: `npm install -g @anthropic-ai/claude-code`. |
| 24 | Bridge adapter starts but emits `[WARNING] path_gate self_test_failed` | A protected path has become accessible due to permission change | Run `bridge.sh doctor` for details. Usually caused by a `chmod` or `chown` applied to the `~/.corvin/` directory tree. Restore permissions: `chmod 600 ~/.config/corvin-voice/secrets.json ~/.corvin/global/memory/recall.db`. |
| 25 | Web console shows bridges as "unknown" / no data | Adapter not running (console backend is the adapter) | Start the adapter: `bash operator/bridges/bridge.sh up`. The console connects to the adapter's uvicorn server on port 8765. |
| 26 | `bridge.sh console` fails with "Console not set up" | The Python venv for the web UI has not been bootstrapped | Run: `bash core/console/bootstrap.sh`. This creates `core/console/.venv` and builds the frontend. Then retry `bridge.sh console`. |
| 27 | `http://127.0.0.1:8765` shows "connection refused" | `bridge.sh console` (or the systemd webui unit) is not running | Start the console: `bash operator/bridges/bridge.sh console`. On Linux with systemd: `systemctl --user start corvin-webui.service`. |
| 28 | `setup.ps1` on Windows says "Admin required" | WSL2 installation needs elevated privileges | Accept the UAC prompt (the script re-launches itself). If it fails, right-click PowerShell → "Run as administrator" and re-run `setup.ps1`. |
| 29 | `setup.ps1` says "reboot required" then nothing happens | WSL2 kernel was not loaded; reboot needed | Reboot, open Ubuntu from the Start Menu (first-run setup: choose username/password), then re-run `powershell -ExecutionPolicy Bypass -File setup.ps1`. |
| 30 | Console chat shows "Connection error — reconnecting…" (or "Reconnecting…") after a task | The console used to run with uvicorn `--reload`; a chat turn that edited repo code under `core/console`/`core/gateway` triggered a reload that closed the chat WebSocket (code 1012). | Fixed: the systemd `corvin-webui` unit and `bridge.sh console` no longer use `--reload`. After updating, regenerate the unit (`bash operator/bridges/bridge.sh up`) and `systemctl --user restart corvin-webui`. Apply new console/gateway code with a `systemctl --user restart corvin-webui` (no auto-reload). Developers who want auto-reload: `CORVIN_CONSOLE_RELOAD=1 bash operator/bridges/bridge.sh console` (accepting that live chat WebSockets drop on every code change). |
| 31 | Console chat artifacts download instead of showing inline (PDF/audio/video) | The workdir route used to send `Content-Disposition: attachment`. | Fixed: artifacts are served `inline` so images, PDFs, audio and video render in-place in the chat; the per-artifact Download button still saves the file via the HTML `download` attribute. |
| 32 | `corvin-webui.service` stuck in `failed` state; `journalctl --user -u corvin-webui` shows `[Errno 98] address already in use` on port 8765 | A restart raced the previous instance's socket release: the old `RestartSec=5` retried faster than the port could free up, and 5 rapid failures inside `StartLimitIntervalSec=60` tripped systemd's "start request repeated too quickly" — a **permanent** failed state with no further auto-retry. | Fixed (2026-07-12): the unit now actively waits (up to 15s) for port 8765 to free before binding, and the retry budget is wider (`StartLimitBurst=8`/`120s`) with exponential backoff. Regenerate the unit after updating: `bash operator/bridges/bridge.sh up` (or manually re-render `core/gateway/systemd/corvin-webui.service` and `systemctl --user daemon-reload`). To recover a currently-stuck instance right now: `systemctl --user reset-failed corvin-webui.service && systemctl --user start corvin-webui.service`. |
| 33 | Voice reply (esp. a fresh-install greeting) sounds like it's just reading out a command line — backticks, `--flags`, ALL_CAPS env-var names spoken aloud | A hardcoded engine-unreachable fallback message (e.g. "claude CLI is not installed... start `ollama serve`... set CORVIN_OS_ENGINE") is used as BOTH the visible chat text and — unmodified — the spoken text; this fires on a fresh install where Claude Code can't be reached (stripped-PATH auto-downgrade to Hermes, see #9/ADR-0159) and Ollama isn't running yet either. Also hit the exact same way on the primary Claude Code engine's own timeout/error fallbacks (not just the Hermes edge case), and on Codex/OpenCode. Separately, a `/task` (background) completion delivered through `completion_notify.py` never stripped the tag at all, so it could leak the raw `<voice>...</voice>` markup straight into the visible chat message — worse than the original bug. | Fixed (2026-07-12, hardened same day after adversarial review): every affected fallback string (`call_claude()`, `_call_claude_streaming_via_engine()`, `_call_codex_streaming_via_engine()`, `_call_opencode_streaming_via_engine()`, `_call_hermes_streaming_via_engine()`, `call_claude_streaming()`'s ClaudeCodeEngine guard — all in `operator/bridges/shared/adapter.py`) now builds its return value via `voice_tag.with_voice_override(visible, spoken)`, a shared helper (`operator/bridges/shared/voice_tag.py`) that (a) appends a `<voice>…</voice>` override with a natural English sentence — same mechanism a model-authored reply uses to speak something different from what's shown, and (b) neutralizes any literal `<`/`>` in the visible text first, so untrusted subprocess stderr / provider error text can never smuggle a stray `<voice>` tag that hijacks the extraction. `completion_notify.mark_done()` now also calls `extract_voice_override()` itself — the one choke point every producer (including `bg_task_worker.py`) already calls — so a background-task completion can never leak the raw tag regardless of which engine produced it. The spoken sentences are English, matching the pre-existing visible-text convention in these functions (CLAUDE.md: repository content is English; these are static source literals, not runtime language-adapted text, so the "user-facing runtime text" exception didn't apply to a hardcoded German literal). |
| 36 | `mcp__imagegen-zero-config__generate_image` hangs forever — spinner never resolves, no image, no error, no timeout (reported live on Windows 11) | Investigated in depth (timeout coverage across the whole call chain, MCP subprocess spawn on Windows, whether anything downstream ever bounds a hanging turn). Confirmed: `_save_image_bytes()`'s `mkdir`/`write_bytes` had NO timeout — only a `try/except`, which cannot interrupt a syscall stuck *inside the kernel* (e.g. a stalled OneDrive-synced or network-mapped folder backing the session workdir — both common on Windows, and made more likely to matter by the previous day's `CORVIN_IMAGE_OUTDIR` fix, which pointed the write target at the session workdir instead of an arbitrary cwd). Separately confirmed: **nothing downstream ever times out a hanging turn either** — the console's stdout-reading loop (`chat_runtime.py`) has no deadline, and the messenger bridge's `subprocess.communicate()` is explicitly documented as "no default time limit" unless an operator opts in via `CLAUDE_BRIDGE_TIMEOUT`. So a stuck write was invisible and unbounded all the way from the MCP server to the user's screen. | Fixed (2026-07-13): `operator/mcp_manager/servers/imagegen-zero-config/main.py` now bounds both (a) the file-save step (`_SAVE_TIMEOUT_S = 15s`) and (b) the entire `generate_image()` tool call as a holistic safety net (`_TOTAL_TIMEOUT_S = 150s`, generous enough for the worst-case L44 gate + provider HTTP call chain to legitimately finish). Both use a `threading.Thread(daemon=True)` + bounded `.join()` — NOT `signal.alarm`, which doesn't exist on Windows and would have made this a Linux-only fix contradicting "must run everywhere." A still-stuck operation is abandoned (its daemon thread never blocks process exit) rather than awaited; the tool call itself always returns within the bound, either with the real result or a clear "timed out" `ImageGenRefused` error instead of hanging silently forever. Verified with real simulated 5-second hangs in both spots — tests assert the call actually returns in under 2s despite the artificial hang, not just that the timeout constant exists. Tests: `test_imagegen_zero_config.py::test_save_image_bytes_abandons_a_stuck_write_instead_of_hanging`, `::test_generate_image_returns_timeout_error_instead_of_hanging_forever`, `::test_generate_image_still_works_normally_through_the_timeout_wrapper` (regression guard for the fast path). |
| 35 | Console chat: an image generated via the `imagegen-zero-config` MCP tool shows only as a generic downloadable file card (filename, size, download icon) instead of rendering inline as a photo | Investigated in depth (frontend `isImage` MIME check, backend `_artifact_mime()` classification, `sid`/URL construction, `_SAFE_SUBPATH` regex, CSP headers — all verified correct for a `.jpeg` file). Found one confirmed, real reliability gap: `operator/mcp_manager/servers/imagegen-zero-config/main.py::_save_image_bytes()` writes to `Path.cwd()/outputs` by default — relying on implicit cwd inheritance through the `claude` CLI subprocess, an assumption the server's own code comments already flagged as unverified for this exact spawn chain (the same class of gap that already needed an explicit `CORVIN_HOME`/`CORVIN_TENANT_ID` workaround). **Could not conclusively confirm this is what produced the reported screenshot** — no browser-automation tool was available in this session to reproduce live, and the exact file wasn't present on the machine used to investigate (the report was from a separate Windows installation). | Hardened regardless, since the gap is real and well-evidenced: `get_active_mcp_servers()` (`operator/mcp_manager/mcp_manager/activate.py`) gained an `image_outdir` parameter that explicitly sets `CORVIN_IMAGE_OUTDIR` on the `imagegen-zero-config` catalog entry's env, pointing it at the actual session/chat workdir's `outputs/` subdirectory instead of leaving it to guess. Wired at both call sites: the console (`chat_runtime.py::_persona_mcp_config`, now takes a `workdir` param) and the messenger bridges (`adapter.py::_resolve_spawn_inputs`). New tests close the blind spot that let this go unverified: `test_mcp_m4.py::TestImageOutdirInjection` (env injection), and `test_imagegen_zero_config.py`'s new round-trip tests — proving `_save_image_bytes()` actually honours the env var AND that the file it writes is classified `image/jpeg` by the real `chat_runtime._artifact_mime()` function, not a synthetic path. If this doesn't fully resolve the symptom, the next diagnostic step is the browser DevTools Network tab for the failed `<img>` request (exact HTTP status immediately narrows it further). |
| 34 | Fresh-install/console "Welcome" spoken greeting comes out in English even though `profile.display_language` is set to a non-de/en locale (e.g. `zh`) — not translated, not left in the user's actual language either | `operator/voice/i18n/` ships only `de.json` and `en.json` — no bundle exists for any other locale. `_welcome_check_lang()` (`core/console/corvin_console/routes/setup.py`) resolves the greeting language straight from `profile.display_language` via `i18n.resolve()`, which happily returns e.g. `zh-Hans` — but `i18n.t()`'s own lookup chain (exact locale → base locale → English → literal key, see `i18n.py::t`) then silently falls all the way through to English for every `welcome.*` key, with no warning logged anywhere. **Root cause (found 2026-07-12, same day):** the installer (`corvinOS/installer/steps/piper.py::_seed_profile_display_language`) correctly seeds the language picked at install time — but two OTHER write paths never validate what they store: the generic in-chat `/profile set display_language=<value>` (`operator/voice/scripts/profile_cli.py::cmd_set`) and the console's `PUT /v1/console/profile` (`core/console/corvin_console/routes/profile.py`, `IdentityFields.display_language`) both persisted the raw client value verbatim, unlike the purpose-built `/lang set` (`lang_cli.py`), which always runs it through `i18n.normalise()`. A bare, un-normalized `"zh"` (instead of the canonical `"zh-Hans"`) written through either of those two paths is what actually broke the greeting on this deployment — confirmed by `config.json`'s `lang_default: "de"` / `piper_model_de` showing the installer itself seeded German correctly. | **Fixed (2026-07-12):** both under-validated write paths now route `display_language` through the same `i18n.normalise()` call `/lang set` already uses — `profile_cli.py::cmd_set` special-cases the key and refuses an unrecognisable code instead of storing it; `routes/profile.py`'s `IdentityFields` gained a `field_validator` doing the same, returning HTTP 422 for a bad code. The live profile on this deployment was corrected back to `display_language: "de"` via the now-validated `/profile set` path. Tests: `operator/voice/scripts/test_profile_cli_lang.py` (5 cases) + 2 new cases in `core/console/tests/test_profile_routes.py` (`test_display_language_is_normalised_through_bcp47`, `test_display_language_rejects_unrecognisable_code`). **Residual gap also closed same day:** added a real, fully-translated `operator/voice/i18n/zh-Hans.json` bundle (all `lang`/`consent`/`welcome` keys) — a genuinely `zh-Hans`-preferring user now gets a real Chinese welcome greeting instead of the English fallback. `test_setup_welcome_check.py::test_zh_profile_greeting_is_now_real_chinese` proves it; the "no bundle at all" case is now covered via `ja` instead (still genuinely unbundled). |
| 36 | A chat/DM reply is visibly TRUNCATED mid-message — everything after some point (often where the reply *mentions* voice output, CLI, or code) silently vanishes from the chat text, while the spoken voice-note contains that missing tail | `extract_voice_override()` (`operator/bridges/shared/voice_tag.py`) used a leftmost `<voice>…</voice>` match: it paired the FIRST `<voice>` it found with the NEXT `</voice>`. When the visible prose legitimately *mentions* the literal token `<voice>` (e.g. a reply explaining the voice-override mechanism itself, or `` `<voice>` `` in backticks) and the producer ALSO appends its real trailing `<voice>…</voice>` block, the stray earlier mention paired with the real block's closing tag — so everything between the mention and the end got cut out of the chat text and misrouted into the spoken override. Reported 2026-07-13 (a reply cut off right after a "2. Code-Bug" heading that was immediately followed by "the `<voice>` path"). Note `with_voice_override()` already escaped `<`/`>` for *untrusted* strings, but MODEL-authored replies run unescaped, so the model could hijack its own block. | **Fixed (2026-07-13):** `extract_voice_override()` now pairs the LAST `</voice>` with the nearest preceding `<voice>` (rfind-based), so a stray EARLIER opening tag no longer swallows the real trailing block — the mention stays as literal text in the chat and the real block is extracted for TTS. `with_voice_override()`'s `<`/`>` escaping is kept as defense-in-depth against a stray CLOSING tag in untrusted content. Regression: `operator/bridges/shared/test_adapter_voice_override.py::test_literal_voice_mention_does_not_hijack_real_block`. |
| 37 | Fresh install (esp. Windows): the WELCOME greeting and the first CHAT voice come out in the WRONG language / inconsistent (welcome English, TTS German) — the install-time language choice is not preset for everything | `profile.display_language` (the SSOT every surface reads: `_welcome_check_lang` → English default, bridge `_resolve_voice_output_language` → German default, console `ttsLang` → English default) was seeded ONLY as a side effect of a SUCCESSFUL Piper model download — `_save_model_config` → `_seed_profile_display_language` at the very END of `_download_model`. Every other path returned WITHOUT seeding: the user skipping the voice model (`[0]`), an unparseable menu choice, a failed/partial ONNX fetch (Windows CDN reset / WinError 10054 / offline), or a model PREFETCHED by install.sh/.ps1 (which makes `_setup_model` early-return on "already configured"). Unseeded `display_language` → the three surfaces fall back to their three DIFFERENT hardcoded defaults, so welcome speaks English while the bridge TTS speaks German on the same box. Compounded on Windows by `_detect_language()` using the DEPRECATED `locale.getdefaultlocale()`, which returns `(None, None)` on some configs → a German box detected as `en`. 5-layer LDD root cause: language seeding was not a first-class step but a fragile, skippable byproduct of the TTS-model download (single-source-of-truth + explicit-over-implicit violation). | **Fixed (2026-07-13):** `_setup_model` now seeds `display_language` UNCONDITIONALLY and BEFORE the download, on every branch — interactive choice, non-interactive auto-detect, skip `[0]`, invalid choice, AND the prefetched-model early-return (seeded from `config.json`'s `lang_default` via new `_config_lang_default`). The seed is decoupled from download success (a failed fetch still presets the reply language). `_detect_language()` now uses the non-deprecated `GetUserDefaultLocaleName` (kernel32) first on Windows, with `getdefaultlocale()` only as fallback. `_seed_profile_display_language` now normalises through `i18n.normalise()` (closing the third un-normalised write path — cf. #34). Tests: 8 new cases in `tests/test_installer_piper.py` (seed fires on every branch incl. download-failure + prefetch, normalisation, empty-guard). Defence-in-depth follow-ups (closed same day, 2026-07-13): the three runtime fallbacks no longer DIVERGE when `display_language` is somehow still unset — a shared `i18n.system_language()` OS-locale tier is inserted BELOW the explicit profile pin and ABOVE each surface's constant, so `_welcome_check_lang` (`resolve(display_language, system_language(), default="en")`) and the bridge `_resolve_voice_output_language` (falls back to `system_language()` before the caller's `or "de"`) agree on the user's actual OS language; on Windows `GetUserDefaultLocaleName` makes every surface consistent. The console web chat's first-reply race is closed too: `chat.tsx`'s `ttsLang` now falls back to `navigator.language` (base subtag) instead of a hard `"en"` when the profile query hasn't resolved yet. Tests: 5 new `SystemLanguageTests` in `operator/bridges/shared/test_i18n.py`. |
| 38 | Windows 11: an ACS delegation run (chat auto-delegation / `acs_delegate`) fails immediately with `OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect: '…\sessions\web:<sid>\acs\runs\…\workers'` | `acs_runtime._run_dir()` built the session component as the RAW chat_key `f"{bridge}:{chat}"` (e.g. `web:<sid>`). The `:` is legal on POSIX but ILLEGAL in a Windows path component (drive/ADS separator) → `os.mkdir` raised WinError 123 and every ACS run died on Windows. The main web-session workdir already sanitises this via `chat_runtime._workdir` → `forge.paths.safe_session_subdir` (the 0.9.49 fix, troubleshooting-adjacent to the ':' session-path class), but the ACS run-dir builder had reinvented the path and bypassed it — a reader≠writer + Windows-illegal-path bug. | **Fixed (2026-07-13):** `_run_dir` routes the session component through the SAME SSOT (`safe_session_subdir(sessions_base, f"{bridge}:{chat}")`) → the `:` is sanitised to `web_<sid>` on Windows AND the ACS run dir lands UNDER the real session dir (POSIX byte-identical, honours any legacy dir). PLUS robustness fallback (user request): if the ACS run still can't be set up — runtime import failure, an un-creatable run dir (a `sess.workdir/acs/runs` pre-flight `mkdir`), or an empty task — the turn now FALLS BACK to the normal Claude Code OS-turn (which does its own Task-tool delegation) via the same `_quota_fallback` path the ACS-quota-exhausted case already used, instead of surfacing the raw error. Tests: `operator/bridges/shared/test_acs_runtime_windows_path.py` (3 cases) + existing `test_acs_quota_fallback.py`/`test_web_delegation.py` (20) still green. |
| 39 | Fresh install (web): asking for an image hangs ~240 s ("Der erste Versuch ist nach 240 Sekunden ohne Antwort abgelaufen — bekannter Bug bei hängenden Bildgenerierungen"), then the model retries and waits again | `imagegen-zero-config/main.py::generate_image` runs inside a `_run_bounded` thread with `_TOTAL_TIMEOUT_S = 240` — the ultimate backstop the 0.10.37 fix added for a provider stuck past its own httpx timeout (a documented socket-level edge httpx can't interrupt). But 240 s was far too generous, so a stuck free-tier **Pollinations** request (best-effort community service, no SLA) waited out the whole 4 min before failing with a generic timeout the model then blindly retried. A secondary contributor on a cold box: the L44 house-rules classifier (`_house_rules_classify_hermes`) that gates image gen ran the qwen3 model WITHOUT `think:false`, so a cold classify could burn its 30 s timeout on a `<think>` monologue and eat retries. | **Fixed (2026-07-14):** the provider call now runs inside its OWN `_run_bounded` deadline (`_PROVIDER_TIMEOUT_S = 75 s`), so a stuck Pollinations request degrades to the friendly "free service unavailable — add an OpenAI key for reliable generation" message in ~75 s instead of the 4-minute generic timeout (Pollinations legitimately takes ~15–40 s, so a working generation is unaffected). The whole-call backstop drops 240 s → 180 s. The L44 classifier now sends `think:false` on both Ollama payloads (same class as the summarize fresh-install fix) so a cold safety check stays fast — the gate is unchanged in behavior (still fail-closed / degrade-to-floor on any parse failure). Reliable, higher-quality generation still comes from configuring an OpenAI key (zero-config Pollinations is best-effort by design). Tests: `test_imagegen_zero_config.py::test_provider_hang_is_bounded_not_whole_call_timeout` + `test_house_rules.py::test_hermes_classifier_payload_disables_thinking`. |
| 40 | Fresh install: the spoken Voice Summary just reads the raw answer text word-for-word instead of the real condensed summary (with the learnings/metaphors annex) — works fine on an established install | Traced to Claude Code being permanently unauthenticated on fresh installs, for two independent, compounding reasons: (1) the instructed command was the stale `claude login` — the current Claude Code CLI (2.x) only recognises `claude auth login` as a subcommand; the old name silently falls through to being treated as a chat PROMPT instead of erroring, so nobody ever noticed it stopped working (it was wrong across 10+ places in the repo). (2) A non-interactive install (`curl \| sh`, no TTY) unconditionally SKIPPED the login step entirely — nothing ever attempted it. With the CLI backend permanently unavailable, `summarize.py` fell through to Hermes/Ollama, which ALSO frequently misses its 60 s timeout on a fresh install: only the installer's one-off prewarm ever set `keep_alive`, so a model that had gone cold by the user's first real chat (bridge setup, Discord/WhatsApp linking, etc. all happen first) paid a ~22 s cold-load on top of real generation time. When BOTH backends fail, `summarize()` silently falls through to `naive_truncate` — a near-verbatim, whitespace-collapsed copy of the original text ("completeness over length") — with exit 0 and non-empty stdout either way, so this degraded result was invisible in CorvinOS's own logs. | **Fixed (2026-07-14):** `ensure_claude_login()` (`corvinOS/installer/steps/dependencies.py`) now uses the correct `claude auth login` and, in non-interactive installs, actively drives it instead of skipping — launches it in the background, streams its OAuth-URL output live, and polls for credentials up to a bounded 90 s (never hangs the installer if login isn't completed). All other stale `claude login` references repo-wide (engine_detection.py, engines.tsx, docs, etc.) corrected to `claude auth login`. `summarize.py`'s real Hermes calls (`_summarize_via_hermes`, `_ollama_generate`) now set `keep_alive: "30m"`, not just the installer's prewarm. `summarize()` now prints a `[summarize] degraded: ...` sentinel to stderr (never stdout — that would get spoken) when it falls through to `naive_truncate`; `adapter.py::build_voice_summary` captures and logs `summarize.py`'s stderr (previously discarded entirely) and specifically warns on the degraded sentinel. Tests: `tests/test_installer_claude_login.py` (8 cases), `test_summarize.py::test_hermes_payloads_set_keep_alive` + `::test_structural_fallback_prints_degraded_sentinel_to_stderr`, `test_adapter_voice_stripper_fallback.py::test_degraded_fallback_marker_is_logged`. |
| 41 | Voice output in the CONSOLE web chat speaks the raw, full answer verbatim (truncated blindly at 4000 chars) instead of a real condensed voice summary — every messenger bridge (Discord, WhatsApp, ...) speaks a real summary via `build_voice_summary()`, the console did not, on ANY install (fresh or established) | Structural gap, not a fresh-install or environment issue: the console's `POST /v1/console/voice/tts` (`core/console/corvin_console/routes/voice.py`) truncated `body.text` directly and spoke that. A fully-working, tested `POST /v1/console/voice/summarize` endpoint already existed (calls `summarize.py`, same script bridges use) — but the frontend (`web-next/src/pages/chat.tsx`) never called it (confirmed via grep: zero references to `"voice/summarize"` anywhere under `web-next/src`). So every console voice turn spoke `evt.text` (the raw WS result payload) straight through, regardless of length or markdown/code content. Basic audio *playback* itself (autoplay-unlock, MIME sniffing, TTS provider chain) was already correct and well-tested — this was specifically about *what* got spoken, not *whether* anything got spoken. | **Fixed (2026-07-14):** `voice_tts()` now calls a new `_summarize_for_speech()` helper (shells out to `summarize.py`, same script `/voice/summarize` and `build_voice_summary()` already use) BEFORE truncating — the console now speaks the same real, condensed, learnings/metaphor-capable summary bridges do, via one round trip (no frontend change needed). Falls back to the old raw-truncated behaviour if summarization is unavailable or fails (TTS must never break because the optional summarizer did) — failures are now logged (`_log.warning`) instead of silently swallowed. Timeout matches `adapter.py::build_voice_summary`'s own 120 s parent cap (summarize.py's internal CLI+Hermes budget is up to 105 s; a shorter wrapper timeout would routinely cut off a legitimate in-progress attempt). Tests: `core/console/tests/test_voice_tts_summarize.py` (8 cases). |
| 42 | A very large ACS (Autonomous Compute Shell) delegation workflow crashed on a Windows machine with 64GB RAM | Investigated in depth (Windows pipe-deadlock hypothesis, quadratic history-resend, missing total-size caps — see `docs/claude-ref/layer-engines.md`'s "ADR-0104 — ACS delegation: unbounded-memory hardening" section for the full writeup). Confirmed root cause: `acs_runtime.py`'s manager/worker subprocess calls used plain `proc.communicate()`, which reads the ENTIRE stdout/stderr into memory with NO limit — `_MANAGER_OUTPUT_CAP`/`_WORKER_OUTPUT_CAP` existed as constants but were never wired to anything (dead code). Compounded by two further gaps: `max_total_workers` was only enforced BETWEEN dispatch batches, not within one, so a single manager decision could burst up to 100 concurrent, unbounded-output worker subprocesses; and `max_wall_time` had no ceiling clamp (unlike the other budget fields), giving a run more time to compound both. Not a Windows-specific pipe deadlock (`communicate()` is deadlock-safe by construction on every platform) and not a quadratic prompt-resend bug (prompt construction was already correctly bounded). | **Fixed (2026-07-15):** new `_communicate_capped()` helper drains stdout/stderr via reader threads continuously (so the child can never block on a full pipe) but only RETAINS output up to the cap — excess is read-and-discarded, closing the actual peak-memory problem instead of just truncating an already-fully-buffered string. `_dispatch_workers` now clamps the batch itself against remaining `max_total_workers` budget BEFORE creating any tasks. `max_wall_time` is now clamped to `[3600s default, 86400s ceiling]` like the other budget fields. Tests: `operator/bridges/shared/test_acs_runtime.py` (8 new cases covering output capping with real oversized subprocess output, in-batch worker-count enforcement, and the wall-time clamp). |
| 43 | Agent browsing fails on a fresh install — "Gib mir den Text von dieser Seite" loops `der corvin-browser Dienst ist nicht verfügbar → alternativer Playwright-Browser` and produces nothing (Windows / FREE tier). | BOTH browser backends (the corvin-browser MCP, which proxies to the console's own Chromium via `browser/session.py`, and the fallback Playwright MCP) need Playwright's Chromium binary, which a normal install never provisions: `playwright` is the optional `[browser]` extra so a plain `pip install corvinos` omits it, and even with it the ~150 MB `playwright install chromium` was never run. NOT the OpenAI key, NOT the FREE tier (no license gate on browser routes). The launch error was also swallowed into an opaque "not available" with no hint of the fix. | **Fixed (2026-07-18):** new installer step (`corvinOS/installer/steps/browser.py::ensure_browser`, wired as core.py step_8b, reached by both `install.sh` / `install.ps1` via `corvin-install`) installs Playwright + downloads Chromium out of the box — idempotent, fail-soft. And `session.py::start()` now translates the missing-binary / missing-package failure into an ACTIONABLE `BrowserActionError` ("run `playwright install chromium`") that flows route(409) → corvin-browser MCP `detail` → the model, instead of an opaque 500. Manual recovery on a pre-fix install: `corvin-install --browser` (the bare `playwright` CLI is NOT on the PATH of a `uv tool` install — see docs/browser-automation.md for the per-flavor commands). Tests: `tests/test_installer_browser.py`, `core/console/tests/test_browser_automation.py::test_start_translates_*`. |
| 44 | `systemctl --user restart corvin-webui` takes ~45 s and the journal shows `State 'stop-sigterm' timed out. Killing.` / `Killing process … with signal SIGKILL`. Symptoms of the consequence: plugins never get `on_unload()`, queued audit fan-out copies are lost, and in-flight gateway runs stay stuck in `running` state after a restart. | Two independent budget errors. (a) `uvicorn` ran WITHOUT `--timeout-graceful-shutdown`, so it waited **unbounded** for open connections to close — and the console holds long-lived SSE / WebSocket streams that never close on their own, which is why the log sits on "Waiting for connections to close". (b) `TimeoutStopSec=15` in the unit, while the ASGI lifespan's own shutdown asked for up to 35 s (audit fan-out flush 5 s + `dispatcher.drain(timeout=30)`). systemd therefore SIGKILLed mid-cleanup **every time**, so the lifespan `finally` block — the flush, the plugin unloads, the drain — never ran at all. The adapter unit had already learned this lesson (`TimeoutStopSec=120` > its 90 s drain budget, see adapter-runtime.md); the webui unit never had it applied. | **Fixed (2026-07-26):** `core/gateway/systemd/corvin-webui.service` adds `--timeout-graceful-shutdown 10` and raises `TimeoutStopSec` to 45 s; `app.py`'s dispatcher drain drops 30 s → 15 s. Budget now sums to 10 + 5 + 15 = 30 s inside a 45 s allowance. Measured: restart 46 s + SIGKILL → 10 s and a clean `Stopped`. Apply after updating with `bash operator/bridges/bridge.sh install-units` (that subcommand was itself added in the same change — the service template had referenced it since it was written, but it did not exist, so a template edit could previously only be applied via the full `bridge.sh up`). Whenever either budget changes, check the other: the unit's comment and app.py's comment both point at each other. |
| 45 | `bash operator/bridges/run-all-tests.sh` reports ~17 of 261 suites failing on a healthy checkout, most of them `corvin-gateway …` with `ModuleNotFoundError: No module named 'fastapi'`. | `core/gateway/.venv` existed but was half-built — an editable `corvin_gateway` install with none of its dependencies, the state `python3 -m venv` leaves behind when the `python3-venv` package is missing. The suite's guard was `[ -x .venv/bin/python ]`, i.e. "does a file exist", which was true, so it neither skipped nor worked. Worse, the documented repair (`bash core/gateway/bootstrap.sh`) was gated on `[ ! -d "${VENV_DIR}" ]` and therefore skipped creation for exactly this state, then died on `.venv/bin/pip: No such file or directory` under `set -e` — the repair script could not repair the state it exists for. All four `core/*/bootstrap.sh` scripts had it, including `core/console/bootstrap.sh`, which `corvin-webui.service` calls from `ExecStartPre`. | **Fixed (2026-07-26):** every bootstrap script now tests whether the venv WORKS (`python -m pip --version`), recreates a broken one (only when it really looks like a venv), falls back to `uv venv`, tries `ensurepip`, and fails with an actionable message instead of half-way. `run-all-tests.sh`'s guard asks the same question, so a genuinely absent venv still SKIPS instead of failing. It also exports a PYTHONPATH mirroring the systemd unit's, because `corvin_gateway.dispatcher` imports `spawn_gates` bare at module scope — four suites were failing on a condition that does not exist in production (verified: `corvin_gateway.app` imports fine both in the repo and in a fresh wheel install). Remaining known-red: `test_dispatcher.py`'s `test_post_dispatch_completes_via_fake_engine` + `test_free_tier_second_run_blocked` and `test_smoke.py`'s `test_post_sse_webhook_audit`, all three confirmed failing at 1b3e22c as well. |

---

## Reading the logs

### Using bridge.sh tail

```bash
bash operator/bridges/bridge.sh tail
```

This streams combined output from all running processes in a single terminal, with
each line prefixed by its source.

### Log line format

```
[adapter]   INFO     Session started for chat 12345 with persona assistant
[telegram]  INFO     Message received from 123456789: "hello"
[discord]   WARNING  Rate limit hit, retrying in 5s
[whatsapp]  ERROR    Failed to send message: session expired
```

The prefix in brackets tells you which component produced the line:

| Prefix | Component |
|---|---|
| `[adapter]` | The Python adapter (main orchestrator: routing, persona selection, engine spawn, audit) |
| `[telegram]` | The Telegram Node.js daemon |
| `[discord]` | The Discord Node.js daemon |
| `[whatsapp]` | The WhatsApp Node.js daemon |
| `[slack]` | The Slack Node.js daemon |
| `[email]` | The Email daemon |
| `[forge]` | The Forge MCP server |
| `[skill-forge]` | The SkillForge MCP server |

### Log levels

| Level | Meaning |
|---|---|
| `INFO` | Normal operation. No action needed. |
| `WARNING` | Something unusual happened but the system recovered or is degraded, not down. Worth reading but not urgent. |
| `ERROR` | A specific operation failed. The system is running but something did not work (e.g. failed to send a message, failed to parse a command). |
| `CRITICAL` | A structural invariant was violated. Examples: audit chain broken, vault permissions wrong, path-gate self-test failed. Requires immediate attention. |

### What a healthy startup looks like

```
[adapter]   INFO     Corvin adapter starting (version 0.9.x)
[adapter]   INFO     Self-test: all checks passed
[adapter]   INFO     Audit chain: 157 events, chain verified
[adapter]   INFO     Plugin: forge MCP server loaded
[adapter]   INFO     Plugin: skill-forge MCP server loaded
[adapter]   INFO     Uvicorn listening on 127.0.0.1:8765
[telegram]  INFO     Telegram daemon started
[telegram]  INFO     Bot @MyCorvinBot connected (username: mycorvinbot)
[discord]   INFO     Discord daemon started
[discord]   INFO     Logged in as MyBot#1234
[adapter]   INFO     Bridge adapter ready
```

### Common error patterns

**Token invalid:**
```
[telegram]  ERROR    Telegram API returned 401 Unauthorized — token is invalid or revoked
```
Fix: regenerate the token at @BotFather and update `settings.json`.

**Plugin not registered:**
```
[adapter]   WARNING  Forge MCP server not found — forge tools unavailable
```
Fix: run `claude plugin install voice@corvin-voice-local`.

**Claude Code not found:**
```
[adapter]   CRITICAL engine.claude_cli check failed: claude not found on PATH
```
Fix: see row 2 in the symptom table above.

**Audit chain issue:**
```
[adapter]   CRITICAL Audit chain verification failed at event 42 — hash mismatch
```
Fix: see row 10 in the symptom table above.

**ffmpeg missing (voice notes send as text):**
```
[adapter]   WARNING  ffmpeg not found — TTS output will be sent as text fallback
```
This should be rare: `_resolve_ffmpeg_bin()` falls back to the bundled
`imageio-ffmpeg` static binary automatically when no system ffmpeg is on
PATH, so edge-tts/Piper work out of the box on every platform (including a
fresh Windows install, where the installer intentionally skips installing
system ffmpeg). If you still see this warning, see row 7 above.

### Filtering logs to a specific component

If you only want to see adapter output:

```bash
bash operator/bridges/bridge.sh tail 2>&1 | grep '^\[adapter\]'
```

For real-time error monitoring:

```bash
bash operator/bridges/bridge.sh tail 2>&1 | grep -E 'ERROR|CRITICAL'
```
