Agent Execution¶
Every pod-spawning stage of a Task runs a dedicated Kubernetes Pod, named independently of the Task itself - see Pod naming - for the current agent kind (status.agentKind: brainstorm, incident, refine, implement, review, documentation, or upgrade - clarify is gone as of the
521 lifecycle redesign, folded into implement). The pod hosts a single Go¶
service (tatara-claude-code-wrapper) that wraps one persistent, interactive claude process and exposes it to the operator as a turn-based HTTP API.
The Task persists; the pod does not. A pod's life is bounded by a TTL (Project.spec.agentPodTTLSeconds), and when it stops - on TTL or on a crash - the Task simply gets a new pod for the same stage, continuing from Task.status.notes, not from a resumed conversation. (maxTurnsPerPod used to be a second stop trigger; it is deprecated with zero effect.) This page describes the pod's anatomy, how it boots, how turns flow through it, and how that handoff-and-continuity mechanism works.
Pod anatomy¶
The wrapper pod has exactly one primary container (the wrapper binary, PID 1). Optional init containers and sidecars can be injected via Project.spec.agent knobs, but the core model is single-container: one Go service, one claude process, one persistent conversation.
+----------------------------------------------------------+
| wrapper pod (tatara-claude-code-wrapper) |
| |
| [PID 1: wrapper binary] |
| | |
| +-- spawns --> [claude (interactive PTY)] |
| | /workspace (git clone, edits) |
| | ~/.claude/ (settings, skills) |
| | |
| +-- HTTP :8080 (OIDC-gated public API) |
| +-- HTTP :8090 (127.0.0.1 only - Stop hook target) |
| +-- HTTP :8080 /healthz /readyz /metrics |
+----------------------------------------------------------+
Why PTY, not claude -p¶
claude -p (print/headless mode) is a divergent codepath: different system prompt assembly, different skill loading, different hook and permission behavior. The wrapper's design goal is to run claude in the same harness a human gets - the full interactive TUI, with all skills, hooks, and tool permissions intact. The wrapper therefore allocates a PTY (github.com/creack/pty), spawns claude interactively, and "types" each message in using bracketed paste. The terminal output stream is never parsed for turn results; it is only ring-buffered for boot-dialog detection and debug logging. Results come exclusively from the Stop hook and the on-disk transcript.
--dangerously-skip-permissions is also forbidden: it does not suppress boot dialogs - it adds an extra one.
OIDC HTTP API¶
All /v1/* endpoints require a valid OIDC JWT with audience tatara-claude-code-wrapper (the issuer/realm is operator-injected via OIDC_ISSUER; the homelab runs it in the master realm, but nothing hard-codes that). The operator holds a tatara-claude-code-wrapper-audience client-credentials token and uses it for every call. The internal loopback port (127.0.0.1:8090) is unreachable from outside the pod and carries no authentication - it is the Stop hook's private channel.
| Port | Interface | Purpose |
|---|---|---|
:8080 | pod ClusterIP | /v1/* public API (OIDC-gated) + /healthz /readyz /metrics |
:8090 | 127.0.0.1 only | POST /internal/turn-complete (Stop hook target) |
The operator is the only client of the public API. The full endpoint surface:
| Method + path | Request | Response |
|---|---|---|
POST /v1/messages | {"text":"...","callbackUrl":"...","handoff":false} | 202 {"turnId":"..."}; 409 turn in flight; 410 Gone past the pod's TTL deadline |
POST /v1/interrupt | - | 202; 503 no live PTY. Never 409 - see Stall detection |
GET /v1/messages/{turnID} | - | 200 turn.Record |
GET /v1/messages | - | 200 [turn.Summary] |
GET /v1/session | - | 200 - the six existing fields, plus contractVersion |
DELETE /v1/session | - | 204 (the TTL stop path) |
GET /v1/transcript | - | 200 |
POST /v1/interject is deleted, not merely deprecated - it was live in the pre-redesign wrapper. It let the operator inject text into a turn that was already running (busy state). It is gone because it raced the Stop hook and the transcript tailer: mid-turn PTY injection is exactly the kind of concurrent write into a live claude session the redesign forbids. There is no replacement mid-turn channel; guidance for the next turn goes in the next POST /v1/messages, or - past the pod's TTL - in the one handoff:true turn described below.
Boot sequence¶
The wrapper boots once at pod start and progresses through a deterministic sequence before accepting any turns.
sequenceDiagram
participant K8s
participant Wrapper as wrapper (PID 1)
participant Bootstrap
participant PTY as PTY / claude
participant HTTP
K8s->>Wrapper: pod start (env + ConfigMap mounts)
Wrapper->>Bootstrap: bootstrap.Render()
Bootstrap->>Bootstrap: shallow-clone REPO_URL@REPO_BRANCH into /workspace (if set)
Bootstrap->>Bootstrap: write /workspace/CLAUDE.md (projectClaudeMd)
Bootstrap->>Bootstrap: write ~/.claude/CLAUDE.md (globalClaudeMd)
Bootstrap->>Bootstrap: merge baseMcp + /etc/wrapper/mcp.d/*.json -> /workspace/.mcp.json (0600)
Bootstrap->>Bootstrap: write ~/.claude/settings.json (Stop hook path, bypassPermissions, MCP auto-enable)
Bootstrap->>Bootstrap: seed ~/.claude.json (onboarding, folder-trust, auth accepted)
Bootstrap->>Bootstrap: install baked + custom skills -> /workspace/.claude/skills/
Wrapper->>PTY: allocate PTY, spawn interactive claude
PTY-->>Wrapper: ring buffer fills with TUI output
Note over Wrapper,PTY: bootWait loop
Wrapper->>PTY: detect "Bypass Permissions mode" warning
Wrapper->>PTY: send Down + Enter (accept)
Wrapper->>PTY: wait for output quiescence (>1.5s, floor 4s, cap BOOT_TIMEOUT_SECONDS)
Wrapper->>HTTP: start public + internal HTTP servers
HTTP-->>K8s: /readyz -> 200 (readiness probe passes) Step-by-step¶
1. Config load. Scalar config is read from env vars (injected via the chart's ConfigMap envFrom). File-based config (CLAUDE.md content, MCP fragments, skill archives) is read from /etc/wrapper mounts.
2. Bootstrap render. bootstrap.Render writes, in order:
- Repository clone: if
REPO_URLandREPO_BRANCHare set, the target repo is shallow-cloned into/workspace. Pre- and post-clone lifecycle hooks fire around this step. - CLAUDE.md files:
/workspace/CLAUDE.md(project instructions, fromprojectClaudeMd) and~/.claude/CLAUDE.md(global instructions, fromglobalClaudeMd). - MCP server config:
/workspace/.mcp.jsonassembled by merging the bakedtatara-climemory server entry with any overlay fragments from/etc/wrapper/mcp.d/*.json. Written mode 0600. ~/.claude/settings.json: wires the Stop hook binary (/usr/local/bin/cc-stop-hook), setspermissions.defaultMode: bypassPermissions, and setsenableAllProjectMcpServers: true.~/.claude.json(mode 0600): the no-dialog seed. Pre-populateshasCompletedOnboardingand setsprojects["/workspace"].hasTrustDialogAccepted: true, suppressing the seedable interactive dialogs. In the tatara deployment the operator injects a Claude subscription OAuth token asCLAUDE_CODE_OAUTH_TOKEN(from theoauth-tokenSecret key), so claude authenticates via subscription and there is no "use this API key?" dialog to seed. ThecustomApiKeyResponsesfingerprint (last 20 chars ofANTHROPIC_API_KEY) is only written when anANTHROPIC_API_KEYis set instead - the alternate, metered-API-key auth path.- Skills: copies baked skills from
/templates/skillsand custom skills from/etc/wrapper/skillsinto/workspace/.claude/skills/.
3. PTY spawn. session.Start allocates a pseudo-terminal and launches claude interactively with no permission flags. Three goroutines start: one reads PTY output into a thread-safe ring buffer, one Waits on the process (to detect unexpected exits), and one runs the boot-wait logic.
4. Boot-wait / dialog acceptance. The "Bypass Permissions mode" warning cannot be suppressed via the seed file - it appears on every boot. The wrapper detects it in the ring buffer (matching with ANSI escape sequences and whitespace stripped, because the TUI lays out words with cursor-move codes) and accepts it by sending Down then Enter. It then waits for PTY output quiescence - defined as no new bytes for more than 1.5 seconds, with a floor of approximately 4 seconds, and a hard cap of BOOT_TIMEOUT_SECONDS (default 60). A fixed delay is insufficient: claude renders its first frame in roughly 2 seconds but continues initializing after that; submitting a turn too early causes it to exit.
5. HTTP servers start. Once the session is marked ready, the public and internal HTTP servers start. Until this point /readyz returns 503, so Kubernetes readiness probes keep retrying without routing traffic.
Pod restart on unexpected exit
If claude exits for any reason other than a deliberate DELETE /v1/session, the session enters the dead state, /readyz fails, and Kubernetes restarts the pod. The ccw_claude_restarts_total counter increments. In-memory turn history is lost on restart; the on-disk transcript in /workspace survives if the pod uses a PVC. The last ~800 bytes of de-ANSI'd PTY output are logged as pty_tail - the single most diagnostic field for a boot or dialog regression.
Turn loop¶
Turns are strictly sequential. At most one turn is in flight at a time. A second POST /v1/messages while a turn is running returns 409 Conflict.
State machine¶
stateDiagram-v2
direction LR
[*] --> booting : pod start
booting --> ready : bootWait complete
ready --> busy : POST /v1/messages accepted
busy --> ready : Stop hook callback received
busy --> busy : TURN_TIMEOUT_SECONDS elapsed<br/>(stall suspected, probed, re-armed)
busy --> ready : POST /v1/interrupt<br/>(operator escalation, turn resolved failed)
ready --> dead : claude exits unexpectedly
booting --> dead : BOOT_TIMEOUT_SECONDS exceeded
dead --> [*] : pod restarted by K8s Turn submission (ready -> busy)¶
The operator calls POST /v1/messages with a text body, an optional callbackUrl, and a handoff bool. handoff matters only past the pod's TTL deadline t0 (see Pod TTL below): before t0 it is treated as an ordinary turn, and past t0 it admits exactly one further turn - the handoff-note turn - after which every further normal turn 410s. The wrapper's session.Submit:
- Verifies state is
ready; returns409if busy, booting, or dead. Pastt0, a non-handoff turn returns410 Goneinstead. - Creates a turn record with a unique ID (
turn-<base36 nanos>) and staterunning. - Writes the message into the PTY using bracketed paste:
- Waits approximately 400 ms (
SubmitDelay). A single write does not submit - the TUI requires a carriage return as a second write. - Writes
\rto submit. - Sets session state to
busy, starts theTurnTimeouttimer, and returns202 {turnId}.
Turn execution¶
Claude reads CLAUDE.md, invokes MCP tools (tatara-cli, memory server), reads and edits files under /workspace. The wrapper does not observe or parse this activity. PTY output continues to stream into the ring buffer (for debug logging), but result extraction does not begin until the Stop hook fires.
Stop hook (busy -> ready)¶
When claude ends a turn it runs /usr/local/bin/cc-stop-hook. This binary:
- Reads the hook payload from stdin:
{session_id, transcript_path, last_assistant_message, ...}. - Builds a
HookResult:FinalTextfromlast_assistant_message(authoritative), plusUsageand a text fallback parsed from the JSONL transcript's last assistant line, andResultJSONif the agent wrote a/workspace/result.json. - POSTs the result to
http://127.0.0.1:<INTERNAL_ADDR>/internal/turn-complete. - Always exits 0. A Stop hook must never block or alter claude's behavior.
The wrapper's session.Complete handler:
- Cancels the
TurnTimeouttimer (whichever fires first - timeout or complete - wins; the other is a no-op). - Records
transcriptPath, stores the completed turn result. - Sets session state back to
ready. - Increments
ccw_turns_total, recordsccw_turn_duration_seconds. - Fires
OnTurnDoneoutside the lock.
Result delivery¶
OnTurnDone is wired to webhook.Deliver. If callbackUrl was supplied on the original POST (or DEFAULT_CALLBACK_URL is configured), the wrapper POSTs the turn result there, retrying with exponential backoff up to WEBHOOK_RETRIES (default 3). The turn result is always also retrievable by polling GET /v1/messages/{turnId}, regardless of whether the webhook succeeded.
Inactivity timeout¶
If the Stop hook callback does not arrive within TURN_TIMEOUT_SECONDS (default 1800 seconds), the turn is no longer failed. As of tatara-claude-code-wrapper#158 the wrapper marks it stall-suspected and re-arms instead - see Stall detection: probe, interrupt, stop below for what decides whether it is actually killed.
Stall detection: probe, interrupt, stop¶
Killing a stalled turn moved from the wrapper to the operator. The wrapper's only signal was the transcript going quiet, and a single subagent run can silence it for 35+ minutes while the pod works flat out - so a wrapper-side kill on inactivity was destroying real turns to guard against a condition it could not actually detect. The operator has more context (the Task, the ability to ask the agent something) and now owns the decision end to end:
- Suspected.
TURN_TIMEOUT_SECONDSof silence: the wrapper stampsstallSuspectedSince(once per stall, not re-stamped on re-arm, so it measures how long the silence has lasted), incrementsccw_turn_stall_suspected_total, and keeps the turn running. - Probed. The operator sends
POST /v1/probe. Delivery happens at the agent's next tool-call boundary, so a healthy agent inside one long tool call answers late rather than never - a measured 70s sleep buffered a probe 58.2s and it was still delivered. The operator waitsstallProbeGraceSeconds(default 300, floor 60) for a reply, and retries up tostallProbeMaxAttempts(default 2, range 1-5) times. - Interrupted. Past the last unanswered attempt, the operator calls
POST /v1/interrupton the wrapper - a single ESC byte on the PTY, ~40ms, synchronous, even mid-tool-call. Session, transcript, and conversation context all survive. - Stopped. The interrupted turn resolves as
failed/interruptedwith whatever partial output exists, and the ordinary pod TTL stop sequence hands off.
Two outcomes are worth distinguishing at the metric level: never_delivered (the probe was written but no tool-call boundary was ever reached - the agent is blocked inside one long-running tool call, a positive diagnosis) versus unanswered (delivered, no reply before the grace window - consistent with a busy agent that did not treat the probe as worth answering, or a genuinely wedged one). See the stall-probe runbook for the full diagnosis playbook.
After #158 the only wrapper-side bound on a turn already in flight is the pod's TTL (admit()/PodTTL blocks new work past the deadline; it never touches a turn already running). On the operator side, the corresponding backstop for a Task the probe machinery cannot reach at all (a wedged pod, a boot-crash loop, an operator bug) is the 24h residency cap - a hardcoded constant, not a per-Task work budget.
Pod TTL: the stop sequence¶
There is no session-resume mode: --resume, a stored session ID (CONVERSATION_SESSION_ID), a stored S3 transcript object key (CONVERSATION_OBJECT_KEY), and a fork-from-conversation key (HANDOFF_KEY) are all deleted. Every pod's turn-0 gets an identical context bundle, rendered fresh by the operator from current CR state (the Issue, the MergeRequest, comments, events, notes - escaped and XML-ish, delivered as the text of the first POST /v1/messages). The bundle is the continuation state; there is nothing else to resume.
A pod's own lifetime is bounded by a TTL, independent of the Task's stage deadline (see Task Stages for the stage-level clocks). The wall-clock TTL drives the stop sequence, anchored at:
except in the conversing stage, where the anchor is max(podStartedAt, conversationLastEventAt): a maintainer's reply inside the TTL window pushes t0 out instead of the pod being torn down mid-exchange. A fresh reply after t0 still un-parks the Task onto a brand new pod (with a fresh podStartedAt) as usual - this only changes when the operator ends the current, still-live pod's stop sequence.
- Stop admitting normal turns. Past
t0the wrapper refuses anyPOST /v1/messageswithhandoff:falsewith410 Gone. It still accepts exactly one turn withhandoff:true. - Wait for the in-flight turn. A pod is mid-turn at TTL expiry essentially always, and
POST /v1/messagesalready 409s while a turn is running - so the handoff turn cannot simply be submitted immediately. The operator waits for the in-flight turn's callback, bounded byturnTimeoutSeconds. - Submit the one handoff turn: bounded by
POST /v1/messages {"text": "Your pod is being stopped. Call task_note(kind=handoff) with everything the next pod needs, then stop.", "callbackUrl": ..., "handoff": true}turnTimeoutSeconds. - Hard cap at
t0 + 2*turnTimeoutSeconds + 60sgrace. On that cap, or on any410/409/5xx from the handoff turn, the operator writes a synthetic note in-process instead -agent: "operator",kind: "handoff", summarizing the last turn's final text and which repos were pushed - and force-deletes the pod (DELETE /v1/sessionis the same stop path when the pod is still reachable).
Task.status.notes is therefore never empty after a TTL stop: either the agent wrote its own handoff note, or the operator wrote a synthetic one. The pod slot frees, stats.podRecreations increments if the stage did not change, and the Task's next pod for that stage picks up the journal at turn 0.
Task continuity: the notes journal¶
Task.status.notes is an append-only journal every pod reads at turn 0 - Go-side capped at 50 entries, oldest entries spilled to tatara-memory beyond that (see Memory Architecture) and readable back through the task_context(notes=all) MCP tool. It is the only thing that carries forward between pods of the same Task; there is no live session to reconnect to.
One consequence: a brainstorm Task's proposals each become their own new implement Task (one per proposal, spec.kind: implement - the origin role clarify used to hold), each starting cold at its own turn 0 with its own context bundle - not a forked live conversation. Continuity for a sibling comes from what the operator renders into its bundle from the originating Issue/Task state, not from a shared session.
Agent-pod environment contract¶
The operator injects a fixed set of env vars into every agent pod. TATARA_KIND, TATARA_TOOL_PROFILE, and TATARA_SKILL_PROFILE all carry the agent kind (status.agentKind) - the pod that is running right now, not the Task's origin spec.kind. This is a deliberate, load-bearing change from the pre-redesign contract, where these carried the Task's origin kind.
| env | value | change |
|---|---|---|
TATARA_TASK | Task CR name | unchanged |
TATARA_PROJECT | Project CR name | unchanged |
TATARA_KIND | agent kind (status.agentKind) | changed semantics |
TATARA_TOOL_PROFILE | agent kind (7 values) | changed key |
TATARA_SKILL_PROFILE | agent kind (7 values) | changed key |
TATARA_REPO | Repository CR name; empty except on documentation Tasks | narrowed |
TASK_BRANCH | the pod's actual work branch, injected verbatim - never construct it from the Task name. agent.TaskBranch (tatara-operator internal/agent/pod.go) produces tatara/<branchKind>-<number>-<slug> when the Task carries a source issue or PR, tatara/docs-<short-sha> for a documentation Task, or tatara/task-<task-name> otherwise | unchanged form |
TATARA_OPERATOR_URL / TATARA_MEMORY_URL | operator / memory service endpoints | unchanged |
AGENT_POD_TTL_SECONDS | Project.spec.agentPodTTLSeconds | new |
TATARA_CONTRACT_VERSION | 4 | new |
The tool-profile rekey is a day-one fleet wedge if missed
Both the operator's and tatara-cli's kindProfiles maps are keyed on the agent kind now, not the old Task-origin kind. The map fails closed on an unknown key: a pod whose kind is absent from the map gets only the always-on tool set and no submit_outcome registered. clarify is gone from both maps entirely as of contract 4 - a pod still reporting that profile wedges with no terminal tool at all, deliberately (see MCP tools).
Two env vars are removed outright, not merely deprecated: TATARA_CHAT_URL (the chat service is archived/decommissioned; the inter-agent chat room that used to exist is gone, and continuity is the notes journal above, not a chat room) and the whole resume-mode triplet HANDOFF_KEY / CONVERSATION_SESSION_ID / CONVERSATION_OBJECT_KEY.
The contract-version handshake¶
The wrapper image and the operator image ship in different Helm releases (tatara-claude-code-wrapper vs tatara-operator), applied concurrently by tatara-helmfile. A moment where a new operator pairs with an old agent image - old tatara-cli, no submit_outcome, every new endpoint 404ing - is therefore reachable, and would otherwise silently burn a pod's entire turn budget producing nothing, repeated across every Task in flight. Three defenses, all mandatory:
- The operator injects
TATARA_CONTRACT_VERSION=4into every agent pod (above). tatara-cli's MCP server refuses to start ifTATARA_CONTRACT_VERSIONis set and does not match its own compiled contract version - a fatal exit. An unset value (a workstation, a test) is allowed through, which then simply has no MCP server at all - loud, not silent.- The operator reads
GET /v1/session'scontractVersionfield at pod-ready and asserts it before submitting turn-0. On a mismatch, or on a response with nocontractVersionfield at all (an old wrapper):
Task.status.parkReason = agent-contract-mismatch
metric: operator_agent_contract_mismatch_total{expected,got,image}
The Task parks instantly, before a single turn is submitted - zero tokens burned. Any nonzero rate of this metric is a critical alert.
Observability¶
The wrapper exposes structured JSON logs (slog) for every state transition and business action, tagged with turn_id and duration_ms. Prometheus metrics on /metrics:
| Metric | Type | Description |
|---|---|---|
ccw_turns_total{result} | Counter | Turn outcomes: complete or failed |
ccw_turn_duration_seconds | Histogram | End-to-end turn latency |
ccw_turn_in_flight | Gauge | 0 or 1 (exactly one turn at a time) |
ccw_claude_restarts_total | Counter | Unexpected claude process exits |
ccw_webhook_delivery_total{result} | Counter | Callback delivery: ok or dropped |
ccw_hook_received_total | Counter | Stop hook calls received |
ccw_turn_tokens_total{type,model,kind,repo,project} | Counter | Token spend per type (input, output, cache_read, cache_creation) summed across all assistant messages in the turn, attributed to the Task kind, repo, and project |
ccw_turn_cost_usd_total{kind,repo,project} | Counter | Cumulative turn cost in USD (emitted only when /workspace/result.json carries total_cost_usd), attributed by kind, repo, and project |
ccw_lifecycle_hook_total{result,hook} | Counter | Lifecycle hook executions |
ccw_interrupts_total{result} | Counter | POST /v1/interrupt outcomes, including unconfirmed when resolution polling hits its 30s deadline |
ccw_turn_stall_suspected_total | Counter | Inactivity-timer firings since it stopped killing turns - see Stall detection |
ccw_safety_push_total{result} | Counter | Periodic push-only safety-net attempts (BRANCH_PUSH_INTERVAL_SECONDS) |