Skip to content

Spawning and monitoring

app/jobs/agent_session_job.rb is the biggest file in the repo (~3,000 lines) and it is where Zimmer stops being a Rails app and starts being a process supervisor.

Claude Code:

Terminal window
claude --dangerously-skip-permissions \
--disallowedTools Monitor ScheduleWakeup "Bash(sleep *)" "Skill(schedule)" AskUserQuestion \
[--model MODEL] [--append-system-prompt SYSTEM_PROMPT] [--mcp-config PATH] \
(--session-id UUID | --resume UUID) \
-- <prompt>

Codex:

Terminal window
codex exec --json --dangerously-bypass-approvals-and-sandbox \
--cd <working_dir> [-m MODEL] \
--output-last-message <wd>/codex_last_message.txt [-i image]... \
<prompt>

Both are spawned with pgroup: true (so the whole process group can be killed as a unit), stdin and stdout to /dev/null, and stderr to claude_stderr.log / codex_stderr.log inside the clone.

Monitor, ScheduleWakeup, Bash(sleep *), and Skill(schedule) are all blocked because they are Claude Code’s own ways of waiting, and they don’t survive Zimmer. A background sleep loop dies when the container is recreated on deploy; a ScheduleWakeup doesn’t create an AO trigger that AO can track. Agents are pointed at Zimmer’s own MCP wake tools instead. AskUserQuestion is blocked because an interactive prompt would stall an autonomous session forever.

Claude CodeCodex
Session IDZimmer generates it, passes --session-idCodex mints its own; Zimmer captures it from the transcript
MCP config--mcp-config <path>~/.codex/config.toml (no flag)
System prompt--append-system-promptWritten into AGENTS.md below a marker
Resume--resume UUIDcodex exec resume UUID — and no --cd (the subcommand rejects it)
Transcriptplain .jsonlzstd-compressed .jsonl.zst rollouts

The mints_own_session_id? flag on the transcript normalizer is what keeps these straight. Getting it wrong corrupts forked sessions — Claude’s session id must not be rewritten from the transcript, or a fork collides on the unique index.

If images are attached, or the prompt exceeds LARGE_PROMPT_THRESHOLD (100 KB), the Claude adapter switches to stream-json mode and feeds the payload through an IO.pipe written on a background thread. A regular file doesn’t work here — the CLI reads nothing from it.

Shared scrubbing (CliSpawnEnv):

  • Loads a per-clone .env file if present (1 MB cap).
  • Clears inherited env vars — DATABASE_*, RAILS_ENV, GEM_*, RUBY*, and a sweep of everything prefixed BUNDLE*. Without this the agent would inherit Zimmer’s own database credentials and Ruby toolchain.
  • Sets AO_SESSION_SCRATCH_DIR — a durable per-session scratch directory.

Claude adds (ClaudeSpawnEnv): ENABLE_TOOL_SEARCH=false (baseline; the mcp_tool_search extension flips it), CLAUDE_CODE_DISABLE_CRON=1, CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, CLAUDE_CODE_AUTO_COMPACT_WINDOW (default 1,000,000), and when MCP is on: MCP_TIMEOUT=180000, a clone-local NPM_CONFIG_CACHE, and ELICITATION_SESSION_ID.

Codex adds RUST_LOG=warn,rmcp=info and CODEX_HOME.

Once spawned, the job loops: check the process is alive, poll the transcript file, broadcast new messages, repeat. There is a 0.15 second sleep between each broadcast — it must exceed SolidCable’s 100 ms polling interval, and it’s a real throughput cost on a bursty transcript.

Two independent output channels:

  • stderr → session logs. A thread tails the stderr file by byte offset every 0.5 s into a LogBuffer, flushed every 5 iterations.
  • transcript → UI. TranscriptPollerService reads the JSONL, normalizes it, and pushes Turbo Streams. See Transcripts.

stdout is discarded for both runtimes, even though both CLIs are launched with a JSON streaming flag. The transcript file on disk is the only source of truth.

ProcessLifecycleManager#handle_exit asks the runtime’s retry strategy five questions:

Session metadata is a JSON blob, and the job mutates it with a non-atomic read-modify-write. The code says so out loud (agent_session_job.rb:1073-1078), and recommends PostgreSQL’s jsonb operators as the real fix. Correctness-adjacent flags live in there anyway (interrupt_terminate_pid, pending_follow_up_prompt), described in the code as “best-effort FAST PATH, not the correctness guarantee.” Lost updates are possible.

A monitoring job whose lock is older than STALE_UNLOCKED_JOB_AGE (2 minutes) is superseded by a new one. Without this, “follow-up jobs silently skip execution because they see a stale ‘running’ job.” A two-minute magic number is the thing standing between you and a dropped prompt.