Tuning agents and flows¶
Task-oriented guide to the levers that control how much a Project runs, what it spends, and which scheduled flows are active. Every lever here is a field on the Project CR. Full field-by-field reference (types, defaults, kubebuilder validation) lives there; this page is "what do I change to get effect X."
GitOps only - never kubectl edit/patch/helm upgrade by hand
Live Project spec values are owned by the standalone tatara-helmfile repo (values/project-tatara/common.yaml, values/project-infrastructure/common.yaml), which renders the tatara-project chart and self-deploys on merge to main via an in-cluster ARC runner. To change any value on this page: edit the relevant common.yaml, open a PR, review the sticky helmfile diff comment, merge. The pipeline applies it. A live kubectl patch is permitted only as incident response to unblock a down service, and any such patch must be immediately re-asserted through a tatara-helmfile MR so the repo matches live state. See Deployment & GitOps.
Pause a project entirely¶
Set maxConcurrentAgents: 0.
The admission unit is now the pod spawn, not the Task. At 0, admit() short-circuits at the top and no QueuedEvent is ever admitted - so no pod spawns and no Task is created. Every pod-spawning state goes through the same chokepoint, so a 0 freezes the whole project mid-flight, including Tasks that are already running.
There is no Minimum=1 on this field. Project.QueueCapacity() floors at 3 when the field is unset, so the pause is a direct spec.maxConcurrentAgents == 0 check at the top of admit(), deliberately not routed through QueueCapacity() - which would silently un-pause you.
A paused project does not shred its backlog
Every live state not yet admitted (no pod started) carries a 24h admission-starved deadline. That check skips Tasks whose project is paused - it is the only deadline exception in the platform, and it exists so that the kill switch is a pause and not a backlog shredder.
To reduce load without a full stop, lower maxConcurrentAgents to a smaller positive number, or tune queue.capacity / queue.alertCapacity directly if you need the normal-pool and alert-pool (incident) concurrency to diverge from maxConcurrentAgents.
Disable a specific flow¶
A cron-driven activity is off when its schedule is empty. brainstorm and documentation additionally require enabled: true - clearing enabled (or leaving it unset) disables them regardless of schedule.
| To disable | Set |
|---|---|
| Issue scan | scm.cron.issueScan.schedule: "" |
| Brainstorm (self-driven proposals) | scm.cron.brainstorm.enabled: false |
| Documentation (periodic docs upkeep) | scm.cron.documentation.enabled: false |
| Upgrade (dependency bumps) | scm.cron.upgrade.schedule: "" |
There is no scm.cron.mrScan to clear: it was removed from the CRD when the sweep became the single issue and PR intake, and a Project that still carries the block has it pruned silently. Scheduled PR re-review is part of the sweep and is scoped by scm.prReactionScope - see Project.
scm.cron.refine has no independent schedule - it fires as a mandatory barrier before every due scan/brainstorm cycle and cannot be disabled short of removing all of issueScan/brainstorm/documentation schedules.
There is no push-CD deploy-supervision backstop cron any more (cdScan is gone with the fields it swept). Documentation is now one nightly batch Task per project, covering everything delivered in the last 24h - not a per-delivery spawn and not a "did anything meaningful change?" judgment call.
The live projects run issueScan every 10 minutes (*/10 * * * *). Each enrolled repo gets its own scan-offset slot spread across that window, which spreads WHEN each repo becomes due - but maxNewTasksPerSweep still binds per project per pass, not per repo: SweepProject builds one sweepBudget per call and shares it across every repo the pass finds due, so two repos whose offsets land in the same reconcile tick still compete for one shared cap - see the levers table above.
The brainstorm staleness reaper is not wired up¶
scm.cron.brainstorm.staleProposalDays is a real CRD field, so setting it applies cleanly and helmfile diff shows it going in - but no code reads it. There is no reaper: bot-authored proposals with no human engagement are not auto-closed at any age, whatever this is set to. grep -rn StaleProposal over tatara-operator/internal/ finds no consumer, and the operator's MEMORY.md records the field as documented-but-deliberately-not-built.
All three live projects run staleProposalDays: 14 (project-mtg went live 2026-07-24). Read that as a statement of intent, not as an active 14-day window. Draining a clogged backlog is a manual close today, or a lower targetOpenProposals so less is refilled into it.
Tune the brainstorm backlog¶
scm.cron.brainstorm.targetOpenProposals is the level, not a ceiling: the operator refills toward it the moment a maintainer approves or discards a proposal. Raise it for more standing choice, lower it to reduce review load. Lowering it does not close anything - the backlog drains as you decide.
scm:
cron:
brainstorm:
enabled: true
targetOpenProposals: 3
historyWindow: 20
minSessionIntervalMinutes: 12
If sessions start burning tokens without producing, minSessionIntervalMinutes is the brake. It floors the wall-clock gap between two brainstorm sessions whichever path dispatched the prior one, and it is a rate limit, not a circuit breaker: it delays a refill, never suppresses one, and it never inspects how the prior session ended. Positive is an explicit floor, 0 (unset) is the 12-minute default, negative is the explicit opt-out. A deliberate stop is a separate thing the agent asks for by name (action: exhausted).
The brainstorm circuit breaker is retired; do not reach for it
The old brainstorm circuit breaker is gone. It is not a Project field, so writing it applies clean and is pruned silently, and operator_brainstorm_breaker_trip_total is emitted by nothing - the metric a reader was previously sent to watch here does not exist in any repo. It counted an agent correctly reporting "nothing worth proposing" toward a brake, so a healthy project switched its own fast path off and only a cron tick could switch it back on. See internal/controller/proposalcount.go.
Cap spend¶
Two independent levers, from broadest to narrowest:
1. Token-budget admission gate (tokenBudget)¶
Off by default at every level - a Project inherits the operator-wide default (enabled: false) unless it sets its own tokenBudget block. When enabled it pauses the normal pool at proactivePercent of the measured window and the alert/incident pool at emergencyPercent:
project:
spec:
tokenBudget:
enabled: true
mode: customWindow # or claudeSubscription
proactivePercent: 50
emergencyPercent: 80
resetSchedule: "0 0 * * *" # customWindow only
windowDuration: "24h" # customWindow only
tokenLimit: 50000000 # customWindow only
mode: claudeSubscription gates on the Claude 5h/weekly usage windows instead of an absolute token count, and needs no resetSchedule/windowDuration/ tokenLimit. It is live in prod today: the operator-wide default sets tokenBudgetEnabled: true, tokenBudgetMode: claudeSubscription (tatara-helmfile values/tatara-operator/default.yaml), so tatara inherits it with no per-Project override. mtg and infrastructure each set their own tokenBudget block (below). Each usage window can be gated against its own pair, OR'd against the mode-wide fallback:
project:
spec:
tokenBudget:
enabled: true
mode: claudeSubscription
proactivePercent: 50 # fallback for any window left at 0
emergencyPercent: 80
fiveHourProactivePercent: 80
fiveHourEmergencyPercent: 92
weeklyProactivePercent: 75
weeklyEmergencyPercent: 88
The operator-wide default is exactly the block above: the 5h window is the tighter, faster-moving one so it gets more headroom before proactive work pauses; the weekly window moves slowly and exhausting it costs days, so it pauses earlier.
Two Projects now override the weekly pair (2026-08-25, tatara-helmfile#463): the operator-wide 75%/88% held every normal-class admission fleet-wide once account weekly usage reached 86%, including a human-filed mtg issue that never got a pod. mtg raises weeklyProactivePercent to 95 (weeklyEmergencyPercent 97) and holds its own brainstorm/upgrade kinds at the old 75 via spawnCeilingByKind, so the extra headroom goes to human-filed work, not more self-proposed churn. infrastructure - the pool's largest and most deferrable normal-class consumer - funds that headroom by dropping weeklyProactivePercent to 50 and leaves weeklyEmergencyPercent unset so it keeps inheriting the operator-wide 88 (ResolvePercents clamps emergency up to proactive, never down, so cluster incidents on infrastructure still admit).
The feed: each agent pod's silent cc-statusline command reports Claude Code's own rate_limits block to the wrapper on every TUI redraw (tatara-claude-code-wrapper#183). The wrapper attaches the newest snapshot to the turn-complete callback as accountUsage, and the operator parks it on that Task's status.accountUsage. A leader-only AccountUsageFeedReconciler folds the newest snapshot across every Task into the fleet-wide in-process store the gate actually reads - newest-wins, not per-Project, because the subscription is one account shared by every Project (tatara-operator#633). Past tokenBudgetMaxSnapshotAge (90m, fleet-wide - there is deliberately no per-Project override, since staleness is a property of the one shared account) the gate fails open rather than blocking. Because that failure mode is otherwise silent (nothing increments while the gate evaluates 0%), TataraAccountUsageFeedDead alerts on tatara_account_usage_gate_ready == 0 for 30m. It is defined in the operator chart's own PrometheusRule, not tatara-observability; no runbook entry exists for it yet.
A separate axis gates by Task kind rather than by window: spawnCeilingByKind (a Task-kind -> percent map on TokenBudgetSpec). mtg is the only Project setting it today, holding brainstorm and upgrade at 75 while its window pair above rises to 95/97 (see above). It reads the same fleet-wide FiveHourPercent/WeeklyPercent fold described under "The feed" above (budget.KindBlocked, internal/budget/budget.go) - so it is live via the wrapper's cc-statusline feed, the same one the window gate uses, not a separate feed of its own. The older /api/oauth/usage poller (usageEnabled in the operator chart) can also write that same fold but stays off fleet-wide - the shared claude setup-token lacks the user:profile scope that endpoint needs - so it contributes nothing today; the wrapper feed alone already drives mtg's ceiling.
2. Model/effort tiering per agent kind¶
Drop specific agent kinds to a cheaper model or lower reasoning effort while keeping the project-wide fallback high for everything else:
agent:
model: claude-opus-4-8
effort: high
modelByKind:
documentation: claude-sonnet-5
refine: claude-sonnet-5
effortByKind:
documentation: medium
refine: medium
modelByKind / effortByKind key on Task.status.agentKind (the running agent), not Task.spec.kind (the immutable origin). The seven valid keys are brainstorm, incident, implement, review, refine, documentation, upgrade - clarify is gone as of the #521 lifecycle redesign, folded into implement. A missing or empty entry falls back to the project-wide model/effort. The locked default tiering is brainstorm/incident/implement/review on Opus at high effort, and documentation/refine on Sonnet - both live projects run this default unmodified.
A key on a retired kind is silently ignored
values/project-*/common.yaml currently sets modelByKind.triageIssue and effortByKind.triageIssue. triageIssue is a retired kind. The key does not match, so those Tasks fall back to the project-wide default - Opus at high effort. That is a cost regression, not merely dead YAML. Repoint it at the surviving agent kinds above in the same PR.
There is no separate per-task token-count backstop any more (agent.maxTaskTokens is gone). There is also no turn-count backstop any more: agent.maxTurnsPerTask is deprecated with zero effect (tatara-operator#582) - a turn count measures how much an agent has done, not whether it is stuck. What bounds a runaway implement Task now is the 24h residency cap (hardcoded, not a lever) plus the probe/stall escalation below.
All the levers¶
| Lever | Default | What it bounds |
|---|---|---|
maxConcurrentAgents | 3 | Concurrent agent pods. 0 is the pause |
agentPodTTLSeconds | 3600 (min 300) | One pod's life. The Task persists. See the stop sequence below |
maxNewTasksPerSweep | 5 (min 1) | Tasks one sweep pass may mint |
maxOpenTasks | 6 (min 1) | ACTIVE Tasks (state not in {done, rejected} and parkReason == ""). parked(backlog-sweep) Tasks do not count - they hold ownership, not work |
maxBundleBytes | 400000 (min 50000) | Hard byte budget on a rendered context bundle |
agent.maxTurnsPerPod | 40 | Deprecated, zero effect. Kept only because helmfile still sets it |
agent.maxTurnsPerTask | 300 | Deprecated, zero effect. See the residency cap for what replaced it |
agent.maxReviewRounds | 3 | Deprecated, zero effect. The awaiting-review <-> under-implementation cycle is no longer capped by a round count |
agent.maxPodRecreations | 3 | Deprecated, zero effect. A pod that never becomes Ready within podReadyTimeout (5m of podStartedAt) still respawns, but no longer terminates the Task - repeated respawns are now an alert (operator_pod_recreations_total, see Runbooks) bounded only by the 24h residency cap |
agent.turnTimeoutSeconds | 1800 | Meaning changed. No longer kills the turn - after this many seconds of inactivity the operator probes the agent instead (POST /v1/probe), waits stallProbeGraceSeconds for a reply, retries up to stallProbeMaxAttempts times, then interrupts and runs the stop-and-handoff sequence. See Stall probe unanswered |
agent.stallProbeGraceSeconds | 300 (min 60) | How long the operator waits for a stall probe to be answered before counting it unanswered |
agent.stallProbeMaxAttempts | 2 (range 1-5) | Unanswered probes before the operator interrupts the session |
maxOpenTasks is a Task-creation budget and maxNewTasksPerSweep bounds one sweep pass's minting - both are different levers from maxConcurrentAgents, which is a pod-concurrency budget. Raising one does not raise the others.
The human-review round cap is not on this list because it is not a field
A review-kind Task un-parks from awaiting-human on each human comment and stops doing so after 5 laps, which is what keeps a chatty PR thread from spawning one review pod per comment. That 5 is the MaxHumanReviewRounds constant in tatara-operator/api/v1alpha1/constants.go, not agent.maxHumanReviewRounds. Writing the latter into a Project is pruned silently by the apiserver and changes nothing - see AgentSpec.
agentPodTTLSeconds bounds a pod, not a Task. On expiry the operator stops admitting new turns, waits for the in-flight turn's callback (bounded by turnTimeoutSeconds), submits one final handoff turn ("your pod is being stopped; call task_note(kind=handoff) with everything the next pod needs"), and hard-caps at t0 + 2 * turnTimeoutSeconds + 60s. On the cap, or on any 409/5xx, the operator writes a synthetic handoff note in-process from the last-turn continuation state on the Task - status.lastTurnFinalText and status.lastTurnPushedRepos, persisted by whichever path finalised the turn, the turn-complete callback or the poll backstop that recovers turns whose callback never arrived - and stops the pod. It force-deletes only if the graceful stop fails against a pod that is still there.
Task.status.notes is therefore never empty after a TTL stop. Either the agent wrote a handoff, or the operator wrote one for it.
Non-empty is not the same as useful, and the metric distinguishes them. If the last-turn continuation state is empty too - no turn in this stage ever produced a final message or a push - the operator has nothing to synthesize from and writes a PLACEHOLDER note saying so, counted as handoff="none" on operator_agent_pod_ttl_expired_total and on operator_agent_synthetic_handoff_empty_total. That is silent work loss and has its own runbook.
Note that outcome and handoff are independent: outcome records only how the pod came down (graceful or force_deleted), and a stop where the agent handed off perfectly but the wrapper then refused to tear down cleanly is outcome=force_deleted, handoff=agent - no work was lost.
Stage deadlines: one clock family, no per-edge field to forget¶
There is no separate deployBudgetSeconds / deploySingleHopBudgetSeconds pair to tune any more - both fields are gone, along with the Deploying-phase deploy-supervision backstop they bounded. Every stage now carries the same three-clock family (admission, readiness, work), armed by which timestamps are set, against a per-stage budget. There is no per-edge deadline field left to forget. See reference/task-stages.md for the full transition table, the per-stage budgets, and the three-clock mechanics.
Where these values live¶
| Project | Values file |
|---|---|
tatara (self-hosting) | tatara-helmfile/values/project-tatara/common.yaml |
infrastructure (GitLab) | tatara-helmfile/values/project-infrastructure/common.yaml |
| Operator deployment itself (image tag, replica count) | tatara-helmfile/values/tatara-operator/common.yaml |
Flow for any change on this page:
- Edit the relevant
common.yamlin atatara-helmfilebranch. - Open a PR. CI posts a sticky
helmfile diffcomment showing the exact rendered change. - Review the diff, merge to
main. - The in-cluster ARC runner applies automatically - no manual
helmfile apply, nokubectlmutation.
This is the only sanctioned path. See Deployment & GitOps for the full pipeline and rollback story.