Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74a17a1c15 | |||
| 087ee3661a | |||
| 2ef9cfd538 | |||
| 821b7e27fe | |||
| ea5258512c |
@@ -215,6 +215,20 @@ 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`.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -23,6 +23,7 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed the plan status JSON envelope compliance fix (PR #9827 / issue #9450): wrapped `agents plan status --format json` output in spec-required JSON envelope with proper metadata fields (`command`, `status`, `exit_code`, `timing`, `messages`) and fully computed nested data sections (`action`, `project`, `automation`, `attempt`, `progress`, `timing`, `execution`, `cost`).
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
|
||||
@@ -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,127 @@
|
||||
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"
|
||||
@@ -0,0 +1,293 @@
|
||||
"""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}'"
|
||||
)
|
||||
@@ -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)
|
||||
progress: list[dict[str, str]] = [
|
||||
{
|
||||
"step": "Strategize",
|
||||
"status": (
|
||||
"done"
|
||||
if plan.phase.value != "strategize"
|
||||
else _get_progress_status(plan.phase, plan.processing_state)
|
||||
),
|
||||
},
|
||||
{
|
||||
"step": "Execute",
|
||||
"status": (
|
||||
"done"
|
||||
if plan.phase.value not in ("strategize", "execute")
|
||||
else _get_progress_status(plan.phase, plan.processing_state)
|
||||
),
|
||||
},
|
||||
{
|
||||
"step": "Apply",
|
||||
"status": (
|
||||
_get_progress_status(plan.phase, plan.processing_state)
|
||||
if plan.phase.value == "apply"
|
||||
else "queued"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
# ── Timing ────────────────────────────────────────────────────────────────
|
||||
timing_data: dict[str, object] = {}
|
||||
if 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}"
|
||||
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,47 @@ 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 to format_output so it builds the
|
||||
# spec-required envelope correctly (command, status, timing, etc.)
|
||||
_env_data = envelope.get("data", {})
|
||||
_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
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
_print_lifecycle_plan(plan, title="Plan Status")
|
||||
|
||||
Reference in New Issue
Block a user