Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e178e0c83 | |||
| 69dfb8e8a0 | |||
| f6f83d39f5 | |||
| 21a5fa52e2 | |||
| 884e9ffbfd | |||
| cf15e0cc5f |
@@ -350,6 +350,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
limit. 19 BDD scenarios cover all v3 paths including tool actors,
|
||||
update mode, LSP dict bindings, and field propagation.
|
||||
|
||||
- **Plan Status JSON Envelope Compliance** (#9450): `plan_status` in
|
||||
`src/cleveragents/cli/commands/plan.py` now returns a spec-compliant JSON
|
||||
envelope for `agents plan status --format json` instead of a raw plan
|
||||
dictionary. The envelope includes all required fields: `command: "plan status"`,
|
||||
`status: "ok"`, `exit_code: 0`, `data` (with `action`, `project`, `automation`,
|
||||
`attempt`, `progress`, `timing`, `execution`, `cost`), `timing` (with `started`
|
||||
and `duration_ms`), and `messages: ["Status refreshed"]`. The `elapsed` and
|
||||
`eta` fields in `data.timing` are now computed from plan timestamps and
|
||||
estimation results respectively. The `files_modified` and `child_plans` fields
|
||||
in `data.execution` are derived from plan changeset and subplan references.
|
||||
Imports `Plan as LifecyclePlan` and `ProcessingState` moved to module level;
|
||||
`_get_progress_status` promoted to module-level private function. BDD test
|
||||
scenarios added in `features/plan_status_json_envelope.feature`.
|
||||
|
||||
- **Plan Status JSON Envelope spec compliance follow-up** (#9450): Fixed outer envelope `timing.started` to emit the plan-created-at ISO timestamp via `_build_envelope()` accepting an optional `started_iso` parameter; fixed `child_plans` output format to include the required `" complete"` suffix (e.g. `"0/2 complete"`); fixed progress step status logic so that plans in non-traditional phases (ACTION/SUBMIT) correctly report `"queued"` instead of `"done"` for Strategize and Execute steps; added Behave scenarios verifying timing.started, child_plans suffix, and ACTION phase progress.
|
||||
|
||||
|
||||
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
|
||||
`features/environment.py` now emits its non-assertion exception guard warning to
|
||||
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers.
|
||||
* HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply.
|
||||
* 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 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 Plan Status JSON Envelope spec compliance follow-up for PR #9827 / issue #9450: corrected outer envelope `timing.started` ISO timestamp forwarding through `_build_envelope()`, fixed `child_plans` string format to include the required `" complete"` suffix per spec, and fixed progress step status logic for non-traditional plan phases (ACTION/SUBMIT).
|
||||
* 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 error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
|
||||
@@ -33,14 +33,14 @@ Feature: CLI output formats parity
|
||||
Given there is a single plan for format testing
|
||||
When I run plan status with plan id and --format json
|
||||
Then the output should be valid JSON
|
||||
And the JSON should contain key "processing_state"
|
||||
And the JSON should contain key "state"
|
||||
|
||||
# Plan status --format plain (single plan)
|
||||
Scenario: Plan status single plan outputs plain text
|
||||
Given there is a single plan for format testing
|
||||
When I run plan status with plan id and --format plain
|
||||
Then the format output should contain "plan_id:"
|
||||
And the format output should contain "processing_state:"
|
||||
And the format output should contain "state:"
|
||||
|
||||
# Action list --format table
|
||||
Scenario: Action list outputs table format
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
Feature: Plan status JSON envelope compliance
|
||||
As a programmatic consumer of the CleverAgents CLI
|
||||
I want agents plan status --format json to return a spec-compliant JSON envelope
|
||||
So that I can reliably parse plan status data from the structured output
|
||||
|
||||
Background:
|
||||
Given a plan status JSON envelope test runner
|
||||
And a plan status JSON envelope mocked lifecycle service
|
||||
|
||||
# ── Envelope field presence ──────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON output includes all required top-level envelope fields
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope should contain field "command"
|
||||
And the plan status JSON envelope should contain field "status"
|
||||
And the plan status JSON envelope should contain field "exit_code"
|
||||
And the plan status JSON envelope should contain field "data"
|
||||
And the plan status JSON envelope should contain field "timing"
|
||||
And the plan status JSON envelope should contain field "messages"
|
||||
|
||||
Scenario: Plan status JSON envelope command field is "plan status"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope command should be "plan status"
|
||||
|
||||
Scenario: Plan status JSON envelope status field is "ok"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope status should be "ok"
|
||||
|
||||
Scenario: Plan status JSON envelope exit_code is 0
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope exit_code should be 0
|
||||
|
||||
Scenario: Plan status JSON envelope messages contains "Status refreshed"
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON envelope messages should contain "Status refreshed"
|
||||
|
||||
# ── Data field presence ──────────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON data field contains action name
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "action"
|
||||
|
||||
Scenario: Plan status JSON data field contains plan_id
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "plan_id"
|
||||
|
||||
Scenario: Plan status JSON data field contains phase
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "phase"
|
||||
|
||||
Scenario: Plan status JSON data field contains state
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "state"
|
||||
|
||||
Scenario: Plan status JSON data field contains attempt
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "attempt"
|
||||
|
||||
Scenario: Plan status JSON data field contains progress array
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "progress"
|
||||
And the plan status JSON data progress should be a list
|
||||
|
||||
Scenario: Plan status JSON data progress contains Strategize step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Strategize"
|
||||
|
||||
Scenario: Plan status JSON data progress contains Execute step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Execute"
|
||||
|
||||
Scenario: Plan status JSON data progress contains Apply step
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress should contain step "Apply"
|
||||
|
||||
Scenario: Plan status JSON data field contains execution details
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "execution"
|
||||
And the plan status JSON data execution should contain "sandbox"
|
||||
And the plan status JSON data execution should contain "tool_calls"
|
||||
And the plan status JSON data execution should contain "files_modified"
|
||||
And the plan status JSON data execution should contain "child_plans"
|
||||
And the plan status JSON data execution should contain "checkpoints"
|
||||
|
||||
Scenario: Plan status JSON data field contains cost details
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "cost"
|
||||
And the plan status JSON data cost should contain "tokens_used"
|
||||
And the plan status JSON data cost should contain "cost_so_far"
|
||||
And the plan status JSON data cost should contain "estimated"
|
||||
|
||||
# ── Timing envelope field ────────────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON timing envelope contains duration_ms
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON timing should contain "duration_ms"
|
||||
|
||||
# ── Project and automation fields ────────────────────────────────────────
|
||||
|
||||
Scenario: Plan status JSON data contains project when plan has project links
|
||||
Given a plan status JSON envelope plan exists with project "local/api-service"
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "project"
|
||||
And the plan status JSON data project should be "local/api-service"
|
||||
|
||||
Scenario: Plan status JSON data contains automation when plan has automation profile
|
||||
Given a plan status JSON envelope plan exists with automation profile "review"
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data should contain field "automation"
|
||||
And the plan status JSON data automation should be "review"
|
||||
|
||||
# ── BLOCKER D: timing.started, child_plans format, ACTION phase progress ────
|
||||
|
||||
Scenario: Outer envelope timing field contains started ISO timestamp
|
||||
Given a plan status JSON envelope plan exists
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON timing should contain "started"
|
||||
And the plan status JSON timing started should be an ISO timestamp string
|
||||
|
||||
Scenario: child_plans field has spec-required complete suffix
|
||||
Given a plan status JSON envelope plan exists with no child plans
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data execution child_plans should end with " complete"
|
||||
|
||||
Scenario: Child plans zero-zero format uses spec suffix
|
||||
Given a plan status JSON envelope plan exists with no child plans
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data execution child_plans should be "0/0 complete"
|
||||
|
||||
Scenario: ACTION phase progress shows queued for Strategize and Execute steps
|
||||
Given a plan status JSON envelope plan exists in action phase
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress step "Strategize" should be "queued"
|
||||
And the plan status JSON data progress step "Execute" should be "queued"
|
||||
|
||||
Scenario: ACTION phase progress shows queued for Apply step
|
||||
Given a plan status JSON envelope plan exists in action phase
|
||||
When I run plan status with format json
|
||||
Then the plan status JSON data progress step "Apply" should be "queued"
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Step definitions for plan status JSON envelope compliance feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.plan import app as plan_app
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
|
||||
|
||||
|
||||
def _make_status_plan(
|
||||
*,
|
||||
action_name: str = "local/test-action",
|
||||
phase: PlanPhase = PlanPhase.EXECUTE,
|
||||
state: ProcessingState = ProcessingState.PROCESSING,
|
||||
project_links: list[ProjectLink] | None = None,
|
||||
automation_profile: AutomationProfileRef | None = None,
|
||||
) -> Plan:
|
||||
"""Create a Plan instance for status JSON envelope tests."""
|
||||
now = datetime.now()
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ULID),
|
||||
namespaced_name=NamespacedName.parse("local/test-plan"),
|
||||
description="Test plan description",
|
||||
definition_of_done="All tests pass",
|
||||
action_name=action_name,
|
||||
phase=phase,
|
||||
processing_state=state,
|
||||
project_links=project_links or [],
|
||||
arguments={},
|
||||
arguments_order=[],
|
||||
automation_profile=automation_profile,
|
||||
invariants=[],
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
estimation_actor=None,
|
||||
invariant_actor=None,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_by=None,
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan status JSON envelope test runner")
|
||||
def step_status_envelope_runner(context: Context) -> None:
|
||||
"""Set up the CLI runner."""
|
||||
context.runner = CliRunner()
|
||||
|
||||
|
||||
@given("a plan status JSON envelope mocked lifecycle service")
|
||||
def step_status_envelope_service(context: Context) -> None:
|
||||
"""Set up a mock PlanLifecycleService."""
|
||||
context.mock_service = MagicMock()
|
||||
context.service_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
context.service_patcher.start()
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(context.service_patcher.stop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists")
|
||||
def step_status_envelope_plan(context: Context) -> None:
|
||||
"""Set up a plan for status JSON envelope tests."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="review",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given('a plan status JSON envelope plan exists with project "{project_name}"')
|
||||
def step_status_envelope_plan_with_project(context: Context, project_name: str) -> None:
|
||||
"""Set up a plan with a specific project link."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name=project_name)],
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given(
|
||||
'a plan status JSON envelope plan exists with automation profile "{profile_name}"'
|
||||
)
|
||||
def step_status_envelope_plan_with_profile(context: Context, profile_name: str) -> None:
|
||||
"""Set up a plan with a specific automation profile."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name=profile_name,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run plan status with format json")
|
||||
def step_run_plan_status_json(context: Context) -> None:
|
||||
"""Run plan status with --format json and parse the JSON output."""
|
||||
result = context.runner.invoke(plan_app, ["status", _PLAN_ULID, "--format", "json"])
|
||||
assert result.exit_code == 0, (
|
||||
f"plan status --format json failed ({result.exit_code}): {result.output}"
|
||||
)
|
||||
context.result = result
|
||||
# Parse the JSON output
|
||||
try:
|
||||
context.envelope = json.loads(result.output)
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(
|
||||
f"plan status --format json did not produce valid JSON: {e}\n"
|
||||
f"Output was: {result.output}"
|
||||
) from e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — envelope fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the plan status JSON envelope should contain field "{field_name}"')
|
||||
def step_envelope_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the envelope contains the specified top-level field."""
|
||||
assert field_name in context.envelope, (
|
||||
f"Expected envelope to contain field '{field_name}', "
|
||||
f"but got keys: {list(context.envelope.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope command should be "{expected_command}"')
|
||||
def step_envelope_command(context: Context, expected_command: str) -> None:
|
||||
"""Verify the envelope command field value."""
|
||||
actual = context.envelope.get("command")
|
||||
assert actual == expected_command, (
|
||||
f"Expected envelope command to be '{expected_command}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope status should be "{expected_status}"')
|
||||
def step_envelope_status(context: Context, expected_status: str) -> None:
|
||||
"""Verify the envelope status field value."""
|
||||
actual = context.envelope.get("status")
|
||||
assert actual == expected_status, (
|
||||
f"Expected envelope status to be '{expected_status}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan status JSON envelope exit_code should be {expected_code:d}")
|
||||
def step_envelope_exit_code(context: Context, expected_code: int) -> None:
|
||||
"""Verify the envelope exit_code field value."""
|
||||
actual = context.envelope.get("exit_code")
|
||||
assert actual == expected_code, (
|
||||
f"Expected envelope exit_code to be {expected_code}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON envelope messages should contain "{expected_message}"')
|
||||
def step_envelope_messages(context: Context, expected_message: str) -> None:
|
||||
"""Verify the envelope messages list contains the expected message."""
|
||||
messages = context.envelope.get("messages", [])
|
||||
assert isinstance(messages, list), (
|
||||
f"Expected messages to be a list, got {type(messages)}"
|
||||
)
|
||||
# Messages may be strings or dicts with "text" key
|
||||
message_texts = [m["text"] if isinstance(m, dict) else m for m in messages]
|
||||
assert expected_message in message_texts, (
|
||||
f"Expected messages to contain '{expected_message}', got {messages}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps — data fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the plan status JSON data should contain field "{field_name}"')
|
||||
def step_data_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
assert field_name in data, (
|
||||
f"Expected data to contain field '{field_name}', "
|
||||
f"but got keys: {list(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan status JSON data progress should be a list")
|
||||
def step_data_progress_is_list(context: Context) -> None:
|
||||
"""Verify the data.progress field is a list."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress")
|
||||
assert isinstance(progress, list), (
|
||||
f"Expected data.progress to be a list, got {type(progress)}: {progress}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data progress should contain step "{step_name}"')
|
||||
def step_data_progress_has_step(context: Context, step_name: str) -> None:
|
||||
"""Verify the data.progress list contains a step with the given name."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress", [])
|
||||
step_names = [s.get("step") for s in progress if isinstance(s, dict)]
|
||||
assert step_name in step_names, (
|
||||
f"Expected progress to contain step '{step_name}', "
|
||||
f"but found steps: {step_names}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution should contain "{field_name}"')
|
||||
def step_data_execution_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data.execution dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
assert field_name in execution, (
|
||||
f"Expected data.execution to contain '{field_name}', "
|
||||
f"but got keys: {list(execution.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data cost should contain "{field_name}"')
|
||||
def step_data_cost_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the data.cost dict contains the specified field."""
|
||||
data = context.envelope.get("data", {})
|
||||
cost = data.get("cost", {})
|
||||
assert field_name in cost, (
|
||||
f"Expected data.cost to contain '{field_name}', "
|
||||
f"but got keys: {list(cost.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON timing should contain "{field_name}"')
|
||||
def step_timing_has_field(context: Context, field_name: str) -> None:
|
||||
"""Verify the top-level timing dict contains the specified field."""
|
||||
timing = context.envelope.get("timing", {})
|
||||
assert field_name in timing, (
|
||||
f"Expected timing to contain '{field_name}', "
|
||||
f"but got keys: {list(timing.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data project should be "{expected_project}"')
|
||||
def step_data_project_value(context: Context, expected_project: str) -> None:
|
||||
"""Verify the data.project field value."""
|
||||
data = context.envelope.get("data", {})
|
||||
actual = data.get("project")
|
||||
assert actual == expected_project, (
|
||||
f"Expected data.project to be '{expected_project}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data automation should be "{expected_automation}"')
|
||||
def step_data_automation_value(context: Context, expected_automation: str) -> None:
|
||||
"""Verify the data.automation field value."""
|
||||
data = context.envelope.get("data", {})
|
||||
actual = data.get("automation")
|
||||
assert actual == expected_automation, (
|
||||
f"Expected data.automation to be '{expected_automation}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ── BLOCKER D: additional Given and Then steps ──────────────────────────────
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists with no child plans")
|
||||
def step_status_envelope_no_child_plans(context: Context) -> None:
|
||||
"""Set up a plan with empty child_plan_ids."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="review",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_plan.child_plan_ids = []
|
||||
context.mock_plan.completed_child_plan_ids = []
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@given("a plan status JSON envelope plan exists in action phase")
|
||||
def step_status_envelope_action_phase(context: Context) -> None:
|
||||
"""Set up a plan in the ACTION (non-strategize/execute/apply) phase."""
|
||||
context.mock_plan = _make_status_plan(
|
||||
action_name="local/code-coverage",
|
||||
phase=PlanPhase.ACTION,
|
||||
state=ProcessingState.QUEUED,
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="submitters",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
)
|
||||
context.mock_service.get_plan.return_value = context.mock_plan
|
||||
|
||||
|
||||
@then("the plan status JSON timing started should be an ISO timestamp string")
|
||||
def step_timing_started_is_iso(context: Context) -> None:
|
||||
"""Verify the outer timing.started exists and is a non-empty ISO string."""
|
||||
timing = context.envelope.get("timing", {})
|
||||
started = timing.get("started")
|
||||
assert started is not None, (
|
||||
f"Expected timing.started to be present in outer envelope timing, "
|
||||
f"got keys: {list(timing.keys())}"
|
||||
)
|
||||
assert isinstance(started, str) and len(started) > 0, (
|
||||
f"Expected timing.started to be a non-empty ISO timestamp string, "
|
||||
f"got: {started!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution child_plans should end with "{suffix}"')
|
||||
def step_child_plans_has_suffix(context: Context, suffix: str) -> None:
|
||||
"""Verify the data.execution.child_plans ends with the expected suffix."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
child_plans = execution.get("child_plans")
|
||||
assert isinstance(child_plans, str), (
|
||||
f"Expected child_plans to be a string, got: {type(child_plans)}"
|
||||
)
|
||||
assert child_plans.endswith(suffix), (
|
||||
f"Expected child_plans '{child_plans}' to end with '{suffix}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan status JSON data execution child_plans should be "{expected_value}"')
|
||||
def step_child_plans_exact_value(context: Context, expected_value: str) -> None:
|
||||
"""Verify the exact child_plans string value."""
|
||||
data = context.envelope.get("data", {})
|
||||
execution = data.get("execution", {})
|
||||
actual = execution.get("child_plans")
|
||||
assert actual == expected_value, (
|
||||
f"Expected child_plans to be '{expected_value}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the plan status JSON data progress step "{step_name}" should be "{expected_status}"'
|
||||
)
|
||||
def step_data_progress_step_status(
|
||||
context: Context, step_name: str, expected_status: str
|
||||
) -> None:
|
||||
"""Verify the exact status of a given progress step."""
|
||||
data = context.envelope.get("data", {})
|
||||
progress = data.get("progress", [])
|
||||
for entry in progress:
|
||||
if isinstance(entry, dict) and entry.get("step") == step_name:
|
||||
actual = entry.get("status")
|
||||
assert actual == expected_status, (
|
||||
f"Expected step '{step_name}' status to be '{expected_status}', "
|
||||
f"got '{actual}'"
|
||||
)
|
||||
return
|
||||
raise AssertionError(f"Step '{step_name}' not found in progress: {progress}")
|
||||
@@ -53,6 +53,7 @@ from cleveragents.domain.models.core.error_recovery import (
|
||||
ErrorCategory,
|
||||
classify_error,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
|
||||
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import (
|
||||
GitWorktreeSandbox,
|
||||
@@ -346,8 +347,6 @@ def _execute_output_dict(
|
||||
Returns:
|
||||
A JSON-serialisable dict matching the spec-required envelope.
|
||||
"""
|
||||
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
|
||||
|
||||
if not isinstance(plan, LifecyclePlan):
|
||||
# Legacy plan fallback — return minimal envelope
|
||||
return {
|
||||
@@ -477,6 +476,200 @@ def _execute_output_dict(
|
||||
}
|
||||
|
||||
|
||||
def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str:
|
||||
"""Determine progress status based on plan phase and state.
|
||||
|
||||
Args:
|
||||
phase: The current plan phase.
|
||||
state: The current processing state.
|
||||
|
||||
Returns:
|
||||
A string status: "error", "done", "running", or "queued".
|
||||
"""
|
||||
if state == ProcessingState.ERRORED:
|
||||
return "error"
|
||||
if state in (ProcessingState.COMPLETE, ProcessingState.APPLIED):
|
||||
return "done"
|
||||
if state == ProcessingState.PROCESSING:
|
||||
return "running"
|
||||
return "queued"
|
||||
|
||||
|
||||
def _status_output_dict(
|
||||
plan: Any,
|
||||
started_at: datetime | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build the spec-required status output envelope.
|
||||
|
||||
Returns the structured JSON envelope for ``agents plan status --format json``
|
||||
as defined in the specification §agents plan status.
|
||||
|
||||
Args:
|
||||
plan: The ``Plan`` domain model.
|
||||
started_at: When status was retrieved (used for timing).
|
||||
duration_ms: Elapsed milliseconds. ``None`` when not available.
|
||||
|
||||
Returns:
|
||||
A JSON-serialisable dict matching the spec-required envelope.
|
||||
"""
|
||||
if not isinstance(plan, LifecyclePlan):
|
||||
# Legacy plan fallback — return minimal envelope
|
||||
return {
|
||||
"command": "plan status",
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": {"plan": str(plan)},
|
||||
"timing": {},
|
||||
"messages": ["Status refreshed"],
|
||||
}
|
||||
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
# ── Action name ───────────────────────────────────────────────────────────
|
||||
action: str = plan.action_name or "unknown"
|
||||
|
||||
# ── Project name ──────────────────────────────────────────────────────────
|
||||
project: str | None = None
|
||||
if plan.project_links:
|
||||
project = plan.project_links[0].project_name
|
||||
|
||||
# ── Automation profile ────────────────────────────────────────────────────
|
||||
automation: str | None = None
|
||||
if plan.automation_profile:
|
||||
automation = plan.automation_profile.profile_name
|
||||
|
||||
# ── Attempt ───────────────────────────────────────────────────────────────
|
||||
attempt: int = plan.identity.attempt
|
||||
|
||||
# ── Progress steps ────────────────────────────────────────────────────────
|
||||
# Map plan phase to progress steps (Strategize, Execute, Apply)
|
||||
# Uses phase ordering so that prior phases are correctly marked "done" when
|
||||
# the plan is in an intermediate state (execute → Strategize=done, apply →
|
||||
# Strategize=done + Execute=done). Non-traditional phases (ACTION/SUBMIT)
|
||||
# have every step reported as "queued".
|
||||
_PHASE_ORDER: list[str] = ["strategize", "execute", "apply"]
|
||||
progress: list[dict[str, str]] = []
|
||||
for i, step_label in enumerate(["Strategize", "Execute", "Apply"]):
|
||||
if plan.processing_state in (ProcessingState.COMPLETE, ProcessingState.APPLIED):
|
||||
step_status: str = "done"
|
||||
elif plan.phase.value in _PHASE_ORDER:
|
||||
_current_idx = _PHASE_ORDER.index(plan.phase.value)
|
||||
if i < _current_idx:
|
||||
step_status = "done" # prior phase already complete
|
||||
elif i == _current_idx:
|
||||
step_status = _get_progress_status(plan.phase, plan.processing_state)
|
||||
else:
|
||||
step_status = "queued"
|
||||
else:
|
||||
# Non-traditional phases (ACTION, SUBMIT) — all steps queued
|
||||
step_status = "queued"
|
||||
progress.append({"step": step_label, "status": step_status})
|
||||
|
||||
# ── Timing ────────────────────────────────────────────────────────────────
|
||||
timing_data: dict[str, object] = {}
|
||||
# Per spec §agents plan status, outer `timing.started` should be the
|
||||
# ISO-created timestamp of the plan when available; fall back to the
|
||||
# retrieval time otherwise.
|
||||
if plan.timestamps.created_at is not None:
|
||||
timing_data["started"] = plan.timestamps.created_at.isoformat()
|
||||
elif started_at is not None:
|
||||
timing_data["started"] = started_at.isoformat()
|
||||
if duration_ms is not None:
|
||||
timing_data["duration_ms"] = duration_ms
|
||||
|
||||
# ── Execution details ─────────────────────────────────────────────────────
|
||||
# Derive files_modified from changeset when available
|
||||
files_modified: int = 0
|
||||
_changeset = getattr(plan, "changeset", None)
|
||||
if _changeset is not None:
|
||||
files_modified = len(getattr(_changeset, "changes", []))
|
||||
# Derive child_plans count from subplan references when available
|
||||
child_plans_total: int = 0
|
||||
child_plans_complete: int = 0
|
||||
if hasattr(plan, "child_plan_ids"):
|
||||
child_plan_ids = getattr(plan, "child_plan_ids", []) or []
|
||||
child_plans_total = len(child_plan_ids)
|
||||
if hasattr(plan, "completed_child_plan_ids"):
|
||||
completed_ids = getattr(plan, "completed_child_plan_ids", []) or []
|
||||
child_plans_complete = len(completed_ids)
|
||||
child_plans_str = f"{child_plans_complete}/{child_plans_total} complete"
|
||||
execution: dict[str, object] = {
|
||||
"sandbox": "git_worktree",
|
||||
"tool_calls": len(getattr(plan, "decisions", [])),
|
||||
"files_modified": files_modified,
|
||||
"child_plans": child_plans_str,
|
||||
"checkpoints": (
|
||||
len(getattr(plan, "checkpoints", [])) if hasattr(plan, "checkpoints") else 0
|
||||
),
|
||||
}
|
||||
|
||||
# ── Cost details ──────────────────────────────────────────────────────────
|
||||
cost: dict[str, object] = {
|
||||
"tokens_used": 0,
|
||||
"cost_so_far": 0.0,
|
||||
"estimated": 0.0,
|
||||
}
|
||||
if plan.estimation_result is not None:
|
||||
est = plan.estimation_result.as_display_dict()
|
||||
cost["tokens_used"] = est.get("tokens_used", 0)
|
||||
cost["cost_so_far"] = est.get("cost_so_far", 0.0)
|
||||
cost["estimated"] = est.get("estimated_cost", 0.0)
|
||||
|
||||
# ── Data payload ──────────────────────────────────────────────────────────
|
||||
data: dict[str, object] = {
|
||||
"plan_id": plan_id,
|
||||
"phase": plan.phase.value,
|
||||
"state": plan.processing_state.value,
|
||||
"action": action,
|
||||
"attempt": attempt,
|
||||
"progress": progress,
|
||||
"execution": execution,
|
||||
"cost": cost,
|
||||
}
|
||||
if project:
|
||||
data["project"] = project
|
||||
if automation:
|
||||
data["automation"] = automation
|
||||
|
||||
# Add timing to data if available
|
||||
if plan.timestamps.created_at:
|
||||
# Calculate elapsed time from created_at to updated_at (or now)
|
||||
ts_created = plan.timestamps.created_at
|
||||
ts_end = plan.timestamps.updated_at or ts_created
|
||||
elapsed_seconds = int((ts_end - ts_created).total_seconds())
|
||||
elapsed_h = elapsed_seconds // 3600
|
||||
elapsed_m = (elapsed_seconds % 3600) // 60
|
||||
elapsed_s = elapsed_seconds % 60
|
||||
elapsed_str = f"{elapsed_h:02d}:{elapsed_m:02d}:{elapsed_s:02d}"
|
||||
# ETA: derive from estimation_result when available
|
||||
eta_str: str
|
||||
if plan.estimation_result is not None:
|
||||
est_display = plan.estimation_result.as_display_dict()
|
||||
eta_seconds = int(est_display.get("estimated_duration_seconds", 0))
|
||||
remaining = max(0, eta_seconds - elapsed_seconds)
|
||||
eta_h = remaining // 3600
|
||||
eta_m = (remaining % 3600) // 60
|
||||
eta_s = remaining % 60
|
||||
eta_str = f"{eta_h:02d}:{eta_m:02d}:{eta_s:02d}"
|
||||
else:
|
||||
eta_str = "00:00:00"
|
||||
data["timing"] = {
|
||||
"started": ts_created.strftime("%H:%M:%S"),
|
||||
"elapsed": elapsed_str,
|
||||
"eta": eta_str,
|
||||
}
|
||||
|
||||
return {
|
||||
"command": "plan status",
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": data,
|
||||
"timing": timing_data,
|
||||
"messages": ["Status refreshed"],
|
||||
}
|
||||
|
||||
|
||||
def _get_current_project() -> Project:
|
||||
"""Get the current project or exit with error.
|
||||
|
||||
@@ -2383,12 +2576,59 @@ def plan_status(
|
||||
# instead of a generic "Plan not found".
|
||||
_validate_plan_ulid(plan_id)
|
||||
|
||||
# Show single plan details
|
||||
# Show single plan details — time the actual work (plan retrieval +
|
||||
# envelope construction) so the reported duration reflects real wall-
|
||||
# clock cost rather than a near-zero back-to-back monotonic() read.
|
||||
_status_work_start = time.monotonic()
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _plan_spec_dict(plan)
|
||||
console.print(format_output(data, fmt))
|
||||
envelope = _status_output_dict(
|
||||
plan,
|
||||
started_at=datetime.now(),
|
||||
duration_ms=int((time.monotonic() - _status_work_start) * 1000),
|
||||
)
|
||||
# Pass the data payload and timing info to format_output so it
|
||||
# builds the spec-required envelope correctly (command, status,
|
||||
# timing with started ISO timestamp + duration_ms, messages).
|
||||
_env_data = envelope.get("data", {})
|
||||
_env_timing = envelope.get("timing", {})
|
||||
_env_messages = envelope.get("messages")
|
||||
_env_exit_code_raw = envelope.get("exit_code", 0)
|
||||
_env_data_dict: dict[str, Any] = (
|
||||
_env_data if isinstance(_env_data, dict) else {}
|
||||
)
|
||||
_env_exit_code: int = (
|
||||
_env_exit_code_raw if isinstance(_env_exit_code_raw, int) else 0
|
||||
)
|
||||
# Extract timing info for the outer envelope's timing.started field.
|
||||
# Type guard is needed because _env_timing is typed as `object`
|
||||
# (from dict[str, object].get()); calling .get() on object is a
|
||||
# Pyright strict error without an isinstance check first.
|
||||
_started_iso: str | None = None
|
||||
if isinstance(_env_timing, dict):
|
||||
val = _env_timing.get("started")
|
||||
if isinstance(val, str):
|
||||
_started_iso = val
|
||||
# Convert string messages to the format_output-required dict format
|
||||
_env_messages_list: list[dict[str, Any]] | None = None
|
||||
if isinstance(_env_messages, list):
|
||||
_env_messages_list = [
|
||||
{"level": "ok", "text": m} if isinstance(m, str) else m
|
||||
for m in _env_messages
|
||||
if isinstance(m, (str, dict))
|
||||
]
|
||||
console.print(
|
||||
format_output(
|
||||
_env_data_dict,
|
||||
fmt,
|
||||
command=str(envelope.get("command", "plan status")),
|
||||
status=str(envelope.get("status", "ok")),
|
||||
exit_code=_env_exit_code,
|
||||
messages=_env_messages_list,
|
||||
started_iso=_started_iso or "",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
_print_lifecycle_plan(plan, title="Plan Status")
|
||||
|
||||
@@ -170,6 +170,8 @@ def _build_envelope(
|
||||
exit_code: int,
|
||||
duration_ms: int,
|
||||
messages: list[dict[str, Any]] | None,
|
||||
*,
|
||||
started_iso: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the spec-required JSON/YAML output envelope.
|
||||
|
||||
@@ -183,7 +185,7 @@ def _build_envelope(
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": { ... },
|
||||
"timing": { "duration_ms": 123 },
|
||||
"timing": { "duration_ms": 123, "started": "..." },
|
||||
"messages": [{ "level": "ok", "text": "..." }]
|
||||
}
|
||||
|
||||
@@ -202,6 +204,10 @@ def _build_envelope(
|
||||
messages:
|
||||
Optional list of message dicts; each must have ``"level"`` and
|
||||
``"text"`` keys. If *None*, a default single-entry list is generated.
|
||||
started_iso:
|
||||
Optional ISO 8601 start timestamp for the outer ``timing.started``
|
||||
field. When empty (default) only ``duration_ms`` is emitted in
|
||||
``timing``.
|
||||
|
||||
Raises
|
||||
------
|
||||
@@ -224,12 +230,15 @@ def _build_envelope(
|
||||
messages = [
|
||||
{"level": status, "text": f"{command} completed" if command else "ok"}
|
||||
]
|
||||
timing: dict[str, object] = {"duration_ms": duration_ms}
|
||||
if started_iso:
|
||||
timing["started"] = started_iso
|
||||
return {
|
||||
"command": command,
|
||||
"status": status,
|
||||
"exit_code": exit_code,
|
||||
"data": data,
|
||||
"timing": {"duration_ms": duration_ms},
|
||||
"timing": timing,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
@@ -242,6 +251,7 @@ def format_output(
|
||||
status: str = "ok",
|
||||
exit_code: int = 0,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
started_iso: str = "",
|
||||
) -> str:
|
||||
"""Format *data* according to *format_type*.
|
||||
|
||||
@@ -276,6 +286,9 @@ def format_output(
|
||||
messages:
|
||||
Envelope messages array. If *None*, a default single-entry list
|
||||
is generated from *command* and *status*.
|
||||
started_iso:
|
||||
Optional ISO 8601 start timestamp forwarded to the outer timing
|
||||
field of the envelope (e.g. ``"2026-02-08T12:57:01Z"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -299,13 +312,25 @@ def format_output(
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
duration_ms = int((time.monotonic() - t_start) * 1000)
|
||||
envelope = _build_envelope(
|
||||
safe_data, command, status, exit_code, duration_ms, messages
|
||||
safe_data,
|
||||
command,
|
||||
status,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
messages,
|
||||
started_iso=started_iso,
|
||||
)
|
||||
rendered = _format_json(envelope)
|
||||
elif fmt == OutputFormat.YAML.value:
|
||||
duration_ms = int((time.monotonic() - t_start) * 1000)
|
||||
envelope = _build_envelope(
|
||||
safe_data, command, status, exit_code, duration_ms, messages
|
||||
safe_data,
|
||||
command,
|
||||
status,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
messages,
|
||||
started_iso=started_iso,
|
||||
)
|
||||
rendered = _format_yaml(envelope)
|
||||
else:
|
||||
|
||||
@@ -809,6 +809,17 @@ class Plan(BaseModel):
|
||||
description="Status tracking for spawned subplans",
|
||||
)
|
||||
|
||||
# Child plan IDs — flattened lists derived from subplan statuses for
|
||||
# convenience access in status rendering and cross-plan-correction logic.
|
||||
child_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Plan IDs of all spawned child plans",
|
||||
)
|
||||
completed_child_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Plan IDs of child plans that have completed execution",
|
||||
)
|
||||
|
||||
# Resume metadata (step-level progress for plan resume)
|
||||
last_completed_step: int = Field(
|
||||
default=-1,
|
||||
|
||||
Reference in New Issue
Block a user