Compare commits

...

5 Commits

Author SHA1 Message Date
HAL9000 c87a706bf1 fix(cli): resolve typecheck failures and add missing infrastructure methods
CI / lint (pull_request) Failing after 1m14s
CI / quality (pull_request) Successful in 1m41s
CI / typecheck (pull_request) Successful in 1m46s
CI / security (pull_request) Successful in 2m25s
CI / coverage (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 30s
CI / helm (pull_request) Successful in 33s
CI / build (pull_request) Successful in 49s
CI / e2e_tests (pull_request) Successful in 4m5s
CI / integration_tests (pull_request) Successful in 7m46s
CI / unit_tests (pull_request) Failing after 9m50s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 6s
Add GitWorktreeSandbox.cleanup_stale() and diff_against_head() class
methods, create strategy_actor module with resolve_strategy_actor(),
add PlanApplyService.correction_diff() method, and fix _get_apply_service()
and _get_plan_executor() to use correct API signatures.

Add Behave BDD unit tests and Robot Framework integration tests for all
new functionality.

ISSUES CLOSED: #8628
2026-05-05 05:18:02 +00:00
HAL9000 0c9a452349 fix(cli): revert plan start and plan show aliases that contradict v3 spec
CI / lint (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 55s
CI / typecheck (pull_request) Failing after 1m28s
CI / helm (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 1m43s
CI / security (pull_request) Successful in 1m48s
CI / coverage (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 26s
CI / e2e_tests (pull_request) Failing after 4m30s
CI / unit_tests (pull_request) Failing after 4m52s
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Failing after 5m10s
CI / status-check (pull_request) Failing after 3s
Revert the 'agents plan start' and 'agents plan show' command aliases
because the v3 specification mandates 'agents plan use' and 'agents plan
status'. The spec explicitly states that the 'use' verb is the command
that transitions a plan from the Action phase into Strategize, and there
are 86 occurrences of 'plan use' in docs/specification.md with zero for
'plan start'.

The aliases diverged from the authoritative specification rather than
aligning with it, creating competing API surfaces.

ISSUES CLOSED: #8628
2026-04-30 05:32:34 +00:00
HAL9000 d3a6f57daa fix(cli): add agents plan start alias or update spec to reflect v3 plan use/execute commands
CI / lint (pull_request) Failing after 34s
CI / quality (pull_request) Successful in 48s
CI / security (pull_request) Successful in 57s
CI / typecheck (pull_request) Successful in 1m4s
CI / coverage (pull_request) Has been skipped
CI / build (pull_request) Successful in 24s
CI / push-validation (pull_request) Successful in 17s
CI / helm (pull_request) Successful in 47s
CI / e2e_tests (pull_request) Successful in 4m48s
CI / unit_tests (pull_request) Failing after 16m2s
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Failing after 22m56s
CI / status-check (pull_request) Has been cancelled
- Added 'agents plan start' as an alias for 'agents plan use' to match v3 spec
- Added 'agents plan show' as an alias for 'agents plan status' to match v3 spec
- Both commands delegate to their canonical counterparts with full feature parity
- Updated module docstring to document the new aliases
- Added BDD tests for both new commands with comprehensive scenarios
- Updated CHANGELOG.md with the new feature entry
2026-04-13 21:54:30 +00:00
clever-agent 5438540803 build: Removed benchmark stage from CI, moving this to its own workflow
CI / lint (push) Successful in 22s
CI / typecheck (push) Successful in 49s
CI / quality (push) Successful in 37s
CI / security (push) Successful in 57s
CI / build (push) Successful in 26s
CI / push-validation (push) Successful in 16s
CI / helm (push) Successful in 39s
CI / e2e_tests (push) Successful in 3m7s
CI / integration_tests (push) Successful in 6m26s
CI / unit_tests (push) Successful in 7m46s
CI / docker (push) Successful in 20s
CI / coverage (push) Successful in 14m50s
CI / status-check (push) Successful in 1s
CI / typecheck (pull_request) Failing after 55s
CI / quality (pull_request) Successful in 48s
CI / security (pull_request) Successful in 53s
CI / build (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 24s
CI / push-validation (pull_request) Successful in 27s
CI / lint (pull_request) Failing after 11m48s
CI / unit_tests (pull_request) Failing after 12m26s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Failing after 17m45s
CI / integration_tests (pull_request) Failing after 19m36s
CI / status-check (pull_request) Has been cancelled
2026-04-13 17:16:41 -04:00
clever-agent c5afe1f3a7 Build: Made the prompts coming from the product builder to the async subagent more clear to avoid issues in the future
CI / benchmark-publish (push) Waiting to run
CI / lint (push) Successful in 29s
CI / security (push) Successful in 55s
CI / quality (push) Successful in 34s
CI / typecheck (push) Successful in 1m26s
CI / benchmark-regression (push) Waiting to run
CI / build (push) Successful in 39s
CI / helm (push) Successful in 38s
CI / push-validation (push) Successful in 26s
CI / e2e_tests (push) Successful in 3m16s
CI / integration_tests (push) Successful in 6m52s
CI / unit_tests (push) Successful in 8m17s
CI / docker (push) Successful in 1m34s
CI / coverage (push) Successful in 13m52s
CI / status-check (push) Successful in 1s
2026-04-13 16:14:24 -04:00
15 changed files with 2169 additions and 359 deletions
-114
View File
@@ -406,120 +406,6 @@ jobs:
build/htmlcov/
retention-days: 30
benchmark-regression:
if: forgejo.event_name == 'pull_request'
runs-on: docker-benchmark
container:
image: python:3.13-slim
needs: [lint, typecheck, security, quality]
steps:
- name: Install system dependencies (nodejs for checkout, git for merge tests)
run: |
apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/*
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute base commit
id: hash
run: |
git fetch origin "${{ forgejo.base_ref }}" --depth=200
BASE_SHA=$(git merge-base HEAD "origin/${{ forgejo.base_ref }}")
echo "ASV_BASE_SHA=${BASE_SHA}" >> $FORGEJO_OUTPUT
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install asv virtualenv uv==${{ env.UV_VERSION }} nox
- name: Restore prior ASV benchmarks
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
run: |
python -m pip install awscli
mkdir -p build/asv/results
aws s3 sync "s3://${ASV_S3_BUCKET}/asv/results" build/asv/results --delete || true
- name: Run asv continuous via nox
env:
ASV_BASE_SHA: ${{ steps.hash.outputs.ASV_BASE_SHA }}
run: |
nox -s benchmark_regression
- name: Archive the results
run: |
tar cf /tmp/asv-results.tar build/asv/results build/asv/html
- name: Upload benchmark artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: asv-results-pr
path: /tmp/asv-results.tar
rentention-days: 30
benchmark-publish:
if: forgejo.event_name == 'push' && ( forgejo.ref == 'refs/heads/master' || forgejo.ref == 'refs/heads/develop' )
runs-on: docker-benchmark
container: python:3.13-slim
steps:
- name: Install system dependencies (nodejs for checkout, git for merge tests)
run: |
apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/*
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install asv virtualenv uv==${{ env.UV_VERSION }} nox
- name: Restore prior ASV benchmarks
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
run: |
python -m pip install awscli
mkdir -p build/asv/results
aws s3 sync "s3://${ASV_S3_BUCKET}/asv/results" build/asv/results --delete || true
- name: Run asv via nox
run: |
nox -s benchmark
- name: Upload updated benchmarks and website to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
run: |
python -m pip install awscli
aws s3 sync build/asv/results "s3://${ASV_S3_BUCKET}/asv/results" --delete
aws s3 sync build/asv/html "s3://${ASV_S3_BUCKET}/asv/html" --delete
- name: Archive the results
run: |
tar cf /tmp/asv-results.tar build/asv/results build/asv/html
- name: Upload benchmark artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: asv-results-pr
path: /tmp/asv-results.tar
rentention-days: 30
build:
runs-on: docker
container:
+5 -1
View File
@@ -165,14 +165,18 @@ Invoke `product-verifier` to get a baseline assessment of how complete the produ
### Step 6: Launch All Supervisors
Launch each supervisor via the async-agent-manager. Each supervisor's prompt must include:
Launch all supervisor in a single batch and in parallel via the `async-agent-manager` subagent. Each supervisor's prompt must include:
- Repository owner/name, credentials, and git identity
- Worker count (for pool supervisors)
- The customized briefing prepared in Step 4
- the name of the supervisor to be launched asynchronously (you can use the `agent-type-info` subagent if you need to get a list of all supervisots)
- the tag the supervisor should use to identify itself, which always starts with "AUTO-" (you can use `agent-prefix-info` subagent to get this information)
The PR review pool supervisor is special: it receives the **reviewer credentials** (`FORGEJO_REVIEWER_PAT`, `FORGEJO_REVIEWER_USERNAME`, `FORGEJO_REVIEWER_PASSWORD`) instead of the primary bot credentials.
**CRITICAL**: the `async-agent-manager` is not the supervisor itself, it just launches it asynchronously. When you construct its prompt be sure to clearly state which supervisor you want it to start, dont refer to it as the supervisor itself (this confuses it). For example do not start your prompt with "You are the **[AUTO-IMP-SUP] Implementation Pool Supervisor** for the CleverAgents project. Your session tag is `[AUTO-IMP-SUP]`." Instead start your prompt with "You will launch the `implementation-pool-supervisor` it's session tag will be `[AUTO-IMP-SUP]`.
### Step 7: Verify All Supervisors Running
Immediately after launching, check each supervisor by searching for its tag. Every supervisor must exist and be in a busy state. If any failed to launch, retry them.
+245 -167
View File
@@ -5,8 +5,180 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- **CI coverage job now waits for unit_tests** (#10714): Added `unit_tests` to the
`needs` list of the `coverage` job in `ci.yml`. Previously the coverage job ran
in parallel with unit tests, which could produce misleading pass results when
tests were still in-flight or had already failed. Coverage now only starts after
unit tests succeed, eliminating redundant parallel test execution and ensuring
coverage results are always meaningful.
- **Diagnostics spec examples expanded to all 9 providers** (#5320): Updated the
`agents diagnostics` command examples in the specification to show all 9 supported
providers (OpenAI, Anthropic, Google, Gemini, Azure, OpenRouter, Cohere, Groq,
Together), matching the implementation from PR #3469. Rich, plain, JSON, and YAML
example outputs now reflect comprehensive provider coverage with accurate warning
counts and per-provider recommendations.
### Added
- **TDD: MCPToolAdapter.infer_resource_slots() TypeError with null properties** (#10470):
Added a TDD issue-capture Behave scenario that reproduces the bug where
`MCPToolAdapter.infer_resource_slots()` raises `TypeError` when the input schema
contains `{"properties": None}`. The test is tagged `@tdd_expected_fail` and will
pass (by inversion) until the underlying bug is fixed.
- **Architecture Pool Supervisor Milestone Assignment** (#7521): Added a "PR Workflow
for Major Changes" section to the `architecture-pool-supervisor` agent definition
documenting the milestone assignment step for spec PRs. The agent now has
`forgejo_update_pull_request` permission to assign PRs to the current active
milestone after creation, improving traceability of specification changes within
project milestone planning. Includes BDD test coverage for the new workflow
documentation and permission configuration.
- **Git Worktree TOCTOU Race Condition** (#7507): Fixed a Time-Of-Check-To-Time-Of-Use
(TOCTOU) race condition in `git_worktree.py` that could cause `git worktree add`
operations to fail under concurrent execution. The fix replaces the unsafe
`mkdtemp()` + `rmdir()` pattern with a parent-directory approach that maintains
the OS-level uniqueness guarantee throughout the entire operation. The parent
temporary directory is now persisted and properly cleaned up on both success and
failure paths. Comprehensive BDD test coverage validates the fix under concurrent
execution and confirms proper cleanup behavior.
### Fixed
- **Built-in actors v3 YAML format** (#10883): Fixed `agents actor run` failing for
built-in actors (e.g., `openai/gpt-4`, `anthropic/claude-3-opus`) due to missing
v3 `type` field in stored configuration. `ActorRegistry.ensure_built_in_actors()`
now generates and persists v3 YAML text with `type: llm` and `description` fields,
ensuring built-in actors work identically to custom actors. The
`_generate_builtin_actor_yaml()` helper creates spec-compliant YAML that passes
`ReactiveConfigParser._is_v3_format()` validation. Includes BDD scenarios and unit
tests covering YAML generation, schema validation, and multiple provider handling.
- **Atomic `server_connect` config writes** (#993): Fixed `server_connect` in
`cli/commands/server.py` to write all three config values (`server.url`,
`server.namespace`, `server.tls-verify`) atomically. A snapshot of the config
file is taken before any writes; if any `set_value()` call fails, the snapshot is
restored and compensating `CONFIG_CHANGED` events are emitted for already-applied
keys so the audit trail reflects the rollback. Added `emit_config_changed()` helper
to `ConfigService` for decoupled event emission in rollback flows. Added
`close()` method to `ReactiveEventBus` for proper resource cleanup in tests.
Resolved merge conflict in `config_service.py` integrating the PR's
`emit_config_changed()` helper with master's scoped config infrastructure.
Removed `# type: ignore[assignment]` by introducing a typed `_AutoDiscover`
sentinel class. BDD regression coverage in
`features/tdd_server_connect_atomic_writes.feature`.
- **Atomic `load_from_metadata` for Autonomy Guardrails** (#7504): Fixed
`AutonomyGuardrailService.load_from_metadata()` to validate both
`AutonomyGuardrails` and `GuardrailAuditTrail` models before writing either
to state, ensuring atomic updates. Previously, a validation failure on the
audit trail after guardrails were already written would leave the system in
an inconsistent state with partial updates. The method now uses a two-phase
validate-then-write approach: all model validation occurs in Phase 1, and
state mutations only happen in Phase 2 after all validations succeed.
- **ReactiveConfigParser route synthesis for v3 actors** (#10807): Fixed
`agents actor run` silently returning empty output for v3 `type:llm` actors.
`_build_from_v3()` and `_build()` now synthesise a default single-node
graph route when agents are created without explicit routes, ensuring
`run_single_shot()` can invoke the LLM via `GraphExecutor`. The nested
`actors:` map format also translates the v3 `actor: "provider/model"` key
into separate `provider` and `model` keys so the correct LLM provider is
instantiated.
- **ActorRegistry.add() spec-compliant YAML support** (#4466): The registry now
accepts actor YAML using the spec's `actors:` map format with nested `config:`
blocks, in addition to the legacy top-level `provider`/`model` format. The
`unsafe` flag and graph descriptor from nested config are now correctly
preserved during registration. Multi-actor YAML (>1 entry in `actors:`/`agents:`
map) is now rejected by `add()` with a `ValidationError`. Nested
`config.options` are now correctly preserved. The `unsafe` coercion now uses
strict `is True or == 1` instead of `bool()` to prevent truthy non-boolean
YAML values (e.g. `unsafe: "no"`) from being treated as unsafe.
- **UKO Runtime Layer 2 (Paradigm) Indexing** (#9351): Added missing `rdf:type
uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
Python class definitions are now correctly classified at layer 2 (paradigm/OO)
in addition to layer 3 (technology). Added the corresponding Behave scenario
`Indexing a Python file populates layer 2 (paradigm)` to
`features/uko_runtime.feature`, completing four-layer guarantee verification
for the UKO runtime.
- **Actor CLI v3 YAML Schema Support** (#6283): Fixed three components to add
full v3 `ActorConfigSchema` support to the actor CLI registration and
execution paths. `ActorConfiguration.from_blob()` now detects v3 format
(top-level `type` key of `llm`/`graph`/`tool`) and correctly extracts
provider, model, and graph descriptors — including `type: tool` actors
without a `model` field. `ActorRegistry.add()` validates against the full
Pydantic v2 schema, persists `skills`/`lsp`/`description` in the config
blob, and compiles graph actors with proper metadata.
`ReactiveConfigParser._build_from_v3()` now uses correct `source`/`target`
edge keys (fixing `KeyError` in `to_graph_config()`), handles `config: null`
nodes without crashing, propagates `context_view`/`memory`/`context`/
`env_vars`/`response_format`/`lsp_capabilities`/`lsp_context_enrichment`
into agent configs, and validates `entry_node` against the nodes map.
Exception handling narrowed from broad `except Exception` to specific
`NotFoundError` and `ActorCompilationError`. v3 registration logic
extracted to `v3_registry.py` to keep `registry.py` under the 500-line
limit. 19 BDD scenarios cover all v3 paths including tool actors,
update mode, LSP dict bindings, and field propagation.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
This makes the guard firing visible in standard Behave console output and CI log
snippets where the structured logging sink may not be displayed. BDD infrastructure
coverage added: a new scenario in `tdd_expected_fail_infrastructure.feature`
asserts that the warning is emitted to stderr when a non-AssertionError exception
is encountered in an `@tdd_expected_fail` scenario, and a second scenario asserts
the warning is NOT emitted when the exception is an `AssertionError`. The
`CONTRIBUTING.md` now documents that `@tdd_expected_fail` step definitions must
signal expected failures via `AssertionError`.
- **Parallel Behave Runner Log Noise Reduction** (#8351): The parallel behave
runner now suppresses captured stdout/stderr for passing worker chunks and
only replays diagnostics for failed, errored, or crashed chunks. This makes
failure output significantly easier to spot in CI and local runs. A worker
crash (unhandled exception) is detected via an all-zero summary and the
captured traceback is always surfaced.
- **Bug Hunt Pool Supervisor Non-Blocking Tracking**: Updated `bug-hunt-pool-supervisor` to make the automation tracking step non-blocking. The `automation-tracking-manager` call in step 5 is now best-effort — if it does not complete within a reasonable time or fails, the supervisor skips it and continues to the next cycle. Added explicit rule 9 clarifying that tracking must never block the main loop. Core functionality (module scanning and worker dispatch) takes priority over status reporting.
- **Name Validator Server-Qualified Format** (#9074): Updated actor, skill, and tool name
validators to accept the spec-required `[[server:]namespace/]name` format. Previously,
server-qualified names like `dev:freemo/custom-analysis` were incorrectly rejected.
Added BDD scenarios for server-qualified name acceptance and rejection. All three
validators (`ActorConfigSchema.validate_name`, `NAMESPACED_NAME_RE`,
`_TOOL_NAME_PATTERN`) now correctly support optional server prefixes while maintaining
backward compatibility with existing `namespace/name` names.
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
automation profile name is not a known built-in profile, instead of silently
falling back to `"manual"`. Users who configured custom automation profiles
(e.g. `"semi-auto"`, `"acme/strict"`) will now receive an actionable error
message listing available built-in profiles. The resolved profile name is also
logged at debug level for observability.
### Added
- Wired `StrategyActor` into the real plan execution path: `_get_plan_executor`
in `plan.py` now resolves the strategy actor via `resolve_strategy_actor()`
(reading the `actor.default.strategy` config key) instead of always
constructing `LLMStrategizeActor`. `run_strategize` in `PlanExecutor` now
passes `resources` (derived from `plan.project_links`) and `project_context`
to the actor so the LLM prompt receives full project context. Strategy
decisions are serialised as JSON in `plan.error_details["strategy_decisions_json"]`
so `_build_decisions` can reconstruct the full hierarchy (dependency ordering,
parent/child structure) during Execute instead of rebuilding from
`definition_of_done`. `StrategizeStubActor.execute` accepts `**kwargs` for
forward-compatibility. Added BDD coverage for the stored-JSON path,
corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios.
(#828)
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
@tdd_issue_<N>` tag system. Scenarios whose referenced bugs were already fixed
@@ -46,10 +218,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`new-issue-creator`, and `issue-state-updater` now delegate all label operations to this
subagent.
- **PRIssue Label Synchronization**: PRs now inherit `Priority/`, `MoSCoW/`, `Points/`,
- **PR-Issue Label Synchronization**: PRs now inherit `Priority/`, `MoSCoW/`, `Points/`,
and `State/` labels from their associated issues at creation time
(`pr-api-creator`). The `backlog-groomer` adds a continuous Pass 19 for ongoing
PRissue label synchronization. The `issue-state-updater` syncs PR state labels whenever
PR-issue label synchronization. The `issue-state-updater` syncs PR state labels whenever
issue states change.
- **Automation Tracking Announcements**: Extended `automation-tracking-manager` with
@@ -63,14 +235,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
the `*-pool-supervisor` naming pattern. New agents added: `pr-editor` (safe PR editing
with description preservation), `pr-manager` (unified PR interface), and
`pr-merge-pool-supervisor` (automated PR merging supervisor). Renamed:
`pr-api-creator``pr-creator`, `pr-checker``pr-ci-test-fixer`,
`pr-status-checker``pr-status-analyzer`, `pr-self-reviewer``pr-reviewer`,
`pr-fix-orchestrator``pr-fix-pool-supervisor`.
`pr-api-creator` to `pr-creator`, `pr-checker` to `pr-ci-test-fixer`,
`pr-status-checker` to `pr-status-analyzer`, `pr-self-reviewer` to `pr-reviewer`,
`pr-fix-orchestrator` to `pr-fix-pool-supervisor`.
- **Automated PR Merging** (`pr-merge-pool-supervisor`): New supervisor continuously
monitors for merge-ready PRs and merges them automatically when all criteria are met
(approvals, CI passing, no conflicts). Supports both formal reviews and comment-based
approvals (LGTM, ✅, "ready to merge", etc.).
approvals (LGTM, ready to merge, etc.).
- **Implementation Worker Workflow Completion**: `implementation-worker` now implements
work claiming protocols with conflict detection, comprehensive review feedback handling
@@ -100,8 +272,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`docs/development/automation-tracking.md` and the new
`docs/development/docs-writer.md` reference.
- **ACMS / UKO API Documentation** (`docs/api/acms.md`): Added comprehensive API
reference for the `cleveragents.acms` package covering the four-layer UKO ontology
hierarchy, `VocabularyRegistry`, `ProvenanceInfo`, `UKOClass`, `UKOProperty`,
`UKOVocabulary`, `Layer2Dependency`, `ParadigmVocabulary`, `DetailLevelMapBuilder`,
and all Layer 3 language vocabulary types (Python, TypeScript, Rust, Java).
The new page is linked from the API Reference index and the MkDocs navigation.
### Changed
- **`product-builder` Worker Allocation Tier Comments** (#8169): Clarified the
`N_FULL` tier comment to explicitly document that PR fixing is handled by
`implementation-pool-supervisor` via its PR-First Priority rule. Updated the
`N_QUARTER` comment to enumerate the pools it covers (UAT, bug hunting, test
infra). Prevents confusion about which supervisor handles PR fix work.
- **Decision Tree Full ULID Display** (#5825): The `agents plan tree` command now
displays full 26-character ULIDs for all decisions instead of truncating them to
8 characters. This enables users to copy decision IDs directly from tree output
@@ -115,7 +300,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **PR Review Policy**: Reduced PR review requirement from 2 approvals to 1. Self-approval
is now permitted including for automated bot PRs. Approval can be a formal review OR an
approval comment (LGTM, Approved, ✅, "ready to merge").
approval comment (LGTM, Approved, ready to merge).
- **Label Delegation Enforcement**: `automation-tracking-manager` now enforces delegation
to `forgejo-label-manager` for all label operations, preventing "invalid label ID" errors
@@ -133,6 +318,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- **Plan Concurrency Race Condition** (#7989): Fixed critical race condition in `execute_plan()` and
`apply_plan()` where concurrent CLI/worker sessions could simultaneously modify the same plan,
corrupting plan state. `LockService` is now wired into the plan lifecycle with plan-level advisory
locking. Each invocation generates a unique caller identity (UUID) to prevent re-entrant lock
acquisition by concurrent sessions on the same plan. Concurrent attempts now raise `LockConflictError`
instead of silently racing. Lock is acquired before phase transition and released in a `finally`
block to ensure cleanup even on error.
- **`--format color` ANSI Output** (#7910): Fixed `format_output` routing the `color` format
option to `_format_plain`, which produced plain uncoloured text instead of ANSI escape
sequences. The `color` format is now routed to `format_output_session` which uses the
`ColorMaterializer` to emit proper ANSI-coloured output. `--format plain` and all other
formats remain unaffected.
- **ContextTierService Thread Safety** (#7547): Added `threading.RLock` to
`ContextTierService` to prevent `RuntimeError: dictionary changed size during
iteration` and data corruption under concurrent plan execution. All public
methods (`store`, `get`, `promote`, `demote`, `evict_lru`, `enforce_staleness`,
`get_metrics`, `get_all_fragments`, `get_hot_fragments`, `get_for_actor`,
`get_scoped_view`, `get_scoped_by_resource`, `get_scoped_metrics`) now acquire
the reentrant lock before accessing the hot/warm/cold tier dicts. The service
was previously documented as single-threaded but registered as a DI Singleton,
causing potential data corruption when parallel subplans shared the same
instance. The `TierRuntimeMixin.enforce_staleness()` and
`ScopedTierMixin.get_scoped_by_resource()` / `get_scoped_metrics()` methods
are also protected. The DI container registration as `providers.Singleton`
is now correct and safe.
- **TOCTOU Race Condition in Git Worktree Sandbox** (#7507): Fixed Time-Of-Check-To-Time-Of-Use race condition in `GitWorktreeSandbox.create()` by replacing unsafe mkdtemp+rmdir pattern with persistent parent directory approach. Parent directory is now held throughout operation lifetime and properly cleaned up in all error paths (timeout, CalledProcessError, OSError) and in the cleanup() method, eliminating race window where another process could claim the worktree path. Comprehensive BDD coverage added for all error-path cleanup branches.
- **Validation Gate Empty-Run Guard** (#7508): Fixed `ApplyValidationSummary.all_required_passed`
returning `True` when zero validations were run, silently bypassing the apply gate. The property
now returns `False` when the validation result set is empty (`is_empty` is `True`), ensuring
@@ -153,6 +368,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`sandbox_root=.cleveragents/sandbox/`, so LLM file output (`FILE:` blocks)
is written to disk during the execute phase. (#4222)
- **SubplanExecutionService fail_fast cancellation** (#7582): Fixed a race condition where
already-running parallel subplans were not cancelled when `fail_fast` fired. Previously,
`Future.cancel()` only prevented queued futures from starting but had no effect on
in-flight futures that completed after `stop_flag` was set -- their `COMPLETE` results
were incorrectly included in the merge output. The fix adds a post-completion guard that
overrides any non-`ERRORED`/non-`CANCELLED` result to `CANCELLED` when `stop_flag` is
active, and clears the associated output to prevent it from entering the merge. Also
replaces the O(n) linear `status` lookup in the `as_completed()` loop with an O(1)
`status_map` dict pre-computed before the executor block.
- **Robot Framework TDD Listener Guards** (#5436): Added three guard conditions to the
`tdd_expected_fail_listener` `end_test()` function to prevent blindly inverting ALL test
failures to passes, which was masking infrastructure errors and causing flaky CI behavior.
@@ -161,6 +386,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
e2e test files and removed `tdd_expected_fail` from 4 context assembly e2e tests where
bugs were already fixed.
- **PluginLoader entry point prefix validation** (#7476): Parse entry point targets before
import, enforce the module allowlist ahead of loading, and add Behave plus Robot Framework
regression coverage to ensure disallowed prefixes never execute untrusted module-level code
in either unit or integration flows.
- **`issue-state-updater` Bash Script Errors**: Removed problematic bash script examples
that tried to invoke `task forgejo-label-manager` as a bash command (the Task tool cannot
be invoked from bash). Replaced with clear step-by-step operational instructions and
@@ -175,9 +405,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`pr-merge-pool-supervisor` to the product-builder's supervisor launch list (18 total
supervisors). Updated all numeric references, pre-flight checklists, and validation logic.
- `ActionRepository.update()` now uses explicit bulk `sa_delete()` + `session.flush()`
before re-inserting child rows for `action_arguments` and `action_invariants`, fixing
a `sqlite3.IntegrityError: UNIQUE constraint failed` crash when `agents plan use` was
called on an action that already had arguments registered via `action create`. (#4197)
---
## [3.8.0] 2026-04-05
## [3.8.0] -- 2026-04-05
### Added
@@ -188,171 +423,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`INVARIANT_VIOLATED` events. Post-correction reconciliation runs
via `CORRECTION_APPLIED` event subscription (best-effort). Added
`InvariantService` Singleton provider in the DI container.
- **TUI Shell danger detection**: The TUI shell mode (`!` prefix) now detects
- **TUI -- Shell danger detection**: The TUI shell mode (`!` prefix) now detects
dangerous command patterns before execution. A configurable pattern registry
classifies commands by danger level (warning, critical) and surfaces a user
warning overlay before proceeding. Patterns cover destructive filesystem
operations, privilege escalation, network exfiltration, and more. (#1003)
- **TUI Permission Question Widget**: A new inline `PermissionQuestionWidget`
- **TUI -- Permission Question Widget**: A new inline `PermissionQuestionWidget`
renders permission requests directly in the conversation stream for single-file
operations. Users can allow/reject with single-key shortcuts (`a`/`A`/`r`/`R`),
navigate with arrow keys, confirm with `Enter`, or press `v` to open the full
permissions screen. (#1004)
- **TUI — Actor thought blocks**: Expandable reasoning trace widgets rendered
inline in the conversation stream with muted styling. Collapsed by default;
expand with `Space` or click. (#1005)
- **UKO provenance tracking**: Every typed triple now carries `sourceResource`,
`validFrom`, and `isCurrent` metadata. A revision chain enables temporal
queries and point-in-time ontology state reconstruction.
- **JSON-RPC 2.0 A2A wire format**: `A2aRequest`/`A2aResponse` fields renamed to
standard JSON-RPC 2.0 names (`method`, `id`, `result`, `error`). The
`A2aVersionNegotiator` handles backward compatibility.
- **Database resource handler**: Full CRUD and checkpoint/rollback support for
SQLite, PostgreSQL, MySQL, and DuckDB resources via the resource DAG.
- **Estimation lifecycle hook**: `actor.default.estimation` config key wires an
estimation actor into the Strategize-to-Estimate lifecycle hook.
- **Persona system**: YAML-backed personas bind actors, argument presets, and scope
references to named identities; persisted in `~/.config/cleveragents/personas/`.
- **Session management**: Create, list, export, and import conversation sessions;
full JSON export/import for portability; Markdown transcript export
(`--format md`) for human-readable sharing.
- **First-run experience**: `ActorSelectionOverlay` guides new users to pick an
actor on first TUI launch; creates a `"default"` persona automatically.
- **Server mode**: `agents server connect` configures a remote CleverAgents server;
Kubernetes Helm chart in `k8s/` for production deployment.
- **A2A integration**: Agent-to-Agent protocol facade wires CLI and TUI to live
application services (session, plan, registry, event).
- **Permissions screen**: TUI overlay for reviewing tool permission requests with
unified, side-by-side, and context diff views; session-scoped allow/reject decisions.
- **Inline permission questions**: `PermissionQuestionWidget` renders single-file
permission requests directly in the conversation stream with single-key shortcuts.
- **Invariant reconciliation**: `InvariantReconciliationActor` runs automatically at
every plan phase transition; failures block the transition and emit `INVARIANT_VIOLATED`.
- **UKO runtime**: Universal Knowledge Ontology query interface, inference engine, and
graph persistence for ACMS context strategies.
### Fixed
- `LangChainChatProvider.name` and `model_id` are now mutable properties with setters,
fixing an `AttributeError` when `PlanService` attempted to resolve provider names after
instantiation. (#1553)
---
## [3.7.0] — 2026-03-15
### Added
- **Interactive TUI** (`agents tui`) — full-screen Textual app with multi-session tabs,
persona switching, slash commands (67 commands across 14 groups), reference picker
(`@`), shell mode (`!`), context-sensitive F1 help, and `Ctrl+T` argument preset cycling.
- **Slash command system** — 67 commands across 14 groups accessible via `/` overlay.
- **Reference picker** — `@` key opens a file/resource reference picker that inserts
references into the input field.
- **TUI persona system** — YAML-backed personas bind actors, argument presets, and scope
references; persisted in `~/.config/cleveragents/personas/`.
- **TUI session export/import** — full JSON round-trip and Markdown transcript export
(`--format md`).
---
## [3.6.0] — 2026-02-28
### Added
- Advanced Context Management System (ACMS) with three-tier context strategy.
- UKO Runtime (Universal Knowledge Ontology) with graph persistence and inference engine.
- Implicit inference engine producing `uko:implicitSiblingOf`, `uko:implicitContains`,
and `uko:implicitDependsOn` triples with confidence 0.7.
---
## [3.5.0] — 2026-02-14
### Added
- Autonomy hardening: advisory locking, validation pipeline, definition-of-done gating.
- Resource DAG with dependency tracking and type hierarchy with multiple inheritance.
- Container resource types (`container.docker`, `container.podman`).
- LSP resource types (`lsp.*`).
---
## [3.4.0] — 2026-01-31
### Added
- ACMS v1 with context scaling strategies.
- Resource type inheritance system (ADR-042).
- Safety profile extraction (ADR-041).
---
## [3.3.0] — 2026-01-17
### Added
- Corrections and subplans support in plan lifecycle.
- Checkpoint and rollback for all resource writes.
- Decision tree versioning and history (ADR-034).
- Decision tree rollback and replay (ADR-035).
---
## [3.2.0] — 2026-01-03
### Added
- Decisions, validations, and invariants in plan lifecycle.
- Validation abstraction layer (ADR-013).
- Invariant system (ADR-016).
- Automation profiles (ADR-017).
- Semantic error prevention (ADR-018).
---
## [3.1.0] — 2025-12-20
### Added
- MCP (Model Context Protocol) adapter and client (ADR-029).
- LSP (Language Server Protocol) client integration (ADR-027).
- Agent Skills Standard (AgentSkills.io) support (ADR-028).
- Skill abstraction definition (ADR-030).
---
## [3.0.0] — 2025-12-06
### Added
- Initial public release of CleverAgents Core.
- Unified `agents` / `cleveragents` CLI entry points.
- Layered architecture: Entry Points → Application → Domain → Infrastructure → Integration → Core.
- Actor system with YAML-defined LangGraph node graphs.
- Tool system with four-stage lifecycle (activate → validate → execute → deactivate).
- Skill system with three-tier progressive disclosure.
- Resource system with DAG and type hierarchy.
- A2A (Agent-to-Agent) protocol facade.
- DI container (`cleveragents.application.container`).
- LangChain/LangGraph integration (ADR-022).
- Provider registry with fallback chain (OpenAI → Anthropic → Google → Azure → OpenRouter → Groq → Together → Cohere).
- Observability: structured logging, metrics, audit trail, token/cost tracking.
- BDD test suite (Behave + Robot Framework).
- Nox automation for lint, typecheck, tests, docs, benchmarks.
- MkDocs-powered documentation with CleverAgents branding.
@@ -0,0 +1,41 @@
Feature: GitWorktreeSandbox class methods for stale cleanup and diff
As a developer
I want class-level helpers to clean up stale worktrees and generate diffs
So that the CLI can manage sandbox lifecycle without a sandbox instance
Background:
Given a gwt_cm test git repository is initialised
Scenario: cleanup_stale removes a stale worktree branch
Given a gwt_cm stale worktree branch exists for plan "plan-stale-001"
When I call GitWorktreeSandbox.cleanup_stale for plan "plan-stale-001"
Then the gwt_cm stale branch should no longer exist
Scenario: cleanup_stale is idempotent when no stale branch exists
When I call GitWorktreeSandbox.cleanup_stale for plan "plan-nonexistent-999"
Then no gwt_cm exception should have been raised
Scenario: cleanup_stale handles empty plan_id gracefully
When I call GitWorktreeSandbox.cleanup_stale with empty plan_id
Then no gwt_cm exception should have been raised
Scenario: cleanup_stale handles empty original_path gracefully
When I call GitWorktreeSandbox.cleanup_stale with empty original_path
Then no gwt_cm exception should have been raised
Scenario: diff_against_head returns None when no worktree branch exists
When I call GitWorktreeSandbox.diff_against_head for plan "plan-no-branch-001"
Then the gwt_cm diff result should be None
Scenario: diff_against_head returns diff when worktree branch has changes
Given a gwt_cm worktree branch with changes exists for plan "plan-diff-001"
When I call GitWorktreeSandbox.diff_against_head for plan "plan-diff-001"
Then the gwt_cm diff result should not be None
Scenario: diff_against_head handles empty plan_id gracefully
When I call GitWorktreeSandbox.diff_against_head with empty plan_id
Then the gwt_cm diff result should be None
Scenario: diff_against_head handles empty original_path gracefully
When I call GitWorktreeSandbox.diff_against_head with empty original_path
Then the gwt_cm diff result should be None
@@ -0,0 +1,39 @@
Feature: PlanApplyService correction_diff method
As a developer
I want correction_diff to return a diff for a specific correction attempt
So that users can inspect what changed during a correction
Scenario: correction_diff returns rich format when no changeset exists
Given a pacd service with no changeset for plan "plan-001"
When I call correction_diff for plan "plan-001" correction "corr-001" with format "rich"
Then the pacd result should contain "No changeset available"
Scenario: correction_diff returns plain format when no changeset exists
Given a pacd service with no changeset for plan "plan-002"
When I call correction_diff for plan "plan-002" correction "corr-002" with format "plain"
Then the pacd result should contain "No changeset available"
Scenario: correction_diff returns json format when no changeset exists
Given a pacd service with no changeset for plan "plan-003"
When I call correction_diff for plan "plan-003" correction "corr-003" with format "json"
Then the pacd result should contain "No changeset available"
Scenario: correction_diff returns diff when changeset exists
Given a pacd service with a changeset for plan "plan-004"
When I call correction_diff for plan "plan-004" correction "corr-004" with format "rich"
Then the pacd result should contain "corr-004"
Scenario: correction_diff returns plain diff when changeset exists
Given a pacd service with a changeset for plan "plan-005"
When I call correction_diff for plan "plan-005" correction "corr-005" with format "plain"
Then the pacd result should contain "corr-005"
Scenario: correction_diff returns json diff when changeset exists
Given a pacd service with a changeset for plan "plan-006"
When I call correction_diff for plan "plan-006" correction "corr-006" with format "json"
Then the pacd result should contain "corr-006"
Scenario: correction_diff returns yaml diff when changeset exists
Given a pacd service with a changeset for plan "plan-007"
When I call correction_diff for plan "plan-007" correction "corr-007" with format "yaml"
Then the pacd result should contain "corr-007"
@@ -0,0 +1,245 @@
"""Step definitions for GitWorktreeSandbox class methods feature.
All steps use the ``gwt_cm`` prefix to avoid collisions with other step files.
Note: GitWorktreeSandbox is imported lazily inside step functions to ensure
the isolated repo's src directory (added by environment.py before_all) takes
precedence over PYTHONPATH entries.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from behave import given, then, when
from behave.runner import Context
def _get_gwt() -> type:
"""Lazily import GitWorktreeSandbox to use the isolated repo's version."""
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
return GitWorktreeSandbox
def _init_test_repo_cm(ctx: Context) -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwt-cm-test-repo-")
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=repo_dir,
capture_output=True,
check=True,
)
readme = os.path.join(repo_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_dir,
capture_output=True,
check=True,
)
return repo_dir
@given("a gwt_cm test git repository is initialised")
def step_gwt_cm_init_repo(ctx: Context) -> None:
"""Initialise a temporary git repository for testing."""
ctx.gwt_cm_repo_dir = _init_test_repo_cm(ctx)
ctx.gwt_cm_exception: Exception | None = None
ctx.gwt_cm_diff_result: str | None = None
@given('a gwt_cm stale worktree branch exists for plan "{plan_id}"')
def step_gwt_cm_create_stale_branch(ctx: Context, plan_id: str) -> None:
"""Create a stale worktree branch to simulate a previous execute."""
repo_dir: str = ctx.gwt_cm_repo_dir
branch_name = f"cleveragents/plan-{plan_id}"
# Create the branch directly without a worktree
subprocess.run(
["git", "branch", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
@when('I call GitWorktreeSandbox.cleanup_stale for plan "{plan_id}"')
def step_gwt_cm_call_cleanup_stale(ctx: Context, plan_id: str) -> None:
"""Call the cleanup_stale class method."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, plan_id)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@when("I call GitWorktreeSandbox.cleanup_stale with empty plan_id")
def step_gwt_cm_cleanup_stale_empty_plan_id(ctx: Context) -> None:
"""Call cleanup_stale with an empty plan_id."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, "")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@when("I call GitWorktreeSandbox.cleanup_stale with empty original_path")
def step_gwt_cm_cleanup_stale_empty_path(ctx: Context) -> None:
"""Call cleanup_stale with an empty original_path."""
GitWorktreeSandbox = _get_gwt()
try:
GitWorktreeSandbox.cleanup_stale("", "some-plan-id")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
@then("the gwt_cm stale branch should no longer exist")
def step_gwt_cm_branch_not_exist(ctx: Context) -> None:
"""Assert that the stale branch has been removed."""
result = subprocess.run(
["git", "branch", "--list"],
cwd=ctx.gwt_cm_repo_dir,
capture_output=True,
text=True,
check=True,
)
# No cleveragents/plan-* branches should remain
branches = result.stdout.strip()
assert "cleveragents/plan-" not in branches, (
f"Expected no cleveragents/plan-* branches, but found: {branches}"
)
@then("no gwt_cm exception should have been raised")
def step_gwt_cm_no_exception(ctx: Context) -> None:
"""Assert that no exception was raised."""
assert ctx.gwt_cm_exception is None, (
f"Expected no exception, but got: {ctx.gwt_cm_exception}"
)
@given('a gwt_cm worktree branch with changes exists for plan "{plan_id}"')
def step_gwt_cm_create_branch_with_changes(ctx: Context, plan_id: str) -> None:
"""Create a branch with a committed change to simulate execute output."""
repo_dir: str = ctx.gwt_cm_repo_dir
branch_name = f"cleveragents/plan-{plan_id}"
# Create and switch to the new branch
subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Add a new file and commit it
new_file = os.path.join(repo_dir, "generated.py")
with open(new_file, "w") as f:
f.write("# Generated by plan\nresult = 42\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", f"Plan {plan_id} output"],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Switch back to the original branch
subprocess.run(
["git", "checkout", "master"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Try main if master doesn't exist
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
check=False,
)
if result.stdout.strip() != "master":
subprocess.run(
["git", "checkout", "main"],
cwd=repo_dir,
capture_output=True,
check=False,
)
@when('I call GitWorktreeSandbox.diff_against_head for plan "{plan_id}"')
def step_gwt_cm_call_diff_against_head(ctx: Context, plan_id: str) -> None:
"""Call the diff_against_head class method."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head(
ctx.gwt_cm_repo_dir, plan_id
)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@when("I call GitWorktreeSandbox.diff_against_head with empty plan_id")
def step_gwt_cm_diff_empty_plan_id(ctx: Context) -> None:
"""Call diff_against_head with an empty plan_id."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head(
ctx.gwt_cm_repo_dir, ""
)
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@when("I call GitWorktreeSandbox.diff_against_head with empty original_path")
def step_gwt_cm_diff_empty_path(ctx: Context) -> None:
"""Call diff_against_head with an empty original_path."""
GitWorktreeSandbox = _get_gwt()
try:
ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head("", "some-plan")
ctx.gwt_cm_exception = None
except Exception as exc:
ctx.gwt_cm_exception = exc
ctx.gwt_cm_diff_result = None
@then("the gwt_cm diff result should be None")
def step_gwt_cm_diff_is_none(ctx: Context) -> None:
"""Assert that the diff result is None."""
assert ctx.gwt_cm_diff_result is None, (
f"Expected diff result to be None, but got: {ctx.gwt_cm_diff_result!r}"
)
@then("the gwt_cm diff result should not be None")
def step_gwt_cm_diff_is_not_none(ctx: Context) -> None:
"""Assert that the diff result is not None."""
assert ctx.gwt_cm_diff_result is not None, (
"Expected diff result to not be None, but it was None"
)
@@ -0,0 +1,96 @@
"""Step definitions for PlanApplyService correction_diff feature.
All steps use the ``pacd`` prefix to avoid collisions with other step files.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_apply_service import PlanApplyService
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
SpecChangeSet,
)
def _make_mock_lifecycle(plan_id: str, changeset_id: str | None) -> MagicMock:
"""Create a mock lifecycle service."""
lifecycle = MagicMock()
plan = MagicMock()
plan.identity.plan_id = plan_id
plan.changeset_id = changeset_id
plan.error_details = None
plan.validation_summary = None
plan.sandbox_refs = []
lifecycle.get_plan.return_value = plan
return lifecycle
def _make_changeset(plan_id: str, changeset_id: str) -> SpecChangeSet:
"""Create a SpecChangeSet with one entry."""
cs = SpecChangeSet(changeset_id=changeset_id, plan_id=plan_id)
entry = ChangeEntry(
plan_id=plan_id,
resource_id="RES001",
tool_name="builtin/test-tool",
operation=ChangeOperation.MODIFY,
path="src/app.py",
before_hash="abcdef0123456789",
after_hash="123456abcdefghij",
)
cs.add_change(entry)
return cs
@given('a pacd service with no changeset for plan "{plan_id}"')
def step_pacd_service_no_changeset(ctx: Context, plan_id: str) -> None:
"""Set up a PlanApplyService where the plan has no changeset."""
lifecycle = _make_mock_lifecycle(plan_id, changeset_id=None)
ctx.pacd_service = PlanApplyService(lifecycle_service=lifecycle)
ctx.pacd_plan_id = plan_id
ctx.pacd_result: str = ""
@given('a pacd service with a changeset for plan "{plan_id}"')
def step_pacd_service_with_changeset(ctx: Context, plan_id: str) -> None:
"""Set up a PlanApplyService where the plan has a changeset."""
changeset_id = f"cs-{plan_id}"
lifecycle = _make_mock_lifecycle(plan_id, changeset_id=changeset_id)
changeset = _make_changeset(plan_id, changeset_id)
changeset_store: Any = MagicMock()
changeset_store.get.return_value = changeset
ctx.pacd_service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=changeset_store,
)
ctx.pacd_plan_id = plan_id
ctx.pacd_result = ""
@when(
'I call correction_diff for plan "{plan_id}" correction "{correction_id}" '
'with format "{fmt}"'
)
def step_pacd_call_correction_diff(
ctx: Context, plan_id: str, correction_id: str, fmt: str
) -> None:
"""Call correction_diff on the service."""
ctx.pacd_result = ctx.pacd_service.correction_diff(
plan_id=plan_id,
correction_id=correction_id,
fmt=fmt,
)
@then('the pacd result should contain "{expected}"')
def step_pacd_result_contains(ctx: Context, expected: str) -> None:
"""Assert that the result contains the expected string."""
assert expected in ctx.pacd_result, (
f"Expected result to contain {expected!r}, but got: {ctx.pacd_result!r}"
)
@@ -0,0 +1,80 @@
"""Step definitions for strategy actor resolution feature.
All steps use the ``sar`` prefix to avoid collisions with other step files.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
@given("a sar mock provider registry is available")
def step_sar_mock_registry(ctx: Context) -> None:
"""Set up a mock provider registry."""
ctx.sar_registry: Any = MagicMock()
ctx.sar_registry.__bool__ = lambda self: True
@given("a sar mock lifecycle service is available")
def step_sar_mock_lifecycle(ctx: Context) -> None:
"""Set up a mock lifecycle service."""
ctx.sar_lifecycle: Any = MagicMock()
ctx.sar_lifecycle.get_plan = MagicMock(return_value=MagicMock())
ctx.sar_lifecycle.get_action = MagicMock(return_value=MagicMock())
@when('I call resolve_strategy_actor with config_value "{config_value}"')
def step_sar_call_with_config(ctx: Context, config_value: str) -> None:
"""Call resolve_strategy_actor with the given config_value."""
registry = getattr(ctx, "sar_registry", None)
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value=config_value,
)
@when("I call resolve_strategy_actor with no provider registry")
def step_sar_call_no_registry(ctx: Context) -> None:
"""Call resolve_strategy_actor with no provider registry."""
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=None,
lifecycle_service=lifecycle,
config_value=None,
)
@when("I call resolve_strategy_actor with no config_value")
def step_sar_call_no_config(ctx: Context) -> None:
"""Call resolve_strategy_actor with no config_value."""
registry = getattr(ctx, "sar_registry", None)
lifecycle = getattr(ctx, "sar_lifecycle", MagicMock())
ctx.sar_result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value=None,
)
@then("the sar resolved actor should be None")
def step_sar_result_is_none(ctx: Context) -> None:
"""Assert that the resolved actor is None."""
assert ctx.sar_result is None, (
f"Expected resolved actor to be None, but got: {ctx.sar_result!r}"
)
@then("the sar resolved actor should not be None")
def step_sar_result_is_not_none(ctx: Context) -> None:
"""Assert that the resolved actor is not None."""
assert ctx.sar_result is not None, (
"Expected resolved actor to not be None, but it was None"
)
@@ -0,0 +1,27 @@
Feature: Strategy actor resolution for plan execution
As a developer
I want resolve_strategy_actor to select the correct strategize actor
So that plan execution uses the right LLM or stub actor
Scenario: resolve_strategy_actor returns None when config_value is "stub"
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with config_value "stub"
Then the sar resolved actor should be None
Scenario: resolve_strategy_actor returns None when provider_registry is None
Given a sar mock lifecycle service is available
When I call resolve_strategy_actor with no provider registry
Then the sar resolved actor should be None
Scenario: resolve_strategy_actor returns LLMStrategizeActor when registry is available
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with config_value "llm"
Then the sar resolved actor should not be None
Scenario: resolve_strategy_actor returns LLMStrategizeActor when config_value is None
Given a sar mock provider registry is available
And a sar mock lifecycle service is available
When I call resolve_strategy_actor with no config_value
Then the sar resolved actor should not be None
+90
View File
@@ -0,0 +1,90 @@
*** Settings ***
Documentation Integration tests for GitWorktreeSandbox class methods:
... cleanup_stale and diff_against_head.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_git_worktree_class_methods.py
*** Test Cases ***
Cleanup Stale With No Existing Branch Is Idempotent
[Documentation] cleanup_stale does nothing when no stale branch exists
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-no-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-no-branch-ok
Cleanup Stale Removes Existing Branch
[Documentation] cleanup_stale removes a stale worktree branch
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-removes-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-removes-branch-ok
Cleanup Stale With Empty Plan ID Is Safe
[Documentation] cleanup_stale handles empty plan_id without raising
${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-empty-plan-id cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cleanup-stale-empty-plan-id-ok
Diff Against Head Returns None When No Branch
[Documentation] diff_against_head returns None when no worktree branch exists
${result}= Run Process ${PYTHON} ${HELPER} diff-no-branch cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-no-branch-ok
Diff Against Head Returns Diff When Branch Has Changes
[Documentation] diff_against_head returns a non-empty diff when the branch has commits
${result}= Run Process ${PYTHON} ${HELPER} diff-with-changes cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-with-changes-ok
Diff Against Head With Empty Plan ID Returns None
[Documentation] diff_against_head returns None for empty plan_id
${result}= Run Process ${PYTHON} ${HELPER} diff-empty-plan-id cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} diff-empty-plan-id-ok
Strategy Actor Resolves To None For Stub Config
[Documentation] resolve_strategy_actor returns None when config_value is "stub"
${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-stub cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} strategy-actor-stub-ok
Strategy Actor Resolves To None Without Registry
[Documentation] resolve_strategy_actor returns None when no registry is provided
${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-no-registry cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} strategy-actor-no-registry-ok
Correction Diff Returns Output For Plan Without Changeset
[Documentation] correction_diff returns a message when no changeset exists
${result}= Run Process ${PYTHON} ${HELPER} correction-diff-no-changeset cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} correction-diff-no-changeset-ok
Correction Diff Returns Output For Plan With Changeset
[Documentation] correction_diff returns diff output when a changeset exists
${result}= Run Process ${PYTHON} ${HELPER} correction-diff-with-changeset cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} correction-diff-with-changeset-ok
+369
View File
@@ -0,0 +1,369 @@
"""Robot Framework helper for GitWorktreeSandbox class methods integration tests.
Tests cleanup_stale, diff_against_head, resolve_strategy_actor, and
PlanApplyService.correction_diff.
Exit code 0 = success, 1 = failure.
Usage:
python robot/helper_git_worktree_class_methods.py cleanup-stale-no-branch
python robot/helper_git_worktree_class_methods.py cleanup-stale-removes-branch
python robot/helper_git_worktree_class_methods.py cleanup-stale-empty-plan-id
python robot/helper_git_worktree_class_methods.py diff-no-branch
python robot/helper_git_worktree_class_methods.py diff-with-changes
python robot/helper_git_worktree_class_methods.py diff-empty-plan-id
python robot/helper_git_worktree_class_methods.py strategy-actor-stub
python robot/helper_git_worktree_class_methods.py strategy-actor-no-registry
python robot/helper_git_worktree_class_methods.py correction-diff-no-changeset
python robot/helper_git_worktree_class_methods.py correction-diff-with-changeset
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
# Ensure the isolated repo's src directory takes precedence over any
# PYTHONPATH entries (e.g. /app/src from the workspace environment).
_SRC = str(Path(__file__).resolve().parents[1] / "src")
# Remove any conflicting paths that might shadow our isolated repo
sys.path = [p for p in sys.path if not (p.endswith("/src") and p != _SRC)]
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Clear any cached cleveragents modules so our isolated version is used
for _mod_name in list(sys.modules.keys()):
if _mod_name == "cleveragents" or _mod_name.startswith("cleveragents."):
del sys.modules[_mod_name]
from cleveragents.infrastructure.sandbox.git_worktree import ( # noqa: E402
GitWorktreeSandbox,
)
def _init_test_repo() -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwt-cm-robot-")
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=repo_dir,
capture_output=True,
check=True,
)
readme = os.path.join(repo_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_dir,
capture_output=True,
check=True,
)
return repo_dir
def cmd_cleanup_stale_no_branch() -> None:
"""cleanup_stale is idempotent when no stale branch exists."""
repo_dir = _init_test_repo()
try:
GitWorktreeSandbox.cleanup_stale(repo_dir, "plan-nonexistent-999")
print("cleanup-stale-no-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_cleanup_stale_removes_branch() -> None:
"""cleanup_stale removes a stale worktree branch."""
repo_dir = _init_test_repo()
try:
plan_id = "plan-stale-001"
branch_name = f"cleveragents/plan-{plan_id}"
# Create a stale branch
subprocess.run(
["git", "branch", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Verify branch exists
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=repo_dir,
capture_output=True,
text=True,
check=True,
)
assert branch_name in result.stdout, f"Branch {branch_name} should exist"
# Call cleanup_stale
GitWorktreeSandbox.cleanup_stale(repo_dir, plan_id)
# Verify branch is gone
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=repo_dir,
capture_output=True,
text=True,
check=True,
)
assert branch_name not in result.stdout, (
f"Branch {branch_name} should have been removed"
)
print("cleanup-stale-removes-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_cleanup_stale_empty_plan_id() -> None:
"""cleanup_stale handles empty plan_id without raising."""
repo_dir = _init_test_repo()
try:
GitWorktreeSandbox.cleanup_stale(repo_dir, "")
print("cleanup-stale-empty-plan-id-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_no_branch() -> None:
"""diff_against_head returns None when no worktree branch exists."""
repo_dir = _init_test_repo()
try:
result = GitWorktreeSandbox.diff_against_head(repo_dir, "plan-no-branch-001")
assert result is None, f"Expected None, got: {result!r}"
print("diff-no-branch-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_with_changes() -> None:
"""diff_against_head returns a non-empty diff when the branch has commits."""
repo_dir = _init_test_repo()
try:
plan_id = "plan-diff-001"
branch_name = f"cleveragents/plan-{plan_id}"
# Create and switch to the new branch
subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Add a new file and commit it
new_file = os.path.join(repo_dir, "generated.py")
with open(new_file, "w") as f:
f.write("# Generated by plan\nresult = 42\n")
subprocess.run(
["git", "add", "."], cwd=repo_dir, capture_output=True, check=True
)
subprocess.run(
["git", "commit", "-m", f"Plan {plan_id} output"],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Switch back to the original branch
subprocess.run(
["git", "checkout", "master"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Try main if master doesn't exist
rev_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_dir,
capture_output=True,
text=True,
check=False,
)
if rev_result.stdout.strip() not in ("master", "main"):
subprocess.run(
["git", "checkout", "main"],
cwd=repo_dir,
capture_output=True,
check=False,
)
# Call diff_against_head
diff = GitWorktreeSandbox.diff_against_head(repo_dir, plan_id)
assert diff is not None, "Expected a non-None diff"
assert len(diff) > 0, "Expected a non-empty diff"
print("diff-with-changes-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_diff_empty_plan_id() -> None:
"""diff_against_head returns None for empty plan_id."""
repo_dir = _init_test_repo()
try:
result = GitWorktreeSandbox.diff_against_head(repo_dir, "")
assert result is None, f"Expected None, got: {result!r}"
print("diff-empty-plan-id-ok")
finally:
import shutil
shutil.rmtree(repo_dir, ignore_errors=True)
def cmd_strategy_actor_stub() -> None:
"""resolve_strategy_actor returns None when config_value is 'stub'."""
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
registry: Any = MagicMock()
lifecycle: Any = MagicMock()
result = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle,
config_value="stub",
)
assert result is None, f"Expected None for stub config, got: {result!r}"
print("strategy-actor-stub-ok")
def cmd_strategy_actor_no_registry() -> None:
"""resolve_strategy_actor returns None when no registry is provided."""
from cleveragents.application.services.strategy_actor import resolve_strategy_actor
lifecycle: Any = MagicMock()
result = resolve_strategy_actor(
provider_registry=None,
lifecycle_service=lifecycle,
config_value=None,
)
assert result is None, f"Expected None for no registry, got: {result!r}"
print("strategy-actor-no-registry-ok")
def cmd_correction_diff_no_changeset() -> None:
"""correction_diff returns a message when no changeset exists."""
from cleveragents.application.services.plan_apply_service import PlanApplyService
plan_id = "plan-corr-001"
lifecycle: Any = MagicMock()
plan: Any = MagicMock()
plan.identity.plan_id = plan_id
plan.changeset_id = None
plan.error_details = None
plan.validation_summary = None
plan.sandbox_refs = []
lifecycle.get_plan.return_value = plan
service = PlanApplyService(lifecycle_service=lifecycle)
result = service.correction_diff(plan_id, "corr-001", fmt="rich")
assert "No changeset available" in result, (
f"Expected 'No changeset available' in result, got: {result!r}"
)
print("correction-diff-no-changeset-ok")
def cmd_correction_diff_with_changeset() -> None:
"""correction_diff returns diff output when a changeset exists."""
from cleveragents.application.services.plan_apply_service import PlanApplyService
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
SpecChangeSet,
)
plan_id = "plan-corr-002"
changeset_id = "cs-corr-002"
correction_id = "corr-002"
lifecycle: Any = MagicMock()
plan: Any = MagicMock()
plan.identity.plan_id = plan_id
plan.changeset_id = changeset_id
plan.error_details = None
plan.validation_summary = None
plan.sandbox_refs = []
lifecycle.get_plan.return_value = plan
cs = SpecChangeSet(changeset_id=changeset_id, plan_id=plan_id)
entry = ChangeEntry(
plan_id=plan_id,
resource_id="RES001",
tool_name="builtin/test-tool",
operation=ChangeOperation.MODIFY,
path="src/app.py",
before_hash="abcdef0123456789",
after_hash="123456abcdefghij",
)
cs.add_change(entry)
changeset_store: Any = MagicMock()
changeset_store.get.return_value = cs
service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=changeset_store,
)
result = service.correction_diff(plan_id, correction_id, fmt="rich")
assert correction_id in result, (
f"Expected correction_id {correction_id!r} in result, got: {result!r}"
)
print("correction-diff-with-changeset-ok")
_COMMANDS: dict[str, Any] = {
"cleanup-stale-no-branch": cmd_cleanup_stale_no_branch,
"cleanup-stale-removes-branch": cmd_cleanup_stale_removes_branch,
"cleanup-stale-empty-plan-id": cmd_cleanup_stale_empty_plan_id,
"diff-no-branch": cmd_diff_no_branch,
"diff-with-changes": cmd_diff_with_changes,
"diff-empty-plan-id": cmd_diff_empty_plan_id,
"strategy-actor-stub": cmd_strategy_actor_stub,
"strategy-actor-no-registry": cmd_strategy_actor_no_registry,
"correction-diff-no-changeset": cmd_correction_diff_no_changeset,
"correction-diff-with-changeset": cmd_correction_diff_with_changeset,
}
def main() -> None:
"""Entry point for Robot Framework helper."""
if len(sys.argv) < 2:
print("Usage: helper_git_worktree_class_methods.py <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd not in _COMMANDS:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(f"Available: {', '.join(_COMMANDS)}", file=sys.stderr)
sys.exit(1)
try:
_COMMANDS[cmd]()
except Exception as exc:
print(f"FAILED: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -754,6 +754,85 @@ class PlanApplyService:
plan_id=plan.identity.plan_id,
)
# -- Correction diff -----------------------------------------------------
def correction_diff(
self,
plan_id: str,
correction_id: str,
fmt: str = "rich",
) -> str:
"""Generate diff output for a specific correction attempt.
Returns a human-readable summary of the correction attempt.
When no correction-specific changeset is available, falls back
to the plan's current changeset diff.
Args:
plan_id: The plan ULID.
correction_id: The correction attempt identifier.
fmt: Output format (``rich``, ``plain``, ``json``, ``yaml``).
Returns:
Rendered diff string for the correction attempt.
"""
plan = self._lifecycle.get_plan(plan_id)
changeset = self._resolve_changeset(plan)
if changeset is None:
if fmt in ("json", "yaml"):
import json as json_mod
return json_mod.dumps(
{
"plan_id": plan_id,
"correction_id": correction_id,
"message": "No changeset available for this correction.",
},
indent=2,
)
if fmt == "plain":
return (
f"Correction: {correction_id}\n"
f"Plan: {plan_id}\n\n"
"No changeset available for this correction."
)
return (
f"[bold]Correction:[/bold] {correction_id}\n"
f"[bold]Plan:[/bold] {plan_id}\n\n"
"[yellow]No changeset available for this correction.[/yellow]"
)
# Render the changeset diff with correction context header
if fmt == "json":
import json as json_mod
data = _render_diff_json(changeset)
data["correction_id"] = correction_id
return json_mod.dumps(data, indent=2, default=str)
if fmt == "yaml":
import json as json_mod
import yaml as yaml_mod
data = _render_diff_json(changeset)
data["correction_id"] = correction_id
return yaml_mod.dump(
data, default_flow_style=False, sort_keys=False
).rstrip("\n")
if fmt == "plain":
header = (
f"Correction: {correction_id}\n"
f"Plan: {plan_id}\n\n"
)
return header + _render_diff_plain(changeset)
# Rich format
header = (
f"[bold]Correction:[/bold] {correction_id}\n"
f"[bold]Plan:[/bold] {plan_id}\n\n"
)
return header + _render_diff_rich(changeset)
# -- ChangeSet cleanup --------------------------------------------------
def cleanup_changeset(self, plan_id: str) -> int:
@@ -0,0 +1,69 @@
"""Strategy actor resolution for the plan execute phase.
Provides ``resolve_strategy_actor`` which selects the appropriate strategize
actor based on the operator-configured ``actor.default.strategy`` key and
the availability of a ``ProviderRegistry``.
This module decouples actor selection from the CLI layer so that the
``_get_plan_executor`` helper in ``plan.py`` can delegate the decision
without importing ``LLMStrategizeActor`` directly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from cleveragents.application.services.llm_actors import (
PlanLifecycleProtocol,
)
from cleveragents.providers.registry import ProviderRegistry
def resolve_strategy_actor(
provider_registry: ProviderRegistry | None,
lifecycle_service: PlanLifecycleProtocol,
config_value: str | None = None,
) -> Any:
"""Resolve the strategize actor to use for plan execution.
Selection logic:
- If *config_value* is ``"stub"`` return ``None`` (``PlanExecutor``
falls back to ``StrategizeStubActor``).
- If *provider_registry* is available (or *config_value* is ``"llm"``)
return an ``LLMStrategizeActor`` instance.
- Otherwise return ``None`` (stub fallback).
Args:
provider_registry: The provider registry from the DI container.
May be ``None`` when no LLM provider is configured.
lifecycle_service: The plan lifecycle service instance.
config_value: Optional value of the ``actor.default.strategy``
config key. Recognised values: ``"llm"``, ``"stub"``.
Returns:
An ``LLMStrategizeActor`` instance when an LLM provider is
available, or ``None`` to signal that ``PlanExecutor`` should
use its built-in ``StrategizeStubActor``.
"""
from cleveragents.application.services.llm_actors import LLMStrategizeActor
# Explicit stub override
if config_value == "stub":
return None
# No registry available — fall back to stub
if provider_registry is None:
return None
# Explicit LLM override or registry available
try:
return LLMStrategizeActor(
provider_registry=provider_registry,
lifecycle_service=lifecycle_service,
)
except Exception:
# If actor construction fails (e.g. missing credentials),
# fall back to stub rather than crashing the CLI.
return None
+647 -77
View File
@@ -20,6 +20,8 @@ plan lifecycle.
from __future__ import annotations
import contextlib
import json
import os
import re
import shutil
@@ -28,23 +30,35 @@ import warnings
from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
import structlog
import typer
from rich.console import Console
from rich.markup import escape as rich_escape
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError
from cleveragents.a2a.models import A2aRequest
from cleveragents.application.container import get_container
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.error_handling import redact_error_details
from cleveragents.core.exceptions import (
CleverAgentsError,
NotFoundError,
PlanError,
ValidationError,
)
from cleveragents.domain.models.core.error_recovery import (
ErrorCategory,
classify_error,
)
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
from cleveragents.infrastructure.sandbox.git_worktree import (
GitWorktreeSandbox,
)
# Regex for validating namespaced actor names (namespace/name format)
_NAMESPACED_ACTOR_RE = re.compile(r"^[a-z][a-z0-9-]*/[a-z][a-z0-9._-]*$")
@@ -72,6 +86,8 @@ _ULID_VALIDATION_ERROR_MSG = (
"system and cannot be mixed with v3 commands."
)
logger = structlog.get_logger(__name__)
def _notify_facade(operation: str, params: dict[str, Any]) -> None:
"""Best-effort A2A facade notification for protocol bookkeeping.
@@ -202,6 +218,9 @@ _LEGACY_DEPRECATION_MSG = (
)
if TYPE_CHECKING:
from cleveragents.application.services.plan_apply_service import (
PlanApplyService,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
@@ -217,6 +236,7 @@ app = typer.Typer(
)
console = Console()
# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
@@ -1345,33 +1365,48 @@ def _get_lifecycle_service():
return container.plan_lifecycle_service()
def _create_sandbox_for_plan(
_cleanup_logger = structlog.get_logger("cleveragents.cli.commands.plan.cleanup")
def _cleanup_sandbox_for_plan(
plan_id: str,
service: PlanLifecycleService,
) -> tuple[str | None, Any]:
"""Create a git worktree sandbox for a plan's linked project.
) -> None:
"""Remove any worktree sandbox left by a previous execute.
Returns:
A ``(sandbox_root, sandbox_object)`` tuple. When the plan's
project has a git-checkout resource, *sandbox_object* is a
:class:`GitWorktreeSandbox` and *sandbox_root* is the worktree
path. Otherwise falls back to a flat directory under
``.cleveragents/sandbox/`` and *sandbox_object* is ``None``.
Resolves the plan's linked git-checkout resource and delegates
to :meth:`GitWorktreeSandbox.cleanup_stale` in the infrastructure
layer. Silently does nothing if no sandbox is found.
Used by ``plan cancel`` to prevent resource leaks.
"""
from cleveragents.application.container import get_container
from cleveragents.infrastructure.sandbox.git_worktree import (
GitWorktreeSandbox,
)
if not plan_id or not plan_id.strip():
_cleanup_logger.warning("cleanup_sandbox.skipped", reason="empty plan_id")
return
container = get_container()
plan = service.get_plan(plan_id)
try:
plan = service.get_plan(plan_id)
except (NotFoundError, CleverAgentsError) as exc:
_cleanup_logger.warning(
"cleanup_sandbox.plan_not_found",
plan_id=plan_id,
error=str(exc),
)
return
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
# Try to find a git-checkout resource for the first linked project
for project_name in project_names:
try:
project = container.namespaced_project_repo().get(project_name)
except Exception:
except (NotFoundError, CleverAgentsError, SQLAlchemyError) as exc:
_cleanup_logger.debug(
"cleanup_sandbox.project_lookup_failed",
project_name=project_name,
error=str(exc),
)
continue
if project is None:
continue
@@ -1380,6 +1415,82 @@ def _create_sandbox_for_plan(
resource = container.resource_registry_service().show_resource(
lr.resource_id,
)
except (NotFoundError, CleverAgentsError, SQLAlchemyError) as exc:
_cleanup_logger.debug(
"cleanup_sandbox.resource_lookup_failed",
resource_id=lr.resource_id,
error=str(exc),
)
continue
if (
resource.resource_type_name not in ("git-checkout", "git")
or not resource.location
):
continue
GitWorktreeSandbox.cleanup_stale(resource.location, plan_id)
class _SandboxInfo:
"""Metadata for a per-resource sandbox."""
__slots__ = ("project_name", "resource_location", "sandbox_obj", "sandbox_path")
def __init__(
self,
sandbox_path: str,
sandbox_obj: Any,
resource_location: str,
project_name: str,
) -> None:
self.sandbox_path = sandbox_path
self.sandbox_obj = sandbox_obj
self.resource_location = resource_location
self.project_name = project_name
def _create_sandbox_for_plan(
plan_id: str,
service: PlanLifecycleService,
) -> tuple[str | None, list[_SandboxInfo]]:
"""Create per-resource git worktree sandboxes for a plan.
Per spec §19310, each resource gets its own sandbox. A parent
directory is created under ``.cleveragents/sandbox/<plan_id>/``
with per-resource subdirectories named by resource ID.
Returns:
A ``(parent_sandbox_root, sandbox_infos)`` tuple.
*parent_sandbox_root* is the parent directory containing all
per-resource subdirectories (passed to ``PlanExecutor`` as
``sandbox_root``). *sandbox_infos* is a list of
:class:`_SandboxInfo` objects, one per git-checkout resource.
When no git resources are found, falls back to a flat directory
and returns an empty list.
"""
from cleveragents.application.container import get_container
container = get_container()
plan = service.get_plan(plan_id)
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
sandboxes: list[_SandboxInfo] = []
# Track processed repo paths to avoid cleanup_stale destroying
# a sandbox we just created for the same repo (M1 fix).
processed_repos: set[str] = set()
for project_name in project_names:
try:
project = container.namespaced_project_repo().get(project_name)
except Exception:
continue
if project is None:
continue
for linked_resource in getattr(project, "linked_resources", []):
try:
resource = container.resource_registry_service().show_resource(
linked_resource.resource_id,
)
except Exception:
continue
if (
@@ -1387,24 +1498,54 @@ def _create_sandbox_for_plan(
and resource.location
and os.path.isdir(os.path.join(resource.location, ".git"))
):
repo_abs = os.path.realpath(resource.location)
if repo_abs in processed_repos:
continue # M1: skip duplicate repos
processed_repos.add(repo_abs)
GitWorktreeSandbox.cleanup_stale(
resource.location,
plan_id,
)
sandbox = GitWorktreeSandbox(
resource_id=resource.resource_id,
original_path=resource.location,
)
ctx = sandbox.create(plan_id)
return ctx.sandbox_path, sandbox
try:
ctx = sandbox.create(plan_id)
except Exception:
# M3: cleanup already-created sandboxes on failure
for prev in sandboxes:
prev.sandbox_obj.cleanup()
raise
sandboxes.append(
_SandboxInfo(
sandbox_path=ctx.sandbox_path,
sandbox_obj=sandbox,
resource_location=resource.location,
project_name=project_name,
)
)
if sandboxes:
# Always use the first resource's worktree as sandbox_root
# (backward compatible — PlanExecutor and LLMExecuteActor
# write all FILE: blocks here). For multi-resource plans,
# _route_sandbox_files_to_worktrees() redistributes files
# to the correct worktrees after execute completes.
return sandboxes[0].sandbox_path, sandboxes
# Fallback: flat directory sandbox
flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
os.makedirs(flat_root, exist_ok=True)
return flat_root, None
return flat_root, []
def _apply_sandbox_changes(
plan_id: str,
service: PlanLifecycleService,
console: Console,
) -> None:
) -> bool:
"""Apply sandbox changes to the project.
Tries git worktree merge first. If the plan's sandbox was a git
@@ -1413,7 +1554,11 @@ def _apply_sandbox_changes(
Otherwise falls back to flat file copy from
``.cleveragents/sandbox/``.
Spec reference: ``specification.md`` §13241-13276.
Returns:
``True`` if changes were applied successfully, ``False`` if
the merge failed (conflict, timeout, etc.).
Spec reference: ``specification.md`` §19310-19313, §13241-13276.
"""
import subprocess
@@ -1424,7 +1569,11 @@ def _apply_sandbox_changes(
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
branch_name = f"cleveragents/plan-{plan_id}"
# Try git worktree merge for each linked git-checkout resource
# Try git worktree merge for each linked git-checkout resource.
# Per spec §19312: Apply commits each sandbox separately.
merged_count = 0
merge_failed = False
for project_name in project_names:
try:
project = container.namespaced_project_repo().get(project_name)
@@ -1478,7 +1627,6 @@ def _apply_sandbox_changes(
# Count changed files from diff --stat
stat_lines = (diff_stat.stdout or "").strip().splitlines()
# Last line is summary; file lines above it
artifact_count = max(0, len(stat_lines) - 1) if stat_lines else 0
# Parse insertions/deletions from --shortstat
@@ -1511,13 +1659,55 @@ def _apply_sandbox_changes(
check=True,
timeout=30,
)
except subprocess.TimeoutExpired:
console.print(
f"[red]Merge timed out for {project_name}.[/red]\n"
"[yellow]Run 'git merge --abort' manually.[/yellow]"
)
merge_failed = True
continue
except subprocess.CalledProcessError as merge_err:
console.print(f"[red]Merge failed:[/red] {merge_err.stderr.strip()}")
return
detail = (merge_err.stdout or merge_err.stderr or "").strip()
if not detail:
detail = "Unknown merge error"
console.print(f"[red]Merge failed for {project_name}:[/red] {detail}")
try:
abort_result = subprocess.run(
["git", "merge", "--abort"],
cwd=repo_path,
capture_output=True,
check=False,
timeout=10,
)
except subprocess.TimeoutExpired:
console.print(
"[red]Merge abort timed out.[/red]\n"
"[yellow]Run 'git merge --abort' manually.[/yellow]"
)
merge_failed = True
continue
if abort_result.returncode == 0:
console.print(
f"[yellow]{project_name}: merge aborted — "
"project is unchanged.[/yellow]"
)
else:
abort_err = (
abort_result.stdout or abort_result.stderr or ""
).strip()
if not abort_err:
abort_err = "Unknown error"
console.print(
f"[red]Merge abort failed:[/red] {abort_err}\n"
"[yellow]Run 'git merge --abort' manually.[/yellow]"
)
merge_failed = True
continue
merged_count += 1
applied_at = datetime.now().strftime("%Y-%m-%d %H:%M")
# ── Apply Summary panel (spec §13241-13247) ──
# ── Per-resource Apply Summary panel (spec §13241) ──
summary = (
f"[cyan]Plan:[/cyan] {plan_id}\n"
f"[blue]Artifacts:[/blue] {artifact_count} file(s) updated\n"
@@ -1532,7 +1722,6 @@ def _apply_sandbox_changes(
worktree_removed = False
branch_deleted = False
# Find and remove the worktree directory
wt_list = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=repo_path,
@@ -1547,7 +1736,13 @@ def _apply_sandbox_changes(
if part.startswith("worktree "):
wt_path = part.split("worktree ", 1)[1]
subprocess.run(
["git", "worktree", "remove", "--force", wt_path],
[
"git",
"worktree",
"remove",
"--force",
wt_path,
],
cwd=repo_path,
capture_output=True,
check=False,
@@ -1555,7 +1750,6 @@ def _apply_sandbox_changes(
)
worktree_removed = True
# Delete the branch
del_result = subprocess.run(
["git", "branch", "-D", branch_name],
cwd=repo_path,
@@ -1581,18 +1775,25 @@ def _apply_sandbox_changes(
)
console.print(Panel(cleanup_text, title="Sandbox Cleanup", expand=False))
# ── Next Steps panel (spec §13271-13274) ──
console.print(
Panel(
"- Review git diff\n- Commit changes",
title="Next Steps",
expand=False,
)
# Show footer after all resources are processed
if merged_count > 0:
console.print(
Panel(
"- Review git diff\n- Commit changes",
title="Next Steps",
expand=False,
)
)
console.print("[green]✓ OK[/green] Changes applied")
if merge_failed:
console.print(
"[yellow]Some resources failed to merge. See errors above.[/yellow]"
)
return False
return True
# ── Footer (spec §13276) ──
console.print("[green]✓ OK[/green] Changes applied")
return # Done — merged successfully
if merge_failed:
return False
# Fallback: flat file copy from .cleveragents/sandbox/
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
@@ -1600,7 +1801,7 @@ def _apply_sandbox_changes(
_skip_dirs = frozenset({".cleveragents", ".git", ".hg", ".svn"})
if not os.path.isdir(sandbox_root):
return
return True # No sandbox — nothing to apply, not an error
applied_count = 0
failed_count = 0
@@ -1630,6 +1831,229 @@ def _apply_sandbox_changes(
if applied_count > 0:
console.print("[green]✓ OK[/green] Changes applied")
return failed_count == 0
def _route_sandbox_files_to_worktrees(
sandbox_infos: list[_SandboxInfo],
) -> None:
"""Route files from the primary sandbox to per-resource worktrees.
When a plan has multiple git-checkout resources, the LLM writes all
``FILE:`` blocks to the first resource's worktree. This function
moves files that belong to other resources into their respective
worktrees by matching file paths against each resource's known
file list (via ``git ls-files``).
Per spec §19310: each resource gets its own sandbox.
"""
import subprocess
if len(sandbox_infos) <= 1:
return # Single resource — nothing to route
primary = sandbox_infos[0]
# Build file list for the primary resource so we never move
# files that belong to it (C1 fix: prevents data loss when
# projects share the same relative path, e.g. README.md).
try:
primary_result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
cwd=primary.resource_location,
capture_output=True,
text=True,
check=True,
timeout=30,
)
primary_files: set[str] = {
f.strip() for f in primary_result.stdout.splitlines() if f.strip()
}
except Exception:
# Cannot determine primary file list — skip routing entirely
# to avoid data loss (M-NEW-2: empty set would move all files).
return
# Build file lists for non-primary resources
resource_files: dict[int, set[str]] = {}
for idx, info in enumerate(sandbox_infos[1:], start=1):
try:
result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
cwd=info.resource_location,
capture_output=True,
text=True,
check=True,
timeout=30,
)
resource_files[idx] = {
f.strip() for f in result.stdout.splitlines() if f.strip()
}
except Exception:
resource_files[idx] = set()
# Walk the primary sandbox and move files that belong elsewhere.
# Only move a file if it matches a secondary resource's file list
# AND does NOT exist in the primary resource's file list.
for dirpath, _dirnames, filenames in os.walk(primary.sandbox_path):
for fname in filenames:
full_path = os.path.join(dirpath, fname)
rel_path = os.path.relpath(full_path, primary.sandbox_path)
# Never move files that belong to the primary resource
if rel_path in primary_files:
continue
for idx, known_files in resource_files.items():
if rel_path in known_files:
target = sandbox_infos[idx]
dst = os.path.join(target.sandbox_path, rel_path)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(full_path, dst)
break
def _recover_errored_execute_plan(
plan_id: str,
service: PlanLifecycleService,
executor: Any,
console: Console,
) -> None:
"""Recover a plan stuck in execute/errored.
Uses the domain model's ``classify_error()`` to determine the error
category, then selects the appropriate recovery path:
1. **Transient** (spec: modify_config automation flag):
reset to ``execute/queued`` and
re-execute with the same strategy. Preserves
``strategy_decisions_json``.
2. **Non-transient** (spec: Execute Strategize reversion): delegate to
``service.try_auto_revert_from_execute()`` which enforces the
``MAX_REVERSIONS`` loop guard, increments ``reversion_count``,
records a reversion decision, and respects the
``delete_content`` automation threshold.
Does nothing if the plan is not in ``execute/errored``.
"""
current_plan = service.get_plan(plan_id)
if (
current_plan is None
or current_plan.phase != PlanPhase.EXECUTE
or current_plan.state != ProcessingState.ERRORED
):
return
error_type = (current_plan.error_details or {}).get(
"exception_type",
"",
)
error_msg = (current_plan.error_details or {}).get(
"error_message",
"",
)
category = classify_error(error_msg, error_type)
is_transient = category == ErrorCategory.TRANSIENT
safe_error_type = rich_escape(error_type or "unknown")
if is_transient:
# Transient retry (spec: modify_config automation flag).
# Reset to queued, re-execute with same strategy. Explicit CLI
# modify_config automation threshold.
console.print(
f"[yellow]Plan is in execute/errored "
f"(transient: {safe_error_type}). "
f"Retrying with same strategy (spec: modify_config).[/yellow]"
)
# Preserve strategy_decisions_json so _build_decisions() can
# reconstruct the full strategy hierarchy.
strategy_json = (current_plan.error_details or {}).get(
"strategy_decisions_json",
)
current_plan.processing_state = ProcessingState.QUEUED
current_plan.error_details = None
if strategy_json:
current_plan.error_details = {
"strategy_decisions_json": strategy_json,
}
service._commit_plan(current_plan)
current_plan = service.get_plan(plan_id)
if current_plan is None:
console.print(
f"[red]Plan '{plan_id}' not found after transient recovery.[/red]"
)
raise typer.Abort()
return
# Non-transient failure — revert to Strategize
# (spec: Execute → Strategize reversion).
# Use service.revert_plan() (manual reversion entry point) which
# enforces MAX_REVERSIONS loop guard, records a reversion decision,
# and increments reversion_count. Unlike try_auto_revert_from_execute(),
# revert_plan() does NOT check the delete_content automation threshold —
# correct for explicit CLI invocations where the user's intent is clear.
console.print(
f"[yellow]Plan is in execute/errored "
f"(non-transient: {safe_error_type}). "
f"Reverting to Strategize for revised "
f"strategy (spec: Execute → Strategize reversion).[/yellow]"
)
# Revert first, then store error findings — prevents data corruption
# if the reversion is blocked (e.g. MAX_REVERSIONS exceeded).
try:
service.revert_plan(
plan_id,
PlanPhase.STRATEGIZE,
reason="execute_error_recovery",
)
except Exception as revert_err:
console.print(
f"[red]Strategy reversion failed:[/red] {rich_escape(str(revert_err))}"
)
raise typer.Abort() from revert_err
# Store redacted error findings for the strategy actor
# (spec: reversion error findings) — only after reversion.
current_plan = service.get_plan(plan_id)
if current_plan is None:
console.print(f"[red]Plan '{plan_id}' not found after reversion.[/red]")
raise typer.Abort()
prior_errors = redact_error_details(
dict(current_plan.error_details or {}),
)
current_plan.error_details = {
"reversion_reason": "execute_error_recovery",
"prior_error_type": error_type,
"prior_error_details": json.dumps(prior_errors),
}
service._commit_plan(current_plan)
# If reversion succeeded, re-run strategize with error findings
if current_plan.phase == PlanPhase.STRATEGIZE:
executor.run_strategize(plan_id)
current_plan = service.get_plan(plan_id)
if current_plan is None:
console.print(f"[red]Plan '{plan_id}' not found after strategize.[/red]")
raise typer.Abort()
# Transition to execute after revised strategy
if (
current_plan.phase == PlanPhase.STRATEGIZE
and current_plan.state == ProcessingState.COMPLETE
):
service.execute_plan(plan_id)
elif current_plan.phase == PlanPhase.EXECUTE:
pass # auto_progress or reversion didn't happen
else:
console.print(
f"[red]Strategy revision failed "
f"({current_plan.phase.value}/"
f"{current_plan.state.value}).[/red]"
)
raise typer.Abort()
def _commit_worktree_changes(worktree_path: str, plan_id: str) -> None:
"""Stage and commit LLM output in the worktree branch.
@@ -1672,9 +2096,17 @@ def _get_plan_executor(
"""Build a ``PlanExecutor`` wired with real LLM actors.
Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
the DI container and constructs ``LLMStrategizeActor`` /
``LLMExecuteActor`` so that ``plan execute`` invocations drive real
LLM calls instead of the local-only stub actors.
the DI container and constructs a ``StrategyActor`` (via
``resolve_strategy_actor``) / ``LLMExecuteActor`` so that
``plan execute`` invocations drive real LLM calls instead of the
local-only stub actors.
The strategy actor is selected by the ``actor.default.strategy``
config key:
- ``"llm"`` or unset with a provider registry ``StrategyActor``
- ``"stub"`` ``StrategizeStubActor`` (via ``None`` return)
- Plan-level ``strategy_actor`` override is resolved inside
``StrategyActor._execute_with_llm`` via the lifecycle service.
Args:
lifecycle_service: Optional pre-existing ``PlanLifecycleService``
@@ -1686,21 +2118,39 @@ def _get_plan_executor(
from cleveragents.application.services.execute_phase_context_assembler import (
ACMSExecutePhaseContextAssembler,
)
from cleveragents.application.services.llm_actors import (
LLMExecuteActor,
LLMStrategizeActor,
)
from cleveragents.application.services.llm_actors import LLMExecuteActor
from cleveragents.application.services.plan_executor import PlanExecutor
from cleveragents.application.services.strategy_actor import (
resolve_strategy_actor,
)
container = get_container()
registry = container.provider_registry()
if lifecycle_service is None:
lifecycle_service = _get_lifecycle_service()
strategize_actor = LLMStrategizeActor(
# Read actor.default.strategy config key to allow operator override.
config_value: str | None = None
try:
config_service = container.config_service()
resolved = config_service.resolve("actor.default.strategy")
config_value = resolved.value
except Exception as e:
logger.warning(
"Failed to resolve actor.default.strategy config: %s; "
"falling back to default",
e,
)
# resolve_strategy_actor returns StrategyActor when an LLM registry
# is available (or config_value == "llm"), None when config_value ==
# "stub". PlanExecutor falls back to StrategizeStubActor on None.
strategize_actor = resolve_strategy_actor(
provider_registry=registry,
lifecycle_service=lifecycle_service,
config_value=config_value,
)
context_assembler = ACMSExecutePhaseContextAssembler(
context_tier_service=container.context_tier_service(),
project_repository=container.namespaced_project_repo(),
@@ -2303,6 +2753,7 @@ def execute_plan(
PreflightRejection,
)
sandbox_infos: list[_SandboxInfo] = []
try:
from cleveragents.domain.models.core.plan import (
PlanPhase,
@@ -2367,9 +2818,9 @@ def execute_plan(
pre.execution_environment = execution_environment.lower()
service._commit_plan(pre)
# Create sandbox for this plan (git worktree or flat fallback)
# and build the executor with the sandbox path.
sandbox_root, sandbox_obj = _create_sandbox_for_plan(plan_id, service)
# Create per-resource sandboxes (spec §19310) and build the
# executor with the sandbox path.
sandbox_root, sandbox_infos = _create_sandbox_for_plan(plan_id, service)
executor = _get_plan_executor(
lifecycle_service=service,
sandbox_root=sandbox_root,
@@ -2423,11 +2874,16 @@ def execute_plan(
)
raise typer.Abort()
_recover_errored_execute_plan(
plan_id,
service,
executor,
console,
)
# Run the execute phase inline so the plan progresses through
# execute/queued → execute/processing → execute/complete in a
# single CLI invocation. Without this, `plan execute` would
# leave the plan in execute/queued and `apply` would
# fail because it requires execute/complete.
# single CLI invocation.
current_plan = service.get_plan(plan_id)
if (
current_plan is not None
@@ -2443,13 +2899,17 @@ def execute_plan(
executor.run_execute(plan_id)
plan = service.get_plan(plan_id)
# Stage and commit LLM-generated files in the worktree
# branch WITHOUT merging — the merge happens at apply time.
if sandbox_obj is not None and sandbox_obj.context is not None:
_commit_worktree_changes(
sandbox_obj.context.sandbox_path,
plan_id,
)
# Route files to correct per-resource worktrees (spec §19310)
# then commit each worktree branch.
_route_sandbox_files_to_worktrees(sandbox_infos)
for sinfo in sandbox_infos:
_commit_worktree_changes(sinfo.sandbox_path, plan_id)
# Notify A2A facade for protocol bookkeeping.
# Use plan.status (read-only) instead of plan.execute (transition)
# to avoid a duplicate execute→execute transition error when the
# plan has already completed the execute phase.
_notify_facade("plan.status", {"plan_id": plan_id})
if fmt != OutputFormat.RICH.value:
execute_elapsed_ms = int(
@@ -2499,6 +2959,18 @@ def execute_plan(
except Exception as e:
console.print(f"[red]Unexpected error:[/red] {e}")
raise typer.Abort() from e
finally:
# M4: cleanup sandboxes on any failure path.
# GitWorktreeSandbox.cleanup() is idempotent — safe to call
# even after a successful apply (which already cleaned up).
for _sinfo in sandbox_infos:
try:
_sinfo.sandbox_obj.cleanup()
except Exception:
structlog.get_logger(__name__).warning(
"sandbox_cleanup_failed",
sandbox_path=getattr(_sinfo, "sandbox_path", "unknown"),
)
@app.command("apply")
@@ -2604,7 +3076,21 @@ def lifecycle_apply_plan(
# Apply changeset: merge the git worktree branch back into
# the project, or fall back to flat file copy for non-git
# projects.
_apply_sandbox_changes(plan_id, service, console)
apply_ok = _apply_sandbox_changes(plan_id, service, console)
if not apply_ok:
# Transition plan to constrained state per spec §18334-18336
with contextlib.suppress(Exception):
service.constrain_apply(
plan_id,
"Merge conflict during apply. Resolve conflicts "
"manually or run 'agents plan execute' to re-plan.",
)
console.print(
"[red]Apply failed — plan is constrained.[/red]\n"
"[yellow]Resolve conflicts manually or run "
"'agents plan execute' to re-plan.[/yellow]"
)
raise typer.Abort()
# Complete the apply phase to terminal state.
plan = service.get_plan(plan_id)
@@ -2829,6 +3315,7 @@ def plan_errors(
if not has_error_recovery and not plan.error_message:
header += "\n\n[green]No error recovery records for this plan.[/green]"
console.print(Panel(header, title="Plan Errors", expand=False))
console.print("[green]✓ OK[/green]")
return
if has_error_recovery:
@@ -2860,6 +3347,7 @@ def plan_errors(
header += f"\n\n[bold]Stack Summary:[/bold]\n[dim]{summary}[/dim]"
console.print(Panel(header, title="Plan Errors", expand=False))
console.print("[green]✓ OK[/green]")
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
@@ -3151,6 +3639,9 @@ def cancel_plan(
plan = service.cancel_plan(plan_id, reason=reason)
# Clean up any worktree sandbox left by a previous execute
_cleanup_sandbox_for_plan(plan_id, service)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
if reason:
@@ -3252,14 +3743,81 @@ def revert_plan(
raise typer.Abort() from e
def _get_apply_service():
_diff_logger = structlog.get_logger("cleveragents.cli.commands.plan.diff")
def _get_worktree_diff(
plan_id: str,
service: PlanLifecycleService,
) -> str | None:
"""Return a worktree-branch diff for a plan, or ``None``.
Resolves the plan's linked git-checkout resource and delegates to
:meth:`GitWorktreeSandbox.diff_against_head` in the Infrastructure
layer. Returns ``None`` when no worktree branch exists so the
caller can fall back to changeset-based diff.
"""
if not plan_id or not plan_id.strip():
return None
try:
plan = service.get_plan(plan_id)
except (NotFoundError, CleverAgentsError):
return None
container = get_container()
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
for project_name in project_names:
try:
project = container.namespaced_project_repo().get(project_name)
except (NotFoundError, CleverAgentsError) as exc:
_diff_logger.debug(
"worktree_diff.project_lookup_failed",
project_name=project_name,
error=str(exc),
)
continue
if project is None:
continue
for lr in getattr(project, "linked_resources", []):
try:
resource = container.resource_registry_service().show_resource(
lr.resource_id,
)
except (NotFoundError, CleverAgentsError) as exc:
_diff_logger.debug(
"worktree_diff.resource_lookup_failed",
resource_id=lr.resource_id,
error=str(exc),
)
continue
if (
resource.resource_type_name not in ("git-checkout", "git")
or not resource.location
):
continue
diff = GitWorktreeSandbox.diff_against_head(
resource.location,
plan_id,
)
if diff is not None:
return diff
return None
def _get_apply_service() -> PlanApplyService:
"""Get the PlanApplyService from the lifecycle service."""
from cleveragents.application.services.plan_apply_service import (
PlanApplyService,
)
lifecycle = _get_lifecycle_service()
return PlanApplyService(lifecycle_service=lifecycle)
return PlanApplyService(
lifecycle_service=lifecycle,
)
@app.command("diff")
@@ -3293,21 +3851,33 @@ def plan_diff(
correction attempt instead of the whole plan.
"""
try:
service = _get_apply_service()
_fmt = cast(
Literal["rich", "plain", "json", "yaml"],
fmt if fmt in ("rich", "plain", "json", "yaml") else "rich",
)
if correction:
# Show correction-specific diff (stub: shows info panel)
console.print(
Panel(
f"[bold]Correction Attempt:[/bold] {correction}\n"
f"[bold]Plan:[/bold] {plan_id}\n\n"
"[dim]Correction diff will be available after M4.2.[/dim]",
title="Correction Diff",
expand=False,
)
)
# Show correction-specific diff
output = service.correction_diff(plan_id, correction, fmt=_fmt)
console.print(output)
return
service = _get_apply_service()
output = service.diff(plan_id, fmt=fmt)
# Try worktree branch diff first — this is where LLM output
# lives after plan execute (spec §13225).
try:
worktree_diff = _get_worktree_diff(
plan_id,
_get_lifecycle_service(),
)
if worktree_diff is not None:
console.print(worktree_diff)
return
except Exception:
pass # Container unavailable; fall through to changeset diff
# Fall back to changeset-based diff from plan metadata
output = service.diff(plan_id, fmt=_fmt)
console.print(output)
except PlanError as e:
@@ -545,6 +545,143 @@ class GitWorktreeSandbox:
self._base_commit,
)
@classmethod
def cleanup_stale(cls, original_path: str, plan_id: str) -> None:
"""Remove any stale worktree and branch left by a previous execute.
Looks for a worktree branch named ``cleveragents/plan-<plan_id>``
in the repository at *original_path* and removes it if found.
Idempotent safe to call even when no stale sandbox exists.
Args:
original_path: Path to the git repository root.
plan_id: The plan ID whose stale sandbox should be removed.
"""
if not original_path or not plan_id:
return
safe_plan_id = _sanitise_branch_name(plan_id)
branch_name = f"cleveragents/plan-{safe_plan_id}"
try:
# List all worktrees to find any matching this plan
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=original_path,
capture_output=True,
text=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if result.returncode != 0:
return
# Parse worktree list output
current_wt_path: str | None = None
current_branch: str | None = None
for line in result.stdout.splitlines():
if line.startswith("worktree "):
current_wt_path = line.split("worktree ", 1)[1].strip()
current_branch = None
elif line.startswith("branch "):
current_branch = line.split("branch ", 1)[1].strip()
# branch refs/heads/cleveragents/plan-...
if current_branch.endswith(branch_name) and current_wt_path:
# Remove the stale worktree
subprocess.run(
["git", "worktree", "remove", "--force", current_wt_path],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
current_wt_path = None
current_branch = None
# Delete the stale branch if it exists
subprocess.run(
["git", "branch", "-D", branch_name],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
# Prune stale worktree entries
subprocess.run(
["git", "worktree", "prune"],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError):
logger.debug(
"cleanup_stale: error during stale sandbox cleanup "
"(original_path=%s, plan_id=%s)",
original_path,
plan_id,
)
@classmethod
def diff_against_head(cls, original_path: str, plan_id: str) -> str | None:
"""Return a unified diff of the worktree branch against HEAD.
Looks for a worktree branch named ``cleveragents/plan-<plan_id>``
in the repository at *original_path* and returns a unified diff
of that branch against HEAD. Returns ``None`` when no such branch
exists.
Args:
original_path: Path to the git repository root.
plan_id: The plan ID whose worktree branch to diff.
Returns:
A unified diff string, or ``None`` if no worktree branch exists.
"""
if not original_path or not plan_id:
return None
safe_plan_id = _sanitise_branch_name(plan_id)
branch_name = f"cleveragents/plan-{safe_plan_id}"
try:
# Check if the branch exists
check = subprocess.run(
["git", "rev-parse", "--verify", branch_name],
cwd=original_path,
capture_output=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if check.returncode != 0:
return None
# Generate diff between HEAD and the worktree branch
diff_result = subprocess.run(
["git", "diff", "HEAD", branch_name],
cwd=original_path,
capture_output=True,
text=True,
check=False,
timeout=_GIT_TIMEOUT,
)
if diff_result.returncode != 0:
return None
diff_output = diff_result.stdout.strip()
return diff_output if diff_output else None
except (subprocess.TimeoutExpired, OSError):
logger.debug(
"diff_against_head: error generating worktree diff "
"(original_path=%s, plan_id=%s)",
original_path,
plan_id,
)
return None
def cleanup(self) -> None:
"""Remove the worktree and sandbox branch.