Skip to content

Task

A Task is the durable, per-project object that carries one implementation stream from the moment it is triaged to the moment it is delivered. It is not one pod, not one PR, and not one issue. It owns the SCM artifacts it spans - which are separate Issue and MergeRequest CRs, listed in status.issueRefs and status.mrRefs - and it outlives every pod that ever runs for it.

apiVersion: tatara.dev/v1alpha1
kind: Task

Two enums on this CR are easy to confuse, and the whole model hangs on the difference between them.

spec.kind is the ORIGIN. It is immutable and baked into the Task's name.

status.agentKind is the AGENT THAT IS RUNNING RIGHT NOW. It changes as the Task moves through its states. Seven values.

clarify is gone, platform-wide, as of the #521 lifecycle redesign - both as an agent kind and as an origin: spec.kind: implement is its direct replacement in MintIssueTask (SweepIssueKind = "implement"), the same function that mints a Task from any new issue, whether webhook-delivered or sweep-discovered. Its three decisions folded into action values on the implement outcome (see Approval Gates), so the pod that judges approval is the same pod that goes on to write the code, and the origin kind that starts that conversation is implement itself.

spec.kind (origin) Minted by Entry state State after triage
brainstorm a project cron new refined
incident a Grafana alert webhook new refined
implement an issue webhook, or the backlog sweep new refined
refine a project cron new refined
review a PR/MR webhook (always a human's PR) new awaiting-review
documentation the nightly documentation batch cron under-implementation - (no triage)
takeover a maintainer's hand-over comment on a foreign MR under-implementation - (no triage)
upgrade the project's dependency-upgrade cron (scm.cron.upgrade) under-implementation - (no triage)

Eight spec.kind values now, not six. takeover is the pre-existing maintainer-gated MR hand-over path, unrelated to the #521 rename. upgrade (2026-08-13) is the newest addition - a scheduled, opt-in, disabled-by-default agent that takes one dependency-upgrade unit per Task; like documentation, it mints straight into under-implementation because its submitted/declined outcome schema has no approval gate to drive at refined. See Upgrade.

The Task's name is <project>-<kind>-<YYYY-MM-DD>-<uid5>, capped at 49 characters. Its pod's name is computed independently - see Pod naming for both formats, and for why 49.

Operator-managed

Tasks are created and fully managed by the operator, from an admitted QueuedEvent. status.state is written by the operator only - no agent writes it, and no agent asks for a state. An agent submits an outcome; the operator decides what that outcome means. Direct kubectl apply of a Task is for debugging, not a normal path.

Progress lives in exactly one field, status.state, and it has eight members - down from the pre-redesign machine's fifteen status.stage members, with parking and pod-liveness split out into their own orthogonal properties. The transition table, the three clocks that bound every state, the cycle caps and the closed set of reasons are all on their own page: the Task state machine.

Continuation state lives in exactly one field, status.notes, and it is an append-only journal: Task notes.


TaskSpec

type TaskSpec struct {
    ProjectRef string `json:"projectRef"`
    // RepositoryRef is the PRIMARY repo, set ONLY on documentation Tasks.
    // +optional
    RepositoryRef string `json:"repositoryRef,omitempty"`
    // +kubebuilder:validation:MaxLength=16384
    Goal string `json:"goal"`
    // Kind is the ORIGIN. Immutable, baked into the name. NOT the running agent
    // kind (that is Status.AgentKind).
    // +kubebuilder:validation:Enum=brainstorm;incident;implement;refine;review;documentation;takeover;upgrade
    Kind string `json:"kind,omitempty"`
    // +optional
    // +kubebuilder:validation:MaxItems=20
    MergeOrder []string `json:"mergeOrder,omitempty"`
    // +optional
    // +kubebuilder:validation:MaxItems=50
    AlertRules []string `json:"alertRules,omitempty"`
    // +optional
    DedupKey string `json:"dedupKey,omitempty"`
    // +optional
    // +kubebuilder:validation:MaxItems=100
    DocumentsTasks []string `json:"documentsTasks,omitempty"`
    // +optional
    MaxTurnsPerTask int `json:"maxTurnsPerTask,omitempty"`
}
Field Type Required Description
projectRef string yes Parent Project CR name
repositoryRef string conditional The primary repo. Set only on documentation Tasks (the docs repo). Every other kind is project-scoped and leaves it empty
goal string yes The natural-language goal. Non-evictable: the byte guard can spill comments and notes, but it can never shrink the goal, so the goal carries a hard cap of its own (MaxLength=16384) or it eats the budget the guard is defending
kind enum yes The origin: brainstorm, incident, implement, refine, review, documentation, takeover, upgrade. Immutable. clarify is gone; implement takes over its origin role (see above). takeover predates the #521 rename; upgrade is the newest addition
mergeOrder []string conditional The sequential, dependency-ordered list of Repository CR names whose MRs merge in this order. Required - and validated to cover every owned MR's repo - whenever the Task owns MRs in more than one repo. MaxItems=20
alertRules []string no Grafana alert-rule names that triggered an incident Task. MaxItems=50
dedupKey string no The incident alert-group hash. Empty on every non-incident Task
documentsTasks []string no The delivered Tasks this nightly documentation batch covers. MaxItems=100
maxTurnsPerTask int no Deprecated, zero effect (see Project.spec.agent.maxTurnsPerTask). Kept on the schema only because it still round-trips from stored Tasks and old callers.

mergeOrder has no lexical default

A multi-repo Task without a mergeOrder is a validation failure, not a lexically-ordered merge. Lexical order across this platform's own repos is agent-skills < cli < claude-code-wrapper < operator - which merges cli before operator, and that is precisely the schema-skew fleet outage this redesign exists to prevent. A default that is wrong in the most important case is worse than no default.

The old single spec.maxTurns is gone, split into maxTurnsPerPod / maxTurnsPerTask (the split survives on the schema), and both of those - along with maxReviewRounds and maxPodRecreations - are now deprecated with zero enforcement. A turn or round count measures how much an agent has done, not whether it is stuck; see Project.spec.agent for what replaced them (probe-based stall detection and a hardcoded 24h residency cap).


TaskStatus

Field Type Description
state enum The 8-member state (new, refined, under-implementation, awaiting-review, merged, deployed, done, rejected). The only progress field. Written by the operator only. See the state machine
stateEnteredAt time Stamped on every transition. The clock for the operator-driven states
stateReason string The reason on done / rejected only - mandatory on rejected. Closed, disjoint from parkReason. See stage reasons
parkReason enum Whether the Task is stalled, orthogonal to state: a Task parks where it is, not into a fourth state. Empty, or one of 34 closed reasons. See the park flag
parkedAt time When parkReason was set. The base of the park-retention clock (7d, except backlog-sweep, which never ages out)
parkedFromState string Observability only for most reasons - the un-park target is re-derived from Issue.status.status and the owned-MR state, never read back from here - except the no-outcome un-park gate, which does require it to be under-implementation or awaiting-review
retryAttempts int The retry lane's own counter, folded across the park round trip like stageElapsedCarrySeconds - real progress or a human UnparkHuman release resets it, a bare re-park does not. Cap MaxUnparkRetries (5), then parked(retry-exhausted)
retryNextAt time When the retry lane's next backoff attempt is due. DeclineRetryNotDue in stage.Unpark refuses an early un-park call, so the schedule holds even from a webhook-triggered re-check
agentKind enum The agent running now: brainstorm, incident, refine, review, documentation, implement, upgrade. Seven values - clarify is gone
podName string The current agent pod's name. See Pod naming
podStartedAt time Stamped when the pod is created, and re-stamped on every respawn. It arms the readiness clock, and it is the base of the pod TTL (podStartedAt + agentPodTTLSeconds). Cleared on every transition
stateWorkStartedAt time Stamped when the pod becomes Ready. It arms the idle/work clock. Cleared on every transition
conversationLastEventAt time The idle clock's own anchor for the three live states - the latest of the last human comment, pod-ready, or last turn-complete. Re-stamped only by a human webhook comment, never by the agent's own activity
notes []Note The append-only journal. It is the continuation state. See Task notes
pendingEvents []TaskEvent Mid-flight SCM events awaiting the next turn boundary. See below
stats TaskStats Tokens, turns, pods, artifacts
accountUsage *TaskAccountUsage This Task's pod's newest Claude subscription usage snapshot (observedAt, fiveHourPercent, fiveHourReset, weeklyPercent, weeklyReset), reported by the agent's silent cc-statusline command via the wrapper's turn-complete callback. Nil until the pod's statusline has reported at least once. Per-Task input only - the tokenBudget gate itself reads a leader-only fleet-wide fold of every Task's snapshot, not this field directly. See Tuning (tatara-operator#633)
deliveredAt time When the Task reached done. The reaper's 48h clock runs from here
documentedBy string The nightly documentation batch Task that covered this delivered Task. Empty until a batch covers it, and permanently empty for a Task that shipped no code
issueRefs []string The Issue CRs this Task owns. MaxItems=50
mrRefs []string The MergeRequest CRs this Task owns. MaxItems=50
mergeCursor int How far the sequential merge got through spec.mergeOrder. Persisted, so a restarted operator resumes and never re-merges
mergeReentries int Bounds the merged re-entry cycle. Cap 3, then parked(merge-blocked)
deployReentries int Bounds the deployed re-entry cycle. Cap 3, then parked(deploy-blocked)
headMoveReentries int Bounds the awaiting-review / merged moved-head cycle. Cap 3, then parked(head-moving). This one spawns a review pod every lap
ciRedReentries int Bounds the re-implement cycle when live CI on the reviewed head goes red after approval. Cap 3, then parked(ci-blocked)
ciWaitSince time Set while the operator holds an un-parked Task waiting for a pending pipeline on its own open owned MR to resolve, at outcome-submission time. Not a park and not a new state - a parked Task cannot be driven, and this hold needs to keep reconciling. Bounded at 30 minutes (CIWaitDeadline), then the Task advances anyway. Cleared on every exit: CI goes green (awaiting-review), CI goes red (enterCIRed, back into implement), or the deadline passes
humanReviewRounds int Bounds the un-park cycle of a review-kind Task. Cap 5 (maxHumanReviewRounds), then it stays parked. Also spawns a pod every lap
pinnedPlanNoteId string The plan note's id, pinned at action=approved grant. The operator hashes that note's body at grant and re-checks it before code is written - a plan swapped after approval routes back to refined (plan-hash-mismatch) instead of proceeding
foldInFlight []string The member Tasks a refine umbrella is mid-adoption of. The reaper skips anything named here
resolvedModel string The model resolved for this Task's pod at spawn (modelByKind on the agent kind, else the project model). Stamped once, so cost is priced by the model that actually ran
shortDescription string One-line description, for the print columns
conditions []Condition Standard Kubernetes conditions

The five cycle-cap counters above are the Task-side half of the story. The sixth, reviewRounds, lives on the MergeRequest - see cycle caps for all six in one table.

!!! warning "There is no deployDeadline, no mergeWaitDeadline, no reviewResolveDeadline" The per-edge deadline family is gone, generalised into stateEnteredAt plus the three-clock model. Three clocks - admission, readiness, work/idle - are armed by which timestamps are set, and every state carries a budget. There is no per-edge deadline field left to forget on a new edge. See the deadline invariant.

Note

type Note struct {
    At    metav1.Time `json:"at"`
    // Agent is the WRITER. The REST layer stamps it from Status.AgentKind; an
    // agent can NEVER produce "operator".
    // +kubebuilder:validation:Enum=brainstorm;incident;clarify;refine;review;documentation;implement;operator;upgrade
    Agent string `json:"agent"`
    // +kubebuilder:validation:Enum=note;plan;handoff
    Kind string `json:"kind"`
    // +kubebuilder:validation:MaxLength=4096
    Body string `json:"body"`
}

Full semantics on Task notes.

TaskEvent

type TaskEvent struct {
    At metav1.Time `json:"at"`
    // +kubebuilder:validation:Enum=issue_comment;mr_comment;mr_review;label;alert
    Kind   string `json:"kind"`
    Repo   string `json:"repo"`   // Repository CR name
    Number int    `json:"number"` // 0 for kind=alert
    Author string `json:"author"`
    // +kubebuilder:validation:MaxLength=4096
    Body string `json:"body"`
}

TaskStats

type TaskStats struct {
    TokensInput         int64    `json:"tokensInput,omitempty"`
    TokensOutput        int64    `json:"tokensOutput,omitempty"`
    TokensCacheRead     int64    `json:"tokensCacheRead,omitempty"`
    TokensCacheCreation int64    `json:"tokensCacheCreation,omitempty"`
    Turns               int      `json:"turns,omitempty"`
    PodRuns             int      `json:"podRuns,omitempty"`
    WallSeconds         int64    `json:"wallSeconds,omitempty"`
    AgentsRun           []string `json:"agentsRun,omitempty"`
    IssueCount          int      `json:"issueCount,omitempty"`
    MRCount             int      `json:"mrCount,omitempty"`
    PodRecreations      int      `json:"podRecreations,omitempty"`
    NotesSpilled        int      `json:"notesSpilled,omitempty"`
    NotesSpilledRefs    []string `json:"notesSpilledRefs,omitempty"`
    AgentStops          int      `json:"agentStops,omitempty"`
}
Field Description
tokensInput / tokensOutput / tokensCacheRead / tokensCacheCreation Token accounting across every pod of this Task
turns Lifetime turns across every pod. No longer checked against a cap - maxTurnsPerTask is deprecated and turn-budget-exhausted no longer occurs. Observability only
podRuns Pods this Task has run
wallSeconds Total agent wall time
agentsRun The agent kinds that have run. MaxItems=50
issueCount / mrCount Owned Issue / MergeRequest counts. Both are print columns
podRecreations Pod respawns within the current state. No longer capped - maxPodRecreations is deprecated and pod-recreation-exhausted no longer occurs; a Task now respawns indefinitely up to the residency cap (24h). Still counted (feeds operator_pod_recreations_total, labeled by reason since tatara-operator#587) so the compensating alert has data. Reset to 0 on every transition
notesSpilled Notes evicted to tatara-memory by the byte guard
notesSpilledRefs One track_id per spill batch. It accumulates - a single scalar ref would orphan every earlier batch. Read back with task_context(notes=all)
agentStops Consecutive agent-requested stops in the current state. Past AgentStopReArmCap (3) the dispatcher parks no-outcome instead of spawning a replacement pod. Reset on real progress or an un-park

kubectl get task shows: State (.status.state), Park (.status.parkReason), Agent (.status.agentKind), Kind (.spec.kind), Project (priority 1), Turns (.status.stats.turns), Description, plus kubectl's own default Age.

State and Kind next to each other is the fastest way to see the thing people get wrong: a Task named ...-refine-... sitting at state=under-implementation with agentKind=implement is not an anomaly. It is the normal path - state is kind-agnostic, and Park being non-empty is what actually means the Task is stalled, not any particular state value.


The etcd object budget

A Task is a hot object with unbounded-ish lists on it, and an object that grows past the API server's ceiling becomes permanently unwritable. That is the worst failure mode in the design - every writer fails, and the Task's Issues stay pinned open by ownership forever - so it is foreclosed before it can happen.

Every write is sized before it is issued. fitForWrite marshals the object and evicts oldest comments and oldest notes until it is under 800,000 bytes - half the ~1.5 MiB etcd ceiling.

The headroom is not timidity. metadata.managedFields grows unboundedly under repeated server-side-apply patches on a hot object and is counted against the same limit, so half the ceiling is reserved for the part of the object the operator does not control.

The eviction itself is ordered: the spill to tatara-memory happens once, outside the retry closure, and a note or comment is dropped only on spill success. Nothing is ever dropped into a hole. The in-cluster trim that follows is pure and in-memory, so it is safe to re-run on a conflict.

A count cap is not a byte cap

"409 when there are 200 notes" is a count cap. 200 notes of 4 KB is 800 KB; 200 notes of 40 KB is 8 MB. Only bytes are bytes. And a 413 is not retried by RetryOnConflict, so the failure is silent, total, and permanent. That is why the guard is byte-exact and runs before the write, not a MaxItems marker hoping for the best.

When the guard cannot win - the object exceeds the budget with nothing left to evict - the Task parks loudly at parkReason=object-too-large, written through a minimal status patch (MinimalFailPatch) that touches only status.parkReason / status.parkedAt, carries none of the oversized lists, and therefore cannot itself 413. This is uniform across every state now - there is no separate failed terminal for pod-less states to route to instead. object-too-large is UnparkNever: it has no re-entry and ages out at ParkRetention like any other terminal park. A note write is never rejected on a count cap (an agent must always be able to write its handoff), and a Task that genuinely cannot fit dies with a recorded reason instead of becoming a silently unwritable zombie.


Mid-flight events

status.pendingEvents is how new SCM activity reaches an agent that is already running. Events are delivered at the turn boundary - never mid-turn - and render ahead of the context bundle, so the agent reads the delta first and the refreshed baseline second. If no pod is running, one spawns and they ride in turn 0.

The list is capped at 20, drop-oldest, in Go, before the write. MaxItems=25 is a backstop only: an API-server 422 is not retried by RetryOnConflict and would hot-loop webhook redelivery.

It is cleared by set-difference inside the retry closure, keyed on (kind, repo, number, at), and never by nil-assign - a webhook arriving between render and clear must not be silently dropped - and only after the wrapper has accepted the submit.

A bot-authored event is never enqueued

The enqueue filter drops any event whose author is Project.spec.scm.botLogin. This is load-bearing, not hygiene: several paths do post as the bot - an agent's own issue_write(action=comment), the operator's deploy-timeout notice, the terminal reap comment - and without the filter any of those would land in the Task's own pendingEvents and un-park the Task the platform just parked itself into, a fully autonomous loop driven by nothing but the platform talking to itself. Not every park reason posts a comment at all - identity-unverified posts nothing to the thread - but the filter has to hold uniformly regardless of which reasons do.


Removed fields

Everything below was on the pre-redesign Task and is gone. If you have automation, dashboards or jsonpath queries reading any of it, this table is the migration.

Removed Where it went
phase, lifecycleState status.stage - one field, fifteen members, no dual-terminal helper
parkReason status.stageReason (now a closed set, and mandatory on every terminal)
pendingInterjections, pendingComments status.pendingEvents
workItems (and WorkItemRef) the Issue and MergeRequest CRs, referenced by status.issueRefs / status.mrRefs. It was an embedded slice and never a CRD
subtasks (and SubtaskRef, and the Subtask CRD itself) status.notes, as a plan note
sessionID, conversationObjectKey, handover status.notes, as a handoff note. There is no session resume and no continuation preamble
prURL, prNumber, headBranch, mergeCommitSHA, mergedHeadSHA MergeRequest.status
deployedVersion, deployArtifact, cascadeStage MergeRequest.status. There is no cascade state machine any more; merging and deploying are ordinary stages
changeSummary MergeRequest.status.significance, plus the submit_outcome payload
reviewVerdict, prOutcome, issueOutcome, implementOutcome, brainstormOutcome submit_outcome - one tool name, one schema per agent kind
turnsCompleted, cumulativeTokens, lastTurnInputTokens, cumulativeInput, cumulativeOutput, cumulativeCacheRead, cumulativeCacheCreation status.stats
approvedByMaintainer, autoApproved Issue.status.approval (single-use ApprovalEvidence). Approval is comment text, matched by the operator; labels are write-only
gateEnteredAt, lastActivityAt, deadlineAt, mergeWaitDeadline, reviewResolveDeadline, deployDeadline generalised into stageEnteredAt plus the three-clock family. The wedge class they killed stays killed; the guarantee is now total instead of per-edge
implementContext, implementEmptyRetries, implementGiveUps, writebackSkip4xxAttempts, disarmFailures, lifecycleIterations deleted. The cycle caps replace the ad-hoc loop-breakers
resultSummary, discoveredIssues, followupIssueURL, linksSyncedURLs, linksSyncFailures, issueLinks, prLinks deleted. The Issue / MergeRequest mirrors and submit_outcome cover all of it
spec: source (and TaskSource), maxTurns, approvalRequired, proposedIssue (and ProposedIssueSpec), reposInScope, systemicGroup (and SystemicGroup), alertRule maxTurns split into maxTurnsPerPod / maxTurnsPerTask; alertRule became alertRules; the rest fold into the Issue CR, ownership, and mergeOrder
kind enum: implement, selfImprove, triageIssue, healthCheck, issueLifecycle triageIssue and the front half of issueLifecycle landed on clarify at the time, healthCheck on brainstorm, and the back half of issueLifecycle on the operator's own merging / deploying stages. clarify itself is gone too now - see the #521 rename below

The #521 rename: stage (15 members) became state (8) plus parkReason

A second redesign, independent of the migration above, replaced status.stage and its fifteen members with three orthogonal properties. This is not a removal in the same sense as the table above - it is a field rename plus a decomposition, and it is recent enough that dashboards and jsonpath built against the first redesign's stage field need this table too:

Old (status.stage, 15 members) New
status.stage status.state - 8 members: new, refined, under-implementation, awaiting-review, merged, deployed, done, rejected
stage=parked (a stage, a terminal, and a pod-less marker, conflated) status.parkReason - an orthogonal flag on top of whichever state the Task is already in. See the park flag
stage=failed (and every failed(...) reason) folded into status.parkReason (most reasons) or status.state=rejected (a genuine stop, e.g. false-positive) - there is no failed state any more
stage=delivered status.state=done
stage=triaging, clarifying, investigating, refining, approved status.state=new or refined, disambiguated by Task.status.agentKind, not by the state itself
stage=implementing status.state=under-implementation
stage=reviewing status.state=awaiting-review
stage=merging, deploying status.state=merged, deployed
stage=documenting status.state=under-implementation with agentKind=documentation
status.stageEnteredAt / stageWorkStartedAt / stageReason status.stateEnteredAt / stateWorkStartedAt / stateReason (the last now mandatory only on rejected, not on every terminal)
status.parkedFromStage status.parkedFromState (same observability-only semantics, plus one load-bearing use: the no-outcome un-park gate)
Task.spec.kind: clarify Task.spec.kind: implement (SweepIssueKind) - the same mint path, an issue webhook or the backlog sweep. Its three decisions became action values on the implement outcome - see Approval Gates

Note.Agent's enum still lists clarify as a valid historical value (notes already in etcd say clarify) even though nothing can write it any more - removing an already-used enum value breaks CRD ratcheting on every Task that still carries one. See the note on AgentKindFor having no clarify case above.

spec.dedupKey is kept. Five of the six old dedup mechanisms fold into the (repo, number) natural key of the Issue and MergeRequest CRs and are genuinely deleted. The sixth does not: dedupKey is the incident alert-group hash, and a firing alert arrives from Grafana with no issue and no PR to key on. There is no natural key for it to fold into.

The internal/harness REST endpoints are also deleted. QueuedEvent is not deleted.


Example

apiVersion: tatara.dev/v1alpha1
kind: Task
metadata:
  name: tatara-incident-2026-07-12-m4z8q
  namespace: tatara
spec:
  projectRef: tatara
  kind: incident
  goal: |
    Resolve tatara-operator#291: the reaper deletes a Task whose pod is
    mid-turn. Confirm scope with the maintainer, then implement.
  mergeOrder:
    - tatara-operator
    - tatara-cli

mergeOrder is set here because this stream is expected to touch two repos and the operator merges them in that order, one at a time, each on green CI.


Inspecting a Task

# Every Task on a project, with State / Park / Agent / Kind / Turns
kubectl -n tatara get task -l tatara.dev/project=tatara

# Why is it parked?
kubectl -n tatara get task tatara-incident-2026-07-12-m4z8q \
  -o jsonpath='{.status.state}{" "}{.status.parkReason}{"\n"}'

# The journal - this is the continuation state, and the first thing to read
kubectl -n tatara get task tatara-incident-2026-07-12-m4z8q \
  -o jsonpath='{.status.notes}' | jq -r '.[] | "\(.at) \(.agent)/\(.kind): \(.body)"'

# What it owns
kubectl -n tatara get task tatara-incident-2026-07-12-m4z8q \
  -o jsonpath='{.status.issueRefs}{"\n"}{.status.mrRefs}{"\n"}'

# Stream the current pod's logs
kubectl -n tatara logs \
  "$(kubectl -n tatara get task tatara-incident-2026-07-12-m4z8q \
      -o jsonpath='{.status.podName}')" \
  -c tatara-claude-code-wrapper -f

See also