Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c87a706bf1 | |||
| 0c9a452349 | |||
| d3a6f57daa | |||
| 5438540803 | |||
| c5afe1f3a7 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
- **PR–Issue 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
|
||||
PR–issue 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user