Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion planfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions planfile/cli/groups/ticket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ticket_next,
ticket_prune_deps,
ticket_ready,
ticket_resume,
ticket_show,
ticket_split,
ticket_start,
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion planfile/cli/groups/ticket/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
34 changes: 34 additions & 0 deletions project/ticket-209/README.md
Original file line number Diff line number Diff line change
@@ -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 <ticket_id>` 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.
25 changes: 25 additions & 0 deletions project/ticket-209/intent.json
Original file line number Diff line number Diff line change
@@ -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
}
130 changes: 130 additions & 0 deletions tests/test_ticket_resume_archived.py
Original file line number Diff line number Diff line change
@@ -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"]

Loading