feat: checkpoint device agent runtime milestones

This commit is contained in:
2026-07-06 17:24:03 +08:00
parent 2d4251e98e
commit 5658735bca
153 changed files with 8060 additions and 65 deletions
@@ -0,0 +1,67 @@
## 1. Package scaffolding
- [ ] 1.1 Create the `workflow/` package (`__init__.py`, `models.py`, `conditions.py`, `skill_exec.py`, `runner.py`, `store.py`, `config.py`)
- [ ] 1.2 Add `workflow*` to `[tool.setuptools.packages.find].include` in `pyproject.toml` (no new third-party dependency; `sqlite3` is stdlib, already used by `storage/task_metadata.py`)
- [ ] 1.3 Add Workflow Runtime configuration in `workflow/config.py`: default poll interval, default wait-step timeout, default `WorkflowStore` db path (`workflows/workflows.sqlite3`)
- [ ] 1.4 Extend the project's smoke test (that imports every package) to import `workflow`
## 2. Workflow and step data model (capability: workflow-orchestration)
- [ ] 2.1 Implement `workflow/models.py`: `ConditionSpec{kind: str, params: dict}`, the four step dataclasses (`PlannedGoalStep{step_id, goal, next_step_id}`, `SkillInvocationStep{step_id, skill_id, args, next_step_id}`, `WaitForConditionStep{step_id, condition, timeout_seconds, poll_interval_seconds, next_step_id}`, `BranchStep{step_id, condition, on_true, on_false}`), and the `WorkflowStep` union type
- [ ] 2.2 Implement `WorkflowDefinition{id, name, steps: list[WorkflowStep], entry_step_id}` with a constructor-time validation pass rejecting duplicate `step_id`s and any `next_step_id`/`on_true`/`on_false`/`entry_step_id` that does not reference an existing step in the same definition
- [ ] 2.3 Implement `WorkflowRun{id, definition_id, status, current_step_id, variables: dict, step_results: list[WorkflowStepResult], created_at, updated_at}` and `WorkflowStepResult{step_id, kind, success, detail, task_id: str | None, timestamp}`, with `to_dict()`/`from_dict()` helpers mirroring `core/models.py`'s style
- [ ] 2.4 Write unit tests for `WorkflowDefinition` construction: valid heterogeneous-step definition accepted; duplicate `step_id` rejected; dangling `next_step_id`/`on_true`/`on_false`/`entry_step_id` reference rejected
## 3. WorkflowStore persistence (capability: workflow-orchestration)
- [ ] 3.1 Implement `workflow/store.py`: `WorkflowStore(db_path)` with schema creation for `workflow_definitions`, `workflow_runs`, `workflow_step_results` tables in its own SQLite file, following `storage/task_metadata.py`'s connect-per-call pattern
- [ ] 3.2 Implement `WorkflowStore.save_definition(definition)` / `get_definition(definition_id)`
- [ ] 3.3 Implement `WorkflowStore.create_run(definition_id, initial_variables) -> WorkflowRun` (status `pending`/`running`, `current_step_id` set to the definition's `entry_step_id`)
- [ ] 3.4 Implement `WorkflowStore.append_step_result(run_id, step_result)` and `WorkflowStore.update_run(run_id, *, status=None, current_step_id=None, variables=None)`, both persisting immediately (no in-memory-only buffering)
- [ ] 3.5 Implement `WorkflowStore.get_run(run_id) -> WorkflowRun` reconstructing the full run (status, current step, variables, ordered step results) from persisted rows
- [ ] 3.6 Write unit tests for `WorkflowStore`: create/get round-trip, step-result append ordering, run survives being re-opened via a fresh `WorkflowStore(same db_path)` instance (simulating a process restart)
## 4. ConditionEvaluator registry (capability: workflow-orchestration)
- [ ] 4.1 Implement `workflow/conditions.py`: `ConditionEvaluator` protocol/ABC with `evaluate(spec, *, scene, world_state) -> bool`, and a registry `dict[str, ConditionEvaluator]`
- [ ] 4.2 Implement the `scene_contains_text` evaluator (checks the most recent `Scene`'s elements/text for a target substring from `spec.params`)
- [ ] 4.3 Implement the `world_variable_equals` evaluator (compares `world_state.variables.get(spec.params["name"])` to `spec.params["value"]`; returns `False` — never raises — when `world_state` is `None` or the key is absent)
- [ ] 4.4 Implement the `elapsed_seconds` evaluator (returns `True` once at least `spec.params["seconds"]` have elapsed since the wait step started polling)
- [ ] 4.5 Implement the `step_result_success` evaluator (looks up a prior step's recorded `WorkflowStepResult.success` by `spec.params["step_id"]` from the run's step-result log)
- [ ] 4.6 Implement `evaluate_condition(spec, *, scene, world_state) -> bool` as the registry lookup entry point, raising a distinguishable `UnknownConditionKindError` for an unregistered `kind` (caught by the runner and turned into a failed step, not an uncaught exception)
- [ ] 4.7 Write unit tests for each built-in evaluator (true case, false case) and for the unregistered-kind error path
## 5. Skill-invocation execution path (capability: workflow-orchestration)
- [ ] 5.1 Implement `workflow/skill_exec.py`: `validate_skill_args(skill, args) -> None` raising a descriptive error listing missing required parameters or unrecognized argument keys, called before any tool dispatch
- [ ] 5.2 Implement `resolve_skill_steps(skill, args) -> list[dict]` substituting `{param}` placeholders in the skill's stored tool-call template with validated `args` values
- [ ] 5.3 Implement `run_flow_template_skill(skill, args) -> list[StepResult]` calling `validate_skill_args`, `resolve_skill_steps`, then dispatching each resolved tool call through the same `tools/*` functions `Executor.execute()` already uses, collecting one `StepResult` per resolved step
- [ ] 5.4 Implement the `Skill.kind != "flow_template"` guard: reject with a step-definition error before attempting resolution
- [ ] 5.5 Define (or import, if `skill-learning-runtime`/`skill-catalog-subscription` is already implemented) the minimal `Skill`/`FlowTemplateSkill` dataclass shape `skill_exec.py` depends on, with a code comment flagging reconciliation once one of those changes lands
- [ ] 5.6 Write unit tests for `skill_exec`: valid-args resolution produces the expected resolved tool-call sequence; missing required parameter fails before any tool call; non-flow-template skill kind is rejected
## 6. WorkflowRunner orchestration (capability: workflow-orchestration)
- [ ] 6.1 Implement `workflow/runner.py`: `WorkflowRunner(store, task_runner_factory, ...)` with `run(definition, device_id, initial_variables=None) -> WorkflowRun` that creates a run via `WorkflowStore.create_run()` and drives steps until a terminal status
- [ ] 6.2 Implement the planned-goal step handler: construct a `core.models.Task(goal=step.goal, device_id=...)`, call `TaskRunner(...).run(task)`, map `task.status`/`task.failure_reason` to a `WorkflowStepResult`
- [ ] 6.3 Implement the skill-invocation step handler: look up the referenced skill by `skill_id`, call `skill_exec.run_flow_template_skill`, map the returned `StepResult`s to one aggregate `WorkflowStepResult`
- [ ] 6.4 Implement the wait-for-condition step handler: poll `conditions.evaluate_condition()` at `poll_interval_seconds` until `True` or `timeout_seconds` elapses; record success or a timeout failure
- [ ] 6.5 Implement the branch step handler: evaluate the condition once and set the run's next step to `on_true`/`on_false` accordingly, bypassing default sequential advancement
- [ ] 6.6 Implement default sequential advancement (no explicit `next_step_id`/branch target) as "the next step in `WorkflowDefinition.steps` order" for non-branch step kinds
- [ ] 6.7 After each step handler returns, call `WorkflowStore.append_step_result()` and `WorkflowStore.update_run()` (new `current_step_id`, and `status` if terminal) before advancing — no step is considered "done" until this checkpoint write completes
- [ ] 6.8 Implement `WorkflowRunner.resume(run_id) -> WorkflowRun`: load the persisted run via `WorkflowStore.get_run()`, and continue driving from its persisted `current_step_id`, skipping any step already present in the loaded `step_results` log
- [ ] 6.9 Implement `resume()` on an already-`completed`/`failed`/`cancelled` run as a no-op that returns the existing run state unchanged
- [ ] 6.10 Write unit tests for `WorkflowRunner.run()` covering: a linear planned-goal-only workflow reaching `completed`; a workflow with a failing planned-goal step reaching `failed` with a recorded reason; a skill-invocation step executing resolved tool calls (mocked `tools/*`); a wait step succeeding before timeout and timing out after
- [ ] 6.11 Write unit tests for branch step selection (true path and false path) and for default sequential advancement between non-branch steps
## 7. Resumability end-to-end validation (capability: workflow-orchestration)
- [ ] 7.1 Write an end-to-end test that runs a multi-step workflow through `WorkflowRunner`, stops after two steps (simulating a crash by discarding the in-memory `WorkflowRunner`/`TaskRunner` instances), constructs a fresh `WorkflowRunner`/`WorkflowStore` pointed at the same db file, and asserts `resume(run_id)` continues from the third step without re-invoking the first two steps' tool calls or delegated tasks
- [ ] 7.2 Write an end-to-end test asserting a `WorkflowRun`'s persisted `variables` and `step_results` log are readable and correctly ordered after a full run via `WorkflowStore.get_run()`
- [ ] 7.3 Write an end-to-end test combining a branch step with a wait step and a skill-invocation step in one workflow, asserting the run reaches `completed` via the expected branch path
## 8. Composition safety checks and full-suite validation
- [ ] 8.1 Confirm no existing file under `runtime/`, `storage/`, `tools/`, or `core/` is modified by this change (composition via import only, per design.md's D1/D8/D9)
- [ ] 8.2 Write a test that runs a real (non-mocked) `runtime.task.TaskRunner` instance inside a `WorkflowRunner`-driven planned-goal step, guarding against silent drift in `agent-runtime`'s public `run(task) -> Task` contract this change composes over
- [ ] 8.3 Run the full test suite (`pytest`) and confirm every existing test in `tests/` passes unmodified, with only new `tests/test_workflow_*.py`-style files added