first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Harness Auth Preflight + Runtime Failure Detection
|
||||
|
||||
## Summary
|
||||
Cloud agent runs using third-party harnesses (Claude Code, Codex) should detect authentication and runtime API failures early and report them with actionable error messages instead of letting the harness drift mid-run with an opaque failure.
|
||||
|
||||
This feature has two parts:
|
||||
1. A lightweight **authentication preflight check** that runs before the main harness CLI command starts. Same UX as a normal setup-commands block.
|
||||
2. A **background output scanner** that watches the running harness block for known runtime failure substrings (e.g. invalid API key, exhausted credits) and terminates the run on the first hit. This replaces the original billing preflight check, which burned a test API request and told us nothing the real run wouldn't surface seconds later.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Preflight sequence
|
||||
1. After harness environment config is written (auth.json, config files, etc.) but before the main harness CLI command starts, the driver runs the **authentication check** for the harness (if one is defined).
|
||||
2. If the authentication check fails (non-zero exit code), the task is marked as failed on the server with a `HarnessAuthFailed` error indicating that login credentials are invalid, and the process exits.
|
||||
3. If the authentication check passes (exit code 0), the driver proceeds to start the main harness CLI command. The runtime failure scanner (see "Runtime failure detection" below) starts at the same time.
|
||||
4. The authentication check is optional per-harness. A harness that returns no command skips this step silently.
|
||||
|
||||
### Per-harness commands
|
||||
5. **Claude Code**: Authentication check: `claude auth status --json`
|
||||
6. **Codex**: Authentication check: `codex login status`
|
||||
7. **Gemini**: No authentication check. Skipped silently.
|
||||
8. Future harnesses opt in by implementing the `auth_check_command` trait method; the default is no check.
|
||||
|
||||
### Success / failure semantics
|
||||
9. Authentication check success is determined solely by exit code. Exit code 0 = pass; any non-zero exit code = fail.
|
||||
10. The authentication check is executed as a visible block in the shared session UI and is classified as part of the existing **Set up environment commands** collapsible group, in line with the other environment-setup blocks. The block is individually expandable to reveal the CLI's captured output, and the whole group collapses to "Ran setup commands" once the harness session begins. On preflight failure, the group remains expanded so the user can inspect the failing block directly.
|
||||
11. The captured block output is also stashed in the driver-side error `detail` so the same text reaches server logs and the failure status message.
|
||||
|
||||
### Runtime failure detection (replaces the billing preflight)
|
||||
12. While the main harness CLI command is running, the driver runs a background scanner that watches the harness's output block for substrings indicating an API request failure (invalid key, exhausted credits, no billing access, quota exceeded, etc.). The set of substrings is supplied per-harness via the `runtime_error_patterns` trait method.
|
||||
13. The scanner uses an **adaptive polling schedule**: 5-second polls for the first 30 seconds, then 15-second polls for the next 60 seconds (total budget: 90 seconds, ~10 polls). After the schedule completes without a hit, the scanner stops and the harness continues uninterrupted.
|
||||
14. Each poll reads the harness block's visible output (no ANSI escape sequences, secrets obfuscated) and scans for the first matching pattern via the same DFA infrastructure used by the find feature (`RegexDFAs::new_many`). Matching is case-insensitive.
|
||||
15. On the first hit, the scanner enters a **stall-confirmation loop** before failing the task (see "Stall confirmation" below). Only after the harness output has stabilized does the driver send `/exit` to the harness and report the failure to the server.
|
||||
16. The scanner is a no-op for harnesses that return an empty `runtime_error_patterns` slice (e.g. Gemini today). Adding new patterns is a one-line change in the per-harness file.
|
||||
|
||||
### Stall confirmation (false-positive guard for harness retries)
|
||||
17. Some third-party harnesses (Claude Code, Codex) print transient API errors and then automatically retry. A naive "first-hit wins" scanner would false-positive in that case and kill a recovering run. The runtime scanner therefore confirms quiescence before failing:
|
||||
- On a pattern hit, the scanner snapshots the harness block's visible plaintext.
|
||||
- Every 10 seconds for up to 60 seconds, it takes another snapshot and compares.
|
||||
- As long as the snapshots differ — i.e. the harness is producing new bytes, including spinner frames — the loop keeps running.
|
||||
- The first time two consecutive snapshots are byte-identical *and* the originating pattern is still present in the block, the detection is confirmed and the task is failed.
|
||||
- If the 60-second budget elapses without stabilization, the detection is dropped. The outer scan schedule resumes normal polling, and a later tick may re-detect.
|
||||
- If output stabilizes but the matched line has scrolled out of the visible window (i.e. the harness recovered far enough that the error is gone), the detection is also dropped.
|
||||
18. The confirmation time counts toward the outer 90-second scan-schedule budget so a harness that keeps retripping the same pattern can't extend the watch window indefinitely.
|
||||
|
||||
### Error reporting
|
||||
19. When the authentication preflight fails, the driver sends an `updateAgentTask` mutation to the server with:
|
||||
- `taskState: FAILED`
|
||||
- `statusMessage`: "Harness '{harness}' authentication check failed: login credentials are invalid or expired. Verify that the authentication secret configured for this harness is correct."
|
||||
- `errorCode: AUTHENTICATION_REQUIRED`
|
||||
20. When the runtime scanner detects a failure, the driver sends an `updateAgentTask` mutation to the server with:
|
||||
- `taskState: FAILED`
|
||||
- `statusMessage`: "Harness '{harness}' could not make a successful API request. Matched failure pattern '{pattern}' in harness output: \"{excerpt}\". This usually means the API key is invalid, out of credits, or the account is misconfigured."
|
||||
- `errorCode: AUTHENTICATION_REQUIRED` (same code as the auth preflight failure; runtime failures are an extension of the same class of error)
|
||||
21. After reporting the failure to the server, the driver exits the harness and terminates the process. No retry is attempted.
|
||||
|
||||
### Timeouts
|
||||
22. The authentication preflight command has a timeout of 30 seconds. If it does not exit within the timeout, it is treated as a failure with a message indicating the check timed out.
|
||||
23. The runtime scanner has a fixed 90-second observation window after the harness command starts (including any time spent in the stall-confirmation loop). It does not impose a timeout on the harness; it simply stops watching after the window elapses.
|
||||
|
||||
### Interaction with existing checks
|
||||
24. Preflight checks run after `ThirdPartyHarness::validate()` (which checks CLI installation) and after `build_runner()` (which writes auth config files). This ordering ensures the CLI binary exists and credentials are on disk before we attempt to use them.
|
||||
25. The authentication preflight runs before `HarnessRunner::start()`, which launches the main CLI command. The main command is never started if the authentication check fails.
|
||||
26. The runtime scanner starts after `HarnessRunner::start()` returns the `BlockId` for the harness's main block. It races against the harness command, the periodic conversation save, and the idle-on-complete signal in the existing select loop.
|
||||
|
||||
### Idempotency and side effects
|
||||
27. The authentication check commands (`claude auth status`, `codex login status`) are read-only status queries with no side effects.
|
||||
28. The runtime scanner is purely observational — it reads the harness block's existing output. It never injects commands into the terminal and never modifies harness state. On a detected failure it issues the existing `/exit` slash command (the same call used by the idle-on-complete path).
|
||||
|
||||
### Edge cases
|
||||
29. If the harness CLI is installed but the auth config file was not written successfully (e.g. disk full), the authentication check will fail with a non-zero exit code. This is the correct behavior — the run cannot proceed without valid auth.
|
||||
30. If network connectivity is unavailable, the authentication check will fail with the generic auth-failure message; the stderr logs will contain the CLI's own diagnostic output for further debugging.
|
||||
31. If the harness CLI version is too old to support the preflight command (e.g. `claude auth status` not available), the command will fail with a non-zero exit code. This is acceptable — the subsequent main command would also likely fail.
|
||||
32. If the runtime scanner detects a failure but the harness exits successfully on its own before the `/exit` signal is processed, the detected failure still wins: the driver reports `HarnessRuntimeFailureDetected` rather than the harness's own (likely successful-looking) exit code. This avoids the confusing case where the harness reports success after the API call already failed.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Harness Auth Preflight + Runtime Failure Detection — Tech Spec
|
||||
|
||||
## Context
|
||||
See `PRODUCT.md` for user-facing behavior. This document covers the implementation.
|
||||
|
||||
### Current state
|
||||
Third-party harnesses implement the `ThirdPartyHarness` trait (`app/src/ai/agent_sdk/driver/harness/mod.rs:122`). The lifecycle for a third-party harness run is:
|
||||
|
||||
1. `ThirdPartyHarness::validate()` — checks CLI is on PATH.
|
||||
2. `ThirdPartyHarness::build_runner()` — writes config files (auth, trust, system prompt, MCP) and returns a `HarnessRunner`.
|
||||
3. `HarnessRunner::start()` — creates the external conversation on the server and launches the main CLI command.
|
||||
|
||||
These steps are orchestrated in `AgentDriver::run_internal` (`driver.rs:1608`), which calls `setup_harness` → `prepare_harness` → `run_preflight_checks` → `run_harness`.
|
||||
|
||||
Driver errors are classified in `error_classification.rs` into `(AgentTaskState, TaskStatusUpdate)` pairs and reported to the server via `report_driver_error`. The `PlatformErrorCode` enum already has `AUTHENTICATION_REQUIRED`.
|
||||
|
||||
### Relevant files
|
||||
- `app/src/ai/agent_sdk/driver/harness/mod.rs` — `ThirdPartyHarness` trait, `HarnessRunner` trait, `preflight_commands_for`
|
||||
- `app/src/ai/agent_sdk/driver/harness/claude_code.rs`, `harness/codex.rs`, `harness/gemini.rs` — per-harness implementations
|
||||
- `app/src/ai/agent_sdk/driver.rs` — `AgentDriver`, `AgentDriverError` enum, `run_internal`, `prepare_harness`, `run_harness`, `run_preflight_checks`
|
||||
- `app/src/ai/agent_sdk/driver/harness_output_monitor.rs` — runtime scanner (new)
|
||||
- `app/src/ai/agent_sdk/driver/error_classification.rs` — maps `AgentDriverError` → `(AgentTaskState, TaskStatusUpdate)`
|
||||
- `app/src/ai/agent_sdk/driver/terminal.rs` — `TerminalDriver::execute_command`, `block_snapshot`, `find_first_match_in_block_output` (new)
|
||||
- `app/src/terminal/model/find.rs` — `RegexDFAs` machinery reused by the scanner
|
||||
- `app/src/terminal/model/blockgrid.rs` — `BlockGrid::find(&RegexDFAs)` returns matches over cell storage directly
|
||||
|
||||
## Proposed changes
|
||||
|
||||
### 1. Trait surface changes on `ThirdPartyHarness`
|
||||
|
||||
Drop the billing preflight method and add a runtime-error-patterns method:
|
||||
|
||||
```rust path=null start=null
|
||||
// Removed:
|
||||
// fn billing_check_command(&self) -> Option<String> { None }
|
||||
|
||||
// Added:
|
||||
/// Substrings to scan for in the running harness block's output. A hit
|
||||
/// indicates the harness can't make a successful API request.
|
||||
fn runtime_error_patterns(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
```
|
||||
|
||||
Per-harness:
|
||||
- **Claude Code** and **Codex**: drop `billing_check_command`, add `runtime_error_patterns` returning an empty slice with a `TODO(REMOTE-1385)` to fill in validated needles.
|
||||
- **Gemini**: inherits default `None` / empty.
|
||||
|
||||
`preflight_commands_for(harness)` in `harness/mod.rs` now only pushes `auth_check_command()`. The viewer's "setup commands" grouping continues to recognize preflight blocks by exact string equality.
|
||||
|
||||
### 2. New error variant + classification
|
||||
|
||||
In `driver.rs`'s `AgentDriverError`:
|
||||
|
||||
```rust path=null start=null
|
||||
// Removed: HarnessAuthFailureKind enum and the `kind` field on
|
||||
// HarnessAuthCheckFailed.
|
||||
|
||||
#[error("Harness '{harness}' auth preflight failed")]
|
||||
HarnessAuthCheckFailed {
|
||||
harness: String,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
// Added:
|
||||
#[error("Harness '{harness}' reported a runtime failure matching '{pattern}'")]
|
||||
HarnessRuntimeFailureDetected {
|
||||
harness: String,
|
||||
pattern: String,
|
||||
excerpt: String,
|
||||
},
|
||||
```
|
||||
|
||||
In `error_classification.rs`, both variants map to `(AgentTaskState::Failed, PlatformErrorCode::AuthenticationRequired)` with distinct user-visible messages. The runtime failure message surfaces both the matched needle and the excerpt from the harness output.
|
||||
|
||||
### 3. New `find_first_match_in_block_output` helper on `TerminalDriver`
|
||||
|
||||
Add a sibling to `block_snapshot` in `driver/terminal.rs` that runs the existing DFA machinery against the block's output grid directly:
|
||||
|
||||
```rust path=null start=null
|
||||
pub struct BlockOutputMatch {
|
||||
pub matched_text: String,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
pub fn find_first_match_in_block_output(
|
||||
&self,
|
||||
block_id: &BlockId,
|
||||
dfas: &RegexDFAs,
|
||||
ctx: &AppContext,
|
||||
) -> Option<BlockOutputMatch>;
|
||||
```
|
||||
|
||||
Internally:
|
||||
- Acquire the terminal model lock (same scope as `block_snapshot`).
|
||||
- Look up the block by ID; return `None` if missing.
|
||||
- Run `block.output_grid().find(dfas).next()` to get the first `Match = RangeInclusive<Point>`.
|
||||
- Use the grid handler's `bounds_to_string` to extract the matched substring (used to identify the originating needle) and the full row(s) the match touches (used as the user-visible excerpt). Both with `include_esc_sequences=false` and `RespectObfuscatedSecrets::Yes` so the failure message never leaks credentials.
|
||||
|
||||
No new grid-side code is required — `BlockGrid::find` is the same path the find feature uses on every keystroke.
|
||||
|
||||
### 4. New `harness_output_monitor` module
|
||||
|
||||
File: `app/src/ai/agent_sdk/driver/harness_output_monitor.rs`.
|
||||
|
||||
```rust path=null start=null
|
||||
pub(crate) struct DetectedHarnessError {
|
||||
pub pattern: String,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
const SCAN_SCHEDULE: &[(Duration, Duration)] = &[
|
||||
(Duration::from_secs(5), Duration::from_secs(30)),
|
||||
(Duration::from_secs(15), Duration::from_secs(60)),
|
||||
];
|
||||
|
||||
pub(crate) fn build_dfas(patterns: &[&'static str]) -> Option<RegexDFAs>;
|
||||
pub(crate) fn pattern_for_match(matched_text: &str, patterns: &[&'static str]) -> Option<&'static str>;
|
||||
pub(crate) async fn watch_block_for_errors(
|
||||
block_id: BlockId,
|
||||
patterns: &'static [&'static str],
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<DetectedHarnessError>;
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- Resolve immediately with `None` when `patterns.is_empty()` so Gemini/Oz pay zero runtime cost.
|
||||
- Build the combined DFA once at scanner entry via `RegexDFAs::new_many` (with regex-escaped needles + case-insensitive matching). Wrap in `Arc` so each tick can clone cheaply.
|
||||
- Use `warpui::r#async::Timer::after` for ticks, matching the existing `run_harness` periodic-save pattern.
|
||||
- Each tick spawns onto `foreground` to call `TerminalDriver::find_first_match_in_block_output(&dfas)`. On `Some`, the scanner runs the **stall confirmation loop** (see below) before mapping `matched_text` → originating needle via `pattern_for_match`, capping the excerpt to ~240 chars, and resolving the future with `DetectedHarnessError`.
|
||||
- The DFA scans cell storage from the start each tick, so no `last_scanned_len` cursor is needed — the regex_automata cache memoizes transitions.
|
||||
|
||||
#### Stall confirmation loop
|
||||
After a pattern hit, the scanner runs `confirm_stall` before reporting a failure. This guards against false positives when a harness prints a transient API error and then automatically retries.
|
||||
|
||||
```rust path=null start=null
|
||||
/// Gap between consecutive plaintext snapshots while confirming a hit.
|
||||
const STALL_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Total budget for the stall-confirmation loop.
|
||||
const STALL_CONFIRMATION_BUDGET: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Pure equality check kept out of the async body for easy unit tests.
|
||||
fn outputs_stalled(before: Option<&str>, after: Option<&str>) -> bool {
|
||||
matches!((before, after), (Some(a), Some(b)) if a == b)
|
||||
}
|
||||
|
||||
/// Polls the block plaintext every `STALL_POLL_INTERVAL` for up to
|
||||
/// `STALL_CONFIRMATION_BUDGET`. Returns `(Some(hit), elapsed)` once two
|
||||
/// consecutive snapshots are byte-identical AND a pattern is still
|
||||
/// present after the second snapshot; otherwise `(None, elapsed)`.
|
||||
async fn confirm_stall(
|
||||
block_id: &BlockId,
|
||||
dfas: &Arc<RegexDFAs>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> (Option<BlockOutputMatch>, Duration);
|
||||
```
|
||||
|
||||
Design notes:
|
||||
- **Why plaintext equality over `dirty_cells_range`?** `GridHandler::dirty_cells_range` is per-pty-pass-scoped — it resets to the cursor point on every `on_finish_byte_processing` call, so it's not a usable "has anything happened lately" oracle. Sampling the visible plaintext twice and comparing them gives us the same information directly, and works for spinner-only retries (the spinner cell changes between samples → the snapshots differ → the loop keeps running).
|
||||
- **Why count confirmation time against the outer scan-schedule budget?** A flaky harness that keeps retripping the same pattern would otherwise extend the watch window indefinitely — each candidate hit would buy another 60 seconds. Adding `confirmation_elapsed` back to the outer `elapsed` counter caps the total observation period at the outer 90-second budget plus whatever final confirmation was in flight when the schedule exhausted.
|
||||
- A new helper `TerminalDriver::block_output_plaintext(block_id, ctx) -> Option<String>` is added alongside `find_first_match_in_block_output`. It reads `block.output_grid().contents_to_string(false, None)` under the same model lock and obfuscates secrets identically.
|
||||
|
||||
### 5. Wire the scanner into `run_harness`
|
||||
|
||||
`run_harness` gains two new parameters and a fourth select arm:
|
||||
|
||||
```rust path=null start=null
|
||||
async fn run_harness(
|
||||
runner: Arc<dyn HarnessRunner>,
|
||||
harness_name: String,
|
||||
runtime_error_patterns: &'static [&'static str],
|
||||
foreground: &ModelSpawner<Self>,
|
||||
harness_exit_rx: oneshot::Receiver<()>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
let command_handle = runner.start(foreground).await?;
|
||||
let block_id = command_handle.block_id().clone();
|
||||
let mut command_handle = command_handle.fuse();
|
||||
let mut harness_exit_rx = harness_exit_rx.fuse();
|
||||
|
||||
let scanner_fut = harness_output_monitor::watch_block_for_errors(
|
||||
block_id, runtime_error_patterns, foreground,
|
||||
)
|
||||
.fuse();
|
||||
futures::pin_mut!(scanner_fut);
|
||||
|
||||
let mut detected_runtime_failure: Option<harness_output_monitor::DetectedHarnessError> = None;
|
||||
|
||||
let command_result = loop {
|
||||
futures::select! {
|
||||
exit_code = command_handle => break exit_code,
|
||||
_ = warpui::r#async::Timer::after(HARNESS_SAVE_INTERVAL).fuse() => {
|
||||
/* existing periodic save */
|
||||
}
|
||||
_ = harness_exit_rx => {
|
||||
/* existing graceful-exit branch */
|
||||
}
|
||||
detected = scanner_fut => {
|
||||
if let Some(error) = detected {
|
||||
// Ask the harness to exit gracefully so cleanup runs;
|
||||
// record the failure and let the command future complete.
|
||||
let _ = runner.exit(foreground).await;
|
||||
detected_runtime_failure = Some(error);
|
||||
}
|
||||
// When the schedule exhausts without a hit, the `Fuse`
|
||||
// wrapper makes this branch stay Pending forever.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* existing final save + cleanup */
|
||||
|
||||
if let Some(error) = detected_runtime_failure {
|
||||
return Err(AgentDriverError::HarnessRuntimeFailureDetected {
|
||||
harness: harness_name,
|
||||
pattern: error.pattern,
|
||||
excerpt: error.excerpt,
|
||||
});
|
||||
}
|
||||
|
||||
/* existing exit-code mapping */
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- After `scanner_fut` resolves to `None`, the `Fuse` wrapper makes the branch stay `Pending` forever, so we don't busy-loop.
|
||||
- A detected runtime failure takes precedence over the harness's own exit code: surface the actionable detail rather than a generic "exit code N".
|
||||
- The cleanup-disposition computation also factors `detected_runtime_failure` so we always drop resumption state on a runtime failure (a failed run shouldn't be silently resumable).
|
||||
|
||||
### 6. Caller update in `run_internal`
|
||||
|
||||
The third-party branch in `run_internal` already has `harness.as_ref()` in hand. Plumb the harness name and runtime patterns through both the credentials-refresh branch and the no-refresh branch:
|
||||
|
||||
```rust path=null start=null
|
||||
let harness_name = harness.cli_agent().command_prefix().to_owned();
|
||||
let runtime_error_patterns = harness.runtime_error_patterns();
|
||||
Self::run_harness(runner, harness_name, runtime_error_patterns, &foreground, harness_exit_rx).await
|
||||
```
|
||||
|
||||
### 7. Server-side changes
|
||||
|
||||
None. Both failure modes map to the existing `AUTHENTICATION_REQUIRED` `PlatformErrorCode`, differentiated only by the human-readable `statusMessage`. The server already handles `AUTHENTICATION_REQUIRED` in task state transitions.
|
||||
|
||||
## Testing and validation
|
||||
|
||||
### Unit tests
|
||||
- **`harness_output_monitor_tests.rs`** (new):
|
||||
- `build_dfas` returns `None` for empty patterns and `Some` otherwise; regex metacharacters in needles are escaped.
|
||||
- `pattern_for_match` maps a case-different matched text back to the originating `'static` needle; returns `None` when no needle matches; picks the first matching needle when multiple lowercase-equal candidates exist.
|
||||
- **`error_classification_tests.rs`**:
|
||||
- `HarnessAuthCheckFailed` maps to `(Failed, AuthenticationRequired)` with the auth-failure message. (Existing `harness_auth_test_request_failed_*` test removed since the `TestRequestFailed` variant is gone.)
|
||||
- `HarnessRuntimeFailureDetected` maps to `(Failed, AuthenticationRequired)`; the user-visible message contains both the matched `pattern` and the `excerpt`.
|
||||
- **`harness/mod_tests.rs`**:
|
||||
- Billing tests removed (`*_billing_check_command`, billing assertions in `preflight_commands_for_*`).
|
||||
- `preflight_commands_for_claude_returns_auth_only` / `preflight_commands_for_codex_returns_auth_only` pin the auth check string to the trait impl.
|
||||
- `runtime_error_patterns()` is callable on Claude/Codex (slice may be empty); Gemini returns an empty slice.
|
||||
|
||||
### Manual validation
|
||||
- Claude Code with a valid API key → auth check passes, harness runs, scanner finds no match in 90 seconds → run completes normally.
|
||||
- Claude Code with an invalid API key → auth check fails, task is marked FAILED with the auth-failure message.
|
||||
- Claude Code with a valid key but exhausted credits → auth check passes, harness emits a credit-balance error to its block, scanner detects it, task is marked FAILED with the runtime-failure message including the matched pattern + excerpt.
|
||||
- Same matrix for Codex.
|
||||
- Gemini → auth check skipped, scanner is a no-op, harness proceeds and behaves as before.
|
||||
|
||||
## Parallelization
|
||||
Single-agent task. The change is tightly coupled (trait method, classifier, driver loop, scanner module, new helper, tests) and lives in one crate; parallel agents would step on each other.
|
||||
Reference in New Issue
Block a user