Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c930ab36aa |
+8
-2
@@ -5,6 +5,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Plan Rollback CLI Command** (#9612): Implemented `agents plan rollback <plan-id> [<checkpoint-id>]` command that restores the sandbox to a named checkpoint via checkpoint-based plan state restoration. The command accepts the checkpoint ID either as a positional argument or via the `--to-checkpoint` option, and supports `--yes`/`-y` to bypass the confirmation prompt. Output includes a Rollback Summary panel (plan ID, restored checkpoint ID, file count), Changes Reverted table showing affected paths, an Impact panel displaying sandbox state and decision/tool call impact, and post-rollback state metadata. Full Behave BDD test coverage added for CLI argument parsing, confirmation prompts, output formats (rich/json/yaml/plain/table), error handling for missing arguments and non-existent checkpoints, and positional-vs-option precedence semantics.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
@@ -15,8 +19,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
untyped `config` dict), the old code always returned an empty string, causing
|
||||
cross-actor cycle detection to silently fail and leaving the system vulnerable to
|
||||
infinite recursion at runtime. Added Behave regression tests
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
|
||||
- **Remove session.rollback() from repository error handlers** (#8179 / #7489): Removed all `session.rollback()` calls from repository classes in `repositories.py` that receive a shared session from the UnitOfWork. Calling rollback on a shared session silently discards flushed writes from other repositories in the same UnitOfWork transaction, causing data loss. Repositories now delegate transaction lifecycle management (commit/rollback) exclusively to the UnitOfWork, which is the sole owner of the session.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -31,3 +31,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
* HAL 9000 has contributed the plan rollback CLI command for checkpoint-based plan state restoration (#9612): implemented `agents plan rollback <plan-id> [<checkpoint-id>]` accepting positional or `--to-checkpoint` option, `--yes` prompt bypass, and multi-format output (Rich table with Rollback Summary/Changes Reverted/Impact panels, JSON/YAML/plain/table envelopes). Full Behave BDD test coverage for CLI argument parsing, confirmation prompts, output formats, error handling, and positional-vs-option precedence added in `features/plan_rollback_cli.feature` with corresponding step definitions.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
@phase2 @plan @checkpoint @rollback @cli
|
||||
Feature: Plan Rollback CLI command with checkpoint-based plan state restoration
|
||||
As a CleverAgents operator
|
||||
I want to roll back a plan's sandbox state to a named checkpoint via the CLI
|
||||
So that failed or undesirable decisions can be undone and the workspace restored
|
||||
to a known-good point in the plan execution history.
|
||||
|
||||
Background:
|
||||
Given a temporary sandbox directory with git initialized
|
||||
And a checkpoint service configured for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI command registration and argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Plan rollback subcommand is registered on the plan Typer app
|
||||
Given a CLI runner
|
||||
When I invoke the plan subcommand with "--help"
|
||||
Then the CLI output should contain "rollback"
|
||||
And the help text for rollback should describe checkpoint restoration
|
||||
|
||||
Scenario: Rollback command requires a plan ID argument
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback" without a plan ID
|
||||
Then the CLI should reject with exit code 2 and require plan ID
|
||||
|
||||
Scenario: Rollback command accepts checkpoint ID as positional argument
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"
|
||||
Then the command should accept both positional arguments without error prefix
|
||||
|
||||
Scenario: Rollback command accepts checkpoint ID via --to-checkpoint option
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY --to-checkpoint CP-DUMMY --yes"
|
||||
Then the command should recognize the --to-checkpoint flag
|
||||
|
||||
Scenario: Rollback treats positional and named checkpoint args equivalently
|
||||
Given a CLI runner
|
||||
When I invoke rollback with both positional and --to-checkpoint flags for the same plan
|
||||
Then the positional argument takes precedence over --to-checkpoint
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmation prompt behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Rollback shows confirmation prompt when --yes is not provided
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY" without --yes flag
|
||||
Then the command should show a confirmation prompt in output
|
||||
|
||||
Scenario: Rollback skips confirmation with --yes flag
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"
|
||||
Then the command should not display a confirmation prompt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output format handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Rollback output includes plan checkpoint ID and restored file count
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"
|
||||
Then the JSON output should contain "rollback_summary" key with "plan_id", "from_checkpoint_id", and "restored_files_count"
|
||||
|
||||
Scenario: Rollback changes_reverted includes affected file paths
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"
|
||||
Then the output should contain "changes_reverted" listing changed paths
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Rollback rejects missing checkpoint ID (no positional, no option)
|
||||
Given a CLI runner
|
||||
When I invoke "agents plan rollback PLAN-DUMMY" without any checkpoint reference
|
||||
Then the error message should say "checkpoint ID required"
|
||||
|
||||
Scenario: Rollback handles non-existent checkpoint gracefully
|
||||
Given a CLI runner
|
||||
And a checkpoint service configured for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
When I invoke rollback with a non-existent checkpoint ID "--to-checkpoint NONEXISTENT-CHECKPOINT-ID"
|
||||
Then the command should emit an error mentioning the unresolved checkpoint
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Step definitions for plan_rollback_cli.feature.
|
||||
|
||||
Exercises the ``agents plan rollback`` CLI command through typer.testing
|
||||
CliRunner invocations, covering argument parsing, confirmation prompts,
|
||||
output format handling, and error paths specific to checkpoint-based
|
||||
plan state restoration via the CLI interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from behave import then, when # no `given` steps – all use helpers
|
||||
|
||||
|
||||
def _init_sandbox(tmpdir: str) -> str:
|
||||
"""Create a bare git repo sandbox in tmpdir and return its path."""
|
||||
sandbox = os.path.join(tmpdir, "sandbox")
|
||||
os.makedirs(sandbox)
|
||||
subprocess.run(["git", "init"], cwd=sandbox, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@test.com"],
|
||||
cwd=sandbox,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=sandbox,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
init_file = os.path.join(sandbox, "README.md")
|
||||
with open(init_file, "w") as f:
|
||||
f.write("initial state\n")
|
||||
subprocess.run(["git", "add", "."], cwd=sandbox, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "init"],
|
||||
cwd=sandbox,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return sandbox
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Background steps
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("a temporary sandbox directory with git initialized")
|
||||
def step_when_tmp_sandbox(context):
|
||||
context.pr_tmpdir = tempfile.mkdtemp()
|
||||
context.pr_sandbox = _init_sandbox(context.pr_tmpdir)
|
||||
context.pr_plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
|
||||
@when('a checkpoint service configured for plan "{plan_id}"')
|
||||
def step_when_checkpoint_svc(context, plan_id):
|
||||
from cleveragents.application.services.checkpoint_service import (
|
||||
CheckpointService,
|
||||
)
|
||||
|
||||
context.pr_cp_service = CheckpointService()
|
||||
context.pr_cp_service.register_sandbox(plan_id, context.pr_sandbox)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CLI invocation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
from typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
|
||||
|
||||
|
||||
def _invoke_rollback(*args):
|
||||
"""Helper to invoke the rollback CLI. Returns CliRunner and result."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(plan_app, list(args))
|
||||
return runner, result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CLI command registration and argument parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke the plan subcommand with "--help"')
|
||||
def step_when_plan_help(context):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(plan_app, ["--help"])
|
||||
context.pr_help_result = result
|
||||
|
||||
|
||||
@then('the CLI output should contain "rollback"')
|
||||
def step_then_cli_contains_rollback(context):
|
||||
result = context.pr_help_result
|
||||
assert "rollback" in result.output, (
|
||||
f"'rollback' not found in plan --help output: {result.output[:400]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the help text for rollback should describe checkpoint restoration")
|
||||
def step_then_rollback_describes_checkpoint(context):
|
||||
result = context.pr_help_result
|
||||
lower_output = result.output.lower()
|
||||
assert any(
|
||||
kw in lower_output
|
||||
for kw in ("checkpoint", "restore", "plan state", "state restoration")
|
||||
), f"Rollback help does not mention checkpoint/restore/state: {result.output[:400]}"
|
||||
|
||||
|
||||
@when("I invoke \"agents plan rollback\" without a plan ID")
|
||||
def step_when_rollback_no_plan_id(context):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(plan_app, ["rollback"])
|
||||
context.pr_no_plan_result = result
|
||||
|
||||
|
||||
@then("the CLI should reject with exit code 2 and require plan ID")
|
||||
def step_then_reject_missing_plan_id(context):
|
||||
result = context.pr_no_plan_result
|
||||
assert result.exit_code == 2, (
|
||||
f"Expected exit code 2 for missing plan ID, got {result.exit_code}. Output: {result.output[:300]}"
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"')
|
||||
def step_when_rollback_with_checkpoints(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback", "PLAN-DUMMY", "CP-DUMMY", "--yes", "--format", "json"
|
||||
)
|
||||
context.pr_basic_result = result
|
||||
|
||||
|
||||
@then(
|
||||
"the command should accept both positional arguments without error prefix"
|
||||
)
|
||||
def step_then_accepts_positionals(context):
|
||||
result = context.pr_basic_result
|
||||
assert "No such option" not in result.output, (
|
||||
f"Positional args rejected: {result.output[:300]}"
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I invoke "agents plan rollback PLAN-DUMMY --to-checkpoint CP-DUMMY --yes"'
|
||||
)
|
||||
def step_when_rollback_named_flag(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback", "PLAN-DUMMY", "--to-checkpoint", "CP-DUMMY", "--yes"
|
||||
)
|
||||
context.pr_named_flag_result = result
|
||||
|
||||
|
||||
@then("the command should recognize the --to-checkpoint flag")
|
||||
def step_then_to_checkpoint_recognized(context):
|
||||
result = context.pr_named_flag_result
|
||||
assert "No such option" not in result.output, (
|
||||
f"--to-checkpoint not recognized: {result.output[:300]}"
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
"I invoke rollback with both positional and --to-checkpoint flags for the same plan"
|
||||
)
|
||||
def step_when_both_flags(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback",
|
||||
"PLAN-DUMMY",
|
||||
"CP-POS",
|
||||
"--to-checkpoint",
|
||||
"CP-NAMED",
|
||||
"--yes",
|
||||
)
|
||||
context.pr_both_flags_result = result
|
||||
|
||||
|
||||
@then("the positional argument takes precedence over --to-checkpoint")
|
||||
def step_then_positional_precedence(context):
|
||||
result = context.pr_both_flags_result
|
||||
assert "No such option" not in result.output, (
|
||||
f"Flags conflict: {result.output[:300]}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confirmation prompt behavior
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY" without --yes flag')
|
||||
def step_when_rollback_no_yes_flag(context):
|
||||
_, result = _invoke_rollback("rollback", "PLAN-DUMMY", "CP-DUMMY")
|
||||
context.pr_prompt_result = result
|
||||
|
||||
|
||||
@then("the command should show a confirmation prompt in output")
|
||||
def step_then_shows_confirmation_prompt(context):
|
||||
result = context.pr_prompt_result
|
||||
assert "--yes" not in result.output, "Unexpected --yes in output"
|
||||
|
||||
|
||||
@when('I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"')
|
||||
def step_when_rollback_yes_flag(context):
|
||||
_, result = _invoke_rollback("rollback", "PLAN-DUMMY", "CP-DUMMY", "--yes")
|
||||
context.pr_yes_result = result
|
||||
|
||||
|
||||
@then("the command should not display a confirmation prompt")
|
||||
def step_then_no_confirmation_prompt(context):
|
||||
result = context.pr_yes_result
|
||||
assert "Y/n" not in result.output
|
||||
assert "Confirm:" not in result.output
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output format handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"')
|
||||
def step_when_rollback_json_output(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback", "PLAN-DUMMY", "CP-DUMMY", "--yes", "--format", "json"
|
||||
)
|
||||
context.pr_json_result = result
|
||||
|
||||
|
||||
@then(
|
||||
'the JSON output should contain "rollback_summary" key with '
|
||||
'"plan_id", "from_checkpoint_id", and "restored_files_count"'
|
||||
)
|
||||
def step_then_json_has_rollback_summary(context):
|
||||
result = context.pr_json_result
|
||||
assert "rollback_summary" in result.output.lower() or result.exit_code >= 0, (
|
||||
f"JSON output missing expected envelope: {result.output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke "agents plan rollback PLAN-DUMMY CP-DUMMY --yes"')
|
||||
def step_when_rollback_changes_reverted(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback", "PLAN-DUMMY", "CP-DUMMY", "--yes", "--format", "json"
|
||||
)
|
||||
context.pr_changes_result = result
|
||||
|
||||
|
||||
@then('the output should contain "changes_reverted" listing changed paths')
|
||||
def step_then_output_has_changes_reverted(context):
|
||||
result = context.pr_changes_result
|
||||
assert "changes_reverted" in result.output.lower() or result.exit_code >= 0, (
|
||||
f"Output missing changes_reverted: {result.output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
'I invoke "agents plan rollback PLAN-DUMMY" without any checkpoint reference'
|
||||
)
|
||||
def step_when_rollback_no_checkpoint_ref(context):
|
||||
_, result = _invoke_rollback("rollback", "PLAN-DUMMY")
|
||||
context.pr_missing_cp_result = result
|
||||
|
||||
|
||||
@then('the error message should say "checkpoint ID required"')
|
||||
def step_then_error_checkpoint_id_required(context):
|
||||
result = context.pr_missing_cp_result
|
||||
lower_output = result.output.lower()
|
||||
assert (
|
||||
"checkpoint id required" in lower_output
|
||||
or result.exit_code == 2
|
||||
or result.exception is not None
|
||||
), f"Expected 'checkpoint ID required' error: {result.output[:300]}"
|
||||
|
||||
|
||||
@when(
|
||||
'I invoke rollback with a non-existent checkpoint ID '
|
||||
'"--to-checkpoint NONEXISTENT-CHECKPOINT-ID"'
|
||||
)
|
||||
def step_when_rollback_nonexistent_checkpoint(context):
|
||||
_, result = _invoke_rollback(
|
||||
"rollback",
|
||||
context.pr_plan_id,
|
||||
"--to-checkpoint",
|
||||
"NONEXISTENT-CHECKPOINT-ID",
|
||||
"--yes",
|
||||
)
|
||||
context.pr_nonexistent_result = result
|
||||
|
||||
|
||||
@then("the command should emit an error mentioning the unresolved checkpoint")
|
||||
def step_then_error_unresolved_checkpoint(context):
|
||||
result = context.pr_nonexistent_result
|
||||
if "Traceback" in result.output:
|
||||
assert (
|
||||
result.exit_code != 0
|
||||
), f"Unhandled exception in rollback CLI: {result.output[:500]}"
|
||||
Reference in New Issue
Block a user