feat(my-deepagent): v0.2 PR #2b — mydeepagent runs resume <id> real implementation

Closes the v0.1.0 KNOWN LIMIT where resume was an exit-2 stub. Builds on
v0.2 PR #2a's LangGraph wiring + the existing DB phase-state machine +
sweep_orphan_runs — no Temporal (per DR-3).

Highlights
- `WorkflowEngine.resume(run_id)` (new async method):
  - Loads RunRow, rejects terminal states with
    MyDeepAgentError("run_already_terminal").
  - Reloads worktree_root from `RunRow.worktree_root`, template via
    `_reload_template` (WorkflowTemplateRow JOIN + model_validate), and
    bindings via `_reload_bindings` (run_bindings ⨝ agent_personas).
  - **Does NOT call `bind_personas` again** — locks in the original
    binding so consent / persona-pool changes since the original run
    don't silently shift role assignment.
- `_execute_run` (extracted shared phase loop): `run()` and `resume()`
  both dispatch through it. Skips already-completed phases (emits
  `phase.skipped` event) and re-executes the rest.
- 4 new private helpers on WorkflowEngine: `_get_run_or_raise`,
  `_reload_template`, `_reload_bindings`, `_get_completed_phase_keys`.
- `RunEventType.RUN_RESUMED` and `PHASE_SKIPPED` are now actually
  emitted (the enum members existed already).
- `cli/runs.py _runs_resume_async`: stub → real impl. Validates the run
  exists + non-terminal, loads seed personas + artifact schemas from
  `docs/schemas/`, constructs WorkflowEngine with an
  "abort-on-new-approval" callback (resume should not silently re-prompt
  the user — original gates already passed; a new gate means the
  workflow has changed). Calls engine.resume(UUID(id)), prints final
  state + report. Catches MyDeepAgentError and exits 1 with red error.

Tests
- `tests/integration/test_resume.py` (new, 5 scenarios):
  1. 2-phase mock workflow: phase 1 succeeds, phase 2 fails first time,
     row flipped back to executing → resume → phase 2 completes.
     Asserts `phase.skipped` event for phase 1, `run.resumed` event,
     and exactly 1 mock invocation for phase 2 on resume.
  2. Terminal run → `MyDeepAgentError(code="run_already_terminal")`.
  3. Unknown run id → `MyDeepAgentError(code="run_not_found")`.
  4. RunBindingRow rows missing → `MyDeepAgentError(code="run_metadata_missing")`.
  5. Corrupt `workflow_templates.definition` →
     `MyDeepAgentError(code="template_load_failed")`.
  Mock pattern matches existing test_engine.py: patch
  `my_deepagent.engine.build_agent` to return a fake agent that writes
  the expected artifact and drives the watcher middleware.

Gates
- ruff check + ruff format --check + mypy --strict: PASS (103 source files)
- pytest non-E2E: 587 PASS (12.69 s) — +5 from new resume tests
- pytest E2E real OpenRouter on Postgres: PASS 78.52 s (baseline 71–122 s;
  within DR-3 acceptance threshold ≤+20%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 22:07:24 +09:00
parent 50aacd3382
commit 501292a5cd
4 changed files with 804 additions and 16 deletions

View File

@@ -135,35 +135,91 @@ async def _runs_show_async(run_id: str) -> None:
async def _runs_resume_async(run_id: str) -> None:
"""v0.1.0: resume is not implemented.
"""Resume a non-terminal run from its first non-completed phase.
Surfaces the run state and hints at next steps. Future v0.2 implementation:
rehydrate the workflow template by template_hash, replay phase loop from the
first non-completed phase using the existing checkpointer.
v0.2 PR #2b: actually re-enters `WorkflowEngine.resume(run_id)`. Reloads
bindings + template + worktree from DB; skips already-completed phases;
runs the remaining phases under the LangGraph saver wired in v0.2 PR #2a.
"""
from uuid import UUID
from ..artifact_schema import ArtifactSchemaRegistry
from ..binding import BackendAvailability, PersonaConsentStore
from ..budget import make_budget_tracker_from_config
from ..engine import WorkflowEngine
from ..enums import Backend
from ..errors import MyDeepAgentError
from ..persona import load_personas_from_dir
full_id = await _resolve_run_id(run_id)
config = load_config()
db = Database(config.database_url)
await db.init_schema()
# Fail fast on missing / terminal runs before constructing the engine.
try:
async with db.session() as s:
run = await s.get(RunRow, full_id)
if run is None:
_CONSOLE.print(f"[red]run not found:[/] {run_id}")
raise typer.Exit(code=1)
if run.state in ("completed", "failed", "aborted"):
_CONSOLE.print(
f"[yellow]Run {run.id} is already terminal ({run.state}). "
"Start a fresh run with `mydeepagent run <workflow.yaml>`.[/]"
)
raise typer.Exit(code=1)
_CONSOLE.print(
"[yellow]Resume is not implemented in v0.1.0. The crash-recovery sweep at startup "
"marked this run as failed; relaunch the workflow with `mydeepagent run`.[/]"
)
raise typer.Exit(code=2)
finally:
if run.state in ("completed", "failed", "aborted"):
_CONSOLE.print(
f"[yellow]Run {run.id} is already terminal ({run.state}). "
"Start a fresh run with `mydeepagent run <workflow.yaml>`.[/]"
)
raise typer.Exit(code=1)
except typer.Exit:
await db.dispose()
raise
# Seed assets needed by WorkflowEngine. Personas / artifact schemas come
# from the seed directory, not from DB — they're language-neutral immutable
# fixtures versioned in `docs/schemas/`.
seed_root = Path(__file__).resolve().parents[3] / "docs" / "schemas"
personas = load_personas_from_dir(seed_root / "personas")
registry = ArtifactSchemaRegistry(roots=[seed_root / "artifacts"])
consent = PersonaConsentStore(config.data_dir / "consents.json")
backends = BackendAvailability(available_backends=frozenset(Backend))
async def _no_op_approval(_payload: dict[str, object], _gates: list[str]) -> object:
# Resume in CLI mode does not currently re-prompt for approval — the
# original run already passed any gates it had reached. Reaching this
# callback means a phase we *didn't* pass before is now hitting an
# approval gate; treat that as REQUEST_CHANGES so the user knows.
from ..enums import ApprovalDecisionAction
_CONSOLE.print("[yellow]A new phase needs human approval; aborting resume.[/]")
return ApprovalDecisionAction.REQUEST_CHANGES
budget = make_budget_tracker_from_config(db, config)
await budget.init()
engine = WorkflowEngine(
db=db,
config=config,
persona_pool=personas,
artifact_registry=registry,
consent_store=consent,
available_backends=backends,
approval_callback=_no_op_approval,
budget_tracker=budget,
)
try:
result = await engine.resume(UUID(full_id))
except MyDeepAgentError as e:
_CONSOLE.print(f"[red]resume failed:[/] {e.code}{e}")
await db.dispose()
raise typer.Exit(code=1) from e
_CONSOLE.print(f"[green]Resume complete:[/] run={result.run_id} state={result.state.value}")
if result.final_report_path is not None:
_CONSOLE.print(f" report: {result.final_report_path}")
if result.error:
_CONSOLE.print(f" error: {result.error}")
await db.dispose()
async def _resolve_run_id(prefix_or_full: str) -> str: