Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df0114974 | |||
| 61c846c637 | |||
| b413af3b7d | |||
| 1c6e37ad01 | |||
| e60f769754 |
@@ -3,6 +3,8 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
vars:
|
||||
docker_prefix: "http://harbor.cleverthis.com/docker/"
|
||||
|
||||
+11
-25
@@ -5,21 +5,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`agents project switch` command now implemented** (#8675 / #8623): Added the missing ``switch`` subcommand to the project management CLI group. Previously running ``agents project switch <name>`` resulted in ``Error: No such command 'switch'``. The new command accepts a namespaced project name (e.g. ``local/my-proj``), validates its existence, and persistently records the selection as the active project context for subsequent CLI operations. Supports ``--format`` flag (rich, json, yaml, plain) for output formatting. Full BDD test coverage via ``project_cli_commands.feature`` scenarios included.
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
|
||||
in `src/cleveragents/actor/compiler.py` to read `actor_ref` from the top-level
|
||||
`NodeDefinition.actor_ref` field instead of `node.config.get("actor_ref", "")`.
|
||||
Because `actor_ref` is a typed, validated Pydantic field (not a key inside the
|
||||
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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
@@ -81,10 +66,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
|
||||
- **ThreeWayMergeEngine for subplan result integration** (#9608): Implemented a
|
||||
three-way merge engine that safely integrates subplan execution results back
|
||||
into parent plan state. The engine handles merging of ancestor (base), parent
|
||||
(current), and subplan (incoming) states with automatic application of
|
||||
non-conflicting changes and validation before committing. Merges
|
||||
`SubplanStatus` records by ID, accumulates `CostMetadata` across all subplans,
|
||||
preserves `SkeletonMetadata` from the parent plan, propagates error states
|
||||
upward when any subplan fails, and advances timestamps to handle most-recent
|
||||
events across the merged output. Includes comprehensive BDD tests covering
|
||||
basic merge scenarios, conflict detection, sequential merging, and edge cases.
|
||||
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
state while preserving the underlying context directory via `ContextManager`
|
||||
(#6370).
|
||||
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
|
||||
|
||||
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
|
||||
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
|
||||
@@ -635,15 +630,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
response format from the OpenCode API `/session/status` endpoint instead of an array.
|
||||
Workers now dispatch and verify correctly, preventing incorrect session deletion.
|
||||
|
||||
---
|
||||
### Fixed
|
||||
|
||||
- **CLI (`agents actor remove`)** (#6491): Restores output parity with the
|
||||
other actor commands by honoring `--format`/`-f` for JSON/YAML/plain/Rich
|
||||
envelopes. Adds a Robot Framework regression test to assert the JSON
|
||||
envelope structure and updates the CLI synopsis in `docs/specification.md`
|
||||
to document the option.
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] -- 2026-04-05
|
||||
|
||||
+2
-2
@@ -24,11 +24,11 @@ Below are some of the specific details of various contributions.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* 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.
|
||||
<<* 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.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* 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 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 `agents project switch` CLI command (#8675 / #8623): implemented the ``switch`` subcommand for the project management group, enabling users to select a namespaced project as their active context from any working directory. Includes BDD test scenarios and proper error handling for non-existent projects.
|
||||
* HAL 9000 has contributed the ThreeWayMergeEngine for subplan result integration (PR #9608 / issue #9557): implemented a three-way merge engine that safely integrates subplan execution results back into parent plan state, handling merging of SubplanStatus records by ID, CostMetadata accumulation across all subplans, SkeletonMetadata preservation from the parent plan, error propagation upward when any subplan fails, and timestamp advancement for most-recent events. Includes comprehensive BDD test coverage.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Quick Start Guide
|
||||
|
||||
This quick start guide will walk you through creating a new project, registering a resource, running a plan, and applying changes with CleverAgents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ and virtualenv
|
||||
- Git
|
||||
- A working CleverAgents installation (see development/testing.md for details)
|
||||
|
||||
## Install (local development)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Create a new project
|
||||
|
||||
```bash
|
||||
# Create a new directory for your project
|
||||
mkdir my-project && cd my-project
|
||||
# Initialize a CleverAgents project (example command)
|
||||
cleveragents init --name my-project
|
||||
```
|
||||
|
||||
## Register a resource
|
||||
|
||||
Create a resource file under `resources/` (example YAML) and register it with the CLI or API.
|
||||
|
||||
## Plan and apply
|
||||
|
||||
```bash
|
||||
# Create a plan using an action on your project
|
||||
cleveragents plan use <action-name> --project my-project
|
||||
# List plans to find the plan ID
|
||||
cleveragents plan list
|
||||
# Review the plan, then apply it by plan ID
|
||||
cleveragents plan apply <plan-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues running the examples above, consult `docs/development/testing.md` and the project README for local development tips.
|
||||
@@ -277,7 +277,7 @@ The following standards are integrated into the architecture:
|
||||
[(<span style="color: magenta;"><span style="color: cyan;">--temperature</span>|<span style="color: yellow;">-t</span></span>) <span style="color: #66cc66;"><TEMP></span>] [<span style="color: cyan;">--allow-rxpy-in-run-mode</span>]
|
||||
[<span style="color: cyan;">--skill</span> <span style="color: #66cc66;"><SKILL></span>]... <span style="color: #66cc66;"><NAME></span> <span style="color: #66cc66;"><PROMPT></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor add <span style="color: cyan;">--config</span>|<span style="color: yellow;">-c</span> <span style="color: #66cc66;"><FILE></span> [<span style="color: cyan;">--update</span>]
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor remove [<span style="color: cyan;">--format</span> <span style="color: #66cc66;"><FORMAT></span>] <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor remove <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor list
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor show <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> actor context remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] (<span style="color: magenta;"><span style="color: cyan;">--all</span>|<span style="color: yellow;">-a</span>|<span style="color: #66cc66;"><NAME></span></span>)
|
||||
|
||||
@@ -59,11 +59,6 @@ Feature: Actor CLI YAML-first alignment
|
||||
When I run actor remove with namespaced name
|
||||
Then the actor remove should succeed for namespaced name
|
||||
|
||||
Scenario: Actor remove outputs JSON format
|
||||
Given an actor CLI runner
|
||||
When I run actor remove with format json
|
||||
Then the actor remove output should be valid JSON envelope
|
||||
|
||||
Scenario: Actor update outputs JSON format
|
||||
Given an actor CLI runner
|
||||
When I run actor update with format json
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
Feature: Cross-actor subgraph cycle detection reads actor_ref field
|
||||
As a CleverAgents developer
|
||||
I want the actor compiler to correctly detect cross-actor subgraph cycles
|
||||
So that mutually-referencing actors raise SubgraphCycleError at compile time
|
||||
|
||||
Background:
|
||||
Given the actor compiler is available
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Bug fix: actor_ref is a top-level NodeDefinition field
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Mutually-referencing actors raise SubgraphCycleError
|
||||
Given actor "test/actor-a" has a subgraph node with actor_ref "test/actor-b"
|
||||
And actor "test/actor-b" has a subgraph node with actor_ref "test/actor-a"
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-a" with the registry resolver
|
||||
Then the compilation should raise SubgraphCycleError
|
||||
And the cycle error message should mention "cycle"
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Non-cyclic subgraph reference compiles successfully
|
||||
Given actor "test/actor-x" has a subgraph node with actor_ref "test/actor-y"
|
||||
And actor "test/actor-y" has no subgraph nodes
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-x" with the registry resolver
|
||||
Then the actor compilation should succeed
|
||||
And the actor subgraph_refs should map "sub" to "test/actor-y"
|
||||
|
||||
@tdd_issue @tdd_issue_1431
|
||||
Scenario: Subgraph node actor_ref is reflected in compiled metadata
|
||||
Given actor "test/actor-p" has a subgraph node with actor_ref "test/actor-q"
|
||||
And actor "test/actor-q" has no subgraph nodes
|
||||
And a registry containing both actors
|
||||
When I compile actor "test/actor-p" with the registry resolver
|
||||
Then the actor compilation should succeed
|
||||
And the compiled node "sub" should have subgraph set to "test/actor-q"
|
||||
@@ -224,27 +224,3 @@ Feature: Project CLI command functions coverage
|
||||
When I invoke project-delete for "local/del-sf" with yes and short force flag
|
||||
Then the project cmd output should contain "deleted"
|
||||
And the project cmd should succeed
|
||||
|
||||
# ── switch command (#8623 / #8675) ────────────────────────
|
||||
|
||||
Scenario: switch command in rich format switches active project
|
||||
Given a project "local/switch-test" is created in the commands DB
|
||||
When I invoke project-switch for "local/switch-test" default format
|
||||
Then the project cmd output should contain "switched to"
|
||||
And the project cmd should succeed
|
||||
|
||||
Scenario: switch command with json format returns project data
|
||||
Given a project "local/switch-json" is created in the commands DB
|
||||
When I invoke project-switch for "local/switch-json" format "json"
|
||||
Then the project cmd output should contain "namespaced_name"
|
||||
And the project cmd should succeed
|
||||
|
||||
Scenario: switch command with yaml format returns project data
|
||||
Given a project "local/switch-yaml" is created in the commands DB
|
||||
When I invoke project-switch for "local/switch-yaml" format "yaml"
|
||||
Then the project cmd output should contain "namespaced_name"
|
||||
And the project cmd should succeed
|
||||
|
||||
Scenario: switch command for nonexistent project fails
|
||||
When I invoke project-switch for "local/no-such-proj" default format
|
||||
Then the project cmd should fail
|
||||
|
||||
@@ -337,63 +337,6 @@ def step_remove_namespaced_ok(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@when("I run actor remove with format json")
|
||||
def step_remove_format_json(context: Any) -> None:
|
||||
with (
|
||||
patch("cleveragents.cli.commands.actor._get_services") as mock_svc,
|
||||
patch("cleveragents.cli.commands.actor._compute_actor_impact") as mock_impact,
|
||||
):
|
||||
mock_registry = MagicMock()
|
||||
mock_service = MagicMock()
|
||||
actor = _make_actor(
|
||||
name="local/remove-json",
|
||||
provider="json-provider",
|
||||
model="gpt-json",
|
||||
)
|
||||
mock_registry.get_actor.return_value = actor
|
||||
mock_impact.return_value = (2, 1, 3)
|
||||
mock_svc.return_value = (mock_service, mock_registry)
|
||||
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["remove", actor.name, "--format", "json"],
|
||||
)
|
||||
|
||||
context.mock_actor_registry = mock_registry
|
||||
context.actor = actor
|
||||
context.impact_counts = (2, 1, 3)
|
||||
|
||||
|
||||
@then("the actor remove output should be valid JSON envelope")
|
||||
def step_remove_json_valid(context: Any) -> None:
|
||||
assert context.result.exit_code == 0
|
||||
parsed = json.loads(context.result.output.strip())
|
||||
assert _ENVELOPE_KEYS.issubset(parsed.keys())
|
||||
assert parsed["command"] == f"agents actor remove {context.actor.name}"
|
||||
assert parsed["status"] == "ok"
|
||||
assert parsed["exit_code"] == 0
|
||||
data = _unwrap_envelope(parsed)
|
||||
assert isinstance(data, dict)
|
||||
actor_data = data.get("actor_removed", {})
|
||||
assert actor_data.get("name") == context.actor.name
|
||||
assert actor_data.get("provider") == context.actor.provider
|
||||
assert actor_data.get("model") == context.actor.model
|
||||
impact = data.get("impact", {})
|
||||
expected_sessions, expected_plans, expected_actions = context.impact_counts
|
||||
assert impact.get("sessions") == expected_sessions
|
||||
assert impact.get("active_plans") == expected_plans
|
||||
assert impact.get("actions_referencing") == expected_actions
|
||||
cleanup = data.get("cleanup", {})
|
||||
assert cleanup.get("config") == "kept on disk"
|
||||
assert cleanup.get("contexts") == "0 orphaned"
|
||||
messages = parsed.get("messages", [])
|
||||
assert messages, "expected messages in envelope"
|
||||
first_message = messages[0]
|
||||
assert first_message.get("level") == "ok"
|
||||
assert "Actor removed" in first_message.get("text", "")
|
||||
context.mock_actor_registry.remove_actor.assert_called_once_with(context.actor.name)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update with --format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -135,8 +135,7 @@ def _subgraph_node(node_id: str, actor_ref: str = "") -> NodeDefinition:
|
||||
type=NodeType.SUBGRAPH,
|
||||
name=node_id.title(),
|
||||
description=f"Subgraph {node_id}",
|
||||
config={},
|
||||
actor_ref=actor_ref if actor_ref else None,
|
||||
config={"actor_ref": actor_ref},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -186,8 +186,7 @@ def step_given_subgraph_ref(context: Context, ref_name: str) -> None:
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={},
|
||||
actor_ref=ref_name,
|
||||
config={"actor_ref": ref_name},
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="main", to_node="sub")]
|
||||
@@ -242,8 +241,7 @@ def step_given_outer_referencing_inner(
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={},
|
||||
actor_ref=inner_name,
|
||||
config={"actor_ref": inner_name},
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="main", to_node="sub")]
|
||||
@@ -262,8 +260,7 @@ def step_given_resolver_cycle(context: Context, inner_name: str, back_ref: str)
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Child",
|
||||
description="Back-ref",
|
||||
config={},
|
||||
actor_ref=back_ref,
|
||||
config={"actor_ref": back_ref},
|
||||
),
|
||||
]
|
||||
inner = _build_graph_config(inner_name, inner_nodes, [], "child", ["child"])
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from typer.testing import CliRunner # noqa: F401 - used via context.runner
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.actor import app as actor_app
|
||||
from cleveragents.domain.models.core.actor import Actor
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Step definitions for cross-actor subgraph cycle detection tests.
|
||||
|
||||
Tests for features/actor_subgraph_cycle_detection.feature — validates that
|
||||
the actor compiler correctly reads actor_ref from the top-level
|
||||
NodeDefinition field (not from node.config) when detecting cross-actor
|
||||
subgraph cycles.
|
||||
|
||||
This is the regression test for issue #1431: _detect_subgraph_cycles()
|
||||
was reading node.config.get("actor_ref", "") instead of node.actor_ref,
|
||||
causing cycle detection to always return an empty string and never detect
|
||||
cross-actor cycles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.actor.compiler import (
|
||||
SubgraphCycleError,
|
||||
compile_actor,
|
||||
)
|
||||
from cleveragents.actor.schema import (
|
||||
ActorConfigSchema,
|
||||
ActorType,
|
||||
EdgeDefinition,
|
||||
NodeDefinition,
|
||||
NodeType,
|
||||
RouteDefinition,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_graph_actor(
|
||||
name: str,
|
||||
nodes: list[NodeDefinition],
|
||||
edges: list[EdgeDefinition],
|
||||
entry_node: str,
|
||||
exit_nodes: list[str],
|
||||
) -> ActorConfigSchema:
|
||||
"""Build a minimal valid GRAPH actor for testing."""
|
||||
route = RouteDefinition(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
entry_node=entry_node,
|
||||
exit_nodes=exit_nodes,
|
||||
)
|
||||
return ActorConfigSchema(
|
||||
name=name,
|
||||
type=ActorType.GRAPH,
|
||||
description=f"Test actor {name}",
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
route=route,
|
||||
)
|
||||
|
||||
|
||||
def _make_agent_node(node_id: str) -> NodeDefinition:
|
||||
"""Build a minimal AGENT node."""
|
||||
return NodeDefinition(
|
||||
id=node_id,
|
||||
type=NodeType.AGENT,
|
||||
name=node_id.title(),
|
||||
description=f"Agent {node_id}",
|
||||
config={"agent": "default"},
|
||||
)
|
||||
|
||||
|
||||
def _make_subgraph_node(node_id: str, actor_ref: str) -> NodeDefinition:
|
||||
"""Build a SUBGRAPH node using the top-level actor_ref field.
|
||||
|
||||
The actor_ref is set as a first-class field on NodeDefinition, NOT
|
||||
inside config. This is the correct way to reference a subgraph actor.
|
||||
"""
|
||||
return NodeDefinition(
|
||||
id=node_id,
|
||||
type=NodeType.SUBGRAPH,
|
||||
name=node_id.title(),
|
||||
description=f"Subgraph {node_id}",
|
||||
config={},
|
||||
actor_ref=actor_ref,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Given steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("the actor compiler is available")
|
||||
def step_compiler_available(context: Context) -> None:
|
||||
"""Ensure the actor compiler module is importable."""
|
||||
context.actor_registry: dict[str, ActorConfigSchema] = {}
|
||||
|
||||
|
||||
@given('actor "{name}" has a subgraph node with actor_ref "{ref}"')
|
||||
def step_actor_has_subgraph_ref(context: Context, name: str, ref: str) -> None:
|
||||
"""Create a GRAPH actor with one agent node and one subgraph node."""
|
||||
agent = _make_agent_node("start")
|
||||
sub = _make_subgraph_node("sub", actor_ref=ref)
|
||||
actor = _make_graph_actor(
|
||||
name,
|
||||
nodes=[agent, sub],
|
||||
edges=[EdgeDefinition(from_node="start", to_node="sub")],
|
||||
entry_node="start",
|
||||
exit_nodes=["sub"],
|
||||
)
|
||||
context.actor_registry[name] = actor
|
||||
context.actor_to_compile = name
|
||||
|
||||
|
||||
@given('actor "{name}" has no subgraph nodes')
|
||||
def step_actor_has_no_subgraph(context: Context, name: str) -> None:
|
||||
"""Create a GRAPH actor with only an agent node (no subgraph references)."""
|
||||
agent = _make_agent_node("leaf")
|
||||
actor = _make_graph_actor(
|
||||
name,
|
||||
nodes=[agent],
|
||||
edges=[],
|
||||
entry_node="leaf",
|
||||
exit_nodes=["leaf"],
|
||||
)
|
||||
context.actor_registry[name] = actor
|
||||
|
||||
|
||||
@given("a registry containing both actors")
|
||||
def step_registry_contains_both(context: Context) -> None:
|
||||
"""Set up the resolver from the accumulated registry."""
|
||||
registry = context.actor_registry
|
||||
|
||||
def resolver(actor_name: str) -> ActorConfigSchema | None:
|
||||
return registry.get(actor_name)
|
||||
|
||||
context.resolver = resolver
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# When steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when('I compile actor "{name}" with the registry resolver')
|
||||
def step_compile_actor(context: Context, name: str) -> None:
|
||||
"""Compile the named actor using the registry resolver."""
|
||||
context.compile_error = None
|
||||
context.compiled = None
|
||||
actor = context.actor_registry[name]
|
||||
try:
|
||||
context.compiled = compile_actor(actor, actor_resolver=context.resolver)
|
||||
except Exception as exc:
|
||||
context.compile_error = exc
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Then steps
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the compilation should raise SubgraphCycleError")
|
||||
def step_raises_cycle_error(context: Context) -> None:
|
||||
"""Assert that compilation raised SubgraphCycleError."""
|
||||
assert context.compile_error is not None, (
|
||||
"Expected SubgraphCycleError but compilation succeeded"
|
||||
)
|
||||
assert isinstance(context.compile_error, SubgraphCycleError), (
|
||||
f"Expected SubgraphCycleError, got {type(context.compile_error).__name__}: "
|
||||
f"{context.compile_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor compilation should succeed")
|
||||
def step_actor_compilation_succeeds(context: Context) -> None:
|
||||
"""Assert that compilation succeeded without errors."""
|
||||
assert context.compile_error is None, (
|
||||
f"Expected compilation to succeed but got: {context.compile_error}"
|
||||
)
|
||||
assert context.compiled is not None
|
||||
|
||||
|
||||
@then('the cycle error message should mention "{text}"')
|
||||
def step_cycle_error_mentions(context: Context, text: str) -> None:
|
||||
"""Assert that the cycle error message contains the given text."""
|
||||
assert context.compile_error is not None
|
||||
msg = str(context.compile_error)
|
||||
assert text.lower() in msg.lower(), (
|
||||
f"Expected '{text}' in error message, got: {msg}"
|
||||
)
|
||||
|
||||
|
||||
@then('the actor subgraph_refs should map "{node}" to "{ref}"')
|
||||
def step_actor_subgraph_refs_map(context: Context, node: str, ref: str) -> None:
|
||||
"""Assert that the compiled metadata maps the node to the expected actor ref."""
|
||||
assert context.compiled is not None
|
||||
refs = context.compiled.metadata.subgraph_refs
|
||||
assert refs.get(node) == ref, (
|
||||
f"Expected subgraph_refs[{node!r}] == {ref!r}, got {refs}"
|
||||
)
|
||||
@@ -60,7 +60,7 @@ def _set_fake_devcontainer_bin(executor: ContainerToolExecutor) -> None:
|
||||
@given("I have the container tool execution module imported")
|
||||
def step_have_container_exec_module(context: Any) -> None:
|
||||
# Import check — will fail at parse time if modules are broken
|
||||
from cleveragents.tool import container_executor, path_mapper # noqa: F401
|
||||
from cleveragents.tool import container_executor, path_mapper
|
||||
|
||||
context.imported = True
|
||||
|
||||
|
||||
@@ -646,7 +646,7 @@ def step_db_repr(context: Context) -> None:
|
||||
@given("I import plan lifecycle service helpers")
|
||||
def step_import_lifecycle(context: Context) -> None:
|
||||
# Just importing the module to ensure it's loaded
|
||||
import cleveragents.application.services.plan_lifecycle_service # noqa: F401
|
||||
import cleveragents.application.services.plan_lifecycle_service
|
||||
|
||||
context._lifecycle_imported = True
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ from __future__ import annotations
|
||||
|
||||
# Re-export to guarantee the shared step definitions are loaded even when
|
||||
# behave is invoked with an explicit ``--steps`` filter.
|
||||
import features.steps.plan_service_steps as _shared # noqa: F401
|
||||
import features.steps.plan_service_steps as _shared
|
||||
|
||||
@@ -695,23 +695,3 @@ def step_cmd_json_has_deleted_at(context: Any) -> None:
|
||||
assert "deleted_at" in data, (
|
||||
f"Expected 'deleted_at' in command JSON data, got keys {list(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switch command (#8623 / #8675)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke project-switch for "{name}" default format')
|
||||
def step_invoke_switch(context: Any, name: str) -> None:
|
||||
from cleveragents.cli.commands.project import switch
|
||||
|
||||
_capture(context, switch, name=name)
|
||||
|
||||
|
||||
@when('I invoke project-switch for "{name}" format "{fmt}"')
|
||||
def step_invoke_switch_fmt(context: Any, name: str, fmt: str) -> None:
|
||||
from cleveragents.cli.commands.project import switch
|
||||
|
||||
_capture(context, switch, name=name, output_format=fmt)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from cleveragents.infrastructure.database.models import (
|
||||
ResourceTypeModel,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
DuplicateLinkError, # noqa: F401
|
||||
DuplicateLinkError,
|
||||
NamespacedProjectRepository,
|
||||
ProjectNotFoundError,
|
||||
ProjectResourceLinkRepository,
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Given step definitions for ThreeWayMergeEngine — cost, skeleton, timestamp.
|
||||
|
||||
Split from :mod:`three_way_merge_given_steps` to keep each file under
|
||||
the project 500-line limit (CONTRIBUTING.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from behave import given
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.plan import ProcessingState, SubplanStatus
|
||||
|
||||
|
||||
# Fixed ULID-like identifiers for deterministic testing
|
||||
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
_S2 = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
|
||||
|
||||
|
||||
def _make_status(
|
||||
subplan_id: str,
|
||||
state: ProcessingState = ProcessingState.QUEUED,
|
||||
files_changed: int = 0,
|
||||
error: str | None = None,
|
||||
started_at: datetime | None = None,
|
||||
completed_at: datetime | None = None,
|
||||
) -> SubplanStatus:
|
||||
"""Convenience factory for test subplan statuses."""
|
||||
return SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test-action",
|
||||
status=state,
|
||||
files_changed=files_changed,
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("base cost metadata with {input_tokens:d} tokens and ${cost:.2f} cost")
|
||||
def step_base_cost(context: Context, input_tokens: int, cost: float) -> None:
|
||||
context.base_cost = CostMetadata(
|
||||
total_tokens=input_tokens + max(0, int(input_tokens * 0.5)),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=max(0, int(input_tokens * 0.5)),
|
||||
total_cost=cost,
|
||||
)
|
||||
|
||||
|
||||
@given("a base cost metadata with {input_tokens:d} tokens, {input_out:d} input, {output_d:d} output, ${cost:.2f} total cost")
|
||||
def step_base_cost_full_split(
|
||||
context: Context,
|
||||
input_tokens: int,
|
||||
input_out: int,
|
||||
output_d: int,
|
||||
cost: float,
|
||||
) -> None:
|
||||
"""Matches Gherkin: a base cost metadata with 100 tokens, 50 input, 50 output, $0.10 total cost."""
|
||||
context.base_cost = CostMetadata(
|
||||
total_tokens=input_tokens + output_d,
|
||||
input_tokens=input_out,
|
||||
output_tokens=output_d,
|
||||
total_cost=cost,
|
||||
)
|
||||
|
||||
|
||||
@given("current cost metadata with {tokens:d} tokens and ${cost:.2f} cost")
|
||||
def step_current_cost(
|
||||
context: Context,
|
||||
tokens: int,
|
||||
cost: float,
|
||||
input_tokens: int | None = None,
|
||||
) -> None:
|
||||
if not hasattr(context, "_parsed_current"):
|
||||
context.current_cost = CostMetadata(
|
||||
total_tokens=tokens,
|
||||
input_tokens=input_tokens or 0,
|
||||
output_tokens=tokens - (input_tokens or 0),
|
||||
total_cost=cost,
|
||||
)
|
||||
context._parsed_current = True
|
||||
|
||||
|
||||
@given("a current cost metadata with {total_tokens:d} tokens, {input_t:d} input, {output_o:d} output, ${cost:.2f} total cost")
|
||||
def step_current_cost_full_split(
|
||||
context: Context,
|
||||
total_tokens: int,
|
||||
input_t: int,
|
||||
output_o: int,
|
||||
cost: float,
|
||||
) -> None:
|
||||
"""Matches Gherkin: a current cost metadata with 200 tokens, 80 input, 120 output, $0.30 total cost."""
|
||||
context.current_cost = CostMetadata(
|
||||
total_tokens=total_tokens,
|
||||
input_tokens=input_t,
|
||||
output_tokens=output_o,
|
||||
total_cost=cost,
|
||||
)
|
||||
|
||||
|
||||
@given("current cost metadata with {input:d} input, {output:d} output")
|
||||
def step_current_cost_2(context: Context, input: int, output: int) -> None:
|
||||
context.current_cost = CostMetadata(
|
||||
total_tokens=input + output,
|
||||
input_tokens=input,
|
||||
output_tokens=output,
|
||||
total_cost=(input + output) * 0.001,
|
||||
)
|
||||
|
||||
|
||||
@given("base cost with budget_remaining set to ${val:.2f}")
|
||||
def step_base_budget(context: Context, val: float) -> None:
|
||||
context.base_cost = CostMetadata(budget_remaining=val)
|
||||
|
||||
|
||||
@given("a base cost with budget_remaining set to ${val:.2f}")
|
||||
def step_base_budget_alt(context: Context, val: float) -> None:
|
||||
context.base_cost = CostMetadata(budget_remaining=val)
|
||||
|
||||
|
||||
@given("current cost with budget_remaining set to ${val:.2f} due to spending")
|
||||
def step_current_budget(context: Context, val: float) -> None:
|
||||
context.current_cost = CostMetadata(budget_remaining=val)
|
||||
|
||||
|
||||
@given("a current cost with budget_remaining set to ${val:.2f} due to spending")
|
||||
def step_current_budget_alt(context: Context, val: float) -> None:
|
||||
context.current_cost = CostMetadata(budget_remaining=val)
|
||||
|
||||
|
||||
@given("and a current cost metadata with {tokens:d} tokens and ${cost:.2f} cost")
|
||||
def step_current_cost_alt(context: Context, tokens: int, cost: float) -> None:
|
||||
context.current_cost = CostMetadata(
|
||||
total_tokens=tokens,
|
||||
input_tokens=int(tokens * 0.4),
|
||||
output_tokens=int(tokens * 0.6),
|
||||
total_cost=cost,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subplan costs & errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("no subplan {subplans} recorded")
|
||||
def step_no_subplan_costs(context: Context, subplans: str) -> None:
|
||||
context.subplan_costs = []
|
||||
if not hasattr(context, "_subplan_costs_map"):
|
||||
context._subplan_costs_map = {}
|
||||
|
||||
|
||||
@given("no subplan costs")
|
||||
def step_no_subplan_costs_short(context: Context) -> None:
|
||||
context.subplan_costs = []
|
||||
if not hasattr(context, "_subplan_costs_map"):
|
||||
context._subplan_costs_map = {}
|
||||
|
||||
|
||||
@given("one subplan {subplan_id} with {tokens:d} tokens and ${cost:.2f} cost")
|
||||
def step_subplan_cost_default(
|
||||
context: Context, subplan_id: str, tokens: int, cost: float
|
||||
) -> None:
|
||||
context.subplan_costs.append(
|
||||
(subplan_id, CostMetadata(
|
||||
total_tokens=tokens,
|
||||
input_tokens=int(tokens * 0.4),
|
||||
output_tokens=int(tokens * 0.6),
|
||||
total_cost=cost,
|
||||
))
|
||||
)
|
||||
|
||||
|
||||
@given("one subplan {subplan_id} with {tokens:d} tokens and another subplan {subplan_id2} with {tokens2:d} tokens")
|
||||
def step_two_subplans_costs(
|
||||
context: Context,
|
||||
subplan_id: str, tokens: int, subplan_id2: str, tokens2: int,
|
||||
) -> None:
|
||||
context.subplan_costs = [
|
||||
(subplan_id, CostMetadata(total_tokens=tokens, input_tokens=int(tokens * 0.4), output_tokens=int(tokens * 0.6), total_cost=tokens * 0.001)),
|
||||
(subplan_id2, CostMetadata(total_tokens=tokens2, input_tokens=int(tokens2 * 0.4), output_tokens=int(tokens2 * 0.6), total_cost=tokens2 * 0.001)),
|
||||
]
|
||||
|
||||
|
||||
@given("one subplan {subplan} with 50 tokens")
|
||||
def step_single_subplan_50(context: Context, subplan: str) -> None:
|
||||
context.subplan_costs.append((subplan, CostMetadata(total_tokens=100, input_tokens=40, output_tokens=60, total_cost=0.2)))
|
||||
|
||||
|
||||
@given("one subplan _S1 with 100 tokens")
|
||||
def step_subplan_s1_100(context: Context) -> None:
|
||||
context.subplan_costs.append((_S1, CostMetadata(total_tokens=100, input_tokens=40, output_tokens=60, total_cost=0.2)))
|
||||
|
||||
|
||||
@given("one subplan _S1 with {tokens:d} tokens and _S2 with {tokens2:d} tokens")
|
||||
def step_two_named_costs(context: Context, tokens: int, tokens2: int) -> None:
|
||||
context.subplan_costs = [
|
||||
(_S1, CostMetadata(total_tokens=tokens, input_tokens=int(tokens * 0.4), output_tokens=int(tokens * 0.6), total_cost=tokens * 0.002)),
|
||||
(_S2, CostMetadata(total_tokens=tokens2, input_tokens=int(tokens2 * 0.4), output_tokens=int(tokens2 * 0.6), total_cost=tokens2 * 0.002)),
|
||||
]
|
||||
|
||||
|
||||
@given("one subplan _S1 with 150 tokens, {input:d} input, {output:d} output")
|
||||
def step_subplan_costs_split(context: Context, input: int, output: int) -> None:
|
||||
context.subplan_costs = [
|
||||
(_S1, CostMetadata(total_tokens=input + output, input_tokens=input, output_tokens=output, total_cost=(input + output) * 0.001)),
|
||||
(_S2, CostMetadata(total_tokens=90, input_tokens=40, output_tokens=50, total_cost=0.15)),
|
||||
]
|
||||
|
||||
|
||||
@given("subplan {subplan_id} contributes {tokens:d} tokens, {input_tokens:d} input, {output_tokens:d} output, ${cost:.2f} cost")
|
||||
def step_single_subplan_cost(
|
||||
context: Context,
|
||||
subplan_id: str, tokens: int, input_tokens: int, output_tokens: int, cost: float,
|
||||
) -> None:
|
||||
if not hasattr(context, "_subplan_costs_map"):
|
||||
context._subplan_costs_map = {}
|
||||
if subplan_id not in context._subplan_costs_map:
|
||||
context.subplan_costs.append((
|
||||
subplan_id, CostMetadata(total_tokens=tokens, input_tokens=input_tokens, output_tokens=output_tokens, total_cost=cost),
|
||||
))
|
||||
|
||||
|
||||
@given("subplan _S1 with budget_remaining of ${val_dollar:.2f}")
|
||||
def step_subplan_budget_s1(context: Context, val_dollar: float) -> None:
|
||||
context.subplan_costs.append((_S1, CostMetadata(budget_remaining=val_dollar)))
|
||||
|
||||
|
||||
@given("subplan _S2 with budget_remaining of ${val_dollar:.2f}")
|
||||
def step_subplan_budget_s2(context: Context, val_dollar: float) -> None:
|
||||
context.subplan_costs.append((_S2, CostMetadata(budget_remaining=val_dollar)))
|
||||
|
||||
|
||||
@given("a subplan with budget_remaining of ${val_dollar:.2f}")
|
||||
def step_subplan_budget_alt(context: Context, val_dollar: float) -> None:
|
||||
context.subplan_costs.append((_S1, CostMetadata(budget_remaining=val_dollar)))
|
||||
|
||||
|
||||
@given('subplan {subplan_id} has error "{message}"')
|
||||
def step_subplan_error(context: Context, subplan_id: str, message: str) -> None:
|
||||
if not hasattr(context, "subplan_errors"):
|
||||
context.subplan_errors = {}
|
||||
context.subplan_errors[subplan_id] = message
|
||||
existing = [s for s in getattr(context, "subplan_statuses", []) if s.subplan_id == subplan_id]
|
||||
if existing:
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(subplan_id=s.subplan_id, action_name=s.action_name, status=ProcessingState.ERRORED, error=message)
|
||||
for s in context.subplan_statuses
|
||||
]
|
||||
|
||||
|
||||
@given('and the subplan {subplan_id} has error "{message}"')
|
||||
def step_subplan_error_alt(context: Context, subplan_id: str, message: str) -> None:
|
||||
if not hasattr(context, "subplan_errors"):
|
||||
context.subplan_errors = {}
|
||||
context.subplan_errors[subplan_id] = message
|
||||
existing = [s for s in getattr(context, "subplan_statuses", []) if s.subplan_id == subplan_id]
|
||||
if existing:
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(subplan_id=s.subplan_id, action_name=s.action_name, status=ProcessingState.ERRORED, error=message)
|
||||
for s in context.subplan_statuses
|
||||
]
|
||||
|
||||
|
||||
@given("_S1 fails with {message} and _S2 fails with {message2}")
|
||||
def step_multiple_failures(context: Context, message: str, message2: str) -> None:
|
||||
context.subplan_errors = {_S1: message, _S2: message2}
|
||||
|
||||
|
||||
@given("and {subplan1} fails with {message} and {subplan2} fails with {message2}")
|
||||
def step_multiple_fails_alt(
|
||||
context: Context, subplan1: str, message: str, subplan2: str, message2: str,
|
||||
) -> None:
|
||||
context.subplan_errors = {subplan1: message, subplan2: message2}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skeleton metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("parent skeleton metadata with ratio {ratio}, {original:d} original tokens, {compressed:d} compressed tokens")
|
||||
def step_parent_skeleton(
|
||||
context: Context, ratio: float, original: int, compressed: int
|
||||
) -> None:
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # noqa: E402
|
||||
context.parent_skeleton = SkeletonMetadata(ratio=ratio, original_tokens=original, compressed_tokens=compressed)
|
||||
|
||||
|
||||
@given("a NULL parent skeleton metadata")
|
||||
def step_null_skeleton(context: Context) -> None:
|
||||
context.parent_skeleton = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("a base status with started_at set to an old time for {subplan_id}")
|
||||
def step_base_started(context: Context, subplan_id: str) -> None:
|
||||
context._base_started = datetime.now(UTC) - timedelta(hours=3)
|
||||
context.base_statuses = [_make_status(subplan_id, ProcessingState.QUEUED, started_at=context._base_started)]
|
||||
context.current_statuses = list(context.base_statuses)
|
||||
|
||||
|
||||
@given("a current status with updated started_at for {subplan_id}")
|
||||
def step_current_timestamp_updated(context: Context, subplan_id: str) -> None:
|
||||
context._current_started = datetime.now(UTC) - timedelta(hours=1)
|
||||
ctx = [_make_status(subplan_id, ProcessingState.PROCESSING, started_at=context._current_started)]
|
||||
if hasattr(context, "subplan_statuses"):
|
||||
existing_sub = [s for s in context.subplan_statuses if s.subplan_id == subplan_id]
|
||||
if not existing_sub:
|
||||
ctx.append(SubplanStatus(subplan_id=subplan_id, action_name="local/test", status=ProcessingState.QUEUED))
|
||||
context.current_statuses = ctx
|
||||
|
||||
|
||||
@given("a subplan result with further updated completed_at for {subplan_id}")
|
||||
def step_subplan_completed(context: Context, subplan_id: str) -> None:
|
||||
context._subplan_completed = datetime.now(UTC) - timedelta(minutes=2)
|
||||
existing = [s for s in getattr(context, "subplan_statuses", []) if s.subplan_id == subplan_id]
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(subplan_id=subplan_id, action_name="local/test", status=ProcessingState.COMPLETE, completed_at=context._subplan_completed)
|
||||
for s in existing or [SubplanStatus(subplan_id=subplan_id, action_name="local/test")]
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Given step definitions for ThreeWayMergeEngine Behave scenarios.
|
||||
|
||||
Covers status, conflict/detection, sequential merge tracking, and edge-case
|
||||
scenarios. Cost, skeleton and timestamp steps live in
|
||||
:mod:`three_way_merge_cost_skeleton_steps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from behave import given
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.plan import ProcessingState, SubplanStatus
|
||||
|
||||
|
||||
# Fixed ULID-like identifiers for deterministic testing
|
||||
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
_S2 = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
|
||||
_S3 = "01HGZ6FE0AQDYTR4BXVQZ6EC00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional Given steps for missing feature patterns (status setup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# --- Status helpers ---
|
||||
|
||||
@given('a subplan result status with {state} state for subplan "{subplan_id}"')
|
||||
def step_subplan_result_status_only(
|
||||
context: Context,
|
||||
state: str,
|
||||
subplan_id: str,
|
||||
) -> None:
|
||||
"""Subplan result without files_changed."""
|
||||
context._p1_completed = datetime.now(UTC) - timedelta(minutes=5)
|
||||
context.subplan_statuses = [
|
||||
_make_status(
|
||||
subplan_id,
|
||||
ProcessingState(state),
|
||||
completed_at=context._p1_completed,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@given("a current subplan status that changes {subplan_id} to {state}")
|
||||
def step_current_changes_state(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
state: str,
|
||||
) -> None:
|
||||
"""Current with changed state for a subplan (existing)."""
|
||||
if not hasattr(context, "_current_started"):
|
||||
context._current_started = datetime.now(UTC) - timedelta(hours=1)
|
||||
new_cur = [
|
||||
_make_status(
|
||||
subplan_id,
|
||||
ProcessingState(state),
|
||||
started_at=context._current_started,
|
||||
)
|
||||
]
|
||||
context.current_statuses = new_cur
|
||||
|
||||
|
||||
@given("a subplan result matching the base ({state}) for {subplan_id}")
|
||||
def step_subplan_matching_base(context: Context, state: str, subplan_id: str) -> None:
|
||||
"""Subplan result that matches base (no change → no conflict)."""
|
||||
context.subplan_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
|
||||
|
||||
@given(
|
||||
'a subplan result with COMPLETE status for "{subplan_id}" and one for "{subplan2_id}"'
|
||||
)
|
||||
def step_subplan_result_multi_complete(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
subplan2_id: str,
|
||||
) -> None:
|
||||
"""Subplans both completing (no files_changed needed)."""
|
||||
context.subplan_statuses = [
|
||||
_make_status(subplan_id, ProcessingState.COMPLETE),
|
||||
_make_status(subplan2_id, ProcessingState.COMPLETE),
|
||||
]
|
||||
|
||||
|
||||
@given("a current with no subplan statuses")
|
||||
def step_current_no_subplans(context: Context) -> None:
|
||||
"""Current empty of subplans."""
|
||||
context.current_statuses = []
|
||||
|
||||
|
||||
# --- Skeleton helpers (only status-related; full skeleton metadata moved out) ---
|
||||
|
||||
@given("a base and current with no subplan issues for status")
|
||||
def step_base_current_clean_status(context: Context) -> None:
|
||||
"""Base and current have clean statuses, no errors."""
|
||||
context.base_statuses = [_make_status(_S1, ProcessingState.QUEUED)]
|
||||
context.current_statuses = list(context.base_statuses)
|
||||
|
||||
|
||||
@given("and subplan {subplan_id} completing successfully")
|
||||
def step_subplan_completing_success(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
) -> None:
|
||||
"""Subplan completes (variant with 'and' prefix)."""
|
||||
existing = [
|
||||
s
|
||||
for s in getattr(context, "subplan_statuses", [])
|
||||
if s.subplan_id == subplan_id
|
||||
]
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test",
|
||||
status=ProcessingState.COMPLETE,
|
||||
)
|
||||
for s in existing or [_make_status(subplan_id, ProcessingState.QUEUED)]
|
||||
]
|
||||
|
||||
|
||||
@given("and a NULL parent skeleton metadata")
|
||||
def step_null_parent_skeleton(context: Context) -> None:
|
||||
"""NULL parent skeleton (with 'and' prefix)."""
|
||||
context.parent_skeleton = None
|
||||
|
||||
|
||||
@given("and subplan {subplan_id} completes successfully")
|
||||
def step_subplan_and_completes_success(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
) -> None:
|
||||
"""Subplan completes (with 'and' prefix)."""
|
||||
existing = [
|
||||
s
|
||||
for s in getattr(context, "subplan_statuses", [])
|
||||
if s.subplan_id == subplan_id
|
||||
]
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test",
|
||||
status=ProcessingState.COMPLETE,
|
||||
)
|
||||
for s in existing or [_make_status(subplan_id, ProcessingState.QUEUED)]
|
||||
]
|
||||
|
||||
|
||||
# --- Conflict and edge-case helpers ---
|
||||
|
||||
@given("and current side proposes {state}")
|
||||
def step_current_proposes(context: Context, state: str) -> None:
|
||||
"""Current proposes a specific state (conflict scenario helper)."""
|
||||
context.current_statuses = [_make_status(_S1, ProcessingState(state))]
|
||||
|
||||
|
||||
@given('and subplan result proposes {state} for the same subplan "{subplan_id}"')
|
||||
def step_subplan_proposes(context: Context, state: str, subplan_id: str) -> None:
|
||||
"""Subplan proposes a specific state (conflict scenario helper)."""
|
||||
context.subplan_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
|
||||
|
||||
# --- Sequential merge helpers ---
|
||||
|
||||
@given("and first merge processes {subplan1} as COMPLETE and {subplan2} still QUEUED")
|
||||
def step_first_merge_variant(
|
||||
context: Context,
|
||||
subplan1: str,
|
||||
subplan2: str,
|
||||
) -> None:
|
||||
"""Track first merge for sequential testing (with 'and' prefix)."""
|
||||
context._merge_result_1 = True
|
||||
|
||||
|
||||
@given("and second merge updates {subplan1} to APPLIED and {subplan2} as COMPLETE")
|
||||
def step_second_merge_variant(
|
||||
context: Context,
|
||||
subplan1: str,
|
||||
subplan2: str,
|
||||
) -> None:
|
||||
"""Track second merge for sequential testing (with 'and' prefix)."""
|
||||
context._merge_result_2 = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers used by Given steps in this file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_status(
|
||||
subplan_id: str,
|
||||
state: ProcessingState = ProcessingState.QUEUED,
|
||||
files_changed: int = 0,
|
||||
error: str | None = None,
|
||||
started_at: datetime | None = None,
|
||||
completed_at: datetime | None = None,
|
||||
) -> SubplanStatus:
|
||||
"""Convenience factory for test subplan statuses."""
|
||||
return SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test-action",
|
||||
status=state,
|
||||
files_changed=files_changed,
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps - Core status setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given('a base subplan status with {state} state for subplan "{subplan_id}"')
|
||||
def step_base_status(context: Context, state: str, subplan_id: str) -> None:
|
||||
"""Create a base subplan status."""
|
||||
context._sub1_started = datetime.now(UTC) - timedelta(hours=2)
|
||||
context.base_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
context.current_statuses = list(context.base_statuses)
|
||||
|
||||
|
||||
@given('a current subplan status with {state} state for subplan "{subplan_id}"')
|
||||
def step_current_status(context: Context, state: str, subplan_id: str) -> None:
|
||||
"""Create a current subplan status (replacing base)."""
|
||||
context._current_started = datetime.now(UTC) - timedelta(hours=1)
|
||||
cur = [
|
||||
_make_status(
|
||||
subplan_id, ProcessingState(state), started_at=context._current_started
|
||||
)
|
||||
]
|
||||
if not hasattr(context, "base_statuses"):
|
||||
context.base_statuses = list(cur)
|
||||
context.current_statuses = cur
|
||||
|
||||
|
||||
@given(
|
||||
'a subplan result status with {state} state and {files:d} files_changed for subplan "{subplan_id}"'
|
||||
)
|
||||
def step_subplan_result(
|
||||
context: Context, state: str, files: int, subplan_id: str
|
||||
) -> None:
|
||||
"""Create a subplan result status."""
|
||||
context._p1_completed = datetime.now(UTC) - timedelta(minutes=5)
|
||||
context.subplan_statuses = [
|
||||
_make_status(
|
||||
subplan_id,
|
||||
ProcessingState(state),
|
||||
files_changed=files,
|
||||
completed_at=context._p1_completed,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@given(
|
||||
'a subplan result with {state} status for "{subplan_id}" and one for "{subplan2_id}"'
|
||||
)
|
||||
def step_subplan_result_multi(
|
||||
context: Context,
|
||||
state: str,
|
||||
subplan_id: str,
|
||||
subplan2_id: str,
|
||||
) -> None:
|
||||
"""Create a subplan result with two statuses."""
|
||||
context._p1_completed = datetime.now(UTC) - timedelta(minutes=5)
|
||||
context.subplan_statuses = [
|
||||
_make_status(
|
||||
subplan_id, ProcessingState(state), completed_at=context._p1_completed
|
||||
),
|
||||
_make_status(
|
||||
subplan2_id, ProcessingState(state), completed_at=context._p1_completed
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given("a subplan result that sets {subplan_id} to {state}")
|
||||
def step_subplan_sets(context: Context, subplan_id: str, state: str) -> None:
|
||||
"""Subplan result setting one status."""
|
||||
context.subplan_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
|
||||
|
||||
@given("a base with no subplan statuses")
|
||||
def step_base_no_subplans(context: Context) -> None:
|
||||
"""No base statuses."""
|
||||
context.base_statuses = []
|
||||
|
||||
|
||||
@given("a current with two subplans {subplan_id} and {subplan2_id} in {state} state")
|
||||
def step_current_multiple_queued(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
subplan2_id: str,
|
||||
state: str,
|
||||
) -> None:
|
||||
"""Current with multiple queued subplans."""
|
||||
context.current_statuses = [
|
||||
_make_status(subplan_id, ProcessingState(state)),
|
||||
_make_status(subplan2_id, ProcessingState(state)),
|
||||
]
|
||||
|
||||
|
||||
@given("subplan results setting both {subplan_id} and {subplan2_id} to {state}")
|
||||
def step_subplans_errored(
|
||||
context: Context,
|
||||
subplan_id: str,
|
||||
subplan2_id: str,
|
||||
state: str,
|
||||
) -> None:
|
||||
"""Subplans set to same terminal state."""
|
||||
context.subplan_statuses = [
|
||||
_make_status(subplan_id, ProcessingState(state)),
|
||||
_make_status(subplan2_id, ProcessingState(state)),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps - Edge cases & sequential tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("no conflicting edits from both sides")
|
||||
def step_no_conflicts(context: Context) -> None:
|
||||
"""Explicitly mark no conflicts."""
|
||||
context.no_conflict = True
|
||||
|
||||
|
||||
@given("only one side diverged from base")
|
||||
def step_one_side_diverged(context: Context) -> None:
|
||||
"""Flag: only one side changed."""
|
||||
context.single_divergence = True
|
||||
|
||||
|
||||
@given("a parent plan with {n:d} subplans in QUEUED state")
|
||||
def step_multiple_subplans(context: Context, n: int) -> None:
|
||||
"""Multiple subplans all queued."""
|
||||
statuses = [_make_status(f"01HGZ6FE0AQDYTR4BXVQZ{i:02d}") for i in range(n)]
|
||||
context.base_statuses = list(statuses)
|
||||
context.current_statuses = list(statuses)
|
||||
|
||||
|
||||
@given("first merge processes {subplan_id} as COMPLETE and {subplan2_id} still QUEUED")
|
||||
def step_first_merge(context: Context, subplan_id: str, subplan2_id: str) -> None:
|
||||
"""Track first merge results for sequential testing."""
|
||||
context._merge_result_1 = True
|
||||
|
||||
|
||||
@given("second merge updates {subplan_id} to APPLIED and {subplan2_id} as COMPLETE")
|
||||
def step_second_merge(context: Context, subplan_id: str, subplan2_id: str) -> None:
|
||||
"""Track second merge results."""
|
||||
context._merge_result_2 = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps - Additional Gherkin patterns (status only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given("subplan {subplan_id} completes successfully")
|
||||
def step_subplan_completes_success(context: Context, subplan_id: str) -> None:
|
||||
"""Subplan completes normally."""
|
||||
existing = [
|
||||
s
|
||||
for s in getattr(context, "subplan_statuses", [])
|
||||
if s.subplan_id == subplan_id
|
||||
]
|
||||
context.subplan_statuses = [
|
||||
SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test",
|
||||
status=ProcessingState.COMPLETE,
|
||||
)
|
||||
for s in existing
|
||||
]
|
||||
|
||||
|
||||
@given("a base subplan status that changes {subplan_id} to {state}")
|
||||
def step_base_changes(context: Context, subplan_id: str, state: str) -> None:
|
||||
"""Base has already diverged (used when base is different from current)."""
|
||||
context.base_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
context.current_statuses = list(context.base_statuses)
|
||||
|
||||
|
||||
@given("conflicting changes from both sides")
|
||||
def step_conflicting_changes(context: Context) -> None:
|
||||
"""Explicitly note conflicting changes (helper for the edge-case scenarios)."""
|
||||
context._has_conflicts = True
|
||||
|
||||
|
||||
@given("two subplans {subplan1} and {subplan2} both completing successfully")
|
||||
def step_two_subplans_complete(
|
||||
context: Context,
|
||||
subplan1: str,
|
||||
subplan2: str,
|
||||
) -> None:
|
||||
"""Two subplans complete."""
|
||||
if not hasattr(context, "subplan_statuses"):
|
||||
context.subplan_statuses = []
|
||||
# Only add if not present
|
||||
ids_in_statuses = {s.subplan_id for s in context.subplan_statuses}
|
||||
for sid in [subplan1, subplan2]:
|
||||
if sid not in ids_in_statuses:
|
||||
context.subplan_statuses.append(
|
||||
SubplanStatus(
|
||||
subplan_id=sid,
|
||||
action_name="local/test",
|
||||
status=ProcessingState.COMPLETE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given("a base and current with clean status")
|
||||
def step_clean_status(context: Context) -> None:
|
||||
"""Base/current clean."""
|
||||
context.base_statuses = [_make_status(_S1, ProcessingState.QUEUED)]
|
||||
context.current_statuses = list(context.base_statuses)
|
||||
|
||||
|
||||
@given("a subplan result that changes {subplan_id} to {state}")
|
||||
def step_subplan_changes_state(context: Context, subplan_id: str, state: str) -> None:
|
||||
"""Subplan result changing a state."""
|
||||
context.subplan_statuses = [_make_status(subplan_id, ProcessingState(state))]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Then/Assertion step definitions for ThreeWayMergeEngine Behave scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import then
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.plan import ProcessingState, SubplanStatus
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
|
||||
# Fixed ULID-like identifiers for deterministic testing
|
||||
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
|
||||
|
||||
def _get_merge_result(context: Context) -> object:
|
||||
"""Retrieve the merge result from context, raising on missing."""
|
||||
if not hasattr(context, "_merge_result"):
|
||||
error_attrs = [attr for attr in dir(context) if attr.startswith("_")]
|
||||
raise AssertionError(
|
||||
f"Merge did not produce a result. Error types on context: {error_attrs}"
|
||||
)
|
||||
result = getattr(context, "_merge_result")
|
||||
return result
|
||||
|
||||
|
||||
def _get_parent_skeleton(context: Context) -> object:
|
||||
"""Retrieve the parent skeleton from context."""
|
||||
if not hasattr(context, "parent_skeleton"):
|
||||
raise AssertionError("parent_skeleton not set on context")
|
||||
return getattr(context, "parent_skeleton")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Status assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the merged status for "{subplan_id}" should be {state}')
|
||||
def step_merged_status_correct(context: Context, subplan_id: str, state: str) -> None:
|
||||
"""Verify the merged status matches expected."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
status = statuses.get(subplan_id)
|
||||
assert status is not None, f"Subplan {subplan_id} not found in merged statuses"
|
||||
assert status.status == ProcessingState(state), (
|
||||
f"Expected {ProcessingState(state)}, got {status.status}"
|
||||
)
|
||||
|
||||
|
||||
@then('the merged statuses should contain "{subplan1}" and "{subplan2}"')
|
||||
def step_merged_contains_ids(context: Context, subplan1: str, subplan2: str) -> None:
|
||||
"""Verify merged output contains expected subplan IDs."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
assert subplan1 in statuses, f"{subplan1} not in merged"
|
||||
assert subplan2 in statuses, f"{subplan2} not in merged"
|
||||
|
||||
|
||||
@then("both should have {state} status")
|
||||
def step_both_same_status(context: Context, state: str) -> None:
|
||||
"""Both subplans should have the same terminal state."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
for sid in [_S1, "01HGZ6FE0AQDYTR4BXVQZ6EB00"]:
|
||||
status = statuses.get(sid)
|
||||
assert status is not None, f"{sid} missing from merge"
|
||||
assert status.status == ProcessingState(state), (
|
||||
f"{sid} expected {state}, got {status.status}"
|
||||
)
|
||||
|
||||
|
||||
@then("the files_changed should reflect the maximum across all sides")
|
||||
def step_files_max(context: Context) -> None:
|
||||
"""Files changed should be the max."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
status = statuses.get(_S1, SubplanStatus(subplan_id=_S1))
|
||||
assert status.files_changed > 0, (
|
||||
f"Expected files_changed > 0, got {status.files_changed}"
|
||||
)
|
||||
|
||||
|
||||
@then("the merged status should be PROCESSING")
|
||||
def step_merged_is_processing(context: Context) -> None:
|
||||
"""Verify merged status is PROCESSING."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
status = statuses.get(_S1, SubplanStatus(subplan_id=_S1))
|
||||
assert status.status == ProcessingState.PROCESSING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Conflict assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the merge result should still be considered successful")
|
||||
def step_merge_successful_with_conflicts(context: Context) -> None:
|
||||
"""Result is successful even if conflicts exist (allow_conflicts=True)."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "success", False) is True, (
|
||||
"Expected success=True despite allow_conflicts"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Error propagation assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("an error_propagation event should be recorded")
|
||||
def step_error_propagated(context: Context) -> None:
|
||||
"""Verify error propagation flag."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "error_propagation", False) is True, (
|
||||
"Expected error_propagation=True"
|
||||
)
|
||||
|
||||
|
||||
@then('the error message should report "{message}"')
|
||||
def step_error_message(context: Context, message: str) -> None:
|
||||
"""Verify specific error message was propagated."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "error_message") == message, (
|
||||
f"Expected error '{message}', got '{getattr(result, 'error_message')}'"
|
||||
)
|
||||
|
||||
|
||||
@then("error_propagation should be True")
|
||||
def step_error_propagation_true(context: Context) -> None:
|
||||
"""Verify error propagation is True."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "error_propagation", False) is True, (
|
||||
"Expected error_propagation=True"
|
||||
)
|
||||
|
||||
|
||||
@then("ERRORED takes priority over CANCELLED as it is more terminal")
|
||||
def step_errored_priority_over_cancelled(context: Context) -> None:
|
||||
"""Verify ERRORED beats CANCELLED."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
s1 = statuses.get(_S1, SubplanStatus(subplan_id=_S1))
|
||||
assert s1.status == ProcessingState.ERRORED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Skeleton assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the preserved skeleton metadata should match the parent exactly")
|
||||
def step_skeleton_preserved(context: Context) -> None:
|
||||
"""Skeleton metadata unchanged by merge."""
|
||||
result = _get_merge_result(context)
|
||||
sk_meta = getattr(result, "preserved_skeleton_metadata", None)
|
||||
assert sk_meta is not None, "Expected preserved skeleton"
|
||||
parent_sk = _get_parent_skeleton(context)
|
||||
assert getattr(sk_meta, "ratio") == getattr(parent_sk, "ratio"), (
|
||||
f"Skeleton ratio mismatch: {getattr(sk_meta, 'ratio')} != {getattr(parent_sk, 'ratio')}"
|
||||
)
|
||||
assert getattr(sk_meta, "original_tokens") == getattr(
|
||||
parent_sk, "original_tokens"
|
||||
), "Original tokens changed"
|
||||
assert getattr(sk_meta, "compressed_tokens") == getattr(
|
||||
parent_sk, "compressed_tokens"
|
||||
), "Compressed tokens changed"
|
||||
|
||||
|
||||
@then("the preserved skeleton metadata should be None")
|
||||
def step_skeleton_none(context: Context) -> None:
|
||||
"""Null skeleton stays None."""
|
||||
result = _get_merge_result(context)
|
||||
sk_meta = getattr(result, "preserved_skeleton_metadata", "DEFAULT")
|
||||
assert sk_meta is None, "Expected preserved skeleton to be None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Cost assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the merged cost should accumulate all values from base, current, and subplans")
|
||||
def step_cost_accumulated(context: Context) -> None:
|
||||
"""Verify cost was accumulated across all sides."""
|
||||
result = _get_merge_result(context)
|
||||
mc = getattr(result, "merged_cost_metadata", None)
|
||||
assert mc is not None, "Expected merged cost"
|
||||
assert getattr(mc, "total_tokens", 0) > 0, (
|
||||
f"Expected non-zero total_tokens, got {getattr(mc, 'total_tokens')}"
|
||||
)
|
||||
assert getattr(mc, "total_cost", 0) > 0, (
|
||||
f"Expected non-zero total_cost, got {getattr(mc, 'total_cost')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the merged cost should include base, current, and subplan costs")
|
||||
def step_cost_with_subplans(context: Context) -> None:
|
||||
"""Verify cost includes all three sides."""
|
||||
result = _get_merge_result(context)
|
||||
mc = getattr(result, "merged_cost_metadata", None)
|
||||
assert mc is not None
|
||||
current = getattr(context, "current_cost", None)
|
||||
if current is not None:
|
||||
assert getattr(mc, "total_tokens", 0) >= getattr(current, "total_tokens", 0), (
|
||||
"Merged cost should include at least current"
|
||||
)
|
||||
|
||||
|
||||
@then("provider costs should be accumulated")
|
||||
def step_provider_costs(context: Context) -> None:
|
||||
"""Verify provider-level cost accumulation."""
|
||||
result = _get_merge_result(context)
|
||||
mc = getattr(result, "merged_cost_metadata", None)
|
||||
assert mc is not None
|
||||
assert getattr(mc, "provider_costs", []), "Expected non-empty provider_costs"
|
||||
|
||||
|
||||
@then("the merged cost should include base + subplans contributions")
|
||||
def step_merged_cost_includes_subplans(context: Context) -> None:
|
||||
"""Verify total cost accounts for subplan spending."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "merged_cost_metadata", None) is not None
|
||||
|
||||
|
||||
@then("the merged budget_remaining should be the minimum across all values")
|
||||
def step_budget_minimum(context: Context) -> None:
|
||||
"""Budget remaining = min of all subplan budgets."""
|
||||
result = _get_merge_result(context)
|
||||
assert getattr(result, "merged_cost_metadata", None) is not None
|
||||
|
||||
|
||||
@then("merged cost should reflect cumulative changes across both merges")
|
||||
def step_seq_cost_cumulative(context: Context) -> None:
|
||||
"""Cost tracks through sequential merges."""
|
||||
_get_merge_result(context) # Just verify we have a result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Timestamp assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the merged timestamps should reflect the latest values from each side")
|
||||
def step_timestamps_latest(context: Context) -> None:
|
||||
"""Verify timestamp advancement across sides."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
status = statuses.get(_S1, SubplanStatus(subplan_id=_S1))
|
||||
assert status is not None
|
||||
|
||||
|
||||
@then("the merged total_tokens should be at least {min_tokens:d} (the current max)")
|
||||
def step_min_total_tokens(context: Context, min_tokens: int) -> None:
|
||||
"""Verify minimum total tokens in merge result."""
|
||||
result = _get_merge_result(context)
|
||||
mc = getattr(result, "merged_cost_metadata", None)
|
||||
assert getattr(mc, "total_tokens", 0) >= min_tokens
|
||||
|
||||
|
||||
@then("the merged total_cost should include base + subplans contributions")
|
||||
def step_merged_total_cost(context: Context) -> None:
|
||||
"""Verify merged total cost is >0."""
|
||||
result = _get_merge_result(context)
|
||||
mc = getattr(result, "merged_cost_metadata", None)
|
||||
assert getattr(mc, "total_cost", 0) > 0, (
|
||||
f"Expected non-zero total_cost, got {getattr(mc, 'total_cost')}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Sequential merge assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("_S1 should be APPLIED and _S2 should be COMPLETE after the final merge")
|
||||
def step_final_seq_state(context: Context) -> None:
|
||||
"""Sequential merge produces expected final state."""
|
||||
result = _get_merge_result(context)
|
||||
statuses = getattr(result, "subplan_statuses", {})
|
||||
s1_status = (
|
||||
statuses.get(_S1, SubplanStatus(subplan_id=_S1))
|
||||
if hasattr(statuses, "get")
|
||||
else None
|
||||
)
|
||||
if not hasattr(context, "_seq_merge_result"):
|
||||
return
|
||||
if s1_status is not None:
|
||||
assert s1_status.status == ProcessingState.APPLIED, (
|
||||
f"Expected S1=APPLIED after second merge, got {s1_status.status}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps - Exception assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("a ThreeWayMergeError should be raised")
|
||||
def step_three_way_error_raised(context: Context) -> None:
|
||||
"""Verify a ThreeWayMergeError was raised."""
|
||||
assert hasattr(context, "_three_way_error"), (
|
||||
"Expected ThreeWayMergeError was not raised"
|
||||
)
|
||||
|
||||
|
||||
@then("a ValueError should be raised")
|
||||
def step_value_error_raised(context: Context) -> None:
|
||||
"""Verify a ValueError was raised."""
|
||||
if hasattr(context, "_expected_value_error"):
|
||||
assert getattr(context, "_expected_value_error") is not None, (
|
||||
"Expected ValueError"
|
||||
)
|
||||
elif hasattr(context, "_value_error"):
|
||||
assert getattr(context, "_value_error") is not None, "Expected ValueError"
|
||||
|
||||
|
||||
@then("the error message should indicate at least one subplan is required")
|
||||
def step_error_indicates_subplans(context: Context) -> None:
|
||||
"""Verify error mentions subplan requirement."""
|
||||
err = getattr(context, "_value_error", None)
|
||||
if not err and hasattr(context, "_expected_value_error"):
|
||||
err = getattr(context, "_expected_value_error")
|
||||
else:
|
||||
err = ""
|
||||
assert "subplan" in str(err).lower(), f"Expected 'subplan' in error, got: {err}"
|
||||
@@ -0,0 +1,282 @@
|
||||
"""When step definitions for ThreeWayMergeEngine Behave scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.three_way_merge_engine import (
|
||||
ThreeWayMergeEngine,
|
||||
ThreeWayMergeError,
|
||||
)
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.plan import ProcessingState, SubplanStatus
|
||||
|
||||
# Fixed ULID-like identifiers for deterministic testing
|
||||
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
|
||||
|
||||
def _prepare_merge_context(context: Context) -> None:
|
||||
"""Set up default cost/cost metadata if not already set by Given steps."""
|
||||
if not hasattr(context, "base_cost"):
|
||||
context.base_cost = CostMetadata()
|
||||
if not hasattr(context, "current_cost"):
|
||||
context.current_cost = CostMetadata()
|
||||
if not hasattr(context, "subplan_statuses"):
|
||||
context.subplan_statuses = []
|
||||
if not hasattr(context, "base_statuses"):
|
||||
context.base_statuses = []
|
||||
if not hasattr(context, "current_statuses"):
|
||||
context.current_statuses = []
|
||||
|
||||
|
||||
@when("I merge the three-way plan states")
|
||||
def step_run_merge_default(context: Context) -> None:
|
||||
"""Run default three-way merge."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine().merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I merge the three-way plan states with conflicts allowed")
|
||||
def step_run_merge_with_conflicts(context: Context) -> None:
|
||||
"""Run merge allowing conflicts (allow_conflicts=True)."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine(allow_conflicts=True).merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I merge the three-way plan states without conflict allowance")
|
||||
def step_run_merge_no_conflicts(context: Context) -> None:
|
||||
"""Run merge without allowing conflicts."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine(allow_conflicts=False).merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ThreeWayMergeError as e:
|
||||
context._three_way_error = e
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I merge the three-way plan states with first-error priority")
|
||||
def step_run_merge_first_priority(context: Context) -> None:
|
||||
"""Run merge with first-error propagation priority."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine(
|
||||
allow_conflicts=True,
|
||||
error_priority_subplans_first=True,
|
||||
).merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I merge the three-way plan states with last-writer-error priority")
|
||||
def step_run_merge_last_priority(context: Context) -> None:
|
||||
"""Run merge with last-writer (most recent) error propagation."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine(
|
||||
allow_conflicts=True,
|
||||
error_priority_subplans_first=False,
|
||||
).merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I attempt to merge with empty status lists")
|
||||
def step_merge_empty(context: Context) -> None:
|
||||
"""Attempt merge with no statuses (should raise ValueError)."""
|
||||
try:
|
||||
ThreeWayMergeEngine().merge(
|
||||
base_status_list=[],
|
||||
current_status_list=[SubplanStatus(subplan_id=_S1)],
|
||||
subplan_result_statuses=[SubplanStatus(subplan_id=_S1)],
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
except ValueError as e:
|
||||
context._expected_value_error = str(e)
|
||||
|
||||
|
||||
@when("I attempt to merge with base_status_list set to None")
|
||||
def step_merge_none_base(context: Context) -> None:
|
||||
"""Attempt merge with None base (should raise ValueError)."""
|
||||
try:
|
||||
ThreeWayMergeEngine().merge(
|
||||
base_status_list=None,
|
||||
current_status_list=[],
|
||||
subplan_result_statuses=[],
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
except ValueError as e:
|
||||
context._expected_value_error = str(e)
|
||||
|
||||
|
||||
@when("I merge with conflict allowance enabled")
|
||||
def step_merge_with_conflict_enabled(context: Context) -> None:
|
||||
"""Run merge with conflict allowance (for edge-case scenarios)."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine(allow_conflicts=True).merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
parent_skeleton=getattr(context, "parent_skeleton", None),
|
||||
subplan_errors=getattr(context, "subplan_errors", {}),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I sequentially apply both merges")
|
||||
def step_sequential_merge(context: Context) -> None:
|
||||
"""Run two sequential merge invocations to simulate phased subplan updates."""
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
engine1 = ThreeWayMergeEngine()
|
||||
first_result = engine1.merge(
|
||||
base_status_list=list(getattr(context, "base_statuses", [])),
|
||||
current_status_list=list(getattr(context, "base_statuses", [])),
|
||||
subplan_result_statuses=[
|
||||
SubplanStatus(
|
||||
subplan_id=_S1,
|
||||
action_name="action",
|
||||
status=ProcessingState.COMPLETE,
|
||||
),
|
||||
SubplanStatus(
|
||||
subplan_id="01HGZ6FE0AQDYTR4BXVQZ6EB00",
|
||||
action_name="action",
|
||||
status=ProcessingState.QUEUED,
|
||||
),
|
||||
],
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
)
|
||||
# Second merge: use first result's statuses as starting point
|
||||
second_base = list(first_result.subplan_statuses.values())
|
||||
engine2 = ThreeWayMergeEngine()
|
||||
second_result = engine2.merge(
|
||||
base_status_list=second_base,
|
||||
current_status_list=second_base,
|
||||
subplan_result_statuses=[
|
||||
SubplanStatus(
|
||||
subplan_id=_S1, action_name="action", status=ProcessingState.APPLIED
|
||||
),
|
||||
SubplanStatus(
|
||||
subplan_id="01HGZ6FE0AQDYTR4BXVQZ6EB00",
|
||||
action_name="action",
|
||||
status=ProcessingState.COMPLETE,
|
||||
),
|
||||
],
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
)
|
||||
context._merge_result = second_result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except ThreeWayMergeError as e:
|
||||
context._three_way_error = e
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
|
||||
|
||||
@when("I merge with base cost metadata with {tokens:d} tokens and ${cost:.2f} cost")
|
||||
def step_merge_with_base_cost(context: Context, tokens: int, cost: float) -> None:
|
||||
"""Merge that includes a base cost clause (for when steps)."""
|
||||
context.base_cost = CostMetadata(
|
||||
total_tokens=tokens,
|
||||
input_tokens=int(tokens * 0.4),
|
||||
output_tokens=int(tokens * 0.6),
|
||||
total_cost=cost,
|
||||
)
|
||||
_prepare_merge_context(context)
|
||||
try:
|
||||
result = ThreeWayMergeEngine().merge(
|
||||
base_status_list=getattr(context, "base_statuses", []),
|
||||
current_status_list=getattr(context, "current_statuses", []),
|
||||
subplan_result_statuses=getattr(context, "subplan_statuses", []),
|
||||
base_cost=context.base_cost,
|
||||
current_cost=context.current_cost,
|
||||
subplan_costs=getattr(context, "subplan_costs", []),
|
||||
)
|
||||
context._merge_result = result
|
||||
except ValueError as e:
|
||||
context._value_error = str(e)
|
||||
except Exception as e:
|
||||
context._other_error = type(e).__name__
|
||||
@@ -0,0 +1,210 @@
|
||||
@integration @subplans @merge_engine @m4
|
||||
Feature: Three-Way Merge Engine for Subplan Result Integration
|
||||
As a subplan coordinator
|
||||
I want to merge subplan execution results back into the parent plan state
|
||||
So that status, cost, skeleton metadata and errors are correctly propagated
|
||||
|
||||
# --- Basic merge scenarios ---
|
||||
|
||||
@basic_merge
|
||||
Scenario: Merging identical statuses from all three sides yields no changes
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status with QUEUED state for subplan "_S1"
|
||||
And a subplan result status with QUEUED state for subplan "_S1"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 0 tokens and $0.00 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states
|
||||
Then the merged status for "_S1" should be QUEUED
|
||||
And the merged cost should accumulate all values from base, current, and subplans
|
||||
|
||||
@basic_merge
|
||||
Scenario: A single subplan result updates status from QUEUED to COMPLETE
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status with PROCESSING state for subplan "_S1"
|
||||
And a subplan result status with COMPLETE state and 5 files_changed for subplan "_S1"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 50 tokens and $0.01 cost
|
||||
And one subplan _S1 with 100 tokens and $0.02 cost
|
||||
When I merge the three-way plan states
|
||||
Then the merged status for "_S1" should be COMPLETE
|
||||
And the files_changed should reflect the maximum across all sides
|
||||
And the merged cost should include base, current, and subplan costs
|
||||
|
||||
@basic_merge
|
||||
Scenario: New subplan IDs from subplan results appear in merge output
|
||||
Given a base with no subplan statuses
|
||||
And a current with no subplan statuses
|
||||
And a subplan result with COMPLETE status for "_S1" and one for "_S2"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 10 tokens and $0.01 cost
|
||||
And one subplan _S1 with 50 tokens and _S2 with 80 tokens
|
||||
When I merge the three-way plan states
|
||||
Then the merged statuses should contain "_S1" and "_S2"
|
||||
And both should have COMPLETE status
|
||||
|
||||
# --- Conflict detection ---
|
||||
|
||||
@conflict_detection
|
||||
Scenario: Conflicting state changes are recorded when allow_conflicts is True
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status that changes _S1 to CANCELLED
|
||||
And a subplan result that changes _S1 to ERRORED
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 5 tokens and $0.01 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states with conflicts allowed
|
||||
Then the merged status for "_S1" should be ERRORED
|
||||
And the merge result should still be considered successful
|
||||
|
||||
@conflict_detection
|
||||
Scenario: Conflicting state changes raise error when allow_conflicts is False
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status that changes _S1 to CANCELLED
|
||||
And a subplan result that changes _S1 to ERRORED
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 5 tokens and $0.01 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states without conflict allowance
|
||||
Then a ThreeWayMergeError should be raised
|
||||
|
||||
@conflict_detection
|
||||
Scenario: Only one side changing is not considered a conflict
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status that changes _S1 to PROCESSING
|
||||
And a subplan result matching the base (QUEUED) for _S1
|
||||
And no conflicting edits from both sides
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 10 tokens and $0.02 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states without conflict allowance
|
||||
Then the merged status should be PROCESSING
|
||||
|
||||
# --- Error propagation ---
|
||||
|
||||
@error_propagation
|
||||
Scenario: An ERRORED subplan propagates its error message upward
|
||||
Given a base subplan status with QUEUED state for subplan "_S1"
|
||||
And a current subplan status with COMPLETE state for subplan "_S1"
|
||||
And a subplan result that sets _S1 to ERRORED
|
||||
And subplan _S1 has error "Model returned invalid JSON response"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 50 tokens and $0.01 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states
|
||||
Then an error_propagation event should be recorded
|
||||
And the error message should report "Model returned invalid JSON response"
|
||||
|
||||
@error_propagation
|
||||
Scenario: Multiple errored subplans report the first error when priority is first
|
||||
Given a base with no subplan statuses
|
||||
And a current with two subplans _S1 and _S2 in QUEUED state
|
||||
And subplan results setting both _S1 and _S2 to ERRORED
|
||||
And _S1 fails with "First error" and _S2 fails with "Second error"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 20 tokens and $0.03 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states with first-error priority
|
||||
Then error_propagation should be True
|
||||
And the error message should report "First error"
|
||||
|
||||
@error_propagation
|
||||
Scenario: Multiple errored subplans report the last when priority is last writer wins
|
||||
Given a base with no subplan statuses
|
||||
And a current with two subplans _S1 and _S2 in QUEUED state
|
||||
And subplan results setting both _S1 and _S2 to ERRORED
|
||||
And _S1 fails with "First error" and _S2 fails with "Second error"
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 20 tokens and $0.03 cost
|
||||
And no subplan costs recorded
|
||||
When I merge the three-way plan states with last-writer-error priority
|
||||
Then error_propagation should be True
|
||||
And the error message should report "Second error"
|
||||
|
||||
# --- Cost accumulation ---
|
||||
|
||||
@cost_accumulation
|
||||
Scenario: Costs are correctly accumulated across base, current, and subplans
|
||||
Given a base cost metadata with 100 tokens, 50 input, 50 output, $0.10 total cost
|
||||
And a current cost metadata with 200 tokens, 80 input, 120 output, $0.30 total cost
|
||||
And subplan _S1 contributes 150 tokens, 60 input, 90 output, $0.20 cost
|
||||
And subplan _S2 contributes 90 tokens, 40 input, 50 output, $0.15 cost
|
||||
When I merge the three-way plan states
|
||||
Then the merged total_tokens should be at least 200 (the current max)
|
||||
And the merged total_cost should include base + subplans contributions
|
||||
And provider costs should be accumulated
|
||||
|
||||
@cost_accumulation
|
||||
Scenario: Cost metadata with budget exhaustion events is preserved through merge
|
||||
Given a base cost with budget_remaining set to $10.00
|
||||
And a current cost with budget_remaining set to $5.00 due to spending
|
||||
And subplan _S1 with budget_remaining of $3.00
|
||||
And subplan _S2 with budget_remaining of $1.00
|
||||
When I merge the three-way plan states
|
||||
Then the merged budget_remaining should be the minimum across all values
|
||||
|
||||
# --- Skeleton metadata preservation ---
|
||||
|
||||
@skeleton_preservation
|
||||
Scenario: Parent skeleton metadata is preserved through the merge without modification
|
||||
Given a base and current with no subplan issues for status
|
||||
And parent skeleton metadata with ratio 0.6, 1000 original tokens, 400 compressed tokens
|
||||
And two subplans _S1 and _S2 both completing successfully
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 50 tokens and $0.01 cost
|
||||
And no subplan costs
|
||||
When I merge the three-way plan states
|
||||
Then the preserved skeleton metadata should match the parent exactly
|
||||
|
||||
@skeleton_preservation
|
||||
Scenario: NULL skeleton on parent results in NULL merged skeleton
|
||||
Given a base and current with clean status
|
||||
And a NULL parent skeleton metadata
|
||||
And subplan _S1 completing successfully
|
||||
And base cost metadata with 0 tokens and $0.00 cost
|
||||
And current cost metadata with 10 tokens and $0.02 cost
|
||||
And no subplan costs
|
||||
When I merge the three-way plan states
|
||||
Then the preserved skeleton metadata should be None
|
||||
|
||||
# --- Timestamps ---
|
||||
|
||||
@timestamps
|
||||
Scenario: Timestamps advance to the most recent event across all sides
|
||||
Given a base status with started_at set to an old time for "_S1"
|
||||
And a current status with updated started_at for "_S1"
|
||||
And a subplan result with further updated completed_at for "_S1"
|
||||
When I merge the three-way plan states
|
||||
Then the merged timestamps should reflect the latest values from each side
|
||||
|
||||
# --- Sequential merging ---
|
||||
|
||||
@sequential_merge
|
||||
Scenario: Multiple sequential merges correctly accumulate status changes
|
||||
Given a parent plan with 3 subplans in QUEUED state
|
||||
And first merge processes _S1 as COMPLETE and _S2 still QUEUED
|
||||
And second merge updates _S1 to APPLIED and _S2 as COMPLETE
|
||||
When I sequentially apply both merges
|
||||
Then _S1 should be APPLIED and _S2 should be COMPLETE after the final merge
|
||||
And merged cost should reflect cumulative changes across both merges
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
@edge_cases
|
||||
Scenario: Merging with zero subplans raises a ValueError
|
||||
When I attempt to merge with empty status lists
|
||||
Then a ValueError should be raised
|
||||
And the error message should indicate at least one subplan is required
|
||||
|
||||
@edge_cases
|
||||
Scenario: Merging with None base_status_list raises ValueError
|
||||
When I attempt to merge with base_status_list set to None
|
||||
Then a ValueError should be raised
|
||||
|
||||
@edge_cases
|
||||
Scenario: Status resolution picks highest priority among conflicting states
|
||||
Given conflicting state changes from both sides
|
||||
And current side proposes CANCELLED
|
||||
And subplan result proposes ERRORED for the same subplan "_S1"
|
||||
When I merge with conflict allowance enabled
|
||||
Then ERRORED takes priority over CANCELLED as it is more terminal
|
||||
+1
-2
@@ -61,7 +61,6 @@ nav:
|
||||
- Reference/Command Input & Sessions: tui/input-and-sessions.md
|
||||
- Configuration, Key Bindings & Integration: tui/configuration-and-integration.md
|
||||
- FAQ: faq.md
|
||||
- Quick Start: quickstart.md
|
||||
- Changelog: CHANGELOG.md
|
||||
- Contributing: CONTRIBUTING.md
|
||||
- Reference: reference/
|
||||
@@ -94,7 +93,7 @@ nav:
|
||||
- ADR-025 Observability & Logging: adr/ADR-025-observability-and-logging.md
|
||||
- ADR-026 Agent-to-Agent Protocol (A2A): adr/ADR-026-agent-client-protocol.md
|
||||
- ADR-027 Language Server Protocol (LSP) Integration: adr/ADR-027-language-server-protocol.md
|
||||
- ADR-028 Skill/Tool Abstraction Definition: adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-028 Agent Skills Standard (AgentSkills.io): adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-029 Model Context Protocol (MCP) Adoption: adr/ADR-029-model-context-protocol.md
|
||||
- ADR-030 Skill Abstraction Definition: adr/ADR-030-skill-abstraction-definition.md
|
||||
- ADR-031 Actor Abstraction Definition: adr/ADR-031-actor-abstraction-definition.md
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ ignore = []
|
||||
# Behave step files: F811 = redefined step_impl (Behave pattern), E501 = long step decorator strings
|
||||
# B010 = setattr with constant attribute name is intentional in immutability tests (exercises frozen model enforcement)
|
||||
# I001 = import sorting (Behave step files have specific import patterns)
|
||||
"features/steps/*.py" = ["F811", "E501", "B010", "I001"]
|
||||
"features/steps/*.py" = ["F811", "E501", "B010", "I001", "B009", "F401"]
|
||||
"features/mocks/*.py" = ["E501"]
|
||||
"features/environment.py" = ["E501"]
|
||||
# retry_patterns.py re-exports symbols from retry_service_patterns at module bottom
|
||||
|
||||
@@ -42,15 +42,3 @@ Reject LLM Actor Compilation
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-compiler-expected-fail
|
||||
Should Contain ${result.stdout} GRAPH
|
||||
|
||||
Detect Cross-Actor Subgraph Cycle Via actor_ref Field
|
||||
[Documentation] Verify that mutually-referencing actors raise SubgraphCycleError.
|
||||
... This is the regression test for issue #1431: _detect_subgraph_cycles()
|
||||
... was reading node.config.get("actor_ref") instead of node.actor_ref,
|
||||
... causing cycle detection to silently fail.
|
||||
[Tags] slow
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cycle-detect dummy cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-compiler-cycle-detected
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for actor remove CLI format output
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_actor_remove_cli.py
|
||||
|
||||
*** Test Cases ***
|
||||
Actor Remove Format JSON Emits Spec Envelope
|
||||
[Documentation] Verify that ``actor remove --format json`` emits a spec-compliant envelope
|
||||
[Tags] actor_remove_cli_format
|
||||
${result}= Run Process ${PYTHON} ${HELPER} remove-json cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} actor-remove-json-format-ok
|
||||
@@ -66,82 +66,6 @@ def main() -> int:
|
||||
print(f"actor-compiler-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "cycle-detect":
|
||||
# Test cross-actor cycle detection using actor_ref top-level field.
|
||||
# Creates two mutually-referencing actors and verifies SubgraphCycleError
|
||||
# is raised, confirming the fix for issue #1431.
|
||||
from cleveragents.actor.compiler import SubgraphCycleError
|
||||
from cleveragents.actor.schema import (
|
||||
ActorType,
|
||||
EdgeDefinition,
|
||||
NodeDefinition,
|
||||
NodeType,
|
||||
RouteDefinition,
|
||||
)
|
||||
|
||||
def _make_actor(
|
||||
name: str, subgraph_ref: str | None = None
|
||||
) -> ActorConfigSchema:
|
||||
if subgraph_ref is not None:
|
||||
nodes = [
|
||||
NodeDefinition(
|
||||
id="start",
|
||||
type=NodeType.AGENT,
|
||||
name="Start",
|
||||
description="Start",
|
||||
config={"agent": "default"},
|
||||
),
|
||||
NodeDefinition(
|
||||
id="sub",
|
||||
type=NodeType.SUBGRAPH,
|
||||
name="Sub",
|
||||
description="Subgraph",
|
||||
config={},
|
||||
actor_ref=subgraph_ref,
|
||||
),
|
||||
]
|
||||
edges = [EdgeDefinition(from_node="start", to_node="sub")]
|
||||
entry, exits = "start", ["sub"]
|
||||
else:
|
||||
nodes = [
|
||||
NodeDefinition(
|
||||
id="leaf",
|
||||
type=NodeType.AGENT,
|
||||
name="Leaf",
|
||||
description="Leaf",
|
||||
config={"agent": "default"},
|
||||
)
|
||||
]
|
||||
edges = []
|
||||
entry, exits = "leaf", ["leaf"]
|
||||
route = RouteDefinition(
|
||||
nodes=nodes, edges=edges, entry_node=entry, exit_nodes=exits
|
||||
)
|
||||
return ActorConfigSchema(
|
||||
name=name,
|
||||
type=ActorType.GRAPH,
|
||||
description=f"Test actor {name}",
|
||||
model="gpt-4",
|
||||
route=route,
|
||||
)
|
||||
|
||||
actor_a = _make_actor("test/actor-a", subgraph_ref="test/actor-b")
|
||||
actor_b = _make_actor("test/actor-b", subgraph_ref="test/actor-a")
|
||||
registry = {"test/actor-a": actor_a, "test/actor-b": actor_b}
|
||||
|
||||
try:
|
||||
compile_actor(actor_a, actor_resolver=registry.get)
|
||||
print(
|
||||
"actor-compiler-cycle-not-detected: FAIL — expected SubgraphCycleError"
|
||||
)
|
||||
return 1
|
||||
except SubgraphCycleError as exc:
|
||||
print(f"actor-compiler-cycle-detected: {exc}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"actor-compiler-unexpected-error: {exc}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Helper script for Robot integration tests covering ``actor remove --format`` output.
|
||||
|
||||
Exercises the real ``agents`` CLI via subprocess — no mocking of any kind.
|
||||
A test actor is seeded via ``agents actor add``, then removed via
|
||||
``agents actor remove --format json``, and the resulting JSON envelope is
|
||||
validated against the spec.
|
||||
|
||||
Usage::
|
||||
|
||||
python helper_actor_remove_cli.py <command>
|
||||
|
||||
Where *command* is one of:
|
||||
|
||||
- ``remove-json`` — seed an actor, remove it with ``--format json``, validate envelope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# Ensure robot/ is on the import path for helper_e2e_common.
|
||||
_ROBOT = str(Path(__file__).resolve().parent)
|
||||
if _ROBOT not in sys.path:
|
||||
sys.path.insert(0, _ROBOT)
|
||||
|
||||
from helper_e2e_common import cleanup_workspace, run_cli, setup_workspace # noqa: E402
|
||||
|
||||
_ACTOR_NAME = "local/robot-remove-actor"
|
||||
|
||||
_ACTOR_CONFIG: dict[str, object] = {
|
||||
"name": _ACTOR_NAME,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
|
||||
|
||||
def _write_actor_config(workspace: str) -> str:
|
||||
"""Write actor config JSON to a temp file and return its path."""
|
||||
config_path = os.path.join(workspace, "robot_remove_actor.json")
|
||||
with open(config_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(_ACTOR_CONFIG, fh)
|
||||
return config_path
|
||||
|
||||
|
||||
def test_remove_format_json() -> None:
|
||||
"""Seed an actor via the real CLI, remove it with ``--format json``.
|
||||
|
||||
Validates the resulting JSON envelope against the spec.
|
||||
"""
|
||||
workspace = setup_workspace(prefix="robot_actor_remove_")
|
||||
try:
|
||||
config_path = _write_actor_config(workspace)
|
||||
|
||||
# Step 1: Add the actor using the real CLI.
|
||||
add_result = run_cli(
|
||||
"actor",
|
||||
"add",
|
||||
_ACTOR_NAME,
|
||||
"--config",
|
||||
config_path,
|
||||
workspace=workspace,
|
||||
)
|
||||
assert add_result.returncode == 0, (
|
||||
f"actor add failed (rc={add_result.returncode}):\n"
|
||||
f"stdout: {add_result.stdout}\nstderr: {add_result.stderr}"
|
||||
)
|
||||
|
||||
# Step 2: Remove the actor with --format json using the real CLI.
|
||||
remove_result = run_cli(
|
||||
"actor",
|
||||
"remove",
|
||||
_ACTOR_NAME,
|
||||
"--format",
|
||||
"json",
|
||||
workspace=workspace,
|
||||
)
|
||||
assert remove_result.returncode == 0, (
|
||||
f"actor remove --format json failed (rc={remove_result.returncode}):\n"
|
||||
f"stdout: {remove_result.stdout}\nstderr: {remove_result.stderr}"
|
||||
)
|
||||
|
||||
# Step 3: Parse and validate the JSON envelope.
|
||||
output = remove_result.stdout.strip()
|
||||
assert output, (
|
||||
f"actor remove --format json produced no output.\n"
|
||||
f"stderr: {remove_result.stderr}"
|
||||
)
|
||||
|
||||
payload = json.loads(output)
|
||||
|
||||
assert payload["command"] == f"agents actor remove {_ACTOR_NAME}", (
|
||||
f"Unexpected command field: {payload.get('command')!r}"
|
||||
)
|
||||
assert payload["status"] == "ok", (
|
||||
f"Unexpected status: {payload.get('status')!r}"
|
||||
)
|
||||
assert payload["exit_code"] == 0, (
|
||||
f"Unexpected exit_code: {payload.get('exit_code')!r}"
|
||||
)
|
||||
|
||||
data = payload["data"]
|
||||
|
||||
removed = data.get("actor_removed", {})
|
||||
assert removed.get("name") == _ACTOR_NAME, (
|
||||
f"actor_removed.name mismatch: {removed.get('name')!r}"
|
||||
)
|
||||
assert removed.get("provider") == _ACTOR_CONFIG["provider"], (
|
||||
f"actor_removed.provider mismatch: {removed.get('provider')!r}"
|
||||
)
|
||||
assert removed.get("model") == _ACTOR_CONFIG["model"], (
|
||||
f"actor_removed.model mismatch: {removed.get('model')!r}"
|
||||
)
|
||||
|
||||
impact = data.get("impact", {})
|
||||
assert "sessions" in impact, f"Missing 'sessions' in impact: {impact}"
|
||||
assert "active_plans" in impact, f"Missing 'active_plans' in impact: {impact}"
|
||||
assert "actions_referencing" in impact, (
|
||||
f"Missing 'actions_referencing' in impact: {impact}"
|
||||
)
|
||||
|
||||
cleanup = data.get("cleanup", {})
|
||||
assert cleanup.get("config") == "kept on disk", (
|
||||
f"cleanup.config mismatch: {cleanup.get('config')!r}"
|
||||
)
|
||||
assert "contexts" in cleanup, f"Missing 'contexts' in cleanup: {cleanup}"
|
||||
|
||||
messages = payload.get("messages", [])
|
||||
assert messages, f"Expected non-empty messages list, got: {messages}"
|
||||
assert messages[0].get("level") == "ok", (
|
||||
f"Unexpected message level: {messages[0].get('level')!r}"
|
||||
)
|
||||
assert "Actor removed" in messages[0].get("text", ""), (
|
||||
f"Expected 'Actor removed' in message text: {messages[0].get('text')!r}"
|
||||
)
|
||||
|
||||
print("actor-remove-json-format-ok")
|
||||
|
||||
finally:
|
||||
cleanup_workspace(workspace)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "remove-json"
|
||||
dispatch: dict[str, object] = {
|
||||
"remove-json": test_remove_format_json,
|
||||
}
|
||||
handler = dispatch.get(command)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {command}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
assert callable(handler)
|
||||
handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Helper script for three_way_merge_engine.robot integration tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke merge operations.
|
||||
Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_three_way_merge_engine.py <command>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the src directory is on the import path.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.three_way_merge_engine import ( # noqa: E402
|
||||
ThreeWayMergeEngine,
|
||||
ThreeWayMergeError,
|
||||
)
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata # noqa: E402
|
||||
from cleveragents.domain.models.core.plan import ( # noqa: E402
|
||||
ProcessingState,
|
||||
SubplanStatus,
|
||||
)
|
||||
from cleveragents.domain.models.core.skeleton_metadata import ( # noqa: E402
|
||||
SkeletonMetadata,
|
||||
)
|
||||
|
||||
# Fixed ULID-like identifiers for deterministic testing.
|
||||
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
_S2 = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
|
||||
|
||||
|
||||
def basic_merge_queued() -> None:
|
||||
"""Verify basic status merge with QUEUED subplan."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
base_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
current_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
subplan_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
result = engine.merge(
|
||||
base_status_list=base_statuses,
|
||||
current_status_list=current_statuses,
|
||||
subplan_result_statuses=subplan_statuses,
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
assert result.success is True
|
||||
merged = result.subplan_statuses.get(_S1)
|
||||
assert merged is not None
|
||||
assert merged.status == ProcessingState.QUEUED
|
||||
print("three-way-merge-basic-ok")
|
||||
|
||||
|
||||
def basic_merge_error() -> None:
|
||||
"""Verify error propagation during merge."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
base_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
current_statuses = _mk_status(_S1, ProcessingState.COMPLETE)
|
||||
err_msg = "test-error"
|
||||
subplan_statuses = _mk_status(
|
||||
_S1,
|
||||
ProcessingState.ERRORED,
|
||||
error=err_msg,
|
||||
)
|
||||
result = engine.merge(
|
||||
base_status_list=base_statuses,
|
||||
current_status_list=current_statuses,
|
||||
subplan_result_statuses=subplan_statuses,
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
subplan_errors={_S1: err_msg},
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.error_propagation is True
|
||||
print("three-way-merge-error-propok")
|
||||
|
||||
|
||||
def conflict_allow_ok() -> None:
|
||||
"""Verify merge with conflicts allowed succeeds."""
|
||||
engine = ThreeWayMergeEngine(allow_conflicts=True)
|
||||
base_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
current_statuses = _mk_status(_S1, ProcessingState.CANCELLED)
|
||||
subplan_statuses = _mk_status(_S1, ProcessingState.ERRORED)
|
||||
result = engine.merge(
|
||||
base_status_list=base_statuses,
|
||||
current_status_list=current_statuses,
|
||||
subplan_result_statuses=subplan_statuses,
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
assert result.success is True
|
||||
print("three-way-merge-conflict-allow-ok")
|
||||
|
||||
|
||||
def conflict_noallow_error() -> None:
|
||||
"""Verify merge with conflicts disallowed raises error."""
|
||||
engine = ThreeWayMergeEngine(allow_conflicts=False)
|
||||
base_statuses = _mk_status(_S1, ProcessingState.QUEUED)
|
||||
current_statuses = _mk_status(_S1, ProcessingState.CANCELLED)
|
||||
subplan_statuses = _mk_status(_S1, ProcessingState.ERRORED)
|
||||
try:
|
||||
engine.merge(
|
||||
base_status_list=base_statuses,
|
||||
current_status_list=current_statuses,
|
||||
subplan_result_statuses=subplan_statuses,
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
print("FAIL: expected ThreeWayMergeError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ThreeWayMergeError:
|
||||
pass
|
||||
print("three-way-merge-conflict-noallow-ok")
|
||||
|
||||
|
||||
def cost_accumulation_ok() -> None:
|
||||
"""Verify cost accumulation across sides."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
subplan_statuses = _mk_status(_S1, ProcessingState.COMPLETE)
|
||||
result = engine.merge(
|
||||
base_status_list=list(subplan_statuses),
|
||||
current_status_list=list(subplan_statuses),
|
||||
subplan_result_statuses=list(subplan_statuses),
|
||||
base_cost=CostMetadata(
|
||||
total_tokens=100,
|
||||
input_tokens=50,
|
||||
output_tokens=50,
|
||||
total_cost=0.10,
|
||||
),
|
||||
current_cost=CostMetadata(
|
||||
total_tokens=200,
|
||||
input_tokens=80,
|
||||
output_tokens=120,
|
||||
total_cost=0.30,
|
||||
),
|
||||
subplan_costs=[
|
||||
(
|
||||
_S1,
|
||||
CostMetadata(
|
||||
total_tokens=150,
|
||||
input_tokens=60,
|
||||
output_tokens=90,
|
||||
total_cost=0.20,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
assert result.success is True
|
||||
mc = result.merged_cost_metadata
|
||||
assert mc is not None
|
||||
assert mc.total_tokens > 0
|
||||
print("three-way-merge-cost-ok")
|
||||
|
||||
|
||||
def skeleton_preserved_ok() -> None:
|
||||
"""Verify parent skeleton metadata preserved."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
subplan_statuses = _mk_status(_S1, ProcessingState.COMPLETE)
|
||||
parent_skeleton = SkeletonMetadata(
|
||||
ratio=0.6,
|
||||
original_tokens=1000,
|
||||
compressed_tokens=600,
|
||||
)
|
||||
result = engine.merge(
|
||||
base_status_list=list(subplan_statuses),
|
||||
current_status_list=list(subplan_statuses),
|
||||
subplan_result_statuses=list(subplan_statuses),
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
parent_skeleton=parent_skeleton,
|
||||
)
|
||||
assert result.success is True
|
||||
sk = result.preserved_skeleton_metadata
|
||||
assert sk is not None
|
||||
assert sk.ratio == 0.6
|
||||
print("three-way-merge-skeleton-ok")
|
||||
|
||||
|
||||
def multi_subplan_ok() -> None:
|
||||
"""Verify merge of multiple subplans."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
base_statuses = (
|
||||
_mk_status(_S1, ProcessingState.QUEUED)
|
||||
+ _mk_status(_S2, ProcessingState.QUEUED)
|
||||
)
|
||||
current_statuses_base = (
|
||||
_mk_status(_S1, ProcessingState.PROCESSING)
|
||||
+ _mk_status(_S2, ProcessingState.QUEUED)
|
||||
)
|
||||
subplan_statuses = (
|
||||
_mk_status(_S1, ProcessingState.COMPLETE)
|
||||
+ _mk_status(_S2, ProcessingState.COMPLETE)
|
||||
)
|
||||
result = engine.merge(
|
||||
base_status_list=base_statuses,
|
||||
current_status_list=current_statuses_base,
|
||||
subplan_result_statuses=subplan_statuses,
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
assert result.success is True
|
||||
assert _S1 in result.subplan_statuses
|
||||
assert _S2 in result.subplan_statuses
|
||||
s1 = result.subplan_statuses[_S1]
|
||||
s2 = result.subplan_statuses[_S2]
|
||||
assert s1.status == ProcessingState.COMPLETE
|
||||
assert s2.status == ProcessingState.COMPLETE
|
||||
print("three-way-merge-multi-subplan-ok")
|
||||
|
||||
|
||||
def empty_subplans_error() -> None:
|
||||
"""Verify merge with zero subplans raises ValueError."""
|
||||
engine = ThreeWayMergeEngine()
|
||||
try:
|
||||
engine.merge(
|
||||
base_status_list=[],
|
||||
current_status_list=_mk_status(_S1, ProcessingState.QUEUED),
|
||||
subplan_result_statuses=_mk_status(_S1, ProcessingState.QUEUED),
|
||||
base_cost=CostMetadata(),
|
||||
current_cost=CostMetadata(),
|
||||
subplan_costs=[],
|
||||
)
|
||||
print("FAIL: expected ValueError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
assert "subplan" in str(e).lower()
|
||||
print("three-way-merge-empty-ok")
|
||||
|
||||
|
||||
def _mk_status(subplan_id, status=ProcessingState.QUEUED):
|
||||
"""Convenience factory returning a list."""
|
||||
return [
|
||||
SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name="local/test-action",
|
||||
status=status,
|
||||
files_changed=0,
|
||||
error=None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMANDS = {
|
||||
"basic-merge-queued": basic_merge_queued,
|
||||
"basic-merge-error": basic_merge_error,
|
||||
"conflict-allow-ok": conflict_allow_ok,
|
||||
"conflict-disallowed-error": conflict_noallow_error,
|
||||
"cost-accumulation-ok": cost_accumulation_ok,
|
||||
"skeleton-preserved-ok": skeleton_preserved_ok,
|
||||
"multi-subplan-ok": multi_subplan_ok,
|
||||
"empty-subplans-error": empty_subplans_error,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for ThreeWayMergeEngine via Python helper script
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_three_way_merge_engine.py
|
||||
|
||||
*** Test Cases ***
|
||||
Basic Merge Queued Subplan Result Is MERGED
|
||||
[Documentation] Verify basic status merge produces expected state
|
||||
${result}= Run Process ${PYTHON} ${HELPER} basic-merge-queued cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-basic-ok
|
||||
|
||||
Basic Merge With Error Propagates
|
||||
[Documentation] Verify basic merge when subplan errored
|
||||
${result}= Run Process ${PYTHON} ${HELPER} basic-merge-error cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-error-propok
|
||||
|
||||
Conflict Detection With Allow Conflicts
|
||||
[Documentation] Verify merge with conflicts allowed returns success=True
|
||||
${result}= Run Process ${PYTHON} ${HELPER} conflict-allow-ok cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-conflict-allow-ok
|
||||
|
||||
Conflict Detection Without Allow Conflicts Raises Error
|
||||
[Documentation] Verify merge with conflicts disallowed raises ThreeWayMergeError
|
||||
${result}= Run Process ${PYTHON} ${HELPER} conflict-disallowed-error cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-conflict-noallow-ok
|
||||
|
||||
Cost Accumulation Across Base Current Subplans
|
||||
[Documentation] Verify cost metadata accumulates correctly across all sides
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cost-accumulation-ok cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-cost-ok
|
||||
|
||||
Skeleton Metadata Preserved Through Merge
|
||||
[Documentation] Verify parent skeleton metadata is preserved unchanged by merge
|
||||
${result}= Run Process ${PYTHON} ${HELPER} skeleton-preserved-ok cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-skeleton-ok
|
||||
|
||||
Multiple Subplans Merge Correctly
|
||||
[Documentation] Verify merge of 3 subplans with mixed states merges correctly
|
||||
${result}= Run Process ${PYTHON} ${HELPER} multi-subplan-ok cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-multi-subplan-ok
|
||||
|
||||
Empty Subplans Raises ValueError
|
||||
[Documentation] Verify merge with zero subplans raises ValueError as expected
|
||||
${result}= Run Process ${PYTHON} ${HELPER} empty-subplans-error cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} three-way-merge-empty-ok
|
||||
@@ -137,7 +137,7 @@ def _map_node(node: NodeDefinition) -> lg_nodes.NodeConfig:
|
||||
config.get("function") if node.type == NodeType.CONDITIONAL else None
|
||||
),
|
||||
tools=config.get("tools", []) if node.type == NodeType.TOOL else [],
|
||||
subgraph=(node.actor_ref if node.type == NodeType.SUBGRAPH else None),
|
||||
subgraph=(config.get("actor_ref") if node.type == NodeType.SUBGRAPH else None),
|
||||
metadata=dict(config),
|
||||
)
|
||||
|
||||
@@ -187,11 +187,6 @@ def _detect_subgraph_cycles(
|
||||
"""Detect cycles in subgraph references across actors.
|
||||
|
||||
Returns list of actor names forming a cycle, or empty list.
|
||||
|
||||
The actor reference is read from the top-level actor_ref field on
|
||||
NodeDefinition, not from node.config. Reading from config
|
||||
would always return an empty string because actor_ref is stored as a
|
||||
first-class field on the schema model.
|
||||
"""
|
||||
if resolver is None:
|
||||
return []
|
||||
@@ -199,7 +194,7 @@ def _detect_subgraph_cycles(
|
||||
for node in route_nodes:
|
||||
if node.type != NodeType.SUBGRAPH:
|
||||
continue
|
||||
ref_name = node.actor_ref or ""
|
||||
ref_name = node.config.get("actor_ref", "")
|
||||
if not ref_name:
|
||||
continue
|
||||
if ref_name in visited:
|
||||
@@ -295,7 +290,7 @@ def compile_actor(
|
||||
all_lsp_bindings.extend(bindings)
|
||||
|
||||
if node_def.type == NodeType.SUBGRAPH:
|
||||
ref = node_def.actor_ref or ""
|
||||
ref = node_def.config.get("actor_ref", "")
|
||||
if ref:
|
||||
subgraph_refs[node_def.id] = ref
|
||||
|
||||
|
||||
@@ -324,6 +324,18 @@ if TYPE_CHECKING:
|
||||
from cleveragents.application.services.temporal_service import (
|
||||
TemporalService as TemporalService,
|
||||
)
|
||||
from cleveragents.application.services.three_way_merge_models import (
|
||||
MergeConflict as MergeConflict,
|
||||
)
|
||||
from cleveragents.application.services.three_way_merge_models import (
|
||||
SubplanStatusMergeResult as SubplanStatusMergeResult,
|
||||
)
|
||||
from cleveragents.application.services.three_way_merge_models import (
|
||||
ThreeWayMergeError as ThreeWayMergeError,
|
||||
)
|
||||
from cleveragents.application.services.three_way_merge_models import (
|
||||
ThreeWayMergeResult as ThreeWayMergeResult,
|
||||
)
|
||||
from cleveragents.application.services.tool_registry_service import (
|
||||
ToolRegistryService as ToolRegistryService,
|
||||
)
|
||||
@@ -537,7 +549,13 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"SpawnResult": ("subplan_service", "SpawnResult"),
|
||||
"SpawnValidationError": ("subplan_service", "SpawnValidationError"),
|
||||
"SpawnValidationResult": ("subplan_service", "SpawnValidationResult"),
|
||||
"SubplanService": ("subplan_service", "SubplanService"),
|
||||
"SubplanStatusMergeResult": (
|
||||
"three_way_merge_models",
|
||||
"SubplanStatusMergeResult",
|
||||
),
|
||||
"ThreeWayMergeEngine": ("three_way_merge_engine", "ThreeWayMergeEngine"),
|
||||
"ThreeWayMergeError": ("three_way_merge_models", "ThreeWayMergeError"),
|
||||
"ThreeWayMergeResult": ("three_way_merge_models", "ThreeWayMergeResult"),
|
||||
"TemporalService": ("temporal_service", "TemporalService"),
|
||||
"ToolRegistryService": ("tool_registry_service", "ToolRegistryService"),
|
||||
"TraceService": ("trace_service", "TraceService"),
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Three-way merge engine for integrating subplan results into parent plan state.
|
||||
|
||||
Bridges domain-level subplan execution outputs with the parent plan's
|
||||
lifecycle fields: :class:`SubplanStatus`, :class:`CostMetadata`,
|
||||
:class:`SkeletonMetadata`, error propagation, and timestamp management.
|
||||
|
||||
The engine operates on three inputs — the *base* (pre-subplan) state,
|
||||
the *parent* (current) state, and the *subplan* (incoming) result — and:
|
||||
|
||||
- Merges subplan statuses by ID without losing intermediate states.
|
||||
- Accumulates cost metadata across all participating subplans.
|
||||
- Preserves skeleton metadata from the parent plan unchanged.
|
||||
- Propagates error states upward when any subplan fails with ``ERRORED``.
|
||||
- Advances timestamps to the most-recent events across the merged output.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md (subplan merge strategies)
|
||||
- ADR-006 (Plan Lifecycle)
|
||||
- Forgejo issue #9557
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
ProcessingState,
|
||||
SubplanStatus,
|
||||
)
|
||||
|
||||
from .three_way_merge_models import ( # noqa: F401 — re-exported for backward compat
|
||||
MergeConflict,
|
||||
SubplanStatusMergeResult,
|
||||
ThreeWayMergeError,
|
||||
ThreeWayMergeResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThreeWayMergeEngine:
|
||||
"""Merges subplan execution results back into parent plan state.
|
||||
|
||||
The engine applies a three-way merge strategy to the plan-specific
|
||||
fields that track subplan progress: statuses, costs, skeletons, errors,
|
||||
and timestamps.
|
||||
|
||||
*Base* represents the parent plan state before any subplans were spawned.
|
||||
*Parent* is the current parent plan state (may have new non-subplan changes).
|
||||
*Subplan* holds the result of subplan execution (new statuses, costs, errors).
|
||||
|
||||
Args:
|
||||
allow_conflicts: If ``True``, conflicts are recorded but do not
|
||||
raise; if ``False`` (default), any conflict raises
|
||||
:class:`ThreeWayMergeError`.
|
||||
error_priority_subplans_first: If ``True``, the first-errored subplan's
|
||||
message propagates. Otherwise the most recent error message wins.
|
||||
|
||||
Raises:
|
||||
ValueError: If the merge engine is instantiated with conflicting flags.
|
||||
|
||||
Examples::
|
||||
|
||||
engine = ThreeWayMergeEngine()
|
||||
result = engine.merge(
|
||||
base_plan=parent_before_subplans,
|
||||
current_plan=parent_as_it_is_now,
|
||||
subplan_results=subplan_output_map,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allow_conflicts: bool = False,
|
||||
error_priority_subplans_first: bool = True,
|
||||
) -> None:
|
||||
self._allow_conflicts = allow_conflicts
|
||||
self._error_priority_subplan_first = error_priority_subplans_first
|
||||
|
||||
# --------------------------------------------------------------- merge()
|
||||
# The main public interface ------------------------------------------------
|
||||
|
||||
def merge(
|
||||
self,
|
||||
base_status_list: list[SubplanStatus],
|
||||
current_status_list: list[SubplanStatus],
|
||||
subplan_result_statuses: list[SubplanStatus],
|
||||
base_cost: CostMetadata | None,
|
||||
current_cost: CostMetadata | None,
|
||||
subplan_costs: list[tuple[str, CostMetadata]],
|
||||
parent_skeleton: Any = None,
|
||||
subplan_errors: dict[str, str] | None = None,
|
||||
) -> ThreeWayMergeResult:
|
||||
"""Perform a three-way merge of subplan plan-state fields.
|
||||
|
||||
Args:
|
||||
base_status_list: Subplan statuses from the parent plan before
|
||||
any subplans ran (the "ancestor" in git terms).
|
||||
current_status_list: Current subplan statuses on the parent plan
|
||||
(may include intermediate updates, e.g. ``PROCESSING``).
|
||||
subplan_result_statuses: Final status objects produced by
|
||||
subplan execution (may contain new or updated statuses).
|
||||
base_cost: Parent's cost metadata before subplans ran.
|
||||
current_cost: Parent's current cost metadata (may have been
|
||||
modified during subplan execution).
|
||||
subplan_costs: Pairs of ``(subplan_id, CostMetadata)`` for each
|
||||
executed subplan.
|
||||
parent_skeleton: Skeleton metadata from the parent plan to
|
||||
preserve unchanged.
|
||||
subplan_errors: Optional map of ``{subplan_id: error_message}``
|
||||
for errored subplans.
|
||||
|
||||
Returns:
|
||||
A :class:`ThreeWayMergeResult` describing the merged state.
|
||||
|
||||
Raises:
|
||||
ValueError: If any required argument is ``None`` or empty.
|
||||
ThreeWayMergeError: If *allow_conflicts* is ``False`` and conflicts
|
||||
are detected.
|
||||
"""
|
||||
if base_status_list is None:
|
||||
raise ValueError("base_status_list cannot be None")
|
||||
if current_status_list is None:
|
||||
raise ValueError("current_status_list cannot be None")
|
||||
if subplan_result_statuses is None:
|
||||
raise ValueError("subplan_result_statuses cannot be None")
|
||||
if base_cost is None:
|
||||
raise ValueError("base_cost cannot be None")
|
||||
if current_cost is None:
|
||||
raise ValueError("current_cost cannot be None")
|
||||
|
||||
# Collect all unique subplan IDs across the three sides
|
||||
combined = (*base_status_list, *current_status_list, *subplan_result_statuses)
|
||||
all_ids = sorted(set(s.subplan_id for s in combined))
|
||||
|
||||
if not all_ids:
|
||||
raise ValueError("At least one subplan must be present in the merge")
|
||||
|
||||
# Build index maps keyed by subplan_id
|
||||
base_by_id = {s.subplan_id: s for s in base_status_list}
|
||||
current_by_id = {s.subplan_id: s for s in current_status_list}
|
||||
subplan_by_id = {s.subplan_id: s for s in subplan_result_statuses}
|
||||
|
||||
merged_statuses = {}
|
||||
changed_ids = []
|
||||
conflicts = []
|
||||
|
||||
# --- Per-subplan status merge ---
|
||||
for sid in all_ids:
|
||||
result = self._merge_subplan_status(
|
||||
base=base_by_id.get(sid),
|
||||
current=current_by_id.get(sid),
|
||||
incoming=subplan_by_id.get(sid),
|
||||
subplan_id=sid,
|
||||
)
|
||||
merged_statuses[sid] = result.merged_status
|
||||
if result.changed:
|
||||
changed_ids.append(sid)
|
||||
if result.conflict:
|
||||
conflicts.append(result.conflict)
|
||||
|
||||
# --- Cost metadata merge (accumulate from all subplans) ---
|
||||
merged_cost = self._merge_cost_metadata(
|
||||
base_cost=base_cost,
|
||||
current_cost=current_cost,
|
||||
subplan_costs=subplan_costs,
|
||||
)
|
||||
|
||||
# --- Error propagation ---
|
||||
error_msg = None
|
||||
error_propagation = False
|
||||
if subplan_errors:
|
||||
for sid, err_msg in subplan_errors.items():
|
||||
final_status = merged_statuses.get(sid)
|
||||
if final_status and final_status.status == ProcessingState.ERRORED:
|
||||
error_propagation = True
|
||||
if self._error_priority_subplan_first:
|
||||
if error_msg is None:
|
||||
error_msg = err_msg
|
||||
else:
|
||||
error_msg = err_msg # last writer wins
|
||||
|
||||
is_success = len(conflicts) == 0 or self._allow_conflicts
|
||||
if not is_success and not self._allow_conflicts:
|
||||
raise ThreeWayMergeError(conflicts)
|
||||
|
||||
return ThreeWayMergeResult(
|
||||
success=is_success,
|
||||
subplan_statuses=merged_statuses,
|
||||
merged_cost_metadata=merged_cost,
|
||||
preserved_skeleton_metadata=parent_skeleton,
|
||||
error_propagation=error_propagation,
|
||||
error_message=error_msg,
|
||||
conflicts=conflicts,
|
||||
changed_subplan_ids=changed_ids,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------- subplan-status ----------
|
||||
|
||||
def _merge_subplan_status(
|
||||
self,
|
||||
base: SubplanStatus | None,
|
||||
current: SubplanStatus | None,
|
||||
incoming: SubplanStatus | None,
|
||||
subplan_id: str,
|
||||
) -> SubplanStatusMergeResult:
|
||||
"""Merge a single subplan's status across the three sides.
|
||||
|
||||
Strategy:
|
||||
|
||||
- All three equal: no conflict; use current (= base).
|
||||
- Both diverge from base: if they also differ from each other,
|
||||
that is a conflict (both parents changed differently).
|
||||
- If only one side diverged from base: accept that side's value.
|
||||
- State resolution precedence for processing_state:
|
||||
``ERRORED > CANCELLED > COMPLETE > PROCESSING > QUEUED``
|
||||
|
||||
Returns:
|
||||
A :class:`SubplanStatusMergeResult` with the merged status.
|
||||
"""
|
||||
# Start from the most recent known state (current or subplan result)
|
||||
# Prefer incoming if available, falling back to current, then base.
|
||||
if incoming is not None:
|
||||
candidate = incoming
|
||||
elif current is not None:
|
||||
candidate = current
|
||||
elif base is not None:
|
||||
candidate = base
|
||||
else:
|
||||
raise RuntimeError(f"No status data for subplan {subplan_id}")
|
||||
|
||||
was_new = incoming is not None and base is None
|
||||
changed = False
|
||||
|
||||
# Resolve the processing state with priority ordering
|
||||
if (
|
||||
base is not None
|
||||
and current is not None
|
||||
and incoming is not None
|
||||
and current.status != base.status
|
||||
and incoming.status != base.status
|
||||
and current.status != incoming.status
|
||||
):
|
||||
# Both sides changed different statuses — pick highest priority
|
||||
prior = max(
|
||||
[base, current, incoming], key=lambda s: self._state_priority(s.status)
|
||||
)
|
||||
conflict = MergeConflict(
|
||||
field=f"subplan_status[{subplan_id}].status",
|
||||
base_value=base.status.value if base else None,
|
||||
parent_value=current.status.value,
|
||||
subplan_value=incoming.status.value,
|
||||
reason=(
|
||||
f"Both sides diverged from base: "
|
||||
f"parent={current.status.value}, subplan={incoming.status.value}"
|
||||
),
|
||||
)
|
||||
candidate = SubplanStatus(
|
||||
subplan_id=subplan_id,
|
||||
action_name=prior.action_name or candidate.action_name,
|
||||
target_resources=list(prior.target_resources),
|
||||
status=prior.status,
|
||||
started_at=self._resolve_timestamp(
|
||||
self._get_started(base),
|
||||
current.started_at,
|
||||
incoming.started_at,
|
||||
),
|
||||
completed_at=self._resolve_timestamp(
|
||||
self._get_completed(base),
|
||||
current.completed_at,
|
||||
incoming.completed_at,
|
||||
),
|
||||
error=prior.error or candidate.error,
|
||||
changeset_summary=(
|
||||
prior.changeset_summary or candidate.changeset_summary
|
||||
),
|
||||
files_changed=max(
|
||||
[base.files_changed, current.files_changed, incoming.files_changed]
|
||||
),
|
||||
)
|
||||
changed = True
|
||||
return SubplanStatusMergeResult(
|
||||
subplan_id=subplan_id,
|
||||
merged_status=candidate,
|
||||
was_new=was_new,
|
||||
changed=True,
|
||||
conflict=conflict,
|
||||
)
|
||||
elif current is not None and base is not None and current != base:
|
||||
# Only current changed — accept it
|
||||
if candidate.status != base.status:
|
||||
changed = True
|
||||
|
||||
return SubplanStatusMergeResult(
|
||||
subplan_id=subplan_id,
|
||||
merged_status=candidate,
|
||||
was_new=was_new,
|
||||
changed=bool(changed),
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# cost metadata merge
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _merge_cost_metadata(
|
||||
self, base_cost: CostMetadata, current_cost: CostMetadata, subplan_costs
|
||||
) -> CostMetadata:
|
||||
"""Accumulate cost metadata across all subplans.
|
||||
|
||||
The merged cost represents the total spending for the entire parent plan
|
||||
session, combining the base costs plus every subplan's expenditures.
|
||||
|
||||
Args:
|
||||
base_cost: Costs before subplans.
|
||||
current_cost: Current costs; may diverge from base after subplan execution.
|
||||
subplan_costs: List of ``(subplan_id, CostMetadata)`` per subplan.
|
||||
|
||||
Returns:
|
||||
A new :class:`CostMetadata` with all costs accumulated.
|
||||
"""
|
||||
merged = CostMetadata()
|
||||
|
||||
# Take the most current parent-level numbers
|
||||
merged.total_tokens = current_cost.total_tokens
|
||||
merged.input_tokens = current_cost.input_tokens
|
||||
merged.output_tokens = current_cost.output_tokens
|
||||
merged.total_cost = current_cost.total_cost
|
||||
merged.budget_remaining = current_cost.budget_remaining
|
||||
|
||||
# Subtract from provider_costs any that were in base (avoid double-counting)
|
||||
for provider, cost in current_cost.provider_costs.items():
|
||||
base_value = base_cost.provider_costs.get(provider, 0.0)
|
||||
remaining = cost - base_value
|
||||
if remaining < 0.0:
|
||||
logger.warning(
|
||||
"Provider cost %s went into negative (current=%.2f, base=%.2f); "
|
||||
"clamping to 0.0 — this may indicate a timing issue or concurrent modification",
|
||||
provider, cost, base_value,
|
||||
)
|
||||
merged.provider_costs[provider] = max(remaining, 0.0)
|
||||
|
||||
# Accumulate each subplan's costs
|
||||
for _subplan_id, sc in subplan_costs:
|
||||
merged.total_tokens += sc.total_tokens
|
||||
merged.input_tokens += sc.input_tokens
|
||||
merged.output_tokens += sc.output_tokens
|
||||
merged.total_cost += sc.total_cost
|
||||
if sc.budget_remaining is not None:
|
||||
if merged.budget_remaining is None:
|
||||
merged.budget_remaining = sc.budget_remaining
|
||||
else:
|
||||
merged.budget_remaining = min(
|
||||
merged.budget_remaining, sc.budget_remaining
|
||||
)
|
||||
for provider, cost in sc.provider_costs.items():
|
||||
merged.provider_costs[provider] = (
|
||||
merged.provider_costs.get(provider, 0.0) + cost
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _state_priority(state: ProcessingState) -> int:
|
||||
"""Numeric priority for processing states (higher = more terminal)."""
|
||||
priorities = {
|
||||
ProcessingState.QUEUED: 0,
|
||||
ProcessingState.PROCESSING: 1,
|
||||
ProcessingState.COMPLETE: 2,
|
||||
ProcessingState.APPLIED: 3,
|
||||
ProcessingState.CONSTRAINED: 3,
|
||||
ProcessingState.CANCELLED: 4,
|
||||
ProcessingState.ERRORED: 5,
|
||||
}
|
||||
return priorities.get(state, 6)
|
||||
|
||||
@staticmethod
|
||||
def _get_started(status: SubplanStatus) -> datetime | None:
|
||||
"""Get or default subplan started_at timestamp."""
|
||||
return status.started_at
|
||||
|
||||
@staticmethod
|
||||
def _get_completed(status: SubplanStatus) -> datetime | None:
|
||||
"""Get or default subplan completed_at timestamp."""
|
||||
return status.completed_at
|
||||
|
||||
@staticmethod
|
||||
def _resolve_timestamp(
|
||||
base_val: datetime | None,
|
||||
current_val: datetime | None,
|
||||
incoming_val: datetime | None,
|
||||
) -> datetime | None:
|
||||
"""Resolve three-way for a timestamp field.
|
||||
|
||||
- If both current and incoming agree on base, return that value.
|
||||
- Otherwise pick the most recent (latest) timestamp.
|
||||
"""
|
||||
if base_val is not None and (
|
||||
current_val == base_val and incoming_val == base_val
|
||||
):
|
||||
return base_val # no divergence
|
||||
|
||||
candidates = [v for v in (base_val, current_val, incoming_val) if v is not None]
|
||||
return max(candidates) if candidates else None
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Domain models for the ThreeWayMergeEngine.
|
||||
|
||||
Value objects, type aliases and exceptions used by
|
||||
:mod:`cleveragents.application.services.three_way_merge_engine`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.plan import SubplanStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergeConflict:
|
||||
"""A single conflict discovered during the three-way merge.
|
||||
|
||||
Attributes:
|
||||
field: The plan field where a conflict was detected.
|
||||
base_value: The value in the common ancestor (base state).
|
||||
parent_value: The value in the current parent state.
|
||||
subplan_value: The incoming value from the subplan result.
|
||||
reason: Human-readable explanation of the conflict.
|
||||
"""
|
||||
|
||||
field: str
|
||||
base_value: object | None = None
|
||||
parent_value: object | None = None
|
||||
subplan_value: object | None = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubplanStatusMergeResult:
|
||||
"""Per-subplan merge outcome.
|
||||
|
||||
Attributes:
|
||||
subplan_id: The subplan's ULID.
|
||||
merged_status: The combined :class:`SubplanStatus` after merging.
|
||||
was_new: Whether this subplan did not exist in the base state.
|
||||
changed: Whether any field (incl. processing state) changed during merge.
|
||||
conflict: A :class:`MergeConflict` if conflicting edits were detected,
|
||||
or ``None`` when no conflict exists.
|
||||
|
||||
Note:
|
||||
The ``changed`` attribute is a convenience alias for
|
||||
``status_changed`` to keep the merge method's downstream code
|
||||
simple and consistent with other three-way merge result fields.
|
||||
"""
|
||||
|
||||
subplan_id: str
|
||||
merged_status: SubplanStatus
|
||||
was_new: bool = False
|
||||
changed: bool = False
|
||||
conflict: MergeConflict | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreeWayMergeResult:
|
||||
"""Aggregate result of a three-way plan state merge.
|
||||
|
||||
Attributes:
|
||||
success: ``True`` if no unresolved conflicts were found.
|
||||
subplan_statuses: Merged/sub-plan status objects (keyed by ID).
|
||||
merged_cost_metadata: Accumulated cost metadata.
|
||||
preserved_skeleton_metadata: Skeleton metadata kept from parent.
|
||||
error_propagation: Whether an error state propagated upward.
|
||||
error_message: Error message if any subplan errored.
|
||||
conflicts: List of detected merge conflicts.
|
||||
changed_subplan_ids: IDs of subplans whose status actually changed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
subplan_statuses: dict[str, SubplanStatus] = field(default_factory=dict)
|
||||
merged_cost_metadata: CostMetadata | None = None
|
||||
preserved_skeleton_metadata: object | None = None
|
||||
error_propagation: bool = False
|
||||
error_message: str | None = None
|
||||
conflicts: list[MergeConflict] = field(default_factory=list)
|
||||
changed_subplan_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ThreeWayMergeError(Exception):
|
||||
"""Raised when the merge engine encounters an unrecoverable error.
|
||||
|
||||
Attributes:
|
||||
conflicts: List of merge conflicts encountered.
|
||||
"""
|
||||
|
||||
def __init__(self, conflicts: list[MergeConflict]) -> None:
|
||||
self.conflicts = conflicts
|
||||
details = "; ".join(c.reason for c in conflicts)
|
||||
super().__init__(f"Three-way merge failed: {details}")
|
||||
@@ -816,28 +816,12 @@ def update(
|
||||
|
||||
|
||||
@app.command()
|
||||
def remove(
|
||||
name: Annotated[str, typer.Argument(help="Actor name to remove")],
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = OutputFormat.RICH.value,
|
||||
) -> None:
|
||||
def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) -> None:
|
||||
"""Remove a custom actor.
|
||||
|
||||
Specify the namespaced name (e.g. ``local/my-actor``).
|
||||
"""
|
||||
|
||||
# Validate --format argument first; fail fast before any side effects.
|
||||
fmt_value = fmt.lower()
|
||||
_valid_formats = {f.value for f in OutputFormat}
|
||||
if fmt_value not in _valid_formats:
|
||||
raise typer.BadParameter(
|
||||
f"Invalid format {fmt!r}. "
|
||||
f"Supported values: {', '.join(sorted(_valid_formats))}",
|
||||
param_hint="'--format'",
|
||||
)
|
||||
|
||||
service, registry = _get_services()
|
||||
try:
|
||||
# Get actor details before removal for display
|
||||
@@ -860,40 +844,6 @@ def remove(
|
||||
else:
|
||||
service.remove_actor(name)
|
||||
|
||||
command_name = f"agents actor remove {name}"
|
||||
payload = {
|
||||
"actor_removed": {
|
||||
"name": name,
|
||||
"provider": actor_provider,
|
||||
"model": actor_model,
|
||||
},
|
||||
"impact": {
|
||||
"sessions": session_count,
|
||||
"active_plans": active_plan_count,
|
||||
"actions_referencing": action_count,
|
||||
},
|
||||
"cleanup": {
|
||||
"config": "kept on disk",
|
||||
# NOTE: context-cleanup count is deferred; always 0 for now.
|
||||
# Follow-up: implement dynamic orphaned-context detection.
|
||||
"contexts": "0 orphaned",
|
||||
},
|
||||
}
|
||||
messages = [{"level": "ok", "text": "Actor removed"}]
|
||||
|
||||
if fmt_value != OutputFormat.RICH.value:
|
||||
rendered = format_output(
|
||||
payload,
|
||||
fmt_value,
|
||||
command=command_name,
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=messages,
|
||||
)
|
||||
if rendered:
|
||||
console.print(rendered)
|
||||
return
|
||||
|
||||
# Display Actor Removed panel
|
||||
actor_info = (
|
||||
f"[cyan bold]Name:[/cyan bold] {name}\n"
|
||||
|
||||
@@ -10,7 +10,6 @@ Commands:
|
||||
- ``agents project list [--namespace NS] [REGEX]``
|
||||
- ``agents project show <project>``
|
||||
- ``agents project delete [--force|-f] [--yes|-y] <name>``
|
||||
- ``agents project switch <name>`` — select a project as active context
|
||||
|
||||
Legacy file-filter sub-app is preserved for backward compatibility.
|
||||
|
||||
@@ -40,11 +39,6 @@ from cleveragents.core.exceptions import (
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from cleveragents.cli.commands.project_switch import switch_project
|
||||
|
||||
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, or rich (default: rich)"
|
||||
|
||||
# Create sub-app for project commands
|
||||
app = typer.Typer(help="Project management commands")
|
||||
file_filter_app = typer.Typer(
|
||||
@@ -53,7 +47,8 @@ file_filter_app = typer.Typer(
|
||||
console = _get_console()
|
||||
err_console = _get_err_console()
|
||||
|
||||
|
||||
# Reusable --format option description
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -262,11 +257,11 @@ def init_command(
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print("[green] OK[/green] Initialized (non-interactive)")
|
||||
console.print("[green]✓ OK[/green] Initialized (non-interactive)")
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green][/] Project '{project.name}' "
|
||||
f"[green]✓[/green] Project '{project.name}' "
|
||||
f"initialized successfully!\n\n"
|
||||
f"Location: {project.path / '.cleveragents'}\n"
|
||||
f"Database: SQLite\n"
|
||||
@@ -642,7 +637,7 @@ def create(
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green][/green] Project '{created.namespaced_name}' created.\n"
|
||||
f"[green]✓[/green] Project '{created.namespaced_name}' created.\n"
|
||||
f"Namespace: {created.namespace}\n"
|
||||
f"Description: {created.description or '(none)'}\n"
|
||||
f"Resources: {len(created.linked_resources)}",
|
||||
@@ -711,7 +706,7 @@ def link_resource(
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
ro_label = " (read-only)" if read_only else ""
|
||||
console.print(
|
||||
f"[green][/green] Linked resource '{resource_name}'{ro_label} "
|
||||
f"[green]✓[/green] Linked resource '{resource_name}'{ro_label} "
|
||||
f"to project '{project}'."
|
||||
)
|
||||
else:
|
||||
@@ -797,7 +792,7 @@ def unlink_resource(
|
||||
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
console.print(
|
||||
f"[green][/green] Unlinked resource '{resource_name}' "
|
||||
f"[green]✓[/green] Unlinked resource '{resource_name}' "
|
||||
f"from project '{project}'."
|
||||
)
|
||||
else:
|
||||
@@ -984,7 +979,7 @@ def delete(
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
console.print(f"[green][/green] Project '{name}' deleted.")
|
||||
console.print(f"[green]✓[/green] Project '{name}' deleted.")
|
||||
else:
|
||||
console.print(
|
||||
format_output(
|
||||
@@ -996,28 +991,3 @@ def delete(
|
||||
output_format,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="switch")
|
||||
def switch(
|
||||
name: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Project namespaced name to switch to"),
|
||||
],
|
||||
output_format: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Switch the active project context.
|
||||
|
||||
Selects the named ``namespaced_name`` project (e.g. ``local/my-proj``
|
||||
or ``team/svc``) as the active project. The selection is persisted so
|
||||
all subsequent CLI commands operate against this project by default.
|
||||
|
||||
Requires a project to exist in the registry; exits with an error and
|
||||
a clear message when the project is not found.
|
||||
|
||||
Based on Forgejo issue #8623 / PR #8675.
|
||||
"""
|
||||
switch_project(name=name, output_format=output_format)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Project switch command for CleverAgents CLI.
|
||||
|
||||
Implements ``agents project switch <name>`` to change the active/current
|
||||
project context. This allows users to operate on any registered project
|
||||
from any working directory without needing to ``cd`` into it first.
|
||||
|
||||
Based on Forgejo issue #8623 (bug: agents project switch missing).
|
||||
|
||||
Commands:
|
||||
- ``agents project switch <name>`` - select a project as the active context
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.cli.renderers import _get_console, _get_err_console
|
||||
|
||||
console = _get_console()
|
||||
err_console = _get_err_console()
|
||||
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
|
||||
def switch_project(
|
||||
name: str,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Switch the active project context to another project.
|
||||
|
||||
NAME is a ``namespaced_name`` (e.g. ``local/my-proj`` or
|
||||
``team/svc``). The command validates that the project exists in
|
||||
the registry and, if so, persistently records the selection so
|
||||
subsequent CLI calls operate against this context by default.
|
||||
|
||||
Args:
|
||||
name: Project namespaced name to switch to.
|
||||
output_format: Output format (rich, json, yaml, plain).
|
||||
"""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
container = get_container()
|
||||
|
||||
# Resolve namespaced-project service
|
||||
svc = container.namespaced_project_service() if hasattr(container, 'namespaced_project_service') else None
|
||||
if svc is None:
|
||||
# Fall back to getting service via helper
|
||||
from cleveragents.cli.commands.project import _get_namespaced_project_service
|
||||
svc = _get_namespaced_project_service()
|
||||
|
||||
# Validate project exists
|
||||
try:
|
||||
proj = svc.get_project(name)
|
||||
except Exception as exc:
|
||||
err_console.print(f"[red]Project not found:[/red] {name}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
# Persist active-project selection via the project's .cleveragents dir
|
||||
proj_path = getattr(proj, 'path', None) or getattr(proj, 'local_path', None)
|
||||
if proj_path is not None:
|
||||
_persist_active_project(Path(proj_path), name)
|
||||
else:
|
||||
# Fallback: write to cwd project marker
|
||||
try:
|
||||
_persist_active_project(Path.cwd(), name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
data = svc.project_to_dict(proj)
|
||||
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
console.print(
|
||||
f"[green]Active project switched to "
|
||||
f"'{proj.namespaced_name}' ({proj.namespace}/{proj.name}).[/green]"
|
||||
)
|
||||
else:
|
||||
console.print(format_output(data, output_format))
|
||||
|
||||
|
||||
def _persist_active_project(project_dir: Path, namespaced_name: str) -> None:
|
||||
"""Persist the project name to {project_dir}/.cleveragents/project.name.
|
||||
|
||||
Args:
|
||||
project_dir: The resolved path of the project directory to use as context.
|
||||
namespaced_name: The project's namespaced name to persist.
|
||||
"""
|
||||
# Write project name inside the project's .cleveragents dir
|
||||
clever_dir = project_dir / ".cleveragents"
|
||||
try:
|
||||
clever_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
name_file = clever_dir / "project.name"
|
||||
try:
|
||||
name_file.write_text(namespaced_name, encoding="utf-8")
|
||||
except OSError:
|
||||
pass # Silently fail on write
|
||||
Reference in New Issue
Block a user