Issue-lifecycle M0 - lifecycle model + skeleton - Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Introduce the issueLifecycle Task kind, its Status.LifecycleState machine fields, the binders that create it, and a reconcileLifecycle skeleton that wires the Triage and Implement states to the existing agent-run machinery - deployable but inert beyond triage+implement.
Architecture: Per spec docs/superpowers/specs/2026-06-12-issue-lifecycle-agent-design.md. The lifecycle reconcile dispatches on Status.LifecycleState, reusing ensurePodAndService/driveTurns for agent-run states and transitioning state on the agent's terminal decision. M0 lands the model + Triage + Implement -> MRCI handoff (MRCI/Merge/MainCI are M1).
Tech stack: Go (controller-runtime, kubebuilder), envtest, table-driven tests.
Repo: tatara-operator only. Branch off fresh main in a worktree.
File structure¶
api/v1alpha1/task_types.go- newTaskStatusfields (lifecycle*).api/v1alpha1/project_types.go- newAgentSpec/ScmSpecfields.api/v1alpha1/zz_generated.deepcopy.go- regenerated.config/crd/bases/*.yaml+charts/.../crds/*.yaml- regenerated CRDs.internal/controller/lifecycle.go- NEW:reconcileLifecycle+ state handlers + transition helper.internal/controller/lifecycle_test.go- NEW: table-driven state tests.internal/controller/task_controller.go- dispatchissueLifecycleKind toreconcileLifecycle; per-kind inflight gauge includesissueLifecycle.internal/controller/projectscan.go-issueScancreatesissueLifecycle(Triage) instead oftriageIssue;mrScanbot-PR createsissueLifecycle(MRCI entry) instead ofselfImprove. (MRCI handler is M1; M0 sets the entry state + dedup.)internal/webhook/server.go- labeled-issue path createsissueLifecycle; bot-PR path createsissueLifecycle(MRCI entry).internal/obs/metrics.go- lifecycle gauges/counters scaffolding.
Per spec, the standalone triageIssue/selfImprove Task-creation is replaced; their writeback logic is reused by the lifecycle handlers (folded in M0/M1, not duplicated). Keep review/brainstorm arms.
Task 1: Task lifecycle status fields¶
Files: Modify api/v1alpha1/task_types.go; Test api/v1alpha1/task_types_test.go (or an envtest CRUD test in controller suite).
- Step 1: Write the failing test. A test that constructs a
Task, sets each new status field (LifecycleState="Triage",LastActivityAt,DeadlineAt,HeadBranch,PRNumber,MergeCommitSHA,CumulativeTokens,LastTurnInputTokens,LifecycleIterations,Handover), round-trips it through the fake clientStatus().Update/Get, and asserts the values persist. Also assert theKindenum acceptsissueLifecycle. - Step 2: Run it - expect FAIL (fields/enum value absent).
- Step 3: Add the fields to
TaskStatuswith kubebuilder markers exactly as the spec's CRD section:lifecycleState(enumTriage;Conversation;Implement;MRCI;Merge;MainCI;Done;Stopped;Parked),lastActivityAt/deadlineAt(*metav1.Time),headBranch/mergeCommitSHA/handover(string),prNumber/lifecycleIterations(int),cumulativeTokens/lastTurnInputTokens(int64). AddissueLifecycleto theKindenum marker onTaskSpec.Kind. - Step 4:
make manifests generate(regenerate CRDs + deepcopy). Run the test - expect PASS. - Step 5: Commit
feat(api): Task lifecycle status fields + issueLifecycle kind.
Task 2: Project agent/scm lifecycle config fields¶
Files: Modify api/v1alpha1/project_types.go; Test alongside Task 1's suite.
- Step 1: Failing test asserting a
Projectacceptsspec.agent.contextWindowTokens,spec.agent.handoverThresholdPercent,spec.agent.maxLifecycleIterations,spec.scm.babysitDeadlineMinutes,spec.scm.conversationIdleMinutesand that unset values default per the kubebuilder markers (200000 / 50 / 10 / 60 / 60). - Step 2: Run - FAIL.
- Step 3: Add the fields with
+kubebuilder:default=markers exactly per spec. (AgentSpecalready hasMaxTurnsPerTask,TurnTimeoutSeconds; add alongside.ScmSpecalready hasMergePolicyetc.) - Step 4:
make manifests generate; test PASS. - Step 5: Commit
feat(api): Project lifecycle config (context window, deadlines, idle).
Task 3: Lifecycle transition helper + metrics¶
Files: Create internal/controller/lifecycle.go; Modify internal/obs/metrics.go; Test internal/controller/lifecycle_test.go, internal/obs/metrics_test.go.
- Step 1: Failing test for a
setLifecycleState(ctx, task, to, reason)helper: it mustRetryOnConflict-updateStatus.LifecycleState, emit an INFO logaction=lifecycle_transitionwithfrom/to, and incrementtatara_lifecycle_transition_total{from,to}. Assert the field changes and the counter increments (use a test registry). - Step 2: Run - FAIL.
- Step 3: Implement
setLifecycleState(RetryOnConflict re-Get + status update, likeclearWritebackPending) and the metrics: gaugestatara_lifecycle_state{state}, counterstatara_lifecycle_transition_total{from,to},tatara_lifecycle_handover_total,tatara_lifecycle_giveup_total{reason}, histogramstatara_mrci_wait_seconds,tatara_lifecycle_seconds. Add the obs methods. - Step 4: Test PASS.
- Step 5: Commit
feat(controller): lifecycle transition helper + metrics.
Task 4: reconcileLifecycle skeleton dispatch¶
Files: Modify internal/controller/task_controller.go, internal/controller/lifecycle.go; Test lifecycle_test.go.
- Step 1: Failing test that a
TaskwithKind=issueLifecycleand emptyLifecycleStateis initialized toTriagebyreconcileLifecycle, and that an unknownLifecycleStatereturns an error (defensive). Drive via the reconciler with envtest fakes. - Step 2: Run - FAIL.
- Step 3: Implement. In
Reconcile, after the existing gates (memory, concurrency, approval) and before the generic spawn, add:if task.Spec.Kind == "issueLifecycle" { return r.reconcileLifecycle(ctx, &project, &task) }. ImplementreconcileLifecycleswitching onStatus.LifecycleState: empty -> setTriage;Triage/Implement-> the agent-run handlers (Task ⅚);MRCI/Merge/MainCI/Conversation-> a stub returningRequeueAfter: pollRequeuewith a// M1/M2marker (NOT an error);Done/Stopped/Parked->ctrl.Result{}, nil; default -> error. UpdateupdateInflightGaugeknown-kinds list to includeissueLifecycle. - Step 4: Test PASS.
- Step 5: Commit
feat(controller): reconcileLifecycle skeleton dispatch.
Task 5: Triage state (reuse the triage agent-run + issue_outcome)¶
Files: Modify internal/controller/lifecycle.go; Test lifecycle_test.go.
- Step 1: Failing tests (table) for the Triage handler driving an agent run via the existing pod/turn machinery to a terminal result carrying an
IssueOutcome: IssueOutcome.Action=close->CloseIssuecalled with the comment, transition toDone.IssueOutcome.Action=discuss->Commentposts the questions, transition toConversation, setDeadlineAt = now + conversationIdleMinutes.IssueOutcome.Action=implement-> transition toImplement(no SCM write). Use the fakeSCMWriterto assert the right egress; assert the state field after.- Step 2: Run - FAIL.
- Step 3: Implement the Triage handler: ensure pod + drive turns (reuse
ensurePodAndService/driveTurns); on the run's terminal (the agent calledissue_outcome, recorded inStatus.IssueOutcome), tear down the wrapper session/pod (reuse the cleanup half ofterminatewithout setting a terminal Task phase - extract ateardownAgentRunhelper), then branch onIssueOutcome.Actionexactly as the tests require, usingsetLifecycleState. The close path reuseswriteBackIssue's CloseIssue logic; the discuss path reuseswriter.Comment. - Step 4: Test PASS.
- Step 5: Commit
feat(controller): lifecycle Triage state.
Task 6: Implement state -> open MR -> MRCI handoff¶
Files: Modify internal/controller/lifecycle.go; Test lifecycle_test.go.
- Step 1: Failing test: the Implement handler drives an agent run, then on terminal opens the MR via the existing
writeBackOpenChangeegress, recordsPrURL/prNumber/headBranch, and transitions toMRCI. AssertOpenChangecalled withtaskBranch(task)and the default branch,prNumberparsed, state ==MRCI. - Step 2: Run - FAIL.
- Step 3: Implement the Implement handler: ensure pod + drive turns (the existing plan->subtask flow, agent pushes
taskBranch); on terminal,teardownAgentRun, call the shared open-change logic (refactorwriteBackOpenChangeso the lifecycle can call it and read back the opened PR URL/number), setStatus.PrURL/prNumber/headBranch, incrementlifecycleIterations,setLifecycleState(MRCI). (M0: the MR body is the existingwriteBackBody; M4 enriches it with scope. TheCloses #Nlink is added in M1/M4 - M0 keeps the existing body.) - Step 4: Test PASS.
- Step 5: Commit
feat(controller): lifecycle Implement state opens MR, enters MRCI.
Task 7: Binders create issueLifecycle (issueScan + labeled-issue webhook)¶
Files: Modify internal/controller/projectscan.go, internal/webhook/server.go; Test projectscan_test.go, server_test.go.
- Step 1: Failing tests:
issueScanover an eligible open issue creates a Task withKind=issueLifecycle(entry state empty -> Triage), labelssource-repo/source-number/source-kind=issueLifecycle; lane occupancy countsissueLifecycle(replacingtriageIssuein the kinds arg).- A labeled-issue webhook (
triggerLabelapplied) createsissueLifecyclewithStatus.LifecycleState=Implement(the spec's "issue carrying triggerLabel -> enter at Implement"). - Dedup: a non-terminal
issueLifecycleTask for(repo, number)suppresses re-creation;LifecycleState in {Done,Stopped,Parked}frees it. - Step 2: Run - FAIL.
- Step 3: Implement. In
projectscan.go, change the issueScan Task kind toissueLifecycle, updatelaneOccupancy(..., "issueLifecycle"), and the dedup terminal check to include lifecycle terminals. Inserver.go, the labeled-issue branch createsissueLifecycleand sets the initialLifecycleState=Implementwhen the trigger label is present at creation (else Triage). Keep the existing per-(repo,number)dedup labels. - Step 4: Test PASS.
- Step 5: Commit
feat(controller): binders create issueLifecycle tasks.
Task 8: Binder creates issueLifecycle MRCI-entry for bot PRs (replaces selfImprove)¶
Files: Modify internal/controller/projectscan.go, internal/webhook/server.go; Test as above.
- Step 1: Failing tests:
mrScan(and thepull_requestbot-authored webhook) for a bot-authored open PR creates a TaskKind=issueLifecycle,Status.LifecycleState=MRCI,prNumber/PrURLset, keyed to the linked issue number if the PR body hasCloses #Nelse to the PR number. Human-authored PRs still createreview. Lane occupancy for mrScan countsissueLifecycle(MRCI) +review. - Step 2: Run - FAIL.
- Step 3: Implement. Replace the bot-PR
selfImprovecreation withissueLifecycleMRCI-entry creation (setLifecycleState=MRCI,prNumber,PrURL). Reuse the existing authorship determination (GetPRState.Author == botLogin). ParseCloses #Nfrom the PR body for the dedup key (best-effort; fall back to PR number). Keepreviewfor human PRs. UpdatelaneOccupancy(..., "issueLifecycle", "review"). - Step 4: Test PASS.
- Step 5: Commit
feat(controller): bot-PR binder creates issueLifecycle MRCI entry.
Task 9: Remove dead single-shot triageIssue/selfImprove creation; keep shared logic¶
Files: internal/controller/writeback.go, task_controller.go, tests.
- Step 1: Failing/again-green test sweep. Confirm no binder creates
triageIssueorselfImproveanymore (grep + a test asserting issueScan/mrScan kinds). The authorship pre-spawn gate intask_controller.go(Kind=="selfImprove") is repurposed: the MRCI-entry lifecycle Task's authorship is verified at MRCI handling (M1) instead - for M0, move the existingselfImproveBotAuthoredgate to also acceptissueLifecycleMRCI entries, or defer to M1. KeepwriteBackSelfImprove/writeBackIssueas reusable helpers (called by lifecycle handlers), not as kind-dispatch arms that are now unreachable - delete the unreachabledoWriteBackarms fortriageIssue/selfImproveonly if nothing else calls them, else leave a clear comment. NO dead code (hard rule 4): either reused or removed. - Step 2: Run the full suite - PASS (
make test). - Step 3:
gofmt,golangci-lint run,helm lint charts/*- clean. - Step 4: Commit
refactor(controller): retire standalone triageIssue/selfImprove creation.
Self-review checklist (run before requesting review)¶
- Every new status/spec field has a kubebuilder marker matching the spec.
- CRDs + deepcopy regenerated (
make manifests generate); chartcrds/updated. -
reconcileLifecyclehandles every enum value (no fallthrough panic); M1/M2 stubs requeue, do not error. - No binder creates
triageIssue/selfImprove; no unreachable dead code. -
updateInflightGaugeknown-kinds includesissueLifecycle. - Full suite green with
-race; gofmt/golangci/helm-lint clean.