44 KiB
Gateway Operations & Monitoring
Canonical notes for the always-on gateway daemon (packages/gateway) and its automated health checks.
Extension scope (context-local only)
The gateway pi extension is now context-local, not global:
- Canonical source:
pi/extensions/gateway/index.ts(repo-tracked) - Active install path:
~/.joelclaw/gateway/.pi/extensions/gateway(symlink to canonical source) - Global path
~/.pi/agent/extensions/gatewaymust stay absent
Daemon startup now enforces this invariant:
- missing local extension → startup fails
- global gateway extension present → startup fails
This prevents non-gateway pi sessions from loading gateway automation hooks.
Manual CLI checks
joelclaw gateway status
joelclaw gateway diagnose --hours 1 --lines 120
joelclaw gateway test
joelclaw gateway events
joelclaw gateway restart
joelclaw gateway enable
joelclaw gateway known-issues
joelclaw gateway mute imessage --reason "imsg-rpc reconnect instability"
joelclaw gateway unmute imessage
Use diagnose first; it runs process/Redis/log/e2e/model checks in one pass.
Thread-oriented demand context (ADR-0237)
Demand-driven context gathering in pi/extensions/gateway/index.ts now prefers conversation_threads over flat slack_messages / email_threads snippets.
Current operator-facing priorities are:
- project momentum — recent threads already linked to active projects
- relationship threads — important people, email threads, and anything explicitly waiting on Joel
- momentum risks — vault gaps or
needs_joelthreads that imply dropped balls, missing capture, or stalled progress
Contract:
- query
conversation_threadsfirst - keep
slack_messages+email_threadsonly as warm-up fallback while the new collection backfills - format summaries for actionability, not transcript archaeology
- only inject these sections for real operator turns, preserving ADR-0235's silent accumulation model
Gateway context refresh scoping (2026-03-09)
ADR-0204 rolling context refresh in pi/extensions/gateway/index.ts must stay scoped to real conversational topics, not automated gateway envelopes.
Observed failure mode:
- hidden
context-refreshcustom messages injected unrelated memory into the live gateway session - session transcripts showed garbage from unrelated voice/livekit work inside the gateway session
- root cause was the refresh path harvesting topic seeds from automated digests (
## 📋 Batch Digest,## 🔔 Gateway,> ⚡ Automated gateway event, recovery messages, terseNoted.replies) and then running broad global recall queries
Current contract:
- only seed
gwRecentTopicsfrom non-automated message content - skip terse acknowledgements and gateway-generated recovery/context blocks
- if there is no scoped topic seed, skip rolling recall instead of querying generic global memory
- compaction recovery may reuse cached scoped recall, but must not invent fresh generic recall context
- boot-time
memory-enforcerretrieval for the gateway session must also use a gateway-scoped recall query (gateway daemon telegram redis session routing compaction) instead of the generic interactive recall query - hidden startup recall should filter obvious meta-junk (
## Plan Summary, "Should I:", etc.) so the gateway does not inject procedural transcript sludge as memory
This is a safety fix: better to inject no extra memory than poison the gateway session with unrelated context.
Embedded pi dependency skew + prompt-budget guard (2026-03-09)
Two real gateway failures were fixed together:
-
Embedded pi deps were stale inside
packages/gateway/- The gateway package was still pinned to
@mariozechner/pi-ai/@mariozechner/pi-coding-agent0.52.12. pi --versionon the machine can say0.57.xand still not reflect what the gateway daemon imports at runtime.- Symptom: fallback recovery probes spam
model_fallback.probe_failedwithpi model not found: openai-codex/gpt-5.4even though upstream pi already supports GPT-5.4. - Fix: keep
packages/gateway/package.jsonaligned with the actual pi-mono model catalog when gateway model/fallback policy changes. - Guardrail: if the active primary model already equals the configured fallback, the daemon now remaps fallback to a distinct compatible model instead of silently running with a no-op fallback.
- The gateway package was still pinned to
-
Prompt budget now gets checked before dispatch, not only after provider rejection
- The command queue now runs a pre-dispatch maintenance hook before
session.prompt(). - If the projected prompt budget would land near the model ceiling, the gateway compacts first.
- If the session age already crossed the rotation limit or the projected prompt would still be too close to the ceiling, the gateway rotates to a fresh session first and seeds it with the compression summary.
- This is meant to stop repeated
prompt is too longpoison-loop failures before the provider has to say it.
- The command queue now runs a pre-dispatch maintenance hook before
-
Resumed sessions can carry stale model state across daemon restarts
- Redis config is the operator contract, but a resumed pi session can still come back on the last fallback/manual model if startup does not explicitly reconcile it.
- Symptom: gateway looks healthy but keeps behaving like the wrong model, fallback decisions stop matching Redis config, and session-pressure context windows can be computed against stale/default values.
- Fix: daemon startup now reconciles the resumed session back to the requested primary model before fallback control initializes, and context-window checks resolve from the active model registry when the live session object omits that field.
Operator note: when diagnosing fallback weirdness, check both the machine pi --version and the versions pinned in packages/gateway/package.json. If those drift, the daemon can lie about model availability. Also compare Redis gateway config to the actual live session model after restart — a resumed session can otherwise preserve stale model state and make the daemon look sane while running the wrong thing.
Redis-degraded mode (ADR-0214, 2026-03-06)
Gateway runtime now distinguishes availability from Redis health.
joelclaw gateway status reports:
mode: normal— Redis bridge healthymode: redis_degraded— daemon/session/channels still available, but Redis-backed capabilities are degraded
In redis_degraded mode:
- direct channel conversation stays online
gateway statusfalls back to daemon health instead of pretending the gateway is deadgateway diagnosemarks the Redis bridge as degraded and skips Redis-dependent E2E testing- degraded capabilities are listed explicitly (event bridge, replay, Redis-backed operational commands, Telegram poll-owner lease durability)
Session pressure is now surfaced in status payloads as first-class data:
- context usage %
- queue depth
- last compaction age
- session age
- next action (
observe/compact/rotate) - next threshold summary (
compact at 65% .../rotate at 75% .../rotate immediately) - thread counts (
active/warm/total) - fallback state + activation count + consecutive prompt failures
- pressure signals (
context_usage,context_ceiling,compaction_gap,session_age) - alert state (
lastNotifiedHealth,lastNotifiedAt, cooldown)
joelclaw gateway diagnose now includes a dedicated session-pressure layer instead of burying pressure state inside generic status findings.
The daemon emits OTEL under daemon.session-pressure (session_pressure.alert.suppressed|failed). Session-pressure states do not page Telegram; rotation/compaction pressure stays in status/diagnose/OTEL because it is gateway maintenance, not Joel action.
Idle maintenance is now autonomous for time-based pressure too:
- watchdog evaluates session pressure even when no turn is active
- if the idle session crosses
compaction_gap, the daemon runs compaction without waiting for another message - if the idle session crosses
session_ageand rotation is the next action, the daemon rotates to a fresh session with the compression summary before the next inbound turn arrives - those idle maintenance runs emit the same
daemon.maintenance.started|completed|failedlifecycle telemetry withsource: watchdog
Operator rule: if status says redis_degraded, do not treat that as a full gateway outage. Diagnose substrate/Redis separately while using direct conversation paths if needed.
Interruptibility and supersession (ADR-0196 / ADR-0218 rank 4)
Latest direct human turns now win by contract across Telegram, Discord, iMessage, and Slack invoke paths.
Current runtime contract:
- direct human turns get a short
1.5sbatching window before dispatch so rapid follow-ups land as one prompt - batching is keyed per source (
telegram:<chat>,discord:<thread|dm>,imessage:<chat>,slack:<channel[:thread]>) - if the source is already active, the gateway does not wait on the batch timer — it supersedes immediately
- queued stale prompts for that source are dropped
- durable queue replay must not self-drop the freshest human message; only genuinely newer same-source messages may supersede it
- the daemon requests
session.abort()on the stale active turn - stale response output is suppressed instead of being delivered after the newer ask
- Telegram, Discord, Slack, and iMessage get a short supersession acknowledgement when possible
- passive intel / background event routes are excluded from the human batching path
joelclaw gateway status now exposes supersession with both latest-wins state and batching state:
activeRequest/lastEvent— supersession details for the stale active turnbatching.windowMs— current batch windowbatching.pendingCount/batching.pendingSources— human sources currently being held before dispatchbatching.lastFlush— most recent batched flush source/time/message count
joelclaw gateway diagnose adds an interruptibility layer that shows whether a supersession is active plus current/last batching details.
Current boundary of the slice:
- shipped: cross-channel human latest-wins supersession + batching window + stale-response suppression
- still open: richer interruptibility coverage for non-message operator actions
Operator ack/timeout tracing (ADR-0218 rank 5)
Telegram operator actions now get trace ids and an explicit lifecycle instead of blind button toasts or silent slash-command hangs.
Current runtime slice covers:
- command-menu callbacks (
cmd:*) - worktree callbacks (
worktree:*) - ADR pitch callbacks (
pitch:*) - default Telegram callback actions and external callback-route handoffs
- direct Telegram slash commands registered through the command handler
- native Telegram
/stop,/esc, and/killoperator commands
For those paths, the gateway now tracks:
traceIdkind(callback/command)- ack state (
pending/succeeded/failed) - dispatch/completion/failure timestamps
- timeout state (
15sdefault, longer for downstream agent/external callback paths) - route + chat/message metadata
Operator surfaces:
joelclaw gateway statusexposes canonicaloperatorTracingcallbackTracingremains as a compatibility alias for the same snapshotjoelclaw gateway diagnoseadds anoperator-tracinglayer- timeout/failure paths send an explicit Telegram follow-up with route + trace id instead of silently relying on spinners or missing command replies
Current deeper rank-5 slice now also carries queued Telegram agent-command trace ids through downstream gateway execution:
- queued
/commandandcmd:*agent executions keep the same trace id through queue dispatch - traces complete on downstream turn completion instead of lying at enqueue time
- prompt failure, assistant error, and supersession paths fail the trace explicitly
- agent-backed command traces use a longer timeout window (
120s) because downstream execution is real work, not just callback ack latency
External callback-route consumers now also have a real completion handoff path:
- routed external callbacks stay active after the gateway publishes them instead of being marked complete immediately
- the gateway advertises a Redis trace-result channel with the routed callback payload
- downstream consumers can publish
completed/failedback with the sametraceId - the in-tree Restate Telegram route now uses that handoff, so external callback traces close on real downstream resolution instead of publish-time fiction
- timeout stays terminal if the external consumer never reports back
Still open: any out-of-tree external callback consumer has to adopt the same trace-result handoff or it will still timeout as untracked work.
Channel runtime contracts (ADR-0218 rank 6)
Gateway status now exposes a canonical channels surface so Telegram ownership semantics stop being a bespoke one-off.
Current runtime slice exposes per-channel runtime snapshots for:
telegram— configured/started/healthy, owner state, polling state, retry attempts, conflict streak, last lease statusdiscord— configured/started/healthy, ready state, bot user idimessage— configured/started/healthy, connected state, reconnect attempts/delay, healing stateslack— configured/started/healthy, connected state, bot/allowed user ids
Operator surfaces:
joelclaw gateway statusexposes rawchannelsplus summarizedchannelHealthchannelHealthcarries degraded/muted channel lists plus last degrade/recover event and per-channel transition timestampsjoelclaw gateway diagnoseadds achannel-healthlayer with current contract state, muted known issues, and last alert event/healthcomponents now reflect per-channel contract state instead of just a Telegram boolean- Telegram
fallbackwithleaseEnabled=falseis expected local mode, not a degraded owner contract
Daemon behavior now includes immediate channel-health transition handling:
- detect configured channel
healthy ↔ degradedtransitions - emit OTEL under
daemon.channel-health(channel_health.state.changed,channel_health.alert.sent|suppressed|failed) - send direct Telegram degrade/recover alerts unless that channel is muted as a known issue
- reuse the same known-issues keys as the autonomous monitor (
gateway:health:muted-channels,gateway:health:mute-reasons)
The deeper rank-6 slice now adds active heal policy state:
- each channel carries
healPolicy(restart/manual/none) andhealReason channelHealth.healingexposes degraded streak count, cooldown, attempts, last heal result, plusmanualRepairRequired,manualRepairSummary, andmanualRepairCommandswhen the watchdog cannot fix the channel itselfjoelclaw gateway diagnoseadds achannel-healinglayer and now spells out manual repair steps instead of vaguearmedwording- watchdog attempts guarded restarts for restart-eligible degraded channels after
2consecutive degraded checks with a10mcooldown - ownership/lease problems (for example Telegram passive poll ownership or retrying
getUpdatesconflicts) stay visible asmanualinstead of triggering dumb restart churn - degraded channels that are muted as known issues now also flip to
manualinstead of quietly advertising a restart policy that the watchdog will never actually execute while muted - Telegram retry/conflict states no longer read as healthy local fallback:
/health,gateway status, andgateway diagnosenow degrade the contract when polling is down and only retrying
Current boundary of this rank-6 slice:
- shipped: reusable channel runtime health/ownership snapshots, immediate degrade/recover alerting with known-issue suppression, guarded per-channel heal policy state with restart attempts for restart-eligible channels, and explicit manual repair guidance for manual-policy degradations
- still open: stricter single-owner semantics beyond Telegram and any richer/native repair automation beyond CLI-guided operator steps
Telegram multi-instance polling ownership (2026-03-05)
Gateway Telegram ingress now uses a Redis lease so multiple gateway instances can coexist without all trying to long-poll the same bot token.
- Poll owner lease key:
joelclaw:gateway:telegram:poll-owner:<tokenHash> - Only the lease owner starts
getUpdatespolling. - Non-owners stay in passive/send-only mode and retry lease acquisition with backoff.
- Lease owner renews periodically; on lease loss it stops polling and re-enters passive mode.
- Poll status key:
joelclaw:gateway:telegram:poll-status:<tokenHash>(owner/passive/fallback/stopped snapshots)
Telemetry:
telegram.channel.poll_owner.acquiredtelegram.channel.poll_owner.passivetelegram.channel.poll_owner.retry_scheduledtelegram.channel.poll_owner.losttelegram.channel.poll_owner.fallback
Conflict guard remains in place:
- If Bot API returns
409: Conflict: terminated by other getUpdates request, gateway retries with exponential backoff (telegram.channel.retry_scheduled) instead of one-shot disable. telegram.channel.start_failedcarries retry metadata (attempt,retryDelayMs,conflict,pollLeaseOwned).- Recovery after transient conflicts emits
telegram.channel.polling_recovered.
Important: Telegram phone/desktop clients are not Bot API pollers. getUpdates contention only happens between bot processes using the same token.
Redis reconnect hardening (2026-03-05)
Gateway lockups during Redis link flaps were caused by MaxRetriesPerRequestError storms from ioredis command clients. Hardening now in place:
- Redis channel clients use
maxRetriesPerRequest: nullto avoid command-queue flush storms during reconnect churn. - Gateway mode reads/writes fail open (
active) with warn-level OTEL (mode.read.failed,mode.write.failed) instead of throwing. - Heartbeat interval wraps
tickHeartbeat()in a local try/catch so transient Redis failures cannot leak as unhandled promise rejections. - Duplicate daemon-level
unhandledRejectionhandlers were collapsed to one. It now suppresses known TUI-only noise (Theme not initialized) and rate-limits repeated Redis max-retry rejections intodaemon.redis.max_retries_rejectiontelemetry.
Result: Redis transport blips may degrade event throughput briefly, but should no longer wedge the process or spam unbounded unhandled-rejection logs.
Behavior control plane (ADR-0211)
Gateway behavior preservation is now deterministic and operator-controlled.
Control lane (runtime-authoritative)
joelclaw gateway behavior add --type keep|more|less|stop|start --text "..."
joelclaw gateway behavior list
joelclaw gateway behavior remove --id <directive-id>
joelclaw gateway behavior apply
joelclaw gateway behavior stats
- Active contract is stored in Redis (
joelclaw:gateway:behavior:contract). - Runtime injection reads Redis on each turn and injects:
<GATEWAY_BEHAVIOR_CONTRACT version="..." hash="...">
- KEEP: ...
- LESS: ...
</GATEWAY_BEHAVIOR_CONTRACT>
- Injection placement is deterministic: before
# Rolein prompt assembly (below identity, above role). - Injection telemetry emits
behavior.contract.injectedwithbehavior_contract_hash.
Capture lane (extension -> CLI only)
Gateway extension passively scans operator prompts for:
KEEP: ...MORE: ...LESS: ...STOP: ...START: ...
On match it shells to joelclaw gateway behavior add ....
It does not write Redis/Typesense directly.
Learning lane (advisory only)
A daily Inngest cron (gateway/behavior.daily-review) analyzes last 24h gateway sessions + OTEL and writes good_patterns / bad_patterns candidates to Typesense (gateway_behavior_history, kind=candidate, status=pending).
- No auto-activation.
- Operators must promote manually:
joelclaw gateway behavior promote --id <candidate-id>. - Stale pending candidates expire automatically via TTL governance.
Slack passive firehose prerequisites
The launchd start script now derives Slack routing env vars at boot from existing Slack secrets:
slack_user_token→auth.test→SLACK_ALLOWED_USER_IDslack_bot_token+ resolved user ID →conversations.open→SLACK_DEFAULT_CHANNEL_ID
This removes dependency on non-existent slack_allowed_user_id / slack_default_channel_id secrets and restores passive firehose routing after restarts.
Joel-authored non-mention Slack channel messages now enter the canonical gateway signal pipeline as Redis events (slack.signal.received) instead of freelancing a direct gateway turn. That means Slack passive intel and email now share the same relay heuristics:
- normalize into one signal shape
- score with explicit rules (VIP sender, Joel signal, action/decision keywords, project cues, low-signal/noise patterns)
- correlate by project/contact/conversation keys
- route as
immediate,batched, orsuppressed
Canonical implementation lives in packages/gateway/src/operator-relay.ts.
Current contract:
vip.email.receivedis ingested by relay policy so the gateway keeps correlation context without announcing a duplicate operator alert after the VIP pipeline delivers the richer brief directly to Telegram- raw
front.message.receivedno longer pages by default; it pages only for production/security/money failures or a human/project direct ask - lower-signal project/person email and passive Slack intel batch into correlated signal digests instead of paging immediately
- obvious email noise (
newsletter,unsubscribe, shopping/restock/cart, bot review-in-progress, weekly summaries) is suppressed before it reaches the gateway session - meta-system chatter (
gateway.*, session pressure, channel degradation, knowledge watchdog drift, friction/check-gateway-health noise) is suppressed from Telegram; it remains available through status/diagnose/OTEL/logs - low-signal
recoveredautomation events are suppressed instead of paging Telegram just because a daemon became healthy again - digest prompts must ask for an operator brief, not
HEARTBEAT_OKsludge - outbound operator relay strips leaked
HEARTBEAT_OKprefixes from non-heartbeat content before Telegram delivery - if Redis is unavailable, Joel-authored Slack passive intel falls back to direct enqueue rather than disappearing
Slack important-channel intelligence
The Slack live listener still only invokes the gateway for Joel DMs, explicit bot mentions, and tracked mention threads. Important channels are different: they are collected, not auto-answered.
Configure selected channels with private runtime env vars:
SLACK_IMPORTANT_CHANNEL_IDS— comma-separated Slack channel IDs; preferred because IDs survive renamesSLACK_IMPORTANT_CHANNEL_NAMES— comma-separated names or#names; fallback for local/dev use
For messages in important channels:
- every non-bot message is indexed through
channel/message.receivedforchannel_messages/conversation_threadscontext - every non-bot message is queued as
slack.signal.receivedwithpassiveIntel: trueandimportantChannel: true - Joel-authored messages also carry
joelSignal: true - relay policy batches normal important-channel chatter, escalates only when score crosses the immediate threshold, and suppresses low-signal noise
- if Redis is unavailable, non-Joel important-channel messages stay index-only rather than directly invoking the gateway session
This gives the gateway working memory of important channels without turning Slack into an operator notification sewer pipe.
If either derivation fails, startup logs explicit warnings in /tmp/joelclaw/gateway.log so degraded Slack behavior is visible immediately.
Gateway now exposes GET /health/slack on port 3018 (same Bun server as the WS bridge). It returns:
200when Slack channel is started503when Slack channel is not started
Talon dynamic services now probe this endpoint via http.gateway_slack in ~/.joelclaw/talon/services.toml with critical = true and critical_after_consecutive_failures = 3 (debounced alerting for brief reconnect churn).
Slack channel-name resolution now classifies channel_not_found as a permanent resolve error and applies long cooldown (slack.channel.resolve_unavailable) instead of spamming transient resolve_failed warnings.
Restart race hardening: daemon shutdown now removes PID/WS/session files only when the file still belongs to that process. This prevents old-process cleanup from deleting newly written marker files during fast restarts.
Gateway process diagnostics now use exact launchd state inspection (launchctl print-disabled + launchctl print gui/<uid>/com.joel.gateway) so disabled launch agents are reported explicitly. joelclaw gateway restart now re-enables com.joel.gateway before bootstrap/kickstart, so a disabled service can recover via the normal restart command.
joelclaw gateway enable is a direct launch-agent recovery command: enable service, bootstrap plist, kickstart daemon, then report pid/state.
Outbound media payload support
gateway/send.message now supports optional media fields in addition to text/keyboard:
media_url(remote URL)media_path(local file path)mime_type(routing hint, e.g.image/png,video/mp4,audio/ogg)caption(optional media caption)
Gateway daemon outbound drain now checks media payloads and calls channel.sendMedia() when available. Channels without sendMedia() fall back to text delivery with the media link/path. Telegram routes media by MIME type (image/* → photo, video/* → video, audio/* → audio/voice for audio/ogg, else document).
Watchdog hardening: when a turn is stuck for >10 minutes, the daemon now aborts once and starts a recovery grace timer (90s). If no recovery signal (turn_end or next prompt dispatch) arrives before the deadline, the daemon self-restarts via launchd. This prevents the "process alive but session wedged" state where queues stop draining indefinitely.
Stuck detection now only runs while the queue is actively waiting for turn_end (idleWaiter pending). If the idle waiter itself times out (5 minutes safety valve), the daemon emits daemon.watchdog:watchdog.idle_waiter.timeout, releases the drain lock, marks the turn as ended, and clears pending stuck-recovery state. This prevents stale prompt markers from causing repeated false watchdog.session_stuck restart loops.
Prompt dispatch tracking now starts after session.prompt() successfully accepts the prompt (instead of before the call). This prevents immediate auth/model rejection failures from being misclassified as "stuck turn" incidents.
Gateway primary standard: the gateway agent primary model is openai-codex/gpt-5.5. Startup env (~/.joelclaw/scripts/gateway-start.sh) sets PI_MODEL_PROVIDER=openai-codex and PI_MODEL=gpt-5.5; Redis config key joelclaw:gateway:config should store model: "gpt-5.5". The inference-router catalog allows explicit GPT-5.5 gateway config while keeping older codex aliases on GPT-5.4 for non-gateway compatibility.
Fallback standardization guard: gateway fallback remains openai-codex/gpt-5.4 so fallback is not identical to the GPT-5.5 primary. If Redis still has legacy Anthropic fallbacks (claude-sonnet-4-6 or claude-sonnet-4-5), daemon startup remaps to codex and emits daemon.fallback:fallback.model.remapped.
Opus timeout floor guard: when the primary model is claude-opus-4-6, gateway config now floors fallbackTimeoutMs to 240000 even if Redis still says 120000. ADR-0091 already recorded real Opus first-token latency beyond 120s, so 120s had become a stale SLA that caused avoidable fallback churn.
Aborted-turn monitor guard: message_end with no text (especially stopReason: aborted) now clears the fallback timeout watch immediately instead of waiting on a later turn_end that may never arrive. Without this, aborted turns could poison _promptDispatchedAt and make the next successful turn inherit absurd fake prompt.latency / fallback timing.
Fallback decision telemetry guard: fallback control now emits structured model_fallback.decision events alongside the coarse swap/probe actions. Activation reasons are bucketed (timeout, consecutive_failures, rate_limit, provider_overloaded, etc.), recovery probes record probeCount, and probe failures include error_kind plus backoff_ms so OTEL can tell the difference between a legitimately sick provider and a noisy control loop.
Fallback watchdog grace guard: when ADR-0091 fallback has just activated, the watchdog now gives the swapped model a short grace window before declaring the session dead on the same consecutive-failure counter. This stops auth/provider failures from triggering fallback activated and watchdog.session_dead in the same breath, which previously restarted the daemon before fallback could earn a successful turn.
Recovery probe backoff guard: when the primary model recovery probe fails for transient/persistent reasons, the gateway now backs off future probes instead of mindlessly retrying every interval. This borrows the OpenClaw pattern of “don’t keep probing the same sick provider in the same failure window” and cuts swap↔probe churn without disabling recovery entirely.
No-op fallback guard: if primary and fallback resolve to the same model ID/provider, fallback swapping is disabled for that session (no swapped/primary_restored churn). The daemon emits daemon.fallback:fallback.disabled.same_model once at startup so operators can spot misconfiguration without alert spam.
Model-failure ping guard: queue-level model failures (auth, missing API key, rate-limit/overload, model-not-found, network unavailable) now send an immediate operator alert to the default channel (Telegram currently). Generic failures also alert when consecutive prompt failures reach 3. Alerts are cooldown-limited per reason/source (2 minutes) and emit OTEL events under daemon.alerting (model_failure.alert.sent|suppressed|failed).
Pi-session Langfuse guardrails (alert-only)
The pi/extensions/langfuse-cost extension now tracks per-session LLM call count, token totals, and cumulative cost (when usage payloads include cost fields).
- Guardrails emit
console.warn(...)on first breach per threshold type. - Guardrails are alert-only: no auto-stop, no auto-downgrade, no forced compaction.
Tune thresholds with environment variables on the gateway process:
JOELCLAW_LANGFUSE_ALERT_MAX_LLM_CALLS(default120)JOELCLAW_LANGFUSE_ALERT_MAX_TOTAL_TOKENS(default1200000)JOELCLAW_LANGFUSE_ALERT_MAX_COST_USD(default20)
Restart the gateway after changing threshold env vars.
Runtime dependency note: pi/extensions/langfuse-cost resolves the optional langfuse package from the repo root, not from a workspace package. If root install drift drops langfuse from package.json / node_modules, the gateway will log langfuse-cost: cannot load optional dependency 'langfuse'; telemetry disabled. until the root dependency is restored.
Secret fallback note: shared joelclaw Langfuse loaders now shell to secrets lease --json and only accept result.value. This is deliberate — when the agent-secrets daemon is down, secrets lease prints an ok:false JSON error envelope to stdout and still exits 0. Raw stdout must never be trusted as LANGFUSE_HOST / LANGFUSE_BASE_URL or credential material.
Gateway operator steering cadence
The gateway role prompt (roles/gateway.md) requires proactive steering check-ins during active work:
- one short check-in at start
- another every ~60–120 seconds while work is still active
- never more than 2 autonomous actions in a row without a check-in
- immediate check-in on state changes (delegated, blocked, recovered, done)
- if behavior looks frenzy/noisy, stop and request steering before continuing
Keep updates concise for mobile Telegram reading.
Runtime guardrail enforcement (ADR-0189, 2026-03-06)
Prompt guidance is no longer the only line of defense.
Runtime guardrails now add two daemon-level tripwires:
- Tool-budget checkpoint tripwire
- channel turns get a budget of 2 tool actions before a forced status checkpoint
- background/internal turns get a budget of 4 tool actions before a forced checkpoint
- the daemon sends the checkpoint directly to Telegram and emits OTEL under
daemon.guardrails
- Post-push deploy verification
- after a successful
git pushwhose latest commit touchedapps/web/or root config (turbo.json,package.json,pnpm-lock.yaml) - the daemon schedules
vercel ls --yes 2>&1 | head -10after ~75 seconds - failures page the operator and emit
guardrail.deploy_verification.failed
- after a successful
joelclaw gateway status exposes the live guardrail state under guardrails, including whether a checkpoint already fired this turn and any pending deploy verifications.
Session lifecycle guards (ADR-0213)
Three guards prevent context bloat and overnight fallback thrash:
Compaction circuit breaker (4h max gap)
After every turn_end, if >4 hours since last compaction, force session.compact() regardless of token count. Prevents the scenario where context grows unchecked when pi's auto-compaction misses (e.g. model_change entries disrupting threshold calculation).
Session age limit (8h max)
After every turn_end, if session is >8 hours old, create a fresh session with compression summary. Prevents multi-day JSONL growth before fallback thrash starts. Session recycle is no longer an operator-facing Telegram notice by default; it stays in logs/OTEL/status unless a higher-signal failure path escalates it.
Quiet hours auto-batching (11 PM – 7 AM PST)
During quiet hours, all non-interactive events are batched (not immediate). Batch digest flush is deferred until wake hours. Human messages (telegram, imessage, etc.) and error events always process immediately.
Additional low-signal guards now apply:
restate/restate/*sources count as automation for batching, so successful queue-dispatch DAG completions do not hit the live gateway session immediatelytest.gateway-e2eis suppressed from operator delivery by default (internal probe, not a human-facing notification)cron.heartbeatevents are excluded from operator signal digests entirely, even when mixed with real project signals, so digests stop growing junkmisc:cron.heartbeatsections- low-signal-only digests (for example heartbeat-only or queue-dispatch-complete-only batches) are dropped instead of prompting the model just to say
HEARTBEAT_OK - gateway heartbeat treats Talon reachability as advisory telemetry, not operator-facing degradation by itself; local gateway health still escalates, Talon-only misses stay in logs/OTEL
- fallback swap/recovery notices no longer page Telegram during quiet hours, and routine recovery notices are log/OTEL-only instead of operator spam
- direct operator-only
Knowledge Watchdog Alertmessages are always suppressed; degraded turn-write accounting is system maintenance and stays in OTEL/logs unless a higher-signal user-work path escalates it - proactive compaction now has hysteresis: after a successful proactive compact, the gateway waits for either a 30m cooldown or a meaningful usage jump before compacting again
- fallback recovery now requires a minimum dwell on the fallback model before probing primary again, which prevents immediate swap→restore chatter when a recovery probe tick lands right on top of a fresh fallback activation
- maintenance windows (compact/rotate) are now first-class runtime state: the daemon emits
daemon.maintenance.started|completed|failed, surfaces maintenance activity in gateway status, and watchdog/idle-wait logic treats active compaction/rotation as busy work instead of a dead turn - idle waiter now uses bounded maintenance extensions: if compaction/rotation is genuinely in flight when the normal
turn_endsafety valve would fire, the drain lock extends in 60s slices up to a 15m aggregate ceiling before giving up
subscription.updated events are rendered as dedicated "📡 Feed Update" automated messages instead of being folded into batch digests, so feed changes stay visible even when digest-only pressure controls are active.
Incident context (2026-03-05): Without these guards, the gateway entered a thrash loop: 92 fallback activations, 83 timeouts, 128 model swaps over 11 hours. Root cause: 12h without compaction → context bloat → Opus first-token > 120s → positive feedback loop.
Availability-first posture (ADR-0189 related)
Gateway operation is orchestration-first, not execution-first:
- stay highly available and interruptible
- avoid heads-down implementation/research in the gateway session
- delegate heavy work immediately, then monitor + report progress
- keep check-ins frequent while delegated tasks are running
- include/suggest required skills in delegation prompts for domain work
If the gateway starts doing long solo work, that is a role failure and should be corrected immediately.
Message-class triage and operator routing (ADR-0189 related)
Gateway handles two distinct inbound classes:
- User/operator messages (Joel direct chat)
- System/automation messages (
## 🔔,## 📋,## ❌,## ⚠️,## VIP)
Routing rule:
- do not forward all system traffic to operator
- escalate to operator only for high-signal/action-required states (blocked flows, repeated unresolved failures, security/safety concerns, or explicit decision points)
- low-signal/transient system chatter is triaged/logged/monitored without operator interruption
This keeps operator channel high signal while preserving autonomous handling.
Role resolution (gateway/system/interactive)
Gateway sessions run with GATEWAY_ROLE=central. System sessions can set JOELCLAW_ROLE=system (or another role alias). The identity-inject extension resolves the role file in this order:
JOELCLAW_ROLE_FILEenv override (explicit path)JOELCLAW_ROLEalias (mapssystem→~/.joelclaw/roles/system.md,<name>→~/.joelclaw/roles/<name>.md)~/.joelclaw/roles/gateway.mdwhenGATEWAY_ROLE=central- fallback to
~/.joelclaw/ROLE.md
If the selected role file is missing, it falls back to ROLE.md.
Identity files are reloaded on each session_start, so role changes apply on the next session without requiring a process restart.
Startup logs include rolePath=... in the [identity-inject] line so role selection is explicit.
System-session relay packets
System sessions should treat the gateway as the operator relay, not as another mailbox.
- use
joelclaw notify send ...for operator-facing progress, status, and reports - use
joelclaw mail ...for agent coordination, file reservations, and handoffs - relay packets from
systemsessions should include: session handle (when present), current objective, touched surfaces, interesting memory/slog references, desired improvements, blockers, and next move - gateway applies ADR-0189 routing policy before anything reaches Joel; low-signal packets may be summarized or suppressed
This keeps the transport split clean: mail for agents, notify for operator relay.
Telegram reply routing guard
The daemon now uses a three-layer source resolution strategy for message_end routing:
- active queue source (
getActiveSource()) - source captured during
message_start/text deltas - recent prompt source recovery (30s window, channel-like sources only)
This protects against late assistant segments that arrive after the active source is cleared (observed once at daemon.response.source_fallback_console).
- goal: prevent accidental
source: "console"fallback for Telegram-origin turns - impact: avoids short trailing replies being dropped by console-channel suppression rules
- telemetry:
daemon.response.source_recovered_recent_prompt(info) when recovery path is useddaemon.response.source_fallback_console(warn) only when a recent channel-origin prompt exists but routing still collapses to consoledaemon.response.source_console_no_context(info) for expected startup/background console turns (observable but non-paging)
Background turn attribution telemetry
To diagnose unsolicited/autonomous user-facing turns without behavior gating, gateway now emits attribution telemetry across classification → dispatch → response → console forwarding:
redis-channel:events.triaged(debug)- counts + per-bucket reason counters (
immediate|batched|suppressed) - per-bucket event type counts (
immediateTypes,batchedTypes,suppressedTypes)
- counts + per-bucket reason counters (
redis-channel:events.dispatched(info)source,sourceKind,originSession,eventTypes,backgroundOnly
redis-channel:events.dispatched.background_only(debug)- emitted when a dispatch has no human/interactive event in the actionable set
daemon.response:response.generated(debug)- source attribution at response synthesis time (
hasActiveSource,hasCapturedSource, recent prompt age)
- source attribution at response synthesis time (
daemon.response:response.generated.background_source(debug)- response source resolved to internal/background (
gateway|console)
- response source resolved to internal/background (
daemon.outbound:outbound.console_forward.*...skipped(reasons:no-telegram-config,source-is-telegram,filtered-by-forward-rule)...suppressed_policy(reason:background-internal-no-source-context)...attempt,...sent,...failed
Quick queries:
joelclaw otel search "events.dispatched.background_only" --hours 24
joelclaw otel search "response.generated.background_source" --hours 24
joelclaw otel search "outbound.console_forward" --hours 24
Runtime suppression guard (minimal behavior gate):
- console→Telegram forwarding is now suppressed when response attribution shows all of:
sourceKind=internalbackgroundSource=truehasActiveSource=falsehasCapturedSource=falserecoveredFromRecentPrompt=false
- suppression is OTEL-visible via
daemon.outbound:outbound.console_forward.suppressed_policy - this guard only affects unsolicited background-origin console forwards; explicit channel replies and recovered recent-prompt replies still route normally
Turn-level knowledge writes (ADR-0202)
Gateway turn-end flow now emits a default-on knowledge/turn.write.requested event for every turn:
- meaningful turn → captures
summary/decision/evidence/usefulnessTags - non-meaningful turn → sends explicit
skipReason:routine-heartbeatduplicate-signalno-new-information
Gateway emits eligibility OTEL (knowledge.turn_write.eligible) before dispatch so compliance can detect drift when write dispatch fails.
Interrupt controls by channel
Telegram chat (@JoelClawPandaBot):
/stop— aborts the active turn without killing the gateway daemon./esc— alias for/stop.
iMessage chat (plain text):
stopor/stop— aborts the active turn.escor/esc— alias for stop.
Emergency-only manual control (Telegram only):
/kill— hard stop: disables launchd service and kills the daemon process.
/kill is intentionally destructive. Use stop/esc first.
Automated monitoring
Heartbeat fan-out now includes:
- event:
gateway/health.check.requested - function:
check/gateway-health - source file:
packages/system-bus/src/inngest/functions/check-gateway-health.ts
What it checks
-
General gateway health (critical layers) via
joelclaw gateway diagnoseprocesscli-statuse2e-testredis-state
-
Channel-specific degradation from OTEL events in
otel_eventstelegram-channeldiscord-channelimessage-channelslack-channel
Automated behavior
- Tracks per-incident streaks in Redis (noise suppression)
- Auto-restarts gateway on sustained restart-eligible general failures (
process,cli-status,redis-state) with cooldown protection e2e-testfailures still mark health degraded and alert, but no longer trigger auto-restart by themselves- Alerts on sustained unresolved failure/degradation
- Supports muted channel known-issues list; muted channels are still probed + logged, excluded from autonomous monitor channel alert notifications, and also suppress the daemon's immediate rank-6 degrade/recover Telegram alerts
- Emits OTEL event:
- component:
check-gateway-health - action:
gateway.health.checked
- component:
Redis keys used by monitor
gateway:health:monitor:general-streakgateway:health:monitor:general-alert-cooldowngateway:health:monitor:restart-cooldowngateway:health:monitor:channel-streak:<channel>gateway:health:monitor:channel-alert-cooldowngateway:health:muted-channels(JSON array of channel IDs)gateway:health:mute-reasons(JSON object mapping channel → reason)
Related files
packages/system-bus/src/inngest/functions/heartbeat.tspackages/system-bus/src/inngest/functions/check-gateway-health.tspackages/cli/src/commands/gateway.tspackages/gateway/src/daemon.tsskills/gateway-diagnose/SKILL.md