Documentation
¶
Overview ¶
Package domain contains pure domain types for the backlog subsystem. It imports only standard library packages — no ent, no headless, no git deps — so it can be imported by any layer (server, adapters, pkg/events) without creating an import cycle with the parent session package.
Index ¶
- Constants
- Variables
- func CanTransitionBacklog(from, to BacklogStatus) bool
- func TransitionGuard(item BacklogItemTransitionInput, to BacklogStatus) error
- func ValidTransitions() map[BacklogStatus]map[BacklogStatus]bool
- type AcCriteriaJSON
- type AcCriterion
- type AcStatus
- type BacklogCategory
- type BacklogItemTransitionInput
- type BacklogStatus
- type CriterionVerdict
- type ReviewOutcome
- type StuckReason
Constants ¶
const ( ReviewVerdictPass = ReviewOutcomePass ReviewVerdictFail = ReviewOutcomeFail ReviewVerdictPartial = ReviewOutcomePartial ReviewVerdictUnverifiable = ReviewOutcomeUnverifiable )
Backward-compatible aliases so callers can be migrated incrementally. Prefer ReviewOutcome* constants in new code.
const DefaultBacklogPriority = 3
DefaultBacklogPriority is the default priority assigned to new backlog items when no priority is specified. Lower values indicate higher priority.
Variables ¶
var ( ErrACRequired = errors.New("acceptance criteria required before marking ready") ErrPlanRequired = errors.New("plan must be approved or skip_planning must be true before spawning work session") ErrPlanArtifactsRequired = errors.New("plan artifacts path is required when planning is not skipped") ErrVerdictRequired = errors.New("PASS verdict or manual override required before marking done") ErrCodeNotOnMain = errors.New("code changes must actually be on main (merged locally or via a merged PR) before marking done; provide override_reason to bypass") ErrUnresolvedBlockers = errors.New("item has one or more blockers that have not reached done") ErrVerdictClearRequiredForReady = errors.New("item has a recorded PASS verdict; provide override_reason to send it back to ready anyway") )
Sentinel errors for transition guards.
var AllStuckReasons = []StuckReason{ StuckReasonPRReadyUnmerged, StuckReasonReworkCap, StuckReasonAbandonedReview, StuckReasonStaleWork, StuckReasonBouncing, StuckReasonPushFailed, StuckReasonOrphanedTriage, StuckReasonAutonomousStuck, StuckReasonSpawnFailed, StuckReasonPlanNotApproved, StuckReasonPRPendingNoPR, StuckReasonReworkBlockedStale, StuckReasonPRNeedsFix, StuckReasonRespawnBlockedActive, StuckReasonLikelyFlaky, StuckReasonBlockedByDependency, StuckReasonMultipleReasons, StuckReasonBounceCapExhausted, }
AllStuckReasons lists every valid StuckReason constant.
Functions ¶
func CanTransitionBacklog ¶
func CanTransitionBacklog(from, to BacklogStatus) bool
CanTransitionBacklog reports whether a transition from one backlog status to another is permitted.
func TransitionGuard ¶
func TransitionGuard(item BacklogItemTransitionInput, to BacklogStatus) error
TransitionGuard validates business rules before a status transition. It returns nil when the transition is allowed, or a sentinel error when a guard condition is violated. It does NOT check CanTransition — callers must invoke CanTransition separately if structural validity is also required.
func ValidTransitions ¶
func ValidTransitions() map[BacklogStatus]map[BacklogStatus]bool
ValidTransitions returns a deep copy of the authoritative transition table. Callers that need a local snapshot (e.g. for concurrent reads without repeated map lookups) should call this once at construction time.
Types ¶
type AcCriteriaJSON ¶
type AcCriteriaJSON string
AcCriteriaJSON is the JSON-serialized form of []AcCriterion stored in the DB. Using a named type prevents silently passing Description or other string fields where serialized AC criteria are expected.
const AcCriteriaJSONEmpty AcCriteriaJSON = ""
AcCriteriaJSONEmpty is the zero value — an empty criteria list.
func SerializeAcCriteria ¶
func SerializeAcCriteria(criteria []AcCriterion) (AcCriteriaJSON, error)
SerializeAcCriteria serializes acceptance criteria to an AcCriteriaJSON value.
func (AcCriteriaJSON) IsEmpty ¶
func (j AcCriteriaJSON) IsEmpty() bool
IsEmpty reports whether j contains no criteria JSON.
func (AcCriteriaJSON) Parse ¶
func (j AcCriteriaJSON) Parse() ([]AcCriterion, error)
Parse deserializes the criteria from JSON.
type AcCriterion ¶
type AcCriterion struct {
Index int `json:"index"`
Text string `json:"text"`
Status AcStatus `json:"status"` // pending, in_progress, done, fail
Note string `json:"note,omitempty"`
}
AcCriterion is a single acceptance criterion for a backlog item.
func ParseAcCriteria ¶
func ParseAcCriteria(raw AcCriteriaJSON) ([]AcCriterion, error)
ParseAcCriteria deserializes acceptance criteria from a JSON string.
type AcStatus ¶
type AcStatus string
AcStatus represents the status of a single acceptance criterion.
type BacklogCategory ¶ added in v1.41.0
type BacklogCategory string
BacklogCategory is a validated string-backed enum classifying a backlog item into a coarse bucket (bugfix/feature/chore/refactor). It is purely a frontend-defaulting hint — BacklogItemForm.tsx applies each category's automation-toggle defaults once, at creation time, into local form state; the server only persists and validates the string itself (see session.IsValidBacklogCategory), it does not resolve or apply any defaults. Unlike StuckReason above, the empty string is a valid value here too, meaning "uncategorized" — today's behavior for every existing item, preserved exactly.
const ( BacklogCategoryBugfix BacklogCategory = "bugfix" BacklogCategoryFeature BacklogCategory = "feature" BacklogCategoryChore BacklogCategory = "chore" BacklogCategoryRefactor BacklogCategory = "refactor" )
func (BacklogCategory) IsValid ¶ added in v1.41.0
func (c BacklogCategory) IsValid() bool
IsValid reports whether c is a known backlog category, or the empty string (uncategorized).
type BacklogItemTransitionInput ¶
type BacklogItemTransitionInput struct {
Status BacklogStatus
AcCriteria AcCriteriaJSON // serialized acceptance criteria
PlanApproved bool
SkipPlanning bool
PlanArtifactsPath string // path to plan artifacts written by triage session
OverallOutcome ReviewOutcome // from linked ReviewVerdict
OverrideReason string
// HasUnshippedCode is true when a work session committed code
// (LastCommitSha != "") that has not been verified to actually be on main —
// locally (merged/committed directly) or remotely (merged PR, pulled or not).
// A PrURL alone does NOT clear this: an open, unmerged, or later-reverted PR
// still has PrURL set, so it was never proof the code shipped. The
// review→done guard uses this to block premature done transitions.
HasUnshippedCode bool
// HasUnresolvedBlockers is true when this item has at least one
// BacklogItemDependency where the blocker has not reached done. The
// ready/queued->in_progress guard uses this to keep a gated item from
// being dequeued/started ahead of its blocker. Callers populate this via
// a batched query (see DequeueNextQueuedItems) rather than a per-item
// lookup.
HasUnresolvedBlockers bool
}
BacklogItemTransitionInput carries the fields needed by TransitionGuard.
type BacklogStatus ¶
type BacklogStatus string
BacklogStatus represents the lifecycle state of a backlog item.
const ( BacklogStatusIdea BacklogStatus = "idea" BacklogStatusRefining BacklogStatus = "refining" BacklogStatusReady BacklogStatus = "ready" BacklogStatusQueued BacklogStatus = "queued" BacklogStatusInProgress BacklogStatus = "in_progress" BacklogStatusReview BacklogStatus = "review" BacklogStatusPRPending BacklogStatus = "pr_pending" BacklogStatusDone BacklogStatus = "done" BacklogStatusArchived BacklogStatus = "archived" )
type CriterionVerdict ¶
type CriterionVerdict struct {
CriterionIndex int `json:"criterion_index"`
Outcome ReviewOutcome `json:"outcome"`
Evidence string `json:"evidence"`
}
CriterionVerdict holds the review outcome for a single acceptance criterion.
type ReviewOutcome ¶
type ReviewOutcome string
ReviewOutcome is a typed verdict outcome value (PASS, FAIL, PARTIAL, UNVERIFIABLE).
const ( ReviewOutcomePass ReviewOutcome = "PASS" ReviewOutcomeFail ReviewOutcome = "FAIL" ReviewOutcomePartial ReviewOutcome = "PARTIAL" ReviewOutcomeUnverifiable ReviewOutcome = "UNVERIFIABLE" )
func AggregateOutcome ¶
func AggregateOutcome(verdicts []CriterionVerdict) ReviewOutcome
AggregateOutcome computes the overall outcome from a slice of CriterionVerdicts. Priority (highest to lowest): FAIL > PARTIAL > UNVERIFIABLE > PASS. Returns FAIL when the slice is empty to prevent auto-approval of empty reviews.
func (ReviewOutcome) IsValid ¶
func (o ReviewOutcome) IsValid() bool
IsValid reports whether o is a recognised review outcome.
type StuckReason ¶ added in v1.38.0
type StuckReason string
StuckReason is a validated string-backed enum of the classes a backlog item can be "stuck" for — matching the house BacklogStatus/ReviewOutcome style (validated at the boundary via IsValid, not a truly-unrepresentable sum type). Only these compile-time constants should ever reach MarkStuck; no unvalidated string should reach the DB.
const ( // StuckReasonPRReadyUnmerged: a pr_pending item's PR is green, mergeable, // and unmerged past the threshold (see prReadyToMergeSolo). StuckReasonPRReadyUnmerged StuckReason = "pr_ready_unmerged" // StuckReasonReworkCap: the auto-rework loop hit maxAutoReworkIterations // and parked the item for manual action. StuckReasonReworkCap StuckReason = "rework_cap" // StuckReasonAbandonedReview: a review-status item has a review verdict on // record but nothing active in flight. StuckReasonAbandonedReview StuckReason = "abandoned_review" // StuckReasonStaleWork: an in_progress item's active work session reported // no progress for longer than maxWorkSessionStaleness. StuckReasonStaleWork StuckReason = "stale_work" // StuckReasonBouncing: an item crossed in_progress <-> review >= bounceThreshold // times within bounceLookback with no PASS verdict. StuckReasonBouncing StuckReason = "bouncing" // StuckReasonPushFailed: pushAndCreatePR failed (push rejected / gh pr // create errored) leaving a post-review item with no pr_number. StuckReasonPushFailed StuckReason = "push_failed" // StuckReasonOrphanedTriage: an idea-status item's triage session ended // without ever transitioning the item to ready. Covers two shapes: the // session is still open and has gone stale (crashed, was killed, or the // server restarted mid-triage), or the session already ended cleanly (the // headless call errored, or returned output the triage parser rejected — // e.g. a premature "still working" status message instead of the final // JSON block, confirmed live 2026-07-29/30, see // docs/tasks/backlog-feature-improvement.md's 2026-07-30 entry) but // TriggerTriage never reached its idea->ready transition. Previously only // the first shape was detected, and only when a human manually // re-triggered triage (tombstoneOrphanTriageSessions); this reason lets the // periodic stuck sweep catch both shapes without a manual retry — see // reconcileOrphanedTriageItems (session/backlog_lifecycle.go). StuckReasonOrphanedTriage StuckReason = "orphaned_triage" // StuckReasonAutonomousStuck: an autonomous driver run stopped after // maxTurns without a DONE signal. Previously only surfaced as a one-off // ephemeral notification (onAutonomousDriverComplete), invisible to the // Unfinished tab's durable stuck-reason system. StuckReasonAutonomousStuck StuckReason = "autonomous_stuck" // StuckReasonSpawnFailed: AutoReopenAfterFailedReview transitioned an item // to in_progress, then SpawnSessionFromItem failed AND the scoped rollback // to "review" also failed (its precondition no longer matched — something // else touched the item in the interim). Previously this left the item // silently stranded at in_progress with no work session and no visible // error (server/services/backlog_service_triage.go's rollback branch only // logged it) — invisible to every other stuck detector, since none of them // check "in_progress with zero live sessions and no error surfaced." StuckReasonSpawnFailed StuckReason = "spawn_failed" // StuckReasonPlanNotApproved: DequeueNextQueuedItems' planning gate // (SkipPlanning=false, PlanApproved=false) refuses to claim a queued item // indefinitely — by design (see that function's doc comment) — with only a // per-tick WARNING log and no durable, human-visible signal. Confirmed live // 2026-07-22: three items sat queued for days, silently re-blocked on every // 60s tick, invisible on the kanban board (BUG-037) and with no "Approve // Plan" action anywhere in the UI to unblock them. StuckReasonPlanNotApproved StuckReason = "plan_not_approved" // StuckReasonPRPendingNoPR: an item is in pr_pending status but has no PR // reference (pr_number == 0, pr_url == ""). Every downstream reconciler // (ReconcilePRPending's FindPRPendingItems query, EnablePRAutoMerge, etc.) // requires a real PrNumber, so an item in this shape is invisible to // everything else and sits in pr_pending permanently with nothing left to // poll or retry (BUG-040). Detection-only backstop: two write-ordering // bugs (pushAndCreatePR's best-effort field persist; ReconcilePRPending's // closed-PR branch clearing fields before confirming the reopen actually // succeeded) were found and fixed as the direct cause of the live incident // this reason was added for, but this detector exists so any *future* // mistake with the same shape — "a write silently doesn't happen or // happens out of order, and nothing detects the resulting dead end" — is // still visible and retryable from /unfinished rather than a silent // permanent stall. StuckReasonPRPendingNoPR StuckReason = "pr_pending_no_pr" // StuckReasonReworkBlockedStale: a review-status item's failed-review // rework attempt is blocked because its prior work session is still alive // (hasActiveWorkSession's guard, in AutoReopenAfterFailedReview) but has // produced no output for longer than maxReworkBlockStaleness (15min — see // project_plans/review-gate-stale-session-rework/decisions/ADR-001- // staleness-threshold-recalibration.md). Set by // notifyIfActiveWorkSessionStale (server/services/backlog_service_triage.go), // resolved by ResolveReworkBlockedStaleIfRecovered once the session // produces output again, leaves review, or its work session ends. // Distinct from StuckReasonStaleWork, which covers the structurally // similar but different-status case of an in_progress item's active work // session going stale — the two are deliberately kept as separate reasons // (different item status, different threshold, different urgency) rather // than merged. StuckReasonReworkBlockedStale StuckReason = "rework_blocked_stale" // StuckReasonPRNeedsFix: a pr_pending item's PR has failing CI, blocking // reviews, or a merge conflict (ReconcilePRPending's spawn-fix branches). // Gates ReconcilePRPending's AutoReopenForPRFix dispatch through the // shared remediation backoff (Storage.RemediationDue) so a PR that keeps // failing CI doesn't get a fresh fix session spawned on every ~60s // reconciliation tick indefinitely — previously ungated, unlike every // sibling remediation call site in session/backlog_lifecycle.go (see // docs/tasks/backlog-feature-improvement.md's 2026-07-28 entry). Resolved // once the PR becomes healthy again or the item reaches done. StuckReasonPRNeedsFix StuckReason = "pr_needs_fix" // StuckReasonRespawnBlockedActive: an automated respawn attempt // (AutoRespawnAutonomousWork, AutoReopenForPRFix, or AutoRespawnReview — // server/services/backlog_service_triage.go) was skipped because the item // already has an active work or review session, per // findActiveWorkSession/findActiveReviewSession. Before this reason // existed, all three call sites only log.InfoLog.Printf'd the skip — // zero operator-visible signal and no audit record, strictly worse than // spawnSessionAfterGates' own 8b guard (activeWorkSessionBlockedError), // which at least returns a progress-enriched error to its synchronous // caller (docs/tasks/backlog-feature-improvement.md, 2026-07-31/ // 2026-08-03 updates). Set by notifyRespawnBlockedByActiveSession, which // reuses workSessionStaleness for the same "still active" vs. "likely // stalled" distinction. Distinct from StuckReasonReworkBlockedStale // (review-status-only, staleness-gated) since this fires regardless of // staleness and covers three different item statuses (in_progress, // pr_pending, review). Resolved the next time the guarding function runs // past its active-session check (the block has cleared). StuckReasonRespawnBlockedActive StuckReason = "respawn_blocked_active" // StuckReasonLikelyFlaky: session.IsFlakyVerdictFlipFlop or // session.IsTestOnlyReworkCycle matched on this item's recent review // history — behavioral evidence (not a keyword match on title/description; // see project_plans/backlog-bounce-escalation/decisions/ADR-002) that the // review outcome may be non-deterministic rather than a real pass/fail // signal. Purely informational: set alongside AutoReopenAfterFailedReview's // existing reopen/park decision, never gating it — a misfiring heuristic // here cannot newly stall an item that would otherwise proceed. Both // predicates carry documented false-positive sources (see their doc // comments in session/stuck_decisions.go); the UI should present this as a // hint to verify, not a confident verdict. StuckReasonLikelyFlaky StuckReason = "likely_flaky" // StuckReasonBlockedByDependency: DequeueNextQueuedItems' dependency gate // (UnresolvedBlockerItemIDs) skipped this item because at least one of its // blocker items (session/ent/schema/backlog_item_dependency.go) has not yet // reached a resolved status (done or archived — see // project_plans/backlog-item-dependencies/decisions/ADR-001-dangling- // blocker-resolution.md). Purely detection/visibility: mirrors // StuckReasonPlanNotApproved's precedent of surfacing an indefinite, // by-design dequeue skip so it's visible on /unfinished and in the item // detail view (BlockerChip) instead of only a per-tick log line. Resolved // the next time UnresolvedBlockerItemIDs finds no unresolved blocker left // for this item. StuckReasonBlockedByDependency StuckReason = "blocked_by_dependency" // StuckReasonMultipleReasons: a synthetic, aggregate reason marking an // item that has multiReasonThreshold or more *other*, non-escalation // stuck reasons open simultaneously (session/stuck_decisions.go's // isMultiReasonEscalated). Set/resolved by reconcileMultiReasonEscalation // (session/backlog_lifecycle_stuck.go) as the count of open non-escalation // reasons crosses the threshold in either direction. Deliberately excludes // itself and StuckReasonBounceCapExhausted from its own count (ADR-001) // to avoid a self-reinforcing escalation loop. Unlike every other // StuckReason, this one carries no independent remediation action of its // own — it exists purely as an operator-visible severity signal that an // item is stuck for multiple simultaneous reasons, not a single narrow // one. StuckReasonMultipleReasons StuckReason = "multiple_reasons" // StuckReasonBounceCapExhausted: a synthetic, aggregate reason marking an // item whose bouncing remediation gate hit MaxRemediationAttempts while // StuckReasonBouncing itself is still open (autoReopenWithBackoffGate, // session/backlog_lifecycle.go). Signals that automated remediation has // given up retrying and the item now needs human intervention. Resolved // by reconcileBouncingItems once StuckReasonBouncing itself resolves, or // by the selfHealStuck backstop once the item's status leaves // in_progress/review entirely. Like StuckReasonMultipleReasons, this is a // meta/aggregate signal with no independent remediation action of its // own. StuckReasonBounceCapExhausted StuckReason = "bounce_cap_exhausted" )
func (StuckReason) IsValid ¶ added in v1.38.0
func (r StuckReason) IsValid() bool
IsValid reports whether r is a known stuck reason value.