From e07c7d61e68256b2f04cb8f0293d6009ed2b9f60 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 14 Oct 2026 23:11:52 +0200 Subject: [PATCH] feat(ticket): restore archived unfinished tickets to current sprint on resume (ticket-209) --- planfile/__init__.py | 31 +++++- planfile/cli/groups/ticket/__init__.py | 2 + planfile/cli/groups/ticket/commands.py | 24 ++++- project/ticket-209/README.md | 34 +++++++ project/ticket-209/intent.json | 25 +++++ tests/test_ticket_resume_archived.py | 130 +++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 project/ticket-209/README.md create mode 100644 project/ticket-209/intent.json create mode 100644 tests/test_ticket_resume_archived.py diff --git a/planfile/__init__.py b/planfile/__init__.py index 08e9fe9..c2705e4 100644 --- a/planfile/__init__.py +++ b/planfile/__init__.py @@ -36,7 +36,7 @@ TicketStatus, TicketUriProcess, ) -from planfile.core.store import PlanfileStore +from planfile.core.store import ImmutableTerminalReopenError, PlanfileStore from planfile.delegation import DelegationActor, load_delegation_actors from planfile.delivery_plan import DeliveryPlanError, DeliveryPlanRepository from planfile.delivery_plan_contracts import ( @@ -681,6 +681,23 @@ def ready_ticket(self, ticket_id: str, note: str | None = None, *, reason: str | if not ticket: return None + status_val = ( + ticket.status.value + if hasattr(ticket.status, "value") + else str(ticket.status) + ).lower() + if status_val in ("done", "canceled", "cancelled"): + raise ImmutableTerminalReopenError("immutable_terminal_reopen") + + located = self.store._locate_ticket_source(ticket_id) + if located is not None: + source_sprint, _ = located + if source_sprint.startswith(("history-", "archive-")): + self.store.move_ticket(ticket_id, "current") + ticket = self.get_ticket(ticket_id) + if not ticket: + return None + execution_data = ( ticket.execution.model_dump(mode="python", exclude_none=False) if ticket.execution @@ -708,6 +725,18 @@ def ready_ticket(self, ticket_id: str, note: str | None = None, *, reason: str | updates["actor"] = actor return self.update_ticket(ticket_id, **updates) + def resume_ticket( + self, + ticket_id: str, + note: str | None = None, + *, + reason: str | None = "resumed", + actor: str | None = None, + ) -> Ticket | None: + """Resume an unfinished ticket (e.g. failed or archived) and restore it to the current runnable queue.""" + return self.ready_ticket(ticket_id, note=note, reason=reason, actor=actor) + + def respond_ticket( self, ticket_id: str, diff --git a/planfile/cli/groups/ticket/__init__.py b/planfile/cli/groups/ticket/__init__.py index 3e92853..21abf01 100644 --- a/planfile/cli/groups/ticket/__init__.py +++ b/planfile/cli/groups/ticket/__init__.py @@ -26,6 +26,7 @@ ticket_next, ticket_prune_deps, ticket_ready, + ticket_resume, ticket_show, ticket_split, ticket_start, @@ -65,6 +66,7 @@ def ticket_callback(ctx: typer.Context) -> None: ticket_app.command("fail")(ticket_fail) ticket_app.command("input")(ticket_input) ticket_app.command("ready")(ticket_ready) + ticket_app.command("resume")(ticket_resume) ticket_app.command("start")(ticket_start) ticket_app.command("block")(ticket_block) ticket_app.command("validate")(ticket_validate) diff --git a/planfile/cli/groups/ticket/commands.py b/planfile/cli/groups/ticket/commands.py index 0b8a84e..743cf84 100644 --- a/planfile/cli/groups/ticket/commands.py +++ b/planfile/cli/groups/ticket/commands.py @@ -772,12 +772,34 @@ def ticket_ready(ticket_id: str=typer.Argument(..., help='Ticket ID to make runn """Mark execution state as ready after human input or manual recovery.""" from planfile import Planfile pf = Planfile.auto_discover() - ticket = pf.ready_ticket(ticket_id, note=note) + try: + ticket = pf.ready_ticket(ticket_id, note=note) + except ValueError as exc: + console.print(f'[red]✗[/red] Cannot ready {ticket_id}: {exc}') + raise typer.Exit(1) if not ticket: console.print(f'[red]✗[/red] Ticket {ticket_id} not found.') raise typer.Exit(1) console.print(f'[green]✓[/green] Ticket {ticket.id} is [green]ready[/green]') +def ticket_resume( + ticket_id: str=typer.Argument(..., help='Ticket ID to resume from archive or failed state'), + note: str | None=typer.Option(None, '-n', '--note', help='Optional note explaining recovery/resumption'), +) -> None: + """Resume an archived or failed ticket and restore it to the current runnable queue.""" + from planfile import Planfile + pf = Planfile.auto_discover() + try: + ticket = pf.resume_ticket(ticket_id, note=note) + except ValueError as exc: + console.print(f'[red]✗[/red] Cannot resume {ticket_id}: {exc}') + raise typer.Exit(1) + if not ticket: + console.print(f'[red]✗[/red] Ticket {ticket_id} not found.') + raise typer.Exit(1) + console.print(f'[green]✓[/green] Resumed {ticket.id} → [green]current (ready)[/green]') + + def ticket_fail( ticket_id: str=typer.Argument(..., help='Ticket ID to mark as failed'), error: str=typer.Option(..., '--error', '-e', help='Failure reason'), diff --git a/project/ticket-209/README.md b/project/ticket-209/README.md new file mode 100644 index 0000000..9186985 --- /dev/null +++ b/project/ticket-209/README.md @@ -0,0 +1,34 @@ +# Ticket 209: Planfile: resume archived unfinished ticket in Koru queue + +- **ID**: ticket-209 +- **Owner**: agent:antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: VALIDATION +- **Created**: 2026-10-14 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user requested backlog continuation. Address maskservice/c2004#486 / PLF-3019. +An unfinished ticket (failed or blocked) rotated into an archive/history sprint file remains excluded from `current` sprint even after `ready_ticket` updates its status to open and execution to ready, because it physically resides in an archive/history sprint file. +Improve supported resume/recovery contract in `ready_ticket` and `resume_ticket`: +1. When `ready_ticket` or `resume_ticket` is invoked on an unfinished ticket located in an archive/history sprint, automatically restore it to the `current` sprint via `store.move_ticket(ticket_id, "current")`, restoring native `runnable_report` and `next_ticket` admission. +2. Preserve immutability of completed/canceled tickets: reject readying/resuming tickets that are in terminal state (`done`, `canceled`, `cancelled`). +3. Add `planfile ticket resume ` CLI command and `resume_ticket` method on `Planfile`. +4. Add comprehensive regression tests for resuming archived unfinished tickets and rejecting completed tickets. + +## Acceptance criteria + +- [x] AC-01: `ready_ticket` and `resume_ticket` restore an archived unfinished ticket to the `current` sprint, making it visible to `runnable_report` and `next_ticket`. +- [x] AC-02: `ready_ticket` and `resume_ticket` reject attempts to ready or resume tickets in terminal states (`done`, `canceled`, `cancelled`), preserving immutability. +- [x] AC-03: Add `resume_ticket` method to `Planfile` and `resume` CLI command to `ticket_app`. +- [x] AC-04: Unit and integration tests verify the resume/recovery contract across both single-YAML and sharded storage modes. + +## Validation + +- Pytest: 5/5 tests in `tests/test_ticket_resume_archived.py` pass cleanly. +- Governance: `./project/governance-check.sh` reports `GOV-PASS: passed (0 errors, 0 warnings)`. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-209/intent.json b/project/ticket-209/intent.json new file mode 100644 index 0000000..581eac1 --- /dev/null +++ b/project/ticket-209/intent.json @@ -0,0 +1,25 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-209", + "summary": "Planfile: resume archived unfinished ticket in Koru queue", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-209/**", + "TODO.md", + "project/TICKETS.md", + "planfile/**", + "tests/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/tests/test_ticket_resume_archived.py b/tests/test_ticket_resume_archived.py new file mode 100644 index 0000000..1e01a6e --- /dev/null +++ b/tests/test_ticket_resume_archived.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import pytest +import yaml + +from planfile import Planfile +from planfile.core.models import Ticket, TicketExecution +from planfile.core.store import ImmutableTerminalReopenError, Store + + +def test_resume_archived_unfinished_ticket_restores_current_runnability(tmp_path: Path): + pf = Planfile(str(tmp_path)) + old = datetime.now(timezone.utc) - timedelta(days=2) + + # Create an unfinished ticket that failed/blocked and was archived into history + ticket = pf.create_ticket( + name="Recoverable unfinished task", + status="blocked", + execution=TicketExecution(state="blocked", queue="default"), + ) + history_sprint = f"history-{old:%Y-%m-%d}" + moved = pf.store.move_ticket(ticket.id, history_sprint) + assert moved is True + + # While archived, ticket is NOT in current sprint or runnable report + current_tickets = pf.list_tickets(sprint="current") + assert ticket.id not in [t.id for t in current_tickets] + runnable = pf.runnable_report(sprint="current") + assert ticket.id not in runnable["servable"] + + # Resume the ticket + resumed = pf.resume_ticket(ticket.id, note="Unblocked after dependency fix") + assert resumed is not None + assert resumed.id == ticket.id + assert resumed.status.value == "open" + assert resumed.execution.state == "ready" + assert resumed.sprint == "current" + + # Ticket is now admitted in current sprint and runnable report + current_tickets_after = pf.list_tickets(sprint="current") + assert ticket.id in [t.id for t in current_tickets_after] + runnable_after = pf.runnable_report(sprint="current") + assert ticket.id in runnable_after["servable"] + next_up = pf.next_ticket(sprint="current") + assert next_up is not None + assert next_up.id == ticket.id + + +def test_ready_ticket_on_archived_ticket_restores_to_current(tmp_path: Path): + pf = Planfile(str(tmp_path)) + ticket = pf.create_ticket( + name="Task to ready", + status="open", + execution=TicketExecution(state="failed", queue="default"), + ) + # Move to history + pf.store.move_ticket(ticket.id, "history-2026-10-01") + assert ticket.id not in [t.id for t in pf.list_tickets(sprint="current")] + + # ready_ticket restores it to current + ready = pf.ready_ticket(ticket.id) + assert ready is not None + assert ready.status.value == "open" + assert ready.execution.state == "ready" + assert ready.sprint == "current" + assert ticket.id in [t.id for t in pf.list_tickets(sprint="current")] + + +def test_resume_rejects_completed_or_canceled_ticket(tmp_path: Path): + pf = Planfile(str(tmp_path)) + done_ticket = pf.create_ticket(name="Done task", status="done") + canceled_ticket = pf.create_ticket(name="Canceled task", status="canceled") + + with pytest.raises(ImmutableTerminalReopenError, match="immutable_terminal_reopen"): + pf.resume_ticket(done_ticket.id) + + with pytest.raises(ImmutableTerminalReopenError, match="immutable_terminal_reopen"): + pf.resume_ticket(canceled_ticket.id) + + with pytest.raises(ImmutableTerminalReopenError, match="immutable_terminal_reopen"): + pf.ready_ticket(done_ticket.id) + + +def test_cli_ticket_resume_command(tmp_path: Path, monkeypatch): + from typer.testing import CliRunner + from planfile.cli.commands import app + + runner = CliRunner() + pf = Planfile(str(tmp_path)) + ticket = pf.create_ticket( + name="CLI Resumed task", + status="open", + execution=TicketExecution(state="failed", queue="default"), + ) + pf.store.move_ticket(ticket.id, "history-2026-10-01") + + # Change working directory in test runner + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["ticket", "resume", ticket.id, "-n", "resumed via CLI"]) + assert result.exit_code == 0 + assert "Resumed" in result.output + + reloaded = pf.get_ticket(ticket.id) + assert reloaded.sprint == "current" + assert reloaded.execution.state == "ready" + + +def test_resume_archived_unfinished_ticket_sharded_storage(tmp_path: Path): + pf = Planfile(str(tmp_path)) + pf.store.migrate_to_sharded_yaml(shard_size=10) + + ticket = pf.create_ticket( + name="Sharded recoverable task", + status="blocked", + execution=TicketExecution(state="blocked", queue="default"), + ) + pf.store.move_ticket(ticket.id, "history-2026-10-02") + assert ticket.id not in [t.id for t in pf.list_tickets(sprint="current")] + + resumed = pf.resume_ticket(ticket.id) + assert resumed is not None + assert resumed.sprint == "current" + assert resumed.status.value == "open" + assert resumed.execution.state == "ready" + assert ticket.id in [t.id for t in pf.list_tickets(sprint="current")] + runnable = pf.runnable_report(sprint="current") + assert ticket.id in runnable["servable"] +