fix(persistence): persist reversion_count, last_completed_step, and last_checkpoint_id on LifecyclePlanModel #3055
@@ -0,0 +1,64 @@
|
||||
"""Add reversion_count, last_completed_step, last_checkpoint_id columns to v3_plans.
|
||||
|
||||
These columns are required for plan resume and reversion-limit functionality.
|
||||
Without them, ``Plan.reversion_count``, ``last_completed_step``, and
|
||||
``last_checkpoint_id`` are not persisted to the database, causing plan resume
|
||||
and reversion limits to fail after a process restart.
|
||||
|
||||
Revision ID: m9_002_plan_resume_fields
|
||||
Revises: m9_001_session_name_column
|
||||
Create Date: 2026-04-05 00:00:00
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m9_002_plan_resume_fields"
|
||||
down_revision: str | Sequence[str] | None = "m9_001_session_name_column"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add resume-tracking columns to ``v3_plans``.
|
||||
|
||||
Adds ``reversion_count``, ``last_completed_step``, and
|
||||
``last_checkpoint_id``.
|
||||
"""
|
||||
op.add_column(
|
||||
"v3_plans",
|
||||
sa.Column(
|
||||
"reversion_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"v3_plans",
|
||||
sa.Column(
|
||||
"last_completed_step",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="-1",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"v3_plans",
|
||||
sa.Column("last_checkpoint_id", sa.Text(), nullable=True),
|
||||
|
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove resume-tracking columns from ``v3_plans``.
|
||||
|
||||
Drops ``reversion_count``, ``last_completed_step``, and
|
||||
``last_checkpoint_id``.
|
||||
"""
|
||||
op.drop_column("v3_plans", "last_checkpoint_id")
|
||||
op.drop_column("v3_plans", "last_completed_step")
|
||||
op.drop_column("v3_plans", "reversion_count")
|
||||
@@ -0,0 +1,70 @@
|
||||
Feature: Plan resume fields round-trip persistence
|
||||
As a developer
|
||||
I want reversion_count, last_completed_step, and last_checkpoint_id to persist through the database
|
||||
So that plan resume and reversion limits work correctly after process restarts
|
||||
|
||||
Background:
|
||||
Given a fresh in-memory plan persistence database
|
||||
And a prerequisite action "local/persist-action" exists in the database
|
||||
|
||||
Scenario: reversion_count persists through database round-trip
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP01"
|
||||
And the plan has reversion_count 2
|
||||
When I persist the plan via the plan repository
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP01"
|
||||
Then the retrieved plan reversion_count should be 2
|
||||
|
||||
Scenario: last_completed_step persists through database round-trip
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP02"
|
||||
And the plan has last_completed_step 4
|
||||
When I persist the plan via the plan repository
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP02"
|
||||
Then the retrieved plan last_completed_step should be 4
|
||||
|
||||
Scenario: last_checkpoint_id persists through database round-trip
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP03"
|
||||
And the plan has last_checkpoint_id "01HV000000000000000000CP01"
|
||||
When I persist the plan via the plan repository
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP03"
|
||||
Then the retrieved plan last_checkpoint_id should be "01HV000000000000000000CP01"
|
||||
|
||||
Scenario: All three resume fields persist together through database round-trip
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP04"
|
||||
And the plan has reversion_count 1
|
||||
And the plan has last_completed_step 3
|
||||
And the plan has last_checkpoint_id "01HV000000000000000000CP02"
|
||||
When I persist the plan via the plan repository
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP04"
|
||||
Then the retrieved plan reversion_count should be 1
|
||||
And the retrieved plan last_completed_step should be 3
|
||||
And the retrieved plan last_checkpoint_id should be "01HV000000000000000000CP02"
|
||||
|
||||
Scenario: Default values persist correctly when fields are not set
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP05"
|
||||
When I persist the plan via the plan repository
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP05"
|
||||
Then the retrieved plan reversion_count should be 0
|
||||
And the retrieved plan last_completed_step should be -1
|
||||
And the retrieved plan last_checkpoint_id should be None
|
||||
|
||||
Scenario: Resume fields persist across database reconnection
|
||||
Given the plan persistence database is file-based
|
||||
And a prerequisite action "local/persist-action" exists in the database
|
||||
And a new lifecycle plan with ID "01HV000000000000000000RP06"
|
||||
And the plan has reversion_count 3
|
||||
And the plan has last_completed_step 5
|
||||
And the plan has last_checkpoint_id "01HV000000000000000000CP03"
|
||||
When I persist the plan via the plan repository
|
||||
And I close and reopen the persistence database
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP06"
|
||||
Then the retrieved plan reversion_count should be 3
|
||||
And the retrieved plan last_completed_step should be 5
|
||||
And the retrieved plan last_checkpoint_id should be "01HV000000000000000000CP03"
|
||||
|
||||
Scenario: Updated reversion_count persists after plan update
|
||||
Given a new lifecycle plan with ID "01HV000000000000000000RP07"
|
||||
And the plan has reversion_count 0
|
||||
When I persist the plan via the plan repository
|
||||
And I update the plan reversion_count to 2
|
||||
And I retrieve the plan by ID "01HV000000000000000000RP07"
|
||||
Then the retrieved plan reversion_count should be 2
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Step definitions for plan resume fields persistence feature.
|
||||
|
||||
Tests round-trip persistence of reversion_count, last_completed_step,
|
||||
and last_checkpoint_id through the LifecyclePlanRepository.
|
||||
|
||||
Background steps (fresh in-memory DB, prerequisite action, file-based DB,
|
||||
close-and-reopen, new lifecycle plan, persist plan) are reused from
|
||||
plan_persistence_steps.py — they share the same step text so Behave
|
||||
automatically picks them up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: mutate the plan under test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the plan has reversion_count {count:d}")
|
||||
def step_plan_has_reversion_count(context: Context, count: int) -> None:
|
||||
"""Set reversion_count on the plan being built."""
|
||||
context._pp_plan = context._pp_plan.model_copy(update={"reversion_count": count})
|
||||
|
||||
|
||||
@given("the plan has last_completed_step {step:d}")
|
||||
def step_plan_has_last_completed_step(context: Context, step: int) -> None:
|
||||
"""Set last_completed_step on the plan being built."""
|
||||
context._pp_plan = context._pp_plan.model_copy(update={"last_completed_step": step})
|
||||
|
||||
|
||||
@given('the plan has last_checkpoint_id "{checkpoint_id}"')
|
||||
def step_plan_has_last_checkpoint_id(context: Context, checkpoint_id: str) -> None:
|
||||
"""Set last_checkpoint_id on the plan being built."""
|
||||
context._pp_plan = context._pp_plan.model_copy(
|
||||
update={"last_checkpoint_id": checkpoint_id}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When: retrieve the plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I retrieve the plan by ID "{plan_id}"')
|
||||
def step_retrieve_plan_by_id(context: Context, plan_id: str) -> None:
|
||||
"""Retrieve the plan from the repository and store it for assertions."""
|
||||
context.retrieved_plan = context._pp_plan_repo.get(plan_id)
|
||||
assert context.retrieved_plan is not None, (
|
||||
f"Plan {plan_id!r} not found in repository"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When: update reversion_count on an already-persisted plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I update the plan reversion_count to {count:d}")
|
||||
def step_update_plan_reversion_count(context: Context, count: int) -> None:
|
||||
"""Update reversion_count on the persisted plan.
|
||||
|
||||
Uses ``context._pp_plan`` (the plan that was just persisted) so this
|
||||
step can be called immediately after "I persist the plan via the plan
|
||||
repository" without a prior retrieve step.
|
||||
"""
|
||||
updated = context._pp_plan.model_copy(update={"reversion_count": count})
|
||||
context._pp_plan_repo.update(updated)
|
||||
context._pp_session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: assert resume field values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the retrieved plan reversion_count should be {count:d}")
|
||||
def step_retrieved_plan_reversion_count(context: Context, count: int) -> None:
|
||||
"""Assert the retrieved plan's reversion_count."""
|
||||
assert context.retrieved_plan.reversion_count == count, (
|
||||
f"Expected reversion_count={count}, got {context.retrieved_plan.reversion_count}"
|
||||
)
|
||||
|
||||
|
||||
@then("the retrieved plan last_completed_step should be {step:d}")
|
||||
def step_retrieved_plan_last_completed_step(context: Context, step: int) -> None:
|
||||
"""Assert the retrieved plan's last_completed_step."""
|
||||
assert context.retrieved_plan.last_completed_step == step, (
|
||||
f"Expected last_completed_step={step}, "
|
||||
f"got {context.retrieved_plan.last_completed_step}"
|
||||
)
|
||||
|
||||
|
||||
@then('the retrieved plan last_checkpoint_id should be "{checkpoint_id}"')
|
||||
def step_retrieved_plan_last_checkpoint_id(
|
||||
context: Context, checkpoint_id: str
|
||||
) -> None:
|
||||
"""Assert the retrieved plan's last_checkpoint_id matches the expected value."""
|
||||
assert context.retrieved_plan.last_checkpoint_id == checkpoint_id, (
|
||||
f"Expected last_checkpoint_id={checkpoint_id!r}, "
|
||||
f"got {context.retrieved_plan.last_checkpoint_id!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the retrieved plan last_checkpoint_id should be None")
|
||||
def step_retrieved_plan_last_checkpoint_id_none(context: Context) -> None:
|
||||
"""Assert the retrieved plan's last_checkpoint_id is None."""
|
||||
assert context.retrieved_plan.last_checkpoint_id is None, (
|
||||
f"Expected last_checkpoint_id=None, "
|
||||
f"got {context.retrieved_plan.last_checkpoint_id!r}"
|
||||
)
|
||||
@@ -639,6 +639,13 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
|
||||
)
|
||||
attempt = Column(Integer, nullable=False, default=1)
|
||||
|
||||
# Resume tracking fields
|
||||
reversion_count = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_completed_step = Column(
|
||||
Integer, nullable=False, default=-1, server_default="-1"
|
||||
)
|
||||
last_checkpoint_id = Column(String(26), nullable=True)
|
||||
|
||||
# Rendered description and DoD
|
||||
description = Column(Text, nullable=False)
|
||||
definition_of_done = Column(Text, nullable=True)
|
||||
@@ -1015,6 +1022,9 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
|
||||
tags=tags_list,
|
||||
reusable=cast(bool, self.reusable),
|
||||
read_only=cast(bool, self.read_only),
|
||||
reversion_count=cast(int, self.reversion_count),
|
||||
last_completed_step=cast(int, self.last_completed_step),
|
||||
last_checkpoint_id=cast("str | None", self.last_checkpoint_id),
|
||||
execution_environment=cast("str | None", self.execution_environment),
|
||||
execution_env_priority=(
|
||||
ExecutionEnvPriority(cast(str, self.execution_env_priority))
|
||||
@@ -1076,6 +1086,9 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
|
||||
phase=plan.phase.value if hasattr(plan.phase, "value") else plan.phase,
|
||||
processing_state=state_str,
|
||||
attempt=plan.identity.attempt,
|
||||
reversion_count=plan.reversion_count,
|
||||
last_completed_step=plan.last_completed_step,
|
||||
last_checkpoint_id=plan.last_checkpoint_id,
|
||||
description=plan.description,
|
||||
definition_of_done=plan.definition_of_done,
|
||||
strategy_actor=plan.strategy_actor,
|
||||
|
||||
@@ -1400,6 +1400,11 @@ class LifecyclePlanRepository:
|
||||
)
|
||||
row.decision_root_id = getattr(plan, "decision_root_id", None) # type: ignore[assignment]
|
||||
|
||||
# Resume tracking fields
|
||||
row.reversion_count = plan.reversion_count # type: ignore[assignment]
|
||||
row.last_completed_step = plan.last_completed_step # type: ignore[assignment]
|
||||
row.last_checkpoint_id = plan.last_checkpoint_id # type: ignore[assignment]
|
||||
|
||||
row.reusable = plan.reusable # type: ignore[assignment]
|
||||
row.read_only = plan.read_only # type: ignore[assignment]
|
||||
row.execution_environment = plan.execution_environment # type: ignore[assignment]
|
||||
|
||||
Reference in New Issue
Block a user
Minor: This uses
sa.Text()but the SQLAlchemy model declaresString(26). For SQLite this is fine, but for PostgreSQL it would create schema drift (TEXT vs VARCHAR(26)). Considersa.String(26)to match the model. Non-blocking.Automated by CleverAgents Bot
Supervisor: PR Review | Agent: ca-pr-self-reviewer