Skip to content

Bug: adv_epic_retire cannot retire zero-entry Epics (recomputeEpicProgress excludes total===0) #295

Description

@JRedeker

Problem

adv_epic_retire cannot retire an empty (zero-entry) Epic. It refuses with epic_incomplete even when no blockers exist:

{
  "error": "Epic <id> has incomplete entries and cannot be retired",
  "code": "epic_incomplete",
  "blockers": []
}

The empty blockers array confirms the refusal is structural, not product-blocked. There is no operator-only Epic purge tool, and adv_archive_purge is changes-only.

Root cause

recomputeEpicProgress at plugin/src/temporal/epic-state.ts:899-904 derives Epic status after every mutation:

status:
  state.status === "archived"
    ? EpicStatusSchema.enum.archived
    : completed === total && total > 0      // ← line 902, the gap
      ? EpicStatusSchema.enum.completed
      : EpicStatusSchema.enum.active,

For total=0, completed=0: completed === total is 0 === 0true, but && total > 0 is false, so the whole condition is false → status forced to active. An empty epic can never reach completed via this path. Because recomputeEpicProgress runs after every mutation, no subsequent tool can flip the status.

Both retirement guards then reject on progress.status !== "completed":

  • plugin/src/storage/store-temporal/epics.ts:467 (store-layer retire)
  • plugin/src/temporal/epic-state.ts:751-754 (applyEpicArchivedToState workflow-signal re-check)

Both: if (progress.status !== "completed" || blockers.length > 0) → throw epic_incomplete.

Evidence (live incident)

alignPokeedgeSkyTheme in the pokeedge-web project is a historical static-theme Epic (Taupe/Sky alignment target). The work has been superseded by the current Stone/Sky + premiumColorPresets direction. Its narrative explicitly says:

Retire this empty Epic once the Epic retirement path accepts zero-entry initiatives. Current adv_epic_retire dry run refuses it as epic_incomplete while returning no blockers, so retirement is tool-blocked rather than product-blocked.

State (via adv_epic_show view:compact):

{
  "total_entries": 0,
  "completed_entries": 0,
  "active_entries": 0,
  "next_entry_id": null,
  "status": "active",
  "version": 1
}

adv_epic_retire dry-run probe (2026-07-23):

adv_epic_retire(
  epic_id: "alignPokeedgeSkyTheme",
  expected_version: 1,
  evidence: "Dry-run probe...",
  retired_by: "agent",
  dryRun: true
)
→ {"error":"Epic alignPokeedgeSkyTheme has incomplete entries and cannot be retired",
   "code":"epic_incomplete","blockers":[]}

Test coverage gap

  • recomputeEpicProgress suite (epic-state.test.ts:814-873) only covers non-empty cases (3 mixed entries → active; 2 terminal → completed). The 0-entry case is entirely untested.
  • All epic_incomplete tests (epic-state.test.ts:969-996, store-temporal/epics.test.ts:745-778) use epics with ≥1 named active/shell entry. None exercise the 0-entry path.

Proposed fix

Option A (recommended, minimal): drop && total > 0 at epic-state.ts:902:

-      : completed === total && total > 0
+      : completed === total

Empty epic → completed; eligible to retire naturally; both guards already accept blockers: []. Semantically correct (zero work = complete). No existing test breaks (verified: both recomputeEpicProgress cases use total ≥ 2). A brand-new empty epic becomes immediately retireable, but retirement is still an explicit tool call — no auto-retire risk.

Option B (conservative): leave status active for empty epics; add total_entries === 0 as an alternate pass path in both guards (epics.ts:467, epic-state.ts:751). Matches the "retirement-path-only" framing in the Epic narrative but touches 2 sites.

Either option should ship with a regression test in epic-state.test.ts (recomputeEpicProgress 0-entry case) and store-temporal/epics.test.ts (retire-on-empty).

No projection-write changes needed — RetiredEpicProjection snapshots whatever state.epic holds, including empty entries: [].

Acceptance sketch

  • Given an empty Epic (total_entries === 0, no entries), adv_epic_retire succeeds and the Epic moves to retired projection.
  • adv_epic_retire dryRun: true on an empty Epic returns the retirement preview without epic_incomplete.
  • recomputeEpicProgress on entries: [] produces status: completed (Option A) or stays active but remains retireable (Option B).
  • Regression test in epic-state.test.ts covers the 0-entry recomputeEpicProgress case.
  • Regression test in store-temporal/epics.test.ts covers retire-on-empty.

Related

  • Discussed in alignPokeedgeSkyTheme Epic narrative (pokeedge-web project, refreshed 2026-07-19).
  • Adjacent logic: recomputeEpicProgress callers at epic-state.ts:182,204,232,275,339,422,461,577,611,660,701,734,797 — no behavioral change for non-empty epics.
  • No existing ADV change in ~/dev/advance covers this; this issue is the tracking entry.

Filed from the pokeedge-web session after adv-temporal-repair diagnosis (2026-07-23).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:mediumReal friction with workaround; tackle when high-pri cleared

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions