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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user