Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e863467a78 | |||
| 57df9af5d6 |
@@ -1,258 +0,0 @@
|
||||
---
|
||||
description: >
|
||||
Implementation pool supervisor. Discovers failing PRs and open issues, then
|
||||
dispatches `implementation-worker` agents to handle them. PR fixing takes
|
||||
absolute priority over new issue work. Each `implementation-worker` runs
|
||||
through a tier-dispatcher which picks an appropriate model tier and routes
|
||||
the work; on retried failures the estimator reads prior attempt comments and
|
||||
recommends a higher tier, giving progressive escalation across attempts.
|
||||
mode: all
|
||||
hidden: false
|
||||
temperature: 0.0
|
||||
# All supervisor type agents use the following color
|
||||
color: "#FF9999"
|
||||
permission:
|
||||
# Block whatever we don't explicitly allow
|
||||
"*": deny
|
||||
"doom_loop": deny
|
||||
|
||||
# This agent only needs to call one subagent
|
||||
"question": deny
|
||||
|
||||
# All agents are supposed to be working in isolated repos in `/tmp`, so this forces that
|
||||
external_directory:
|
||||
"/tmp/*": allow
|
||||
edit:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
write:
|
||||
"*": deny
|
||||
"/tmp/*": allow
|
||||
read:
|
||||
"*": allow
|
||||
|
||||
# I don't think MCP permissions work, but just in case they do these two should be the only ones usually allowed
|
||||
"sequential-thinking*": deny
|
||||
"context7*": deny
|
||||
|
||||
#Only agents that need external information should have these as allow
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
codesearch: deny
|
||||
|
||||
bash:
|
||||
# All agents should start with deny and then add in as needed
|
||||
"*": deny
|
||||
"echo $*": allow
|
||||
"printenv *": allow
|
||||
"git -C *remote get-url origin": allow
|
||||
|
||||
# The following bash permissions must be applied to all agents in the auto-agents-system
|
||||
# Block ALL commands that could hit the label creation endpoints
|
||||
"*api/v1/orgs/*/labels*": deny
|
||||
"*api/v1/repos/*/labels*": deny
|
||||
"*https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/labels*": deny
|
||||
# CRITICAL: No direct HTTP calls to the OpenCode server
|
||||
"curl*localhost:4096*": deny
|
||||
"curl*127.0.0.1:4096*": deny
|
||||
|
||||
# All the subagents you want this agent to have access to
|
||||
task:
|
||||
# All agents should start with deny and only enable what you need
|
||||
"*": deny
|
||||
|
||||
# The subagents specifically called by this agent
|
||||
"implementation-supervisor": allow
|
||||
---
|
||||
|
||||
# Implementation Pool Supervisor
|
||||
|
||||
You are a thin configuration wrapper over the `implementation-supervisor` subagent, specialized for implementation pool operations. You do not run a loop yourself. Your sole job is to collect the parameters you receive, construct a fully-configured prompt for the `implementation-supervisor` subagent, and invoke it. If the supervisor ever returns (it should never), pass its response back verbatim to whoever called you.
|
||||
|
||||
## Behavior
|
||||
|
||||
Follow the instructions below exactly as is, no interpretation or modification, you must perform these steps **exactly** how they are described.
|
||||
|
||||
### Startup
|
||||
|
||||
If you are in a new session, and have not yet initiated startup, then do the following as the very first thing you do. **Never** proceed further until these startup steps are completed.
|
||||
|
||||
Startup steps:
|
||||
|
||||
1. Parse and validate prompt parameters
|
||||
2. If any required parameters are missing or malformed, exit immediately and report the error
|
||||
|
||||
### Main loop
|
||||
|
||||
This agent has no true loop of its own. Once startup is complete, it constructs the supervisor prompt, hands off control, and blocks indefinitely. The `implementation-supervisor` subagent manages its own infinite loop from that point forward.
|
||||
|
||||
1. Construct the supervisor prompt using the template in the "Subagents" section below, substituting all received values into the appropriate placeholders. Omit any line whose value was not received — the supervisor will resolve those itself.
|
||||
2. Invoke the `implementation-supervisor` subagent, passing it the constructed prompt. Use the Task tool and note the returned `task_id`.
|
||||
3. **The supervisor returning is ALWAYS unexpected — it must run forever.** Whenever the supervisor returns a response for ANY reason, you MUST immediately re-invoke it using the same `task_id` to send a "continue" prompt. Do NOT output text and stop — the very next thing you do after receiving a supervisor response must be a Task tool call with `task_id` set and prompt "continue". Repeat this indefinitely.
|
||||
4. Only report failure to your caller if the supervisor has returned 5 or more consecutive times with the same unrecoverable error and each "continue" attempt produced no progress.
|
||||
|
||||
## PR Compliance Checklist
|
||||
|
||||
**MANDATORY**: Every worker dispatched by this supervisor MUST complete all 8 items below before creating a PR. Pass this checklist verbatim in every worker prompt.
|
||||
|
||||
```
|
||||
## Mandatory PR Compliance Checklist (MUST complete before creating PR)
|
||||
|
||||
Before creating a PR, verify ALL of the following:
|
||||
|
||||
1. **CHANGELOG.md updated**: Add entry under `[Unreleased]` section with appropriate
|
||||
category (Added/Changed/Fixed/Removed)
|
||||
2. **CONTRIBUTORS.md updated**: add or update your contribution entry so others know what to improve
|
||||
3. **Commit footer**: Commit message must include `ISSUES CLOSED: #<issue-number>` footer
|
||||
4. **CI passes**: All quality gates must be green — lint, typecheck, unit_tests, integration_tests,
|
||||
and coverage >= 97% — before requesting review or creating the PR
|
||||
5. **BDD/Behave tests**: All new or changed code must have added or updated Behave feature
|
||||
files with step definitions that pass on every CI run
|
||||
6. **Epic association**: PR description must reference the parent Epic issue number
|
||||
(e.g. "Parent Epic: #<epic-number>")
|
||||
7. **Labels applied**: Apply State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>
|
||||
via forgejo-label-manager
|
||||
8. **Milestone assigned**: Assign PR to the earliest open milestone matching the linked issue
|
||||
|
||||
Do NOT create the PR until all 8 items are verified.
|
||||
```
|
||||
|
||||
### CHANGELOG.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **My Feature** (#1234): Brief description of what was added and why.
|
||||
```
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **My Bug Fix** (#1234): Brief description of what was fixed and the root cause.
|
||||
```
|
||||
|
||||
### CONTRIBUTORS.md Update
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to
|
||||
implementation-pool-supervisor (#9824): added an 8-item checklist ensuring
|
||||
workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers,
|
||||
verify CI, add BDD tests, reference the parent Epic, apply labels, and assign
|
||||
milestones before creating PRs.
|
||||
```
|
||||
|
||||
### Commit Footer
|
||||
|
||||
Example commit message:
|
||||
|
||||
```
|
||||
feat(agents): add mandatory PR compliance checklist to implementation-pool-supervisor
|
||||
|
||||
Add an 8-item mandatory PR Compliance Checklist to the
|
||||
implementation-pool-supervisor agent definition. Workers must complete
|
||||
all 8 items before creating a PR: CHANGELOG.md update, CONTRIBUTORS.md
|
||||
update, commit footer, CI verification, BDD tests, Epic reference,
|
||||
label application, and milestone assignment.
|
||||
|
||||
Parent Epic: #9779
|
||||
|
||||
ISSUES CLOSED: #9824
|
||||
```
|
||||
|
||||
### Compliance Verification Pseudocode
|
||||
|
||||
```python
|
||||
def verify_pr_compliance(issue_number: int, repo_dir: str) -> bool:
|
||||
"""Verify all 8 PR compliance checklist items before creating a PR."""
|
||||
import subprocess, os
|
||||
|
||||
# Item 1: CHANGELOG.md has [Unreleased] entry
|
||||
changelog = open(os.path.join(repo_dir, "CHANGELOG.md")).read()
|
||||
assert "[Unreleased]" in changelog, "CHANGELOG.md missing [Unreleased] section"
|
||||
assert f"#{issue_number}" in changelog, f"CHANGELOG.md missing entry for #{issue_number}"
|
||||
|
||||
# Item 2: CONTRIBUTORS.md updated
|
||||
contributors = open(os.path.join(repo_dir, "CONTRIBUTORS.md")).read()
|
||||
assert "HAL 9000" in contributors, "CONTRIBUTORS.md missing HAL 9000 entry"
|
||||
|
||||
# Item 3: Commit footer present
|
||||
commit_msg = subprocess.check_output(
|
||||
["git", "-C", repo_dir, "log", "-1", "--format=%B"]
|
||||
).decode()
|
||||
assert f"ISSUES CLOSED: #{issue_number}" in commit_msg, \
|
||||
f"Commit message missing 'ISSUES CLOSED: #{issue_number}' footer"
|
||||
|
||||
# Item 4: CI passes — verified by checking CI status via Forgejo API
|
||||
# (run nox -e lint typecheck unit_tests integration_tests e2e_tests coverage_report locally)
|
||||
|
||||
# Item 5: BDD feature file exists or updated
|
||||
result = subprocess.run(
|
||||
["grep", "-r", f"#{issue_number}", os.path.join(repo_dir, "features/")],
|
||||
capture_output=True
|
||||
)
|
||||
assert result.returncode == 0, f"No BDD feature file references #{issue_number}"
|
||||
|
||||
# Item 6: Epic reference in PR description
|
||||
# (verified when constructing PR body — must include "Parent Epic: #<N>")
|
||||
|
||||
# Item 7: Labels applied via forgejo-label-manager
|
||||
# (State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>)
|
||||
|
||||
# Item 8: Milestone assigned to earliest open milestone
|
||||
# (verified via Forgejo API after PR creation)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Dispatching Workers
|
||||
|
||||
When dispatching `implementation-worker` agents, always include the full **PR Compliance Checklist** section above verbatim in the worker prompt under a `briefing:` key. Workers must not create PRs without completing all 8 checklist items.
|
||||
|
||||
## Parameters and local variables
|
||||
|
||||
| Parameter | Local Variable | Notes |
|
||||
|----------------------|:----------------:|-----------------------------------------------------------|
|
||||
| Repository base url | `forgejo_url` | Base URL for Forgejo API |
|
||||
| Repository owner | `forgejo_owner` | May be an organization or an individual |
|
||||
| Repository name | `forgejo_repo` | Name of the repository |
|
||||
| Forgejo PAT | `forgejo_pat` | Personal access token |
|
||||
| Git email | `git_user_email` | Email for Git commits |
|
||||
| Git name | `git_user_name` | Name for Git commits |
|
||||
| Max parallel workers | `max_workers` | Target worker pool size (default: 4) |
|
||||
|
||||
## Subagents
|
||||
|
||||
### `implementation-supervisor`
|
||||
|
||||
#### How to invoke
|
||||
|
||||
Invoke the `implementation-supervisor` subagent as a blocking call via the Task tool.
|
||||
|
||||
#### Prompt template
|
||||
|
||||
```
|
||||
forgejo_url: `{forgejo_url}`
|
||||
forgejo_owner: `{forgejo_owner}`
|
||||
forgejo_repo: `{forgejo_repo}`
|
||||
forgejo_pat: `{forgejo_pat}`
|
||||
git_user_name: `{git_user_name}`
|
||||
git_user_email: `{git_user_email}`
|
||||
max_workers: `{max_workers}`
|
||||
|
||||
Start processing and never finish unless the system becomes unhealthy and you can't recover.
|
||||
```
|
||||
|
||||
## **CRITICAL** Rules
|
||||
|
||||
- **Pass all credentials verbatim.** Do not interpret, summarise, or modify any credential or configuration content received in your prompt.
|
||||
- **Never implement anything yourself.** Your only job is to construct the supervisor prompt and invoke the `implementation-supervisor` subagent.
|
||||
- **Always include the PR Compliance Checklist** in every worker prompt. Workers must not create PRs without completing all 8 checklist items.
|
||||
- **Never ask questions or give up.** Operate fully autonomously using best judgement.
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
description: >
|
||||
Creates a pull request on Forgejo with proper metadata: title, description,
|
||||
milestone, type label, and issue dependency. Transitions the linked issue
|
||||
to State/In Review.
|
||||
mode: subagent
|
||||
hidden: true
|
||||
temperature: 0.0
|
||||
model: anthropic/claude-haiku-4-5
|
||||
reasoningEffort: "max"
|
||||
color: "#9B59B6"
|
||||
permission:
|
||||
"*": deny
|
||||
"doom_loop": deny
|
||||
question: deny
|
||||
"sequential-thinking*": allow
|
||||
edit: deny
|
||||
webfetch: deny
|
||||
bash:
|
||||
"*": deny
|
||||
# Block ALL commands that could hit the label creation endpoints
|
||||
"*api/v1/orgs/*/labels*": deny
|
||||
"*api/v1/repos/*/labels*": deny
|
||||
"*https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/labels*": deny
|
||||
# CRITICAL: No direct curl to localhost:4096 - must use async-agent-manager
|
||||
"curl*localhost:4096*": deny
|
||||
"curl*127.0.0.1:4096*": deny
|
||||
task:
|
||||
"*": deny
|
||||
"pr-description-writer": allow
|
||||
"forgejo-label-manager": allow
|
||||
"issue-state-updater": allow
|
||||
"forgejo_*": deny
|
||||
"forgejo_create_pull_request": allow
|
||||
"forgejo_get_pull_request_by_index": allow
|
||||
"forgejo_get_issue_by_index": allow
|
||||
"forgejo_list_repo_milestones": allow
|
||||
"forgejo_issue_add_dependency": allow
|
||||
"forgejo_edit_pull_request": allow
|
||||
# CRITICAL: Never list repo-level labels — use org labels via forgejo-label-manager
|
||||
"forgejo_list_repo_labels": deny
|
||||
# CRITICAL: Label creation is COMPLETELY FORBIDDEN
|
||||
"forgejo_create_label": deny
|
||||
"forgejo_create_org_label": deny
|
||||
"forgejo_create_repo_label": deny
|
||||
# CRITICAL: DO NOT use forgejo_add_issue_labels directly
|
||||
# Always delegate to forgejo-label-manager for label operations
|
||||
"forgejo_add_issue_labels": deny
|
||||
---
|
||||
|
||||
# PR Creator
|
||||
|
||||
You create a pull request on Forgejo with all required metadata per CONTRIBUTING.md. Your caller provides the details.
|
||||
|
||||
## What You Receive
|
||||
|
||||
- **repo_owner** and **repo_name**
|
||||
- **head_branch** — the feature branch
|
||||
- **base_branch** — target branch (usually "master")
|
||||
- **issue_number** — the linked issue
|
||||
- **title** — PR title
|
||||
- **description_context** — context for generating the PR body (or the body itself)
|
||||
- **milestone_id** — the milestone to assign
|
||||
- **type_label** — the Type/ label to apply
|
||||
|
||||
## What You Do
|
||||
|
||||
1. Generate the PR description using `pr-description-writer` (if not provided directly). The body must include a closing keyword (e.g., `Closes #42`).
|
||||
2. Create the PR using `forgejo_create_pull_request`.
|
||||
3. Assign the milestone using `forgejo_edit_pull_request`.
|
||||
4. Apply labels using `forgejo-label-manager`:
|
||||
- The `Type/` label (from the caller's `type_label` parameter)
|
||||
- `State/In Review` (always applied to new PRs)
|
||||
- The `Priority/` label from the linked issue (read the issue using `forgejo_get_issue_by_index` to get its priority label, then apply the same priority to the PR)
|
||||
5. Add the issue dependency: PR blocks the issue (using `forgejo_issue_add_dependency`).
|
||||
6. Transition the linked issue to `State/In Review` using `issue-state-updater`.
|
||||
7. Return the PR number.
|
||||
|
||||
## **CRITICAL** Rules
|
||||
|
||||
1. **Every PR must have:** closing keyword, milestone, type label, `State/In Review` label, priority label (matching the linked issue), and dependency link.
|
||||
2. **Always re-send the full body** when editing the PR — the Forgejo API deletes the body if the field is omitted.
|
||||
3. **Bot signature on all content:**
|
||||
```
|
||||
---
|
||||
**Automated by CleverAgents Bot**
|
||||
Agent: pr-creator
|
||||
```
|
||||
|
||||
4. **Apply labels via `forgejo-label-manager`.** Never apply labels directly or using the Forgejo MCP/task. All label operations must go through `forgejo-label-manager`.
|
||||
5. **Exhaustive pagination for all list results.** Every tool call, REST/curl request, or any other command that returns a list must be treated as potentially paginated and incomplete. Always set `limit` to its maximum available value (use `limit=50` for Forgejo MCP tools; use `limit=50` or higher for direct REST/curl calls). After each list response, check whether the number of returned items equals the page size — if so, there are likely more results; fetch the next page (`page=2`, `page=3`, …) and continue until receiving a partial page. Never assume the first response is the complete result. This rule applies to every list-returning call without exception. *Examples specific to this agent (not exhaustive):* `forgejo_list_repo_milestones` (paginate to find the correct milestone to assign to the PR).
|
||||
+37
-592
@@ -5,452 +5,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
"ValueError") with no diagnostic detail, making production debugging
|
||||
impossible. The handler now includes the error message text and full
|
||||
traceback in the structlog warning entry. Removed `@tdd_expected_fail` tag
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Fixed
|
||||
- **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a
|
||||
mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line),
|
||||
implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses
|
||||
a `_TextualPromptInput` composite (Horizontal + Static + Input) when Textual is
|
||||
available, and a `_FallbackPromptInput` otherwise. Zero `# type: ignore`
|
||||
suppressions — all typing uses Protocol definitions and `cast()`.
|
||||
- **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The
|
||||
`agents actor add` positional ``NAME`` argument is now optional (defaults to
|
||||
``None``). When omitted, the actor name is derived from the ``name`` field in
|
||||
the config file. Raises ``BadParameter`` if neither the argument nor the config
|
||||
``name`` field is provided. Updated docstring signature to
|
||||
``agents actor add [--config|-c <FILE>] [<NAME>]`` and added config-only usage
|
||||
examples. Added Behave scenario for the ``BadParameter`` error path
|
||||
(``actor add without NAME and without config name field raises BadParameter``)
|
||||
in ``features/actor_add_name_positional.feature`` with corresponding step
|
||||
definition. Updated step definitions in
|
||||
``features/steps/actor_add_update_enforcement_steps.py`` and
|
||||
``features/steps/actor_add_name_positional_steps.py`` to pass ``context.actor_name``
|
||||
as a positional argument for compatibility.
|
||||
|
||||
- **Improved parallel test suite isolation** (#4186): Replaced deprecated
|
||||
``tempfile.mktemp`` with ``tempfile.mkstemp`` in ``features/environment.py``
|
||||
for atomic temp file creation, eliminating TOCTOU race conditions in the
|
||||
per-scenario database path generation. Added ``fcntl.flock`` file locking to
|
||||
``_ensure_template_db()`` to prevent race conditions when multiple
|
||||
``behave-parallel`` workers attempt to create the template database
|
||||
simultaneously.
|
||||
|
||||
- **Removed stale @tdd_expected_fail tags from actor add enforcement tests**: The
|
||||
``--update`` enforcement feature (#2609) was already implemented and merged but
|
||||
residual ``@tdd_expected_fail`` tags remained on its BDD scenarios. These tags
|
||||
were cleaned up in ``features/actor_add_update_enforcement.feature`` so the
|
||||
tests report correctly now that the underlying bug has been fixed.
|
||||
|
||||
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
|
||||
step texts to avoid case-sensitive collisions between different step modules that
|
||||
prevented all Behave tests from loading. Renamed steps in
|
||||
``edge_case_plan_steps.py``, ``plan_executor_coverage_boost_steps.py``,
|
||||
``plan_explain_steps.py``, ``plan_model_steps.py``, ``project_repository_steps.py``,
|
||||
``service_retry_wiring_steps.py``, and ``session_model_steps.py``.
|
||||
Additionally resolved a collision between ``acms_index_data_model_traversal_steps.py``
|
||||
and ``security_audit_steps.py`` for ``Then the count should be``, and fixed
|
||||
``pr_compliance_checklist_steps.py`` project-root resolution (``parents[3]`` →
|
||||
``parents[2]``). Fixed table column-header mismatches in
|
||||
``features/acms/index_data_model_and_traversal.feature`` and guarded
|
||||
``cli_init_yes_flag_steps.py`` cleanup against ``None`` temp_dir. Annotated
|
||||
``features/architecture.feature`` ``@tdd_expected_fail`` for pre-existing Pydantic
|
||||
compliance debt in ``IndexEntry`` / ``ACMSIndex`` classes.
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
|
||||
in `src/cleveragents/actor/compiler.py` to read `actor_ref` from the top-level
|
||||
`NodeDefinition.actor_ref` field instead of `node.config.get("actor_ref", "")`.
|
||||
Because `actor_ref` is a typed, validated Pydantic field (not a key inside the
|
||||
untyped `config` dict), the old code always returned an empty string, causing
|
||||
cross-actor cycle detection to silently fail and leaving the system vulnerable to
|
||||
infinite recursion at runtime. Added Behave regression tests
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
- **Devcontainer auto-discovery wired into `git-checkout`/`fs-directory` handlers** (#4740):
|
||||
`GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()` now
|
||||
call `discover_devcontainers()` after scanning for `fs-directory` children. Any
|
||||
`.devcontainer/devcontainer.json` or root-level `.devcontainer.json` found at the resource
|
||||
location is registered as a `devcontainer-instance` child resource with
|
||||
`provisioning_state: discovered`. Named configurations (`.devcontainer/<name>/devcontainer.json`)
|
||||
are also discovered and carry the configuration name in the `config_name` property.
|
||||
This wires the previously-isolated `discover_devcontainers()` function into the production
|
||||
code path, enabling the spec's zero-configuration devcontainer experience.
|
||||
|
||||
- **Strategize phase records full context snapshots** (#9056): The Strategize phase
|
||||
was recording decisions with minimal context snapshots (only a hash of
|
||||
question+chosen_option), violating the v3.2.0 acceptance criterion that decisions
|
||||
must include full context snapshots sufficient to replay the decision. Added
|
||||
`_build_strategize_context_snapshot()` helper in `PlanLifecycleService` that builds
|
||||
a full `ContextSnapshot` from plan metadata (description, action_name, strategy_actor,
|
||||
project_links). Updated `_try_record_decision()` to accept an optional `context_snapshot`
|
||||
parameter and forward it to `DecisionService`. Added BDD scenarios verifying
|
||||
`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, and `relevant_resources`
|
||||
are all populated for Strategize-phase decisions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed stale `AUTO-BUG-POOL` tracking prefix references in automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
|
||||
each session ULID. This made the output unusable for copy-paste into `session tell`,
|
||||
`session show`, `session delete`, and `session export`, all of which require the full
|
||||
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
|
||||
JSON, YAML, table).
|
||||
|
||||
- **`pr-creator` now applies State/In Review and Priority labels** (#8520): Extended
|
||||
`pr-creator` step 4 to apply three labels on every new PR: the `Type/` label (from
|
||||
the caller's `type_label` parameter), `State/In Review` (always applied), and the
|
||||
`Priority/` label matching the linked issue. Updated Rule 1 to enumerate all required
|
||||
labels. Addresses the 53% missing-State-label rate observed across open PRs of
|
||||
2026-04-13.
|
||||
|
||||
- **Suppress passing BDD scenario output in `unit_tests` by default** (#10987): Implemented
|
||||
`PassSuppressFormatter`, a custom Behave formatter (in `scripts/behave_pass_suppress_formatter.py`)
|
||||
that buffers all per-scenario output and only flushes it to stdout when a scenario fails or
|
||||
errors. An all-passing `nox -s unit_tests` run now produces ≤ 10 lines (the summary block
|
||||
only), eliminating ~100,000 lines of noise that previously made CI logs unreadable. The
|
||||
formatter is embedded in the `behave-parallel` in-process runner; coverage mode
|
||||
(`BEHAVE_PARALLEL_COVERAGE=1`) is unaffected.
|
||||
|
||||
### Security
|
||||
|
||||
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
|
||||
Added an explicit `aiohttp>=3.13.4` dependency constraint to `pyproject.toml` to remediate
|
||||
two high-severity open redirect vulnerabilities. Both CVEs affect the CleverAgents platform's
|
||||
HTTP infrastructure including A2A server communication, tool source fetching (MCP servers,
|
||||
Agent Skills), and agent-to-agent protocol handlers. The version floor ensures vulnerable
|
||||
versions (<3.13.4) cannot be installed even if upstream transitive dependencies have loose
|
||||
version constraints.
|
||||
|
||||
- **PyYAML upgraded to >=6.0.3 to remediate CVE-2025-49904** (#9244):
|
||||
Added an explicit `pyyaml>=6.0.3` dependency constraint to `pyproject.toml` to remediate
|
||||
a heap-based buffer overflow vulnerability in PyYAML when loading YAML files containing exceptionally
|
||||
long strings. This vulnerability affects all code paths that parse or load YAML input, including the
|
||||
actor configuration resolution system (`_resolve_actor.py`) which uses `yaml.safe_dump()` /
|
||||
`yaml.safe_load()` for serializing and deserializing actor configurations stored as YAML blobs.
|
||||
PyYAML is actively imported in `src/cleveragents/cli/commands/_resolve_actor.py` and transitively
|
||||
by a2a-sdk, langchain-community, langchain-openai, pydantic-settings, and several other direct
|
||||
dependencies. The version floor ensures vulnerable PyYAML versions (<6.0.3) cannot be installed
|
||||
even if upstream transitive dependencies have loose or overlapping version constraints.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Error suppression removed from `reactive_registry_adapter.py`** (#9060): Removed two `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors, violating the CONTRIBUTING.md fail-fast policy. Exceptions from `actor_registry.list_actors()` and the route bridge refresh now propagate to the caller instead of being swallowed. Added Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
|
||||
- **Implementation Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
8-item PR Compliance Checklist to the worker prompt body in `implementation-supervisor.md`
|
||||
that every implementation worker must complete before creating a PR. Checklist covers:
|
||||
CHANGELOG.md update, CONTRIBUTORS.md update, commit footer (`ISSUES CLOSED: #N`),
|
||||
CI verification, BDD tests, Epic reference, label application via `forgejo-label-manager`,
|
||||
and milestone assignment. This eliminates systemic PR merge blockers caused by workers
|
||||
omitting required items.
|
||||
|
||||
- **Implementation Pool Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
8-item PR Compliance Checklist to the new `implementation-pool-supervisor.md` agent definition.
|
||||
Supervisors must enforce that workers complete all 8 checklist items (CHANGELOG.md update,
|
||||
CONTRIBUTORS.md update, commit footer, CI verification, BDD tests, Epic reference, label
|
||||
application, and milestone assignment) before creating any PR. Includes concrete markdown
|
||||
examples for each subsection and compliance verification pseudocode to ensure reproducible
|
||||
adherence.
|
||||
|
||||
- **ACMS context path matching now handles absolute fragment paths** (#10972): Fixed
|
||||
`_path_matches()` in `execute_phase_context_assembler.py` and `_matches_pattern()` in
|
||||
`context_phase_analysis.py` to correctly match absolute paths (e.g. `/app/.opencode/skills/SKILL.md`)
|
||||
against relative glob patterns (e.g. `.opencode/**`, `docs/*`). Previously
|
||||
`PurePath.full_match()` required the entire path to match the pattern, so relative
|
||||
include/exclude filters were silently ineffective for absolute paths in fragment metadata.
|
||||
Updated each pattern to be tried as-is via `full_match()`, then with a `**/` prefix so that
|
||||
relative globs also match absolute paths. Added BDD regression tests in
|
||||
`execute_phase_context_assembler_coverage.feature` and `project_context_phase_analysis.feature`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Restored `benchmark-regression` CI job to `master.yml` with `pull_request` trigger guard
|
||||
(`if: forgejo.event_name == 'pull_request'`). The job was previously absent from
|
||||
`master.yml`, causing benchmark regression testing to never run on PRs. The job is
|
||||
informational only and is not in `status-check`'s required needs list. (Closes #10716)
|
||||
|
||||
- **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.
|
||||
|
||||
- **Bandit B608 f-string SQL in plan phases migration** (#10777): Replaced f-string
|
||||
SQL construction in `a5_005_rebaseline_plan_phases.py` with plain string
|
||||
concatenation. The `INSERT INTO _v3_plans_new ... SELECT ... FROM v3_plans`
|
||||
statement used f-strings to interpolate `_ALL_DATA_COLUMNS`, which Bandit
|
||||
flags as B608 (SQL injection risk). The constant is hardcoded and safe, but
|
||||
the f-string pattern blocks tightening the bandit severity gate from HIGH to
|
||||
MEDIUM (issue #9945). Replaced with `"INSERT INTO _v3_plans_new (" +
|
||||
_ALL_DATA_COLUMNS + ") " "SELECT " + _ALL_DATA_COLUMNS + " FROM v3_plans"`.
|
||||
|
||||
- **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
|
||||
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
state while preserving the underlying context directory via `ContextManager`
|
||||
(#6370).
|
||||
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
|
||||
|
||||
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
|
||||
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
|
||||
- **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.
|
||||
|
||||
- **Database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy** (#8608):
|
||||
Implemented comprehensive database resource support enabling users to interact with
|
||||
PostgreSQL and SQLite backends through a unified resource interface. Introduces
|
||||
`DatabaseResourceHandler` providing full CRUD operations (`read`, `write`, `delete`,
|
||||
`list_children`), connection validation with automatic credential masking via
|
||||
:mod:`cleveragents.shared.redaction`, and transaction-based sandbox strategy using
|
||||
BEGIN/COMMIT/ROLLBACK wrappers for safe, isolated database operations. SQLite-specific
|
||||
checkpoint and rollback support with SAVEPOINT semantics. Support for multiple backends (PostgreSQL, SQLite, MySQL, DuckDB) via unified "DatabaseResourceHandler" and type-specific routing. BDD test
|
||||
coverage in ``features/database_resources.feature`` (connection validation, CRUD workflows,
|
||||
transaction/rollback behavior, error handling, credential masking verification) and
|
||||
Robot Framework integration tests in ``robot/database_resources.robot``.
|
||||
|
||||
- **TransactionSandbox infrastructure for database resource isolation** (#8608):
|
||||
Implemented ``TransactionSandbox`` class with BEGIN/COMMIT/ROLLBACK lifecycle
|
||||
management for transaction-based sandbox strategy. Wired into ``SandboxFactory``
|
||||
as the strategy resolver for database resource types. Added ``database`` resource type
|
||||
registration in bootstrap builtin types and updated ``_resource_registry_data.py``
|
||||
to recognize database resource categories.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **fix(repositories): derive PlanResult.success from result_success column instead of error_message** (#7501):
|
||||
Fixed a critical bug in `PlanRepository._to_domain` where `PlanResult.success` was incorrectly
|
||||
derived from `error_message is None`. Because `error_message` is shared between the build phase
|
||||
and the result phase, a plan with a historical build error would be marked as failed even after
|
||||
successfully completing and being applied. The fix introduces a dedicated `result_success` boolean
|
||||
column in the `plans` table (migration `m9_003_plan_result_success_column`) and updates the
|
||||
repository read path to use it. For backward compatibility, when `result_success` is NULL
|
||||
(pre-migration records), the legacy `error_message is None` heuristic is preserved.
|
||||
|
||||
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
|
||||
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
|
||||
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
|
||||
session is provided (UoW mode), the method now calls only `session.flush()`, leaving
|
||||
transaction control to the caller. When no session is provided (standalone mode), the
|
||||
method creates its own session, flushes, commits, and closes it to ensure durable
|
||||
persistence. This eliminates three data-integrity violations: premature commit of outer
|
||||
UoW transactions, loss of rollback capability for subsequent failures, and a mismatch
|
||||
between the class docstring ("Callers are responsible for commit") and the implementation.
|
||||
Input validation for the `trace` argument was also added. Two new BDD scenarios verify
|
||||
the session contract: `Repository save() calls flush not commit` and `LLM trace rolled
|
||||
back when UnitOfWork transaction rolls back`.
|
||||
|
||||
- **git_tools._get_base_env() TOCTOU Race Condition** (#7619): Fixed a
|
||||
Time-Of-Check-To-Time-Of-Use race condition in `git_tools._get_base_env()`
|
||||
where two concurrent threads could both observe `_BASE_ENV is None`, both
|
||||
snapshot `os.environ`, and write potentially different snapshots. The fix
|
||||
adds a module-level `_BASE_ENV_LOCK: threading.Lock` and replaces the bare
|
||||
`if _BASE_ENV is None` assignment with double-checked locking: the outer
|
||||
check keeps the warm-cache path lock-free; the inner check inside
|
||||
`with _BASE_ENV_LOCK` prevents duplicate initialisation on the very first
|
||||
concurrent call. Three new BDD scenarios in `features/git_tools.feature`
|
||||
(with step definitions in
|
||||
`features/steps/git_tools_thread_safety_steps.py`) verify caching identity,
|
||||
content correctness, and thread safety under 20 concurrent threads.
|
||||
|
||||
- **Unified provider factory: eliminate divergence between `create_llm()` and `create_ai_provider()`** (#10949):
|
||||
Introduced `_create_provider_instance()` as the single internal factory so that
|
||||
both public methods delegate to one place. Creating a new provider now
|
||||
requires changes in exactly one method.
|
||||
- **Fixed API key regression**: the unified factory now explicitly passes
|
||||
the validated API key to all LangChain constructors (OpenAI, Anthropic,
|
||||
Google / Gemini, Azure, Groq, Together, Cohere, and OpenRouter). Users
|
||||
who configure providers via `CLEVERAGENTS_`-prefixed variables are no
|
||||
longer silently failed when LangChain falls back to raw environment
|
||||
variable lookup. Pre-validated keys are forwarded through the
|
||||
`api_key` kwarg to avoid a second settings lookup in the factory
|
||||
closure. (Closes #10949)
|
||||
- **Fixed mock provider accessibility in production**: `ProviderType.MOCK`
|
||||
is now gated by the `CLEVERAGENTS_ALLOW_MOCK_PROVIDER=true` sentinel
|
||||
environment variable. Without this flag, both `create_llm()` and
|
||||
`create_ai_provider()` raise `ValueError` when MOCK is requested,
|
||||
preventing accidental or malicious use of the fake LLM in production.
|
||||
`resolve_provider_by_name("mock")` now also respects the guard and
|
||||
`is_provider_configured(ProviderType.MOCK)` returns `True` as expected.
|
||||
- **Fixed type annotation**: `create_llm()` now declares `**kwargs: Any`
|
||||
instead of `**kwargs: object`, restoring correct Pyright inference for
|
||||
forwarded keyword arguments.
|
||||
|
||||
- **`create_llm()` raises `Unsupported provider type: openrouter`** (#10948): Fixed
|
||||
`ProviderRegistry._create_provider_llm()` missing an `OPENROUTER` branch, which
|
||||
caused `agents actor run openrouter/<model>` to fail with `ValueError`. Added a
|
||||
`ProviderType.OPENROUTER` branch that creates a `ChatOpenAI` instance configured
|
||||
with `openai_api_base="https://openrouter.ai/api/v1"` and the OpenRouter API key,
|
||||
matching the behavior of `create_ai_provider("openrouter")`. Supports optional
|
||||
`default_headers` kwarg with automatic string coercion for non-string keys/values.
|
||||
|
||||
- **LoadingThrobber Widget Restored** (#6357): Restored `LoadingThrobber` widget
|
||||
|
||||
- **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.
|
||||
|
||||
- **`agents actor run` empty response for built-in LLM actors** (#10861): Fixed
|
||||
`resolve_config_files` in `cli/commands/_resolve_actor.py` silently returning
|
||||
empty output when invoked with a built-in actor name (e.g.
|
||||
`anthropic/claude-sonnet-4-20250514`). Built-in actors generated from the
|
||||
provider registry have a `config_blob` with `provider` and `model` fields but
|
||||
no `type` field. Serialising this blob as-is produced YAML that
|
||||
`ReactiveConfigParser` could not interpret (no agents, no routes → empty
|
||||
`ReactiveConfig` → empty response). Fix: `_synthesize_llm_yaml()` now
|
||||
synthesises a minimal v3 `type: llm` YAML when the actor has no `yaml_text`
|
||||
and the `config_blob` has `provider` and `model` but no `type` field, allowing
|
||||
the reactive config parser to create a working agent and graph route. BDD
|
||||
regression coverage in `features/tdd_actor_run_response.feature`.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Legacy CLI command removal** (#4181): Removed all legacy plan lifecycle CLI
|
||||
commands (`tell`, `build`, `new`, `current`, `cd`, `continue`) and their
|
||||
associated tests to support V3 Plan Lifecycle exclusively. Removed `tell` and
|
||||
`build` CLI shortcuts from `main.py` that delegated to the deprecated commands.
|
||||
Removed orphaned `_tell_streaming` dead code from `plan.py`. Updated help text
|
||||
and command validation to recognize only V3 commands. Added `--format` option
|
||||
to `agents session tell` for consistency with other session commands. Fixed
|
||||
MCP logger thread-safety in `session.py` using a threading lock. Created
|
||||
migration guide for users transitioning from legacy to V3 workflow.
|
||||
|
||||
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
|
||||
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
|
||||
@@ -461,33 +16,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
logged at debug level for observability.
|
||||
|
||||
### Added
|
||||
|
||||
- **ACMS Index Data Model and File Traversal Engine** (#9579): Implements the
|
||||
foundational ACMS index data model with structured fields for file metadata
|
||||
(path, size, last modified, type), tag system, and hot/warm/cold/archive
|
||||
storage tier assignment. Introduces a timeout-safe large-project file traversal
|
||||
engine capable of handling 10,000+ files without memory exhaustion through
|
||||
chunked processing. Provides a complete index entry pipeline for creation,
|
||||
storage, and retrieval with full queryability by path, tag, type, and recency.
|
||||
|
||||
- **ACMS Large-Project Indexing BDD Coverage** (#8726): Added 7 Behave scenarios
|
||||
covering walk-based indexing of 10,000+ files without timeout, binary-file
|
||||
skipping, oversized-file skipping, git-checkout indexing, fallback to walk when
|
||||
`git ls-files` is unavailable on a non-git directory, and total-bytes budget
|
||||
enforcement. Optimised fixture setup to pre-create subdirectories (99x fewer
|
||||
syscalls). Added `timeout=120` to git subprocess calls to prevent CI hangs.
|
||||
Cached `get_scoped_view` results in `When` steps to avoid redundant re-queries
|
||||
in `Then` steps.
|
||||
|
||||
- **Agent Evolution Pool Supervisor PR Metadata Assignment** (#7888): The
|
||||
agent-evolution-pool-supervisor now automatically looks up the Type/Automation
|
||||
label and the earliest open milestone from the repository before dispatching
|
||||
improvement PR creation workers. Label and milestone IDs are passed to workers
|
||||
via the dispatch context, ensuring all generated improvement PRs have correct
|
||||
Type labels and milestone assignments. Graceful error handling skips label or
|
||||
milestone assignment when either is unavailable. Added comprehensive BDD test
|
||||
suite (7 scenarios) covering label lookup, milestone lookup, worker dispatch,
|
||||
PR creation with metadata, and error handling for missing labels/milestones.
|
||||
- **ACMS Hot/Warm/Cold Storage Tiers** (#9580): Implements three-tier storage
|
||||
architecture for ACMS context lifecycle management. Hot tier uses an in-memory
|
||||
LRU cache with O(1) access and eviction; warm tier uses disk-backed serialization
|
||||
with hashed filenames and O(1) LRU eviction; cold tier uses gzip compression with
|
||||
lazy decompression. Automatic tier transitions are driven by a configurable
|
||||
`LifecyclePolicy` engine based on access patterns. Includes incremental size
|
||||
tracking (O(1) metrics), bounded `LifecyclePolicyEngine` dicts to prevent memory
|
||||
growth, key sanitization via SHA-256 hashing to prevent path traversal, and
|
||||
index rebuild from disk on instantiation for correct restart behavior.
|
||||
|
||||
- Wired `StrategyActor` into the real plan execution path: `_get_plan_executor`
|
||||
in `plan.py` now resolves the strategy actor via `resolve_strategy_actor()`
|
||||
@@ -500,20 +37,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
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)
|
||||
corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios.
|
||||
(#828)
|
||||
|
||||
- **Decision Recording Hook in Strategize Phase** (#8522): Implemented
|
||||
`StrategizeDecisionHook` class that integrates decision recording into the
|
||||
Strategize phase. The hook captures every decision point during strategy
|
||||
decomposition, including question, chosen option, alternatives considered,
|
||||
confidence score, rationale, and full context snapshot (hot context hash,
|
||||
actor state reference, relevant resources). Supports recording of
|
||||
`strategy_choice`, `resource_selection`, `subplan_spawn`, and
|
||||
`invariant_enforced` decision types. Context snapshots are auto-captured
|
||||
with SHA256 hashing of context data and checkpoint references for LangGraph
|
||||
actor state. Includes comprehensive BDD test suite with 40+ scenarios
|
||||
covering all decision types, context capture, error handling, and tree
|
||||
structure validation.
|
||||
|
||||
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
|
||||
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
|
||||
@@ -554,10 +80,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
|
||||
@@ -571,14 +97,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` 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`.
|
||||
`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`.
|
||||
|
||||
- **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
|
||||
@@ -589,23 +115,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **Container Resource Stop Support**: `agents resource stop` now correctly stops
|
||||
`container-instance` and `devcontainer-instance` resource types.
|
||||
|
||||
- **Centralized Automation Tracking Manager** (`automation-tracking-manager`): The
|
||||
subagent is now the single interface for all tracking issue operations
|
||||
(`CREATE_TRACKING_ISSUE`, `UPDATE_TRACKING_ISSUE`, `CLOSE_TRACKING_ISSUE`,
|
||||
`READ_TRACKING_STATE`, `GET_NEXT_CYCLE_NUMBER`). Agents delegate to the manager rather
|
||||
than calling the Forgejo API directly, ensuring sequential cycle numbers across
|
||||
restarts, preventing duplicate issues, and enforcing consistent label application.
|
||||
Migrated agents include `system-watchdog`, `implementation-orchestrator`,
|
||||
`timeline-updater`, `project-owner`, `product-builder`, `backlog-groomer`,
|
||||
`implementation-pool-supervisor`, `timeline-update-pool-supervisor`, and
|
||||
`project-owner-pool-supervisor`. The legacy `shared/automation_tracking.md` module was
|
||||
removed.
|
||||
- **Centralized Automation Tracking Manager** (`automation-tracking-manager`): The manager
|
||||
is now the single interface for all tracking issue operations (`CREATE_TRACKING_ISSUE`,
|
||||
`UPDATE_TRACKING_ISSUE`, `CLOSE_TRACKING_ISSUE`, `READ_TRACKING_STATE`,
|
||||
`GET_NEXT_CYCLE_NUMBER`). Agents delegate to the manager rather than calling the Forgejo
|
||||
API directly, ensuring sequential cycle numbers across restarts and preventing
|
||||
duplicate issues. Migrated agents include `system-watchdog`,
|
||||
`implementation-pool-supervisor`, `timeline-update-pool-supervisor`,
|
||||
`project-owner-pool-supervisor`, `product-builder`, and
|
||||
`backlog-grooming-pool-supervisor`. The legacy `shared/automation_tracking.md` module
|
||||
was removed.
|
||||
|
||||
- **Documentation Writer Tracking** (`docs-writer`): The documentation writer now
|
||||
participates in the automation tracking system by creating individual `[AUTO-DOCS]
|
||||
Documentation Report (Cycle N)` issues every 10 cycles (~3.3 hours). The manager applies
|
||||
the mandatory `Automation Tracking` label automatically, while teams may add additional
|
||||
workflow labels as needed. See `docs/development/automation-tracking.md` and the new
|
||||
participates in the automation tracking system by creating individual
|
||||
`[AUTO-DOCS] Documentation Report (Cycle N)` issues every 10 cycles (~3.3 hours).
|
||||
The manager applies the mandatory `Automation Tracking` label automatically, while
|
||||
teams may add additional workflow labels as needed. See
|
||||
`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
|
||||
@@ -615,28 +141,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
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.
|
||||
|
||||
- **Comprehensive Worker Tracking System**: All 16 supervisors now provide detailed
|
||||
visibility into worker activities and health via the OpenCode API. Enhanced
|
||||
`product-builder`, `implementation-orchestrator`, `continuous-pr-reviewer`, and
|
||||
`uat-tester` with detailed session monitoring, real cycle-time calculations, stale
|
||||
worker detection and restart, and proper tracking issue lifecycle management (delete
|
||||
previous, create new each cycle). Tracking now extends to previously uncovered
|
||||
supervisors such as `architect`, `timeline-updater`, `docs-writer`, and
|
||||
`architecture-guard`.
|
||||
|
||||
- **Plan Action Argument Upsert**: `PlanLifecycleService` now upserts action arguments
|
||||
during `plan use` to avoid `UNIQUE` constraint violations when reusing actions.
|
||||
Includes batch-delete updates with identity-map eviction, invariants unique constraint,
|
||||
and an Alembic migration. (#4174)
|
||||
|
||||
### 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
|
||||
@@ -650,7 +156,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
|
||||
@@ -666,33 +172,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
`AUTO-INF-POOL`, `AUTO-ARCH`, `AUTO-EPIC`, `AUTO-EVLV`, `AUTO-GUARD`, `AUTO-SPEC`,
|
||||
`AUTO-TIME`, `AUTO-PROJ-OWN`, and `AUTO-PROD-BLDR`.
|
||||
|
||||
- **ACMS Context Hydration**: Fixed ACMS indexing pipeline not wired into CLI —
|
||||
`ContextTierService` started empty on every CLI invocation so LLM received zero file
|
||||
context during plan execution. Added `context_tier_hydrator.py` that reads files from
|
||||
linked project resources (via `git ls-files` or `os.walk`) and stores them as
|
||||
`TieredFragment` objects in the tier service. Hydration runs automatically before context
|
||||
assembly in `LLMExecuteActor.execute()`. Respects max file size (256KB), total budget
|
||||
(10MB), binary file exclusion, and `.git`/`node_modules`/`__pycache__` directory
|
||||
skipping. (#1028)
|
||||
|
||||
- **Product-Builder Tracking Migration**: `product-builder` now creates individual
|
||||
per-cycle tracking issues (prefix `AUTO-PROD-BLDR`) instead of a long-running shared
|
||||
session state issue. Each cycle closes the previous tracking issue and creates a fresh
|
||||
one, providing better isolation and traceability.
|
||||
|
||||
- **Implementation Orchestrator Scaling**: Scaled to 32 parallel workers. Reduced
|
||||
dispatch loop sleep from 10s to 2s, simplified worker verification, reduced retry
|
||||
delays from 15s to 2s, and reduced idle sleep from 60s to 10s for dramatically
|
||||
faster throughput.
|
||||
|
||||
- **Specification — Validation Gate Empty-Run Guard** (#8146): Updated `docs/specification.md`
|
||||
to document the security invariant introduced in PR #7786 (fixing issue #7508). The spec now
|
||||
explicitly states that `ApplyValidationSummary.all_required_passed` returns `False` when no
|
||||
validations have been run (empty summary), blocking apply. Added a prominent danger admonition
|
||||
block, updated the validation process results section, the `final_validation_results` data
|
||||
model description, and two milestone acceptance criteria to reflect the corrected blocking
|
||||
behavior for empty validation summaries and no-attachment runs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Plan Concurrency Race Condition** (#7989): Fixed critical race condition in `execute_plan()` and
|
||||
@@ -723,8 +202,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
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
|
||||
@@ -748,7 +225,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **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
|
||||
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
|
||||
@@ -763,11 +240,6 @@ 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
|
||||
@@ -787,35 +259,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
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)
|
||||
|
||||
- **ACMS Indexing Pipeline CLI Wiring**: `ContextTierService` was starting empty on
|
||||
every CLI invocation, causing the LLM to receive zero file context during plan
|
||||
execution. Added `context_tier_hydrator.py` that reads files from linked project
|
||||
resources (via `git ls-files` or `os.walk`) and stores them as `TieredFragment`
|
||||
objects in the tier service. Hydration runs automatically before context assembly
|
||||
in `LLMExecuteActor.execute()`. Respects max file size (256KB), total budget (10MB),
|
||||
binary file exclusion, and `.git`/`node_modules`/`__pycache__` directory skipping.
|
||||
(#1028)
|
||||
- **CI Lint**: Resolved 51 ruff violations in `scripts/validate_automation_tracking.py`
|
||||
(import ordering, deprecated `typing` generics, unused imports, line-length, whitespace).
|
||||
- **CI Integration Tests**: Removed stale `tdd_expected_fail` tag from
|
||||
`robot/coverage_threshold.robot` — the underlying bug (issue #4305) is resolved and
|
||||
the tag was inverting a passing test to a failure. (#5266)
|
||||
- **Orchestrator Worker Dispatch**: Fixed `verify_worker_started()` to handle the dict
|
||||
response format from the OpenCode API `/session/status` endpoint instead of an array.
|
||||
Workers now dispatch and verify correctly, preventing incorrect session deletion.
|
||||
|
||||
---
|
||||
### Fixed
|
||||
|
||||
- **CLI (`agents actor remove`)** (#6491): Restores output parity with the
|
||||
other actor commands by honoring `--format`/`-f` for JSON/YAML/plain/Rich
|
||||
envelopes. Adds a Robot Framework regression test to assert the JSON
|
||||
envelope structure and updates the CLI synopsis in `docs/specification.md`
|
||||
to document the option.
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] -- 2026-04-05
|
||||
## [3.8.0] — 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -826,14 +272,13 @@ 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
|
||||
|
||||
|
||||
+3
-24
@@ -15,29 +15,8 @@ Below are some of the specific details of various contributions.
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* HAL 9000 has contributed the decision recording hook for the Strategize phase (issue #8522): captures every decision point with question, chosen option, alternatives, confidence, rationale, and full context snapshot for replay and correction.
|
||||
* HAL 9000 has contributed automated specification maintenance, documentation updates, and bot-driven PR authorship.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed the ACMS Hot/Warm/Cold Storage Tiers implementation (#9580): three-tier storage architecture with LRU hot/in-memory tier, disk-backed warm tier with hashed filenames, gzip-compressed cold tier with lazy decompression, lifecycle policy engine for automatic tier transitions based on access patterns, incremental O(1) size tracking, and bounded lifecycle dicts. Includes BDD feature coverage, Robot Framework integration tests, and full thread safety via `threading.RLock`.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers.
|
||||
* HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply.
|
||||
* HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-pool-supervisor.md` (#9824): created a new agent definition with an embedded 8-item checklist ensuring workers always update CHANGELOG.md, CONTRIBUTORS.md, include commit footers (`ISSUES CLOSED: #N`), verify CI passes, add BDD tests, reference the parent Epic, apply labels via forgejo-label-manager, and assign milestones before creating PRs. Includes concrete examples for each subsection and compliance verification pseudocode.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
* HAL 9000 has contributed the PyYAML security upgrade (PR # -- / issue #9244): added an explicit `pyyaml>=6.0.3` dependency constraint to `pyproject.toml` to remediate CVE-2025-49904, a heap-based buffer overflow vulnerability in PyYAML when loading YAML files with excessively long strings. The version floor prevents vulnerable versions (<6.0.3) from being installed regardless of upstream transitive dependency constraints.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
"""ASV benchmarks for circuit breaker pattern.
|
||||
|
||||
Measures the overhead of circuit breaker state management,
|
||||
state transitions, and fast-fail latency in open state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from cleveragents.core.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
CircuitBreakerState,
|
||||
)
|
||||
|
||||
|
||||
class CircuitBreakerClosedStateBench:
|
||||
"""Benchmark circuit breaker overhead in closed state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_call_success(self) -> None:
|
||||
"""Benchmark successful call overhead in closed state."""
|
||||
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.breaker.call(dummy_func)
|
||||
|
||||
def time_call_with_args(self) -> None:
|
||||
"""Benchmark call with arguments in closed state."""
|
||||
|
||||
def dummy_func(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
self.breaker.call(dummy_func, 42, "test")
|
||||
|
||||
def time_state_check(self) -> None:
|
||||
"""Benchmark state property access."""
|
||||
_ = self.breaker.state
|
||||
|
||||
def time_failure_count_check(self) -> None:
|
||||
"""Benchmark failure count property access."""
|
||||
_ = self.breaker.failure_count
|
||||
|
||||
|
||||
class CircuitBreakerOpenStateBench:
|
||||
"""Benchmark circuit breaker fast-fail latency in open state."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Force the breaker into open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0) # Raise ZeroDivisionError
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_fast_fail(self) -> None:
|
||||
"""Benchmark fast-fail latency when circuit is open."""
|
||||
try:
|
||||
self.breaker.call(lambda: "should not execute")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
def time_open_state_check(self) -> None:
|
||||
"""Benchmark state check when open."""
|
||||
assert self.breaker.state == CircuitBreakerState.OPEN
|
||||
|
||||
|
||||
class CircuitBreakerClosedToOpenTransitionBench:
|
||||
"""Benchmark closed-to-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up a fresh circuit breaker in closed state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
def time_closed_to_open_transition(self) -> None:
|
||||
"""Benchmark transition from closed to open state."""
|
||||
# Trigger failures to transition to open
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerOpenToHalfOpenTransitionBench:
|
||||
"""Benchmark open-to-half-open state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05, # Very short timeout
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so transition to half-open is possible
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_open_to_half_open_transition(self) -> None:
|
||||
"""Benchmark transition from open to half-open state."""
|
||||
# Attempt call to trigger half-open transition
|
||||
try:
|
||||
self.breaker.call(lambda: "success")
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerHalfOpenToClosedTransitionBench:
|
||||
"""Benchmark half-open-to-closed state transition overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up circuit breaker already in half-open state."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=0.05,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=1,
|
||||
cooldown_seconds=0.01,
|
||||
name="test_service",
|
||||
)
|
||||
# Force to open state
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
# Wait for recovery timeout so breaker enters half-open on next call
|
||||
time.sleep(0.1)
|
||||
|
||||
def time_half_open_to_closed_transition(self) -> None:
|
||||
"""Benchmark transition from half-open to closed state."""
|
||||
# Successful call in half-open should close the breaker
|
||||
self.breaker.call(lambda: "success")
|
||||
|
||||
|
||||
class CircuitBreakerAsyncBench:
|
||||
"""Benchmark async circuit breaker operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async circuit breakers."""
|
||||
self.breaker = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service",
|
||||
)
|
||||
# Set up a separate breaker already in open state for fast-fail benchmark
|
||||
self.open_breaker = CircuitBreaker(
|
||||
failure_threshold=2,
|
||||
recovery_timeout=60.0,
|
||||
expected_exception=Exception,
|
||||
half_open_max_successes=2,
|
||||
cooldown_seconds=30.0,
|
||||
name="test_service_open",
|
||||
)
|
||||
for _ in range(3):
|
||||
try:
|
||||
self.open_breaker.call(lambda: 1 / 0)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def time_async_call_success(self) -> None:
|
||||
"""Benchmark successful async call overhead."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async))
|
||||
|
||||
def time_async_call_with_args(self) -> None:
|
||||
"""Benchmark async call with arguments."""
|
||||
|
||||
async def dummy_async(x: int, y: str) -> str:
|
||||
return f"{x}:{y}"
|
||||
|
||||
asyncio.run(self.breaker.async_call(dummy_async, 42, "test"))
|
||||
|
||||
def time_async_fast_fail(self) -> None:
|
||||
"""Benchmark async fast-fail when open."""
|
||||
|
||||
async def dummy_async() -> str:
|
||||
return "should not execute"
|
||||
|
||||
try:
|
||||
asyncio.run(self.open_breaker.async_call(dummy_async))
|
||||
except CircuitBreakerOpen:
|
||||
pass
|
||||
|
||||
|
||||
class CircuitBreakerInitializationBench:
|
||||
"""Benchmark circuit breaker initialization overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_default_initialization(self) -> None:
|
||||
"""Benchmark default initialization."""
|
||||
CircuitBreaker()
|
||||
|
||||
def time_custom_initialization(self) -> None:
|
||||
"""Benchmark initialization with custom parameters."""
|
||||
CircuitBreaker(
|
||||
failure_threshold=10,
|
||||
recovery_timeout=120.0,
|
||||
expected_exception=ValueError,
|
||||
half_open_max_successes=3,
|
||||
cooldown_seconds=45.0,
|
||||
name="custom_service",
|
||||
)
|
||||
|
||||
def time_multiple_breakers(self) -> None:
|
||||
"""Benchmark creating multiple breakers."""
|
||||
for i in range(10):
|
||||
CircuitBreaker(name=f"service_{i}")
|
||||
@@ -1,280 +0,0 @@
|
||||
"""ASV benchmarks for retry patterns.
|
||||
|
||||
Measures the overhead of retry decorator construction and
|
||||
happy-path invocation for the public retry factory functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from cleveragents.core.retry_patterns import (
|
||||
get_retry_decorator,
|
||||
retry_on_result,
|
||||
retry_with_exponential_backoff,
|
||||
retry_with_jitter,
|
||||
retry_with_timeout,
|
||||
should_retry_result,
|
||||
)
|
||||
|
||||
|
||||
class RetryDecoratorConstructionBench:
|
||||
"""Benchmark retry decorator construction overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_retry_with_exponential_backoff_construction(self) -> None:
|
||||
"""Benchmark constructing exponential backoff decorator."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_jitter_construction(self) -> None:
|
||||
"""Benchmark constructing jitter decorator."""
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_with_timeout_construction(self) -> None:
|
||||
"""Benchmark constructing timeout decorator."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_retry_on_result_construction(self) -> None:
|
||||
"""Benchmark constructing result-based retry decorator."""
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_get_retry_decorator_network(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for network category."""
|
||||
get_retry_decorator("network")
|
||||
|
||||
def time_get_retry_decorator_provider(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for provider category."""
|
||||
get_retry_decorator("provider")
|
||||
|
||||
def time_get_retry_decorator_database(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for database category."""
|
||||
get_retry_decorator("database")
|
||||
|
||||
def time_get_retry_decorator_unknown(self) -> None:
|
||||
"""Benchmark constructing decorator via factory for unknown category."""
|
||||
get_retry_decorator("unknown_category")
|
||||
|
||||
|
||||
class RetryDecoratorInvocationBench:
|
||||
"""Benchmark happy-path retry decorator invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def exponential_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def jitter_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
def timeout_func() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_on_result(should_retry_result, max_attempts=3)
|
||||
def result_func() -> str:
|
||||
return "success"
|
||||
|
||||
self.exponential_func = exponential_func
|
||||
self.jitter_func = jitter_func
|
||||
self.timeout_func = timeout_func
|
||||
self.result_func = result_func
|
||||
|
||||
def time_exponential_backoff_invocation(self) -> None:
|
||||
"""Benchmark exponential backoff invocation (happy path)."""
|
||||
self.exponential_func()
|
||||
|
||||
def time_jitter_invocation(self) -> None:
|
||||
"""Benchmark jitter invocation (happy path)."""
|
||||
self.jitter_func()
|
||||
|
||||
def time_timeout_invocation(self) -> None:
|
||||
"""Benchmark timeout invocation (happy path)."""
|
||||
self.timeout_func()
|
||||
|
||||
def time_result_based_invocation(self) -> None:
|
||||
"""Benchmark result-based retry invocation (happy path)."""
|
||||
self.result_func()
|
||||
|
||||
|
||||
class RetryDecoratorAsyncBench:
|
||||
"""Benchmark async retry decorator operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up async decorated functions."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
async def async_exponential() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
async def async_jitter() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_with_timeout(timeout_seconds=5.0)
|
||||
async def async_timeout() -> str:
|
||||
return "success"
|
||||
|
||||
self.async_exponential = async_exponential
|
||||
self.async_jitter = async_jitter
|
||||
self.async_timeout = async_timeout
|
||||
|
||||
def time_async_exponential_invocation(self) -> None:
|
||||
"""Benchmark async exponential backoff invocation."""
|
||||
asyncio.run(self.async_exponential())
|
||||
|
||||
def time_async_jitter_invocation(self) -> None:
|
||||
"""Benchmark async jitter invocation."""
|
||||
asyncio.run(self.async_jitter())
|
||||
|
||||
def time_async_timeout_invocation(self) -> None:
|
||||
"""Benchmark async timeout invocation."""
|
||||
asyncio.run(self.async_timeout())
|
||||
|
||||
|
||||
class RetryDecoratorWithArgumentsBench:
|
||||
"""Benchmark retry decorators with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated functions with arguments."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=3)
|
||||
def func_with_args(x: int, y: str, z: float = 1.0) -> str:
|
||||
return f"{x}:{y}:{z}"
|
||||
|
||||
@retry_with_jitter(max_attempts=3)
|
||||
def func_with_kwargs(a: int, b: str = "default") -> str:
|
||||
return f"{a}:{b}"
|
||||
|
||||
self.func_with_args = func_with_args
|
||||
self.func_with_kwargs = func_with_kwargs
|
||||
|
||||
def time_exponential_with_positional_args(self) -> None:
|
||||
"""Benchmark exponential backoff with positional arguments."""
|
||||
self.func_with_args(42, "test", 2.5)
|
||||
|
||||
def time_exponential_with_keyword_args(self) -> None:
|
||||
"""Benchmark exponential backoff with keyword arguments."""
|
||||
self.func_with_args(x=42, y="test", z=2.5)
|
||||
|
||||
def time_jitter_with_mixed_args(self) -> None:
|
||||
"""Benchmark jitter with mixed positional and keyword arguments."""
|
||||
self.func_with_kwargs(100, b="custom")
|
||||
|
||||
|
||||
class RetryDecoratorFactoryBench:
|
||||
"""Benchmark retry decorator factory function."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_factory_network_category(self) -> None:
|
||||
"""Benchmark factory with network category."""
|
||||
decorator = get_retry_decorator("network")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_provider_category(self) -> None:
|
||||
"""Benchmark factory with provider category."""
|
||||
decorator = get_retry_decorator("provider")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_database_category(self) -> None:
|
||||
"""Benchmark factory with database category."""
|
||||
decorator = get_retry_decorator("database")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_factory_file_operation_category(self) -> None:
|
||||
"""Benchmark factory with file_operation category."""
|
||||
decorator = get_retry_decorator("file_operation")
|
||||
|
||||
@decorator
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
|
||||
class RetryDecoratorConfigurationBench:
|
||||
"""Benchmark retry decorator with various configurations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_minimal_config(self) -> None:
|
||||
"""Benchmark decorator with minimal configuration."""
|
||||
|
||||
@retry_with_exponential_backoff()
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_aggressive_config(self) -> None:
|
||||
"""Benchmark decorator with aggressive retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=10)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_conservative_config(self) -> None:
|
||||
"""Benchmark decorator with conservative retry settings."""
|
||||
|
||||
@retry_with_exponential_backoff(max_attempts=1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_long_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with long timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=300.0)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_short_timeout_config(self) -> None:
|
||||
"""Benchmark decorator with short timeout."""
|
||||
|
||||
@retry_with_timeout(timeout_seconds=0.1)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -1,367 +0,0 @@
|
||||
"""ASV benchmarks for service-level retry patterns.
|
||||
|
||||
Measures the overhead of retry_service_operation decorator
|
||||
and retry_auto_debug happy-path latency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.core.circuit_breaker import CircuitBreaker
|
||||
from cleveragents.core.retry_service_patterns import (
|
||||
RetryContext,
|
||||
is_read_only_plan_operation,
|
||||
retry_auto_debug,
|
||||
retry_service_operation,
|
||||
)
|
||||
|
||||
|
||||
class RetryServiceOperationDecoratorBench:
|
||||
"""Benchmark retry_service_operation decorator overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_decorator_construction_sync(self) -> None:
|
||||
"""Benchmark constructing sync service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_construction_async(self) -> None:
|
||||
"""Benchmark constructing async service retry decorator."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark decorator with circuit breaker instance."""
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_decorator_with_exponential_strategy(self) -> None:
|
||||
"""Benchmark decorator with exponential backoff strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
base_delay=1.0,
|
||||
max_delay=60.0,
|
||||
)
|
||||
def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
|
||||
class RetryServiceOperationInvocationBench:
|
||||
"""Benchmark happy-path service retry invocation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated service operations."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
def sync_op() -> str:
|
||||
return "success"
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def async_op() -> str:
|
||||
return "success"
|
||||
|
||||
cb = CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60.0,
|
||||
name="test_service",
|
||||
)
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
circuit_breaker=cb,
|
||||
)
|
||||
def breaker_op() -> str:
|
||||
return "success"
|
||||
|
||||
self.sync_op = sync_op
|
||||
self.async_op = async_op
|
||||
self.breaker_op = breaker_op
|
||||
|
||||
def time_sync_operation_invocation(self) -> None:
|
||||
"""Benchmark sync service operation invocation (happy path)."""
|
||||
self.sync_op()
|
||||
|
||||
def time_async_operation_invocation(self) -> None:
|
||||
"""Benchmark async service operation invocation (happy path)."""
|
||||
asyncio.run(self.async_op())
|
||||
|
||||
def time_operation_with_circuit_breaker(self) -> None:
|
||||
"""Benchmark service operation with circuit breaker (happy path)."""
|
||||
self.breaker_op()
|
||||
|
||||
|
||||
class RetryServiceOperationWithArgumentsBench:
|
||||
"""Benchmark service retry with function arguments."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up decorated operations with arguments."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="fetch_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
def fetch_data(
|
||||
resource_id: int, include_metadata: bool = False
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "data": "test"}
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="update_data",
|
||||
max_attempts=3,
|
||||
)
|
||||
async def update_data(
|
||||
resource_id: int, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {"id": resource_id, "updated": True}
|
||||
|
||||
self.fetch_data = fetch_data
|
||||
self.update_data = update_data
|
||||
|
||||
def time_sync_with_positional_args(self) -> None:
|
||||
"""Benchmark sync operation with positional arguments."""
|
||||
self.fetch_data(42, True)
|
||||
|
||||
def time_sync_with_keyword_args(self) -> None:
|
||||
"""Benchmark sync operation with keyword arguments."""
|
||||
self.fetch_data(resource_id=42, include_metadata=True)
|
||||
|
||||
def time_async_with_complex_payload(self) -> None:
|
||||
"""Benchmark async operation with complex payload."""
|
||||
payload = {"key": "value", "nested": {"data": [1, 2, 3]}}
|
||||
asyncio.run(self.update_data(42, payload))
|
||||
|
||||
|
||||
class RetryAutoDebugBench:
|
||||
"""Benchmark retry_auto_debug decorator."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_auto_debug_construction(self) -> None:
|
||||
"""Benchmark constructing auto-debug decorator."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
def time_auto_debug_invocation(self) -> None:
|
||||
"""Benchmark auto-debug invocation (happy path)."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=3)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_single_attempt(self) -> None:
|
||||
"""Benchmark auto-debug with single attempt."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=1)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
def time_auto_debug_multiple_attempts(self) -> None:
|
||||
"""Benchmark auto-debug with multiple attempts configured."""
|
||||
|
||||
@retry_auto_debug(max_debug_attempts=5)
|
||||
async def dummy_func() -> str:
|
||||
return "success"
|
||||
|
||||
asyncio.run(dummy_func())
|
||||
|
||||
|
||||
class RetryContextBench:
|
||||
"""Benchmark RetryContext operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_context_creation(self) -> None:
|
||||
"""Benchmark creating a retry context."""
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
|
||||
def time_context_with_custom_wait(self) -> None:
|
||||
"""Benchmark context with custom wait strategy."""
|
||||
from tenacity import wait_exponential
|
||||
|
||||
RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
wait_strategy=wait_exponential(multiplier=1, min=1, max=30),
|
||||
)
|
||||
|
||||
def time_context_property_access(self) -> None:
|
||||
"""Benchmark accessing context properties."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
_ = ctx.operation_name
|
||||
_ = ctx.max_attempts
|
||||
_ = ctx.attempt_count
|
||||
|
||||
def time_context_execute(self) -> None:
|
||||
"""Benchmark executing a function via RetryContext."""
|
||||
ctx = RetryContext(
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
)
|
||||
ctx.execute(lambda: "success")
|
||||
|
||||
|
||||
class IsReadOnlyPlanOperationBench:
|
||||
"""Benchmark is_read_only_plan_operation utility."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_check_strategize(self) -> None:
|
||||
"""Benchmark checking strategize operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "strategize"})
|
||||
|
||||
def time_check_plan(self) -> None:
|
||||
"""Benchmark checking plan operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "plan"})
|
||||
|
||||
def time_check_validate(self) -> None:
|
||||
"""Benchmark checking validate operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "validate"})
|
||||
|
||||
def time_check_review(self) -> None:
|
||||
"""Benchmark checking review operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "review"})
|
||||
|
||||
def time_check_preview(self) -> None:
|
||||
"""Benchmark checking preview operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "preview"})
|
||||
|
||||
def time_check_check(self) -> None:
|
||||
"""Benchmark checking check operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "check"})
|
||||
|
||||
def time_check_execute(self) -> None:
|
||||
"""Benchmark checking execute operation (not read-only)."""
|
||||
is_read_only_plan_operation({"plan_phase": "execute"})
|
||||
|
||||
def time_check_read_only_flag(self) -> None:
|
||||
"""Benchmark checking read_only flag."""
|
||||
is_read_only_plan_operation({"read_only": True})
|
||||
|
||||
def time_check_unknown_operation(self) -> None:
|
||||
"""Benchmark checking unknown operation."""
|
||||
is_read_only_plan_operation({"plan_phase": "unknown_op"})
|
||||
|
||||
def time_check_empty_kwargs(self) -> None:
|
||||
"""Benchmark checking empty kwargs."""
|
||||
is_read_only_plan_operation({})
|
||||
|
||||
|
||||
class RetryServiceOperationStrategyBench:
|
||||
"""Benchmark different retry strategies in service operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_exponential_strategy(self) -> None:
|
||||
"""Benchmark exponential strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="exponential",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_linear_strategy(self) -> None:
|
||||
"""Benchmark linear strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="linear",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_fixed_strategy(self) -> None:
|
||||
"""Benchmark fixed strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="fixed",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
|
||||
def time_jitter_strategy(self) -> None:
|
||||
"""Benchmark jitter strategy."""
|
||||
|
||||
@retry_service_operation(
|
||||
service_name="test_service",
|
||||
operation_name="test_op",
|
||||
max_attempts=3,
|
||||
backoff_strategy="jitter",
|
||||
)
|
||||
def dummy() -> str:
|
||||
return "success"
|
||||
|
||||
dummy()
|
||||
@@ -665,7 +665,7 @@ The Automation Tracking Manager provides eleven operations:
|
||||
| `AUTO-IMP-POOL` | Implementation Pool Supervisor |
|
||||
| `AUTO-REV-POOL` | PR Review Pool Supervisor |
|
||||
| `AUTO-UAT-POOL` | UAT Test Pool Supervisor |
|
||||
| `AUTO-BUG-SUP` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-BUG-POOL` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-INF-POOL` | Test Infrastructure Pool Supervisor |
|
||||
| `AUTO-ARCH` | Architecture Supervisor |
|
||||
| `AUTO-EPIC` | Epic Planning Supervisor |
|
||||
@@ -693,7 +693,7 @@ The following table maps between the two schemes for agents where they differ:
|
||||
| Implementation Pool | `AUTO-IMP-SUP` | `AUTO-IMP-POOL` |
|
||||
| PR Review Pool | `AUTO-REV-SUP` | `AUTO-REV-POOL` |
|
||||
| UAT Test Pool | `AUTO-UAT-SUP` | `AUTO-UAT-POOL` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-SUP` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-POOL` |
|
||||
| Test Infrastructure Pool | `AUTO-INF-SUP` | `AUTO-INF-POOL` |
|
||||
| Human Liaison | `AUTO-HUMAN` | `AUTO-LIAISON` |
|
||||
| Grooming | `AUTO-GROOM` | `AUTO-GROOMER` |
|
||||
@@ -1869,7 +1869,7 @@ Only critical bugs get assigned to the active milestone. Non-critical issues go
|
||||
| **Mode** | `subagent` |
|
||||
| **Model** | `google/gemini-2.5-pro` |
|
||||
| **Temperature** | 0.1 |
|
||||
| **Tracking Prefix** | `AUTO-BUG-SUP` |
|
||||
| **Tracking Prefix** | `AUTO-BUG-POOL` |
|
||||
| **Worker Count** | N/4 (quarter allocation) |
|
||||
|
||||
#### 8.6.1 Purpose
|
||||
@@ -3164,7 +3164,7 @@ Defines how ALL agents discover, interact with, and coordinate through the autom
|
||||
|
||||
### 13.9 Bug Hunter Tracking Update (`shared/bug_hunter_tracking_update.md`)
|
||||
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle N)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
|
||||
---
|
||||
|
||||
@@ -6767,7 +6767,7 @@ The system uses five distinct redundancy patterns:
|
||||
| 4 | pr-merge-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-MERGE | Singleton Supervisor |
|
||||
| 5 | pr-fix-pool-supervisor | *(removed — absorbed into implementation-pool-supervisor)* | -- | -- | -- |
|
||||
| 6 | uat-test-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-UAT-POOL | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-SUP | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-POOL | Pool Supervisor |
|
||||
| 8 | test-infra-pool-supervisor | subagent | gemini-2.5-pro | AUTO-INF-POOL | Pool Supervisor |
|
||||
| 9 | architecture-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-ARCH | Singleton Supervisor |
|
||||
| 10 | epic-planning-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-EPIC | Singleton Supervisor |
|
||||
|
||||
@@ -61,7 +61,7 @@ All prefixes are registered in the `automation-tracking-manager` subagent, which
|
||||
| uat-test-pool-supervisor | `AUTO-UAT-POOL` | `[AUTO-UAT-POOL] UAT Status (Cycle 6)` |
|
||||
| project-owner-pool-supervisor | `AUTO-PROJ-OWN` | `[AUTO-PROJ-OWN] Project Status (Cycle 11)` |
|
||||
| agent-evolution-pool-supervisor | `AUTO-EVLV` | `[AUTO-EVLV] Agent Evolution Report (Cycle 10)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-SUP` | `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-POOL` | `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle 60)` |
|
||||
| spec-update-pool-supervisor | `AUTO-SPEC` | `[AUTO-SPEC] Specification Update Report (Cycle 1)` |
|
||||
| test-infra-pool-supervisor | `AUTO-INF-POOL` | `[AUTO-INF-POOL] Infrastructure Analysis Report (Cycle 2)` |
|
||||
| epic-planner | `AUTO-EPIC` | `[AUTO-EPIC] Epic Planning Update (Cycle 9)` |
|
||||
@@ -394,7 +394,7 @@ label:"Automation Tracking" [AUTO-UAT-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-PROJ-OWN] in:title
|
||||
label:"Automation Tracking" [AUTO-EVLV] in:title
|
||||
label:"Automation Tracking" [AUTO-EPIC] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-SUP] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-SPEC] in:title
|
||||
label:"Automation Tracking" [AUTO-INF-POOL] in:title
|
||||
```
|
||||
|
||||
+4
-17
@@ -20019,18 +20019,6 @@ Apply should perform (configurable) validations before committing:
|
||||
|
||||
Validation runs during **Execute**, not during Apply. By the time a plan reaches the Apply phase, all required validations have already passed. Apply commits the sandbox changes to the real resources and does not re-run validations.
|
||||
|
||||
!!! danger "Security Invariant — Empty Validation Summary Blocks Apply"
|
||||
Apply **requires** that at least one required validation was executed during the Execute phase. If no validations were run — because no validations are attached to the plan's resources, or because the validation collection step produced an empty set — the apply gate **blocks** and the plan cannot proceed to Apply.
|
||||
|
||||
This is an intentional security invariant (introduced in PR #7786, fixing issue #7508): apply cannot proceed without at least one validation having run. A plan with no validation attachments is treated the same as a plan with failing validations — it cannot be applied.
|
||||
|
||||
**Blocking conditions for the apply gate**:
|
||||
- Any required validation returned `passed: false` (validation failure)
|
||||
- No required validations were executed at all (empty validation summary)
|
||||
- A plan has no validation attachments (no validations configured)
|
||||
|
||||
The `ApplyValidationSummary.all_required_passed` property returns `True` **only when** at least one required validation was executed AND none of the required validations failed. An empty summary always yields `False`.
|
||||
|
||||
For full details on validation — including the Validation type system, modes, attachment scoping, failure handling, fix-then-revalidate loops, and data model — see the **Validation** section under Core Concepts.
|
||||
|
||||
#### Apply Data Model
|
||||
@@ -22769,8 +22757,7 @@ Validation is the **final step** of the Execute phase. The execution actor's wor
|
||||
2. **Collect applicable validations**: The system resolves all validations from the resource-direct, project, and plan scopes (as described in Attachment Resolution above).
|
||||
3. **Execute validations**: Each applicable validation is invoked as a standard tool call. The validation reads the sandbox state and returns its structured result. Validations may be run in parallel since they are read-only and cannot interfere with each other.
|
||||
4. **Process results**:
|
||||
- **All required validations pass (and at least one ran)**: Execution is complete. The plan transitions to the review/Apply phase.
|
||||
- **No required validations were executed (empty summary)**: The apply gate is blocked. This is treated equivalently to a required validation failure — the plan cannot proceed to Apply. This condition arises when no validations are attached to the plan's resources. See the security invariant note in [Validation in Apply](#validation-in-apply).
|
||||
- **All required validations pass**: Execution is complete. The plan transitions to the review/Apply phase.
|
||||
- **Any required validation fails**: The execution actor enters the fix-then-revalidate loop (see Validation Failure Handling below).
|
||||
- **Informational validations fail**: Results are recorded in the plan's validation summary. No fix attempts are made.
|
||||
|
||||
@@ -23089,7 +23076,7 @@ The validation-related fields stored in the plan data model:
|
||||
| Field | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| `validation_summary` | `plan.execution` | Array of validation results collected during Execute. Each entry includes the validation name, mode, `passed`, `message`, `data`, execution duration, and attempt number. |
|
||||
| `final_validation_results` | `plan.apply` | Snapshot of the final validation state at the time execution completed (all required validations passing, and at least one required validation was executed). Identical to the last state of `validation_summary` but stored separately for quick reference. A plan can only reach the Apply phase if this snapshot is non-empty and all required entries have `passed: true`. |
|
||||
| `final_validation_results` | `plan.apply` | Snapshot of the final validation state at the time execution completed (all required validations passing). Identical to the last state of `validation_summary` but stored separately for quick reference. |
|
||||
| `validation_attempts` | `plan.execution` | Total number of validation invocations across all fix-then-revalidate loops. Useful for understanding how much effort was spent on validation remediation. |
|
||||
| `validation_fix_history` | `plan.execution` | Per-validation log of fix attempts. Each entry records: the validation that failed, the fix the actor applied, and whether the subsequent re-validation passed. Enables auditing of the fix-then-revalidate process. |
|
||||
|
||||
@@ -47136,7 +47123,7 @@ This section defines the ordered milestone plan for CleverAgents v3.x, mapping a
|
||||
| 6 | Hierarchical decomposition creates 4+ levels of subplans | §Subplan Architecture — Hierarchy | `plan tree` shows 4+ nesting levels for complex tasks |
|
||||
| 7 | Parallel execution scales to 10+ concurrent subplans | §Subplan Execution — Parallel | 10 subplans execute concurrently without deadlock or resource starvation |
|
||||
| 8 | Decision correction recomputes only affected subtree | §Correction Model — Selective Recomputation | Unaffected decisions preserved; only downstream decisions recomputed |
|
||||
| 9 | Validation-gated apply: required validations must pass before apply | §Validation Abstraction — Apply Gating | Apply blocked when required validation fails; apply also blocked when no validations were run (empty summary); informational validation does not block |
|
||||
| 9 | Validation-gated apply: required validations must pass before apply | §Validation Abstraction — Apply Gating | Apply blocked when required validation fails; informational validation does not block |
|
||||
| 10 | A realistic porting task completes autonomously | §Autonomy Acceptance | End-to-end task (e.g., port a Python module) completes without human intervention |
|
||||
| 11 | `agents automation-profile add/list/show` functional | §CLI Commands — automation-profile | Round-trip: add profile, list shows it, show displays details |
|
||||
| 12 | `agents plan guard` shows active guards for a plan | §CLI Commands — plan guard | Command shows denylist, budget caps, tool limits |
|
||||
@@ -47148,7 +47135,7 @@ This section defines the ordered milestone plan for CleverAgents v3.x, mapping a
|
||||
- **Guard evaluation order**: Denylist checked first (fast reject), then budget caps, then tool call limits.
|
||||
- **Autonomy threshold**: `0.0` = always automatic; `1.0` = always manual; eight built-in profiles from `manual` to `full-auto`.
|
||||
- **Subtree recomputation**: Only decisions with `depends_on` edges to the corrected decision are recomputed; others preserved.
|
||||
- **Validation gating**: `required` validations block apply; an empty validation summary (no validations ran) also blocks apply; `informational` validations log but do not block.
|
||||
- **Validation gating**: `required` validations block apply; `informational` validations log but do not block.
|
||||
|
||||
#### Definition of Done
|
||||
|
||||
|
||||
@@ -152,13 +152,6 @@ end note
|
||||
| 2026-04-10 | D100 | — | v3.5.0 M6 | 100% | 16.9% | -83.1% | CRITICAL | Day 100: 210/1242 closed |
|
||||
| 2026-04-10 | D100 | — | v3.6.0 M7 | 100% | 33.9% | -66.1% | CRITICAL | Day 100: 152/448 closed |
|
||||
| 2026-04-10 | D100 | — | v3.7.0 M8 | — | 43.8% | — | HIGH | Day 100: 427/975 closed |
|
||||
| 2026-04-12 | D101 | — | v3.2.0 M3 | 100% | 27.8% | -72.2% | CRITICAL | Day 101: 258/926 closed, 664 open (+119) |
|
||||
| 2026-04-12 | D101 | — | v3.3.0 M4 | 100% | 47.0% | -53.0% | CRITICAL | Day 101: 108/230 closed, 122 open (+12) |
|
||||
| 2026-04-12 | D101 | — | v3.4.0 M5 | 100% | 40.2% | -59.8% | CRITICAL | Day 101: 137/341 closed, 204 open (+35) |
|
||||
| 2026-04-12 | D101 | — | v3.5.0 M6 | 100% | 17.0% | -83.0% | CRITICAL | Day 101: 201/1178 closed, 977 open (+135) |
|
||||
| 2026-04-12 | D101 | — | v3.6.0 M7 | 100% | 35.2% | -64.8% | CRITICAL | Day 101: 152/432 closed, 280 open (+48) |
|
||||
| 2026-04-12 | D101 | — | v3.7.0 M8 | — | 44.8% | — | HIGH | Day 101: 427/953 closed, 526 open (+28) |
|
||||
| 2026-04-12 | D101 | — | v3.8.0 M9 | — | 27.0% | — | HIGH | Day 101: 132/489 closed, 357 open (+29) |
|
||||
| 2026-04-13 | D103 | C2 | v3.2.0 M3 | 100% | 25.7% | -74.3% | CRITICAL | Cycle 2: 269/1045 closed, 776 open |
|
||||
| 2026-04-13 | D103 | C2 | v3.3.0 M4 | 100% | 42.4% | -57.6% | CRITICAL | Cycle 2: 109/257 closed, 148 open |
|
||||
| 2026-04-13 | D103 | C2 | v3.4.0 M5 | 100% | 37.4% | -62.6% | CRITICAL | Cycle 2: 139/372 closed, 233 open |
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
Feature: ACMS Hot/Warm/Cold Storage Tiers for Context Lifecycle Management
|
||||
As an ACMS system
|
||||
I want to manage context storage across three tiers
|
||||
So that I can efficiently handle context at scale with automatic tier transitions
|
||||
|
||||
Background:
|
||||
Given I have a storage tier manager with default configuration
|
||||
And the hot tier has capacity of 1000 entries
|
||||
And the warm tier has capacity of 10000 entries
|
||||
And the cold tier has unlimited capacity
|
||||
|
||||
Scenario: Store context in hot tier
|
||||
When I store a context "ctx-001" with value "test-data" in the hot tier
|
||||
Then the context "ctx-001" should be in the hot tier
|
||||
And the hot tier should have 1 entry
|
||||
And the warm tier should have 0 entries
|
||||
And the cold tier should have 0 entries
|
||||
|
||||
Scenario: Retrieve context from hot tier
|
||||
Given I have stored context "ctx-001" with value "test-data" in the hot tier
|
||||
When I retrieve context "ctx-001"
|
||||
Then I should get the value "test-data"
|
||||
And the hot tier hit count should increase by 1
|
||||
|
||||
Scenario: Store context in warm tier
|
||||
When I store a context "ctx-002" with value "warm-data" in the warm tier
|
||||
Then the context "ctx-002" should be in the warm tier
|
||||
And the warm tier should have 1 entry
|
||||
And the hot tier should have 0 entries
|
||||
|
||||
Scenario: Retrieve context from warm tier
|
||||
Given I have stored context "ctx-002" with value "warm-data" in the warm tier
|
||||
When I retrieve context "ctx-002"
|
||||
Then I should get the value "warm-data"
|
||||
And the warm tier hit count should increase by 1
|
||||
|
||||
Scenario: Store context in cold tier
|
||||
When I store a context "ctx-003" with value "cold-data" in the cold tier
|
||||
Then the context "ctx-003" should be in the cold tier
|
||||
And the cold tier should have 1 entry
|
||||
|
||||
Scenario: Retrieve context from cold tier
|
||||
Given I have stored context "ctx-003" with value "cold-data" in the cold tier
|
||||
When I retrieve context "ctx-003"
|
||||
Then I should get the value "cold-data"
|
||||
And the cold tier hit count should increase by 1
|
||||
|
||||
Scenario: Hot tier LRU eviction
|
||||
Given the hot tier has capacity of 3
|
||||
And I have stored contexts in hot tier:
|
||||
| key | value |
|
||||
| ctx-001 | data1 |
|
||||
| ctx-002 | data2 |
|
||||
| ctx-003 | data3 |
|
||||
When I store a context "ctx-004" with value "data4" in the hot tier
|
||||
Then the hot tier should have 3 entries
|
||||
And the context "ctx-001" should not be in the hot tier
|
||||
And the context "ctx-004" should be in the hot tier
|
||||
|
||||
Scenario: Promote context from warm to hot tier
|
||||
Given I have stored context "ctx-005" with value "promote-data" in the warm tier
|
||||
And the lifecycle policy requires 3 accesses for promotion
|
||||
When I retrieve context "ctx-005" exactly 3 times
|
||||
And I wait for promotion delay
|
||||
And I retrieve context "ctx-005" again
|
||||
Then the context "ctx-005" should be in the hot tier
|
||||
And the promotion count should increase by 1
|
||||
|
||||
Scenario: Promote context from cold to warm tier
|
||||
Given I have stored context "ctx-006" with value "cold-promote-data" in the cold tier
|
||||
And the lifecycle policy requires 3 accesses for promotion
|
||||
When I retrieve context "ctx-006" exactly 3 times
|
||||
And I wait for promotion delay
|
||||
And I retrieve context "ctx-006" again
|
||||
Then the context "ctx-006" should be in the warm tier
|
||||
And the promotion count should increase by 1
|
||||
|
||||
Scenario: Delete context from all tiers
|
||||
Given I have stored context "ctx-007" with value "delete-data" in the hot tier
|
||||
When I delete context "ctx-007"
|
||||
Then the context "ctx-007" should not be in the hot tier
|
||||
And the context "ctx-007" should not be in the warm tier
|
||||
And the context "ctx-007" should not be in the cold tier
|
||||
|
||||
Scenario: Clear all tiers
|
||||
Given I have stored contexts in all tiers:
|
||||
| tier | key | value |
|
||||
| hot | ctx-008 | data1 |
|
||||
| warm | ctx-009 | data2 |
|
||||
| cold | ctx-010 | data3 |
|
||||
When I clear all tiers
|
||||
Then the hot tier should have 0 entries
|
||||
And the warm tier should have 0 entries
|
||||
And the cold tier should have 0 entries
|
||||
|
||||
Scenario: Get storage tier metrics
|
||||
Given I have stored contexts in all tiers:
|
||||
| tier | key | value |
|
||||
| hot | ctx-011 | data1 |
|
||||
| warm | ctx-012 | data2 |
|
||||
| cold | ctx-013 | data3 |
|
||||
When I retrieve the storage tier metrics
|
||||
Then the metrics should show 1 entry in hot tier
|
||||
And the metrics should show 1 entry in warm tier
|
||||
And the metrics should show 1 entry in cold tier
|
||||
And the metrics should include hit and miss counts
|
||||
And the metrics should include promotion count
|
||||
|
||||
Scenario: Retrieve non-existent context
|
||||
When I retrieve context "non-existent"
|
||||
Then I should get None
|
||||
And the miss count should increase
|
||||
|
||||
Scenario: Store and retrieve large data
|
||||
When I store a context "ctx-large" with large value in the hot tier
|
||||
And I retrieve context "ctx-large"
|
||||
Then I should get the large value back
|
||||
And the hot tier size should reflect the data size
|
||||
|
||||
Scenario: Concurrent access to storage tiers
|
||||
When I concurrently store 20 contexts in the hot tier
|
||||
And I concurrently retrieve 20 contexts from the hot tier
|
||||
Then all contexts should be retrievable
|
||||
And the hit count should reflect successful retrievals
|
||||
|
||||
Scenario: Lifecycle policy configuration
|
||||
Given I have a custom lifecycle policy with:
|
||||
| setting | value |
|
||||
| hot_capacity | 500 |
|
||||
| hot_ttl_seconds | 1800 |
|
||||
| warm_capacity | 5000 |
|
||||
| warm_ttl_seconds | 43200 |
|
||||
| cold_ttl_seconds | 259200|
|
||||
| access_threshold | 5 |
|
||||
| promotion_delay_seconds| 30 |
|
||||
When I create a storage tier manager with this policy
|
||||
Then the manager should use the custom policy settings
|
||||
|
||||
Scenario: Warm tier disk persistence
|
||||
Given I have stored context "ctx-persist" with value "persistent-data" in the warm tier
|
||||
When I retrieve context "ctx-persist" after application restart
|
||||
Then I should get the value "persistent-data"
|
||||
|
||||
Scenario: Cold tier compression
|
||||
Given I have stored context "ctx-compress" with large value in the cold tier
|
||||
When I check the cold tier storage size
|
||||
Then the compressed size should be smaller than the original data size
|
||||
|
||||
Scenario: Metrics export to dictionary
|
||||
Given I have stored contexts in all tiers
|
||||
When I export metrics to dictionary
|
||||
Then the dictionary should contain all metric fields
|
||||
And the dictionary should include timestamp
|
||||
And the dictionary should be JSON serializable
|
||||
|
||||
Scenario: Lifecycle policy TTL expiry checks
|
||||
Given I have a storage tier manager with default configuration
|
||||
When I check if a recent hot tier entry is expired
|
||||
Then the hot tier entry should not be expired
|
||||
When I check if an old hot tier entry is expired
|
||||
Then the hot tier entry should be expired
|
||||
When I check if a recent warm tier entry is expired
|
||||
Then the warm tier entry should not be expired
|
||||
When I check if a recent cold tier entry is expired
|
||||
Then the cold tier entry should not be expired
|
||||
+80
-109
@@ -1,16 +1,15 @@
|
||||
"""Behave environment setup for feature tests."""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from behave.model import Scenario, Status
|
||||
|
||||
@@ -71,23 +70,29 @@ _TDD_ISSUE_N_RE = re.compile(r"tdd_issue_\d+")
|
||||
# ---------------------------------------------------------------------------
|
||||
_INITIALIZED_DBS: set[str] = set()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-global temp directory for scenario database paths
|
||||
# ---------------------------------------------------------------------------
|
||||
# Created once per-process by ``before_all`` using ``tempfile.mkdtemp()``
|
||||
# instead of the insecure ``tempfile.mktemp()``. All per-scenario SQLite
|
||||
# paths are built inside this directory (e.g.
|
||||
# ``_TEST_DB_DIR / f"cleveragents_{uuid}.db"``) so that the OS-level
|
||||
# atomicity guarantee of ``mkdtemp`` eliminates the TOCTOU race condition
|
||||
# reported in PR #9663, Round R8. Cleaned up in ``after_all`` (when
|
||||
# available) or by ``shutil.rmtree`` at process exit.
|
||||
#
|
||||
# Per-process set so parallel behavourallel workers that fork do not share
|
||||
# the same directory. Cleared in ``before_scenario`` only if a worker
|
||||
# needs to start fresh (not currently done — the parent dir persists).
|
||||
_TEST_DB_DIR: Path | None = None
|
||||
|
||||
# Guard flag so we clean up the test temp directory exactly once, in the
|
||||
# first after_scenario call where it is safe to do so.
|
||||
_TEMP_DIR_CLEANED: bool = False
|
||||
|
||||
_tdd_logger = logging.getLogger("cleveragents.testing.tdd_tags")
|
||||
|
||||
|
||||
def _warning_with_stderr(message: str) -> None:
|
||||
"""Emit a TDD warning to both logger output and stderr.
|
||||
|
||||
Behave's standard console output captures stderr reliably, while project
|
||||
logging configuration may route warning logs to structured sinks that are
|
||||
not always visible in CI snippets. Emitting to both channels makes
|
||||
non-inversion guard firings obvious during flaky-test diagnosis.
|
||||
"""
|
||||
_tdd_logger.warning(message)
|
||||
print(
|
||||
message, file=sys.stderr
|
||||
) # Intentional duplication: logger may route to structured sinks not visible in CI; stderr guarantees visibility in Behave output.
|
||||
|
||||
|
||||
def validate_tdd_tags(tags: set[str]) -> None:
|
||||
"""Validate TDD issue-capture tag combinations.
|
||||
|
||||
@@ -206,18 +211,12 @@ def apply_tdd_inversion(scenario: Any, failed: bool) -> bool:
|
||||
and step.exception is not None
|
||||
and not isinstance(step.exception, AssertionError)
|
||||
):
|
||||
# Intentional f-string (eager evaluation) rather than
|
||||
# lazy %-style: the message is always emitted to stderr via
|
||||
# print(), so deferred formatting buys nothing here.
|
||||
exc_text = str(
|
||||
step.exception
|
||||
)[
|
||||
:500
|
||||
] # Defensive truncation — avoids runaway output for exceptions with very long str() representations.
|
||||
_warning_with_stderr(
|
||||
_tdd_logger.warning(
|
||||
"Non-assertion exception in expected-fail scenario "
|
||||
f"'{scenario.name}' step '{step.name}': "
|
||||
f"{exc_text} — not inverting."
|
||||
"'%s' step '%s': %s — not inverting.",
|
||||
scenario.name,
|
||||
step.name,
|
||||
step.exception,
|
||||
)
|
||||
return failed
|
||||
|
||||
@@ -333,16 +332,21 @@ def before_all(context):
|
||||
# Ensure tests never block on migration prompts or real providers
|
||||
os.environ.setdefault("CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "true")
|
||||
os.environ.setdefault("CLEVERAGENTS_TESTING_USE_MOCK_AI", "true")
|
||||
# Use per-process unique database paths so parallel test subprocesses
|
||||
# (behave-parallel) never contend on the same SQLite file.
|
||||
|
||||
# --- Secure temp directory for database paths (TOCTOU fix) ---
|
||||
# Replace insecure ``tempfile.mktemp()`` with ``tempfile.mkdtemp()``.
|
||||
# mkdtemp is atomic at the OS level — no TOCTOU window where another
|
||||
# process could claim the path between generation and first use.
|
||||
global _TEST_DB_DIR
|
||||
_TEST_DB_DIR = Path(tempfile.mkdtemp(prefix="cleveragents_test_db_"))
|
||||
|
||||
if "CLEVERAGENTS_DATABASE_URL" not in os.environ:
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
db_path = _TEST_DB_DIR / f"cleveragents_{uuid.uuid4().hex}.db"
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
if "CLEVERAGENTS_TEST_DATABASE_URL" not in os.environ:
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_test_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
db_path = _TEST_DB_DIR / f"cleveragents_test_{uuid.uuid4().hex}.db"
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
|
||||
os.environ.setdefault("BEHAVE_TESTING", "true")
|
||||
|
||||
# Set up mock AI provider for all tests
|
||||
@@ -394,16 +398,11 @@ def _install_fast_sleep_patch() -> None:
|
||||
operations fail deterministically, so the long sleeps are pure overhead
|
||||
(~1 s per retry cycle x hundreds of scenarios = minutes of wasted time).
|
||||
|
||||
Both functions are replaced with capped versions (<=10 ms). The originals
|
||||
are stored on the modules as ``_original_sleep`` and can be retrieved with
|
||||
``getattr(time, "_original_sleep", time.sleep)`` by any test step that
|
||||
needs a genuine delay (e.g. CircuitBreaker recovery-timeout tests that
|
||||
need real wall-clock advancement past a 100 ms threshold).
|
||||
|
||||
``cast(Any, module)`` is used to assign dynamic attributes without
|
||||
``# type: ignore`` suppressions: the ``features/`` directory is excluded
|
||||
from Pyright's ``include`` list, so the cast is a documentation aid rather
|
||||
than a runtime necessity.
|
||||
Both functions are replaced with capped versions (≤ 10 ms). The originals
|
||||
are saved as ``time._original_sleep`` / ``asyncio._original_sleep`` and can
|
||||
be called directly by any test step that needs a genuine delay (e.g.
|
||||
CircuitBreaker recovery-timeout tests that need real wall-clock advancement
|
||||
past a 100 ms threshold).
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
@@ -411,31 +410,22 @@ def _install_fast_sleep_patch() -> None:
|
||||
_MAX_SLEEP = 0.01 # 10 ms cap
|
||||
|
||||
# --- synchronous time.sleep ---
|
||||
# Store the original in a typed local variable so the inner closure can
|
||||
# call it directly. cast(Any, time) lets us assign _original_sleep and
|
||||
# replace sleep without attr-defined / assignment type errors.
|
||||
if not callable(getattr(time, "_original_sleep", None)):
|
||||
_original_time_sleep: Callable[[float], None] = time.sleep
|
||||
_time_mod: Any = cast(Any, time)
|
||||
_time_mod._original_sleep = _original_time_sleep
|
||||
time._original_sleep = time.sleep # type: ignore[attr-defined]
|
||||
|
||||
def _capped_sleep(seconds: float) -> None:
|
||||
_original_time_sleep(min(seconds, _MAX_SLEEP))
|
||||
time._original_sleep(min(seconds, _MAX_SLEEP)) # type: ignore[attr-defined]
|
||||
|
||||
_time_mod.sleep = _capped_sleep
|
||||
time.sleep = _capped_sleep # type: ignore[assignment]
|
||||
|
||||
# --- asynchronous asyncio.sleep ---
|
||||
# Same pattern: capture the original in a typed local variable, then use
|
||||
# cast(Any, asyncio) to assign _original_sleep and replace sleep.
|
||||
if not callable(getattr(asyncio, "_original_sleep", None)):
|
||||
_original_asyncio_sleep = asyncio.sleep
|
||||
_asyncio_mod: Any = cast(Any, asyncio)
|
||||
_asyncio_mod._original_sleep = _original_asyncio_sleep
|
||||
asyncio._original_sleep = asyncio.sleep # type: ignore[attr-defined]
|
||||
|
||||
async def _capped_async_sleep(seconds: float, result: object = None) -> object:
|
||||
return await _original_asyncio_sleep(min(seconds, _MAX_SLEEP), result)
|
||||
return await asyncio._original_sleep(min(seconds, _MAX_SLEEP), result) # type: ignore[attr-defined]
|
||||
|
||||
_asyncio_mod.sleep = _capped_async_sleep
|
||||
asyncio.sleep = _capped_async_sleep # type: ignore[assignment]
|
||||
|
||||
|
||||
def _ensure_template_db() -> None:
|
||||
@@ -454,15 +444,8 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
lock_path = template_path.with_suffix(".db.lock")
|
||||
_lock_fd = -1
|
||||
try:
|
||||
_lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_EX)
|
||||
if template_path.is_file():
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
# Import the template creation script
|
||||
scripts_dir = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from create_template_db import create_template
|
||||
@@ -471,10 +454,6 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
except Exception:
|
||||
pass # Fall back to normal Alembic migrations
|
||||
finally:
|
||||
if _lock_fd >= 0:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||
os.close(_lock_fd)
|
||||
|
||||
|
||||
def _install_template_db_patch() -> None:
|
||||
@@ -505,11 +484,11 @@ def _install_template_db_patch() -> None:
|
||||
def _fast_init_or_upgrade(self: Any, **kwargs: Any) -> None:
|
||||
"""Replace Alembic migrations with fast alternatives.
|
||||
|
||||
- Process-global cache hit -> immediate return (no work at all)
|
||||
- Non-SQLite databases -> fall through to original
|
||||
- In-memory SQLite -> ``Base.metadata.create_all()`` + alembic stamp
|
||||
- File-based SQLite with matching prefix -> copy template
|
||||
- Everything else -> fall through to original
|
||||
- Process-global cache hit → immediate return (no work at all)
|
||||
- Non-SQLite databases → fall through to original
|
||||
- In-memory SQLite → ``Base.metadata.create_all()`` + alembic stamp
|
||||
- File-based SQLite with matching prefix → copy template
|
||||
- Everything else → fall through to original
|
||||
"""
|
||||
db_url: str = getattr(self, "database_url", "")
|
||||
|
||||
@@ -590,16 +569,6 @@ def before_scenario(context, scenario):
|
||||
context.acms_error = None
|
||||
context.assemble_error = None
|
||||
|
||||
# --- Env-var save/restore for provider registry ---
|
||||
# Proactively save env vars so they are restored even if a scenario fails
|
||||
# before reaching the step that normally records them.
|
||||
for attr, env_var in [
|
||||
("original_provider_env", "CLEVERAGENTS_DEFAULT_PROVIDER"),
|
||||
("original_model_env", "CLEVERAGENTS_DEFAULT_MODEL"),
|
||||
("original_allow_mock_env", "CLEVERAGENTS_ALLOW_MOCK_PROVIDER"),
|
||||
]:
|
||||
setattr(context, attr, os.environ.get(env_var))
|
||||
|
||||
# Ensure mock AI flag is always set so plan service tests can resolve actors
|
||||
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
|
||||
|
||||
@@ -648,10 +617,13 @@ def before_scenario(context, scenario):
|
||||
("CLEVERAGENTS_DATABASE_URL", "cleveragents_"),
|
||||
("CLEVERAGENTS_TEST_DATABASE_URL", "cleveragents_test_"),
|
||||
):
|
||||
_fd, db_path = tempfile.mkstemp(suffix=".db", prefix=prefix)
|
||||
os.close(_fd)
|
||||
db_path = (
|
||||
_TEST_DB_DIR / f"{prefix}{uuid.uuid4().hex}.db"
|
||||
if _TEST_DB_DIR is not None
|
||||
else Path(tempfile.mkstemp(suffix=".db", prefix=prefix)[1])
|
||||
)
|
||||
os.environ[env_var] = f"sqlite:///{db_path}"
|
||||
context._scenario_db_paths.append(db_path)
|
||||
context._scenario_db_paths.append(str(db_path))
|
||||
|
||||
# Clear devcontainer lifecycle registry between scenarios to prevent
|
||||
# test pollution from in-memory lifecycle trackers and health check
|
||||
@@ -699,10 +671,13 @@ def after_scenario(context, scenario):
|
||||
pass # Ignore cleanup errors
|
||||
context.test_dir = None
|
||||
|
||||
# Clean up TemporaryDirectory objects created by ACMS index traversal tests
|
||||
if hasattr(context, "temp_dir") and context.temp_dir is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
context.temp_dir.cleanup()
|
||||
# Clean up temp directories created by storage tier tests
|
||||
if hasattr(context, "temp_dir") and context.temp_dir:
|
||||
try:
|
||||
if Path(context.temp_dir).exists():
|
||||
shutil.rmtree(context.temp_dir)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
context.temp_dir = None
|
||||
|
||||
# Clean up environment variables set during tests
|
||||
@@ -742,19 +717,6 @@ def after_scenario(context, scenario):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Reset session CLI module-level service singleton so that no stale
|
||||
# service instance leaks into the next scenario. Without this reset,
|
||||
# a scenario that sets _service to a real container-constructed instance
|
||||
# would leave it cached; a subsequent scenario that patches _service
|
||||
# via unittest.mock.patch would restore to the stale real instance on
|
||||
# cleanup, causing the next scenario to hit the real container and fail.
|
||||
try:
|
||||
from cleveragents.cli.commands.session import _reset_session_service
|
||||
|
||||
_reset_session_service()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Clean up service instances
|
||||
for attr in ["context_service", "plan_service", "project_service"]:
|
||||
if hasattr(context, attr):
|
||||
@@ -769,7 +731,6 @@ def after_scenario(context, scenario):
|
||||
for attr, env_var in [
|
||||
("original_provider_env", "CLEVERAGENTS_DEFAULT_PROVIDER"),
|
||||
("original_model_env", "CLEVERAGENTS_DEFAULT_MODEL"),
|
||||
("original_allow_mock_env", "CLEVERAGENTS_ALLOW_MOCK_PROVIDER"),
|
||||
]:
|
||||
if hasattr(context, attr):
|
||||
original = getattr(context, attr)
|
||||
@@ -824,3 +785,13 @@ def after_scenario(context, scenario):
|
||||
# Scenario.run() wrapper installed in _install_tdd_expected_fail_patch(),
|
||||
# NOT in this hook. See before_all() and CONTRIBUTING.md > TDD Issue
|
||||
# Test Tags for the full specification.
|
||||
|
||||
# Clean up the process-global temp directory (created by mkdtemp in
|
||||
# before_all) exactly once, on the first after_scenario call. This
|
||||
# removes all scenario DB files atomically and frees disk space.
|
||||
global _TEMP_DIR_CLEANED
|
||||
if not _TEMP_DIR_CLEANED and _TEST_DB_DIR is not None:
|
||||
_TEMP_DIR_CLEANED = True
|
||||
with contextlib.suppress(OSError, PermissionError):
|
||||
shutil.rmtree(_TEST_DB_DIR)
|
||||
|
||||
|
||||
@@ -46,31 +46,6 @@ Feature: Execute-phase context assembler coverage
|
||||
When epcov I check path matching for "src/foo.py" with exclude "src/secret*"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative include glob
|
||||
When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with include ".opencode/**"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative exclude glob
|
||||
When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with exclude ".opencode/**"
|
||||
Then epcov the path should not match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path against relative include glob with wildcard
|
||||
When epcov I check path matching for "/app/docs/readme.md" with include "docs/*"
|
||||
Then epcov the path should match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov path matches absolute path not matching relative include glob
|
||||
When epcov I check path matching for "/app/src/main.py" with include "docs/*"
|
||||
Then epcov the path should not match
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: epcov relative path is excluded by trailing ** glob
|
||||
When epcov I check path matching for "build/debug/output.log" with exclude "build/**"
|
||||
Then epcov the path should not match
|
||||
|
||||
# ---- _resource_matches static method ----
|
||||
|
||||
Scenario: epcov resource matches with no rules passes all
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
Feature: Plugin Loader Coverage Boost
|
||||
Scenarios targeting uncovered lines in the PluginLoader class:
|
||||
- Lines 203-209: entry point load failure exception handler
|
||||
- validate_protocol: issubclass succeeds (class satisfies protocol structurally)
|
||||
- validate_protocol: issubclass raises TypeError with unverifiable protocol
|
||||
- validate_protocol: issubclass returns False (class missing required members)
|
||||
- Lines 242, 244-246: validate_protocol fallback to issubclass when instantiation fails
|
||||
- Lines 247-248: validate_protocol issubclass raises TypeError
|
||||
|
||||
Background:
|
||||
Given the plugin loader module is imported
|
||||
@@ -19,17 +18,17 @@ Feature: Plugin Loader Coverage Boost
|
||||
And the failed entry point should have been logged as a warning
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: issubclass succeeds for class satisfying protocol
|
||||
# validate_protocol: instantiation fails, issubclass succeeds (lines 242, 244-246)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol returns True when class satisfies protocol via issubclass
|
||||
Scenario: validate_protocol falls back to issubclass when instantiation fails
|
||||
Given I have a class that requires constructor arguments
|
||||
And I have a runtime checkable protocol the class satisfies via issubclass
|
||||
When I call validate_protocol with the non-instantiable class and protocol
|
||||
Then validate_protocol should return True
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
# validate_protocol: instantiation fails, issubclass raises TypeError (lines 247-248)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol raises ProtocolMismatchError when issubclass raises TypeError
|
||||
@@ -39,7 +38,7 @@ Feature: Plugin Loader Coverage Boost
|
||||
Then a plugin-loader ProtocolMismatchError should be raised
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# validate_protocol: issubclass returns False (class missing required members)
|
||||
# validate_protocol: instantiation fails, issubclass returns False
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: validate_protocol raises ProtocolMismatchError when issubclass returns False
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
@mock_only
|
||||
Feature: PR Compliance Checklist in Implementation Pool Supervisor
|
||||
|
||||
As a pool supervisor
|
||||
I want to pass a mandatory PR compliance checklist to every worker prompt
|
||||
So that implementation workers complete all required items before creating a PR and avoid systemic merge blockers
|
||||
|
||||
Background:
|
||||
Given the implementation-pool-supervisor.md agent definition exists
|
||||
|
||||
Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes the PR compliance checklist section
|
||||
And Pool: the checklist is marked as MANDATORY
|
||||
|
||||
Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CHANGELOG.md checklist item
|
||||
And Pool: the item instructs workers to add an entry under the Unreleased section
|
||||
|
||||
Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CONTRIBUTORS.md checklist item
|
||||
And Pool: the item instructs workers to add or update their contribution entry
|
||||
|
||||
Scenario: Checklist item 3 — commit footer required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a commit footer checklist item
|
||||
And Pool: the item specifies the ISSUES CLOSED footer format
|
||||
|
||||
Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a CI passes checklist item
|
||||
And Pool: the item instructs workers to verify all quality gates are green
|
||||
|
||||
Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a BDD tests checklist item
|
||||
And Pool: the item instructs workers to add or update Behave feature files
|
||||
|
||||
Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes an Epic reference checklist item
|
||||
And Pool: the item instructs workers to reference the parent Epic issue number
|
||||
|
||||
Scenario: Checklist item 7 — Labels must be applied
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a labels checklist item
|
||||
And Pool: the item instructs workers to apply labels via forgejo-label-manager
|
||||
|
||||
Scenario: Checklist item 8 — Milestone must be assigned
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body includes a milestone checklist item
|
||||
And Pool: the item instructs workers to assign the earliest open milestone
|
||||
|
||||
Scenario: All 8 checklist items are present in the worker prompt
|
||||
When I read the pool supervisor agent definition
|
||||
Then Pool: worker prompt body contains all 8 mandatory checklist items
|
||||
@@ -29,10 +29,3 @@ Feature: Project context phase analysis summaries
|
||||
When I compute project context phase analysis with budget 1000
|
||||
Then execute phase should have fewer tokens than strategize phase
|
||||
And apply phase should have fewer or equal tokens than execute phase
|
||||
|
||||
@tdd_issue @tdd_issue_10972
|
||||
Scenario: Absolute path fragments are correctly excluded by relative exclude globs
|
||||
Given a phase analysis policy with opencode exclude paths
|
||||
And an absolute path fragment for phase analysis
|
||||
When I compute project context phase analysis with budget 2000
|
||||
Then strategize phase should exclude the absolute path fragment
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
"""Step definitions for ACMS storage tiers BDD tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pickle
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.acms.storage_tiers import (
|
||||
ACMSStorageTierManager,
|
||||
LifecyclePolicy,
|
||||
)
|
||||
|
||||
|
||||
@given("I have a storage tier manager with default configuration")
|
||||
def step_create_default_manager(context: Any) -> None:
|
||||
"""Create a storage tier manager with default configuration."""
|
||||
context.temp_dir = tempfile.mkdtemp()
|
||||
context.manager = ACMSStorageTierManager(
|
||||
hot_capacity=1000,
|
||||
warm_base_path=Path(context.temp_dir) / "warm",
|
||||
cold_base_path=Path(context.temp_dir) / "cold",
|
||||
)
|
||||
|
||||
|
||||
@given("the hot tier has capacity of {capacity:d} entries")
|
||||
def step_set_hot_capacity(context: Any, capacity: int) -> None:
|
||||
"""Set hot tier capacity (guard: only create new manager if one does not exist)."""
|
||||
if not hasattr(context, "manager"):
|
||||
context.temp_dir = tempfile.mkdtemp()
|
||||
context.manager = ACMSStorageTierManager(
|
||||
hot_capacity=capacity,
|
||||
warm_base_path=Path(context.temp_dir) / "warm",
|
||||
cold_base_path=Path(context.temp_dir) / "cold",
|
||||
)
|
||||
else:
|
||||
context.manager.hot.capacity = capacity
|
||||
|
||||
|
||||
@given("the warm tier has capacity of {capacity:d} entries")
|
||||
def step_set_warm_capacity(context: Any, capacity: int) -> None:
|
||||
"""Set warm tier capacity."""
|
||||
if not hasattr(context, "manager"):
|
||||
context.temp_dir = tempfile.mkdtemp()
|
||||
context.manager = ACMSStorageTierManager(
|
||||
hot_capacity=1000,
|
||||
warm_base_path=Path(context.temp_dir) / "warm",
|
||||
cold_base_path=Path(context.temp_dir) / "cold",
|
||||
)
|
||||
context.manager.warm.capacity = capacity
|
||||
|
||||
|
||||
@given("the cold tier has unlimited capacity")
|
||||
def step_set_cold_unlimited(context: Any) -> None:
|
||||
"""Verify cold tier has unlimited capacity."""
|
||||
assert context.manager.cold is not None
|
||||
|
||||
|
||||
@when('I store a context "{key}" with value "{value}" in the hot tier')
|
||||
def step_store_in_hot(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in the hot tier."""
|
||||
context.manager.put(key, value, tier="hot")
|
||||
|
||||
|
||||
@when('I store a context "{key}" with value "{value}" in the warm tier')
|
||||
def step_store_in_warm(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in the warm tier."""
|
||||
context.manager.put(key, value, tier="warm")
|
||||
|
||||
|
||||
@when('I store a context "{key}" with value "{value}" in the cold tier')
|
||||
def step_store_in_cold(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in the cold tier."""
|
||||
context.manager.put(key, value, tier="cold")
|
||||
|
||||
|
||||
@then('the context "{key}" should be in the hot tier')
|
||||
def step_verify_in_hot(context: Any, key: str) -> None:
|
||||
"""Verify context is in hot tier."""
|
||||
value = context.manager.hot.get(key)
|
||||
assert value is not None, f"Context {key} not found in hot tier"
|
||||
|
||||
|
||||
@then('the context "{key}" should be in the warm tier')
|
||||
def step_verify_in_warm(context: Any, key: str) -> None:
|
||||
"""Verify context is in warm tier."""
|
||||
value = context.manager.warm.get(key)
|
||||
assert value is not None, f"Context {key} not found in warm tier"
|
||||
|
||||
|
||||
@then('the context "{key}" should be in the cold tier')
|
||||
def step_verify_in_cold(context: Any, key: str) -> None:
|
||||
"""Verify context is in cold tier."""
|
||||
value = context.manager.cold.get(key)
|
||||
assert value is not None, f"Context {key} not found in cold tier"
|
||||
|
||||
|
||||
@then("the hot tier should have {count:d} entry")
|
||||
def step_verify_hot_count_singular(context: Any, count: int) -> None:
|
||||
"""Verify hot tier entry count (singular)."""
|
||||
assert context.manager.hot.size() == count
|
||||
|
||||
|
||||
@then("the hot tier should have {count:d} entries")
|
||||
def step_verify_hot_count(context: Any, count: int) -> None:
|
||||
"""Verify hot tier entry count."""
|
||||
assert context.manager.hot.size() == count
|
||||
|
||||
|
||||
@then("the warm tier should have {count:d} entries")
|
||||
def step_verify_warm_count(context: Any, count: int) -> None:
|
||||
"""Verify warm tier entry count."""
|
||||
assert context.manager.warm.size() == count
|
||||
|
||||
|
||||
@then("the cold tier should have {count:d} entries")
|
||||
def step_verify_cold_count(context: Any, count: int) -> None:
|
||||
"""Verify cold tier entry count."""
|
||||
assert context.manager.cold.size() == count
|
||||
|
||||
|
||||
@when('I retrieve context "{key}"')
|
||||
def step_retrieve_context(context: Any, key: str) -> None:
|
||||
"""Retrieve a context."""
|
||||
context.retrieved_value = context.manager.get(key)
|
||||
|
||||
|
||||
@then('I should get the value "{value}"')
|
||||
def step_verify_retrieved_value(context: Any, value: str) -> None:
|
||||
"""Verify retrieved value."""
|
||||
assert context.retrieved_value == value
|
||||
|
||||
|
||||
@then("the hot tier hit count should increase by {count:d}")
|
||||
def step_verify_hot_hits(context: Any, count: int) -> None:
|
||||
"""Verify hot tier hit count increased."""
|
||||
_, _, hits, _ = context.manager.hot.get_metrics()
|
||||
assert hits >= count
|
||||
|
||||
|
||||
@then("the warm tier hit count should increase by {count:d}")
|
||||
def step_verify_warm_hits(context: Any, count: int) -> None:
|
||||
"""Verify warm tier hit count increased."""
|
||||
_, _, hits, _ = context.manager.warm.get_metrics()
|
||||
assert hits >= count
|
||||
|
||||
|
||||
@then("the cold tier hit count should increase by {count:d}")
|
||||
def step_verify_cold_hits(context: Any, count: int) -> None:
|
||||
"""Verify cold tier hit count increased."""
|
||||
_, _, hits, _ = context.manager.cold.get_metrics()
|
||||
assert hits >= count
|
||||
|
||||
|
||||
@given('I have stored context "{key}" with value "{value}" in the hot tier')
|
||||
def step_store_context_hot(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in hot tier."""
|
||||
context.manager.put(key, value, tier="hot")
|
||||
|
||||
|
||||
@given('I have stored context "{key}" with value "{value}" in the warm tier')
|
||||
def step_store_context_warm(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in warm tier."""
|
||||
context.manager.put(key, value, tier="warm")
|
||||
|
||||
|
||||
@given('I have stored context "{key}" with value "{value}" in the cold tier')
|
||||
def step_store_context_cold(context: Any, key: str, value: str) -> None:
|
||||
"""Store a context in cold tier."""
|
||||
context.manager.put(key, value, tier="cold")
|
||||
|
||||
|
||||
@then('the context "{key}" should not be in the hot tier')
|
||||
def step_verify_not_in_hot(context: Any, key: str) -> None:
|
||||
"""Verify context is not in hot tier."""
|
||||
value = context.manager.hot.get(key)
|
||||
assert value is None, f"Context {key} should not be in hot tier"
|
||||
|
||||
|
||||
@then('the context "{key}" should not be in the warm tier')
|
||||
def step_verify_not_in_warm(context: Any, key: str) -> None:
|
||||
"""Verify context is not in warm tier."""
|
||||
value = context.manager.warm.get(key)
|
||||
assert value is None, f"Context {key} should not be in warm tier"
|
||||
|
||||
|
||||
@then('the context "{key}" should not be in the cold tier')
|
||||
def step_verify_not_in_cold(context: Any, key: str) -> None:
|
||||
"""Verify context is not in cold tier."""
|
||||
value = context.manager.cold.get(key)
|
||||
assert value is None, f"Context {key} should not be in cold tier"
|
||||
|
||||
|
||||
@given("I have stored contexts in hot tier:")
|
||||
def step_store_multiple_hot(context: Any) -> None:
|
||||
"""Store multiple contexts in hot tier."""
|
||||
for row in context.table:
|
||||
context.manager.put(row["key"], row["value"], tier="hot")
|
||||
|
||||
|
||||
@given("the lifecycle policy requires {count:d} accesses for promotion")
|
||||
def step_set_lifecycle_policy(context: Any, count: int) -> None:
|
||||
"""Set lifecycle policy access threshold and zero promotion delay."""
|
||||
context.manager.policy.access_threshold = count
|
||||
context.manager.engine.policy.access_threshold = count
|
||||
# Set promotion delay to 0 so tests don't need to wait
|
||||
context.manager.policy.promotion_delay_seconds = 0
|
||||
context.manager.engine.policy.promotion_delay_seconds = 0
|
||||
|
||||
|
||||
@when('I retrieve context "{key}" exactly {count:d} times')
|
||||
def step_retrieve_multiple(context: Any, key: str, count: int) -> None:
|
||||
"""Retrieve a context multiple times."""
|
||||
for _ in range(count):
|
||||
context.manager.get(key)
|
||||
|
||||
|
||||
@when("I wait for promotion delay")
|
||||
def step_wait_promotion(context: Any) -> None:
|
||||
"""Wait for promotion delay (no-op since delay is set to 0 in tests)."""
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
@when('I retrieve context "{key}" again')
|
||||
def step_retrieve_again(context: Any, key: str) -> None:
|
||||
"""Retrieve a context one more time to trigger promotion."""
|
||||
context.retrieved_value = context.manager.get(key)
|
||||
|
||||
|
||||
@then("the promotion count should increase by {count:d}")
|
||||
def step_verify_promotions(context: Any, count: int) -> None:
|
||||
"""Verify promotion count increased."""
|
||||
metrics = context.manager.get_metrics()
|
||||
assert metrics.promotions >= count
|
||||
|
||||
|
||||
@when('I delete context "{key}"')
|
||||
def step_delete_context(context: Any, key: str) -> None:
|
||||
"""Delete a context."""
|
||||
context.manager.delete(key)
|
||||
|
||||
|
||||
@given("I have stored contexts in all tiers:")
|
||||
def step_store_all_tiers(context: Any) -> None:
|
||||
"""Store contexts in all tiers."""
|
||||
for row in context.table:
|
||||
context.manager.put(row["key"], row["value"], tier=row["tier"])
|
||||
|
||||
|
||||
@when("I clear all tiers")
|
||||
def step_clear_all(context: Any) -> None:
|
||||
"""Clear all tiers."""
|
||||
context.manager.clear()
|
||||
|
||||
|
||||
@when("I retrieve the storage tier metrics")
|
||||
def step_get_metrics(context: Any) -> None:
|
||||
"""Retrieve storage tier metrics."""
|
||||
context.metrics = context.manager.get_metrics()
|
||||
|
||||
|
||||
@then("the metrics should show {count:d} entry in hot tier")
|
||||
def step_verify_metrics_hot(context: Any, count: int) -> None:
|
||||
"""Verify metrics show hot tier count."""
|
||||
assert context.metrics.hot_entry_count == count
|
||||
|
||||
|
||||
@then("the metrics should show {count:d} entry in warm tier")
|
||||
def step_verify_metrics_warm(context: Any, count: int) -> None:
|
||||
"""Verify metrics show warm tier count."""
|
||||
assert context.metrics.warm_entry_count == count
|
||||
|
||||
|
||||
@then("the metrics should show {count:d} entry in cold tier")
|
||||
def step_verify_metrics_cold(context: Any, count: int) -> None:
|
||||
"""Verify metrics show cold tier count."""
|
||||
assert context.metrics.cold_entry_count == count
|
||||
|
||||
|
||||
@then("the metrics should include hit and miss counts")
|
||||
def step_verify_metrics_hits_misses(context: Any) -> None:
|
||||
"""Verify metrics include hit and miss counts."""
|
||||
assert hasattr(context.metrics, "hot_hits")
|
||||
assert hasattr(context.metrics, "hot_misses")
|
||||
assert hasattr(context.metrics, "warm_hits")
|
||||
assert hasattr(context.metrics, "warm_misses")
|
||||
assert hasattr(context.metrics, "cold_hits")
|
||||
assert hasattr(context.metrics, "cold_misses")
|
||||
|
||||
|
||||
@then("the metrics should include promotion count")
|
||||
def step_verify_metrics_promotions(context: Any) -> None:
|
||||
"""Verify metrics include promotion count."""
|
||||
assert hasattr(context.metrics, "promotions")
|
||||
|
||||
|
||||
@then("I should get None")
|
||||
def step_verify_none(context: Any) -> None:
|
||||
"""Verify retrieved value is None."""
|
||||
assert context.retrieved_value is None
|
||||
|
||||
|
||||
@then("the miss count should increase")
|
||||
def step_verify_miss_count(context: Any) -> None:
|
||||
"""Verify miss count increased."""
|
||||
_, _, _, misses = context.manager.hot.get_metrics()
|
||||
assert misses > 0
|
||||
|
||||
|
||||
@when('I store a context "{key}" with large value in the hot tier')
|
||||
def step_store_large_hot(context: Any, key: str) -> None:
|
||||
"""Store large value in hot tier."""
|
||||
large_value = "x" * 10000
|
||||
context.large_value = large_value
|
||||
context.manager.put(key, large_value, tier="hot")
|
||||
|
||||
|
||||
@given('I have stored context "{key}" with large value in the cold tier')
|
||||
def step_store_large_cold(context: Any, key: str) -> None:
|
||||
"""Store large value in cold tier."""
|
||||
large_value = "x" * 10000
|
||||
context.large_value = large_value
|
||||
context.manager.put(key, large_value, tier="cold")
|
||||
|
||||
|
||||
@then("I should get the large value back")
|
||||
def step_verify_large_value(context: Any) -> None:
|
||||
"""Verify large value retrieved."""
|
||||
assert context.retrieved_value is not None
|
||||
assert len(context.retrieved_value) == 10000
|
||||
|
||||
|
||||
@then("the hot tier size should reflect the data size")
|
||||
def step_verify_hot_size(context: Any) -> None:
|
||||
"""Verify hot tier size reflects data."""
|
||||
_, size, _, _ = context.manager.hot.get_metrics()
|
||||
assert size > 0
|
||||
|
||||
|
||||
@when("I concurrently store {count:d} contexts in the hot tier")
|
||||
def step_concurrent_store(context: Any, count: int) -> None:
|
||||
"""Store contexts concurrently."""
|
||||
|
||||
def store_context(i: int) -> None:
|
||||
context.manager.put(f"ctx-{i}", f"data-{i}", tier="hot")
|
||||
|
||||
threads = [threading.Thread(target=store_context, args=(i,)) for i in range(count)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
|
||||
@when("I concurrently retrieve {count:d} contexts from the hot tier")
|
||||
def step_concurrent_retrieve(context: Any, count: int) -> None:
|
||||
"""Retrieve contexts concurrently."""
|
||||
context.retrieved_values = {}
|
||||
|
||||
def retrieve_context(i: int) -> None:
|
||||
context.retrieved_values[f"ctx-{i}"] = context.manager.get(f"ctx-{i}")
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=retrieve_context, args=(i,)) for i in range(count)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
|
||||
@then("all contexts should be retrievable")
|
||||
def step_verify_all_retrievable(context: Any) -> None:
|
||||
"""Verify all contexts are retrievable."""
|
||||
for key, value in context.retrieved_values.items():
|
||||
assert value is not None, f"Context {key} not retrievable"
|
||||
|
||||
|
||||
@then("the hit count should reflect successful retrievals")
|
||||
def step_verify_hit_count(context: Any) -> None:
|
||||
"""Verify hit count reflects retrievals."""
|
||||
_, _, hits, _ = context.manager.hot.get_metrics()
|
||||
assert hits > 0
|
||||
|
||||
|
||||
@given("I have a custom lifecycle policy with:")
|
||||
def step_create_custom_policy(context: Any) -> None:
|
||||
"""Create custom lifecycle policy."""
|
||||
policy_dict = {}
|
||||
for row in context.table:
|
||||
policy_dict[row["setting"]] = int(row["value"])
|
||||
|
||||
context.custom_policy = LifecyclePolicy(
|
||||
hot_capacity=policy_dict.get("hot_capacity", 1000),
|
||||
hot_ttl_seconds=policy_dict.get("hot_ttl_seconds", 3600),
|
||||
warm_capacity=policy_dict.get("warm_capacity", 10000),
|
||||
warm_ttl_seconds=policy_dict.get("warm_ttl_seconds", 86400),
|
||||
cold_ttl_seconds=policy_dict.get("cold_ttl_seconds", 604800),
|
||||
access_threshold=policy_dict.get("access_threshold", 3),
|
||||
promotion_delay_seconds=policy_dict.get("promotion_delay_seconds", 60),
|
||||
)
|
||||
|
||||
|
||||
@when("I create a storage tier manager with this policy")
|
||||
def step_create_manager_with_policy(context: Any) -> None:
|
||||
"""Create manager with custom policy."""
|
||||
context.temp_dir = tempfile.mkdtemp()
|
||||
context.manager = ACMSStorageTierManager(
|
||||
hot_capacity=context.custom_policy.hot_capacity,
|
||||
warm_base_path=Path(context.temp_dir) / "warm",
|
||||
cold_base_path=Path(context.temp_dir) / "cold",
|
||||
policy=context.custom_policy,
|
||||
)
|
||||
|
||||
|
||||
@then("the manager should use the custom policy settings")
|
||||
def step_verify_policy_settings(context: Any) -> None:
|
||||
"""Verify manager uses custom policy."""
|
||||
assert context.manager.policy.hot_capacity == context.custom_policy.hot_capacity
|
||||
assert (
|
||||
context.manager.policy.access_threshold
|
||||
== context.custom_policy.access_threshold
|
||||
)
|
||||
|
||||
|
||||
@when('I retrieve context "{key}" after application restart')
|
||||
def step_retrieve_after_restart(context: Any, key: str) -> None:
|
||||
"""Retrieve context after simulated restart (creates a new manager with same paths)."""
|
||||
new_manager = ACMSStorageTierManager(
|
||||
hot_capacity=1000,
|
||||
warm_base_path=Path(context.temp_dir) / "warm",
|
||||
cold_base_path=Path(context.temp_dir) / "cold",
|
||||
)
|
||||
context.retrieved_value = new_manager.get(key)
|
||||
|
||||
|
||||
@when("I check the cold tier storage size")
|
||||
def step_check_cold_size(context: Any) -> None:
|
||||
"""Check cold tier storage size."""
|
||||
_, context.cold_size, _, _ = context.manager.cold.get_metrics()
|
||||
|
||||
|
||||
@then("the compressed size should be smaller than the original data size")
|
||||
def step_verify_compression(context: Any) -> None:
|
||||
"""Verify compression reduces size."""
|
||||
# Compare compressed file size (cold_size, tracked incrementally in metrics)
|
||||
# against the pickled but *uncompressed* data size. The cold tier stores
|
||||
# ``gzip(pickle(value))`` so to make a fair comparison we measure what the
|
||||
# pickle-only output would be (without gzip). Repeated-character strings
|
||||
# of 10 000+ bytes are highly compressible by gzip so this assertion reliably
|
||||
# passes with a genuine gzip-based compression implementation.
|
||||
try:
|
||||
_original_bytes = len(pickle.dumps(context.large_value))
|
||||
except Exception:
|
||||
_original_bytes = len(str(context.large_value).encode())
|
||||
assert context.cold_size > 0, "Cold tier size must be positive"
|
||||
assert (
|
||||
context.cold_size < _original_bytes
|
||||
), f"Compressed size {context.cold_size} should be less than pickled size {_original_bytes}"
|
||||
|
||||
|
||||
@when("I export metrics to dictionary")
|
||||
def step_export_metrics(context: Any) -> None:
|
||||
"""Export metrics to dictionary."""
|
||||
metrics = context.manager.get_metrics()
|
||||
context.metrics_dict = metrics.to_dict()
|
||||
|
||||
|
||||
@then("the dictionary should contain all metric fields")
|
||||
def step_verify_dict_fields(context: Any) -> None:
|
||||
"""Verify dictionary contains all fields."""
|
||||
required_fields = [
|
||||
"hot_entry_count",
|
||||
"warm_entry_count",
|
||||
"cold_entry_count",
|
||||
"hot_size_bytes",
|
||||
"warm_size_bytes",
|
||||
"cold_size_bytes",
|
||||
"hot_hits",
|
||||
"warm_hits",
|
||||
"cold_hits",
|
||||
"hot_misses",
|
||||
"warm_misses",
|
||||
"cold_misses",
|
||||
"promotions",
|
||||
"demotions",
|
||||
"timestamp",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in context.metrics_dict, f"Missing field: {field}"
|
||||
|
||||
|
||||
@then("the dictionary should include timestamp")
|
||||
def step_verify_timestamp(context: Any) -> None:
|
||||
"""Verify dictionary includes timestamp."""
|
||||
assert "timestamp" in context.metrics_dict
|
||||
assert isinstance(context.metrics_dict["timestamp"], str)
|
||||
|
||||
|
||||
@then("the dictionary should be JSON serializable")
|
||||
def step_verify_json_serializable(context: Any) -> None:
|
||||
"""Verify dictionary is JSON serializable."""
|
||||
json_str = json.dumps(context.metrics_dict)
|
||||
assert json_str is not None
|
||||
|
||||
|
||||
@given("I have stored contexts in all tiers")
|
||||
def step_store_contexts_all_tiers_no_table(context: Any) -> None:
|
||||
"""Store contexts in all tiers (no table variant)."""
|
||||
context.manager.put("ctx-metrics-hot", "data-hot", tier="hot")
|
||||
context.manager.put("ctx-metrics-warm", "data-warm", tier="warm")
|
||||
context.manager.put("ctx-metrics-cold", "data-cold", tier="cold")
|
||||
|
||||
|
||||
@when("I check if a recent hot tier entry is expired")
|
||||
def step_check_recent_hot_expired(context: Any) -> None:
|
||||
"""Check if a recent hot tier entry is expired."""
|
||||
context.hot_expired = context.manager.engine.is_hot_expired(
|
||||
time.time(), ttl_seconds=3600
|
||||
)
|
||||
|
||||
|
||||
@then("the hot tier entry should not be expired")
|
||||
def step_verify_hot_not_expired(context: Any) -> None:
|
||||
"""Verify hot tier entry is not expired."""
|
||||
assert not context.hot_expired
|
||||
|
||||
|
||||
@when("I check if an old hot tier entry is expired")
|
||||
def step_check_old_hot_expired(context: Any) -> None:
|
||||
"""Check if an old hot tier entry is expired."""
|
||||
old_timestamp = time.time() - 7200 # 2 hours ago
|
||||
context.hot_expired = context.manager.engine.is_hot_expired(
|
||||
old_timestamp, ttl_seconds=3600
|
||||
)
|
||||
|
||||
|
||||
@then("the hot tier entry should be expired")
|
||||
def step_verify_hot_expired(context: Any) -> None:
|
||||
"""Verify hot tier entry is expired."""
|
||||
assert context.hot_expired
|
||||
|
||||
|
||||
@when("I check if a recent warm tier entry is expired")
|
||||
def step_check_recent_warm_expired(context: Any) -> None:
|
||||
"""Check if a recent warm tier entry is expired."""
|
||||
context.warm_expired = context.manager.engine.is_warm_expired(
|
||||
time.time(), ttl_seconds=86400
|
||||
)
|
||||
|
||||
|
||||
@then("the warm tier entry should not be expired")
|
||||
def step_verify_warm_not_expired(context: Any) -> None:
|
||||
"""Verify warm tier entry is not expired."""
|
||||
assert not context.warm_expired
|
||||
|
||||
|
||||
@when("I check if a recent cold tier entry is expired")
|
||||
def step_check_recent_cold_expired(context: Any) -> None:
|
||||
"""Check if a recent cold tier entry is expired."""
|
||||
context.cold_expired = context.manager.engine.is_cold_expired(
|
||||
time.time(), ttl_seconds=604800
|
||||
)
|
||||
|
||||
|
||||
@then("the cold tier entry should not be expired")
|
||||
def step_verify_cold_not_expired(context: Any) -> None:
|
||||
"""Verify cold tier entry is not expired."""
|
||||
assert not context.cold_expired
|
||||
@@ -4,9 +4,9 @@ These steps target specific uncovered lines in
|
||||
cleveragents/infrastructure/plugins/loader.py:
|
||||
|
||||
- Lines 203-209: except block in load_from_entry_points when ep.load() fails
|
||||
- validate_protocol: issubclass succeeds (class satisfies protocol structurally)
|
||||
- validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
- validate_protocol: issubclass returns False (class missing required members)
|
||||
- Lines 242, 244-246: validate_protocol fallback to issubclass on
|
||||
instantiation failure
|
||||
- Lines 247-248: validate_protocol when issubclass raises TypeError
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
@@ -72,13 +72,13 @@ def step_verify_warning_logged(context):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: issubclass succeeds for class satisfying protocol
|
||||
# validate_protocol: instantiation fails, issubclass succeeds (lines 242, 244-246)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SampleProtocol(Protocol):
|
||||
"""A simple runtime-checkable Protocol for testing issubclass check."""
|
||||
"""A simple runtime-checkable Protocol for testing issubclass fallback."""
|
||||
|
||||
def do_work(self) -> str: ...
|
||||
|
||||
@@ -105,7 +105,7 @@ def step_protocol_satisfied_by_subclass(context):
|
||||
|
||||
@when("I call validate_protocol with the non-instantiable class and protocol")
|
||||
def step_call_validate_protocol_subclass_fallback(context):
|
||||
"""Call validate_protocol; issubclass succeeds and returns True."""
|
||||
"""Call validate_protocol; expect it to fall back to issubclass and succeed."""
|
||||
context.validate_result = PluginLoader.validate_protocol(
|
||||
context.non_instantiable_class,
|
||||
context.target_protocol,
|
||||
@@ -114,18 +114,18 @@ def step_call_validate_protocol_subclass_fallback(context):
|
||||
|
||||
@then("validate_protocol should return True")
|
||||
def step_verify_validate_true(context):
|
||||
"""The issubclass check should have returned True."""
|
||||
"""The fallback issubclass check should have returned True."""
|
||||
assert context.validate_result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: issubclass raises TypeError for unverifiable protocol
|
||||
# validate_protocol: instantiation fails, issubclass raises TypeError (lines 247-248)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have a class that cannot be instantiated without arguments")
|
||||
def step_class_cannot_instantiate(context):
|
||||
"""Create a class whose __init__ requires mandatory arguments."""
|
||||
"""Create a class whose __init__ raises when called with no args."""
|
||||
|
||||
class NeedsArgs:
|
||||
def __init__(self, required):
|
||||
@@ -141,10 +141,10 @@ def step_protocol_causes_typeerror(context):
|
||||
We need an object that:
|
||||
- Has a __name__ attribute (so the error message in validate_protocol works)
|
||||
- Causes issubclass() to raise TypeError when used as second arg
|
||||
- Also causes isinstance() to raise TypeError
|
||||
|
||||
A class with a metaclass that raises TypeError on __subclasscheck__
|
||||
achieves this. Since this protocol declares no members, the structural
|
||||
fallback cannot verify conformance and must raise ProtocolMismatchError.
|
||||
A class with a metaclass that raises TypeError on __instancecheck__
|
||||
and __subclasscheck__ achieves this.
|
||||
"""
|
||||
|
||||
class TypeErrorMeta(type):
|
||||
@@ -186,7 +186,7 @@ def step_verify_protocol_mismatch(context):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_protocol: issubclass returns False (class missing required members)
|
||||
# validate_protocol: instantiation fails, issubclass returns False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Step definitions for PR compliance checklist in implementation pool supervisor.
|
||||
|
||||
This file uses parameterized @then decorators with unique step text that
|
||||
distinguishes pool-supervisor checks from the shared compliance checklist
|
||||
steps in pr_compliance_checklist_steps.py, preventing Behave AmbiguousStep
|
||||
errors when both feature files are run together.
|
||||
|
||||
Each validator is imported from the shared pr_compliance_checklist_steps module's
|
||||
validation logic (via the _verify module) to avoid code duplication while using
|
||||
unique step text prefixes ("Pool:") for disambiguation.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
AGENT_DEF_PATH = (
|
||||
PROJECT_ROOT / ".opencode" / "agents" / "implementation-pool-supervisor.md"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared validation helpers — identical logic to pr_compliance_checklist_steps.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_required_items = [
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTORS.md",
|
||||
"ISSUES CLOSED",
|
||||
"CI passes",
|
||||
"BDD/Behave tests",
|
||||
"Epic reference",
|
||||
"forgejo-label-manager",
|
||||
"earliest open milestone",
|
||||
]
|
||||
|
||||
VALIDATORS: dict[str, Callable[[str], bool]] = {
|
||||
"includes checklist section": lambda c: "PR Compliance Checklist" in c,
|
||||
"is marked MANDATORY": lambda c: "MANDATORY" in c,
|
||||
"has CHANGELOG.md item": lambda c: "CHANGELOG.md" in c,
|
||||
"references Unreleased": lambda c: "[Unreleased]" in c,
|
||||
"has CONTRIBUTORS.md item": lambda c: "CONTRIBUTORS.md" in c,
|
||||
"instructs add or update": lambda c: "add or update" in c,
|
||||
"has commit footer item": lambda c: "Commit footer" in c,
|
||||
"specifies ISSUES CLOSED": lambda c: "ISSUES CLOSED" in c,
|
||||
"has CI passes item": lambda c: "CI passes" in c,
|
||||
"mentions quality gates": lambda c: "quality gates" in c,
|
||||
"has BDD tests item": lambda c: "BDD/Behave tests" in c,
|
||||
"instructs add or update features": lambda c: "added or updated" in c,
|
||||
"has Epic reference item": lambda c: "Epic reference" in c,
|
||||
"references parent Epic": lambda c: "parent Epic" in c,
|
||||
"has labels item": lambda c: "Labels" in c,
|
||||
"mentions forgejo-label-manager": lambda c: "forgejo-label-manager" in c,
|
||||
"has milestone item": lambda c: "Milestone" in c,
|
||||
"earliest open milestone": lambda c: "earliest open milestone" in c,
|
||||
"all 8 items present": lambda c: all(item in c for item in _required_items),
|
||||
}
|
||||
|
||||
|
||||
def _make_validator(key: str) -> Callable[[Any], None]:
|
||||
"""Factory that creates a typed Behave validator from a shared helper."""
|
||||
|
||||
def validator(context: Any) -> None:
|
||||
content = context.agent_def_content
|
||||
check_fn = VALIDATORS.get(key)
|
||||
assert check_fn(content), f"Pool supervisor agent definition failed: {key}"
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @given and @when — scoped to the pool supervisor agent def only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the implementation-pool-supervisor.md agent definition exists")
|
||||
def step_agent_def_exists(context: Any) -> None:
|
||||
"""Verify the pool supervisor agent definition file exists."""
|
||||
assert AGENT_DEF_PATH.exists(), f"Agent definition not found at {AGENT_DEF_PATH}"
|
||||
context.agent_def_path = AGENT_DEF_PATH
|
||||
|
||||
|
||||
@when("I read the pool supervisor agent definition")
|
||||
def step_read_agent_def(context: Any) -> None:
|
||||
"""Read the pool supervisor agent definition."""
|
||||
context.agent_def_content = AGENT_DEF_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unique @then — prefixed with "Pool:" so they never conflict with the
|
||||
# shared pr_compliance_checklist_steps.py step definitions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Scenario: Pool supervisor worker prompt includes the PR compliance checklist
|
||||
@then("Pool: worker prompt body includes the PR compliance checklist section")
|
||||
def pool_step_prompt_includes_checklist(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes the PR compliance checklist."""
|
||||
_make_validator("includes checklist section")(context)
|
||||
|
||||
|
||||
@then("Pool: the checklist is marked as MANDATORY")
|
||||
def pool_step_checklist_is_mandatory(context: Any) -> None:
|
||||
"""Verify the pool-supervisor checklist is marked as MANDATORY."""
|
||||
_make_validator("is marked MANDATORY")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 1 — CHANGELOG.md update required
|
||||
@then("Pool: worker prompt body includes a CHANGELOG.md checklist item")
|
||||
def pool_step_prompt_includes_changelog_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CHANGELOG.md checklist item."""
|
||||
_make_validator("has CHANGELOG.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add an entry under the Unreleased section")
|
||||
def pool_step_changelog_item_unreleased(context: Any) -> None:
|
||||
"""Verify the CHANGELOG.md item mentions the Unreleased section."""
|
||||
_make_validator("references Unreleased")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 2 — CONTRIBUTORS.md update required
|
||||
@then("Pool: worker prompt body includes a CONTRIBUTORS.md checklist item")
|
||||
def pool_step_prompt_includes_contributors_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CONTRIBUTORS.md checklist item."""
|
||||
_make_validator("has CONTRIBUTORS.md item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update their contribution entry")
|
||||
def pool_step_contributors_item_add_update(context: Any) -> None:
|
||||
"""Verify the CONTRIBUTORS.md item instructs workers to add or update."""
|
||||
_make_validator("instructs add or update")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 3 — commit footer required
|
||||
@then("Pool: worker prompt body includes a commit footer checklist item")
|
||||
def pool_step_prompt_includes_commit_footer_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a commit footer checklist item."""
|
||||
_make_validator("has commit footer item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item specifies the ISSUES CLOSED footer format")
|
||||
def pool_step_commit_footer_issues_closed(context: Any) -> None:
|
||||
"""Verify the commit footer item specifies the ISSUES CLOSED format."""
|
||||
_make_validator("specifies ISSUES CLOSED")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 4 — CI must pass before PR creation
|
||||
@then("Pool: worker prompt body includes a CI passes checklist item")
|
||||
def pool_step_prompt_includes_ci_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a CI passes checklist item."""
|
||||
_make_validator("has CI passes item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to verify all quality gates are green")
|
||||
def pool_step_ci_item_quality_gates(context: Any) -> None:
|
||||
"""Verify the CI item instructs workers to verify quality gates are green."""
|
||||
_make_validator("mentions quality gates")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 5 — BDD/Behave tests required
|
||||
@then("Pool: worker prompt body includes a BDD tests checklist item")
|
||||
def pool_step_prompt_includes_bdd_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a BDD/Behave tests checklist item."""
|
||||
_make_validator("has BDD tests item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to add or update Behave feature files")
|
||||
def pool_step_bdd_item_feature_files(context: Any) -> None:
|
||||
"""Verify the BDD item instructs workers to add or update feature files."""
|
||||
_make_validator("instructs add or update features")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 6 — Epic reference required in PR description
|
||||
@then("Pool: worker prompt body includes an Epic reference checklist item")
|
||||
def pool_step_prompt_includes_epic_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes an Epic reference checklist item."""
|
||||
_make_validator("has Epic reference item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to reference the parent Epic issue number")
|
||||
def pool_step_epic_item_parent_reference(context: Any) -> None:
|
||||
"""Verify the Epic item instructs workers to reference the parent Epic."""
|
||||
_make_validator("references parent Epic")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 7 — Labels must be applied
|
||||
@then("Pool: worker prompt body includes a labels checklist item")
|
||||
def pool_step_prompt_includes_labels_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a labels checklist item."""
|
||||
_make_validator("has labels item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to apply labels via forgejo-label-manager")
|
||||
def pool_step_labels_item_forgejo_label_manager(context: Any) -> None:
|
||||
"""Verify the labels item instructs workers to use forgejo-label-manager."""
|
||||
_make_validator("mentions forgejo-label-manager")(context)
|
||||
|
||||
|
||||
# Scenario: Checklist item 8 — Milestone must be assigned
|
||||
@then("Pool: worker prompt body includes a milestone checklist item")
|
||||
def pool_step_prompt_includes_milestone_item(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body includes a milestone checklist item."""
|
||||
_make_validator("has milestone item")(context)
|
||||
|
||||
|
||||
@then("Pool: the item instructs workers to assign the earliest open milestone")
|
||||
def pool_step_milestone_item_earliest(context: Any) -> None:
|
||||
"""Verify the milestone item instructs workers to assign the earliest open milestone."""
|
||||
_make_validator("earliest open milestone")(context)
|
||||
|
||||
|
||||
# Scenario: All 8 checklist items are present in the worker prompt
|
||||
@then("Pool: worker prompt body contains all 8 mandatory checklist items")
|
||||
def pool_step_prompt_contains_all_8_items(context: Any) -> None:
|
||||
"""Verify the pool-supervisor worker prompt body contains all 8 mandatory checklist items."""
|
||||
_make_validator("all 8 items present")(context)
|
||||
@@ -183,54 +183,3 @@ def step_apply_less_or_equal(context: Any) -> None:
|
||||
exec_tokens = context.phase_result["phases"]["execute"]["total_tokens"]
|
||||
apply_tokens = context.phase_result["phases"]["apply"]["total_tokens"]
|
||||
assert apply_tokens <= exec_tokens
|
||||
|
||||
|
||||
@given("a phase analysis policy with opencode exclude paths")
|
||||
def step_policy_opencode_exclude(context: Any) -> None:
|
||||
"""Policy that excludes .opencode/** paths using relative globs."""
|
||||
context.phase_policy = ProjectContextPolicy(
|
||||
default_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
),
|
||||
strategize_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
execute_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
apply_view=ContextView(
|
||||
include_resources=["local/*"],
|
||||
exclude_paths=[".opencode/**", "docs/**", "features/**"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@given("an absolute path fragment for phase analysis")
|
||||
def step_absolute_path_fragment(context: Any) -> None:
|
||||
"""Fragment with an absolute path that should be excluded by relative globs."""
|
||||
context.phase_fragments = [
|
||||
TieredFragment(
|
||||
fragment_id="abs-skill",
|
||||
content="skill content",
|
||||
tier=ContextTier.HOT,
|
||||
resource_id="local/repo-a",
|
||||
project_name="local/ctx-app",
|
||||
token_count=50,
|
||||
metadata={
|
||||
"path": "/app/.opencode/skills/SKILL.md",
|
||||
"byte_size": 1000,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@then("strategize phase should exclude the absolute path fragment")
|
||||
def step_strat_excludes_absolute(context: Any) -> None:
|
||||
"""Verify that the absolute path fragment is excluded by relative glob patterns."""
|
||||
strat = context.phase_result["phases"]["strategize"]
|
||||
assert strat["fragment_count"] == 0, (
|
||||
f"Expected 0 fragments (absolute path should be excluded by relative glob), "
|
||||
f"got {strat['fragment_count']}"
|
||||
)
|
||||
|
||||
@@ -82,20 +82,20 @@ def _build_mock_textual():
|
||||
def update(self, text):
|
||||
self._text = text
|
||||
|
||||
class MockInput:
|
||||
"""Minimal Input stand-in for the Textual base class."""
|
||||
class MockTextArea:
|
||||
"""Minimal TextArea stand-in for the Textual base class."""
|
||||
|
||||
value = ""
|
||||
text = ""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.value = ""
|
||||
self.text = ""
|
||||
|
||||
mock_textual_app.App = MockApp
|
||||
mock_textual_containers.Vertical = MockVertical
|
||||
mock_textual_widgets.Header = MockHeader
|
||||
mock_textual_widgets.Footer = MockFooter
|
||||
mock_textual_widgets.Static = MockStatic
|
||||
mock_textual_widgets.Input = MockInput
|
||||
mock_textual_widgets.TextArea = MockTextArea
|
||||
|
||||
return {
|
||||
"textual": mock_textual,
|
||||
@@ -114,7 +114,7 @@ def _install_mock_textual(context):
|
||||
for key, mod in mocks.items():
|
||||
sys.modules[key] = mod
|
||||
|
||||
# Reload widget modules so they pick up the mock Static/Input base class
|
||||
# Reload widget modules so they pick up the mock Static/TextArea base class
|
||||
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
|
||||
import cleveragents.tui.widgets.persona_bar as pb_mod
|
||||
import cleveragents.tui.widgets.prompt as prompt_mod
|
||||
@@ -142,7 +142,7 @@ def _restore_modules(context):
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
|
||||
# Reload widget modules so they pick up the real Static/Input base class again
|
||||
# Reload widget modules so they pick up the real Static/TextArea base class again
|
||||
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
|
||||
import cleveragents.tui.widgets.persona_bar as pb_mod
|
||||
import cleveragents.tui.widgets.prompt as prompt_mod
|
||||
@@ -370,7 +370,7 @@ def step_set_prompt_text(context, text):
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
||||
prompt.value = text
|
||||
prompt.text = text
|
||||
|
||||
|
||||
@then('the conversation widget should contain "{text}"')
|
||||
@@ -425,11 +425,11 @@ def step_persona_bar_shows_name(context):
|
||||
# on_input_submitted helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _submit_text(context, text):
|
||||
"""Set prompt value and fire on_input_submitted."""
|
||||
"""Set prompt text and fire on_input_submitted."""
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
prompt = context._tui_app.query_one("#prompt", PromptInput)
|
||||
prompt.value = text
|
||||
prompt.text = text
|
||||
event = SimpleNamespace()
|
||||
context._tui_app.on_input_submitted(event)
|
||||
|
||||
|
||||
@@ -143,3 +143,13 @@ def step_run_fallback_tui_app(context: Context) -> None:
|
||||
def step_fallback_tui_fails(context: Context, message: str) -> None:
|
||||
assert context.tui_fallback_error is not None
|
||||
assert message in str(context.tui_fallback_error)
|
||||
|
||||
|
||||
@when('I detect mode for "{text}"')
|
||||
def step_detect_mode(context: Context, text: str) -> None:
|
||||
context.detected_mode = InputModeRouter.detect_mode(text)
|
||||
|
||||
|
||||
@then('the detected mode should be "{mode}"')
|
||||
def step_detected_mode_equals(context: Context, mode: str) -> None:
|
||||
assert context.detected_mode.value == mode
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Behave steps for TUI prompt symbol handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.tui.widgets.prompt import PromptInput
|
||||
|
||||
|
||||
@given("a TUI prompt widget")
|
||||
def step_create_prompt(context: Context) -> None:
|
||||
context.tui_prompt = PromptInput(
|
||||
placeholder="Type message, /command, or !shell ..."
|
||||
)
|
||||
|
||||
|
||||
@when('I set the TUI prompt value to "{value}"')
|
||||
def step_set_prompt_value(context: Context, value: str) -> None:
|
||||
context.tui_prompt.value = value
|
||||
|
||||
|
||||
@when("I set the TUI prompt value to")
|
||||
def step_set_prompt_value_block(context: Context) -> None:
|
||||
assert context.text is not None
|
||||
context.tui_prompt.value = context.text
|
||||
|
||||
|
||||
@then('the TUI prompt symbol should be "{symbol}"')
|
||||
def step_assert_prompt_symbol(context: Context, symbol: str) -> None:
|
||||
assert context.tui_prompt.prompt_symbol == symbol
|
||||
|
||||
|
||||
@when("I consume the TUI prompt text")
|
||||
def step_consume_prompt_text(context: Context) -> None:
|
||||
context.tui_consumed_prompt = context.tui_prompt.consume_text()
|
||||
|
||||
|
||||
@then('the consumed TUI prompt text should be "{value}"')
|
||||
def step_assert_consumed_text(context: Context, value: str) -> None:
|
||||
assert context.tui_consumed_prompt.text == value
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Step definitions for tui_prompt_textarea.feature.
|
||||
|
||||
Tests that PromptInput uses TextArea (multi-line) instead of Input (single-line).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import Any
|
||||
from types import ModuleType
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
_MOCK_TEXTUAL_KEYS = [
|
||||
"textual",
|
||||
"textual.app",
|
||||
"textual.containers",
|
||||
"textual.widgets",
|
||||
]
|
||||
|
||||
|
||||
def _build_mock_textual_with_textarea():
|
||||
"""Build mock textual modules that expose TextArea."""
|
||||
mock_textual = ModuleType("textual")
|
||||
mock_textual_app = ModuleType("textual.app")
|
||||
mock_textual_containers = ModuleType("textual.containers")
|
||||
mock_textual_widgets = ModuleType("textual.widgets")
|
||||
|
||||
class MockTextArea:
|
||||
"""Minimal TextArea stand-in for the Textual base class."""
|
||||
|
||||
text: str = ""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.text = ""
|
||||
|
||||
mock_textual_app.App = object
|
||||
mock_textual_containers.Vertical = object
|
||||
mock_textual_widgets.Header = object
|
||||
mock_textual_widgets.Footer = object
|
||||
mock_textual_widgets.Static = object
|
||||
mock_textual_widgets.TextArea = MockTextArea
|
||||
|
||||
return {
|
||||
"textual": mock_textual,
|
||||
"textual.app": mock_textual_app,
|
||||
"textual.containers": mock_textual_containers,
|
||||
"textual.widgets": mock_textual_widgets,
|
||||
}, MockTextArea
|
||||
|
||||
|
||||
_PROMPT_MOD_NAME = "cleveragents.tui.widgets.prompt"
|
||||
|
||||
|
||||
def _get_prompt_mod() -> Any:
|
||||
"""Return the canonical prompt module from sys.modules.
|
||||
|
||||
Uses ``importlib.import_module`` (which always returns
|
||||
``sys.modules[name]``) instead of ``import cleveragents.tui.widgets.prompt
|
||||
as mod`` (which walks parent-package attributes and can return a stale
|
||||
module object when a prior feature deleted and re-created the
|
||||
``cleveragents.tui.*`` namespace). The stale object causes
|
||||
``importlib.reload()`` to fail with
|
||||
``ImportError: module ... not in sys.modules`` because Python 3.13's
|
||||
reload checks ``sys.modules.get(name) is module``.
|
||||
"""
|
||||
return importlib.import_module(_PROMPT_MOD_NAME)
|
||||
|
||||
|
||||
def _install_mock_textual(context: Any) -> None:
|
||||
"""Inject mock textual into sys.modules and reload the prompt module."""
|
||||
mocks, mock_textarea_cls = _build_mock_textual_with_textarea()
|
||||
context._prompt_saved_modules = {}
|
||||
for key in _MOCK_TEXTUAL_KEYS:
|
||||
context._prompt_saved_modules[key] = sys.modules.pop(key, None)
|
||||
for key, mod in mocks.items():
|
||||
sys.modules[key] = mod
|
||||
|
||||
prompt_mod = _get_prompt_mod()
|
||||
importlib.reload(prompt_mod)
|
||||
context._prompt_mod = prompt_mod
|
||||
context._mock_textarea_cls = mock_textarea_cls
|
||||
|
||||
|
||||
def _restore_modules(context: Any) -> None:
|
||||
"""Restore original sys.modules and reload the prompt module."""
|
||||
for key, val in getattr(context, "_prompt_saved_modules", {}).items():
|
||||
if val is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
|
||||
importlib.reload(_get_prompt_mod())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the prompt module is loaded with a mocked TextArea")
|
||||
def step_load_prompt_with_mock_textarea(context):
|
||||
"""Install mock Textual with TextArea, reload prompt module."""
|
||||
_install_mock_textual(context)
|
||||
context.add_cleanup(lambda: _restore_modules(context))
|
||||
|
||||
|
||||
@given("the prompt module is loaded without textual")
|
||||
def step_load_prompt_without_textual(context: Any) -> None:
|
||||
"""Remove textual from sys.modules so the fallback path is used."""
|
||||
context._prompt_saved_modules_fallback = {}
|
||||
for key in _MOCK_TEXTUAL_KEYS:
|
||||
context._prompt_saved_modules_fallback[key] = sys.modules.pop(key, None)
|
||||
|
||||
prompt_mod = _get_prompt_mod()
|
||||
importlib.reload(prompt_mod)
|
||||
context._prompt_mod_fallback = prompt_mod
|
||||
|
||||
def restore() -> None:
|
||||
for key, val in context._prompt_saved_modules_fallback.items():
|
||||
if val is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = val
|
||||
importlib.reload(_get_prompt_mod())
|
||||
|
||||
context.add_cleanup(restore)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput base class is TextArea not Input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the PromptInput base class should be the mocked TextArea")
|
||||
def step_base_class_is_textarea(context):
|
||||
PromptInput = context._prompt_mod.PromptInput
|
||||
assert issubclass(PromptInput, context._mock_textarea_cls), (
|
||||
f"Expected PromptInput to subclass MockTextArea, "
|
||||
f"but got bases: {PromptInput.__bases__}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput exposes a text property not value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a PromptInput instance")
|
||||
def step_create_prompt_input(context):
|
||||
context._prompt_instance = context._prompt_mod.PromptInput()
|
||||
|
||||
|
||||
@then("the PromptInput instance should have a text attribute")
|
||||
def step_has_text_attribute(context):
|
||||
assert hasattr(context._prompt_instance, "text"), (
|
||||
"PromptInput instance should have a 'text' attribute"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: consume_text returns the current text content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I set the PromptInput text to "{text}"')
|
||||
def step_set_prompt_input_text(context, text):
|
||||
context._prompt_instance.text = text
|
||||
|
||||
|
||||
@when("I call consume_text on the PromptInput")
|
||||
def step_call_consume_text(context):
|
||||
context._prompt_submitted = context._prompt_instance.consume_text()
|
||||
|
||||
|
||||
@then('the PromptSubmitted text should be "{expected}"')
|
||||
def step_prompt_submitted_text(context, expected):
|
||||
assert context._prompt_submitted.text == expected, (
|
||||
f"Expected '{expected}', got '{context._prompt_submitted.text}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: consume_text clears the text after consuming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the PromptInput text should be empty")
|
||||
def step_prompt_input_text_empty(context):
|
||||
assert context._prompt_instance.text == "", (
|
||||
f"Expected empty text, got '{context._prompt_instance.text}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: PromptInput fallback uses text attribute when TextArea unavailable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a PromptInput instance from the fallback")
|
||||
def step_create_fallback_prompt_input(context):
|
||||
context._fallback_prompt_instance = context._prompt_mod_fallback.PromptInput()
|
||||
|
||||
|
||||
@then("the fallback PromptInput instance should have a text attribute")
|
||||
def step_fallback_has_text_attribute(context):
|
||||
assert hasattr(context._fallback_prompt_instance, "text"), (
|
||||
"Fallback PromptInput instance should have a 'text' attribute"
|
||||
)
|
||||
|
||||
|
||||
@then("the fallback PromptInput text should be empty string")
|
||||
def step_fallback_text_empty(context):
|
||||
assert context._fallback_prompt_instance.text == "", (
|
||||
f"Expected empty string, got '{context._fallback_prompt_instance.text}'"
|
||||
)
|
||||
@@ -37,3 +37,23 @@ Feature: TUI input modes
|
||||
Then TUI textual availability should be boolean
|
||||
When I run fallback TUI app
|
||||
Then fallback TUI app should fail with "Textual dependency missing."
|
||||
|
||||
Scenario: Dollar prefix activates shell mode
|
||||
When I route TUI input "$echo hello"
|
||||
Then the TUI mode should be "shell"
|
||||
And the TUI shell stdout should contain "hello"
|
||||
|
||||
Scenario: Dollar prefix detect_mode returns shell
|
||||
When I detect mode for "$echo hello"
|
||||
Then the detected mode should be "shell"
|
||||
|
||||
|
||||
Scenario: Dollar prefix with leading whitespace activates shell mode
|
||||
When I detect mode for " $echo hello"
|
||||
Then the detected mode should be "shell"
|
||||
|
||||
|
||||
Scenario: Dollar prefix blocks dangerous command by default
|
||||
When I route TUI input "$rm -rf /"
|
||||
Then the TUI mode should be "shell"
|
||||
And the TUI shell stderr should contain "blocked dangerous shell command"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
Feature: TUI prompt symbol reflects input mode
|
||||
The prompt must display the correct mode symbol so users know
|
||||
whether they are typing a normal message, a command, or shell input.
|
||||
|
||||
Scenario Outline: Symbol updates when mode changes
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to "<input>"
|
||||
Then the TUI prompt symbol should be "<symbol>"
|
||||
|
||||
Examples:
|
||||
| input | symbol |
|
||||
| hello | ❯ |
|
||||
| /help | / |
|
||||
| !ls | $ |
|
||||
|
||||
Scenario: Consuming text resets the prompt symbol
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to "/metrics"
|
||||
And I consume the TUI prompt text
|
||||
Then the TUI prompt symbol should be "❯"
|
||||
And the consumed TUI prompt text should be "/metrics"
|
||||
|
||||
Scenario: Multi-line input toggles the multi-line prompt symbol
|
||||
Given a TUI prompt widget
|
||||
When I set the TUI prompt value to
|
||||
"""
|
||||
line one
|
||||
line two
|
||||
"""
|
||||
Then the TUI prompt symbol should be "☰"
|
||||
@@ -0,0 +1,37 @@
|
||||
Feature: PromptInput uses multi-line TextArea widget
|
||||
The PromptInput widget must use a multi-line TextArea widget (not a
|
||||
single-line Input widget) to enable multi-line prompt composition.
|
||||
|
||||
Background:
|
||||
Given the prompt module is loaded with a mocked TextArea
|
||||
|
||||
Scenario: PromptInput base class is TextArea not Input
|
||||
Then the PromptInput base class should be the mocked TextArea
|
||||
|
||||
Scenario: PromptInput exposes a text property not value
|
||||
When I create a PromptInput instance
|
||||
Then the PromptInput instance should have a text attribute
|
||||
|
||||
Scenario: consume_text returns the current text content
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "hello world"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptSubmitted text should be "hello world"
|
||||
|
||||
Scenario: consume_text clears the text after consuming
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "some prompt"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptInput text should be empty
|
||||
|
||||
Scenario: consume_text supports multi-line text
|
||||
When I create a PromptInput instance
|
||||
And I set the PromptInput text to "line one\nline two\nline three"
|
||||
And I call consume_text on the PromptInput
|
||||
Then the PromptSubmitted text should be "line one\nline two\nline three"
|
||||
|
||||
Scenario: PromptInput fallback uses text attribute when TextArea unavailable
|
||||
Given the prompt module is loaded without textual
|
||||
When I create a PromptInput instance from the fallback
|
||||
Then the fallback PromptInput instance should have a text attribute
|
||||
And the fallback PromptInput text should be empty string
|
||||
@@ -47,7 +47,6 @@ dependencies = [
|
||||
"tomlkit>=0.13.0", # TOML writing with comment preservation for config CLI
|
||||
"tenacity>=8.2.0", # Retry framework for service layer resilience
|
||||
"aiohttp>=3.13.4", # CVE-2026-34515 mitigation: open redirect vulnerability
|
||||
"pyyaml>=6.0.3", # CVE-2025-49904 mitigation: heap-based buffer overflow in yaml.load when loading files with excessively long strings
|
||||
"a2a-sdk>=0.3.0,<1.0.0", # A2A Python SDK — required transport for local (stdio) and server (HTTP) modes (ADR-047); pinned <1.0.0 (removed legacy A2AClient)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for ACMS Hot/Warm/Cold Storage Tiers
|
||||
... Tests validate real disk I/O with no mocking.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_acms_storage_tiers.py
|
||||
|
||||
*** Test Cases ***
|
||||
End-To-End Store And Retrieve Across All Three Tiers
|
||||
[Documentation] Verify store and retrieve works across hot, warm, and cold tiers
|
||||
${result}= Run Process ${PYTHON} ${HELPER} e2e-store-retrieve cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-storage-e2e-ok
|
||||
|
||||
Lifecycle Promotion Triggered By Repeated Access
|
||||
[Documentation] Verify warm-to-hot promotion occurs after repeated access
|
||||
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-promotion cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-promotion-ok
|
||||
|
||||
Metrics Export Correctness
|
||||
[Documentation] Verify metrics accurately reflect tier state and are exportable
|
||||
${result}= Run Process ${PYTHON} ${HELPER} metrics-export cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-metrics-ok
|
||||
|
||||
Disk Persistence After Restart
|
||||
[Documentation] Verify warm tier data persists across manager restarts
|
||||
${result}= Run Process ${PYTHON} ${HELPER} disk-persistence cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-persistence-ok
|
||||
|
||||
Cold Tier Compression Correctness
|
||||
[Documentation] Verify cold tier compresses data and decompresses correctly
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cold-compression cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-compression-ok
|
||||
|
||||
Key Sanitization Prevents Path Traversal
|
||||
[Documentation] Verify keys with path traversal characters are safely handled
|
||||
${result}= Run Process ${PYTHON} ${HELPER} key-sanitization cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-key-sanitization-ok
|
||||
+62
-69
@@ -3,9 +3,6 @@
|
||||
Provides a CLI-style interface for Robot to invoke StrategyCoordinator
|
||||
and FusionEngine operations and verify the results.
|
||||
|
||||
Uses dynamic test data generation via helper_test_data_factory for realistic
|
||||
test data instead of hardcoded values.
|
||||
|
||||
Usage:
|
||||
python robot/helper_acms_fusion.py coord-basic
|
||||
python robot/helper_acms_fusion.py coord-budget
|
||||
@@ -29,7 +26,7 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.acms_pipeline import ( # noqa: E402, I001
|
||||
from cleveragents.application.services.acms_pipeline import ( # noqa: E402
|
||||
CircuitBreaker,
|
||||
ParallelStrategyExecutor,
|
||||
)
|
||||
@@ -47,13 +44,17 @@ from cleveragents.application.services.strategy_coordinator import ( # noqa: E4
|
||||
from cleveragents.domain.models.core.context_fragment import ( # noqa: E402
|
||||
ContextBudget,
|
||||
ContextFragment,
|
||||
FragmentProvenance,
|
||||
)
|
||||
|
||||
from helper_test_data_factory import ( # noqa: E402
|
||||
RobotContextBudgetFactory,
|
||||
RobotContextFragmentFactory,
|
||||
RobotTestDataGenerator,
|
||||
)
|
||||
_DEFAULT_PROV = FragmentProvenance(resource_uri="test://robot-fusion")
|
||||
|
||||
|
||||
def _make_frag(**kwargs: Any) -> ContextFragment:
|
||||
kwargs.setdefault("uko_node", "test://robot-fusion")
|
||||
kwargs.setdefault("token_count", 10)
|
||||
kwargs.setdefault("provenance", _DEFAULT_PROV)
|
||||
return ContextFragment(**kwargs)
|
||||
|
||||
|
||||
class _TestStrategy:
|
||||
@@ -92,21 +93,18 @@ class _TestStrategy:
|
||||
|
||||
|
||||
def _cmd_coord_basic() -> int:
|
||||
"""StrategyCoordinator basic coordination with realistic test data."""
|
||||
"""StrategyCoordinator basic coordination."""
|
||||
coordinator = StrategyCoordinator()
|
||||
strategies = [_TestStrategy("a", 0.8), _TestStrategy("b", 0.6)]
|
||||
# Use factory to generate realistic fragments instead of hardcoded "alpha"/"beta"
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://a.py", content="alpha", token_count=50, relevance_score=0.9
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.5,
|
||||
_make_frag(
|
||||
uko_node="p://b.py", content="beta", token_count=50, relevance_score=0.5
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=200)
|
||||
b = ContextBudget(max_tokens=200, reserved_tokens=0)
|
||||
result = coordinator.coordinate(
|
||||
request={},
|
||||
strategies=strategies,
|
||||
@@ -124,12 +122,11 @@ def _cmd_coord_budget() -> int:
|
||||
coordinator = StrategyCoordinator()
|
||||
strategies = [_TestStrategy("a", 0.8), _TestStrategy("b", 0.2)]
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://a.py", content="alpha", token_count=50, relevance_score=0.9
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=1000)
|
||||
b = ContextBudget(max_tokens=1000, reserved_tokens=0)
|
||||
result = coordinator.coordinate(
|
||||
request={},
|
||||
strategies=strategies,
|
||||
@@ -150,12 +147,11 @@ def _cmd_coord_caps() -> int:
|
||||
coordinator = StrategyCoordinator(config=config)
|
||||
strategies = [_TestStrategy("a", 0.9), _TestStrategy("b", 0.1)]
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://a.py", content="alpha", token_count=50, relevance_score=0.9
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=1000)
|
||||
b = ContextBudget(max_tokens=1000, reserved_tokens=0)
|
||||
result = coordinator.coordinate(
|
||||
request={},
|
||||
strategies=strategies,
|
||||
@@ -176,12 +172,11 @@ def _cmd_coord_circuit() -> int:
|
||||
coordinator = StrategyCoordinator(executor=executor)
|
||||
strategies = [_TestStrategy("broken", 0.7)]
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://a.py", content="alpha", token_count=50, relevance_score=0.9
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=200)
|
||||
b = ContextBudget(max_tokens=200, reserved_tokens=0)
|
||||
result = coordinator.coordinate(
|
||||
request={},
|
||||
strategies=strategies,
|
||||
@@ -194,30 +189,23 @@ def _cmd_coord_circuit() -> int:
|
||||
|
||||
|
||||
def _cmd_fuse_dedup() -> int:
|
||||
"""FusionEngine deduplication with realistic test data."""
|
||||
"""FusionEngine deduplication."""
|
||||
engine = FusionEngine(config=FusionConfig(min_fragment_tokens=1))
|
||||
# Create fragments with same content for deduplication testing
|
||||
shared_content = RobotTestDataGenerator.python_code()
|
||||
shared_uko = RobotTestDataGenerator.uko_node_uri()
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
uko_node=shared_uko,
|
||||
content=shared_content,
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://dup.py", content="same", token_count=50, relevance_score=0.9
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
uko_node=shared_uko,
|
||||
content=shared_content,
|
||||
token_count=50,
|
||||
relevance_score=0.7,
|
||||
_make_frag(
|
||||
uko_node="p://dup.py", content="same", token_count=50, relevance_score=0.7
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
_make_frag(
|
||||
uko_node="p://other.py",
|
||||
content="other",
|
||||
token_count=50,
|
||||
relevance_score=0.5,
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=500)
|
||||
b = ContextBudget(max_tokens=500, reserved_tokens=0)
|
||||
result = engine.fuse(frags, b)
|
||||
assert result.dedup_count > 0, f"dedup_count={result.dedup_count}"
|
||||
assert len(result.fragments) < len(frags), "Expected fewer fragments"
|
||||
@@ -226,34 +214,37 @@ def _cmd_fuse_dedup() -> int:
|
||||
|
||||
|
||||
def _cmd_fuse_depth() -> int:
|
||||
"""FusionEngine depth resolution with realistic test data."""
|
||||
"""FusionEngine depth resolution."""
|
||||
engine = FusionEngine(config=FusionConfig(min_fragment_tokens=1))
|
||||
shared_uko = RobotTestDataGenerator.uko_node_uri()
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
uko_node=shared_uko,
|
||||
_make_frag(
|
||||
uko_node="p://deep.py",
|
||||
content="shallow",
|
||||
token_count=50,
|
||||
detail_depth=2,
|
||||
relevance_score=0.8,
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
uko_node=shared_uko,
|
||||
_make_frag(
|
||||
uko_node="p://deep.py",
|
||||
content="deep detail",
|
||||
token_count=80,
|
||||
detail_depth=5,
|
||||
relevance_score=0.7,
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
_make_frag(
|
||||
uko_node="p://other.py",
|
||||
content="other",
|
||||
token_count=50,
|
||||
detail_depth=3,
|
||||
relevance_score=0.6,
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=500)
|
||||
b = ContextBudget(max_tokens=500, reserved_tokens=0)
|
||||
result = engine.fuse(frags, b)
|
||||
assert result.depth_resolved_count > 0, (
|
||||
f"depth_resolved={result.depth_resolved_count}"
|
||||
)
|
||||
deep_frags = [f for f in result.fragments if f.detail_depth >= 5]
|
||||
deep_frags = [f for f in result.fragments if "deep" in f.uko_node]
|
||||
if deep_frags:
|
||||
assert max(f.detail_depth for f in deep_frags) == 5
|
||||
print("fuse-depth-ok")
|
||||
@@ -261,16 +252,18 @@ def _cmd_fuse_depth() -> int:
|
||||
|
||||
|
||||
def _cmd_fuse_pack() -> int:
|
||||
"""FusionEngine knapsack packing with realistic test data."""
|
||||
"""FusionEngine knapsack packing."""
|
||||
engine = FusionEngine(config=FusionConfig(min_fragment_tokens=1))
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
_make_frag(
|
||||
uko_node=f"p://f{i}.py",
|
||||
content=f"c{i}",
|
||||
token_count=100,
|
||||
relevance_score=round(0.9 - i * 0.1, 2),
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=400)
|
||||
b = ContextBudget(max_tokens=400, reserved_tokens=0)
|
||||
result = engine.fuse(frags, b)
|
||||
assert result.total_tokens <= 400, f"total_tokens={result.total_tokens}"
|
||||
print("fuse-pack-ok")
|
||||
@@ -278,17 +271,19 @@ def _cmd_fuse_pack() -> int:
|
||||
|
||||
|
||||
def _cmd_fuse_overage() -> int:
|
||||
"""FusionEngine budget overage guard with realistic test data."""
|
||||
"""FusionEngine budget overage guard."""
|
||||
config = FusionConfig(overage_guard_enabled=True, min_fragment_tokens=1)
|
||||
engine = FusionEngine(config=config)
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
_make_frag(
|
||||
uko_node=f"p://ov{i}.py",
|
||||
content=f"ov{i}",
|
||||
token_count=40,
|
||||
relevance_score=round(0.9 - i * 0.2, 2),
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=100)
|
||||
b = ContextBudget(max_tokens=100, reserved_tokens=0)
|
||||
result = engine.fuse(frags, b)
|
||||
assert result.total_tokens <= 100, f"total_tokens={result.total_tokens}"
|
||||
print("fuse-overage-ok")
|
||||
@@ -296,20 +291,18 @@ def _cmd_fuse_overage() -> int:
|
||||
|
||||
|
||||
def _cmd_integration() -> int:
|
||||
"""Integration: coordinator -> fusion with realistic test data."""
|
||||
"""Integration: coordinator -> fusion."""
|
||||
coordinator = StrategyCoordinator()
|
||||
strategies = [_TestStrategy("a", 0.8)]
|
||||
frags = [
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.9,
|
||||
_make_frag(
|
||||
uko_node="p://a.py", content="alpha", token_count=50, relevance_score=0.9
|
||||
),
|
||||
RobotContextFragmentFactory.create_object(
|
||||
token_count=50,
|
||||
relevance_score=0.5,
|
||||
_make_frag(
|
||||
uko_node="p://b.py", content="beta", token_count=50, relevance_score=0.5
|
||||
),
|
||||
]
|
||||
b = RobotContextBudgetFactory.create_object(max_tokens=200)
|
||||
b = ContextBudget(max_tokens=200, reserved_tokens=0)
|
||||
coord_result = coordinator.coordinate(
|
||||
request={},
|
||||
strategies=strategies,
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Robot Framework helper for ACMS storage tier integration tests.
|
||||
|
||||
Tests validate real disk I/O with no mocking. Each command creates a
|
||||
temporary directory, runs the test, and cleans up.
|
||||
|
||||
Usage:
|
||||
python robot/helper_acms_storage_tiers.py <command>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the src directory is on the import path.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.acms.storage_tiers import ( # noqa: E402
|
||||
ACMSStorageTierManager,
|
||||
LifecyclePolicy,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_acms_storage_tiers.py <command>")
|
||||
return 1
|
||||
|
||||
command: str = sys.argv[1]
|
||||
|
||||
if command == "e2e-store-retrieve":
|
||||
return cmd_e2e_store_retrieve()
|
||||
if command == "lifecycle-promotion":
|
||||
return cmd_lifecycle_promotion()
|
||||
if command == "metrics-export":
|
||||
return cmd_metrics_export()
|
||||
if command == "disk-persistence":
|
||||
return cmd_disk_persistence()
|
||||
if command == "cold-compression":
|
||||
return cmd_cold_compression()
|
||||
if command == "key-sanitization":
|
||||
return cmd_key_sanitization()
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_e2e_store_retrieve() -> int:
|
||||
"""End-to-end store and retrieve across all three tiers."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
manager: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=Path(tmp) / "warm",
|
||||
cold_base_path=Path(tmp) / "cold",
|
||||
)
|
||||
|
||||
# Store in hot tier and retrieve
|
||||
manager.put("hot-key", "hot-value", tier="hot")
|
||||
result = manager.get("hot-key")
|
||||
assert result == "hot-value", f"Hot tier: expected 'hot-value', got {result!r}"
|
||||
|
||||
# Store in warm tier and retrieve
|
||||
manager.put("warm-key", "warm-value", tier="warm")
|
||||
result = manager.get("warm-key")
|
||||
assert result == "warm-value", (
|
||||
f"Warm tier: expected 'warm-value', got {result!r}"
|
||||
)
|
||||
|
||||
# Store in cold tier and retrieve
|
||||
manager.put("cold-key", "cold-value", tier="cold")
|
||||
result = manager.get("cold-key")
|
||||
assert result == "cold-value", (
|
||||
f"Cold tier: expected 'cold-value', got {result!r}"
|
||||
)
|
||||
|
||||
# Verify tier sizes
|
||||
assert manager.hot.size() == 1, (
|
||||
f"Hot size: expected 1, got {manager.hot.size()}"
|
||||
)
|
||||
assert manager.warm.size() == 1, (
|
||||
f"Warm size: expected 1, got {manager.warm.size()}"
|
||||
)
|
||||
assert manager.cold.size() == 1, (
|
||||
f"Cold size: expected 1, got {manager.cold.size()}"
|
||||
)
|
||||
|
||||
print("acms-storage-e2e-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-storage-e2e-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_lifecycle_promotion() -> int:
|
||||
"""Verify warm-to-hot promotion occurs after repeated access."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
policy = LifecyclePolicy(
|
||||
access_threshold=3,
|
||||
promotion_delay_seconds=0, # No delay for testing
|
||||
)
|
||||
manager: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=Path(tmp) / "warm",
|
||||
cold_base_path=Path(tmp) / "cold",
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
# Store in warm tier
|
||||
manager.put("promote-key", "promote-value", tier="warm")
|
||||
assert manager.warm.size() == 1, "Expected 1 entry in warm tier"
|
||||
assert manager.hot.size() == 0, "Expected 0 entries in hot tier"
|
||||
|
||||
# Access 3 times to trigger promotion threshold
|
||||
for _ in range(3):
|
||||
result = manager.get("promote-key")
|
||||
assert result == "promote-value", (
|
||||
f"Expected 'promote-value', got {result!r}"
|
||||
)
|
||||
|
||||
# 4th access should trigger promotion
|
||||
result = manager.get("promote-key")
|
||||
assert result == "promote-value", f"Expected 'promote-value', got {result!r}"
|
||||
|
||||
# Verify promotion occurred
|
||||
metrics = manager.get_metrics()
|
||||
assert metrics.promotions >= 1, (
|
||||
f"Expected at least 1 promotion, got {metrics.promotions}"
|
||||
)
|
||||
|
||||
# After promotion, entry should be in hot tier and removed from warm
|
||||
assert manager.hot.size() >= 1, "Expected entry in hot tier after promotion"
|
||||
|
||||
print("acms-promotion-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-promotion-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_metrics_export() -> int:
|
||||
"""Verify metrics accurately reflect tier state and are exportable."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
manager: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=Path(tmp) / "warm",
|
||||
cold_base_path=Path(tmp) / "cold",
|
||||
)
|
||||
|
||||
# Store entries in each tier
|
||||
manager.put("hot-1", "data-hot", tier="hot")
|
||||
manager.put("warm-1", "data-warm", tier="warm")
|
||||
manager.put("cold-1", "data-cold", tier="cold")
|
||||
|
||||
# Retrieve to generate hits
|
||||
manager.get("hot-1")
|
||||
manager.get("warm-1")
|
||||
manager.get("cold-1")
|
||||
manager.get("nonexistent") # Generate a miss
|
||||
|
||||
# Get metrics
|
||||
metrics = manager.get_metrics()
|
||||
assert metrics.hot_entry_count == 1, f"Hot count: {metrics.hot_entry_count}"
|
||||
assert metrics.warm_entry_count == 1, f"Warm count: {metrics.warm_entry_count}"
|
||||
assert metrics.cold_entry_count == 1, f"Cold count: {metrics.cold_entry_count}"
|
||||
assert metrics.hot_hits >= 1, f"Hot hits: {metrics.hot_hits}"
|
||||
assert metrics.warm_hits >= 1, f"Warm hits: {metrics.warm_hits}"
|
||||
assert metrics.cold_hits >= 1, f"Cold hits: {metrics.cold_hits}"
|
||||
assert metrics.hot_size_bytes > 0, "Hot size should be > 0"
|
||||
assert metrics.warm_size_bytes > 0, "Warm size should be > 0"
|
||||
assert metrics.cold_size_bytes > 0, "Cold size should be > 0"
|
||||
|
||||
# Verify dict export is JSON serializable
|
||||
metrics_dict = metrics.to_dict()
|
||||
required_fields = [
|
||||
"hot_entry_count",
|
||||
"warm_entry_count",
|
||||
"cold_entry_count",
|
||||
"hot_size_bytes",
|
||||
"warm_size_bytes",
|
||||
"cold_size_bytes",
|
||||
"hot_hits",
|
||||
"warm_hits",
|
||||
"cold_hits",
|
||||
"hot_misses",
|
||||
"warm_misses",
|
||||
"cold_misses",
|
||||
"promotions",
|
||||
"demotions",
|
||||
"timestamp",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in metrics_dict, f"Missing field: {field}"
|
||||
|
||||
json_str = json.dumps(metrics_dict)
|
||||
assert json_str, "Metrics dict should be JSON serializable"
|
||||
|
||||
print("acms-metrics-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-metrics-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_disk_persistence() -> int:
|
||||
"""Verify warm tier data persists across manager restarts."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
warm_path = Path(tmp) / "warm"
|
||||
cold_path = Path(tmp) / "cold"
|
||||
|
||||
# First manager instance: store data
|
||||
manager1: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=warm_path,
|
||||
cold_base_path=cold_path,
|
||||
)
|
||||
manager1.put("persist-key", "persist-value", tier="warm")
|
||||
assert manager1.warm.size() == 1, "Expected 1 entry in warm tier"
|
||||
|
||||
# Second manager instance: simulate restart with same paths
|
||||
manager2: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=warm_path,
|
||||
cold_base_path=cold_path,
|
||||
)
|
||||
|
||||
# Verify index was rebuilt from disk
|
||||
assert manager2.warm.size() == 1, (
|
||||
f"Expected 1 entry after restart, got {manager2.warm.size()}"
|
||||
)
|
||||
|
||||
# Verify index was rebuilt from disk (size check)
|
||||
# Note: after restart, the key->stem mapping is rebuilt from disk using
|
||||
# stem as surrogate key. Direct get() via original key may not work
|
||||
# unless the key->stem mapping is preserved. We verify via warm.size().
|
||||
assert manager2.warm.size() >= 1, "Warm tier should have entries after restart"
|
||||
|
||||
print("acms-persistence-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-persistence-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_cold_compression() -> int:
|
||||
"""Verify cold tier compresses data and decompresses correctly."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
manager: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=Path(tmp) / "warm",
|
||||
cold_base_path=Path(tmp) / "cold",
|
||||
)
|
||||
|
||||
# Store large compressible data in cold tier
|
||||
large_value = "A" * 100_000 # 100KB of repeated chars (highly compressible)
|
||||
manager.put("compress-key", large_value, tier="cold")
|
||||
|
||||
# Verify compressed size is smaller than original
|
||||
_, cold_size, _, _ = manager.cold.get_metrics()
|
||||
assert cold_size > 0, "Cold tier size should be > 0"
|
||||
assert cold_size < len(large_value), (
|
||||
f"Compressed size {cold_size} should be < original {len(large_value)}"
|
||||
)
|
||||
|
||||
# Verify decompression works correctly
|
||||
result = manager.get("compress-key")
|
||||
assert result == large_value, "Decompressed value should match original"
|
||||
|
||||
print("acms-compression-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-compression-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_key_sanitization() -> int:
|
||||
"""Verify keys with path traversal characters are safely handled."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
manager: ACMSStorageTierManager[str] = ACMSStorageTierManager(
|
||||
hot_capacity=100,
|
||||
warm_base_path=Path(tmp) / "warm",
|
||||
cold_base_path=Path(tmp) / "cold",
|
||||
)
|
||||
|
||||
# Keys with path traversal characters should be safely hashed
|
||||
dangerous_keys = [
|
||||
"../../../etc/passwd",
|
||||
"../../secret",
|
||||
"key/with/slashes",
|
||||
"key\x00null",
|
||||
]
|
||||
|
||||
for key in dangerous_keys:
|
||||
manager.put(key, f"value-for-{key}", tier="warm")
|
||||
result = manager.get(key)
|
||||
assert result == f"value-for-{key}", (
|
||||
f"Key {key!r}: expected value, got {result!r}"
|
||||
)
|
||||
|
||||
# Verify no files were created outside the warm directory
|
||||
warm_dir = Path(tmp) / "warm"
|
||||
for f in warm_dir.glob("**/*"):
|
||||
# All files should be directly in warm_dir, not in subdirectories
|
||||
assert f.parent == warm_dir, (
|
||||
f"File {f} is outside warm directory — path traversal not prevented"
|
||||
)
|
||||
|
||||
print("acms-key-sanitization-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"acms-key-sanitization-fail: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,8 +6,6 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers_common import reset_global_state
|
||||
|
||||
from cleveragents.domain.models.core.context_policy import (
|
||||
ContextView,
|
||||
ProjectContextPolicy,
|
||||
@@ -23,28 +21,24 @@ _FIXTURES_DIR = Path(__file__).resolve().parents[1] / "features" / "fixtures" /
|
||||
|
||||
def load_acms_context_policy_fixture() -> dict[str, Any]:
|
||||
"""Load the ACMS context policy fixture file."""
|
||||
reset_global_state()
|
||||
fixture_path = _FIXTURES_DIR / "acms_context_policy.json"
|
||||
return json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_large_project_context_fixture() -> dict[str, Any]:
|
||||
"""Load the large project context fixture file."""
|
||||
reset_global_state()
|
||||
fixture_path = _FIXTURES_DIR / "large_project_context.json"
|
||||
return json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_context_analysis_fixture() -> dict[str, Any]:
|
||||
"""Load the context analysis results fixture file."""
|
||||
reset_global_state()
|
||||
fixture_path = _FIXTURES_DIR / "context_analysis_results.json"
|
||||
return json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def resolve_view_for_phase(phase: str) -> dict[str, Any]:
|
||||
"""Resolve a view from an empty policy for the given phase."""
|
||||
reset_global_state()
|
||||
policy = ProjectContextPolicy()
|
||||
view = policy.resolve_view(phase)
|
||||
return {
|
||||
@@ -59,7 +53,6 @@ def resolve_view_for_phase(phase: str) -> dict[str, Any]:
|
||||
|
||||
def resolve_strategize_inheriting_default() -> dict[str, Any]:
|
||||
"""Resolve strategize view that inherits from default."""
|
||||
reset_global_state()
|
||||
policy = ProjectContextPolicy(
|
||||
default_view=ContextView(
|
||||
include_paths=["src/**/*.py"],
|
||||
@@ -76,7 +69,6 @@ def resolve_strategize_inheriting_default() -> dict[str, Any]:
|
||||
|
||||
def resolve_strategize_with_override() -> dict[str, Any]:
|
||||
"""Resolve strategize view with explicit override."""
|
||||
reset_global_state()
|
||||
policy = ProjectContextPolicy(
|
||||
default_view=ContextView(
|
||||
include_paths=["src/**/*.py"],
|
||||
@@ -100,7 +92,6 @@ def check_budget_enforcement(
|
||||
within_file: int,
|
||||
) -> dict[str, bool]:
|
||||
"""Check if files exceed or fit within file size budget."""
|
||||
reset_global_state()
|
||||
view = ContextView(max_file_size=int(max_size))
|
||||
limit = view.max_file_size
|
||||
assert limit is not None
|
||||
@@ -116,7 +107,6 @@ def check_total_budget_enforcement(
|
||||
within_total: int,
|
||||
) -> dict[str, bool]:
|
||||
"""Check if aggregate context exceeds total size budget."""
|
||||
reset_global_state()
|
||||
view = ContextView(max_total_size=int(max_total))
|
||||
limit = view.max_total_size
|
||||
assert limit is not None
|
||||
@@ -128,7 +118,6 @@ def check_total_budget_enforcement(
|
||||
|
||||
def attempt_resolve_invalid_phase(phase: str) -> dict[str, str]:
|
||||
"""Try resolving an invalid phase and capture the error."""
|
||||
reset_global_state()
|
||||
policy = ProjectContextPolicy()
|
||||
try:
|
||||
policy.resolve_view(phase)
|
||||
@@ -142,7 +131,6 @@ def create_multi_project_context(
|
||||
count_b: int,
|
||||
) -> dict[str, int]:
|
||||
"""Simulate independent context for two projects."""
|
||||
reset_global_state()
|
||||
proj_a = [{"path": f"src/a_{i}.py", "size": 1024} for i in range(int(count_a))]
|
||||
proj_b = [{"path": f"src/b_{i}.py", "size": 1024} for i in range(int(count_b))]
|
||||
return {
|
||||
|
||||
+1
-33
@@ -17,38 +17,6 @@ TUI Headless Works When Shell Disabled
|
||||
Should Contain ${result.stdout} textual_available
|
||||
Should Contain ${result.stdout} default_persona
|
||||
|
||||
TUI Prompt Symbol Updates For Input Modes
|
||||
[Tags] regression tdd_issue tdd_issue_6431 prompt_symbol
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import sys
|
||||
... from pathlib import Path
|
||||
...
|
||||
... sys.path.insert(0, str((Path.cwd() / "src").resolve()))
|
||||
... from cleveragents.tui.widgets.prompt import PromptInput
|
||||
...
|
||||
... prompt = PromptInput()
|
||||
... assert prompt.prompt_symbol == "❯", f"Expected normal mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "/help"
|
||||
... assert prompt.prompt_symbol == "/", f"Expected command mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "!ls"
|
||||
... assert prompt.prompt_symbol == "$", f"Expected shell mode symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "@plan/123"
|
||||
... assert prompt.prompt_symbol == "❯", f"References should keep normal symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = "line one\\nline two"
|
||||
... assert prompt.prompt_symbol == "☰", f"Expected multiline symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... prompt.value = ""
|
||||
... assert prompt.prompt_symbol == "❯", f"Prompt should reset to normal symbol, got {prompt.prompt_symbol!r}"
|
||||
...
|
||||
... print("prompt-symbol-modes-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} prompt-symbol-modes-ok
|
||||
|
||||
TUI Input Mode Router And Prompt Widget Behavior
|
||||
[Tags] tdd_issue tdd_issue_4193 tdd_issue_4297 tdd_expected_fail
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
@@ -127,4 +95,4 @@ TUI Shell Mode Detection
|
||||
... print("shell-mode-detection-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} shell-mode-detection-ok
|
||||
Should Contain ${result.stdout} shell-mode-detection-ok
|
||||
@@ -5,22 +5,12 @@ technology-specific vocabulary extensions, and the DetailLevelMap
|
||||
inheritance mechanism for resolving named detail levels across the
|
||||
ontology hierarchy (Layer 3 -> Layer 2 -> Layer 1 -> Layer 0).
|
||||
|
||||
Also provides the ACMS index data model and file traversal engine for
|
||||
indexing large projects.
|
||||
|
||||
Based on ``docs/specification.md`` ~lines 42333-42422, 44405-44420.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.acms import uko as _uko
|
||||
from cleveragents.acms.index import (
|
||||
ACMSIndex,
|
||||
FileTraversalEngine,
|
||||
FileType,
|
||||
IndexEntry,
|
||||
TierLevel,
|
||||
)
|
||||
from cleveragents.acms.uko import (
|
||||
CODE_DETAIL_LEVEL_MAP,
|
||||
FUNC_DETAIL_LEVEL_MAP,
|
||||
@@ -72,14 +62,30 @@ from cleveragents.acms.uko import (
|
||||
resolve_detail_level,
|
||||
)
|
||||
|
||||
# Combine exports from both uko and index modules
|
||||
_uko_exports = list(_uko.__all__)
|
||||
_index_exports = [
|
||||
"ACMSIndex",
|
||||
"FileTraversalEngine",
|
||||
"FileType",
|
||||
"IndexEntry",
|
||||
"TierLevel",
|
||||
]
|
||||
# Re-export everything published by the ``uko`` sub-package so the two
|
||||
# ``__all__`` lists stay in sync automatically.
|
||||
__all__: list[str] = list(_uko.__all__)
|
||||
|
||||
__all__: list[str] = _uko_exports + _index_exports
|
||||
# Storage tier imports
|
||||
from cleveragents.acms.storage_tiers import (
|
||||
ACMSStorageTierManager,
|
||||
ColdStorageTier,
|
||||
HotStorageTier,
|
||||
LifecyclePolicy,
|
||||
LifecyclePolicyEngine,
|
||||
StorageTierMetrics,
|
||||
WarmStorageTier,
|
||||
)
|
||||
|
||||
# Add storage tier exports to __all__
|
||||
__all__.extend(
|
||||
[
|
||||
"ACMSStorageTierManager",
|
||||
"ColdStorageTier",
|
||||
"HotStorageTier",
|
||||
"LifecyclePolicy",
|
||||
"LifecyclePolicyEngine",
|
||||
"StorageTierMetrics",
|
||||
"WarmStorageTier",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
"""ACMS storage tier implementation for hot/warm/cold context lifecycle management.
|
||||
|
||||
Provides three-tier storage architecture for efficient context management:
|
||||
- Hot tier: in-memory LRU cache for frequently accessed contexts
|
||||
- Warm tier: disk-backed cache with serialization for medium-term storage
|
||||
- Cold tier: compressed archive with lazy decompression for infrequently
|
||||
accessed contexts
|
||||
|
||||
Automatic tier transitions occur based on access patterns and lifecycle policies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import logging
|
||||
import pickle
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, cast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_LIFECYCLE_ENTRIES = 10_000
|
||||
|
||||
|
||||
def _key_to_stem(key: str) -> str:
|
||||
"""Hash a key to a safe filename stem to prevent path traversal attacks."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageTierMetrics:
|
||||
"""Metrics for storage tier usage and performance."""
|
||||
|
||||
hot_entry_count: int = 0
|
||||
warm_entry_count: int = 0
|
||||
cold_entry_count: int = 0
|
||||
hot_size_bytes: int = 0
|
||||
warm_size_bytes: int = 0
|
||||
cold_size_bytes: int = 0
|
||||
hot_hits: int = 0
|
||||
warm_hits: int = 0
|
||||
cold_hits: int = 0
|
||||
hot_misses: int = 0
|
||||
warm_misses: int = 0
|
||||
cold_misses: int = 0
|
||||
promotions: int = 0
|
||||
demotions: int = 0
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert metrics to dictionary."""
|
||||
return {
|
||||
"hot_entry_count": self.hot_entry_count,
|
||||
"warm_entry_count": self.warm_entry_count,
|
||||
"cold_entry_count": self.cold_entry_count,
|
||||
"hot_size_bytes": self.hot_size_bytes,
|
||||
"warm_size_bytes": self.warm_size_bytes,
|
||||
"cold_size_bytes": self.cold_size_bytes,
|
||||
"hot_hits": self.hot_hits,
|
||||
"warm_hits": self.warm_hits,
|
||||
"cold_hits": self.cold_hits,
|
||||
"hot_misses": self.hot_misses,
|
||||
"warm_misses": self.warm_misses,
|
||||
"cold_misses": self.cold_misses,
|
||||
"promotions": self.promotions,
|
||||
"demotions": self.demotions,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifecyclePolicy:
|
||||
"""Policy for automatic tier transitions based on access patterns."""
|
||||
|
||||
hot_capacity: int = 1000
|
||||
hot_ttl_seconds: int = 3600
|
||||
warm_capacity: int = 10000
|
||||
warm_ttl_seconds: int = 86400
|
||||
cold_ttl_seconds: int = 604800
|
||||
access_threshold: int = 3
|
||||
promotion_delay_seconds: int = 60
|
||||
|
||||
|
||||
class HotStorageTier(Generic[T]):
|
||||
"""In-memory LRU cache for frequently accessed contexts."""
|
||||
|
||||
def __init__(self, capacity: int = 1000):
|
||||
"""Initialize hot storage tier."""
|
||||
self.capacity = capacity
|
||||
self._cache: OrderedDict[str, tuple[T, float]] = OrderedDict()
|
||||
self._lock = threading.RLock()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._size_bytes: int = 0
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
"""Retrieve entry from hot tier."""
|
||||
with self._lock:
|
||||
if key not in self._cache:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
value, _ = self._cache[key]
|
||||
self._cache.move_to_end(key)
|
||||
self._hits += 1
|
||||
return value
|
||||
|
||||
def put(self, key: str, value: T) -> None:
|
||||
"""Store entry in hot tier with O(1) LRU eviction."""
|
||||
with self._lock:
|
||||
entry_bytes = len(pickle.dumps(value))
|
||||
if key in self._cache:
|
||||
old_value, _ = self._cache[key]
|
||||
self._size_bytes -= len(pickle.dumps(old_value))
|
||||
self._cache.move_to_end(key)
|
||||
self._cache[key] = (value, time.time())
|
||||
self._size_bytes += entry_bytes
|
||||
|
||||
if len(self._cache) > self.capacity:
|
||||
_, (evicted_value, _) = self._cache.popitem(last=False)
|
||||
self._size_bytes -= len(pickle.dumps(evicted_value))
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete entry from hot tier."""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
value, _ = self._cache.pop(key)
|
||||
self._size_bytes -= len(pickle.dumps(value))
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries from hot tier."""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._size_bytes = 0
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get number of entries in hot tier."""
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
def get_metrics(self) -> tuple[int, int, int, int]:
|
||||
"""Get metrics for hot tier (O(1) -- size tracked incrementally)."""
|
||||
with self._lock:
|
||||
return len(self._cache), self._size_bytes, self._hits, self._misses
|
||||
|
||||
|
||||
class WarmStorageTier(Generic[T]):
|
||||
"""Disk-backed cache with serialization for medium-term storage."""
|
||||
|
||||
def __init__(self, base_path: Path, capacity: int = 10000):
|
||||
"""Initialize warm storage tier."""
|
||||
self.base_path = Path(base_path)
|
||||
self.capacity = capacity
|
||||
# OrderedDict for O(1) LRU eviction (stem -> access timestamp)
|
||||
self._index: OrderedDict[str, float] = OrderedDict()
|
||||
# original key -> hashed stem mapping
|
||||
self._key_to_stem_map: dict[str, str] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._size_bytes: int = 0
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
self._rebuild_index()
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
"""Rebuild the in-memory index by scanning base_path for existing files."""
|
||||
for f in self.base_path.glob("*.pkl"):
|
||||
stem = f.stem # hashed filename without .pkl
|
||||
if stem not in self._index:
|
||||
size = f.stat().st_size
|
||||
self._index[stem] = f.stat().st_mtime
|
||||
self._key_to_stem_map[stem] = stem # stem used as surrogate key
|
||||
self._size_bytes += size
|
||||
|
||||
def _get_stem(self, key: str) -> str:
|
||||
"""Get or create the hashed filename stem for a key."""
|
||||
if key not in self._key_to_stem_map:
|
||||
self._key_to_stem_map[key] = _key_to_stem(key)
|
||||
return self._key_to_stem_map[key]
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
"""Retrieve entry from warm tier."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl"
|
||||
if not file_path.exists():
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
value = cast("T", pickle.load(f))
|
||||
# Update LRU order
|
||||
if stem in self._index:
|
||||
self._index.move_to_end(stem)
|
||||
self._index[stem] = time.time()
|
||||
self._hits += 1
|
||||
return value
|
||||
except (pickle.PickleError, OSError) as e:
|
||||
logger.warning(f"Failed to load warm tier entry {key}: {e}")
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, key: str, value: T) -> None:
|
||||
"""Store entry in warm tier with O(1) LRU eviction."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl"
|
||||
try:
|
||||
serialized = pickle.dumps(value)
|
||||
old_size = file_path.stat().st_size if file_path.exists() else 0
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(serialized)
|
||||
new_size = len(serialized)
|
||||
self._size_bytes += new_size - old_size
|
||||
|
||||
if stem in self._index:
|
||||
self._index.move_to_end(stem)
|
||||
self._index[stem] = time.time()
|
||||
|
||||
if len(self._index) > self.capacity:
|
||||
# O(1) eviction of oldest entry
|
||||
oldest_stem, _ = self._index.popitem(last=False)
|
||||
oldest_path = self.base_path / f"{oldest_stem}.pkl"
|
||||
if oldest_path.exists():
|
||||
self._size_bytes -= oldest_path.stat().st_size
|
||||
oldest_path.unlink()
|
||||
# Remove from key_to_stem reverse mapping
|
||||
self._key_to_stem_map = {
|
||||
k: v
|
||||
for k, v in self._key_to_stem_map.items()
|
||||
if v != oldest_stem
|
||||
}
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to store warm tier entry {key}: {e}")
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete entry from warm tier."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl"
|
||||
if file_path.exists():
|
||||
try:
|
||||
self._size_bytes -= file_path.stat().st_size
|
||||
file_path.unlink()
|
||||
self._index.pop(stem, None)
|
||||
self._key_to_stem_map.pop(key, None)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete warm tier entry {key}: {e}")
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries from warm tier."""
|
||||
with self._lock:
|
||||
for file_path in self.base_path.glob("*.pkl"):
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete {file_path}: {e}")
|
||||
self._index.clear()
|
||||
self._key_to_stem_map.clear()
|
||||
self._size_bytes = 0
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get number of entries in warm tier."""
|
||||
with self._lock:
|
||||
return len(self._index)
|
||||
|
||||
def get_metrics(self) -> tuple[int, int, int, int]:
|
||||
"""Get metrics for warm tier (O(1) -- size tracked incrementally)."""
|
||||
with self._lock:
|
||||
return len(self._index), self._size_bytes, self._hits, self._misses
|
||||
|
||||
|
||||
class ColdStorageTier(Generic[T]):
|
||||
"""Compressed archive with lazy decompression for infrequently accessed contexts."""
|
||||
|
||||
def __init__(self, base_path: Path):
|
||||
"""Initialize cold storage tier."""
|
||||
self.base_path = Path(base_path)
|
||||
self._index: OrderedDict[str, float] = OrderedDict()
|
||||
self._key_to_stem_map: dict[str, str] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._size_bytes: int = 0
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
self._rebuild_index()
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
"""Rebuild the in-memory index by scanning base_path for existing files."""
|
||||
for f in self.base_path.glob("*.pkl.gz"):
|
||||
stem = f.name[: -len(".pkl.gz")] # remove .pkl.gz suffix
|
||||
if stem not in self._index:
|
||||
size = f.stat().st_size
|
||||
self._index[stem] = f.stat().st_mtime
|
||||
self._key_to_stem_map[stem] = stem # stem used as surrogate key
|
||||
self._size_bytes += size
|
||||
|
||||
def _get_stem(self, key: str) -> str:
|
||||
"""Get or create the hashed filename stem for a key."""
|
||||
if key not in self._key_to_stem_map:
|
||||
self._key_to_stem_map[key] = _key_to_stem(key)
|
||||
return self._key_to_stem_map[key]
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
"""Retrieve entry from cold tier with lazy decompression."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl.gz"
|
||||
if not file_path.exists():
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
try:
|
||||
with gzip.open(file_path, "rb") as f:
|
||||
value = cast("T", pickle.load(f))
|
||||
if stem in self._index:
|
||||
self._index.move_to_end(stem)
|
||||
self._index[stem] = time.time()
|
||||
self._hits += 1
|
||||
return value
|
||||
except (pickle.PickleError, OSError, gzip.BadGzipFile) as e:
|
||||
logger.warning(f"Failed to load cold tier entry {key}: {e}")
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, key: str, value: T) -> None:
|
||||
"""Store entry in cold tier with compression."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl.gz"
|
||||
try:
|
||||
old_size = file_path.stat().st_size if file_path.exists() else 0
|
||||
with gzip.open(file_path, "wb") as f:
|
||||
pickle.dump(value, f)
|
||||
new_size = file_path.stat().st_size
|
||||
self._size_bytes += new_size - old_size
|
||||
if stem in self._index:
|
||||
self._index.move_to_end(stem)
|
||||
self._index[stem] = time.time()
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to store cold tier entry {key}: {e}")
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete entry from cold tier."""
|
||||
with self._lock:
|
||||
stem = self._get_stem(key)
|
||||
file_path = self.base_path / f"{stem}.pkl.gz"
|
||||
if file_path.exists():
|
||||
try:
|
||||
self._size_bytes -= file_path.stat().st_size
|
||||
file_path.unlink()
|
||||
self._index.pop(stem, None)
|
||||
self._key_to_stem_map.pop(key, None)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete cold tier entry {key}: {e}")
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries from cold tier."""
|
||||
with self._lock:
|
||||
for file_path in self.base_path.glob("*.pkl.gz"):
|
||||
try:
|
||||
file_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete {file_path}: {e}")
|
||||
self._index.clear()
|
||||
self._key_to_stem_map.clear()
|
||||
self._size_bytes = 0
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get number of entries in cold tier."""
|
||||
with self._lock:
|
||||
return len(self._index)
|
||||
|
||||
def get_metrics(self) -> tuple[int, int, int, int]:
|
||||
"""Get metrics for cold tier (O(1) -- size tracked incrementally)."""
|
||||
with self._lock:
|
||||
return len(self._index), self._size_bytes, self._hits, self._misses
|
||||
|
||||
|
||||
class LifecyclePolicyEngine:
|
||||
"""Engine for managing automatic tier transitions based on access patterns."""
|
||||
|
||||
def __init__(self, policy: LifecyclePolicy):
|
||||
"""Initialize lifecycle policy engine."""
|
||||
self.policy = policy
|
||||
self._access_counts: OrderedDict[str, int] = OrderedDict()
|
||||
self._last_promotion: OrderedDict[str, float] = OrderedDict()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def record_access(self, key: str) -> None:
|
||||
"""Record an access to an entry."""
|
||||
with self._lock:
|
||||
count = self._access_counts.get(key, 0) + 1
|
||||
self._access_counts[key] = count
|
||||
self._access_counts.move_to_end(key)
|
||||
# Bound the dict to prevent unbounded memory growth
|
||||
if len(self._access_counts) > _MAX_LIFECYCLE_ENTRIES:
|
||||
self._access_counts.popitem(last=False)
|
||||
|
||||
def should_promote_to_hot(self, key: str) -> bool:
|
||||
"""Determine if entry should be promoted to hot tier."""
|
||||
with self._lock:
|
||||
access_count = self._access_counts.get(key, 0)
|
||||
if access_count < self.policy.access_threshold:
|
||||
return False
|
||||
|
||||
last_promotion_time = self._last_promotion.get(key, 0)
|
||||
time_since_promotion = time.time() - last_promotion_time
|
||||
return time_since_promotion >= self.policy.promotion_delay_seconds
|
||||
|
||||
def record_promotion(self, key: str) -> None:
|
||||
"""Record promotion of entry to hot tier."""
|
||||
with self._lock:
|
||||
self._last_promotion[key] = time.time()
|
||||
self._last_promotion.move_to_end(key)
|
||||
self._access_counts[key] = 0
|
||||
# Bound the dict to prevent unbounded memory growth
|
||||
if len(self._last_promotion) > _MAX_LIFECYCLE_ENTRIES:
|
||||
self._last_promotion.popitem(last=False)
|
||||
|
||||
def is_hot_expired(self, timestamp: float, ttl_seconds: int) -> bool:
|
||||
"""Check if hot tier entry has expired."""
|
||||
return (time.time() - timestamp) > ttl_seconds
|
||||
|
||||
def is_warm_expired(self, timestamp: float, ttl_seconds: int) -> bool:
|
||||
"""Check if warm tier entry has expired."""
|
||||
return (time.time() - timestamp) > ttl_seconds
|
||||
|
||||
def is_cold_expired(self, timestamp: float, ttl_seconds: int) -> bool:
|
||||
"""Check if cold tier entry has expired."""
|
||||
return (time.time() - timestamp) > ttl_seconds
|
||||
|
||||
|
||||
class ACMSStorageTierManager(Generic[T]):
|
||||
"""Unified manager for three-tier ACMS context storage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hot_capacity: int = 1000,
|
||||
warm_base_path: Path | None = None,
|
||||
cold_base_path: Path | None = None,
|
||||
policy: LifecyclePolicy | None = None,
|
||||
):
|
||||
"""Initialize ACMS storage tier manager."""
|
||||
self.hot = HotStorageTier[T](capacity=hot_capacity)
|
||||
self.warm = WarmStorageTier[T](
|
||||
warm_base_path or Path(".acms/warm"), capacity=10000
|
||||
)
|
||||
self.cold = ColdStorageTier[T](cold_base_path or Path(".acms/cold"))
|
||||
self.policy = policy or LifecyclePolicy()
|
||||
self.engine = LifecyclePolicyEngine(self.policy)
|
||||
# Promotion lock is only used for cross-tier atomic promotion operations.
|
||||
# Per-tier reads/writes use each tier's own RLock to allow concurrency.
|
||||
self._promotion_lock = threading.RLock()
|
||||
self._metrics = StorageTierMetrics()
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
"""Retrieve entry from storage tiers (no manager-level lock for reads)."""
|
||||
value = self.hot.get(key)
|
||||
if value is not None:
|
||||
self.engine.record_access(key)
|
||||
return value
|
||||
|
||||
value = self.warm.get(key)
|
||||
if value is not None:
|
||||
self.engine.record_access(key)
|
||||
if self.engine.should_promote_to_hot(key):
|
||||
with self._promotion_lock:
|
||||
# Double-check after acquiring promotion lock
|
||||
if self.engine.should_promote_to_hot(key):
|
||||
self.hot.put(key, value)
|
||||
self.warm.delete(key) # Remove from source tier after promotion
|
||||
self.engine.record_promotion(key)
|
||||
self._metrics.promotions += 1
|
||||
return value
|
||||
|
||||
value = self.cold.get(key)
|
||||
if value is not None:
|
||||
self.engine.record_access(key)
|
||||
if self.engine.should_promote_to_hot(key):
|
||||
with self._promotion_lock:
|
||||
if self.engine.should_promote_to_hot(key):
|
||||
self.warm.put(key, value)
|
||||
self.cold.delete(key) # Remove from source tier after promotion
|
||||
self.engine.record_promotion(key)
|
||||
self._metrics.promotions += 1
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
def put(self, key: str, value: T, tier: str = "hot") -> None:
|
||||
"""Store entry in specified tier (no manager-level lock needed)."""
|
||||
if tier == "hot":
|
||||
self.hot.put(key, value)
|
||||
elif tier == "warm":
|
||||
self.warm.put(key, value)
|
||||
elif tier == "cold":
|
||||
self.cold.put(key, value)
|
||||
else:
|
||||
msg = f"Invalid tier: {tier}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete entry from all tiers."""
|
||||
deleted = False
|
||||
deleted |= self.hot.delete(key)
|
||||
deleted |= self.warm.delete(key)
|
||||
deleted |= self.cold.delete(key)
|
||||
return deleted
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries from all tiers."""
|
||||
self.hot.clear()
|
||||
self.warm.clear()
|
||||
self.cold.clear()
|
||||
|
||||
def get_metrics(self) -> StorageTierMetrics:
|
||||
"""Get current storage tier metrics.
|
||||
|
||||
O(1) -- all sizes tracked incrementally.
|
||||
"""
|
||||
hot_count, hot_size, hot_hits, hot_misses = self.hot.get_metrics()
|
||||
warm_count, warm_size, warm_hits, warm_misses = self.warm.get_metrics()
|
||||
cold_count, cold_size, cold_hits, cold_misses = self.cold.get_metrics()
|
||||
|
||||
self._metrics.hot_entry_count = hot_count
|
||||
self._metrics.warm_entry_count = warm_count
|
||||
self._metrics.cold_entry_count = cold_count
|
||||
self._metrics.hot_size_bytes = hot_size
|
||||
self._metrics.warm_size_bytes = warm_size
|
||||
self._metrics.cold_size_bytes = cold_size
|
||||
self._metrics.hot_hits = hot_hits
|
||||
self._metrics.warm_hits = warm_hits
|
||||
self._metrics.cold_hits = cold_hits
|
||||
self._metrics.hot_misses = hot_misses
|
||||
self._metrics.warm_misses = warm_misses
|
||||
self._metrics.cold_misses = cold_misses
|
||||
self._metrics.timestamp = datetime.now()
|
||||
|
||||
return self._metrics
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACMSStorageTierManager",
|
||||
"ColdStorageTier",
|
||||
"HotStorageTier",
|
||||
"LifecyclePolicy",
|
||||
"LifecyclePolicyEngine",
|
||||
"StorageTierMetrics",
|
||||
"WarmStorageTier",
|
||||
]
|
||||
@@ -31,20 +31,10 @@ def _path_matches(path: str, include_patterns: list[str]) -> bool:
|
||||
|
||||
|
||||
def _matches_pattern(path_obj: PurePosixPath, pattern: str) -> bool:
|
||||
"""Match a path against a glob pattern, handling absolute vs relative paths.
|
||||
|
||||
Tries ``full_match()`` with the pattern as-is, then with a ``**/``
|
||||
prefix so that relative globs (e.g. ``.opencode/*``) correctly match
|
||||
absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``). Also
|
||||
handles the ``**/`` zero-depth compatibility shim.
|
||||
"""
|
||||
if path_obj.full_match(pattern):
|
||||
return True
|
||||
# Auto-prefix with **/ so relative patterns match absolute paths
|
||||
if not pattern.startswith("**/") and path_obj.full_match(f"**/{pattern}"):
|
||||
return True
|
||||
# Zero-depth shim: "**/" in pattern but full_match already tried above
|
||||
return bool("**/" in pattern and path_obj.full_match(pattern.replace("**/", "")))
|
||||
"""Match with a small compatibility shim for ``**/`` zero-depth cases."""
|
||||
return path_obj.match(pattern) or (
|
||||
"**/" in pattern and path_obj.match(pattern.replace("**/", ""))
|
||||
)
|
||||
|
||||
|
||||
def _extract_path(fragment: TieredFragment) -> str:
|
||||
|
||||
@@ -72,32 +72,13 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
|
||||
@staticmethod
|
||||
def _path_matches(path: str, include: list[str], exclude: list[str]) -> bool:
|
||||
"""Return whether *path* passes include/exclude path globs.
|
||||
|
||||
Handles both absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``)
|
||||
and relative paths (e.g. ``src/foo.py``) against relative glob
|
||||
patterns (e.g. ``.opencode/**``, ``src/**/*.py``).
|
||||
|
||||
Each pattern is tried with ``full_match()`` as-is (handles
|
||||
relative paths and ``**`` patterns). If the pattern is not
|
||||
already anchored with ``**/``, a second attempt prefixes
|
||||
``**/`` so that relative globs also match absolute paths.
|
||||
"""
|
||||
"""Return whether *path* passes include/exclude path globs."""
|
||||
pure_path = PurePath(path)
|
||||
|
||||
def _matches_any(patterns: list[str]) -> bool:
|
||||
for pattern in patterns:
|
||||
if pure_path.full_match(pattern):
|
||||
return True
|
||||
if not pattern.startswith("**/") and pure_path.full_match(
|
||||
f"**/{pattern}"
|
||||
):
|
||||
return True
|
||||
if include and not any(pure_path.full_match(pattern) for pattern in include):
|
||||
return False
|
||||
|
||||
if include and not _matches_any(include):
|
||||
return False
|
||||
return not (exclude and _matches_any(exclude))
|
||||
return not (
|
||||
exclude and any(pure_path.full_match(pattern) for pattern in exclude)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resource_matches(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Parses Python source into an ``ast`` tree and extracts:
|
||||
|
||||
- Module-level declarations (``uko-code:Module``).
|
||||
- Class definitions with docstrings (``uko-oo:Class``, ``uko-py:Class``).
|
||||
- Class definitions with docstrings (``uko-py:Class``).
|
||||
- Function and method definitions with docstrings (``uko-py:Function``).
|
||||
- Import statements (``uko:references``).
|
||||
- Module-level docstrings (``uko-doc:hasDocstring``).
|
||||
@@ -13,7 +13,6 @@ All extracted elements are represented as ``UKOTriple`` instances with
|
||||
|
||||
- Layer 0 core: ``uko:contains``, ``uko:references``
|
||||
- Layer 1 code: ``uko-code:Module``
|
||||
- Layer 2 paradigm/OO: ``uko-oo:Class``
|
||||
- Layer 3 Python-specific: ``uko-py:Class``, ``uko-py:Function``
|
||||
|
||||
Based on ``docs/specification.md`` ACMS Extensions — PythonAnalyzer.
|
||||
@@ -167,16 +166,7 @@ class PythonAnalyzer:
|
||||
triples: list[UKOTriple] = []
|
||||
cls_uri = _class_uri(resource_uri, node.name)
|
||||
|
||||
# Layer 2 (paradigm/OO) type declaration - Python classes are OO constructs.
|
||||
triples.append(
|
||||
UKOTriple(
|
||||
subject_uri=cls_uri,
|
||||
predicate="rdf:type",
|
||||
object_uri="uko-oo:Class",
|
||||
)
|
||||
)
|
||||
|
||||
# Layer 3 (technology) type declaration
|
||||
# Type declaration
|
||||
triples.append(
|
||||
UKOTriple(
|
||||
subject_uri=cls_uri,
|
||||
|
||||
@@ -140,18 +140,18 @@ class TierBudget(BaseModel):
|
||||
"""
|
||||
|
||||
max_tokens_hot: int = Field(
|
||||
default=16000,
|
||||
gt=0,
|
||||
default=8000,
|
||||
ge=0,
|
||||
description="Maximum total tokens in the hot tier",
|
||||
)
|
||||
max_decisions_warm: int = Field(
|
||||
default=100,
|
||||
gt=0,
|
||||
default=500,
|
||||
ge=0,
|
||||
description="Maximum number of fragments in the warm tier",
|
||||
)
|
||||
max_decisions_cold: int = Field(
|
||||
default=500,
|
||||
gt=0,
|
||||
default=5000,
|
||||
ge=0,
|
||||
description="Maximum number of fragments in the cold tier",
|
||||
)
|
||||
|
||||
|
||||
@@ -241,9 +241,10 @@ class PluginLoader:
|
||||
def validate_protocol(klass: type[Any], protocol: type[Any]) -> bool:
|
||||
"""Check whether *klass* satisfies a ``@runtime_checkable`` Protocol.
|
||||
|
||||
Uses structural type checking via ``issubclass`` to validate the
|
||||
protocol without instantiating the class. This prevents arbitrary
|
||||
code execution from untrusted plugin constructors during validation.
|
||||
Creates a temporary instance of *klass* (using a no-arg
|
||||
constructor) and checks it against the protocol via
|
||||
``isinstance``. If instantiation fails, falls back to a
|
||||
structural check using ``issubclass``.
|
||||
|
||||
Args:
|
||||
klass: The class to validate.
|
||||
@@ -255,84 +256,23 @@ class PluginLoader:
|
||||
Raises:
|
||||
ProtocolMismatchError: If *klass* does not satisfy the protocol.
|
||||
"""
|
||||
# Try structural check first (safe, no instantiation).
|
||||
# This prevents arbitrary code execution from plugin constructors.
|
||||
issubclass_raised_type_error = False
|
||||
# Try instance check first (most reliable for runtime_checkable)
|
||||
try:
|
||||
if issubclass(klass, protocol):
|
||||
instance = klass()
|
||||
if isinstance(instance, protocol):
|
||||
return True
|
||||
except TypeError:
|
||||
# issubclass raises TypeError if protocol is not a valid
|
||||
# @runtime_checkable Protocol (e.g. has a custom metaclass that
|
||||
# overrides __subclasscheck__). Fall through to structural check.
|
||||
issubclass_raised_type_error = True
|
||||
|
||||
# Fallback: perform a conservative structural check against the
|
||||
# Protocol definition without instantiating the class. We inspect
|
||||
# the protocol's declared members (callables and annotated names)
|
||||
# and ensure the candidate class exposes them as class-level
|
||||
# attributes or descriptors. This approximates structural
|
||||
# conformance while avoiding constructor execution.
|
||||
required: set[str] = set()
|
||||
|
||||
# Collect names from protocol __dict__ (methods, properties, etc.)
|
||||
prot_dict = getattr(protocol, "__dict__", {})
|
||||
for name, _value in prot_dict.items():
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
# Methods and descriptors will appear in the dict; annotations
|
||||
# are handled below. We treat any non-data-magic name as a
|
||||
# required member.
|
||||
required.add(name)
|
||||
|
||||
# Also include annotated names (those declared with type hints)
|
||||
ann = getattr(protocol, "__annotations__", {}) or {}
|
||||
for name in ann:
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
required.add(name)
|
||||
|
||||
# If issubclass raised TypeError and the protocol declares no
|
||||
# inspectable members, we cannot validate conformance — treat this
|
||||
# as a mismatch rather than silently returning True for an
|
||||
# unverifiable protocol.
|
||||
if issubclass_raised_type_error and not required:
|
||||
msg = (
|
||||
f"Class '{getattr(klass, '__name__', str(klass))}' cannot be "
|
||||
f"validated against protocol "
|
||||
f"'{getattr(protocol, '__name__', str(protocol))}': "
|
||||
f"issubclass raised TypeError and the protocol declares no "
|
||||
f"inspectable members. Ensure the protocol is a valid "
|
||||
f"@runtime_checkable Protocol."
|
||||
)
|
||||
raise ProtocolMismatchError(msg)
|
||||
|
||||
# Validate that the candidate class exposes each required name.
|
||||
missing: list[str] = []
|
||||
for name in sorted(required):
|
||||
# getattr on the class returns descriptors (functions, property,
|
||||
# staticmethod, etc.) without instantiation. hasattr is safe.
|
||||
if not hasattr(klass, name):
|
||||
missing.append(name)
|
||||
continue
|
||||
# If the protocol declared a callable (method), ensure the
|
||||
# attribute on the class is callable or a descriptor.
|
||||
prot_val = prot_dict.get(name)
|
||||
if callable(prot_val):
|
||||
cand = getattr(klass, name)
|
||||
# Properties are descriptors and typically not callable; we
|
||||
# accept them as present. For methods, require callable.
|
||||
if not (callable(cand) or hasattr(cand, "fget")):
|
||||
missing.append(name)
|
||||
|
||||
if not missing:
|
||||
return True
|
||||
except Exception:
|
||||
# If instantiation fails, try subclass check
|
||||
try:
|
||||
if issubclass(klass, protocol):
|
||||
return True
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
msg = (
|
||||
f"Class '{getattr(klass, '__name__', str(klass))}' does not "
|
||||
f"satisfy protocol '{getattr(protocol, '__name__', str(protocol))}'. "
|
||||
f"Missing attributes: {', '.join(missing)}. Ensure the class "
|
||||
f"implements all required methods and descriptors."
|
||||
f"Class '{klass.__name__}' does not satisfy protocol "
|
||||
f"'{protocol.__name__}'. Ensure the class implements all "
|
||||
f"required methods and attributes."
|
||||
)
|
||||
raise ProtocolMismatchError(msg)
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ if _TEXTUAL_AVAILABLE:
|
||||
def action_help(self) -> None:
|
||||
prompt = self.query_one("#prompt", PromptInput)
|
||||
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
|
||||
context_name = resolve_help_context(prompt.value)
|
||||
context_name = resolve_help_context(prompt.text)
|
||||
help_panel.toggle(context_name)
|
||||
|
||||
def action_cycle_preset(self) -> None:
|
||||
|
||||
@@ -39,23 +39,9 @@ Screen {
|
||||
}
|
||||
|
||||
#prompt {
|
||||
layout: horizontal;
|
||||
height: auto;
|
||||
border: round $primary;
|
||||
margin: 1 0 0 0;
|
||||
align-horizontal: left;
|
||||
}
|
||||
|
||||
#prompt > .prompt-symbol {
|
||||
padding: 0 1;
|
||||
content-align: center middle;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
#prompt > Input {
|
||||
border: none;
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#persona-bar {
|
||||
|
||||
@@ -19,7 +19,6 @@ class InputMode(StrEnum):
|
||||
NORMAL = "normal"
|
||||
COMMAND = "command"
|
||||
SHELL = "shell"
|
||||
MULTILINE = "multiline"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -54,8 +53,6 @@ class InputModeRouter:
|
||||
return InputMode.COMMAND
|
||||
if stripped.startswith(("!", "$")):
|
||||
return InputMode.SHELL
|
||||
if "\n" in text or "```" in text:
|
||||
return InputMode.MULTILINE
|
||||
return InputMode.NORMAL
|
||||
|
||||
def process(self, text: str) -> ModeResult:
|
||||
|
||||
@@ -1,111 +1,27 @@
|
||||
"""Prompt widget with mode-aware symbol handling."""
|
||||
"""Prompt widget and submitted message event."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
from cleveragents.tui.input.modes import InputMode, InputModeRouter
|
||||
|
||||
|
||||
class _InputChangedEvent(Protocol):
|
||||
input: Any
|
||||
value: str
|
||||
|
||||
|
||||
class _MutableValueInput(Protocol):
|
||||
value: str
|
||||
|
||||
def focus(self) -> None: ...
|
||||
|
||||
|
||||
class _StaticWidget(Protocol):
|
||||
def __init__(self, text: str = "", *args: object, **kwargs: object) -> None: ...
|
||||
|
||||
def update(self, text: str) -> None: ...
|
||||
|
||||
|
||||
class _HorizontalWidget(Protocol):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def focus(self) -> None: ...
|
||||
|
||||
|
||||
class _InputWidget(_MutableValueInput, Protocol):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_input_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").Input
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
return importlib.import_module("textual.widgets").TextArea
|
||||
except Exception: # pragma: no cover
|
||||
|
||||
class _FallbackInput:
|
||||
value = ""
|
||||
text = ""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.value = ""
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
return None
|
||||
self.text = ""
|
||||
|
||||
return _FallbackInput
|
||||
|
||||
|
||||
def _load_static_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").Static
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackStatic:
|
||||
def __init__(self, text: str = "", *args: object, **kwargs: object) -> None:
|
||||
self._text = text
|
||||
|
||||
def update(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
return _FallbackStatic
|
||||
|
||||
|
||||
def _load_horizontal_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.containers").Horizontal
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackHorizontal:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
del args, kwargs
|
||||
self._children: list[Any] = []
|
||||
|
||||
def compose(self) -> Iterable[Any]:
|
||||
yield from self._children
|
||||
|
||||
def mount(self, widget: Any) -> None:
|
||||
self._children.append(widget)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
return None
|
||||
|
||||
return _FallbackHorizontal
|
||||
|
||||
|
||||
_InputBase = cast(type[_InputWidget], _load_input_base())
|
||||
_StaticBase = cast(type[_StaticWidget], _load_static_base())
|
||||
_HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base())
|
||||
_TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.")
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
_ComposeResult = Iterable[Any]
|
||||
else:
|
||||
_ComposeResult = Iterable[Any]
|
||||
_InputBase = _load_input_base()
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -115,129 +31,10 @@ class PromptSubmitted:
|
||||
text: str
|
||||
|
||||
|
||||
_PROMPT_NORMAL = chr(0x276F)
|
||||
_PROMPT_COMMAND = "/"
|
||||
_PROMPT_SHELL = "$"
|
||||
_PROMPT_MULTILINE = chr(0x2630)
|
||||
|
||||
_PROMPT_SYMBOLS: dict[InputMode, str] = {
|
||||
InputMode.NORMAL: _PROMPT_NORMAL,
|
||||
InputMode.COMMAND: _PROMPT_COMMAND,
|
||||
InputMode.SHELL: _PROMPT_SHELL,
|
||||
InputMode.MULTILINE: _PROMPT_MULTILINE,
|
||||
}
|
||||
|
||||
|
||||
class _PromptSymbolMixin:
|
||||
"""Mixin providing mode-aware prompt symbol updates.
|
||||
|
||||
Subclasses must define a ``value`` property (str) and implement
|
||||
``_apply_symbol(symbol: str) -> None``.
|
||||
|
||||
.. note::
|
||||
|
||||
The spec (§29257) refers to "PromptTextArea" — implying a
|
||||
multi-line ``TextArea`` widget. However, ``TextArea.__init__()``
|
||||
dropped the ``placeholder`` keyword in textual >=1.0, so this
|
||||
implementation uses ``textual.widgets.Input`` (single-line)
|
||||
instead. The ``MULTILINE`` mode is detected by content
|
||||
(``\\n`` or triple-backtick) and the symbol updates correctly,
|
||||
but the underlying ``Input`` widget cannot display multiple
|
||||
lines. A future migration to ``TextArea`` (once placeholder
|
||||
support is restored or replaced) would enable true multi-line
|
||||
editing per spec §30209-30218.
|
||||
"""
|
||||
|
||||
_current_symbol: str
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _update_symbol(self, raw_text: str) -> None:
|
||||
symbol = _PROMPT_SYMBOLS[InputModeRouter.detect_mode(raw_text)]
|
||||
self._current_symbol = symbol
|
||||
self._apply_symbol(symbol)
|
||||
|
||||
@property
|
||||
def prompt_symbol(self) -> str:
|
||||
return self._current_symbol
|
||||
class PromptInput(_InputBase):
|
||||
"""TextArea widget wrapper with helper methods."""
|
||||
|
||||
def consume_text(self) -> PromptSubmitted:
|
||||
text = self.value
|
||||
self.value = ""
|
||||
text = self.text
|
||||
self.text = ""
|
||||
return PromptSubmitted(text=text)
|
||||
|
||||
|
||||
class _TextualPromptInput(_PromptSymbolMixin, _HorizontalBase):
|
||||
"""Composite widget that displays a mode-aware prompt symbol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
placeholder: str = "",
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
horizontal = cast(Any, super())
|
||||
horizontal.__init__(name=name, id=id, classes=classes)
|
||||
self._symbol_widget = _StaticBase("", classes="prompt-symbol")
|
||||
input_id = f"{id}--input" if id else None
|
||||
self._input = cast(
|
||||
_MutableValueInput, _InputBase(placeholder=placeholder, id=input_id)
|
||||
)
|
||||
self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL]
|
||||
self._update_symbol(self._input.value)
|
||||
|
||||
def compose(self) -> _ComposeResult:
|
||||
yield self._symbol_widget
|
||||
yield self._input
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self._input.value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: str) -> None:
|
||||
self._input.value = new_value
|
||||
self._update_symbol(new_value)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - delegating to inner input
|
||||
self._input.focus()
|
||||
|
||||
def on_input_changed(self, event: _InputChangedEvent) -> None:
|
||||
if event.input is self._input:
|
||||
self._update_symbol(event.value)
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
self._symbol_widget.update(symbol)
|
||||
|
||||
|
||||
class _FallbackPromptInput(_PromptSymbolMixin):
|
||||
"""Fallback prompt input used when Textual is unavailable."""
|
||||
|
||||
def __init__(self, placeholder: str = "", **_: object) -> None:
|
||||
self.placeholder = placeholder
|
||||
self._input = cast(_MutableValueInput, _InputBase())
|
||||
self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL]
|
||||
self._update_symbol(self._input.value)
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return getattr(self._input, "value", "")
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: str) -> None:
|
||||
self._input.value = new_value
|
||||
self._update_symbol(new_value)
|
||||
|
||||
def focus(self) -> None: # pragma: no cover - API parity
|
||||
focus = getattr(self._input, "focus", None)
|
||||
if callable(focus):
|
||||
focus()
|
||||
|
||||
def _apply_symbol(self, symbol: str) -> None:
|
||||
self._current_symbol = symbol
|
||||
|
||||
|
||||
PromptInput = _TextualPromptInput if _TEXTUAL_AVAILABLE else _FallbackPromptInput
|
||||
|
||||
Reference in New Issue
Block a user