tatara phase-label dedup + orphan-recovery (Option A) - implementation plan¶
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development (sonnet implementers, opus review) to implement task-by-task. Steps use checkbox (
- [ ]) syntax. TDD throughout: failing test first, watch it fail, minimal code, watch it pass, commit.
Goal: Make the issue's phase label the lifecycle state-of-truth and kill duplicate-assignment + orphaned-MR bugs, using label PRESENCE + Task STATE (NOT label-added-time - see the spec AMENDMENT 2026-06-13).
Architecture: tatara-operator (Go, controller-runtime). Four operator-managed phase labels (tatara-brainstorming/-approved/-implementation/-declined), exactly one current per issue. Dedup keys on label presence + Task state; an orphan (active phase label, no live Task) is recovered by a backstop pass that starts the correct lifecycle entry. Legacy tatara-idea/tatara-rejected are recognized as aliases and lazily migrated by the egress (no migration job).
Tech Stack: Go 1.26, controller-runtime/kubebuilder, envtest, testify-free table tests (t.Run).
Spec: docs/superpowers/specs/2026-06-13-tatara-phase-label-dedup-design.md (read its AMENDMENT first; the label-added-time mechanism + IssueLabelAddedAt are WITHDRAWN).
Pre-flight (the worktree is already created off fresh main; verify baseline)¶
export KUBEBUILDER_ASSETS="$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.21 use 1.33.0 -p path)"
go build ./... && gofmt -l . && go vet ./... && go test ./internal/controller/... ./internal/scm/... ./internal/webhook/...
KUBEBUILDER_ASSETS (above) before go test. EVERY task ends with gofmt -l . clean (lint does NOT gofmt-check here, per MEMORY), go vet ./..., and a conventional commit. Files touched overall: - api/v1alpha1/project_types.go (T1: new ScmSpec label fields) - api/v1alpha1/annotations.go (T5: shared turn-annotation consts) - internal/controller/labels.go (T1: label helpers + setLifecycleLabel) - internal/controller/lifecycle.go (T1 caller fix, T2 state-entry labels) - internal/controller/projectscan.go (T3 dedup, T4 backstop) - internal/controller/task_controller.go (T5: alias consts to shared) - internal/webhook/server.go (T5: reactivate owning Parked task) - charts/tatara-operator/crds/* (T1: make manifests) - matching *_test.go per task.
Tasks are SEQUENTIAL (shared files projectscan.go / lifecycle.go). Order: T1 -> T2 -> T3 -> T4 -> T5 -> T6.
Task 1: Four phase-label helpers + ScmSpec fields + CRD¶
Files: - Modify: api/v1alpha1/project_types.go (ScmSpec) - Modify: internal/controller/labels.go (lifecycleLabels, new helpers, setLifecycleLabel) - Modify callers: internal/controller/lifecycle.go (finishTriage), internal/controller/projectscan.go (brainstorm, proposalBacklog) - Test: internal/controller/labels_test.go - Regen: charts/tatara-operator/crds/ via make manifests
- Step 1: Failing tests in
labels_test.go:
func TestLifecycleLabelsFourDefaults(t *testing.T) {
b, a, i, d := lifecycleLabels(nil)
if b != "tatara-brainstorming" || a != "tatara-approved" || i != "tatara-implementation" || d != "tatara-declined" {
t.Fatalf("defaults: got %q %q %q %q", b, a, i, d)
}
s := &tatarav1alpha1.ScmSpec{BrainstormingLabel: "bs", ApprovedLabel: "ap", ImplementationLabel: "im", DeclinedLabel: "dc"}
b, a, i, d = lifecycleLabels(s)
if b != "bs" || a != "ap" || i != "im" || d != "dc" {
t.Fatalf("overrides: got %q %q %q %q", b, a, i, d)
}
}
func TestManagedAndActivePhaseLabelsIncludeLegacy(t *testing.T) {
managed := managedPhaseLabels(nil)
for _, want := range []string{"tatara-brainstorming", "tatara-approved", "tatara-implementation", "tatara-declined", "tatara-idea", "tatara-rejected"} {
if !contains(managed, want) {
t.Fatalf("managedPhaseLabels missing %q: %v", want, managed)
}
}
active := activePhaseLabels(nil)
for _, want := range []string{"tatara-brainstorming", "tatara-approved", "tatara-implementation", "tatara-idea"} {
if !contains(active, want) {
t.Fatalf("activePhaseLabels missing %q: %v", want, active)
}
}
if contains(active, "tatara-declined") || contains(active, "tatara-rejected") {
t.Fatalf("activePhaseLabels must NOT include terminal labels: %v", active)
}
}
contains helper already exists in the controller test package - repository_controller_test.go. If the linker complains it is unused elsewhere, reuse it; do not redeclare.) -
Step 2: Run, watch fail (
lifecycleLabelsreturns 3 values -> compile error / wrong count).go test ./internal/controller/ -run 'TestLifecycleLabelsFourDefaults|TestManagedAndActivePhaseLabelsIncludeLegacy' -
Step 3: ScmSpec fields in
project_types.go- after the existingApprovedLabel/RejectedLabelblock, keepingIdeaLabel/RejectedLabelas deprecated legacy aliases and ADDING:
// BrainstormingLabel marks an issue tatara is triaging / discussing (pre-approval).
// +kubebuilder:default="tatara-brainstorming"
// +optional
BrainstormingLabel string `json:"brainstormingLabel,omitempty"`
// ImplementationLabel marks an issue whose implementation is in flight.
// +kubebuilder:default="tatara-implementation"
// +optional
ImplementationLabel string `json:"implementationLabel,omitempty"`
// DeclinedLabel marks an issue declined before implementation (triage reject).
// +kubebuilder:default="tatara-declined"
// +optional
DeclinedLabel string `json:"declinedLabel,omitempty"`
IdeaLabel doc comment to: // IdeaLabel is DEPRECATED (legacy alias for BrainstormingLabel); kept for lazy migration. and RejectedLabel to // RejectedLabel is DEPRECATED (legacy alias for DeclinedLabel); kept for lazy migration. Keep their +kubebuilder:default values (tatara-idea/tatara-rejected). - Step 4: Rewrite
lifecycleLabels+ add helpers inlabels.go:
// lifecycleLabels returns the four managed phase labels (brainstorming/approved/
// implementation/declined), applying defaults when a field is empty.
func lifecycleLabels(s *tatarav1alpha1.ScmSpec) (brainstorming, approved, implementation, declined string) {
brainstorming, approved, implementation, declined =
"tatara-brainstorming", "tatara-approved", "tatara-implementation", "tatara-declined"
if s == nil {
return
}
if s.BrainstormingLabel != "" {
brainstorming = s.BrainstormingLabel
}
if s.ApprovedLabel != "" {
approved = s.ApprovedLabel
}
if s.ImplementationLabel != "" {
implementation = s.ImplementationLabel
}
if s.DeclinedLabel != "" {
declined = s.DeclinedLabel
}
return
}
// legacyLabels returns the deprecated idea/rejected labels (lazy migration).
func legacyLabels(s *tatarav1alpha1.ScmSpec) (idea, rejected string) {
idea, rejected = "tatara-idea", "tatara-rejected"
if s == nil {
return
}
if s.IdeaLabel != "" {
idea = s.IdeaLabel
}
if s.RejectedLabel != "" {
rejected = s.RejectedLabel
}
return
}
// managedPhaseLabels returns every label the operator owns (new + legacy), so
// setLifecycleLabel removes all-but-desired and dedup recognizes legacy issues.
func managedPhaseLabels(s *tatarav1alpha1.ScmSpec) []string {
b, a, i, d := lifecycleLabels(s)
idea, rej := legacyLabels(s)
return []string{b, a, i, d, idea, rej}
}
// activePhaseLabels returns the labels meaning "in flight" (brainstorming,
// approved, implementation, + legacy idea). An OPEN issue bearing any of these
// with only-terminal Tasks is an orphan the backstop resumes.
func activePhaseLabels(s *tatarav1alpha1.ScmSpec) []string {
b, a, i, _ := lifecycleLabels(s)
idea, _ := legacyLabels(s)
return []string{b, a, i, idea}
}
-
Step 5:
setLifecycleLabelusesmanagedPhaseLabels- replace these two lines insetLifecycleLabel:with: (the rest of setLifecycleLabel is unchanged; it addsidea, approved, rejected := lifecycleLabels(proj.Spec.Scm) managed := []string{idea, approved, rejected}desired, removes the rest ofmanaged.) -
Step 6: Fix callers (build-green within this task).
lifecycle.gofinishTriage: changeidea, approved, rejected := lifecycleLabels(project.Spec.Scm)tobrainstorming, approved, _, declined := lifecycleLabels(project.Spec.Scm). Replace everyideause withbrainstormingand everyrejecteduse withdeclinedin that function (the close-withheld armsetLifecycleLabel(..., idea)->brainstorming; discuss armidea->brainstorming; await-approval armidea->brainstorming; close armrejected->declined; implement armapprovedunchanged).projectscan.gobrainstorm: changeideaLabel, _, _ := lifecycleLabels(proj.Spec.Scm)toideaLabel, _, _, _ := lifecycleLabels(proj.Spec.Scm)(rename local tobrainstormingLabelfor clarity; it is the labelproposalBacklogcounts). Pass it through unchanged.proposalBacklog: it counts open non-PR issues bearingideaLabel. Keep counting the brainstorming label; ALSO count the legacy idea label so backpressure is correct during migration: change the predicate tohasLabel(iss.Labels, ideaLabel) || hasLabel(iss.Labels, legacyIdea)wherelegacyIdea, _ := legacyLabels(...). (Pass legacyIdea in, or compute inside.)-
Search the whole tree for other
lifecycleLabels(call sites and fix arity:grep -rn 'lifecycleLabels(' internal/. -
Step 7:
make manifeststo regen the CRD with the three new fields. Confirmcharts/tatara-operator/crds/tatara.dev_projects.yamlgainsbrainstormingLabel/implementationLabel/declinedLabel. -
Step 8: Run tests + gates.
go test ./internal/controller/...,gofmt -l .,go vet ./.... All green. -
Step 9: Commit.
feat(labels): four managed phase labels + legacy-alias helpers
Task 2: Apply phase labels at lifecycle state entry¶
Files: - Modify: internal/controller/lifecycle.go - Test: internal/controller/lifecycle_label_test.go (exists) or add cases there
State-entry contract (issue sources only; skip when task.Spec.Source == nil || task.Spec.Source.IsPR): - Triage entry -> tatara-brainstorming - Implement entry -> tatara-implementation - (approved on finishTriage implement, declined on close: ALREADY set by Task 1's finishTriage.)
- Step 1: Failing tests in
lifecycle_label_test.gousing the existinglabelWriter/reconcilerFor/seedLabelTaskfakes (see existing tests in that file for the exact harness):
func TestTriageEntrySetsBrainstormingLabel(t *testing.T) {
// seed an issueLifecycle Task with LifecycleState "" (fresh) and an issue Source.
// reconcile once; assert the labelWriter recorded AddLabel("tatara-brainstorming")
// and that no other managed label was added.
}
func TestImplementEntrySetsImplementationLabel(t *testing.T) {
// seed a Task at LifecycleState "Implement", Phase "" (fresh spawn), issue Source,
// memory Ready, under cap. reconcile; assert AddLabel("tatara-implementation").
}
func TestPRSourceTaskSkipsPhaseLabel(t *testing.T) {
// seed an Implement-entry Task whose Source.IsPR == true.
// reconcile; assert NO AddLabel call was made for a phase label.
}
-
Step 2: Run, watch fail.
-
Step 3: Brainstorming on Triage entry. In
reconcileLifecycle, thecase ""block sets the initial state then requeues. Add the label there (it already hasprojectloaded). Afterr.setLifecycleState(ctx, task, entry, "initial")succeeds and whenentry == "Triage", call a new helper:Also cover tasks that ENTER Triage via reactivation (LifecycleState already "Triage" on first handleTriage). Simplest robust placement: at the TOP ofif entry == "Triage" { if err := r.ensurePhaseLabel(ctx, &project, task, "brainstorming"); err != nil { r.Metrics.ReconcileResult("Task", "error") return ctrl.Result{}, err } }handleTriage, before driving the agent (i.e. when!isTerminal(task.Status.Phase)), callensurePhaseLabel(ctx, project, task, "brainstorming")(idempotent). Put it just beforer.buildTriagePromptFor. -
Step 4: Implementation on Implement entry. In
handleImplement, inside the fresh-spawn branch (task.Status.Phase == ""), AFTER the iteration increment + re-read and BEFORE building the prompt, callr.ensurePhaseLabel(ctx, project, task, "implementation")(idempotent; only fires on Implement spawn, not every poll). -
Step 5: Add
ensurePhaseLabelhelper inlifecycle.go:(finishTriage may optionally be refactored to call// ensurePhaseLabel sets the desired managed phase label on the task's source // ISSUE (no-op for PR sources or missing source). which is one of // "brainstorming"|"approved"|"implementation"|"declined"; it resolves the // configured label name and delegates to setLifecycleLabel (idempotent). func (r *TaskReconciler) ensurePhaseLabel(ctx context.Context, project *tatarav1alpha1.Project, task *tatarav1alpha1.Task, phase string) error { if task.Spec.Source == nil || task.Spec.Source.IsPR || task.Spec.Source.IssueRef == "" { return nil } brainstorming, approved, implementation, declined := lifecycleLabels(project.Spec.Scm) var desired string switch phase { case "brainstorming": desired = brainstorming case "approved": desired = approved case "implementation": desired = implementation case "declined": desired = declined default: return nil } return r.setLifecycleLabel(ctx, project, task, desired) }ensurePhaseLabel(..., "approved"/"declined"/"brainstorming")instead of inlinesetLifecycleLabel, for consistency - OPTIONAL; only if it keeps tests green. Do not change finishTriage behavior.) -
Step 6: Tests + gates green. Commit.
feat(lifecycle): set phase label on Triage and Implement entry
Task 3: Dedup by phase-label presence + Task state¶
Files: - Modify: internal/controller/projectscan.go (isDeduped, add hasAnyLabel, callers mrScan/issueScan) - Test: internal/controller/projectscan_dedup_test.go
- Step 1: Failing tests in
projectscan_dedup_test.go(match the existing test style there):
func TestDedupTerminalTaskWithActiveLabelIsDeduped(t *testing.T) {
// candidate: issue repo "o/r" #5, labels ["tatara-implementation"], updatedAt now.
// existing: ONE terminal task for o/r#5 (Phase "Succeeded" or LifecycleState "Parked"),
// CreationTimestamp BEFORE updatedAt.
// managed := managedPhaseLabels(nil)
// isDeduped(c, existing, managed) == true (orphan; backstop handles; no fresh triage)
}
func TestDedupTerminalTaskNoLabelNewActivityEligible(t *testing.T) {
// candidate: issue o/r#5, labels [] (no managed label), updatedAt AFTER task creation.
// existing: one terminal task for o/r#5 created BEFORE updatedAt.
// isDeduped == false (legacy/untracked + new activity -> eligible for fresh triage)
}
func TestDedupTerminalTaskNoLabelNoNewActivityDeduped(t *testing.T) {
// same as above but updatedAt == task creation (not after) -> isDeduped == true
}
func TestDedupNonTerminalTaskAlwaysDeduped(t *testing.T) {
// existing: a non-terminal task (Phase "Planning") for o/r#5, any labels.
// isDeduped == true (fast path unchanged)
}
func TestDedupDeclinedLabelIsDeduped(t *testing.T) {
// candidate labels ["tatara-declined"], one terminal task. managed includes declined.
// isDeduped == true (declined is in managedPhaseLabels -> suppressed; no scan action)
}
func TestDedupPRHeadShaUnchanged(t *testing.T) {
// PR candidate (isPR true) + terminal task at same headSHA -> deduped (arm unchanged).
}
-
Step 2: Run, watch fail (signature mismatch:
isDedupedtakes 2 args today). -
Step 3: Add
hasAnyLabelinprojectscan.gonearhasLabel: -
Step 4: Rewrite
isDeduped- addmanaged []stringparam; replace ONLY the issue arm. Final function:// isDeduped reports whether a candidate already has a Task that should suppress // a re-pick. Phase labels are the issue's state-of-truth (Option A): // - any non-terminal Task for (repo,number) -> skip (fast path) // - PR: a terminal Task at the same head-sha -> skip // - issue: a managed phase label present on the OPEN issue -> skip (active => // handled by the live Task above; terminal+label => orphan the backstop // resumes; declined => no action). No managed label -> legacy/untracked, fall // back to activity-vs-creation so a stale terminal Task is not re-triaged // unless the issue saw new activity. func isDeduped(c candidate, existing []tatarav1alpha1.Task, managed []string) bool { repoLabel := sanitizeRepoLabel(c.repo) numLabel := strconv.Itoa(c.number) for i := range existing { t := &existing[i] if t.Labels[labelSourceRepo] != repoLabel || t.Labels[labelSourceNumber] != numLabel { continue } lifecycleTerminal := t.Status.LifecycleState != "" && isLifecycleTerminal(t.Status.LifecycleState) if !isTerminal(t.Status.Phase) && !lifecycleTerminal { return true } if c.isPR { if t.Labels[labelHeadSHA] == c.headSHA && c.headSHA != "" { return true } continue } // issue: phase label is state-of-truth. if hasAnyLabel(c.labels, managed) { return true } if !c.updatedAt.After(t.CreationTimestamp.Time) { return true } } return false } -
Step 5: Fix callers. In
mrScanandissueScan, computemanaged := managedPhaseLabels(proj.Spec.Scm)once before the dedup loop and pass it:isDeduped(c, existing, managed). (mrScan candidates are PRs somanagedis unused there, but pass for signature consistency.) Also fix any otherisDeduped(call site (grep -rn 'isDeduped(' internal/). -
Step 6: Tests + gates green. Commit.
fix(projectscan): dedup issues by phase-label presence, not updatedAt
Task 4: Backstop - recover orphaned issues¶
Files: - Modify: internal/controller/projectscan.go (add recoverOrphans, hasNonTerminalTaskForIssue; wire into runScans) - Test: internal/controller/projectscan_run_test.go (or a new projectscan_backstop_test.go)
Orphan = OPEN issue with an active phase label and NO non-terminal Task for it. Recovery entry by label priority (implementation > approved > brainstorming/idea): - tatara-implementation (or no live MR) -> entry Implement (resume coding; an open bot MR is instead recovered by mrScan's MRCI task, which makes the issue non-orphan in the fresh re-list, so this only fires when no live MR exists) - tatara-approved -> entry Implement - tatara-brainstorming / legacy tatara-idea -> entry Triage - tatara-declined -> no action
- Step 1: Failing tests (use the ProjectReconciler test harness in projectscan_*_test.go; fake SCMReader returning issues with labels; fake client with existing tasks):
func TestBackstopRecoversImplementationOrphanToImplement(t *testing.T) {
// reader: open issue o/r#7 labels ["tatara-implementation"], no PRs.
// existing tasks: NONE for o/r#7.
// budget := 3
// recoverOrphans(...); assert ONE issueLifecycle Task created for o/r#7 with
// LifecycleEntryAnnotation == "Implement"; budget decremented to 2.
}
func TestBackstopRecoversBrainstormingOrphanToTriage(t *testing.T) {
// issue labels ["tatara-brainstorming"], no tasks -> Task with entry "Triage".
}
func TestBackstopRecoversLegacyIdeaOrphanToTriage(t *testing.T) {
// issue labels ["tatara-idea"], no tasks -> entry "Triage".
}
func TestBackstopSkipsIssueWithLiveTask(t *testing.T) {
// issue labels ["tatara-implementation"], existing NON-terminal task for o/r#7
// (e.g. an mrScan MRCI task) -> recoverOrphans creates NOTHING.
}
func TestBackstopSkipsDeclined(t *testing.T) {
// issue labels ["tatara-declined"], no tasks -> creates NOTHING.
}
func TestBackstopRespectsBudgetZero(t *testing.T) {
// budget := 0 -> creates NOTHING regardless of orphans.
}
-
Step 2: Run, watch fail.
-
Step 3: Add helpers +
recoverOrphansinprojectscan.go:// hasNonTerminalTaskForIssue reports whether any open (non-terminal) Task exists // for (slug, number) in the snapshot. func hasNonTerminalTaskForIssue(existing []tatarav1alpha1.Task, slug string, number int) bool { repoLabel := sanitizeRepoLabel(slug) numLabel := strconv.Itoa(number) for i := range existing { t := &existing[i] if t.Labels[labelSourceRepo] != repoLabel || t.Labels[labelSourceNumber] != numLabel { continue } if taskOpen(t) { return true } } return false } // recoverOrphans starts the correct lifecycle Task for each OPEN issue that // carries an active phase label but has no live Task (a missed/never-started or // stalled handler). It RE-LISTS existing Tasks so it sees Tasks mrScan/issueScan // created earlier this cycle (an open bot MR becomes a live MRCI Task -> not an // orphan). Bounded by the shared open-task budget. func (r *ProjectReconciler) recoverOrphans(ctx context.Context, proj *tatarav1alpha1.Project, reader scm.SCMReader, repos []tatarav1alpha1.Repository, budget *int) { if *budget <= 0 { return } l := log.FromContext(ctx) existing, err := r.existingScanTasks(ctx, proj) if err != nil { l.Error(err, "backstop: list tasks", "action", "backstop_list_error", "resource_id", proj.Name) return } brainstorming, approved, implementation, _ := lifecycleLabels(proj.Spec.Scm) legacyIdea, _ := legacyLabels(proj.Spec.Scm) for i := range repos { owner, name, oerr := scm.OwnerRepo(repos[i].Spec.URL) if oerr != nil { continue } issues, lerr := reader.ListOpenIssues(ctx, owner, name) if lerr != nil { l.Error(lerr, "backstop: ListOpenIssues", "action", "backstop_list_error", "resource_id", proj.Name, "repo", repos[i].Name) continue } slug := owner + "/" + name for _, iss := range issues { if iss.IsPR { continue } var entry, goal string switch { case hasLabel(iss.Labels, implementation): entry = "Implement" goal = fmt.Sprintf("Resume implementation for %s#%d (phase label present, no live task)", slug, iss.Number) case hasLabel(iss.Labels, approved): entry = "Implement" goal = fmt.Sprintf("Implement approved issue %s#%d", slug, iss.Number) case hasLabel(iss.Labels, brainstorming) || hasLabel(iss.Labels, legacyIdea): entry = "Triage" goal = fmt.Sprintf("Triage issue %s#%d", slug, iss.Number) default: continue } if hasNonTerminalTaskForIssue(existing, slug, iss.Number) { continue } if *budget <= 0 { return } repo, ok := r.matchRepoForSlug(repos, slug) if !ok { continue } cand := candidate{repo: slug, number: iss.Number, labels: iss.Labels, updatedAt: iss.UpdatedAt} ann := map[string]string{tatarav1alpha1.LifecycleEntryAnnotation: entry} if _, cerr := r.createScanTask(ctx, proj, &repo, cand, cand, "backstop", "issueLifecycle", goal, ann); cerr != nil { l.Error(cerr, "backstop: create recovery task", "action", "backstop_create_error", "resource_id", proj.Name, "repo", repo.Name) continue } l.Info("backstop: recovered orphaned issue", "action", "backstop_recover", "resource_id", proj.Name, "issue", fmt.Sprintf("%s#%d", slug, iss.Number), "entry", entry) r.Metrics.ScanItem("backstop", "recovered") *budget-- } } } -
Step 4: Wire into
(Piggybacks the hourly issue cadence; runs only when issueScan is due.runScans. Inside theissueScandue-block, AFTERr.stampScan(ctx, proj, "issueScan")and theconsider(...)calls, add:budgetis the same shared int already declared in runScans.) -
Step 5: Tests + gates green. Commit.
feat(projectscan): backstop recovers orphaned phase-labelled issues
Task 5: Webhook twin-gap - reactivate owning Parked task instead of duplicating¶
Files: - Modify: api/v1alpha1/annotations.go (export turn-annotation consts) - Modify: internal/controller/task_controller.go (alias local consts to the exported ones) - Modify: internal/webhook/server.go (handleIssueComment, add findReactivatableTask + reactivation) - Test: internal/webhook/issue_comment_test.go (or issue_comment_create_test.go)
A human comment on an issue whose only owning Task is Parked must RESUME that Task (clean agent-run reset -> Triage), not spawn a duplicate. Done tasks and no-owning-task keep the existing "create fresh" behavior.
-
Step 1: Export shared consts in
api/v1alpha1/annotations.go:In// Turn-loop annotation keys, shared by the controller (agent-run state) and the // webhook (reactivation must clear them so a fresh run starts clean). const ( AnnCurrentTurn = "tatara.dev/current-turn" AnnCurrentSubtask = "tatara.dev/current-subtask" AnnTurnComplete = "tatara.dev/turn-complete" AnnTurnStartedAt = "tatara.dev/turn-started-at" AnnPodRecreations = "tatara.dev/pod-recreations" )task_controller.go, change the local const block to alias them (keep the local lowercase names so the rest of the package is untouched):(annCurrentTurn = tatarav1alpha1.AnnCurrentTurn annCurrentSubtask = tatarav1alpha1.AnnCurrentSubtask annTurnComplete = tatarav1alpha1.AnnTurnComplete annPodRecreations = tatarav1alpha1.AnnPodRecreations annTurnStartedAt = tatarav1alpha1.AnnTurnStartedAt annPendingHandoverResume = "tatara.dev/pending-handover-resume" annAgentUnreachableSince = "tatara.dev/agent-unreachable-since"constwith a value from another package is legal in Go only for untyped string constants - these are. Confirmgo buildpasses; if the linter objects to mixing, leave annPendingHandoverResume/annAgentUnreachableSince as-is, only the five turn consts need sharing.) -
Step 2: Failing tests in
issue_comment_test.go(use the existing webhook test harness - fake client, signed payload helpers already present in the package):
func TestIssueCommentReactivatesParkedOwningTask(t *testing.T) {
// existing: an issueLifecycle Task for o/r#9, LifecycleState "Parked",
// Phase "Planning", with annCurrentTurn set and a wrapper pod present.
// POST a human issue_comment (ActorLogin != bot) for o/r#9.
// assert: NO new Task created (task count stays 1); the Parked Task now has
// LifecycleState "Triage", Phase "", annCurrentTurn cleared.
}
func TestIssueCommentDoneOwningTaskCreatesFresh(t *testing.T) {
// existing: a Done issueLifecycle Task for o/r#9.
// human comment -> a NEW Triage Task is created (existing behavior; merged work
// stays Done, new comment = new episode).
}
func TestIssueCommentNoOwningTaskCreatesFresh(t *testing.T) {
// no task -> createLifecycleTaskAtTriage (unchanged).
}
-
Step 3: Run, watch fail.
-
Step 4:
findReactivatableTaskinserver.go:// findReactivatableTask returns an owning issueLifecycle Task for issueRef that // went terminal but is resumable (LifecycleState == "Parked"). Done tasks are // NOT reactivated (their work is complete). Returns (task, true) when found. func (s *Server) findReactivatableTask(ctx context.Context, projectName, issueRef string) (*tatarav1.Task, bool) { var tasks tatarav1.TaskList if err := s.cfg.Client.List(ctx, &tasks, client.InNamespace(s.cfg.Namespace)); err != nil { return nil, false } for i := range tasks.Items { t := &tasks.Items[i] if t.Spec.Kind != "issueLifecycle" || t.Spec.ProjectRef != projectName { continue } if t.Spec.Source == nil || t.Spec.Source.IssueRef != issueRef { continue } if t.Status.LifecycleState == "Parked" { return t, true } } return nil, false } -
Step 5: Reactivation in
handleIssueComment. In theif !found {branch, for the!ev.IsPRcase, BEFORE callingcreateLifecycleTaskAtTriage, try reactivation:Addif !found { if !ev.IsPR { if parked, ok := s.findReactivatableTask(ctx, proj.Name, ev.IssueRef); ok { s.reactivateTask(ctx, w, provider, proj, ev, parked) return } s.createLifecycleTaskAtTriage(ctx, w, provider, proj, ev) return } ... }reactivateTask:Add imports to// reactivateTask resumes a Parked owning Task: it clears the agent-run state // (Phase, turn annotations) and the wrapper pod/service, sets LifecycleState back // to Triage, and stamps LastActivityAt/DeadlineAt so the reconciler re-triages // the issue with the new comment. Mirrors the controller's resetAgentRun so the // next reconcile spawns a clean run (no stale turn/pod state). func (s *Server) reactivateTask(ctx context.Context, w http.ResponseWriter, provider string, proj tatarav1.Project, ev scm.WebhookEvent, task *tatarav1.Task) { // Best-effort delete the wrapper pod + service. pod := &corev1.Pod{} pod.Name = agent.PodName(task) pod.Namespace = s.cfg.Namespace if err := s.cfg.Client.Delete(ctx, pod); err != nil && !apierrors.IsNotFound(err) { s.log.ErrorContext(ctx, "reactivate: delete pod (non-fatal)", "error", err, "task", task.Name) } svc := &corev1.Service{} svc.Name = agent.PodName(task) svc.Namespace = s.cfg.Namespace if err := s.cfg.Client.Delete(ctx, svc); err != nil && !apierrors.IsNotFound(err) { s.log.ErrorContext(ctx, "reactivate: delete service (non-fatal)", "error", err, "task", task.Name) } idleMinutes := 60 if proj.Spec.Scm != nil && proj.Spec.Scm.ConversationIdleMinutes > 0 { idleMinutes = proj.Spec.Scm.ConversationIdleMinutes } if updateErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { fresh := &tatarav1.Task{} if err := s.cfg.Client.Get(ctx, client.ObjectKeyFromObject(task), fresh); err != nil { return err } now := metav1.Now() deadline := metav1.NewTime(now.Add(time.Duration(idleMinutes) * time.Minute)) fresh.Status.LifecycleState = "Triage" fresh.Status.Phase = "" fresh.Status.LastActivityAt = &now fresh.Status.DeadlineAt = &deadline if err := s.cfg.Client.Status().Update(ctx, fresh); err != nil { return err } // Clear turn annotations (metadata update, separate from status). fresh2 := &tatarav1.Task{} if err := s.cfg.Client.Get(ctx, client.ObjectKeyFromObject(task), fresh2); err != nil { return err } if fresh2.Annotations != nil { delete(fresh2.Annotations, tatarav1.AnnCurrentTurn) delete(fresh2.Annotations, tatarav1.AnnCurrentSubtask) delete(fresh2.Annotations, tatarav1.AnnTurnComplete) delete(fresh2.Annotations, tatarav1.AnnTurnStartedAt) delete(fresh2.Annotations, tatarav1.AnnPodRecreations) } return s.cfg.Client.Update(ctx, fresh2) }); updateErr != nil { s.log.ErrorContext(ctx, "reactivate: update task", "error", updateErr, "task", task.Name) s.count(provider, ev.Kind, ev.Action, "error") http.Error(w, "reactivate task", http.StatusInternalServerError) return } s.log.InfoContext(ctx, "issue_comment: reactivated parked lifecycle task", "project", proj.Name, "task", task.Name, "issue_ref", ev.IssueRef) s.count(provider, ev.Kind, ev.Action, "accepted") w.WriteHeader(http.StatusAccepted) }server.go:"github.com/szymonrychu/tatara-operator/internal/agent"(forPodName).corev1,apierrors,metav1,retry,timeare already imported. -
Step 6: Tests + gates green (
go test ./internal/webhook/... ./internal/controller/...). Commit.fix(webhook): reactivate parked lifecycle task on comment, no duplicate
Task 6: Full verification gate (not TDD)¶
Files: none (verification only).
-
Step 1: Regenerate + full suite.
All green;make manifests export KUBEBUILDER_ASSETS="$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.21 use 1.33.0 -p path)" go build ./... && gofmt -l . && go vet ./... && go test ./... && golangci-lint rungofmt -l .prints nothing; CRD diff shows only the three new label fields. -
Step 2: Grep for stragglers.
Confirm no 3-returnlifecycleLabels, no 2-argisDeduped, and thattatara-idea/tatara-rejectedappear only inlegacyLabels/project_types.godefaults. -
Step 3: Final opus code review of the whole branch diff vs
main(superpowers:requesting-code-review). Fix Critical/High. Re-run gates.
Self-review checklist (run after writing, before execution)¶
- Spec coverage: four labels (T1), state-entry application (T2), presence+state dedup (T3), backstop (T4), webhook twin-gap (T5), lazy legacy migration (T1 managed set + T3 dedup + T4 idea-alias).
IssueLabelAddedAtcorrectly NOT implemented (amendment). - Type consistency:
lifecycleLabels4-return used everywhere;isDeduped(c, existing, managed)everywhere;ensurePhaseLabelphase strings match the switch; sharedAnnCurrentTurnetc. used in both packages. - No placeholders.