diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index 093b3344f7..5f4ee22d77 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -243,6 +243,9 @@ tests/execution/backends/fixtures/codex_ndjson/*.json: tests/execution/fixtures/*.json: - execution/ +tests/execution/fixtures/*.jsonl: + - execution/ + tests/config/golden_configs/*.yaml: - config/ diff --git a/docs/configuration.md b/docs/configuration.md index 11153c9f62..945697de3e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -372,12 +372,21 @@ AutoSkillit provisions an isolated, wrapper-owned Codex home for each launch. It trusted synchronous `PreCompact` hook for the `auto` trigger; the hook reports `autoskillit_auto_compaction_denied` and a visible `systemMessage`. Headless execution ends with that terminal, non-retryable outcome. In the TUI the message is visible and a -user may continue explicitly or compact manually; manual compaction is not vetoed. +user may continue explicitly or compact manually; manual compaction is not vetoed. This +interactive choice does not make a failed fleet dispatch identity resumable. This requires Codex hook support from the configured minimum version and a trusted hook environment. A native `~/.codex/config.toml` that still contains the old `999999999` sentinel is left untouched, clamped upstream, and stripped from wrapper destinations, so it neither protects nor affects AutoSkillit launches. +The `PreCompact` veto is the prevention control for automatic compaction. If Codex instead +reports a native maximum-window failure, the fleet records +`fleet_l3_context_exhausted` and refuses to resume or retry that failed dispatch identity. +Coverage for the Codex error detail uses schema-backed fixtures based on the [Codex v2 +app-server schema](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json), +not a captured live maximum-window incident. If a live reproduction becomes available, +record the actual Codex CLI version; these fixtures do not establish a new version floor. + ## MCP Response Tracking ```yaml diff --git a/docs/decisions/0004-recipe-redelivery.md b/docs/decisions/0004-recipe-redelivery.md index 4cecaf986d..fc502a9f85 100644 --- a/docs/decisions/0004-recipe-redelivery.md +++ b/docs/decisions/0004-recipe-redelivery.md @@ -28,6 +28,12 @@ Two defenses exist: The re-delivery path remains necessary when model-visible recipe content is lost for any reason. It re-acquires recipe knowledge without reading raw files. +Current dispatched-root backends use synchronous native controls to block automatic +compaction before transcript history is replaced: Claude Code's exit-2 hook and Codex's +trusted `PreCompact` veto. A detected legacy or unexpected `compact_boundary` transcript +is a terminal failure and is never resumed. The transcript check runs after dispatch or +before resume; it detects prior history replacement and does not stop a running session. + ## Decision > **`load_recipe` / `open_kitchen` (the MCP tools exposed by `server/tools/tools_recipe.py`) diff --git a/docs/design/acp-session-contract.md b/docs/design/acp-session-contract.md index 84e9a05538..d36b7e0b95 100644 --- a/docs/design/acp-session-contract.md +++ b/docs/design/acp-session-contract.md @@ -194,7 +194,7 @@ and has no ACP analogue. Both backends raise `CapabilityNotSupportedError` ## Section 2: Recovery Ladder -The recovery ladder maps each of the 20 `RetryReason` enum values +The recovery ladder maps the `RetryReason` enum values (`src/autoskillit/core/types/foundation/_type_enums.py`, lines 44–64) to one of three ACP session rungs — `session/resume`, `session/load`, `session/new` — or to a terminal/wait-and-retry classification. @@ -230,9 +230,24 @@ specific infra-classification signals (e.g. API errors → `RESUME`, rate limits | `OUTCOME_INVARIANT` | `session/new` | `on_failure` | Skill-emitted outcome fields violated their declared relationship. | | `OUTCOME_REPORT_MALFORMED` | `session/new` | `on_failure` | Skill's terminal outcome report could not be parsed. Do not add a label. | | `ASYNC_OBLIGATION` | `session/new` | `on_failure` | Backend-owned work or a wakeup remained unresolved, the bounded completion drain expired, or lifecycle evidence was unavailable. Start fresh; never poll or resume the prior session. | -| `CONTEXT_EXHAUSTED` | (terminal) | N/A | Correlated Codex automatic-compaction veto. Start an explicit new session, or compact manually and deliberately resume. | +| `CONTEXT_EXHAUSTED` | (terminal) | N/A | Correlated Codex automatic-compaction veto. Do not resume or retry that failed session identity. | | `NONE` | (no retry) | N/A | Success — no recovery needed. | +The fleet dispatch layer also emits `fleet_l3_context_exhausted` when retained native +compaction evidence or a maximum-window failure makes that dispatch non-resumable. It is +terminal for the failed dispatch identity even when the result block or sidecar suggests +progress. The campaign may continue to other dispatches according to `continue_on_failure`, +but this identity is never resumed or retried. A separately authorized fresh dispatch is +a new identity and must not reuse the failed session ID. + +The automatic-compaction veto is root prevention; detecting an already-compacted legacy +root while resolving a resume is a refusal fallback and cannot stop compaction already in +progress. Codex maximum-window handling is covered with schema-backed fixtures based on +the [Codex v2 app-server schema](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json), +not a captured live maximum-window incident. +If a live reproduction is separately available, record the actual CLI version; do not +infer a version floor from these fixtures. + ### 2.2 ACP rung semantics - **`session/resume`** — Continue from checkpoint. The recovery step invokes @@ -535,7 +550,7 @@ human-facing ratios remain inside one source pair. | §2 Retry routing | `_compute_retry`, `_build_skill_result` overrides | `src/autoskillit/execution/session/_retry_fsm.py`, `src/autoskillit/execution/headless/_headless_result.py` | | §2 Contract nudge | `_attempt_contract_nudge`, `_merge_token_usage` | `src/autoskillit/execution/headless/_headless_recovery.py` | | §4.9 Token evidence | `TokenMeasure`, source-pair classifier, OTLP sink | `src/autoskillit/_measure_aggregation.py`, `src/autoskillit/execution/session/turn_usage.py`, `src/autoskillit/execution/evidence/otlp_sink.py` | -| §3 Capabilities | `BackendCapabilities` (47 fields) | `src/autoskillit/core/types/execution/_type_backend.py` | +| §3 Capabilities | `BackendCapabilities` | `src/autoskillit/core/types/execution/_type_backend.py` | | §3 Forward-declared | `_FORWARD_DECLARED` | `tests/arch/test_capability_consumption.py` | | §4 Codex flags | `CodexFlags` | `src/autoskillit/execution/backends/codex.py` lines 98–107 | | §4 Codex divergence mappings | Binding, output-mode, idle-stop, and warning sites | `src/autoskillit/execution/backends/codex.py` | diff --git a/docs/execution/orchestration.md b/docs/execution/orchestration.md index ec77a4b344..bd7915cd0a 100644 --- a/docs/execution/orchestration.md +++ b/docs/execution/orchestration.md @@ -64,10 +64,10 @@ tool result. The routing rules per tool: - `clone_repo` / `remove_clone` — read `clone_id` and stash for later cleanup via `register_clone_status` and `batch_cleanup_clones`. -## The 20 `retry_reason` values +## `retry_reason` values -`RetryReason` is a `StrEnum` in `src/autoskillit/core/types/foundation/_type_enums.py` with -20 distinct values. Each value triggers a different recovery route: +`RetryReason` is a `StrEnum` in `src/autoskillit/core/types/foundation/_type_enums.py`. +Each value triggers a different recovery route: | Value | When the orchestrator sets it | Recovery | |-------|-------------------------------|----------| @@ -87,7 +87,7 @@ tool result. The routing rules per tool: | `thinking_stall` | Thinking-only final turn: model consumed tokens (thinking blocks present) but produced no text or tool output | If lifespan_started, route to on_context_limit; else route to on_failure | | `rate_limited` | HTTP 429 rate-limit response detected in session output or exit status | Route to `on_rate_limit` if defined; fall back to `on_context_limit` | | `async_obligation` | Claude skill completion left owned tasks, a scheduled wakeup, a bounded-drain expiry, or no authoritative lifecycle evidence | Start fresh through `on_failure`; never poll or resume the prior session | -| `context_exhausted` | A correlated Codex automatic-compaction veto interrupted the turn | Terminal; start an explicit new session, or compact manually and deliberately resume | +| `context_exhausted` | A correlated Codex automatic-compaction veto interrupted the turn | Terminal for the failed session identity; do not resume or retry it | | `cancelled` | Transport teardown cancelled the worker | Terminal; do not retry | | `outcome_invariant` | Skill-emitted outcome fields violated their declared relationship | Route to `on_failure` | | `outcome_report_malformed` | Skill's terminal outcome report could not be parsed | Route to `on_failure`; do not add a label | @@ -95,6 +95,21 @@ tool result. The routing rules per tool: `recipe/rules_isolation.py` enforces matching `clone_contamination` and `path_contamination` defenses at recipe-validation time. +Fleet dispatches also use the dedicated reason `fleet_l3_context_exhausted` when retained +context evidence or a native maximum-window failure makes the result non-resumable. It +outranks payload, timeout, and sidecar outcomes, while dirty-result parse diagnostics are +preserved. Neither `retry_disposition` nor `continue_on_failure` authorizes resuming or +retrying that failed identity. A campaign may proceed to other dispatches according to +`continue_on_failure`; an ad-hoc caller reports the terminal failure. Any separately +authorized fresh dispatch has a new identity and must not reuse the failed session ID. + +Backend-native controls prevent automatic root compaction. Resume-time detection of a +previously compacted legacy root is a refusal fallback; it does not stop a compaction that +is already underway. Codex maximum-window recognition is covered with schema-backed +fixtures based on the [Codex v2 app-server schema](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json), +not a captured live maximum-window incident. Record the CLI version if a live +reproduction becomes available. + ## Wavefront scheduling The `implementation-groups` recipe runs many sibling implementations in diff --git a/docs/safety/hooks.md b/docs/safety/hooks.md index 6c1e5e7ba8..2b3c150035 100644 --- a/docs/safety/hooks.md +++ b/docs/safety/hooks.md @@ -7,15 +7,26 @@ types in `src/autoskillit/hook_registry/` via the `HOOK_REGISTRY` list of `HookDef` entries; `generate_hooks_json()` then materializes the canonical `hooks.json` that Claude Code reads. -## Codex runtime-only hook - -`auto_compact_guard.py` is a runtime-only Codex `PreCompact` hook with matcher `auto`. -It is synchronous and trusted in wrapper-owned Codex homes. Before Codex replaces -history automatically, it returns `continue: false`, the +## Automatic compaction guard + +`auto_compact_guard.py` handles the `PreCompact`/`auto` matcher on both backends. +Claude publishes the hook in generated `hooks.json`; it blocks only when +`AUTOSKILLIT_SESSION_TYPE=orchestrator`, returning exit 2 with the reason on stderr. +Claude's `auto` matcher covers automatic compaction at the auto-compact window and idle +compaction. The separate `manual` matcher remains unblocked, so users may compact +deliberately. If Claude invokes automatic compaction to recover from a context-limit +error, blocking it surfaces that error and fails the current request. + +The root food-truck launch also sets `DISABLE_AUTO_COMPACT=1` after provider extras to +suppress normal compaction attempts. The hook remains the blocking control if Claude +settings override that environment value. Worker and interactive sessions do not match +the orchestrator session check. + +Codex keeps its runtime-only `PreCompact`/`auto` registration in wrapper-owned homes. +Before Codex replaces history automatically, the hook returns `continue: false`, the `autoskillit_auto_compaction_denied` stop reason, and a visible `systemMessage` that explains the terminal headless result and explicit TUI continuation. A `manual` trigger -does not match, so users may compact manually. This hook is excluded from the Claude -Code registry. +does not match this Codex hook. ## PreToolUse hooks @@ -570,7 +581,7 @@ Hook scripts emit protocol output only through the channel functions in | Channel | Events | Output | |---------|--------|--------| | `deny` | PreToolUse | Nested `permissionDecision: deny` and reason, exit 0 | -| `block` | PreToolUse, PostToolUse, PostToolUseFailure, Stop, SubagentStop | Exit 2 with a non-empty reason on stderr; stdout is empty | +| `block` | PreToolUse, PostToolUse, PostToolUseFailure, Stop, SubagentStop, PreCompact (Claude) | Exit 2 with a non-empty reason on stderr; stdout is empty | | `context` | SessionStart, PreToolUse, PostToolUse, UserPromptExpansion | Nested `additionalContext`, exit 0 | | `rewrite_input` | PreToolUse | Nested `permissionDecision: allow` with non-empty `updatedInput`, exit 0 | | `rewrite_mcp_output` | PostToolUse | Nested `updatedMCPToolOutput`, exit 0; Claude-only | @@ -578,11 +589,12 @@ Hook scripts emit protocol output only through the channel functions in | `halt` | PreCompact | `continue: false`, `stopReason`, and `systemMessage`, exit 0; Codex-only | Blocking uses exit 2 plus stderr. Claude's PostToolUse and PostToolUseFailure -hooks deliver the reason as feedback after the tool has run. Codex's blocking -events require non-empty stderr, which becomes the continuation prompt. -`pretty_output_hook` is Claude-only because Codex refuses `updatedMCPToolOutput`. -Claude discards PreCompact's `continue` and `systemMessage`, so -`auto_compact_guard` remains runtime-only for Codex. +hooks deliver the reason as feedback after the tool has run. Codex block events require +non-empty stderr, which becomes the continuation prompt; Codex `PreCompact` uses its +separate `halt` channel. `pretty_output_hook` is Claude-only because Codex refuses +`updatedMCPToolOutput`. Claude discards `PreCompact`'s `continue` and `systemMessage`, +so its published automatic-compaction guard uses exit 2, while Codex keeps the runtime-only +`halt` registration. The [backend oracles](../../tests/_hook_protocol_oracle.py) interpret output using the vendored schemas in `tests/fixtures/codex_hook_protocol/`, pinned to diff --git a/src/autoskillit/cli/prompts/_prompts.py b/src/autoskillit/cli/prompts/_prompts.py index 30a6fb08b8..903cb72061 100644 --- a/src/autoskillit/cli/prompts/_prompts.py +++ b/src/autoskillit/cli/prompts/_prompts.py @@ -30,6 +30,20 @@ _PRE_DISPATCH_SCOPE = "pre_dispatch" _POST_RECEIPT_SCOPE = "post_receipt" +CONTEXT_EXHAUSTION_RECOVERY_CLAUSE = """## CONTEXT EXHAUSTION FAILURE + +A dispatch failure with reason `fleet_l3_context_exhausted` is terminal for its +failed identity. Do not resume or retry it, pass its `dispatched_session_id` as +`resume_session_id`, reuse its `dispatch_name`, or reset it to clear the failure, +regardless of `effect_provenance.retry_disposition` or campaign +`continue_on_failure`. + +For a campaign, apply `continue_on_failure` only to whether you halt the campaign +or proceed to other dispatches; never retry the failed identity. For an ad-hoc +dispatch, report the terminal failure to the human caller and do not retry. A +separately authorized fresh dispatch has a new identity and must not reuse the +failed session or dispatch ID.""" + @dataclass(frozen=True, slots=True) class _McpStartupRecoveryClause: diff --git a/src/autoskillit/cli/prompts/_prompts_campaign.py b/src/autoskillit/cli/prompts/_prompts_campaign.py index bf78a967ee..b7f0b13ff0 100644 --- a/src/autoskillit/cli/prompts/_prompts_campaign.py +++ b/src/autoskillit/cli/prompts/_prompts_campaign.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from autoskillit.cli.prompts._prompts import ( + CONTEXT_EXHAUSTION_RECOVERY_CLAUSE, _backend_supplement, _ingredient_table_display_instruction, ) @@ -330,6 +331,8 @@ def _build_fleet_campaign_prompt( If the user provided extra guidance at campaign launch, pass it to each dispatch via `caller_instructions=`. The L2 food truck treats it as authoritative. {gate_section}{dynamic_dispatch_section} +{CONTEXT_EXHAUSTION_RECOVERY_CLAUSE} + ## RESUME DISCIPLINE When a dispatch is killed (no result block received), the infrastructure classifies it: diff --git a/src/autoskillit/cli/prompts/_prompts_kitchen.py b/src/autoskillit/cli/prompts/_prompts_kitchen.py index 88e72aac0a..72042a1b91 100755 --- a/src/autoskillit/cli/prompts/_prompts_kitchen.py +++ b/src/autoskillit/cli/prompts/_prompts_kitchen.py @@ -7,6 +7,7 @@ from autoskillit.cli.prompts._prompts import ( _MCP_RETRY_INSTRUCTION, + CONTEXT_EXHAUSTION_RECOVERY_CLAUSE, _backend_supplement, _read_full_sous_chef, ) @@ -206,6 +207,8 @@ def _build_fleet_dispatch_prompt( pass it as caller_instructions. This free-text is injected into the L2 system prompt as authoritative guidance from the dispatcher. +{CONTEXT_EXHAUSTION_RECOVERY_CLAUSE} + ## DISPATCH RESUME PROTOCOL When a dispatch_food_truck call returns with dispatch_status="resumable": diff --git a/src/autoskillit/core/types/foundation/_type_enums.py b/src/autoskillit/core/types/foundation/_type_enums.py index 6dd06485c3..332767a998 100644 --- a/src/autoskillit/core/types/foundation/_type_enums.py +++ b/src/autoskillit/core/types/foundation/_type_enums.py @@ -561,6 +561,8 @@ class FleetErrorCode(StrEnum): FLEET_L3_TIMEOUT = "fleet_l3_timeout" FLEET_L3_NO_RESULT_BLOCK = "fleet_l3_no_result_block" FLEET_L3_PARSE_FAILED = "fleet_l3_parse_failed" + FLEET_L3_RESULT_CONTRACT_VIOLATION = "fleet_l3_result_contract_violation" + FLEET_L3_CONTEXT_EXHAUSTED = "fleet_l3_context_exhausted" FLEET_L3_STARTUP_OR_CRASH = "fleet_l3_startup_or_crash" FLEET_BUDGET_EXCEEDED = "fleet_budget_exceeded" FLEET_QUOTA_EXHAUSTED = "fleet_quota_exhausted" diff --git a/src/autoskillit/core/types/launch/_type_dispatch_identity.py b/src/autoskillit/core/types/launch/_type_dispatch_identity.py index e4b0ffb4d8..ff119c4889 100644 --- a/src/autoskillit/core/types/launch/_type_dispatch_identity.py +++ b/src/autoskillit/core/types/launch/_type_dispatch_identity.py @@ -41,8 +41,11 @@ def _build_sentinel_contract(dispatch_id: str, short: str) -> str: Fields: - success: true if all mandatory steps completed without unresolved failures -- reason: "completed", "failed", "fleet_quota_exhausted", "timeout", - "open_kitchen_failed", "missing_on_failure", "degraded_tool_response" +- reason: when successful, use exactly the reason declared by the reached success stop + whenever the active recipe graph declares success reasons. Use a concise completion + reason only when the graph has no declarations. On failure, report the observed cause; + reserved failure reasons are "fleet_quota_exhausted", "timeout", + "open_kitchen_failed", "missing_on_failure", and "degraded_tool_response". - summary: One-line description of what happened The sentinel markers ---l3-result::{dispatch_id}--- and ---end-l3-result::{dispatch_id}--- diff --git a/src/autoskillit/core/types/protocols/_type_protocols_backend.py b/src/autoskillit/core/types/protocols/_type_protocols_backend.py index 1110566498..e814a5d0b2 100644 --- a/src/autoskillit/core/types/protocols/_type_protocols_backend.py +++ b/src/autoskillit/core/types/protocols/_type_protocols_backend.py @@ -191,6 +191,10 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: ... + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + """Return whether a transcript records history replacement by a summary.""" + ... + def read_child_task(self, child_id: str) -> ChildTaskTranscript | None: """Return the view of one delegated child's own transcript, or None when unresolvable.""" ... diff --git a/src/autoskillit/core/types/results/_type_results.py b/src/autoskillit/core/types/results/_type_results.py index 243f385b88..d5dadd76c1 100644 --- a/src/autoskillit/core/types/results/_type_results.py +++ b/src/autoskillit/core/types/results/_type_results.py @@ -557,6 +557,7 @@ class SkillResult: last_stop_reason: str = "" lifespan_started: bool = False """True when the headless session called an MCP tool (heuristic for server lifespan).""" + compaction_attempted: bool = False provider: ProviderOutcome = field(default_factory=ProviderOutcome.none_used) """Provider execution outcome bundle.""" execution_selection: ExecutionSelection | None = None diff --git a/src/autoskillit/execution/backends/_claude/session_commands.py b/src/autoskillit/execution/backends/_claude/session_commands.py index 9d97721b1a..3891c55aa4 100644 --- a/src/autoskillit/execution/backends/_claude/session_commands.py +++ b/src/autoskillit/execution/backends/_claude/session_commands.py @@ -321,6 +321,7 @@ def build_food_truck_cmd( for k, v in env_extras.items(): if k not in _PROVIDER_EXTRAS_BASE_DENYLIST: extras[k] = v + extras["DISABLE_AUTO_COMPACT"] = "1" filtered_base = {k: v for k, v in os.environ.items() if k not in _HEADLESS_EXCLUSIVE_VARS} spec = self.build_headless_cmd( diff --git a/src/autoskillit/execution/backends/_claude/session_locator.py b/src/autoskillit/execution/backends/_claude/session_locator.py index 2ad26a4c24..b35012ba29 100644 --- a/src/autoskillit/execution/backends/_claude/session_locator.py +++ b/src/autoskillit/execution/backends/_claude/session_locator.py @@ -65,6 +65,35 @@ def project_log_dir(self, cwd: str) -> Path: def session_log_path(self, cwd: str, session_id: str) -> Path | None: return claude_code_log_path(cwd, session_id) + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + """Detect Claude's observed compact-boundary transcript event. + + The system/compact_boundary pair is an observed transcript format, not + a version-stable public schema. + """ + path = self.session_log_path(cwd, session_id) + if path is None: + return False + try: + path.stat() + with path.open(encoding="utf-8") as transcript: + for line in transcript: + if "compact_boundary" not in line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if ( + isinstance(record, Mapping) + and record.get("type") == "system" + and record.get("subtype") == "compact_boundary" + ): + return True + except FileNotFoundError: + return False + return False + def read_child_task(self, child_id: str) -> ChildTaskTranscript | None: if not is_valid_child_task_id(child_id): return None diff --git a/src/autoskillit/execution/backends/_codex/app_server_events.py b/src/autoskillit/execution/backends/_codex/app_server_events.py index 873293b766..f5328e4514 100644 --- a/src/autoskillit/execution/backends/_codex/app_server_events.py +++ b/src/autoskillit/execution/backends/_codex/app_server_events.py @@ -29,6 +29,7 @@ from typing import Any from autoskillit.core import ( + CODEX_CONTEXT_EXHAUSTION_MARKER, BackendEventKind, CodexEventData, CodexEventType, @@ -92,14 +93,35 @@ def _handle_turn_started(params: Mapping[str, Any]) -> dict[str, Any]: return {"type": CodexEventType.TURN_STARTED.value} +def _native_error_fields( + error_payload: Any, + *, + fallback_message: str = "", +) -> tuple[Any, Any]: + if not isinstance(error_payload, Mapping): + return fallback_message, "" + message = error_payload.get("message") or fallback_message + code = error_payload.get("code") or "" + if error_payload.get("codexErrorInfo") == "contextWindowExceeded": + raw_message = error_payload.get("message") + message = raw_message if isinstance(raw_message, str) else fallback_message + code = CODEX_CONTEXT_EXHAUSTION_MARKER + return message, code + + def _handle_turn_completed(params: Mapping[str, Any]) -> dict[str, Any]: turn = params.get("turn") status = turn.get("status") if isinstance(turn, Mapping) else None if status == "completed": return {"type": CodexEventType.TURN_COMPLETED.value} + turn_error = turn.get("error") if isinstance(turn, Mapping) else None + message, code = _native_error_fields( + turn_error, + fallback_message=status if isinstance(status, str) else "", + ) return { "type": CodexEventType.TURN_FAILED.value, - "error": {"message": status or "", "code": ""}, + "error": {"message": message, "code": code}, } @@ -108,12 +130,7 @@ def _handle_error(params: Mapping[str, Any]) -> dict[str, Any]: # An unavailable-but-retryable server error is not a session event at # all -- ignored exactly like item/started, never terminal. return {"type": CodexEventType.ITEM_STARTED.value} - error_payload = params.get("error") - message = "" - code = "" - if isinstance(error_payload, Mapping): - message = error_payload.get("message") or "" - code = error_payload.get("code") or "" + message, code = _native_error_fields(params.get("error")) return {"type": CodexEventType.ERROR.value, "message": message, "code": code} diff --git a/src/autoskillit/execution/backends/_codex_cmd_builders.py b/src/autoskillit/execution/backends/_codex_cmd_builders.py index 7c942065b5..a541772e64 100644 --- a/src/autoskillit/execution/backends/_codex_cmd_builders.py +++ b/src/autoskillit/execution/backends/_codex_cmd_builders.py @@ -398,6 +398,10 @@ def session_log_path(self, cwd: str, session_id: str) -> Path | None: return None return self.locate_session(session_id) + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + """Return false because Codex vetoes compaction before history replacement.""" + return False + def read_child_task(self, child_id: str) -> ChildTaskTranscript | None: if not is_valid_child_task_id(child_id): return None diff --git a/src/autoskillit/execution/backends/_composite_locator.py b/src/autoskillit/execution/backends/_composite_locator.py index 4ea911f534..917f9a4546 100644 --- a/src/autoskillit/execution/backends/_composite_locator.py +++ b/src/autoskillit/execution/backends/_composite_locator.py @@ -13,32 +13,48 @@ class CompositeSessionLocator: _locators: tuple[SessionLocator, ...] = field(default=(), repr=False) - def locate_session(self, session_id: str) -> Path | None: - if not session_id or session_id.startswith(("no_session_", "crashed_")): - return None + def _locator_sources( + self, + ) -> Iterable[tuple[str | None, Callable[[], SessionLocator]]]: if self._locators: - locators: tuple[SessionLocator, ...] = self._locators - for locator in locators: - try: - result = locator.locate_session(session_id) - except Exception: - logger.debug("session_locate_failed", exc_info=True) - continue - if result is not None: - return result - return None + return tuple((None, lambda locator=locator: locator) for locator in self._locators) from autoskillit.execution.backends import BACKEND_REGISTRY - for backend_name, cls in BACKEND_REGISTRY.items(): + return tuple( + (backend_name, lambda cls=cls: cls().session_locator()) + for backend_name, cls in BACKEND_REGISTRY.items() + ) + + def _first_resolved_session(self, session_id: str) -> tuple[SessionLocator, Path] | None: + if not session_id or session_id.startswith(("no_session_", "crashed_")): + return None + for backend_name, locator_factory in self._locator_sources(): try: - result = cls().session_locator().locate_session(session_id) + locator = locator_factory() + result = locator.locate_session(session_id) except Exception: - logger.debug("session_locate_failed", backend=backend_name, exc_info=True) + if backend_name is None: + logger.debug("session_locate_failed", exc_info=True) + else: + logger.debug("session_locate_failed", backend=backend_name, exc_info=True) continue if result is not None: - return result + return locator, result return None + def locate_session(self, session_id: str) -> Path | None: + resolved = self._first_resolved_session(session_id) + return resolved[1] if resolved is not None else None + + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + resolved = self._first_resolved_session(session_id) + if resolved is None: + return False + locator, _ = resolved + # Once the exact session is found, propagate transcript read errors; + # another backend cannot provide authoritative evidence for that session. + return locator.session_context_was_compacted(cwd, session_id) + def project_log_dir(self, cwd: str) -> Path: return self.project_log_dir_for(cwd, "claude-code") diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 5ffef73ae3..c9232fba9c 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -249,7 +249,7 @@ def capabilities(self) -> BackendCapabilities: completion_record_types=frozenset({"turn.completed", "turn.failed", "error"}), session_record_types=frozenset({"item.completed"}), triage_capable=False, - supports_context_exhaustion_detection=False, + supports_context_exhaustion_detection=True, supports_model_capacity_error_detection=True, supports_tool_list_changed=False, required_skill_fields=frozenset({"name", "description"}), diff --git a/src/autoskillit/execution/headless/_headless_adjudication.py b/src/autoskillit/execution/headless/_headless_adjudication.py index 91e178fc75..49aec7cdfd 100644 --- a/src/autoskillit/execution/headless/_headless_adjudication.py +++ b/src/autoskillit/execution/headless/_headless_adjudication.py @@ -72,6 +72,29 @@ logger = get_logger(__name__) +@dataclasses.dataclass(frozen=True, slots=True) +class _SkillResultContext: + """Fields _build_skill_result forwards to _build_stall_result unchanged.""" + + backend: CodingAgentBackend + completion_marker: str + skill_command: str + audit: AuditLog | None + max_consecutive_retries: int + expected_output_patterns: Sequence[str] + completion_required: bool + provider_used: str + write_behavior: WriteBehaviorSpec | None + skill_contract: SkillContract | None + cwd: str + fs_writes_detected: bool + git_writes_detected: bool + file_changes: Sequence[str] + write_watch_dirs: Sequence[Path] + backend_resume_session_id: str + outcome_ledger: WorkspaceOutcomeLedger | None + + def _resolve_skill_session_id( session: ClaudeSessionResult | None, result: SubprocessResult, @@ -234,6 +257,7 @@ def _make_terminated_result( evidence=evidence, kill_reason=result.kill_reason, last_stop_reason=session.last_stop_reason, + compaction_attempted=session.compaction_attempted, lifespan_started=session.lifespan_started, provider=ProviderOutcome(provider_used=provider_used, fallback_activated=False), infra=infra, diff --git a/src/autoskillit/execution/headless/_headless_result.py b/src/autoskillit/execution/headless/_headless_result.py index 62aafdddde..69c4e8ebf0 100644 --- a/src/autoskillit/execution/headless/_headless_result.py +++ b/src/autoskillit/execution/headless/_headless_result.py @@ -43,6 +43,7 @@ _resolve_skill_session_id, _select_parsed_session, _should_flag_cleanup_incomplete, + _SkillResultContext, _StallOutcomeSpec, ) from autoskillit.execution.headless._headless_evidence import ( @@ -143,29 +144,6 @@ def _apply_infra_retry_policy( assert_never(unreachable) -@dataclasses.dataclass(frozen=True, slots=True) -class _SkillResultContext: - """Fields _build_skill_result forwards to _build_stall_result unchanged.""" - - backend: CodingAgentBackend - completion_marker: str - skill_command: str - audit: AuditLog | None - max_consecutive_retries: int - expected_output_patterns: Sequence[str] - completion_required: bool - provider_used: str - write_behavior: WriteBehaviorSpec | None - skill_contract: SkillContract | None - cwd: str - fs_writes_detected: bool - git_writes_detected: bool - file_changes: Sequence[str] - write_watch_dirs: Sequence[Path] - backend_resume_session_id: str - outcome_ledger: WorkspaceOutcomeLedger | None - - def _build_stall_result( result: SubprocessResult, context: _SkillResultContext, @@ -770,6 +748,7 @@ def _build_skill_result( ), session_error=SessionErrorOutcome.from_errors(session.errors), completion_required=completion_required, + compaction_attempted=session.compaction_attempted, ) if is_path_contamination and not controlled_context_exhaustion: sr = dataclasses.replace( diff --git a/src/autoskillit/execution/session/_provider_parse.py b/src/autoskillit/execution/session/_provider_parse.py index 44d9b6a832..ecb6aa94b6 100644 --- a/src/autoskillit/execution/session/_provider_parse.py +++ b/src/autoskillit/execution/session/_provider_parse.py @@ -27,6 +27,14 @@ ) +def _is_compaction_status_record(obj: Mapping[str, Any]) -> bool: + return ( + obj.get("type") == "system" + and obj.get("subtype") == "status" + and obj.get("status") == "compacting" + ) + + def _provider_field(obj: Mapping[str, Any], *names: str) -> Any: """Return the first retained provider field across its observed spellings.""" for name in names: @@ -43,6 +51,7 @@ class _ProviderParseAccumulator: tool_uses: list[dict[str, Any]] = field(default_factory=list) assistant_messages: list[str] = field(default_factory=list) jsonl_context_exhausted: bool = False + compaction_attempted: bool = False stop_reasons: list[str] = field(default_factory=list) seen_block_types: set[str] = field(default_factory=set) has_thinking_only_turn: bool = False @@ -108,6 +117,7 @@ def _accumulate_provider_record(obj: dict[str, Any], acc: _ProviderParseAccumula return if not isinstance(record_type, str) or record_type not in _HANDLED_RECORD_TYPES: return + acc.compaction_attempted = acc.compaction_attempted or _is_compaction_status_record(obj) if record_type == "system" and obj.get("subtype") == "api_retry": acc.api_retry_count += 1 acc.api_retry_last_error = str(_provider_field(obj, "error") or "") diff --git a/src/autoskillit/execution/session/_session_model.py b/src/autoskillit/execution/session/_session_model.py index 82ca21dd6f..1132dbaa34 100644 --- a/src/autoskillit/execution/session/_session_model.py +++ b/src/autoskillit/execution/session/_session_model.py @@ -97,6 +97,7 @@ class ClaudeSessionResult: seen_ndjson_unknown_event_count: int = 0 seen_ndjson_unknown_item_count: int = 0 denied_tool_use_ids: frozenset[str] = field(default_factory=frozenset) + compaction_attempted: bool = False def __post_init__(self) -> None: if not isinstance(self.result, str): @@ -122,8 +123,8 @@ def __post_init__(self) -> None: ) if not isinstance(self.session_id, str): self.session_id = "" if self.session_id is None else str(self.session_id) - if not isinstance(self.jsonl_context_exhausted, bool): - self.jsonl_context_exhausted = bool(self.jsonl_context_exhausted) + self.jsonl_context_exhausted = bool(self.jsonl_context_exhausted) + self.compaction_attempted = bool(self.compaction_attempted) def _is_context_exhausted(self) -> bool: """True when the session hit Claude's context window limit.""" @@ -489,6 +490,7 @@ def parse_session_result(stdout: str, *, provider_used: str = "anthropic") -> Cl assistant_messages=acc.assistant_messages, tool_uses=acc.tool_uses, jsonl_context_exhausted=acc.jsonl_context_exhausted, + compaction_attempted=acc.compaction_attempted, stop_reasons=acc.stop_reasons, has_thinking_only_turn=acc.has_thinking_only_turn, seen_block_types=frozenset(acc.seen_block_types), diff --git a/src/autoskillit/fleet/_outcome.py b/src/autoskillit/fleet/_outcome.py index 66ec770214..de40a6197f 100644 --- a/src/autoskillit/fleet/_outcome.py +++ b/src/autoskillit/fleet/_outcome.py @@ -15,6 +15,7 @@ SkillResult, truncate_text, ) +from autoskillit.fleet._sidecar_synthesis import SIDECAR_RECOVERY_REASON from autoskillit.fleet.campaign_state.state import ( DispatchRecord, DispatchStateHandle, @@ -23,15 +24,31 @@ ) from autoskillit.fleet.campaign_state.state_effects import DispatchEffectProvenance from autoskillit.fleet.campaign_state.state_outcomes import DispatchCompleted, DispatchResult -from autoskillit.fleet.campaign_state.state_transitions import _ABANDON_REASONS +from autoskillit.fleet.campaign_state.state_transitions import ( + _ABANDON_REASONS, +) from autoskillit.fleet.result_parser import L3ParseResult ENVELOPE_STDERR_MAX = 2000 +def is_context_exhausted( + retry_reason: str, + infra_exit_category: str, + compaction_attempted: bool = False, +) -> bool: + """Return whether retained session evidence makes this dispatch non-resumable.""" + return ( + compaction_attempted + or retry_reason == RetryReason.CONTEXT_EXHAUSTED + or infra_exit_category == InfraExitCategory.CONTEXT_EXHAUSTED + ) + + class _DispatchCommonFields(TypedDict): dispatch_id: str dispatched_session_id: str + diagnostic_message: str token_usage: dict[str, Any] lifespan_started: bool stderr: str @@ -86,6 +103,25 @@ def _sanitize_managed_capture_diagnostics(value: str) -> str: ) +def success_reason_contract_error( + reason: object, + success_reasons: frozenset[str] | None, +) -> str | None: + """Return a sanitized diagnostic when a success reason violates declarations.""" + if success_reasons is None: + return None + if not isinstance(reason, str): + return "Dispatch success reason must be a string." + if not reason: + return "Dispatch success is missing its declared reason." + if reason not in success_reasons: + sanitized_reason = _sanitize_managed_capture_diagnostics(reason) + if sanitized_reason: + return f"Dispatch success reason {sanitized_reason!r} is not declared." + return "Dispatch success reason is not declared." + return None + + def _sanitize_failed_managed_capture_reason(value: str) -> str: """Redact private lineage details while retaining failure causes.""" normalized = _MANAGED_LINEAGE_PATH_RE.sub("", value) @@ -132,6 +168,34 @@ def _classify_missing_result( return DispatchStatus.FAILURE, error_code +def _classify_clean_result( + parsed: L3ParseResult, + *, + success_reasons: frozenset[str] | None, +) -> tuple[DispatchStatus, str]: + """Classify a decoded result block, including success-reason enforcement.""" + payload = parsed.payload + if payload and payload.get("success") is True: + raw_reason = payload.get("reason", "") + if parsed.source == "sidecar": + if raw_reason != SIDECAR_RECOVERY_REASON: + return DispatchStatus.FAILURE, FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + return DispatchStatus.SUCCESS, _sanitize_managed_capture_diagnostics(raw_reason) + if success_reason_contract_error(raw_reason, success_reasons) is not None: + return DispatchStatus.FAILURE, FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + return DispatchStatus.SUCCESS, _sanitize_managed_capture_diagnostics(str(raw_reason)) + if payload and payload.get("reason", "") == FleetErrorCode.FLEET_QUOTA_EXHAUSTED: + return DispatchStatus.RESUMABLE, FleetErrorCode.FLEET_QUOTA_EXHAUSTED + + raw_reason = str(payload.get("reason", "")) if payload else "" + reason = _sanitize_managed_capture_diagnostics( + _sanitize_failed_managed_capture_reason(raw_reason) + ) + if raw_reason and not reason: + reason = FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH + return DispatchStatus.FAILURE, reason + + def classify_dispatch_outcome( parsed: L3ParseResult | None, skill_result: SkillResult, @@ -139,24 +203,30 @@ def classify_dispatch_outcome( sidecar_exists: bool = False, checkpoint: SessionCheckpoint | None = None, subtype: str = "", + success_reasons: frozenset[str] | None = None, + context_compacted: bool = False, ) -> tuple[DispatchStatus, str]: """Map L2 food truck subprocess signals to a (DispatchStatus, reason) pair. Pure function — no filesystem access, no side effects. Apply these ordered rules: - 0. controlled context exhaustion → FAILURE + 0. context exhaustion or compaction attempt → terminal context FAILURE 1. timeout + session_id + lifespan_started + (checkpoint or sidecar) + not abandon → RESUMABLE 2. timeout (any other case) → FAILURE - 3. completed_clean + success flag → SUCCESS + 3. completed_clean + true success + valid reason contract → SUCCESS 4. completed_clean + no success → FAILURE 5. completed_dirty → FAILURE (fleet_l3_parse_failed) 6. no_sentinel + session_id + lifespan_started + (checkpoint or sidecar) + not abandon → RESUMABLE 7. no_sentinel (any other case) → FAILURE (fleet_l3_no_result_block) """ - if skill_result.retry_reason == RetryReason.CONTEXT_EXHAUSTED: - return DispatchStatus.FAILURE, FleetErrorCode.FLEET_L3_NO_RESULT_BLOCK + if context_compacted or is_context_exhausted( + skill_result.retry_reason or "", + skill_result.infra.exit_category or "", + skill_result.compaction_attempted, + ): + return DispatchStatus.FAILURE, FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED if subtype == "timeout": return _classify_missing_result( @@ -174,21 +244,11 @@ def classify_dispatch_outcome( error_code=FleetErrorCode.FLEET_L3_NO_RESULT_BLOCK, ) - if parsed.outcome == "completed_clean" and parsed.payload and parsed.payload.get("success"): - reason = _sanitize_managed_capture_diagnostics(str(parsed.payload.get("reason", ""))) - return DispatchStatus.SUCCESS, reason - if parsed.outcome == "completed_clean" and parsed.payload: - reason = parsed.payload.get("reason", "") - if reason == FleetErrorCode.FLEET_QUOTA_EXHAUSTED: - return DispatchStatus.RESUMABLE, FleetErrorCode.FLEET_QUOTA_EXHAUSTED if parsed.outcome == "completed_clean": - raw_reason = str(parsed.payload.get("reason", "")) if parsed.payload else "" - reason = _sanitize_managed_capture_diagnostics( - _sanitize_failed_managed_capture_reason(raw_reason) + return _classify_clean_result( + parsed, + success_reasons=success_reasons, ) - if raw_reason and not reason: - reason = FleetErrorCode.FLEET_L3_STARTUP_OR_CRASH - return DispatchStatus.FAILURE, reason if parsed.outcome == "completed_dirty": return DispatchStatus.FAILURE, FleetErrorCode.FLEET_L3_PARSE_FAILED return _classify_missing_result( @@ -202,22 +262,24 @@ def classify_dispatch_outcome( def build_dispatch_result( *, parsed_result: L3ParseResult | None, - result_success: bool, final_status: DispatchStatus, reason: str, dispatch_id: str, skill_result: SkillResult, + dispatched_session_id: str, backend_name: str, dispatch_checkpoint: SessionCheckpoint | None, started_at: float, ended_at: float, state_path: Path, effect_provenance: DispatchEffectProvenance, + diagnostic_message: str = "", ) -> DispatchResult: """Build the bounded fleet envelope without exposing managed diagnostics.""" common: _DispatchCommonFields = { "dispatch_id": dispatch_id, - "dispatched_session_id": skill_result.session_id or "", + "dispatched_session_id": dispatched_session_id, + "diagnostic_message": diagnostic_message, "token_usage": normalize_dispatch_token_usage( skill_result.token_usage or {}, backend=backend_name, @@ -234,7 +296,6 @@ def build_dispatch_result( if parsed_result is not None and parsed_result.outcome == "completed_clean": return DispatchResult( DispatchCompleted( - success=result_success, dispatch_status=final_status, reason=reason, l3_payload=parsed_result.payload, @@ -246,9 +307,8 @@ def build_dispatch_result( if parsed_result is not None and parsed_result.outcome == "completed_dirty": return DispatchResult( DispatchCompleted( - success=False, dispatch_status=final_status, - reason=FleetErrorCode.FLEET_L3_PARSE_FAILED, + reason=reason, l3_payload=None, l3_raw_body=parsed_result.raw_body, l3_parse_error=parsed_result.parse_error, @@ -259,7 +319,6 @@ def build_dispatch_result( ) return DispatchResult( DispatchCompleted( - success=False, dispatch_status=final_status, reason=reason, l3_payload=None, @@ -279,7 +338,6 @@ def build_success_short_circuit( """Mirror a prior succeeded dispatch without launching a new subprocess.""" return DispatchResult( outcome=DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id=record.dispatch_id, dispatched_session_id=record.dispatched_session_id, diff --git a/src/autoskillit/fleet/_prompts.py b/src/autoskillit/fleet/_prompts.py index aff7aaea9e..49bde44972 100644 --- a/src/autoskillit/fleet/_prompts.py +++ b/src/autoskillit/fleet/_prompts.py @@ -77,6 +77,7 @@ def _build_food_truck_prompt( caller_instructions: str | None = None, has_unguarded_filesystem_access: bool = False, projected_sous_chef: str = "", + success_reasons: frozenset[str] | None = None, ) -> str: """Build the system prompt for an L2 food truck headless session. @@ -97,6 +98,18 @@ def _build_food_truck_prompt( ) dispatch_id_short = dispatch_id[:8] + declared_success_reasons = sorted(success_reasons or ()) + if declared_success_reasons: + success_reason_guidance = ( + "use exactly the reason declared by the reached success stop. " + f"Declared success reasons: {json.dumps(declared_success_reasons)}. " + "Do not substitute a generic reason such as 'completed'." + ) + else: + success_reason_guidance = ( + "use a concise completion reason such as 'completed'; the active recipe graph " + "declares no success reasons." + ) ingredients_json = json.dumps(ingredients) ingredients_pretty_json = json.dumps(ingredients, indent=2) @@ -164,6 +177,7 @@ def _build_food_truck_prompt( Emit the L3 sentinel. {STOP_STEP_EVIDENCE_DOCTRINE_INDENTED} Set success=true for completion terminals, success=false for failure/escalation terminals. + For success, {success_reason_guidance} Do NOT call any MCP tools after a stop step. Do NOT attempt recovery, error reporting, or off-recipe actions after a stop step. A stop step is an INTENTIONAL terminus — the recipe author designed this as the endpoint. @@ -330,8 +344,9 @@ def _build_food_truck_prompt( Fields: - success: true if all mandatory steps completed without unresolved failures -- reason: "completed", "failed", "quota_exhausted", "timeout", - "open_kitchen_failed", "missing_on_failure", "degraded_tool_response" +- reason: for success, {success_reason_guidance} For failure, report the observed + failure reason. Reserved failure reasons are "fleet_quota_exhausted", "timeout", + "open_kitchen_failed", "missing_on_failure", and "degraded_tool_response". - summary: One-line description of what happened{extra_fields_docs} - Failure terminals may also include optional evidence fields reason_kind, outcome_fields, and diagnostic_result. Include only observed values and never invent diff --git a/src/autoskillit/fleet/_sidecar_synthesis.py b/src/autoskillit/fleet/_sidecar_synthesis.py index 07738cff7e..26c47d54db 100644 --- a/src/autoskillit/fleet/_sidecar_synthesis.py +++ b/src/autoskillit/fleet/_sidecar_synthesis.py @@ -5,6 +5,8 @@ from autoskillit.fleet.result_parser import L3ParseResult from autoskillit.fleet.sidecar import IssueSidecarEntry +SIDECAR_RECOVERY_REASON = "sidecar_recovery" + _FAILURE_TERMINAL_PATTERNS = frozenset({"escalate", "failure", "error", "reject"}) @@ -33,7 +35,7 @@ def synthesize_from_sidecar( outcome="completed_clean", payload={ "success": True, - "reason": "sidecar_recovery", + "reason": SIDECAR_RECOVERY_REASON, "pr_urls": pr_urls, "issue_count": len(completed), }, diff --git a/src/autoskillit/fleet/campaign_state/state_error_codes.py b/src/autoskillit/fleet/campaign_state/state_error_codes.py index 7c21ddaf14..ad8000ef85 100644 --- a/src/autoskillit/fleet/campaign_state/state_error_codes.py +++ b/src/autoskillit/fleet/campaign_state/state_error_codes.py @@ -28,6 +28,12 @@ FleetErrorCode.FLEET_GATE_UNKNOWN_DISPATCH: FaultDomain.INFRASTRUCTURE, FleetErrorCode.FLEET_BUDGET_EXCEEDED: FaultDomain.INFRASTRUCTURE, FleetErrorCode.FLEET_RESUME_SESSION_MISSING: FaultDomain.INFRASTRUCTURE, + FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION: FaultDomain.LOGIC, + FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED: FaultDomain.LOGIC, + FleetErrorCode.FLEET_RESET_NOT_FOUND: FaultDomain.LOGIC, + FleetErrorCode.FLEET_RESET_INVALID_TARGET: FaultDomain.LOGIC, + FleetErrorCode.FLEET_RESET_STILL_RUNNING: FaultDomain.LOGIC, + FleetErrorCode.FLEET_INVALID_BACKEND: FaultDomain.LOGIC, FleetErrorCode.FLEET_UNKNOWN_INGREDIENT: FaultDomain.LOGIC, FleetErrorCode.FLEET_MISSING_INGREDIENT: FaultDomain.LOGIC, FleetErrorCode.FLEET_CAMPAIGN_HALTED: FaultDomain.LOGIC, diff --git a/src/autoskillit/fleet/campaign_state/state_outcomes.py b/src/autoskillit/fleet/campaign_state/state_outcomes.py index a8a5dd10f7..b85f5861c7 100644 --- a/src/autoskillit/fleet/campaign_state/state_outcomes.py +++ b/src/autoskillit/fleet/campaign_state/state_outcomes.py @@ -55,7 +55,6 @@ def to_envelope(self) -> str: class DispatchCompleted: """Dispatch that reached subprocess phase — may have succeeded or failed.""" - success: bool dispatch_status: DispatchStatus dispatch_id: str dispatched_session_id: str @@ -73,6 +72,10 @@ class DispatchCompleted: elapsed_seconds: float = 0.0 health_report: dict[str, Any] | None = None + @property + def success(self) -> bool: + return self.dispatch_status is DispatchStatus.SUCCESS + def to_envelope(self) -> str: reason_text = str(self.reason) d: dict[str, Any] = { diff --git a/src/autoskillit/fleet/campaign_state/state_recovery.py b/src/autoskillit/fleet/campaign_state/state_recovery.py index a73f22d0a7..c0fda2f378 100644 --- a/src/autoskillit/fleet/campaign_state/state_recovery.py +++ b/src/autoskillit/fleet/campaign_state/state_recovery.py @@ -20,6 +20,7 @@ CampaignState, DispatchRecord, ResumeDecision, + _clear_dispatch_for_retry, ) from autoskillit.fleet.campaign_state.state_transitions import ( _ABANDON_REASONS, @@ -45,8 +46,8 @@ class ResumePreflight(NamedTuple): can short-circuit to ``_build_success_short_circuit``. reset_performed: True when the chokepoint auto-reset a blocking status (FAILURE/INTERRUPTED/REFUSED) to PENDING via ``reset_blocking_dispatch``. - halt: True when ``continue_on_failure=False`` and the prior status was - terminal — caller must refuse the resume. + halt: True when campaign policy or a non-resumable context failure + refuses the resume. halted_reason: Populated when halt=True; describes why the resume was refused. """ @@ -123,8 +124,8 @@ class _MatchedResumeResult(NamedTuple): caller can short-circuit to ``_build_success_short_circuit``. reset_performed: True when the policy auto-reset a blocking status to PENDING via ``reset_blocking_dispatch``. - halt: True when ``continue_on_failure=False`` and the prior status was - terminal — caller must refuse the resume. + halt: True when campaign policy or a non-resumable context failure + refuses the resume. halted_reason: Populated when ``halt=True``; describes the refusal. """ @@ -142,6 +143,13 @@ def _prepare_matched_resume( continue_on_failure: bool, ) -> _MatchedResumeResult: """Apply resume policy to one matched dispatch without rebuilding its lineage.""" + if dispatch.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED: + return _MatchedResumeResult( + None, + False, + True, + f"Context exhaustion prevents resuming {dispatch_name!r}", + ) if dispatch.status == DispatchStatus.UNKNOWN: return _MatchedResumeResult( short_circuit=None, @@ -187,6 +195,26 @@ class _RecoveryResetResult(NamedTuple): halted: ResumeDecision | None +def _apply_retriable_dispatch_policy( + dispatch: DispatchRecord, + mutator: CampaignStateMutator, + *, + continue_on_failure: bool, + reset_on_retry: bool, +) -> bool: + """Apply reset policy to one failed dispatch; return whether campaign halts.""" + if dispatch.status not in _RETRIABLE_NON_SUCCESS: + return False + if dispatch.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED: + return not continue_on_failure + if not continue_on_failure and not reset_on_retry: + return True + if reset_on_retry and (not continue_on_failure or dispatch.status != DispatchStatus.FAILURE): + _clear_dispatch_for_retry(dispatch) + mutator.mark_dirty() + return False + + def prepare_resume( state_path: Path, dispatch_name: str, @@ -203,9 +231,9 @@ def prepare_resume( - ``ResumePreflight`` with ``reset_performed=True`` when the prior dispatch was in ``{FAILURE, INTERRUPTED, REFUSED}`` and was auto-reset to PENDING via ``reset_blocking_dispatch``. - - ``ResumePreflight`` with ``halt=True`` when ``continue_on_failure`` - is False and the prior dispatch was in a terminal status - (campaign-level halt semantic preserved). + - ``ResumePreflight`` with ``halt=True`` when campaign policy refuses + a terminal prior status or the prior reason is non-resumable context + exhaustion, regardless of ``continue_on_failure``. - ``ResumePreflight`` with ``prior_session_chain`` populated when the dispatch is in ``PENDING`` or ``RESUMABLE`` (caller proceeds with existing resume flow; cap enforcement happens in @@ -445,8 +473,13 @@ def _run_locked_recovery_reset_pass( ) -> _RecoveryResetResult: """Recover stale dispatches and apply campaign-level reset policy under one lock.""" from autoskillit.fleet.campaign_state.state import CampaignStateMutator - from autoskillit.fleet.campaign_state.state_records import ( - _clear_dispatch_for_retry, + + halted = _RecoveryResetResult( + state_missing=False, + halted=ResumeDecision( + next_dispatch_name="", + completed_dispatches_block=FLEET_HALTED_SENTINEL, + ), ) with CampaignStateMutator(state_path) as m: @@ -455,35 +488,18 @@ def _run_locked_recovery_reset_pass( if m.state.opaque_dispatches or any( d.status == DispatchStatus.UNKNOWN for d in m.state.dispatches ): - return _RecoveryResetResult( - state_missing=False, - halted=ResumeDecision( - next_dispatch_name="", - completed_dispatches_block=FLEET_HALTED_SENTINEL, - ), - ) + return halted for d in m.state.dispatches: if d.status == DispatchStatus.RUNNING: resolve_stale_running(d, m, reason="stale_running_on_resume") for d in m.state.dispatches: - if d.status in _RETRIABLE_NON_SUCCESS: - if not continue_on_failure: - if reset_on_retry: - _clear_dispatch_for_retry(d) - m.mark_dirty() - else: - return _RecoveryResetResult( - state_missing=False, - halted=ResumeDecision( - next_dispatch_name="", - completed_dispatches_block=FLEET_HALTED_SENTINEL, - ), - ) - elif reset_on_retry and d.status != DispatchStatus.FAILURE: - # INTERRUPTED / REFUSED reset on reset_on_retry=True. - # FAILURE stays as FAILURE (continue_on_failure=True semantics). - _clear_dispatch_for_retry(d) - m.mark_dirty() + if _apply_retriable_dispatch_policy( + d, + m, + continue_on_failure=continue_on_failure, + reset_on_retry=reset_on_retry, + ): + return halted return _RecoveryResetResult(state_missing=False, halted=None) @@ -518,24 +534,13 @@ def resume_campaign_from_state( *, reset_on_retry: bool = False, ) -> ResumeDecision | None: - """Determine the next dispatch for a resumed campaign. - - Algorithm: - 1. Read state.json; return None if absent or corrupted. - 2. Find first dispatch not in {success, skipped}. - 3. If running exists and stale, mark it interrupted; skip alive ones. - 4. If failure exists and continue_on_failure=False, return None - with reason fleet_halted_on_failure (encoded via a sentinel). - When reset_on_retry=True, reset all FAILURE dispatches to PENDING instead. - 5. Return ResumeDecision with next_dispatch_name and completed block. - - The per-dispatch precondition gate is delegated to ``prepare_resume`` so - this function composes a campaign-level ``ResumeDecision`` from one or - more ``ResumePreflight`` results. - - Returns None if the state file is missing/corrupted. Returns a - ResumeDecision with next_dispatch_name="" if all dispatches are - complete or the campaign is halted. + """Select the next pending or resumable dispatch from campaign state. + + A locked pass recovers stale sessions and applies reset policy; a second + pass composes completed dispatches and the next eligible name. The + continuation policy never clears a dedicated context-exhaustion failure. + Return ``None`` for missing or corrupt state and a halted sentinel when + policy refuses continuation. """ # Pass 1: stale-RUNNING recovery + per-dispatch halt/reset for the FAILURE / # INTERRUPTED / REFUSED statuses. This pass mutates the file (closes the diff --git a/src/autoskillit/fleet/campaign_state/state_transitions.py b/src/autoskillit/fleet/campaign_state/state_transitions.py index d890bd4964..1b9e4f5c51 100644 --- a/src/autoskillit/fleet/campaign_state/state_transitions.py +++ b/src/autoskillit/fleet/campaign_state/state_transitions.py @@ -64,6 +64,7 @@ def from_persisted(cls, raw: str) -> DispatchStatus: DispatchStatus.SUCCESS, DispatchStatus.FAILURE, DispatchStatus.INTERRUPTED, + DispatchStatus.REFUSED, } ), DispatchStatus.FAILURE: frozenset({DispatchStatus.PENDING}), @@ -110,6 +111,7 @@ def _validate_transition(current: str, new: str, dispatch_name: str) -> None: {DispatchStatus.FAILURE, DispatchStatus.INTERRUPTED} ) + _ABANDON_REASONS: frozenset[str] = frozenset( { RetryReason.STALE, diff --git a/src/autoskillit/fleet/dispatch/_api.py b/src/autoskillit/fleet/dispatch/_api.py index b347c5dae3..4ff7d8c092 100644 --- a/src/autoskillit/fleet/dispatch/_api.py +++ b/src/autoskillit/fleet/dispatch/_api.py @@ -33,9 +33,7 @@ get_logger, release_tracker_lease, ) -from autoskillit.fleet._outcome import ( - _sanitize_managed_capture_diagnostics, -) +from autoskillit.fleet._outcome import _sanitize_managed_capture_diagnostics from autoskillit.fleet._startup_warm import warm_failure_path_imports from autoskillit.fleet.campaign_state import state as _fleet_state from autoskillit.fleet.campaign_state.state import DispatchRecord, DispatchStatus @@ -196,7 +194,6 @@ def _build_dispatch_crash_result( ) return DispatchResult( DispatchCompleted( - success=False, dispatch_status=failure_status, dispatch_id=identities.get("dispatch_id", ""), dispatched_session_id=identities.get("dispatched_session_id", ""), @@ -301,6 +298,7 @@ async def execute_dispatch( native_shell_capture_mode: NativeShellCaptureMode | None = None, managed_join_parent_id: str | None = None, adaptation_context: SemanticAdaptationContext | None = None, + prior_success_candidate: DispatchRecord | None = None, ) -> DispatchResult: """Execute a single food truck dispatch. @@ -351,6 +349,7 @@ async def execute_dispatch( native_shell_capture_mode=native_shell_capture_mode, managed_join_parent_id=managed_join_parent_id, adaptation_context=adaptation_context, + prior_success_candidate=prior_success_candidate, ) except asyncio.CancelledError: raise @@ -393,6 +392,7 @@ async def _run_dispatch( native_shell_capture_mode: NativeShellCaptureMode | None = None, managed_join_parent_id: str | None = None, adaptation_context: SemanticAdaptationContext | None = None, + prior_success_candidate: DispatchRecord | None = None, ) -> DispatchResult: """Inner dispatch body — acquires capacity after durable dispatch identity. @@ -441,6 +441,8 @@ async def _run_dispatch( timeout_sec=timeout_sec, managed_join_parent_id=managed_join_parent_id, adaptation_context=adaptation_context, + success_reasons=recipe_ctx.success_reasons, + prior_success_candidate=prior_success_candidate, ) if isinstance(lineage_result, DispatchResult): return lineage_result @@ -573,6 +575,7 @@ async def _run_dispatch( effective_backend=recipe_ctx.effective_backend, dispatch_sidecar_path=execution_result.dispatch_sidecar_path, prior_dispatch_ids=[prior_dispatch_id] if prior_dispatch_id else None, + success_reasons=recipe_ctx.success_reasons, ) result = await finalize_state_write( classification=classification, diff --git a/src/autoskillit/fleet/dispatch/_classification.py b/src/autoskillit/fleet/dispatch/_classification.py index c8fba32ad9..d7ad3324f1 100644 --- a/src/autoskillit/fleet/dispatch/_classification.py +++ b/src/autoskillit/fleet/dispatch/_classification.py @@ -26,6 +26,7 @@ import autoskillit.fleet._api as _facade from autoskillit.core import ( CaptureEntrySpec, + FleetErrorCode, ManagedHeadlessSessionTerminalState, SessionCheckpoint, SkillResult, @@ -34,8 +35,10 @@ from autoskillit.fleet._native_shell_capture import set_lineage_terminal_state from autoskillit.fleet._outcome import ( _checkpoint_to_dict, + _sanitize_managed_capture_diagnostics, build_dispatch_result, classify_dispatch_outcome, + is_context_exhausted, ) from autoskillit.fleet.campaign_state.state import ( DispatchRecord, @@ -72,9 +75,8 @@ class ClassificationResult: labels_cleaned: bool project_log_dir: str extended_chain: list[str] - dispatched_session_id: str | None # for finalize to override skill_result.session_id + dispatched_session_id: str skill_result: SkillResult # for finalize_state_write - result_success: bool dispatch_checkpoint: SessionCheckpoint | None # for finalize resume_checkpoint serialization @@ -88,11 +90,13 @@ class _OutcomeMaterialization: tracker_authority_error: str | None parsed_result: Any extended_chain: list[str] + context_compacted: bool def _materialize_outcome( *, skill_result: SkillResult, + dispatched_session_id: str, spawn_ctx: SpawnContext, tool_ctx: ToolContext, tracker_lease: Any, @@ -128,6 +132,17 @@ def _materialize_outcome( if incoming_resume_checkpoint is not None else loaded_tracker_checkpoint ) + context_compacted = is_context_exhausted( + skill_result.retry_reason or "", + skill_result.infra.exit_category or "", + skill_result.compaction_attempted, + ) + locator = effective_backend.session_locator() if effective_backend is not None else None + if locator is not None and dispatched_session_id: + context_compacted = ( + locator.session_context_was_compacted(str(tool_ctx.project_dir), dispatched_session_id) + or context_compacted + ) extended_chain = prior_session_chain[:] parsed_result: Any = None @@ -135,7 +150,6 @@ def _materialize_outcome( if prior_dispatched_session_id and prior_dispatched_session_id not in extended_chain: extended_chain.append(prior_dispatched_session_id) - locator = effective_backend.session_locator() if effective_backend is not None else None additional_jsonl_paths = [ path for session_id in extended_chain @@ -149,16 +163,16 @@ def _materialize_outcome( is not None ] jsonl_path = ( - locator.session_log_path(str(tool_ctx.project_dir), skill_result.session_id or "") + locator.session_log_path(str(tool_ctx.project_dir), dispatched_session_id) if locator is not None else None ) - if resume_line_offset and skill_result.session_id and resume_session_id: - if skill_result.session_id != resume_session_id: + if resume_line_offset and dispatched_session_id and resume_session_id: + if dispatched_session_id != resume_session_id: logger.warning( "resume_line_offset_invalidated", resume_session_id=resume_session_id, - actual_session_id=skill_result.session_id, + actual_session_id=dispatched_session_id, ) resume_line_offset = 0 parsed_result = _facade.parse_l3_result_block( @@ -175,6 +189,7 @@ def _materialize_outcome( parsed_result is not None and parsed_result.outcome == "no_sentinel" and sidecar_entries + and not context_compacted ): from autoskillit.fleet._sidecar_synthesis import ( synthesize_from_sidecar, @@ -193,6 +208,7 @@ def _materialize_outcome( tracker_authority_error=tracker_authority_error, parsed_result=parsed_result, extended_chain=extended_chain, + context_compacted=context_compacted, ) @@ -236,6 +252,32 @@ async def _cleanup_non_success_non_resumable_labels( return labels_cleaned +def _log_success_reason_contract_violation( + *, + reason: str, + parsed_result: Any, + dispatch_id: str, + success_reasons: frozenset[str] | None, +) -> None: + """Log sanitized evidence when a successful result violates its reason contract.""" + if reason != FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION: + return + payload = parsed_result.payload if parsed_result is not None else None + claimed_reason = payload.get("reason") if payload is not None else None + if isinstance(claimed_reason, str): + claimed_reason = _sanitize_managed_capture_diagnostics(claimed_reason) + elif claimed_reason is not None: + claimed_reason = f"<{type(claimed_reason).__name__}>" + logger.warning( + "dispatch_success_reason_contract_violation", + dispatch_id=dispatch_id, + claimed_reason=claimed_reason, + declared_success_reasons=sorted( + _sanitize_managed_capture_diagnostics(item) for item in (success_reasons or ()) + ), + ) + + async def run_outcome_classification( *, skill_result: SkillResult, @@ -256,14 +298,21 @@ async def run_outcome_classification( dispatch_sidecar_path: str, resume_line_offset: int = 0, prior_dispatch_ids: list[str] | None = None, + success_reasons: frozenset[str] | None = None, ) -> ClassificationResult: """Phase E — classifies the dispatch outcome. Returns a ``ClassificationResult`` carrying every field the orchestrator's finalize shard needs. """ + dispatched_session_id = ( + spawn_ctx.dispatched_session_id[0] + if spawn_ctx.dispatched_session_id + else skill_result.session_id or "" + ) materialization = _materialize_outcome( skill_result=skill_result, + dispatched_session_id=dispatched_session_id, spawn_ctx=spawn_ctx, tool_ctx=tool_ctx, tracker_lease=tracker_lease, @@ -284,6 +333,7 @@ async def run_outcome_classification( tracker_authority_error = materialization.tracker_authority_error parsed_result = materialization.parsed_result extended_chain = materialization.extended_chain + context_compacted = materialization.context_compacted _issue_urls_raw = spawn_ctx.issue_urls_raw final_status, reason = classify_dispatch_outcome( @@ -292,17 +342,18 @@ async def run_outcome_classification( sidecar_exists=sidecar_file.exists(), checkpoint=dispatch_checkpoint, subtype=skill_result.subtype, + success_reasons=success_reasons, + context_compacted=context_compacted, ) - result_success = bool( - parsed_result is not None - and parsed_result.outcome == "completed_clean" - and parsed_result.payload - and parsed_result.payload.get("success", False) + _log_success_reason_contract_violation( + reason=reason, + parsed_result=parsed_result, + dispatch_id=dispatch_id, + success_reasons=success_reasons, ) - if tracker_authority_error is not None: + if tracker_authority_error is not None and reason != FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED: final_status = DispatchStatus.FAILURE reason = tracker_authority_error - result_success = False if final_status != DispatchStatus.RESUMABLE: terminal_state = ( @@ -354,15 +405,11 @@ async def run_outcome_classification( # The orchestrator threads the dispatched backend's log directory through. project_log_dir = str(dispatched_log_dir) if dispatched_log_dir is not None else "" - if ( - resume_session_id - and skill_result.session_id - and resume_session_id != skill_result.session_id - ): + if resume_session_id and dispatched_session_id and resume_session_id != dispatched_session_id: logger.warning( "session_id_continuity_mismatch", resume_session_id=resume_session_id, - returned_session_id=skill_result.session_id, + returned_session_id=dispatched_session_id, ) # SUCCESS → COMMIT provenance. @@ -371,7 +418,7 @@ async def run_outcome_classification( DispatchEffectName.COMMIT, identities={ "dispatch_id": dispatch_id, - "dispatched_session_id": skill_result.session_id or "", + "dispatched_session_id": dispatched_session_id, }, ) provenance.confirm( @@ -379,7 +426,7 @@ async def run_outcome_classification( receipt="dispatch outcome classifier confirmed success", identities={ "dispatch_id": dispatch_id, - "dispatched_session_id": skill_result.session_id or "", + "dispatched_session_id": dispatched_session_id, }, ) @@ -393,13 +440,8 @@ async def run_outcome_classification( labels_cleaned=_labels_cleaned, project_log_dir=project_log_dir, extended_chain=extended_chain, - dispatched_session_id=( - spawn_ctx.dispatched_session_id[0] - if spawn_ctx.dispatched_session_id - else skill_result.session_id - ), + dispatched_session_id=dispatched_session_id, skill_result=skill_result, - result_success=result_success, dispatch_checkpoint=dispatch_checkpoint, ) @@ -435,6 +477,7 @@ async def finalize_state_write( project_log_dir = classification.project_log_dir _issue_urls_raw = spawn_ctx.issue_urls_raw dispatch_checkpoint = classification.dispatch_checkpoint + diagnostic_message = classification.tracker_authority_error or "" # Build the DispatchRecord. record = DispatchRecord( @@ -444,7 +487,7 @@ async def finalize_state_write( campaign_id=campaign_id, caller_session_id=caller_session_id, caller_backend_name=caller_backend_name, - dispatched_session_id=classification.dispatched_session_id or "", + dispatched_session_id=classification.dispatched_session_id, session_chain=extended_chain, dispatched_session_log_dir=project_log_dir, dispatched_pid=spawn_ctx.dispatched_pid[0] if spawn_ctx.dispatched_pid else 0, @@ -456,6 +499,7 @@ async def finalize_state_write( if spawn_ctx.dispatched_create_time else 0.0, reason=reason or "", + diagnostic_message=diagnostic_message, retry_reason=skill_result.retry_reason or "", infra_exit_category=skill_result.infra.exit_category or "", token_usage=normalize_dispatch_token_usage( @@ -512,11 +556,12 @@ async def finalize_state_write( # Build and return the DispatchResult envelope. return build_dispatch_result( parsed_result=parsed_result, - result_success=classification.result_success, final_status=final_status, reason=reason or "", + diagnostic_message=diagnostic_message, dispatch_id=dispatch_id, skill_result=skill_result, + dispatched_session_id=classification.dispatched_session_id, backend_name=effective_backend_name, dispatch_checkpoint=dispatch_checkpoint, started_at=started_at, diff --git a/src/autoskillit/fleet/dispatch/_errors.py b/src/autoskillit/fleet/dispatch/_errors.py index 749c704a2f..1626c7b84f 100644 --- a/src/autoskillit/fleet/dispatch/_errors.py +++ b/src/autoskillit/fleet/dispatch/_errors.py @@ -57,7 +57,6 @@ def complete_failure_with_state( the returned `DispatchResult.per_dispatch_state_path` is `None`. """ completed = DispatchCompleted( - success=False, dispatch_status=dispatch_status, dispatch_id=dispatch_id or "", dispatched_session_id=dispatched_session_id, diff --git a/src/autoskillit/fleet/dispatch/_execution.py b/src/autoskillit/fleet/dispatch/_execution.py index 98c375370e..ff47923bf0 100644 --- a/src/autoskillit/fleet/dispatch/_execution.py +++ b/src/autoskillit/fleet/dispatch/_execution.py @@ -138,16 +138,33 @@ def _resolve_resume_log( project_dir: str, resume_session_id: str | None, prior_session_chain: list[str], - fail_closed: Callable[[str], ExecutionResult], + fail_closed: Callable[[FleetErrorCode, str, str], ExecutionResult], ) -> str | None | ExecutionResult: """Resolve the resume log, falling back to the newest prior session when present.""" if not resume_session_id: return None + def _refuse_compacted_session( + selected_session_id: str, *, original_session_id: str | None = None + ) -> ExecutionResult | None: + if not locator.session_context_was_compacted(project_dir, selected_session_id): + return None + log_fields = {"selected_session_id": selected_session_id} + if original_session_id is not None: + log_fields["original_session_id"] = original_session_id + logger.warning("resume_session_context_compacted", **log_fields) + return fail_closed( + FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED, + f"Session {selected_session_id} has a compacted context; automatic resume is refused.", + selected_session_id, + ) + primary_jsonl = ( locator.session_log_path(project_dir, resume_session_id) if locator is not None else None ) if primary_jsonl is not None and primary_jsonl.exists(): + if (refusal := _refuse_compacted_session(resume_session_id)) is not None: + return refusal return resume_session_id logger.warning( @@ -163,6 +180,12 @@ def _resolve_resume_log( else None ) if fallback_jsonl is not None and fallback_jsonl.exists(): + if ( + refusal := _refuse_compacted_session( + fallback_session_id, original_session_id=resume_session_id + ) + ) is not None: + return refusal logger.info( "resume_session_fallback", original_session_id=resume_session_id, @@ -170,7 +193,11 @@ def _resolve_resume_log( ) return fallback_session_id - return fail_closed(f"JSONL log for session {resume_session_id} not found") + return fail_closed( + FleetErrorCode.FLEET_RESUME_SESSION_MISSING, + f"JSONL log for session {resume_session_id} not found", + "", + ) async def run_execution( @@ -287,11 +314,11 @@ async def run_execution( project_dir=str(tool_ctx.project_dir), resume_session_id=resume_session_id, prior_session_chain=prior_session_chain, - fail_closed=lambda message: _failed_execution_result( - error_code=FleetErrorCode.FLEET_RESUME_SESSION_MISSING, + fail_closed=lambda error_code, message, selected_failed_id: _failed_execution_result( + error_code=error_code, message=message, dispatch_status=DispatchStatus.REFUSED, - dispatched_session_id="", + dispatched_session_id=selected_failed_id, dispatch_id=dispatch_id, managed_lineage_ref=managed_lineage_ref, provenance=provenance, @@ -301,7 +328,7 @@ async def run_execution( started_at=started_at, dispatched_log_dir=dispatched_log_dir, dispatch_sidecar_path=dispatch_sidecar_path, - effective_resume_session_id=resume_session_id, + effective_resume_session_id=selected_failed_id or resume_session_id, ), ) if isinstance(effective_resume_session_id, ExecutionResult): diff --git a/src/autoskillit/fleet/dispatch/_lineage.py b/src/autoskillit/fleet/dispatch/_lineage.py index 57f5427329..2aea401e8f 100644 --- a/src/autoskillit/fleet/dispatch/_lineage.py +++ b/src/autoskillit/fleet/dispatch/_lineage.py @@ -35,11 +35,17 @@ prepare_food_truck_lineage, resolve_dispatch_timeout, ) -from autoskillit.fleet._outcome import build_success_short_circuit as _build_success_short_circuit +from autoskillit.fleet._outcome import ( + build_success_short_circuit as _build_success_short_circuit, +) +from autoskillit.fleet._outcome import ( + success_reason_contract_error, +) from autoskillit.fleet.campaign_state.state import ( DispatchIdentity, DispatchRecord, DispatchStateHandle, + DispatchStatus, read_all_campaign_captures, read_state, ) @@ -47,7 +53,11 @@ DispatchEffectName, DispatchProvenanceTracker, ) -from autoskillit.fleet.campaign_state.state_outcomes import DispatchResult +from autoskillit.fleet.campaign_state.state_outcomes import ( + DispatchCompleted, + DispatchRejected, + DispatchResult, +) from autoskillit.fleet.campaign_state.state_recovery import ResumePreflight, prepare_resume from autoskillit.fleet.dispatch._errors import complete_failure_with_state from autoskillit.workspace import default_skill_resolver, prepare_skill_projection @@ -71,6 +81,42 @@ def resume_managed_join_parent_id(state_path: Path, dispatch_name: str) -> str | return None +def _refuse_context_exhaustion_resume( + *, + state_path: Path, + dispatch_name: str, + resume_session_id: str | None, + prior_dispatch_id: str | None, + provenance: DispatchProvenanceTracker, +) -> DispatchResult | None: + """Reject an explicit resume of a persisted context-exhausted dispatch.""" + if not resume_session_id or not prior_dispatch_id: + return None + prior_state = read_state(state_path) + if prior_state is None: + return None + failed_dispatch = next( + ( + record + for record in prior_state.dispatches + if record.name == dispatch_name + and record.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED + ), + None, + ) + if failed_dispatch is None: + return None + return DispatchResult( + outcome=DispatchRejected( + error_code=FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED, + dispatch_id=failed_dispatch.dispatch_id or prior_dispatch_id, + message="This dispatch cannot resume after context exhaustion.", + effect_provenance=provenance.snapshot(), + ), + per_dispatch_state_path=None, + ) + + @dataclass class ReadyLineage: """Phase B output for the normal (non-prior-success) path — consumed by Phase C.""" @@ -107,6 +153,18 @@ class PriorSuccessLineageResult: prior_success_dispatch_result: DispatchResult +@dataclass(frozen=True, slots=True) +class _PreparedLineageInputs: + identity_preparation: Any + identity: DispatchIdentity + dispatch_id: str + state_path: Path + effective_ingredients: dict[str, str] + dispatches_dir: Path + campaign_id: str + recipe_snapshot: dict + + @dataclass class ReadyLineageResult: """Phase B outcome carrying everything Phase C needs to spawn the executor.""" @@ -118,6 +176,101 @@ class ReadyLineageResult: LineagePreparationResult = PriorSuccessLineageResult | ReadyLineageResult +def _reuse_prior_success( + *, + candidate: DispatchRecord, + success_reasons: frozenset[str] | None, + provenance: DispatchProvenanceTracker, + handle: DispatchStateHandle | None = None, + dispatch_name: str = "", + prior_dispatch_id: str | None = None, +) -> PriorSuccessLineageResult | DispatchResult: + """Validate prior success and preserve the authority used to reuse it.""" + reason_error = success_reason_contract_error(candidate.reason, success_reasons) + if reason_error is not None: + return DispatchResult( + outcome=DispatchRejected( + error_code=FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION, + dispatch_id=candidate.dispatch_id, + message=f"Stored successful dispatch cannot be reused: {reason_error}", + effect_provenance=provenance.snapshot(), + ), + per_dispatch_state_path=None, + ) + + identities: dict[str, object] = { + "dispatch_id": candidate.dispatch_id, + "dispatched_session_id": candidate.dispatched_session_id, + } + if handle is not None: + logger.info( + "resume_skipped_prior_success", + dispatch_name=dispatch_name, + prior_dispatch_id=prior_dispatch_id, + ) + provenance.confirm( + DispatchEffectName.DISPATCH_ALLOCATION, + receipt="opened authoritative prior dispatch state", + identities={"dispatch_id": candidate.dispatch_id, "state_path": handle.state_path}, + ) + provenance.start( + DispatchEffectName.PRIOR_DISPATCH_BINDING, + retry_relevant=False, + identities=identities, + ) + provenance.confirm( + DispatchEffectName.PRIOR_DISPATCH_BINDING, + receipt="authoritative prior dispatch state reported success", + retry_relevant=False, + identities=identities, + ) + provenance.start(DispatchEffectName.COMMIT, identities=identities) + provenance.confirm( + DispatchEffectName.COMMIT, + receipt="reused committed prior dispatch", + identities=identities, + ) + prior_success_result = _build_success_short_circuit( + candidate, + handle, + provenance.snapshot(), + ) + else: + provenance.start( + DispatchEffectName.PRIOR_DISPATCH_BINDING, + retry_relevant=False, + identities=identities, + ) + provenance.confirm( + DispatchEffectName.PRIOR_DISPATCH_BINDING, + receipt="campaign state reported prior success", + retry_relevant=False, + identities=identities, + ) + provenance.start(DispatchEffectName.COMMIT, identities=identities) + provenance.confirm( + DispatchEffectName.COMMIT, + receipt="reused committed campaign dispatch", + identities=identities, + ) + prior_success_result = DispatchResult( + outcome=DispatchCompleted( + dispatch_status=DispatchStatus.SUCCESS, + dispatch_id=candidate.dispatch_id, + dispatched_session_id=candidate.dispatched_session_id, + reason=candidate.reason, + token_usage=dict(candidate.token_usage), + effect_provenance=provenance.snapshot(), + ), + per_dispatch_state_path=None, + ) + + return PriorSuccessLineageResult( + outcome="prior_success_short_circuit", + prior_success_dispatch_result=prior_success_result, + ) + + def create_fresh_handle( *, dispatches_dir: Path, @@ -162,6 +315,7 @@ def prepare_launch( effective_backend: CodingAgentBackend | None, tool_ctx: ToolContext, adaptation_context: SemanticAdaptationContext | None = None, + success_reasons: frozenset[str] | None = None, ) -> tuple[str, Any, Any, Path]: """Build the (prompt, plugin_authority, capability_preparation, authoritative_cwd) tuple. @@ -184,6 +338,7 @@ def prepare_launch( l3_timeout_sec=int(l3_timeout_sec) if l3_timeout_sec is not None else 0, capture=capture, caller_instructions=caller_instructions, + success_reasons=success_reasons, ) plugin_authority = capability_preparation = None if effective_backend is not None: @@ -280,52 +435,32 @@ def _validate_and_materialize_ingredients( ) -async def run_lineage_preparation( +def _prepare_identity_and_ingredients( *, tool_ctx: ToolContext, - recipe: str, - recipe_obj: Any, # RecipeInfo — mirrors the local from `_run_dispatch` - task: str, + recipe_obj: Any, + full_recipe: Recipe, effective_ingredients: dict[str, str], effective_name: str, - full_recipe: Recipe, - effective_backend: CodingAgentBackend | None, caller_backend_name: str, - dispatch_name: str | None, - prompt_builder: Callable[..., str], - capture: dict[str, CaptureEntrySpec] | None, + caller_session_id: str, resume_session_id: str | None, - resume_checkpoint: SessionCheckpoint | None, - resume_message: str | None, - caller_instructions: str | None, prior_dispatch_id: str | None, - caller_session_id: str, provenance: DispatchProvenanceTracker, - native_shell_capture_mode: NativeShellCaptureMode | None, - timeout_sec: int | None, - managed_join_parent_id: str | None = None, - adaptation_context: SemanticAdaptationContext | None = None, -) -> LineagePreparationResult | DispatchResult: - """Mint the per-dispatch state handle, prepare the managed lineage, build the launch tuple. - - All closure-scoped variables from the legacy ``_create_fresh_handle`` and - ``_prepare_launch`` closures are now explicit keyword arguments to the - free functions ``create_fresh_handle`` and ``prepare_launch`` defined in - this module. + success_reasons: frozenset[str] | None, + prior_success_candidate: DispatchRecord | None, +) -> _PreparedLineageInputs | PriorSuccessLineageResult | DispatchResult: + """Resolve identity, prior-success authority, and validated launch ingredients.""" + if prior_success_candidate is not None: + return _reuse_prior_success( + candidate=prior_success_candidate, + success_reasons=success_reasons, + provenance=provenance, + ) - ``managed_lineage_ref`` is threaded via the local variable: every call to - ``complete_failure_with_state`` from this Phase passes - ``managed_lineage_ref=None`` because the assignment at the end of the - function has not fired yet — the original closure's free-variable lookup - always read the latest value, but the only caller that would have - observed a non-None ``managed_lineage_ref`` was the catch-block that - fires BEFORE the post-try assignment. - """ - effective_name = dispatch_name or effective_name dispatches_dir = tool_ctx.temp_dir / "dispatches" dispatches_dir.mkdir(parents=True, exist_ok=True) campaign_id = tool_ctx.kitchen_id - recipe_snapshot = { "recipe_name": recipe_obj.name, "recipe_path": str(recipe_obj.path), @@ -372,58 +507,25 @@ async def run_lineage_preparation( prior_dispatch_id=prior_dispatch_id, ) handle = identity_preparation.handle + context_resume_refusal = _refuse_context_exhaustion_resume( + state_path=handle.state_path, + dispatch_name=effective_name, + resume_session_id=resume_session_id, + prior_dispatch_id=prior_dispatch_id, + provenance=provenance, + ) + if context_resume_refusal is not None: + return context_resume_refusal + if identity_preparation.prior_success_record is not None: - logger.info( - "resume_skipped_prior_success", + return _reuse_prior_success( + candidate=identity_preparation.prior_success_record, + success_reasons=success_reasons, + provenance=provenance, + handle=handle, dispatch_name=effective_name, prior_dispatch_id=prior_dispatch_id, ) - provenance.confirm( - DispatchEffectName.DISPATCH_ALLOCATION, - receipt="opened authoritative prior dispatch state", - identities={ - "dispatch_id": identity_preparation.prior_success_record.dispatch_id, - "state_path": handle.state_path, - }, - ) - provenance.confirm( - DispatchEffectName.PRIOR_DISPATCH_BINDING, - receipt="authoritative prior dispatch state reported success", - retry_relevant=False, - identities={ - "dispatch_id": identity_preparation.prior_success_record.dispatch_id, - "dispatched_session_id": ( - identity_preparation.prior_success_record.dispatched_session_id - ), - }, - ) - provenance.start( - DispatchEffectName.COMMIT, - identities={ - "dispatch_id": identity_preparation.prior_success_record.dispatch_id, - "dispatched_session_id": ( - identity_preparation.prior_success_record.dispatched_session_id - ), - }, - ) - provenance.confirm( - DispatchEffectName.COMMIT, - receipt="reused committed prior dispatch", - identities={ - "dispatch_id": identity_preparation.prior_success_record.dispatch_id, - "dispatched_session_id": ( - identity_preparation.prior_success_record.dispatched_session_id - ), - }, - ) - return PriorSuccessLineageResult( - outcome="prior_success_short_circuit", - prior_success_dispatch_result=_build_success_short_circuit( - identity_preparation.prior_success_record, - handle, - provenance.snapshot(), - ), - ) identity = handle.identity dispatch_id = identity.dispatch_id @@ -431,22 +533,16 @@ async def run_lineage_preparation( provenance.confirm( DispatchEffectName.DISPATCH_ALLOCATION, receipt="per-dispatch state identity persisted", - identities={ - "dispatch_id": dispatch_id, - "state_path": state_path, - }, + identities={"dispatch_id": dispatch_id, "state_path": state_path}, ) if resume_session_id and prior_dispatch_id: provenance.confirm( DispatchEffectName.PRIOR_DISPATCH_BINDING, receipt="prior dispatch state opened", retry_relevant=False, - identities={ - "prior_dispatch_id": prior_dispatch_id, - "dispatch_id": dispatch_id, - }, + identities={"prior_dispatch_id": prior_dispatch_id, "dispatch_id": dispatch_id}, ) - ingredient_materialization = _validate_and_materialize_ingredients( + materialized = _validate_and_materialize_ingredients( effective_ingredients=effective_ingredients, full_recipe=full_recipe, dispatches_dir=dispatches_dir, @@ -457,9 +553,89 @@ async def run_lineage_preparation( effective_name=effective_name, tool_ctx=tool_ctx, ) - if isinstance(ingredient_materialization, DispatchResult): - return ingredient_materialization - effective_ingredients = ingredient_materialization + if isinstance(materialized, DispatchResult): + return materialized + return _PreparedLineageInputs( + identity_preparation=identity_preparation, + identity=identity, + dispatch_id=dispatch_id, + state_path=state_path, + effective_ingredients=materialized, + dispatches_dir=dispatches_dir, + campaign_id=campaign_id, + recipe_snapshot=recipe_snapshot, + ) + + +async def run_lineage_preparation( + *, + tool_ctx: ToolContext, + recipe: str, + recipe_obj: Any, # RecipeInfo — mirrors the local from `_run_dispatch` + task: str, + effective_ingredients: dict[str, str], + effective_name: str, + full_recipe: Recipe, + effective_backend: CodingAgentBackend | None, + caller_backend_name: str, + dispatch_name: str | None, + prompt_builder: Callable[..., str], + capture: dict[str, CaptureEntrySpec] | None, + resume_session_id: str | None, + resume_checkpoint: SessionCheckpoint | None, + resume_message: str | None, + caller_instructions: str | None, + prior_dispatch_id: str | None, + caller_session_id: str, + provenance: DispatchProvenanceTracker, + native_shell_capture_mode: NativeShellCaptureMode | None, + timeout_sec: int | None, + managed_join_parent_id: str | None = None, + adaptation_context: SemanticAdaptationContext | None = None, + success_reasons: frozenset[str] | None = None, + prior_success_candidate: DispatchRecord | None = None, +) -> LineagePreparationResult | DispatchResult: + """Mint the per-dispatch state handle, prepare the managed lineage, build the launch tuple. + + All closure-scoped variables from the legacy ``_create_fresh_handle`` and + ``_prepare_launch`` closures are now explicit keyword arguments to the + free functions ``create_fresh_handle`` and ``prepare_launch`` defined in + this module. + + ``managed_lineage_ref`` is threaded via the local variable: every call to + ``complete_failure_with_state`` from this Phase passes + ``managed_lineage_ref=None`` because the assignment at the end of the + function has not fired yet — the original closure's free-variable lookup + always read the latest value, but the only caller that would have + observed a non-None ``managed_lineage_ref`` was the catch-block that + fires BEFORE the post-try assignment. + """ + effective_name = dispatch_name or effective_name + prepared = _prepare_identity_and_ingredients( + tool_ctx=tool_ctx, + recipe_obj=recipe_obj, + full_recipe=full_recipe, + effective_ingredients=effective_ingredients, + effective_name=effective_name, + caller_backend_name=caller_backend_name, + caller_session_id=caller_session_id, + resume_session_id=resume_session_id, + prior_dispatch_id=prior_dispatch_id, + provenance=provenance, + success_reasons=success_reasons, + prior_success_candidate=prior_success_candidate, + ) + if not isinstance(prepared, _PreparedLineageInputs): + return prepared + + identity_preparation = prepared.identity_preparation + identity = prepared.identity + dispatch_id = prepared.dispatch_id + state_path = prepared.state_path + effective_ingredients = prepared.effective_ingredients + dispatches_dir = prepared.dispatches_dir + campaign_id = prepared.campaign_id + recipe_snapshot = prepared.recipe_snapshot managed_lineage_ref: ManagedHeadlessSessionLineageRef | None = None resolved_timeout = resolve_dispatch_timeout( @@ -499,6 +675,7 @@ async def run_lineage_preparation( effective_backend=effective_backend, tool_ctx=tool_ctx, adaptation_context=adaptation_context, + success_reasons=success_reasons, ), prepare_launch=lambda for_dispatch_id: prepare_launch( for_dispatch_id=for_dispatch_id, @@ -513,6 +690,7 @@ async def run_lineage_preparation( effective_backend=effective_backend, tool_ctx=tool_ctx, adaptation_context=adaptation_context, + success_reasons=success_reasons, ), create_fresh_handle=lambda: create_fresh_handle( dispatches_dir=dispatches_dir, diff --git a/src/autoskillit/fleet/dispatch/_validation.py b/src/autoskillit/fleet/dispatch/_validation.py index 4e679804c6..0bfbd20c95 100644 --- a/src/autoskillit/fleet/dispatch/_validation.py +++ b/src/autoskillit/fleet/dispatch/_validation.py @@ -60,6 +60,8 @@ class RecipeContext: task: str recipe_obj: Any = None # RecipeInfo — populated by Phase A, consumed by Phase B dispatch_name: str | None = None + # From the validated active recipe graph, never the separately loaded raw recipe. + success_reasons: frozenset[str] | None = None async def run_pre_launch_gating( @@ -180,6 +182,7 @@ async def run_pre_launch_gating( ) effective_name = dispatch_name or recipe + declared_success_reasons = validation_result.get("_dispatch_success_reasons") return RecipeContext( effective_name=effective_name, @@ -191,6 +194,9 @@ async def run_pre_launch_gating( task=task, recipe_obj=recipe_obj, dispatch_name=dispatch_name, + success_reasons=( + frozenset(declared_success_reasons) if declared_success_reasons is not None else None + ), ) diff --git a/src/autoskillit/fleet/result_parser.py b/src/autoskillit/fleet/result_parser.py index 78933cd6f3..73b377385a 100644 --- a/src/autoskillit/fleet/result_parser.py +++ b/src/autoskillit/fleet/result_parser.py @@ -135,6 +135,20 @@ def _parse_body( source=source, ) + error = None + if type(parsed.get("success")) is not bool: + error = "success must be a JSON boolean" + elif "reason" in parsed and not isinstance(parsed["reason"], str): + error = "reason must be a string when present" + if error is not None: + return L3ParseResult( + outcome="completed_dirty", + payload=None, + raw_body=body, + parse_error=error, + source=source, + ) + return L3ParseResult( outcome="completed_clean", payload=parsed, diff --git a/src/autoskillit/hook_registry/_drift.py b/src/autoskillit/hook_registry/_drift.py index a6210ba901..3f78f9c756 100644 --- a/src/autoskillit/hook_registry/_drift.py +++ b/src/autoskillit/hook_registry/_drift.py @@ -84,7 +84,7 @@ def _count_hook_registry_drift(settings_path: Path) -> HookDriftResult: runtime_only_basenames = frozenset( script for hook in HOOK_REGISTRY if hook.runtime_only for script in hook.scripts ) - deployed_basenames -= runtime_only_basenames + deployed_basenames -= runtime_only_basenames - canonical_basenames orphaned = deployed_basenames - canonical_basenames return HookDriftResult( missing=len(canonical_basenames - deployed_basenames), diff --git a/src/autoskillit/hook_registry/_registry_data.py b/src/autoskillit/hook_registry/_registry_data.py index 7f109a6516..98797e65b1 100644 --- a/src/autoskillit/hook_registry/_registry_data.py +++ b/src/autoskillit/hook_registry/_registry_data.py @@ -597,6 +597,15 @@ def _build_hook_registry() -> list[HookDef]: enforcement_strength={"claude_code": "not-applicable", "codex": "works-as-is"}, runtime_only=True, ), + HookDef( + matcher="auto", + event_type="PreCompact", + scripts=["guards/auto_compact_guard.py"], + codex_status="not-applicable", + mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + runtime_only=False, + ), # Child terminal-reason observation (issue #4623). Purely observational # (mechanism="side-effect"): records durable child-outcome evidence, # never denies. SubagentStart/SubagentStop/SessionEnd are Claude-only diff --git a/src/autoskillit/hooks/_runtime/_hook_output.py b/src/autoskillit/hooks/_runtime/_hook_output.py index 218a605675..967c6f0a74 100644 --- a/src/autoskillit/hooks/_runtime/_hook_output.py +++ b/src/autoskillit/hooks/_runtime/_hook_output.py @@ -24,7 +24,14 @@ class HookEmission(NamedTuple): { "deny": frozenset({"PreToolUse"}), "block": frozenset( - {"PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SubagentStop"} + { + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "SubagentStop", + "PreCompact", + } ), "context": frozenset({"SessionStart", "PreToolUse", "PostToolUse", "UserPromptExpansion"}), "rewrite_input": frozenset({"PreToolUse"}), diff --git a/src/autoskillit/hooks/formatters/_fmt_recipe.py b/src/autoskillit/hooks/formatters/_fmt_recipe.py index 3a4522b215..c995c6de90 100644 --- a/src/autoskillit/hooks/formatters/_fmt_recipe.py +++ b/src/autoskillit/hooks/formatters/_fmt_recipe.py @@ -65,6 +65,7 @@ "unreachable_step_names", # internal execution-graph normalization metadata "post_prune_routing_edges", # internal preflight field; not displayed to agent "_finalized_projection", # internal host-attested finalized recipe carrier + "_dispatch_success_reasons", } ) diff --git a/src/autoskillit/hooks/guards/AGENTS.md b/src/autoskillit/hooks/guards/AGENTS.md index 191c4499e0..94ee3f2d92 100644 --- a/src/autoskillit/hooks/guards/AGENTS.md +++ b/src/autoskillit/hooks/guards/AGENTS.md @@ -114,12 +114,19 @@ mechanism for detecting subagent context in hook scripts. Used by: `skill_load_guard.py`, `skill_load_post_hook.py` +## Automatic compaction scope + +`auto_compact_guard.py` has a published Claude `PreCompact`/`auto` registration and +the existing runtime-only Codex registration. Claude's automatic matcher covers +threshold-triggered and idle compaction; exit 2 blocks it only in orchestrator sessions. +The separate manual matcher remains available. Codex continues to use its native +`continue: false` halt output. + ## Multi-concern files Files with no registration support their folder; files with several registrations group one related concern. - `__init__.py` — Support module: PreToolUse guard scripts — invoked as standalone python3 commands. -- `auto_compact_guard.py` — Runtime-only Codex `PreCompact` guard registered outside the generated Claude hooks manifest. - `branch_protection_guard.py` — Groups 2 registrations: PreToolUse hook: branch protection guard for merge_worktree and push_to_remote. - `ingredient_lock_guard.py` — Groups 2 registrations: PreToolUse guard: deny run_skill calls for steps locked by lock_ingredients. - `join_settle_guard.py` — Groups 2 registrations: PostToolUse / PostToolUseFailure settlement — record claimed handle outcomes. diff --git a/src/autoskillit/hooks/guards/auto_compact_guard.py b/src/autoskillit/hooks/guards/auto_compact_guard.py index fea6405ad3..29c63efcb4 100644 --- a/src/autoskillit/hooks/guards/auto_compact_guard.py +++ b/src/autoskillit/hooks/guards/auto_compact_guard.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Deny Codex automatic compaction before it can replace session history.""" +"""Block automatic compaction before it can replace session history.""" from __future__ import annotations @@ -16,7 +16,8 @@ sys.path.insert(0, _RUNTIME_DIR) from _hook_constants import CODEX_AUTO_COMPACTION_DENIED_REASON # noqa: E402 -from _hook_output import halt_session # noqa: E402 +from _hook_output import block, halt_session # noqa: E402 +from _hook_settings import hook_session_shape # noqa: E402 _SYSTEM_MESSAGE = ( "AutoSkillit blocked automatic compaction before changing history. Start a new session, " @@ -25,8 +26,9 @@ def main() -> None: - """Emit Codex's synchronous veto only for its registered automatic trigger.""" - if os.environ.get("AUTOSKILLIT_AGENT_BACKEND") != "codex": + """Block Claude orchestrator compaction and preserve Codex's synchronous veto.""" + backend = os.environ.get("AUTOSKILLIT_AGENT_BACKEND") + if backend not in {"codex", "claude-code"}: return try: event = json.load(sys.stdin) @@ -36,7 +38,12 @@ def main() -> None: return if event.get("hook_event_name") != "PreCompact" or event.get("trigger") != "auto": return - halt_session(CODEX_AUTO_COMPACTION_DENIED_REASON, system_message=_SYSTEM_MESSAGE) + if backend == "codex": + halt_session(CODEX_AUTO_COMPACTION_DENIED_REASON, system_message=_SYSTEM_MESSAGE) + else: + _, session_type = hook_session_shape() + if session_type == "orchestrator": + block(_SYSTEM_MESSAGE) if __name__ == "__main__": diff --git a/src/autoskillit/hooks/registry.sha256 b/src/autoskillit/hooks/registry.sha256 index b050ca0526..c9c424b10b 100644 --- a/src/autoskillit/hooks/registry.sha256 +++ b/src/autoskillit/hooks/registry.sha256 @@ -1 +1 @@ -10ac81e11414abf31fe96804f89369c2eeb1bf0ba91d2baf1852ad1013013925 +6996ff84118019160a4dc935e3477dabb3c31b05fa7caf5af604040d1ced2024 diff --git a/src/autoskillit/recipe/_rule_helpers.py b/src/autoskillit/recipe/_rule_helpers.py index c46e448036..6305924566 100644 --- a/src/autoskillit/recipe/_rule_helpers.py +++ b/src/autoskillit/recipe/_rule_helpers.py @@ -11,7 +11,6 @@ _MAX_HOPS, _PATH_SAFE_LOOKAHEAD, _PATH_SAFE_LOOKBEHIND, - _SENTINEL_TRIGGER_RE, _SKILL_CMD_PATTERN, TYPE_CHECKING, Any, @@ -48,7 +47,6 @@ "_MAX_HOPS", "_PATH_SAFE_LOOKAHEAD", "_PATH_SAFE_LOOKBEHIND", - "_SENTINEL_TRIGGER_RE", "_SKILL_CMD_PATTERN", "_build_graph_without_nodes", "_extract_sentinel_fields", diff --git a/src/autoskillit/recipe/api/_api_cache.py b/src/autoskillit/recipe/api/_api_cache.py index 9b2a184a0f..b1d19b2cf6 100644 --- a/src/autoskillit/recipe/api/_api_cache.py +++ b/src/autoskillit/recipe/api/_api_cache.py @@ -68,6 +68,9 @@ def copy_result(self, result: LoadRecipeResult) -> LoadRecipeResult: r["requires_features"] = list(r["requires_features"]) if "deferred_guards" in r: r["deferred_guards"] = list(r["deferred_guards"]) + success_reasons = r.get("_dispatch_success_reasons") + if success_reasons is not None: + r["_dispatch_success_reasons"] = list(success_reasons) return r def put(self, key: tuple, entry: _LoadCacheEntry) -> None: diff --git a/src/autoskillit/recipe/api_orchestration/_api_orchestration_assemble.py b/src/autoskillit/recipe/api_orchestration/_api_orchestration_assemble.py index dd2694f7b9..8d5f811a71 100644 --- a/src/autoskillit/recipe/api_orchestration/_api_orchestration_assemble.py +++ b/src/autoskillit/recipe/api_orchestration/_api_orchestration_assemble.py @@ -171,6 +171,10 @@ def _assemble_load_result( active_recipe, stop_semantics=_stop_semantics ) result["stop_step_semantics"] = _stop_semantics + success_reasons = active_recipe.declared_success_reasons() if active_recipe else None + result["_dispatch_success_reasons"] = ( + sorted(success_reasons) if success_reasons is not None else None + ) result["content_hash"] = recipe.content_hash if recipe else "" result["composite_hash"] = recipe.composite_hash if recipe else "" result["recipe_version"] = recipe.recipe_version if recipe else None diff --git a/src/autoskillit/recipe/api_orchestration/_api_orchestration_validate.py b/src/autoskillit/recipe/api_orchestration/_api_orchestration_validate.py index 36aa161880..2d2f8c150f 100644 --- a/src/autoskillit/recipe/api_orchestration/_api_orchestration_validate.py +++ b/src/autoskillit/recipe/api_orchestration/_api_orchestration_validate.py @@ -222,6 +222,11 @@ def _run_validation_pipeline( if _post_sweep_errors: errors.extend(f"[post-prune] {error}" for error in _post_sweep_errors) raw = "" + errors.extend( + f"[post-prune] unresolved sub_recipe {step.sub_recipe!r} at step {step_name!r}" + for step_name, step in active_recipe.steps.items() + if step.sub_recipe is not None + ) _effective_analysis_edges = _analysis_edges_from_effective_routes( active_recipe, _effective_flow_edges ) diff --git a/src/autoskillit/recipe/helpers/__init__.py b/src/autoskillit/recipe/helpers/__init__.py index 7302d0688a..edc90404d0 100644 --- a/src/autoskillit/recipe/helpers/__init__.py +++ b/src/autoskillit/recipe/helpers/__init__.py @@ -67,7 +67,6 @@ _MAX_HOPS, _PATH_SAFE_LOOKAHEAD, _PATH_SAFE_LOOKBEHIND, - _SENTINEL_TRIGGER_RE, _SKILL_CMD_PATTERN, ValidationContext, _build_graph_without_nodes, @@ -206,7 +205,6 @@ def __getattr__(name: str): "_MAX_HOPS": "_rule_helpers", "_PATH_SAFE_LOOKAHEAD": "_rule_helpers", "_PATH_SAFE_LOOKBEHIND": "_rule_helpers", - "_SENTINEL_TRIGGER_RE": "_rule_helpers", "_SKILL_CMD_PATTERN": "_rule_helpers", "_build_graph_without_nodes": "_rule_helpers", "_extract_sentinel_fields": "_rule_helpers", @@ -297,7 +295,6 @@ def __getattr__(name: str): "_RecipeInputSignature", "_RecipeParser", "_SCRIPTS_PLACEHOLDER", - "_SENTINEL_TRIGGER_RE", "_SKILL_CATEGORY_CACHE", "_SKILL_CMD_PATTERN", "_SKILL_NAMES_CACHE", diff --git a/src/autoskillit/recipe/helpers/_rule_helpers.py b/src/autoskillit/recipe/helpers/_rule_helpers.py index ca8421fcd0..95a0a7a654 100644 --- a/src/autoskillit/recipe/helpers/_rule_helpers.py +++ b/src/autoskillit/recipe/helpers/_rule_helpers.py @@ -12,6 +12,9 @@ from autoskillit.core import get_logger from autoskillit.recipe.analysis._analysis import ValidationContext +from autoskillit.recipe.schema import ( + extract_sentinel_json_blocks, +) if TYPE_CHECKING: from autoskillit.recipe.contracts._contracts_types import SkillContract @@ -65,47 +68,6 @@ def count_skill_args(skill_command: str) -> int: # Maximum hops for BFS push-reachability checks. _MAX_HOPS = 6 -# Trigger regex: matches multiple sentinel-indicating phrases -_SENTINEL_TRIGGER_RE = re.compile( - r"[Ee]xample\s+sentinel:|sentinel\s+JSON:|sentinel:\s*(?=\{)", - re.DOTALL, -) - - -def extract_sentinel_json_blocks(text: str) -> list[str]: - """Extract complete JSON object strings from a text using bracket-aware parsing. - - Handles nested braces and arrays, unlike a simple regex that stops at the first `}`. - Matches any sentinel-indicating trigger phrase (e.g., "Example sentinel:", - "sentinel JSON:", "sentinel: {…}") and then uses bracket counting to find the matching - closing brace for the JSON object that follows. - - Returns a list of raw JSON strings (still serialized) that can be passed to json.loads(). - """ - blocks: list[str] = [] - for match in _SENTINEL_TRIGGER_RE.finditer(text): - # Position right after the trigger phrase - start = match.end() - # Find first non-whitespace character - while start < len(text) and text[start] in " \t\n\r": - start += 1 - if start >= len(text) or text[start] != "{": - continue - # Bracket-counting scan to find matching closing brace - depth = 0 - i = start - while i < len(text): - ch = text[i] - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - blocks.append(text[start : i + 1]) - break - i += 1 - return blocks - def _is_failure_sentinel_value(val: Any) -> bool: """Return True if *val* represents a failure sentinel success field.""" diff --git a/src/autoskillit/recipe/ingredients/_recipe_ingredients.py b/src/autoskillit/recipe/ingredients/_recipe_ingredients.py index dd96e1ea0d..7bc60c9bd9 100644 --- a/src/autoskillit/recipe/ingredients/_recipe_ingredients.py +++ b/src/autoskillit/recipe/ingredients/_recipe_ingredients.py @@ -142,6 +142,7 @@ class LoadRecipeResult(TypedDict, total=False): required_sections: list[dict[str, Any]] recovery: dict[str, Any] warnings: NotRequired[list[str]] + _dispatch_success_reasons: list[str] | None # Internal-only. Recipe API callers receive this only when they explicitly # request the attested server-delivery carrier. _finalized_projection: FinalizedRecipeProjection diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index 5a686ef6ed..dc467b107c 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -1,8 +1,9 @@ -"""Pure domain model for pipeline recipes — dataclasses and enums only.""" +"""Recipe domain models and extraction of declared sentinel JSON examples.""" from __future__ import annotations import dataclasses +import json from collections.abc import Mapping from dataclasses import dataclass, field from enum import StrEnum @@ -25,6 +26,57 @@ CAMPAIGN_REF_RE: Final = re.compile(r"\$\{\{\s*campaign\.(\w+)\s*\}\}") +# Trigger regex: matches multiple sentinel-indicating phrases +_SENTINEL_TRIGGER_RE = re.compile( + r"example\s+sentinel:|sentinel\s+JSON:|sentinel:\s*(?=\{)", + re.DOTALL | re.IGNORECASE, +) + + +def _find_json_object_end(text: str, start: int) -> int | None: + """Find the matching closing brace, respecting JSON strings and escapes.""" + depth = 0 + in_string = False + escaped = False + for i in range(start, len(text)): + ch = text[i] + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + elif ch == '"': + in_string = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return i + 1 + return None + + +def extract_sentinel_json_blocks(text: str) -> list[str]: + """Extract complete JSON object strings following sentinel trigger phrases. + + Handles nested braces and arrays, ignoring braces inside JSON strings. + Matches phrases such as "Example sentinel:", "sentinel JSON:", and "sentinel: {…}". + Returns raw JSON strings that can be passed to json.loads(). + """ + blocks: list[str] = [] + for match in _SENTINEL_TRIGGER_RE.finditer(text): + start = match.end() + while start < len(text) and text[start] in " \t\n\r": + start += 1 + if start >= len(text) or text[start] != "{": + continue + if (end := _find_json_object_end(text, start)) is not None: + blocks.append(text[start:end]) + return blocks + + class RecipeKind(StrEnum): STANDARD = "standard" CAMPAIGN = "campaign" @@ -292,6 +344,24 @@ class Recipe: # list_recipes. It is only loaded via campaign dispatch by name. delivery_segments: tuple[RecipeDeliverySegment, ...] = field(default_factory=tuple) + def declared_success_reasons(self) -> frozenset[str] | None: + """Collect valid success reasons declared by this graph's stop examples.""" + reasons: set[str] = set() + for step in self.steps.values(): + if step.action != "stop" or not step.message: + continue + for block in extract_sentinel_json_blocks(step.message): + try: + payload = json.loads(block) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict) or payload.get("success") is not True: + continue + reason = payload.get("reason") + if isinstance(reason, str) and reason.strip(): + reasons.add(reason.strip()) + return frozenset(reasons) if reasons else None + def __post_init__(self) -> None: self.name = self.name.strip() self.description = self.description.strip() diff --git a/src/autoskillit/recipes/research-archive.json b/src/autoskillit/recipes/research-archive.json index 5ab1044a96..4f45047b31 100644 --- a/src/autoskillit/recipes/research-archive.json +++ b/src/autoskillit/recipes/research-archive.json @@ -134,7 +134,7 @@ }, "research_complete": { "action": "stop", - "message": "Research archival complete. Artifacts separated into a dedicated PR, experiment branch tagged and preserved, original PR closed with cross-references to the artifact PR and archive tag. Emit the L3 result sentinel JSON block now. The sentinel must include: success=true. Example sentinel: {\"success\": true}" + "message": "Research archival complete. Artifacts separated into a dedicated PR, experiment branch tagged and preserved, original PR closed with cross-references to the artifact PR and archive tag. Emit the L3 result sentinel JSON block now. The sentinel must include: success=true, reason=\"research_archive_complete\". Example sentinel: {\"success\": true, \"reason\": \"research_archive_complete\"}" }, "escalate_stop": { "action": "stop", diff --git a/src/autoskillit/recipes/research-archive.yaml b/src/autoskillit/recipes/research-archive.yaml index 22a609115b..f7bc94d866 100644 --- a/src/autoskillit/recipes/research-archive.yaml +++ b/src/autoskillit/recipes/research-archive.yaml @@ -140,9 +140,9 @@ steps: experiment branch tagged and preserved, original PR closed with cross-references to the artifact PR and archive tag. Emit the L3 result sentinel JSON block now. - The sentinel must include: success=true. + The sentinel must include: success=true, reason="research_archive_complete". Example sentinel: - {"success": true} + {"success": true, "reason": "research_archive_complete"} escalate_stop: action: stop diff --git a/src/autoskillit/server/_misc.py b/src/autoskillit/server/_misc.py index 25b8c7c09f..6659b55727 100644 --- a/src/autoskillit/server/_misc.py +++ b/src/autoskillit/server/_misc.py @@ -245,7 +245,9 @@ async def _apply_triage_gate( ) -_INGREDIENTS_ONLY_EXCLUDE = frozenset({"content", "orchestration_rules", "stop_step_semantics"}) +_INGREDIENTS_ONLY_EXCLUDE = frozenset( + {"content", "orchestration_rules", "stop_step_semantics", "_dispatch_success_reasons"} +) def strip_ingredients_only_keys(result: Any) -> dict[str, Any]: diff --git a/src/autoskillit/server/recipe/_recipe_artifact.py b/src/autoskillit/server/recipe/_recipe_artifact.py index cd4b9dc110..83f271eb7b 100644 --- a/src/autoskillit/server/recipe/_recipe_artifact.py +++ b/src/autoskillit/server/recipe/_recipe_artifact.py @@ -139,6 +139,7 @@ class PreparedRecipeGeneration: _RECIPE_GENERATION_SOURCE_EXCLUDED_FIELDS = frozenset( { + "_dispatch_success_reasons", "_finalized_projection", "delivery_bound_spill", "finalized_recipe_projection", diff --git a/src/autoskillit/server/recipe/_recipe_delivery/_finalize.py b/src/autoskillit/server/recipe/_recipe_delivery/_finalize.py index 40d2ae7d9f..8da7f95c50 100644 --- a/src/autoskillit/server/recipe/_recipe_delivery/_finalize.py +++ b/src/autoskillit/server/recipe/_recipe_delivery/_finalize.py @@ -192,6 +192,7 @@ def finalize_recipe_delivery( ): raise ValueError("canonical artifact payload does not match prepared flow generation") surface_payload = dict(payload) + surface_payload.pop("_dispatch_success_reasons", None) if "success" not in surface_payload: surface_payload["success"] = True for generation_field in ( diff --git a/src/autoskillit/server/tools/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index 8cc52b57f2..66ca97198c 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -189,6 +189,7 @@ def render_served_response(payload: dict[str, Any]) -> str: """Render the authoritative pre-backstop response used by recipe serve tools.""" public_payload = dict(payload) public_payload.pop("_finalized_projection", None) + public_payload.pop("_dispatch_success_reasons", None) return json.dumps(public_payload, ensure_ascii=False, separators=(",", ":")) diff --git a/src/autoskillit/server/tools/tools_fleet_dispatch/_handlers.py b/src/autoskillit/server/tools/tools_fleet_dispatch/_handlers.py index 01b131a3bb..749a6a398d 100644 --- a/src/autoskillit/server/tools/tools_fleet_dispatch/_handlers.py +++ b/src/autoskillit/server/tools/tools_fleet_dispatch/_handlers.py @@ -199,33 +199,11 @@ def _resolve_prior_or_skipped_dispatch( effective_name: str, skip_when: str | None, ingredients: dict[str, str] | None, -) -> str | None: +) -> str | DispatchRecord | None: if campaign_path: prior_record = find_completed_dispatch(Path(campaign_path), effective_name) if prior_record is not None: - identities: dict[str, object] = { - "dispatch_id": prior_record.dispatch_id, - "dispatched_session_id": prior_record.dispatched_session_id, - } - provenance.confirm( - DispatchEffectName.PRIOR_DISPATCH_BINDING, - receipt="campaign state reported prior success", - retry_relevant=False, - identities=identities, - ) - provenance.confirm( - DispatchEffectName.COMMIT, - receipt="reused committed campaign dispatch", - identities=identities, - ) - return DispatchCompleted( - success=True, - dispatch_status=DispatchStatus.SUCCESS, - dispatch_id=prior_record.dispatch_id, - dispatched_session_id=prior_record.dispatched_session_id, - reason="prior dispatch already succeeded", - effect_provenance=provenance.snapshot(), - ).to_envelope() + return prior_record if skip_when: dispatches_dir = tool_ctx.temp_dir / "dispatches" @@ -252,7 +230,6 @@ def _resolve_prior_or_skipped_dispatch( ) _confirm_campaign_state_write(provenance, campaign_path, effective_name) return DispatchCompleted( - success=False, dispatch_status=DispatchStatus.SKIPPED, dispatch_id="", dispatched_session_id="", @@ -308,6 +285,7 @@ def _prepare_dispatch( effective_name: str, resume_session_id: str | None, prior_dispatch_id: str | None, + prepare_managed_join: bool = True, ) -> ( tuple[ dict[str, str] | None, @@ -340,12 +318,16 @@ def _prepare_dispatch( FleetErrorCode.FLEET_INVALID_BACKEND, "Fleet dispatch requires a configured backend.", ) - managed_join_parent_id, adaptation_context = _prepare_managed_join( - tool_ctx, - effective_backend, - effective_name, - resume_session_id, - prior_dispatch_id, + managed_join_parent_id, adaptation_context = ( + _prepare_managed_join( + tool_ctx, + effective_backend, + effective_name, + resume_session_id, + prior_dispatch_id, + ) + if prepare_managed_join + else (None, None) ) return effective_backend_map, effective_backend, managed_join_parent_id, adaptation_context @@ -358,8 +340,20 @@ def _finish_dispatch( effective_name: str, continue_on_failure: bool, dispatch_name: str | None, + prior_success_candidate: DispatchRecord | None = None, ) -> str: - if campaign_path and isinstance(result, DispatchResult): + rejected_historical_success = ( + isinstance(result, DispatchResult) + and isinstance(result.outcome, DispatchRejected) + and result.outcome.error_code == FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + and bool(result.outcome.dispatch_id) + ) + if ( + campaign_path + and prior_success_candidate is None + and not rejected_historical_success + and isinstance(result, DispatchResult) + ): provenance.start( DispatchEffectName.CAMPAIGN_STATE_WRITE, identities={"campaign_state_path": campaign_path}, @@ -454,7 +448,6 @@ async def _run_dispatch_with_timeout( effect_provenance=snapshot, ).to_envelope() return DispatchCompleted( - success=False, dispatch_status=DispatchStatus.INTERRUPTED, dispatch_id=identities.get("dispatch_id", ""), dispatched_session_id=identities.get("dispatched_session_id", ""), @@ -600,8 +593,11 @@ async def dispatch_food_truck( skip_when, ingredients, ) - if prior_or_skip is not None: + if isinstance(prior_or_skip, str): return prior_or_skip + prior_success_candidate = ( + prior_or_skip if isinstance(prior_or_skip, DispatchRecord) else None + ) prepared = _prepare_dispatch( tool_ctx, @@ -611,6 +607,7 @@ async def dispatch_food_truck( effective_name, resume_session_id, prior_dispatch_id, + prepare_managed_join=prior_success_candidate is None, ) if isinstance(prepared, str): return prepared @@ -658,6 +655,7 @@ async def dispatch_food_truck( native_shell_capture_mode=native_shell_capture_mode, managed_join_parent_id=managed_join_parent_id, adaptation_context=adaptation_context, + prior_success_candidate=prior_success_candidate, ), ) if isinstance(result, str): @@ -670,6 +668,7 @@ async def dispatch_food_truck( effective_name, continue_on_failure, dispatch_name, + prior_success_candidate=prior_success_candidate, ) except Exception as exc: logger.error("dispatch_food_truck unhandled exception", exc_info=True) diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 9ea1ef46bf..59fc25e3ee 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -1025,7 +1025,7 @@ class AmbientEnvDisposition: "Excludes _HEADLESS_EXCLUSIVE_VARS before build_agent_env layers extras back in for a" "headless Claude Code skill-session launch." ), - "execution/backends/_claude/session_commands.py:325": ( + "execution/backends/_claude/session_commands.py:326": ( "Excludes _HEADLESS_EXCLUSIVE_VARS before build_agent_env layers extras back in for a" "headless Claude Code food-truck orchestrator-session launch." ), diff --git a/tests/_hook_protocol_oracle.py b/tests/_hook_protocol_oracle.py index 3a1328c4ac..f3fd523aa0 100644 --- a/tests/_hook_protocol_oracle.py +++ b/tests/_hook_protocol_oracle.py @@ -75,6 +75,7 @@ class HookVerdict(NamedTuple): KNOWN_UNSUPPORTED_CHANNELS = frozenset( { ("codex", "rewrite_mcp_output", "PostToolUse"), + ("codex", "block", "PreCompact"), ("claude", "halt", "PreCompact"), } ) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 11282366ef..5e6142f2ce 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -1420,6 +1420,8 @@ class SourceMapRejection(enum.StrEnum): "skills/test_phoropter_structural.py", "skills/test_compose_pr_retry.py", "smoke_utils", + "fleet/test_dispatch_outcome_classifier.py", + "server/test_tools_dispatch_halt.py", } ), "migration": frozenset( diff --git a/tests/cli/test_cli_prompts.py b/tests/cli/test_cli_prompts.py index 532574842e..c778456729 100755 --- a/tests/cli/test_cli_prompts.py +++ b/tests/cli/test_cli_prompts.py @@ -768,6 +768,29 @@ def test_fleet_dispatch_infrastructure_section_uses_provenance_disposition(): assert "[reconcile-ambiguity]" in section +def test_fleet_dispatch_context_exhaustion_is_terminal_and_reported(): + from autoskillit.cli.prompts._prompts import CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + + prompt = _build_fleet_dispatch_prompt(mcp_prefix="mcp__autoskillit__") + lower_prompt = prompt.lower() + + assert CONTEXT_EXHAUSTION_RECOVERY_CLAUSE in prompt + assert "fleet_l3_context_exhausted" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "do not resume or retry" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE.lower() + assert "dispatched_session_id" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "resume_session_id" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "dispatch_name" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "reset" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE.lower() + assert "retry_disposition" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "continue_on_failure" in CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + assert "report" in lower_prompt + assert "terminal" in lower_prompt + + start = prompt.index("## INFRASTRUCTURE FAILURE RECOVERY") + section = prompt[start:] + assert "fleet_l3_context_exhausted" not in section + + def test_campaign_prompt_includes_infrastructure_failure_section(): """T4: Campaign prompt has an INFRASTRUCTURE FAILURE section.""" from autoskillit.cli.prompts import _build_fleet_campaign_prompt @@ -842,6 +865,39 @@ def test_campaign_prompt_infrastructure_section_uses_provenance_disposition(): assert "continue_on_failure=False" in section +def test_campaign_context_exhaustion_uses_shared_clause_and_campaign_policy(): + from autoskillit.cli.prompts import _build_fleet_campaign_prompt + from autoskillit.cli.prompts._prompts import CONTEXT_EXHAUSTION_RECOVERY_CLAUSE + from autoskillit.recipe.schema import CampaignDispatch, Recipe, RecipeKind + + recipe = Recipe( + name="context-campaign", + description="test context exhaustion handling", + kind=RecipeKind.CAMPAIGN, + dispatches=[CampaignDispatch(name="d1", recipe="impl", task="do it")], + continue_on_failure=True, + ) + prompt = _build_fleet_campaign_prompt( + campaign_recipe=recipe, + manifest_yaml="dispatches:\n - name: d1", + completed_dispatches="", + mcp_prefix="mcp__autoskillit__", + campaign_id="context-campaign-123", + ) + lower_prompt = prompt.lower() + + assert CONTEXT_EXHAUSTION_RECOVERY_CLAUSE in prompt + assert "fleet_l3_context_exhausted" in prompt + assert "do not resume or retry" in prompt.lower() + assert "continue_on_failure" in prompt + assert "proceed to other dispatches" in lower_prompt + assert "halt" in lower_prompt + + start = prompt.index("## INFRASTRUCTURE FAILURE RECOVERY") + section = prompt[start:] + assert "fleet_l3_context_exhausted" not in section + + def test_campaign_prompt_includes_gate_dispatch_handling_section(): """Campaign prompt includes GATE DISPATCH HANDLING section when a gate dispatch is present.""" from autoskillit.cli.prompts import _build_fleet_campaign_prompt diff --git a/tests/cli/test_fleet_campaign.py b/tests/cli/test_fleet_campaign.py index dfbd0b7d5b..2097fe4637 100644 --- a/tests/cli/test_fleet_campaign.py +++ b/tests/cli/test_fleet_campaign.py @@ -86,8 +86,10 @@ def test_fleet_run_resume_reads_existing_state( assert captured["env"]["AUTOSKILLIT_CAMPAIGN_ID"] == campaign_id -def test_fleet_run_exit_code_passthrough(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """fleet run propagates subprocess exit code.""" +def test_fleet_run_exits_1_when_campaign_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """fleet run exits 1 when the campaign subprocess reports failure.""" import subprocess _stub_guards(monkeypatch) @@ -97,11 +99,11 @@ def test_fleet_run_exit_code_passthrough(monkeypatch: pytest.MonkeyPatch, tmp_pa monkeypatch.setattr( subprocess, "Popen", - lambda *a, **kw: InteractiveProcessStub(2), + lambda *a, **kw: InteractiveProcessStub(1), ) with pytest.raises(SystemExit) as exc_info: _fleet_campaign("test-campaign") - assert exc_info.value.code == 2 + assert exc_info.value.code == 1 def test_fleet_campaign_exits_when_disabled( diff --git a/tests/cli/test_fleet_run.py b/tests/cli/test_fleet_run.py index 4d12ea5724..be309e64f7 100644 --- a/tests/cli/test_fleet_run.py +++ b/tests/cli/test_fleet_run.py @@ -51,7 +51,6 @@ def _make_test_config( def _mock_success_result(dispatch_id="test-dispatch-001", session_id="test-session-001"): return DispatchResult( outcome=DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id=dispatch_id, dispatched_session_id=session_id, @@ -64,7 +63,6 @@ def _mock_success_result(dispatch_id="test-dispatch-001", session_id="test-sessi def _mock_failure_result(status=DispatchStatus.FAILURE): return DispatchResult( outcome=DispatchCompleted( - success=False, dispatch_status=status, dispatch_id="test-dispatch-002", dispatched_session_id="test-session-002", diff --git a/tests/cli/test_fleet_run_admission.py b/tests/cli/test_fleet_run_admission.py index 4a524d2ef3..8e4ed1579e 100644 --- a/tests/cli/test_fleet_run_admission.py +++ b/tests/cli/test_fleet_run_admission.py @@ -40,7 +40,6 @@ def _make_success_result() -> DispatchResult: return DispatchResult( outcome=DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-dispatch", dispatched_session_id="test-session", diff --git a/tests/cli/test_orchestrator_prompt_contract.py b/tests/cli/test_orchestrator_prompt_contract.py index 6cf2f15fda..b2cb7d2dc2 100644 --- a/tests/cli/test_orchestrator_prompt_contract.py +++ b/tests/cli/test_orchestrator_prompt_contract.py @@ -328,3 +328,39 @@ def test_food_truck_prompt_passes_overrides_to_open_kitchen(): "Food truck prompt does not pass overrides to open_kitchen. " "Steps guarded by skip_when_false may be irreversibly pruned using defaults." ) + + +def test_food_truck_prompt_declares_success_reasons_and_matches_identity_failures(): + from autoskillit.core.types.launch._type_dispatch_identity import _build_sentinel_contract + from autoskillit.fleet._prompts import _build_food_truck_prompt + + prompt = _build_food_truck_prompt( + recipe="implementation", + task="test-task", + ingredients={}, + mcp_prefix="mcp__autoskillit__", + dispatch_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + campaign_id="test-campaign-id", + l3_timeout_sec=600, + success_reasons=["z_recipe_complete", "a_recipe_complete"], + ) + identity_contract = _build_sentinel_contract( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "aaaaaaaa", + ) + + assert 'Declared success reasons: ["a_recipe_complete", "z_recipe_complete"]' in prompt + assert "Do not substitute a generic reason such as 'completed'." in prompt + assert 'reason: "completed"' not in prompt + + for failure_reason in ( + "fleet_quota_exhausted", + "timeout", + "open_kitchen_failed", + "missing_on_failure", + "degraded_tool_response", + ): + assert f'"{failure_reason}"' in prompt + assert f'"{failure_reason}"' in identity_contract + assert '"quota_exhausted"' not in prompt + assert '"quota_exhausted"' not in identity_contract diff --git a/tests/contracts/test_run_skill_result_parity.py b/tests/contracts/test_run_skill_result_parity.py index 2a24462d28..d2ad969ba2 100644 --- a/tests/contracts/test_run_skill_result_parity.py +++ b/tests/contracts/test_run_skill_result_parity.py @@ -48,6 +48,7 @@ "api_failure": "flattened into api_error_*, api_terminal_reason, and rate_limit_* keys", "api_retry": "flattened into api_retry_* keys", "audit": "flattened into audit_* keys", + "compaction_attempted": "live-only fleet evidence, excluded from the public envelope", "contamination": "flattened into pre_contamination_* keys", "evidence": "flattened into write and progress-evidence keys", "infra": "flattened into infra_* keys", diff --git a/tests/contracts/test_skill_result_persistence_ledger.py b/tests/contracts/test_skill_result_persistence_ledger.py index 7dfd255051..2ba19ad33c 100644 --- a/tests/contracts/test_skill_result_persistence_ledger.py +++ b/tests/contracts/test_skill_result_persistence_ledger.py @@ -52,6 +52,7 @@ ("branch_name", "branch_name", "live-only:ephemeral-path"), ("candidate_exhausted", "candidate_exhausted", "live-only:derived-elsewhere"), ("cli_subtype", "cli_subtype", "persisted"), + ("compaction_attempted", "compaction_attempted", "live-only:derived-elsewhere"), ("completion_required", "completion_required", "live-only:derived-elsewhere"), ("contamination.retry_reason", "contamination_retry_reason", "live-only:derived-elsewhere"), ("contamination.subtype", "contamination_subtype", "live-only:derived-elsewhere"), diff --git a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json index e8305b9b3f..490171a90b 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json +++ b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json @@ -1,5 +1,5 @@ { - "_registry_hash": "10ac81e11414abf31fe96804f89369c2eeb1bf0ba91d2baf1852ad1013013925", + "_registry_hash": "6996ff84118019160a4dc935e3477dabb3c31b05fa7caf5af604040d1ced2024", "hooks": { "PostToolUse": [ { diff --git a/tests/execution/backends/test_child_task_transcripts.py b/tests/execution/backends/test_child_task_transcripts.py index 4cd47533fe..d5a27e76cd 100644 --- a/tests/execution/backends/test_child_task_transcripts.py +++ b/tests/execution/backends/test_child_task_transcripts.py @@ -488,6 +488,9 @@ def read_child_task(self, child_id: str) -> ChildTaskTranscript | None: raise self.error return self.result + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + return False + def _child_view(child_id: str) -> ChildTaskTranscript: return ChildTaskTranscript( diff --git a/tests/execution/backends/test_claude_backend.py b/tests/execution/backends/test_claude_backend.py index 47be0b7a15..420181126d 100644 --- a/tests/execution/backends/test_claude_backend.py +++ b/tests/execution/backends/test_claude_backend.py @@ -272,6 +272,27 @@ def test_claude_food_truck_excludes_scope_discipline(self) -> None: assert CODEX_SCOPE_DISCIPLINE_DIGEST not in self._extract_prompt(spec) +class TestAutoCompactionLaunchEnvironment: + def test_food_truck_forces_auto_compaction_disabled_after_env_overrides( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DISABLE_AUTO_COMPACT", "0") + spec = ClaudeCodeBackend().build_food_truck_cmd( + **FOOD_TRUCK_BASE, + env_extras={"DISABLE_AUTO_COMPACT": "0"}, + ) + + assert spec.env["DISABLE_AUTO_COMPACT"] == "1" + + def test_skill_worker_does_not_add_auto_compaction_override( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("DISABLE_AUTO_COMPACT", raising=False) + spec = ClaudeCodeBackend().build_skill_session_cmd("/plan", **SKILL_BASE) + + assert "DISABLE_AUTO_COMPACT" not in spec.env + + class TestBuildSkillSessionCmdConfigAdapter: def test_config_adapter_matches_impl(self) -> None: backend = ClaudeCodeBackend() diff --git a/tests/execution/backends/test_claude_session_locator.py b/tests/execution/backends/test_claude_session_locator.py index 32fcb5b1e0..7d77ce5eb4 100644 --- a/tests/execution/backends/test_claude_session_locator.py +++ b/tests/execution/backends/test_claude_session_locator.py @@ -21,6 +21,127 @@ class TestClaudeSessionLocator: + @pytest.mark.parametrize( + "record", + [ + pytest.param( + {"type": "system", "subtype": "compact_boundary"}, + id="synthetic-without-optional-metadata", + ), + pytest.param( + { + "type": "system", + "subtype": "compact_boundary", + "renamed_compaction_metadata": {"synthetic": True}, + }, + id="synthetic-with-renamed-optional-metadata", + ), + ], + ) + def test_session_context_was_compacted_matches_exact_pair_without_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + record: dict[str, object], + ) -> None: + """Synthetic JSONL records need only the exact top-level type/subtype pair.""" + transcript = tmp_path / "synthetic-compact-boundary.jsonl" + transcript.write_text(json.dumps(record) + "\n", encoding="utf-8") + monkeypatch.setattr( + ClaudeSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript, + ) + + assert ClaudeSessionLocator().session_context_was_compacted("/project", "session") + + def test_session_context_was_compacted_ignores_misleading_and_malformed_candidates( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Synthetic malformed, non-object, nested, and substring candidates are ignored.""" + candidates = [ + "not JSON", + '{"subtype":"compact_boundary",', + json.dumps(None), + json.dumps([]), + json.dumps({"type": "systemish", "subtype": "compact_boundary"}), + json.dumps({"type": "system", "subtype": "pre_compact_boundary"}), + json.dumps({"nested": {"type": "system", "subtype": "compact_boundary"}}), + ] + transcript = tmp_path / "synthetic-non-boundary-candidates.jsonl" + transcript.write_text("\n".join(candidates) + "\n", encoding="utf-8") + monkeypatch.setattr( + ClaudeSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript, + ) + + assert not ClaudeSessionLocator().session_context_was_compacted("/project", "session") + + @pytest.mark.parametrize("transcript_kind", ["unresolved", "missing", "clean"]) + def test_session_context_was_compacted_returns_false_for_missing_or_clean_transcripts( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + transcript_kind: str, + ) -> None: + if transcript_kind == "unresolved": + transcript_path = None + elif transcript_kind == "missing": + transcript_path = tmp_path / "missing-synthetic-transcript.jsonl" + else: + transcript_path = tmp_path / "synthetic-clean-transcript.jsonl" + transcript_path.write_text( + json.dumps({"type": "assistant", "text": "ordinary response"}) + "\n", + encoding="utf-8", + ) + monkeypatch.setattr( + ClaudeSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript_path, + ) + + assert not ClaudeSessionLocator().session_context_was_compacted("/project", "session") + + def test_session_context_was_compacted_propagates_unreadable_transcript( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + transcript = tmp_path / "synthetic-unreadable-transcript.jsonl" + transcript.write_text( + json.dumps({"type": "system", "subtype": "compact_boundary"}) + "\n", + encoding="utf-8", + ) + original_open = Path.open + + def _raise_permission_error(path: Path, *args, **kwargs): + if path == transcript: + raise PermissionError("synthetic transcript unreadable") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", _raise_permission_error) + monkeypatch.setattr( + ClaudeSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript, + ) + + with pytest.raises(PermissionError, match="synthetic transcript unreadable"): + ClaudeSessionLocator().session_context_was_compacted("/project", "session") + + def test_session_context_was_compacted_propagates_invalid_utf8( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + transcript = tmp_path / "synthetic-invalid-utf8-transcript.jsonl" + transcript.write_bytes(b"\xff\n") + monkeypatch.setattr( + ClaudeSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript, + ) + + with pytest.raises(UnicodeDecodeError): + ClaudeSessionLocator().session_context_was_compacted("/project", "session") + def test_list_sessions_translates_native_index_and_normalizes_cwd( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 623c33f5ce..6df117923f 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -210,8 +210,8 @@ def test_capabilities_food_truck_true(self) -> None: def test_capabilities_triage_capable_false(self) -> None: assert CodexBackend().capabilities.triage_capable is False - def test_capabilities_supports_context_exhaustion_detection_false(self) -> None: - assert CodexBackend().capabilities.supports_context_exhaustion_detection is False + def test_capabilities_supports_context_exhaustion_detection_true(self) -> None: + assert CodexBackend().capabilities.supports_context_exhaustion_detection is True def test_capabilities_supports_model_capacity_error_detection_true(self) -> None: assert CodexBackend().capabilities.supports_model_capacity_error_detection is True diff --git a/tests/execution/backends/test_codex_session_locator.py b/tests/execution/backends/test_codex_session_locator.py index d914c243c1..cc53511280 100644 --- a/tests/execution/backends/test_codex_session_locator.py +++ b/tests/execution/backends/test_codex_session_locator.py @@ -257,6 +257,25 @@ def test_read_session_decompresses_and_parses_ndjson(self, tmp_path: Path) -> No def test_isinstance_session_locator(self) -> None: assert isinstance(CodexSessionLocator(), SessionLocator) + def test_session_context_was_compacted_returns_false_for_synthetic_boundary( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Codex's synchronous veto precedes replacement, so the transcript is never + legacy-compacted. + """ + transcript = tmp_path / "synthetic-codex-compact-boundary.jsonl" + transcript.write_text( + json.dumps({"type": "system", "subtype": "compact_boundary"}) + "\n", + encoding="utf-8", + ) + monkeypatch.setattr( + CodexSessionLocator, + "session_log_path", + lambda _self, _cwd, _session_id: transcript, + ) + + assert not CodexSessionLocator().session_context_was_compacted("/project", "thread") + def test_locate_session_finds_session_meta_format(self, tmp_path: Path) -> None: session_dir = tmp_path / "codex-sessions" / "2026" / "05" / "26" session_dir.mkdir(parents=True) diff --git a/tests/execution/backends/test_codex_stream_parser.py b/tests/execution/backends/test_codex_stream_parser.py index bba33383ba..f13fbdd4e5 100644 --- a/tests/execution/backends/test_codex_stream_parser.py +++ b/tests/execution/backends/test_codex_stream_parser.py @@ -13,6 +13,7 @@ CodexItemType, StreamParser, ) +from autoskillit.core.types import CODEX_CONTEXT_EXHAUSTION_MARKER from autoskillit.execution.backends._codex.app_server_events import _app_server_to_exec_event from autoskillit.execution.backends._codex_parse import ( CodexStreamParser, @@ -713,6 +714,103 @@ def test_turn_completed_status_failed_or_interrupted_maps_to_turn_failed( assert adapted is not None assert adapted["type"] == "turn.failed" + @pytest.mark.parametrize( + ("method", "params", "expected"), + [ + ( + "turn/completed", + { + "turn": { + "status": "failed", + "error": { + "message": "synthetic context failure", + "codexErrorInfo": "contextWindowExceeded", + }, + } + }, + { + "type": "turn.failed", + "error": { + "message": "synthetic context failure", + "code": CODEX_CONTEXT_EXHAUSTION_MARKER, + }, + }, + ), + ( + "error", + { + "willRetry": False, + "error": { + "message": "synthetic context failure", + "codexErrorInfo": "contextWindowExceeded", + }, + }, + { + "type": "error", + "message": "synthetic context failure", + "code": CODEX_CONTEXT_EXHAUSTION_MARKER, + }, + ), + ], + ids=["turn-completed-failed", "terminal-error"], + ) + def test_context_window_exceeded_metadata_maps_to_codex_context_code( + self, method: str, params: dict[str, object], expected: dict[str, object] + ) -> None: + """Synthetic app-server v2 events preserve the message and normalize the scalar code.""" + adapted = _app_server_to_exec_event({"method": method, "params": params}) + + assert adapted == expected + + @pytest.mark.parametrize( + ("error", "expected_code"), + [ + ( + { + "message": "synthetic unrelated failure", + "code": "E42", + "codexErrorInfo": "notContextWindowExceeded", + }, + "E42", + ), + ( + {"message": "native context failure", "code": "context_length_exceeded"}, + "context_length_exceeded", + ), + ], + ids=["non-context-codex-error-info", "native-context-length-code"], + ) + def test_context_code_normalization_preserves_other_native_codes( + self, error: dict[str, str], expected_code: str + ) -> None: + """Only the exact Codex app-server context enum overrides the native code.""" + adapted = _app_server_to_exec_event( + {"method": "error", "params": {"willRetry": False, "error": error}} + ) + + assert adapted == { + "type": "error", + "message": error["message"], + "code": expected_code, + } + + def test_context_error_with_will_retry_remains_nonterminal(self) -> None: + """Retryable app-server errors stay nonterminal even when context-coded.""" + adapted = _app_server_to_exec_event( + { + "method": "error", + "params": { + "willRetry": True, + "error": { + "message": "synthetic retryable failure", + "codexErrorInfo": "contextWindowExceeded", + }, + }, + } + ) + + assert adapted == {"type": "item.started"} + def test_error_will_retry_true_maps_to_ignored_item_started_equivalent(self) -> None: adapted = _app_server_to_exec_event( { diff --git a/tests/execution/backends/test_composite_session_locator.py b/tests/execution/backends/test_composite_session_locator.py index 662b4a82b9..77df41a462 100644 --- a/tests/execution/backends/test_composite_session_locator.py +++ b/tests/execution/backends/test_composite_session_locator.py @@ -25,10 +25,14 @@ def __init__( locate_result: Path | None = None, log_dir: Path | None = None, summaries: tuple[SessionSummary, ...] = (), + context_compacted: bool = False, + compaction_error: Exception | None = None, ): self._locate = locate_result self._log_dir = log_dir or Path("/stub") self._summaries = summaries + self._context_compacted = context_compacted + self._compaction_error = compaction_error def locate_session(self, session_id: str) -> Path | None: return self._locate @@ -45,6 +49,11 @@ def read_child_task(self, child_id: str) -> None: def list_sessions(self, cwd: str) -> tuple[SessionSummary, ...]: return self._summaries + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + if self._compaction_error is not None: + raise self._compaction_error + return self._context_compacted + def _summary(backend_name: str, session_id: str, cwd: str) -> SessionSummary: return SessionSummary( @@ -100,6 +109,9 @@ def session_log_path(self, cwd, session_id): def read_child_task(self, child_id): return None + def session_context_was_compacted(self, cwd, session_id): + return False + hit = Path("/found/session.jsonl") registry = { "broken": _fake_backend(_FailLocator()), @@ -111,6 +123,34 @@ def read_child_task(self, child_id): assert CompositeSessionLocator().locate_session("sid-1") == hit + def test_context_compaction_uses_first_locator_that_resolves(self): + unresolved = _StubLocator( + context_compacted=True, + compaction_error=AssertionError("unresolved locator must not be queried"), + ) + first_resolved = _StubLocator(Path("/first/session.jsonl"), context_compacted=False) + later_resolved = _StubLocator(Path("/later/session.jsonl"), context_compacted=True) + locator = CompositeSessionLocator((unresolved, first_resolved, later_resolved)) + + assert not locator.session_context_was_compacted("/project", "sid-1") + + def test_context_compaction_preserves_first_resolved_locator_error(self): + first_resolved = _StubLocator( + Path("/unreadable/session.jsonl"), + context_compacted=True, + compaction_error=OSError("transcript unreadable"), + ) + later_resolved = _StubLocator(Path("/later/session.jsonl"), context_compacted=True) + locator = CompositeSessionLocator((first_resolved, later_resolved)) + + with pytest.raises(OSError, match="transcript unreadable"): + locator.session_context_was_compacted("/project", "sid-1") + + def test_context_compaction_returns_false_when_no_locator_resolves(self): + locator = CompositeSessionLocator((_StubLocator(None, context_compacted=True),)) + + assert not locator.session_context_was_compacted("/project", "sid-1") + class TestProjectLogDirFor: def test_dispatches_by_name(self, monkeypatch): diff --git a/tests/execution/fixtures/claude_context_limit_2_1_285.jsonl b/tests/execution/fixtures/claude_context_limit_2_1_285.jsonl new file mode 100644 index 0000000000..33a1782394 --- /dev/null +++ b/tests/execution/fixtures/claude_context_limit_2_1_285.jsonl @@ -0,0 +1,2 @@ +{"type":"assistant","message":{"stop_reason":"stop_sequence","type":"message","content":[{"type":"text","text":"Prompt is too long"}]},"session_id":"6708fcd2-c5ae-49a8-afe0-cf87dbbe2c68","error":"invalid_request"} +{"stop_reason":"stop_sequence","session_id":"6708fcd2-c5ae-49a8-afe0-cf87dbbe2c68","terminal_reason":"prompt_too_long","is_error":true,"subtype":"success","result":"Prompt is too long","type":"result","duration_ms":331} diff --git a/tests/execution/fixtures/claude_context_limit_legacy_control.jsonl b/tests/execution/fixtures/claude_context_limit_legacy_control.jsonl new file mode 100644 index 0000000000..41d8ababc6 --- /dev/null +++ b/tests/execution/fixtures/claude_context_limit_legacy_control.jsonl @@ -0,0 +1,2 @@ +{"type":"assistant","error":"invalid_request","isApiErrorMessage":null,"session_id":"b1f04567-5914-45d3-abb0-870a930fc0a8","parent_tool_use_id":null,"message":{"stop_reason":"stop_sequence","model":"","content":[{"type":"text","text":"Prompt is too long"}],"usage":{"input_tokens":0,"output_tokens":0}}} +{"duration_api_ms":24518,"stop_reason":"stop_sequence","session_id":"b1f04567-5914-45d3-abb0-870a930fc0a8","total_cost_usd":0.4122891,"terminal_reason":"blocking_limit","fast_mode_state":"off","fast_mode_disabled_reason":"disabled_by_env","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":true,"num_turns":11,"subtype":"success","api_error_status":null,"result":"Prompt is too long","type":"result","duration_ms":26339,"uuid":"b5cb0571-aee4-488c-ac9e-c434d23e735d","queued_turn_count":0,"result_index":0} diff --git a/tests/execution/test_exit_classification.py b/tests/execution/test_exit_classification.py index e66127d256..bcf0160e95 100644 --- a/tests/execution/test_exit_classification.py +++ b/tests/execution/test_exit_classification.py @@ -74,6 +74,24 @@ def _turn_failed_ndjson(error_message: str, *, error_code: str = "") -> str: return json.dumps({"type": "turn.failed", "error": error}) +def _app_server_context_exhaustion_payload() -> str: + """Synthetic Codex app-server v2 failure used to exercise native enum normalization.""" + return json.dumps( + { + "method": "turn/completed", + "params": { + "turn": { + "status": "failed", + "error": { + "message": "synthetic app-server context failure", + "codexErrorInfo": "contextWindowExceeded", + }, + } + }, + } + ) + + class TestClassifyInfraExit: def test_context_exhausted_from_jsonl_flag(self): """jsonl_context_exhausted=True → CONTEXT_EXHAUSTED.""" @@ -607,6 +625,35 @@ def test_turn_failed_context_length_exceeded_classify_returns_context_exhausted( == InfraExitCategory.CONTEXT_EXHAUSTED ) + def test_app_server_context_window_exceeded_reaches_infra_classifier(self) -> None: + agent_result = CodexResultParser().parse_stdout( + _app_server_context_exhaustion_payload(), exit_code=1 + ) + adapted = _adapt_agent_result(agent_result) + + assert adapted._is_context_exhausted() is True + assert ( + classify_infra_exit( + adapted, + _sr(returncode=1), + capabilities=CodexBackend().capabilities, + ) + == InfraExitCategory.CONTEXT_EXHAUSTED + ) + + def test_app_server_context_window_exceeded_remains_capability_gated(self) -> None: + agent_result = CodexResultParser().parse_stdout( + _app_server_context_exhaustion_payload(), exit_code=1 + ) + adapted = _adapt_agent_result(agent_result) + capabilities = BackendCapabilities(supports_context_exhaustion_detection=False) + + assert adapted._is_context_exhausted() is True + assert ( + classify_infra_exit(adapted, _sr(returncode=1), capabilities=capabilities) + != InfraExitCategory.CONTEXT_EXHAUSTED + ) + def test_turn_failed_rate_limit_exceeded_jsonl_context_exhausted_false(self) -> None: ndjson = _turn_failed_ndjson("rate_limit_exceeded") agent_result = CodexResultParser().parse_stdout(ndjson) diff --git a/tests/execution/test_headless_result.py b/tests/execution/test_headless_result.py index 3738c49831..55e98940aa 100644 --- a/tests/execution/test_headless_result.py +++ b/tests/execution/test_headless_result.py @@ -40,6 +40,7 @@ _stdout_mentions_write_tools, ) from autoskillit.execution.session import ClaudeSessionResult +from autoskillit.execution.session._exit_classification import classify_infra_exit from autoskillit.execution.session._session_outcome import _compute_outcome from autoskillit.execution.session.turn_usage import build_turn_token_entry from tests.execution.conftest import ( @@ -503,6 +504,67 @@ def test_idle_stall_failure_preserves_lifespan_started_false(self): assert skill_result.lifespan_started is False +class TestCompactionAttemptedPropagation: + @staticmethod + def _compacting_status() -> str: + return json.dumps({"type": "system", "subtype": "status", "status": "compacting"}) + + @classmethod + def _stdout(cls) -> str: + return cls._compacting_status() + "\n" + _success_result_json() + + def test_normal_result_propagates_compaction_without_changing_success(self) -> None: + result = _sr(0, self._stdout(), "", TerminationReason.NATURAL_EXIT) + + skill_result = _build_skill_result(result, backend=ClaudeCodeBackend()) + + assert skill_result.compaction_attempted is True + assert skill_result.success is True + assert skill_result.needs_retry is False + assert skill_result.retry_reason is RetryReason.NONE + + def test_terminated_result_propagates_compaction_and_stale_retry_reason(self) -> None: + result = _stale_result(stdout=self._compacting_status()) + + skill_result = _build_skill_result(result, backend=ClaudeCodeBackend()) + + assert skill_result.compaction_attempted is True + assert skill_result.success is False + assert skill_result.needs_retry is True + assert skill_result.retry_reason is RetryReason.STALE + + +class TestClaudeNativeContextLimitHeadlessParsing: + @pytest.mark.parametrize( + ("fixture_name", "terminal_reason"), + [ + ("claude_context_limit_legacy_control.jsonl", "blocking_limit"), + ("claude_context_limit_2_1_285.jsonl", "prompt_too_long"), + ], + ids=["legacy-blocking-limit", "claude-code-2.1.285-prompt-too-long"], + ) + def test_headless_parse_path_classifies_observed_limit_as_context_exhausted( + self, fixture_name: str, terminal_reason: str + ) -> None: + """Reduced legacy and 2.1.285 parser shapes use the Claude headless parse path.""" + stdout = (Path(__file__).parent / "fixtures" / fixture_name).read_text() + backend = ClaudeCodeBackend() + subprocess_result = _sr(1, stdout, "", TerminationReason.NATURAL_EXIT) + + session = _parse_stdout(subprocess_result, backend=backend) + + assert session.terminal_reason == terminal_reason + assert session._is_context_exhausted() is True + assert ( + classify_infra_exit( + session, + subprocess_result, + capabilities=backend.capabilities, + ) + == InfraExitCategory.CONTEXT_EXHAUSTED + ) + + class TestKillReasonPropagation: def test_stale_failure_propagates_infra_kill(self): result = _stale_result(kill_reason=KillReason.INFRA_KILL) diff --git a/tests/execution/test_session_log_fields.py b/tests/execution/test_session_log_fields.py index 4558bc6059..951930d53b 100644 --- a/tests/execution/test_session_log_fields.py +++ b/tests/execution/test_session_log_fields.py @@ -52,6 +52,9 @@ def session_log_path(self, cwd: str, session_id: str) -> Path | None: def read_child_task(self, child_id: str) -> None: return None + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + return False + def list_sessions(self, cwd: str) -> tuple: return () diff --git a/tests/execution/test_session_parsing.py b/tests/execution/test_session_parsing.py index 08043c4cdc..46f11cf2c7 100644 --- a/tests/execution/test_session_parsing.py +++ b/tests/execution/test_session_parsing.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path import pytest import structlog @@ -809,6 +810,12 @@ def test_to_json_produces_all_required_keys(self): } assert set(parsed.keys()) == expected + def test_compaction_attempted_is_not_part_of_public_json(self): + sr = self._make(compaction_attempted=True) + + assert sr.compaction_attempted is True + assert "compaction_attempted" not in json.loads(sr.to_json()) + def test_to_json_retry_reason_serializes_as_string(self): sr = self._make(needs_retry=True, retry_reason=RetryReason.RESUME) parsed = json.loads(sr.to_json()) @@ -980,6 +987,64 @@ def test_context_exhaustion_via_flat_record_without_result(self): assert len(session.tool_uses) == 1 +class TestClaudeNativeContextLimitParsing: + # Both inputs are reduced parser shapes, not full raw stream records. The + # legacy shape comes from authentic raw lines 47-48 in + # probe_5272/out_a0_autocompact_off_blocking_limit/stdout.jsonl; that run + # does not record a CLI version. The 2.1.285 shape is minimized from the + # latest native output projection, f52c43adb6d51bbe0f42f0a4, on guard + # SHA 9dbfaef8ad508c6b11a5df096b82f268a22d1d19d050c8efe691d3b2c477b4a4. + @pytest.mark.parametrize( + ("fixture_name", "terminal_reason"), + [ + ("claude_context_limit_legacy_control.jsonl", "blocking_limit"), + ("claude_context_limit_2_1_285.jsonl", "prompt_too_long"), + ], + ids=["legacy-blocking-limit", "claude-code-2.1.285-prompt-too-long"], + ) + def test_observed_result_terminal_reason_is_retained_as_context_evidence( + self, fixture_name: str, terminal_reason: str + ) -> None: + """Reduced native-limit shapes retain the terminal reason from each capture.""" + stdout = (Path(__file__).parent / "fixtures" / fixture_name).read_text() + + session = parse_session_result(stdout) + + assert session.terminal_reason == terminal_reason + assert session._is_context_exhausted() is True + + +class TestClaudeCompactionAttemptedParsing: + def test_exact_status_compacting_latches_across_later_success(self) -> None: + """A synthetic native-shaped reset does not clear prior compacting evidence.""" + compacting = json.dumps({"type": "system", "subtype": "status", "status": "compacting"}) + status_reset = json.dumps({"type": "system", "subtype": "status", "status": None}) + completed = json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": "done", + "session_id": "s1", + } + ) + + session = parse_session_result("\n".join((compacting, status_reset, completed))) + + assert session.compaction_attempted is True + assert session.is_error is False + assert session.jsonl_context_exhausted is False + + @pytest.mark.parametrize("status", ["idle", "compacted", "not-compacting"]) + def test_other_system_status_values_do_not_latch(self, status: str) -> None: + """Synthetic non-compacting status values do not create compaction evidence.""" + line = json.dumps({"type": "system", "subtype": "status", "status": status}) + + session = parse_session_result(line) + + assert session.compaction_attempted is False + + # --------------------------------------------------------------------------- # Integration tests: full chain verification # --------------------------------------------------------------------------- diff --git a/tests/execution/test_smoke_codex.py b/tests/execution/test_smoke_codex.py index dd1f72a29d..586c581877 100644 --- a/tests/execution/test_smoke_codex.py +++ b/tests/execution/test_smoke_codex.py @@ -291,6 +291,9 @@ def session_log_path(self, cwd: str, session_id: str) -> Path: def read_child_task(self, child_id: str) -> None: return None + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + return False + def list_sessions(self, cwd: str) -> tuple: return () @@ -333,6 +336,9 @@ def session_log_path(self, cwd: str, session_id: str) -> Path: def read_child_task(self, child_id: str) -> None: return None + def session_context_was_compacted(self, cwd: str, session_id: str) -> bool: + return False + def list_sessions(self, cwd: str) -> tuple: return () diff --git a/tests/fleet/_helpers.py b/tests/fleet/_helpers.py index 47aefaab4b..44989bf053 100644 --- a/tests/fleet/_helpers.py +++ b/tests/fleet/_helpers.py @@ -108,6 +108,40 @@ def _setup_dispatch( tool_ctx.managed_headless_session_lineage_store = DefaultManagedHeadlessSessionLineageStore() +def _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) -> dict[str, Any]: + """Use the bundled implementation recipe and its real loader result in dispatch tests.""" + from autoskillit.recipe.io import builtin_recipes_dir, load_recipe + from autoskillit.recipe.repository import DefaultRecipeRepository + + _setup_dispatch(tool_ctx, monkeypatch, recipe_name="implementation") + repo = tool_ctx.recipes + recipe_info = repo.find("implementation", tool_ctx.project_dir) + assert recipe_info is not None + + recipe_path = builtin_recipes_dir() / "implementation.yaml" + repo.add_full_recipe(recipe_info.path, load_recipe(recipe_path)) + validation_result = DefaultRecipeRepository().load_and_validate( + "implementation", + tool_ctx.project_dir, + temp_dir=tool_ctx.temp_dir, + ingredient_overrides={"task": "dispatch contract regression"}, + ) + error_findings = [ + item + for item in validation_result.get("suggestions", []) + if item.get("severity") == "error" + ] + assert validation_result.get("valid") is True, ( + "Bundled implementation fixture must pass the real recipe loader; " + f"errors={validation_result.get('errors')!r}, findings={error_findings!r}" + ) + declared_reasons = validation_result.get("_dispatch_success_reasons") + assert isinstance(declared_reasons, list) + assert "implementation_complete" in declared_reasons + repo.set_validated("implementation", validation_result) + return validation_result + + async def _run( tool_ctx, recipe: str = "test-recipe", ingredients: dict[str, str] | None = None ) -> dict: @@ -158,11 +192,11 @@ def _make_completed_dirty(): ) -def _make_completed_clean(success: bool, reason: str = ""): +def _make_completed_clean(success: bool, reason: str = "", *, include_reason: bool = False): from autoskillit.fleet.result_parser import L3ParseResult payload: dict = {"success": success} - if reason: + if reason or include_reason: payload["reason"] = reason return L3ParseResult( outcome="completed_clean", @@ -190,6 +224,7 @@ def _mock_backend_with_locator( mock_locator = Mock() if project_log_dir is not None: mock_locator.project_log_dir.return_value = project_log_dir + mock_locator.session_context_was_compacted.return_value = False if session_log_path_side_effect is not None: mock_locator.session_log_path.side_effect = session_log_path_side_effect elif session_log_path is not _SENTINEL: diff --git a/tests/fleet/test_dispatch_backend_override.py b/tests/fleet/test_dispatch_backend_override.py index 84e3f3b0f0..78f3d4d696 100644 --- a/tests/fleet/test_dispatch_backend_override.py +++ b/tests/fleet/test_dispatch_backend_override.py @@ -26,6 +26,7 @@ def _mock_backend(name: str = "claude-code", *, food_truck_capable: bool = True) backend.session_locator.return_value = Mock( project_log_dir=Mock(return_value=Path("/tmp/logs")), session_log_path=Mock(return_value=None), + session_context_was_compacted=Mock(return_value=False), ) backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) backend.build_food_truck_cmd.return_value = Mock( @@ -250,7 +251,7 @@ def test_caller_override_retains_codex_runtime_spec_and_terminal_outcome( ), ) assert status is DispatchStatus.FAILURE - assert reason == FleetErrorCode.FLEET_L3_NO_RESULT_BLOCK + assert reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED @pytest.mark.anyio async def test_dispatch_backend_override_threads_to_executor(self, tool_ctx, monkeypatch): @@ -355,6 +356,7 @@ async def test_dispatch_backend_override_session_locator_uses_dispatch_backend( ctx_locator = M() ctx_locator.project_log_dir.return_value = Path("/claude-logs") ctx_locator.session_log_path.return_value = None + ctx_locator.session_context_was_compacted.return_value = False ctx_backend = _mock_backend("claude-code") ctx_backend.session_locator.return_value = ctx_locator tool_ctx.backend = ctx_backend @@ -362,6 +364,7 @@ async def test_dispatch_backend_override_session_locator_uses_dispatch_backend( dispatch_locator = M() dispatch_locator.project_log_dir.return_value = Path("/codex-logs") dispatch_locator.session_log_path.return_value = None + dispatch_locator.session_context_was_compacted.return_value = False dispatch_be = _mock_backend("codex") dispatch_be.session_locator.return_value = dispatch_locator diff --git a/tests/fleet/test_dispatch_effect_provenance.py b/tests/fleet/test_dispatch_effect_provenance.py index bda2c1b051..2a6dc770b6 100644 --- a/tests/fleet/test_dispatch_effect_provenance.py +++ b/tests/fleet/test_dispatch_effect_provenance.py @@ -167,7 +167,6 @@ def test_every_dispatch_envelope_carries_provenance() -> None: effect_provenance=tracker.snapshot(), ) completed = DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="dispatch-5", dispatched_session_id="session-5", @@ -181,7 +180,6 @@ def test_every_dispatch_envelope_carries_provenance() -> None: def test_failed_completion_preserves_domain_reason_in_error_field() -> None: completed = DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="dispatch-domain-failure", dispatched_session_id="session-domain-failure", @@ -204,7 +202,6 @@ def test_outcome_constructors_require_provenance() -> None: with pytest.raises(TypeError, match="effect_provenance"): DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="dispatch-missing-provenance", dispatched_session_id="", diff --git a/tests/fleet/test_dispatch_envelope_fields.py b/tests/fleet/test_dispatch_envelope_fields.py index 2f409b6f67..371716fc40 100755 --- a/tests/fleet/test_dispatch_envelope_fields.py +++ b/tests/fleet/test_dispatch_envelope_fields.py @@ -20,6 +20,51 @@ pytestmark = [pytest.mark.layer("fleet"), pytest.mark.small, pytest.mark.feature("fleet")] +class TestDispatchCompletedSuccessDerivation: + @pytest.mark.parametrize( + ("dispatch_status", "expected_success"), + [ + ("success", True), + ("failure", False), + ("resumable", False), + ], + ) + def test_success_property_and_envelope_follow_dispatch_status( + self, dispatch_status, expected_success + ): + from autoskillit.fleet import DispatchCompleted, DispatchStatus + + status = DispatchStatus(dispatch_status) + completed = DispatchCompleted( + dispatch_status=status, + dispatch_id="dispatch-derived-success", + dispatched_session_id="session-derived-success", + reason="implementation_complete", + effect_provenance=DispatchEffectProvenance(operation_id="dispatch-derived-success"), + ) + + assert completed.success is expected_success + assert json.loads(completed.to_envelope())["success"] is expected_success + + def test_success_cannot_be_supplied_to_constructor(self): + from typing import Any + + from autoskillit.fleet import DispatchCompleted, DispatchStatus + + constructor: Any = DispatchCompleted + with pytest.raises(TypeError, match="unexpected keyword argument 'success'"): + constructor( + success=True, + dispatch_status=DispatchStatus.FAILURE, + dispatch_id="dispatch-invalid-success", + dispatched_session_id="session-invalid-success", + reason="failure", + effect_provenance=DispatchEffectProvenance( + operation_id="dispatch-invalid-success" + ), + ) + + class TestElapsedSecondsEnvelopeField: @pytest.mark.anyio async def test_dispatch_completed_envelope_contains_elapsed_seconds(self): @@ -31,7 +76,6 @@ async def test_dispatch_completed_envelope_contains_elapsed_seconds(self): from autoskillit.fleet import DispatchCompleted, DispatchStatus completed = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-dispatch-id", dispatched_session_id="test-session-id", @@ -222,7 +266,6 @@ def test_kind_discriminator_present_in_both_envelopes(self): from autoskillit.fleet import DispatchCompleted, DispatchRejected, DispatchStatus completed = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d-c", dispatched_session_id="s-c", @@ -284,7 +327,6 @@ def test_envelope_includes_health_report_when_present(self): from autoskillit.fleet import DispatchCompleted, DispatchStatus completed = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-id", dispatched_session_id="sess-id", @@ -300,7 +342,6 @@ def test_envelope_omits_health_report_when_none(self): from autoskillit.fleet import DispatchCompleted, DispatchStatus completed = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-id", dispatched_session_id="sess-id", @@ -345,7 +386,6 @@ def test_dispatch_completed_enriched_with_health_report(self): from autoskillit.fleet import DispatchCompleted, DispatchStatus original = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-id", dispatched_session_id="sess-id", diff --git a/tests/fleet/test_dispatch_failure_semantics.py b/tests/fleet/test_dispatch_failure_semantics.py index fea41ad1b4..8807818bab 100644 --- a/tests/fleet/test_dispatch_failure_semantics.py +++ b/tests/fleet/test_dispatch_failure_semantics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from unittest.mock import MagicMock import pytest @@ -15,8 +16,11 @@ _make_completed_clean, _make_completed_dirty, _make_no_sentinel, + _mock_backend_with_locator, + _noop_quota_refresher, _read_dispatch_record, _run, + _setup_bundled_implementation_dispatch, _setup_dispatch, ) @@ -303,6 +307,490 @@ async def test_completed_clean_failure_writes_reason_from_payload(self, tool_ctx record = _read_dispatch_record(tool_ctx) assert record["reason"] == "my-failure-reason" + @pytest.mark.anyio + async def test_context_exhausted_session_cannot_become_success_from_payload( + self, tool_ctx, monkeypatch + ): + import dataclasses + + from autoskillit.core import RetryReason + from tests.fakes import _DEFAULT_SKILL_RESULT + + _setup_dispatch(tool_ctx, monkeypatch) + tool_ctx.executor = InMemoryHeadlessExecutor( + default_result=dataclasses.replace( + _DEFAULT_SKILL_RESULT, + success=True, + retry_reason=RetryReason.CONTEXT_EXHAUSTED, + ) + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean(success=True, reason="done"), + ) + + envelope = await _run(tool_ctx) + record = _read_dispatch_record(tool_ctx) + + assert envelope["success"] is False + assert envelope["dispatch_status"] == "failure" + assert envelope["reason"] == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED.value + assert record["status"] == "failure" + assert record["reason"] == envelope["reason"] + + +class TestTrackerAuthorityEnvelope: + @pytest.mark.anyio + async def test_tracker_authority_error_overrides_clean_success_envelope( + self, tool_ctx, monkeypatch + ): + _setup_dispatch(tool_ctx, monkeypatch) + monkeypatch.setattr( + fleet_api, + "load_dispatch_progress", + lambda **_: ( + tool_ctx.temp_dir / "missing-sidecar.jsonl", + [], + None, + "tracker_state_authority_error", + ), + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean(success=True, reason="done"), + ) + + envelope = await _run(tool_ctx) + record = _read_dispatch_record(tool_ctx) + + assert envelope["success"] is False + assert envelope["dispatch_status"] == "failure" + assert envelope["reason"] == "tracker_state_authority_error" + assert envelope["error"] == "tracker_state_authority_error" + assert record["status"] == "failure" + assert record["reason"] == envelope["reason"] + + @pytest.mark.anyio + async def test_dirty_parse_diagnostics_survive_tracker_reason_override( + self, tool_ctx, monkeypatch + ): + _setup_dispatch(tool_ctx, monkeypatch) + monkeypatch.setattr( + fleet_api, + "load_dispatch_progress", + lambda **_: ( + tool_ctx.temp_dir / "missing-sidecar.jsonl", + [], + None, + "tracker_state_authority_error", + ), + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_dirty(), + ) + + envelope = await _run(tool_ctx) + record = _read_dispatch_record(tool_ctx) + + assert envelope["success"] is False + assert envelope["dispatch_status"] == "failure" + assert envelope["reason"] == "tracker_state_authority_error" + assert envelope["error"] == "tracker_state_authority_error" + assert envelope["l3_raw_body"] == "garbled" + assert envelope["l3_parse_error"] == "json decode error" + assert record["status"] == "failure" + assert record["reason"] == envelope["reason"] + + @pytest.mark.anyio + async def test_context_exhaustion_keeps_reason_and_tracker_diagnostics( + self, tool_ctx, monkeypatch + ): + import dataclasses + + from autoskillit.core import FleetErrorCode, RetryReason + from tests.fakes import _DEFAULT_SKILL_RESULT + + tracker_error = "tracker_state_authority_error" + _setup_dispatch(tool_ctx, monkeypatch) + tool_ctx.executor = InMemoryHeadlessExecutor( + default_result=dataclasses.replace( + _DEFAULT_SKILL_RESULT, + retry_reason=RetryReason.CONTEXT_EXHAUSTED, + ) + ) + monkeypatch.setattr( + fleet_api, + "load_dispatch_progress", + lambda **_: ( + tool_ctx.temp_dir / "missing-sidecar.jsonl", + [], + None, + tracker_error, + ), + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_dirty(), + ) + + envelope = await _run(tool_ctx) + record = _read_dispatch_record(tool_ctx) + + context_reason = FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED.value + assert envelope["success"] is False + assert envelope["dispatch_status"] == "failure" + assert envelope["reason"] == context_reason + assert envelope["error"] == context_reason + assert envelope["user_visible_message"] == tracker_error + assert envelope["l3_raw_body"] == "garbled" + assert envelope["l3_parse_error"] == "json decode error" + assert record["status"] == "failure" + assert record["reason"] == context_reason + assert record["diagnostic_message"] == tracker_error + + @pytest.mark.anyio + @pytest.mark.parametrize("returned_session_id", ["different-session-id", ""]) + async def test_compacted_callback_identity_dominates_success_and_tracker_error( + self, tool_ctx, monkeypatch, returned_session_id + ): + import dataclasses + + from tests.fakes import _DEFAULT_SKILL_RESULT + + callback_session_id = "callback-session-id" + tracker_error = "tracker_state_authority_error" + _setup_dispatch(tool_ctx, monkeypatch) + tool_ctx.executor = InMemoryHeadlessExecutor() + + async def _dispatch_food_truck(**kwargs): + kwargs["on_session_id_resolved"](callback_session_id) + return dataclasses.replace( + _DEFAULT_SKILL_RESULT, + success=True, + session_id=returned_session_id, + ) + + tool_ctx.executor.dispatch_food_truck = _dispatch_food_truck + tool_ctx.backend = _mock_backend_with_locator(project_log_dir=tool_ctx.project_dir) + locator = tool_ctx.backend.session_locator.return_value + locator.session_context_was_compacted.side_effect = lambda _cwd, session_id: ( + session_id == callback_session_id + ) + monkeypatch.setattr( + fleet_api, + "load_dispatch_progress", + lambda **_: ( + tool_ctx.temp_dir / "missing-sidecar.jsonl", + [], + None, + tracker_error, + ), + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean(success=True, reason="done"), + ) + + envelope = await _run(tool_ctx) + record = _read_dispatch_record(tool_ctx) + process_spawn = next( + effect + for effect in record["effect_provenance"]["effects"] + if effect["name"] == "process_spawn" + ) + + context_reason = FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED.value + locator.session_context_was_compacted.assert_called_once_with( + str(tool_ctx.project_dir), callback_session_id + ) + locator.session_log_path.assert_called_once_with( + str(tool_ctx.project_dir), callback_session_id + ) + assert envelope["success"] is False + assert envelope["reason"] == context_reason + assert envelope["error"] == context_reason + assert envelope["user_visible_message"] == tracker_error + assert envelope["dispatched_session_id"] == callback_session_id + assert record["status"] == "failure" + assert record["reason"] == context_reason + assert record["diagnostic_message"] == tracker_error + assert record["dispatched_session_id"] == callback_session_id + assert ( + process_spawn["known_downstream_identities"]["dispatched_session_id"] + == callback_session_id + ) + + +class TestDeclaredSuccessContract: + @pytest.mark.parametrize( + ("reason", "include_reason", "claimed_reason"), + [ + pytest.param("", False, None, id="missing"), + pytest.param("", True, "", id="empty"), + pytest.param("unlisted_reason", True, "unlisted_reason", id="undeclared"), + ], + ) + @pytest.mark.anyio + async def test_implementation_rejects_success_without_its_declared_reason( + self, tool_ctx, monkeypatch, reason, include_reason, claimed_reason + ): + import structlog + + validation_result = _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean( + success=True, + reason=reason, + include_reason=include_reason, + ), + ) + + with structlog.testing.capture_logs() as logs: + envelope = await _run(tool_ctx, recipe="implementation") + + record = _read_dispatch_record(tool_ctx) + error_code = FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION.value + assert envelope["success"] is False + assert envelope["dispatch_status"] == "failure" + assert envelope["reason"] == error_code + assert envelope["error"] == error_code + assert record["status"] == "failure" + assert record["reason"] == envelope["reason"] + + warnings = [ + item + for item in logs + if item.get("event") == "dispatch_success_reason_contract_violation" + ] + assert len(warnings) == 1 + assert warnings[0]["dispatch_id"] == envelope["dispatch_id"] + assert warnings[0]["claimed_reason"] == claimed_reason + assert warnings[0]["declared_success_reasons"] == sorted( + validation_result["_dispatch_success_reasons"] + ) + + @pytest.mark.anyio + async def test_implementation_accepts_a_declared_success_reason(self, tool_ctx, monkeypatch): + _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean( + success=True, + reason="implementation_complete", + ), + ) + + envelope = await _run(tool_ctx, recipe="implementation") + record = _read_dispatch_record(tool_ctx) + + assert envelope["success"] is True + assert envelope["dispatch_status"] == "success" + assert envelope["reason"] == "implementation_complete" + assert record["status"] == "success" + assert record["reason"] == envelope["reason"] + + +def _setup_composed_declaration_recipe(tool_ctx, monkeypatch, recipe_name: str): + from autoskillit.recipe.repository import DefaultRecipeRepository + + _setup_dispatch(tool_ctx, monkeypatch, recipe_name=recipe_name) + recipe_dir = tool_ctx.project_dir / ".autoskillit" / "recipes" + sub_recipe_dir = recipe_dir / "sub-recipes" + sub_recipe_dir.mkdir(parents=True, exist_ok=True) + (recipe_dir / f"{recipe_name}.yaml").write_text( + f"""\ +name: {recipe_name} +description: A parent with a reasonless success stop and an optional child. +kitchen_rules: [Follow the active recipe routes and stop when complete.] +ingredients: + include_child: + description: Include the child recipe. + default: "true" + hidden: true +steps: + child: + sub_recipe: declaration-child + gate: include_child + on_success: parent_done + on_failure: escalate + parent_done: + action: stop + message: 'Example sentinel: {{"success": true}}' +""" + ) + (sub_recipe_dir / "declaration-child.yaml").write_text( + """\ +name: declaration-child +description: A child with a declared success stop. +kitchen_rules: [Stop only after the child work is complete.] +steps: + work: + action: confirm + message: Complete the child work. + on_success: child_done + on_failure: done + child_done: + action: stop + message: 'Example sentinel: {"success": true, "reason": "child_complete"}' +""" + ) + repo = DefaultRecipeRepository() + tool_ctx.recipes = repo + return repo + + +class TestComposedSuccessDeclarations: + @pytest.mark.parametrize( + ( + "include_child", + "success_reason", + "include_reason", + "expected_reasons", + "expected_success", + ), + [ + pytest.param( + "true", + "", + False, + frozenset({"child_complete"}), + False, + id="active-child-needs-reason", + ), + pytest.param( + "true", + "child_complete", + True, + frozenset({"child_complete"}), + True, + id="active-child-declared-reason", + ), + pytest.param( + "false", + "", + False, + None, + True, + id="pruned-child-reasonless-parent", + ), + ], + ) + @pytest.mark.anyio + async def test_active_composed_declarations_reach_context_prompt_and_classifier( + self, + tool_ctx, + monkeypatch, + include_child, + success_reason, + include_reason, + expected_reasons, + expected_success, + ): + from autoskillit.fleet._prompts import _build_food_truck_prompt + from autoskillit.fleet.campaign_state.state_effects import DispatchProvenanceTracker + from autoskillit.fleet.dispatch import _classification + from autoskillit.fleet.dispatch._validation import RecipeContext, run_pre_launch_gating + + recipe_name = "composed-declaration-dispatch" + repo = _setup_composed_declaration_recipe(tool_ctx, monkeypatch, recipe_name) + ingredients = {"include_child": include_child} + validation_result = repo.load_and_validate( + recipe_name, + tool_ctx.project_dir, + temp_dir=tool_ctx.temp_dir, + ingredient_overrides=ingredients, + ) + error_findings = [ + item + for item in validation_result.get("suggestions", []) + if item.get("severity") == "error" + ] + assert validation_result.get("valid") is True, ( + "Composed fixture must pass the real recipe loader; " + f"errors={validation_result.get('errors')!r}, findings={error_findings!r}" + ) + expected_list = sorted(expected_reasons) if expected_reasons is not None else None + assert validation_result.get("_dispatch_success_reasons") == expected_list + + gating_result = await run_pre_launch_gating( + tool_ctx=tool_ctx, + recipe=recipe_name, + task="dispatch the composed recipe", + ingredients=ingredients, + dispatch_name=None, + provenance=DispatchProvenanceTracker(), + ) + assert isinstance(gating_result, RecipeContext) + assert gating_result.success_reasons == expected_reasons + + prompt_args: list[dict] = [] + + def prompt_builder(**kwargs): + prompt_args.append(kwargs) + return _build_food_truck_prompt( + mcp_prefix="mcp__autoskillit__", + **kwargs, + ) + + classifier_calls: list[dict] = [] + classify = _classification.classify_dispatch_outcome + + def capture_classifier(*args, **kwargs): + classifier_calls.append(kwargs) + return classify(*args, **kwargs) + + monkeypatch.setattr(_classification, "classify_dispatch_outcome", capture_classifier) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_completed_clean( + success=True, + reason=success_reason, + include_reason=include_reason, + ), + ) + + result = await fleet_api.execute_dispatch( + tool_ctx=tool_ctx, + recipe=recipe_name, + task="dispatch the composed recipe", + ingredients=ingredients, + dispatch_name=None, + timeout_sec=None, + prompt_builder=prompt_builder, + quota_refresher=_noop_quota_refresher, + ) + + envelope = json.loads(result.outcome.to_envelope()) + record = _read_dispatch_record(tool_ctx) + assert prompt_args + assert prompt_args[0]["success_reasons"] == expected_reasons + if expected_reasons: + prompt = tool_ctx.executor.dispatch_calls[0].orchestrator_prompt + assert "child_complete" in prompt + else: + prompt = tool_ctx.executor.dispatch_calls[0].orchestrator_prompt + assert "child_complete" not in prompt + assert classifier_calls[0]["success_reasons"] == expected_reasons + assert envelope["success"] is expected_success + assert record["status"] == ("success" if expected_success else "failure") + if not expected_success: + error_code = FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION.value + assert envelope["reason"] == error_code + assert record["reason"] == error_code + else: + assert envelope["reason"] == success_reason + assert record["reason"] == success_reason + _SIDECAR_INGREDIENTS = {"issue_urls": RecipeIngredient(description="Issue URLs")} @@ -346,6 +834,127 @@ def fresh(cls) -> DispatchIdentity: result = await _run(tool_ctx, ingredients={"issue_urls": issue_url}) assert result["dispatch_status"] == "success" + @pytest.mark.anyio + async def test_compaction_attempt_with_successful_sidecar_and_progress_stays_failed( + self, tool_ctx, monkeypatch + ): + import dataclasses + + from autoskillit.core import ( + DispatchIdentity, + FleetErrorCode, + RetryReason, + ) + from autoskillit.fleet.sidecar import IssueSidecarEntry, append_sidecar_entry + from tests.fakes import _DEFAULT_SKILL_RESULT + + _setup_dispatch(tool_ctx, monkeypatch, ingredients=_SIDECAR_INGREDIENTS) + + fixed_dispatch_id = "aaaabbbb-2222-3333-4444-ffffffffffff" + fixed_identity = DispatchIdentity.from_dispatch_id(fixed_dispatch_id) + + class _FixedDispatchIdentity: + @classmethod + def fresh(cls) -> DispatchIdentity: + return fixed_identity + + monkeypatch.setattr(campaign_state, "DispatchIdentity", _FixedDispatchIdentity) + tool_ctx.executor = InMemoryHeadlessExecutor( + default_result=dataclasses.replace( + _DEFAULT_SKILL_RESULT, + success=True, + retry_reason=RetryReason.NONE, + compaction_attempted=True, + ) + ) + + issue_url = "https://github.com/org/repo/issues/2" + entry = IssueSidecarEntry( + issue_url=issue_url, + status="completed", + ts="2026-05-27T00:00:00Z", + pr_url="https://github.com/org/repo/pull/101", + ) + append_sidecar_entry(fixed_dispatch_id, entry, tool_ctx.project_dir) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_no_sentinel(), + ) + + result = await _run(tool_ctx, ingredients={"issue_urls": issue_url}) + record = _read_dispatch_record(tool_ctx) + + context_reason = FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED.value + assert result["success"] is False + assert result["dispatch_status"] == "failure" + assert result["reason"] == context_reason + assert result["l3_parse_source"] == "stdout" + assert record["status"] == "failure" + assert record["reason"] == context_reason + + @pytest.mark.anyio + async def test_compacted_session_log_blocks_successful_sidecar_synthesis( + self, tool_ctx, monkeypatch + ): + import dataclasses + + from autoskillit.core import DispatchIdentity, FleetErrorCode, RetryReason + from autoskillit.fleet.sidecar import IssueSidecarEntry, append_sidecar_entry + from tests.fakes import _DEFAULT_SKILL_RESULT + + _setup_dispatch(tool_ctx, monkeypatch, ingredients=_SIDECAR_INGREDIENTS) + + fixed_dispatch_id = "aaaabbbb-3333-4444-5555-ffffffffffff" + fixed_identity = DispatchIdentity.from_dispatch_id(fixed_dispatch_id) + + class _FixedDispatchIdentity: + @classmethod + def fresh(cls) -> DispatchIdentity: + return fixed_identity + + monkeypatch.setattr(campaign_state, "DispatchIdentity", _FixedDispatchIdentity) + tool_ctx.backend = _mock_backend_with_locator(project_log_dir=tool_ctx.project_dir) + locator = tool_ctx.backend.session_locator.return_value + locator.session_context_was_compacted.return_value = True + tool_ctx.executor = InMemoryHeadlessExecutor( + default_result=dataclasses.replace( + _DEFAULT_SKILL_RESULT, + success=True, + session_id="compacted-session", + retry_reason=RetryReason.NONE, + compaction_attempted=False, + ) + ) + + issue_url = "https://github.com/org/repo/issues/3" + append_sidecar_entry( + fixed_dispatch_id, + IssueSidecarEntry( + issue_url=issue_url, + status="completed", + ts="2026-05-27T00:00:00Z", + pr_url="https://github.com/org/repo/pull/102", + ), + tool_ctx.project_dir, + ) + monkeypatch.setattr( + fleet_api, + "parse_l3_result_block", + lambda **_: _make_no_sentinel(), + ) + + result = await _run(tool_ctx, ingredients={"issue_urls": issue_url}) + record = _read_dispatch_record(tool_ctx) + + context_reason = FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED.value + assert result["success"] is False + assert result["dispatch_status"] == "failure" + assert result["reason"] == context_reason + assert result["l3_parse_source"] == "stdout" + assert record["status"] == "failure" + assert record["reason"] == context_reason + @pytest.mark.anyio async def test_no_sentinel_with_failed_sidecar_entries_remains_failure( self, tool_ctx, monkeypatch diff --git a/tests/fleet/test_dispatch_outcome_classifier.py b/tests/fleet/test_dispatch_outcome_classifier.py index 0b316ebd5c..af8e972aa5 100644 --- a/tests/fleet/test_dispatch_outcome_classifier.py +++ b/tests/fleet/test_dispatch_outcome_classifier.py @@ -75,7 +75,12 @@ def test_completed_clean_success(self): source="stdout", ) skill_result = dataclasses.replace(_DEFAULT_SKILL_RESULT) - status, reason = classify_dispatch_outcome(parsed, skill_result, sidecar_exists=False) + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + sidecar_exists=False, + success_reasons=None, + ) assert status == DispatchStatus.SUCCESS assert reason == "" @@ -96,7 +101,7 @@ def test_context_exhaustion_overrides_completed_clean_success(self): status, reason = classify_dispatch_outcome(parsed, skill_result) assert status == DispatchStatus.FAILURE - assert reason == FleetErrorCode.FLEET_L3_NO_RESULT_BLOCK + assert reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED def test_completed_clean_failure(self): parsed = L3ParseResult( @@ -112,6 +117,220 @@ def test_completed_clean_failure(self): assert reason == "my-error" +@pytest.mark.parametrize( + ( + "outcome", + "payload", + "source", + "retry_reason", + "infra_exit_category", + "compaction_attempted", + "context_compacted", + "sidecar_exists", + "with_checkpoint", + "subtype", + ), + [ + pytest.param( + None, + None, + "stdout", + RetryReason.CONTEXT_EXHAUSTED, + "unclassified", + False, + False, + True, + True, + "", + id="retry-context-parsed-none-progress", + ), + pytest.param( + "no_sentinel", + None, + "stdout", + RetryReason.RESUME, + "context_exhausted", + False, + False, + False, + True, + "", + id="context-category-resume-no-sentinel-checkpoint", + ), + pytest.param( + "completed_clean", + {"success": True, "reason": "done"}, + "stdout", + RetryReason.NONE, + "context_exhausted", + False, + False, + False, + False, + "", + id="context-category-none-valid-success", + ), + pytest.param( + "completed_clean", + {"success": True, "reason": "sidecar_recovery"}, + "sidecar", + RetryReason.CONTEXT_EXHAUSTED, + "unclassified", + False, + False, + True, + False, + "", + id="retry-context-sidecar-success", + ), + pytest.param( + "completed_clean", + {"success": False, "reason": "implementation_failed"}, + "stdout", + RetryReason.NONE, + "context_exhausted", + False, + False, + True, + True, + "", + id="context-category-none-failure-with-progress", + ), + pytest.param( + "completed_dirty", + None, + "stdout", + RetryReason.RESUME, + "context_exhausted", + False, + False, + False, + True, + "", + id="context-category-resume-dirty-with-checkpoint", + ), + pytest.param( + "completed_clean", + {"success": True, "reason": "done"}, + "stdout", + RetryReason.NONE, + "unclassified", + True, + False, + True, + True, + "", + id="veto-at-threshold-later-success-with-progress", + ), + pytest.param( + None, + None, + "stdout", + RetryReason.NONE, + "unclassified", + False, + True, + False, + False, + "", + id="log-compacted-parsed-none", + ), + pytest.param( + "completed_clean", + {"success": True, "reason": "done"}, + "stdout", + RetryReason.NONE, + "unclassified", + False, + True, + False, + False, + "", + id="log-compacted-clean-success", + ), + pytest.param( + "completed_clean", + {"success": True, "reason": "sidecar_recovery"}, + "sidecar", + RetryReason.NONE, + "unclassified", + False, + True, + True, + False, + "", + id="log-compacted-sidecar-success", + ), + pytest.param( + None, + None, + "stdout", + RetryReason.NONE, + "context_exhausted", + False, + False, + True, + True, + "timeout", + id="context-category-none-timeout-with-checkpoint", + ), + ], +) +def test_context_exhaustion_dominates_parsed_results_and_progress( + outcome, + payload, + source, + retry_reason, + infra_exit_category, + compaction_attempted, + context_compacted, + sidecar_exists, + with_checkpoint, + subtype, +): + parsed = ( + None + if outcome is None + else L3ParseResult( + outcome=outcome, + payload=payload, + raw_body="garbled" if outcome == "completed_dirty" else None, + parse_error="invalid JSON" if outcome == "completed_dirty" else None, + source=source, + ) + ) + skill_result = dataclasses.replace( + _DEFAULT_SKILL_RESULT, + retry_reason=retry_reason, + infra=InfraOutcome(exit_category=infra_exit_category), + compaction_attempted=compaction_attempted, + session_id="sess-context-exhausted" if with_checkpoint or sidecar_exists else "", + lifespan_started=with_checkpoint or sidecar_exists, + ) + checkpoint = ( + SessionCheckpoint( + completed_items=["plan", "implement"], + step_name="implement", + progress_pct=0.5, + ts="2026-10-14T00:00:00Z", + ) + if with_checkpoint + else None + ) + + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + sidecar_exists=sidecar_exists, + checkpoint=checkpoint, + subtype=subtype, + context_compacted=context_compacted, + ) + + assert status is DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED + + class TestQuotaExhaustedOutcome: def test_quota_exhausted_is_resumable(self): """completed_clean + success=false + reason=fleet_quota_exhausted → RESUMABLE.""" @@ -335,7 +554,7 @@ def test_context_exhausted_with_progress_is_failure(self): ) status, reason = classify_dispatch_outcome(parsed, skill_result, sidecar_exists=True) assert status == DispatchStatus.FAILURE - assert reason == FleetErrorCode.FLEET_L3_NO_RESULT_BLOCK + assert reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED def test_api_error_with_progress_is_resumable(self): parsed, skill_result = _no_sentinel(session_id="sess-abc", lifespan_started=True) @@ -415,7 +634,7 @@ def test_sidecar_source_accepted_in_l3_parse_result(self): assert result.source == "sidecar" def test_sidecar_synthesized_completed_clean_is_success(self): - """completed_clean from sidecar synthesis with success=True → SUCCESS with reason.""" + """The reserved sidecar reason is accepted outside recipe declarations.""" parsed = L3ParseResult( outcome="completed_clean", payload={"success": True, "reason": "sidecar_recovery"}, @@ -424,14 +643,44 @@ def test_sidecar_synthesized_completed_clean_is_success(self): source="sidecar", ) skill_result = dataclasses.replace(_DEFAULT_SKILL_RESULT) - status, reason = classify_dispatch_outcome(parsed, skill_result) + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + success_reasons=frozenset({"implementation_complete"}), + ) assert status == DispatchStatus.SUCCESS assert reason == "sidecar_recovery" + @pytest.mark.parametrize( + "payload", + [ + pytest.param({"success": True}, id="missing"), + pytest.param({"success": True, "reason": ""}, id="empty"), + pytest.param({"success": True, "reason": "other_recovery"}, id="different"), + ], + ) + def test_reasonless_recipe_sidecar_requires_exact_reserved_reason(self, payload): + parsed = L3ParseResult( + outcome="completed_clean", + payload=payload, + raw_body=None, + parse_error=None, + source="sidecar", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=None, + ) + + assert status == DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + class TestCompletedCleanSuccessPreservesReason: def test_completed_clean_success_preserves_reason(self): - """completed_clean with success=True preserves reason from payload.""" + """A wholly reasonless recipe keeps a supplied success reason.""" parsed = L3ParseResult( outcome="completed_clean", payload={"success": True, "reason": "implementation_complete"}, @@ -440,7 +689,11 @@ def test_completed_clean_success_preserves_reason(self): source="stdout", ) skill_result = dataclasses.replace(_DEFAULT_SKILL_RESULT) - status, reason = classify_dispatch_outcome(parsed, skill_result) + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + success_reasons=None, + ) assert status == DispatchStatus.SUCCESS assert reason == "implementation_complete" @@ -456,12 +709,16 @@ def test_completed_clean_success_distinct_reasons_distinguishable(self): source="stdout", ) skill_result = dataclasses.replace(_DEFAULT_SKILL_RESULT) - status, reason = classify_dispatch_outcome(parsed, skill_result) + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + success_reasons=None, + ) assert status == DispatchStatus.SUCCESS assert reason == expected_reason def test_completed_clean_success_empty_reason_returns_empty(self): - """Backward compat: success with no reason key returns empty string.""" + """Wholly reasonless recipes retain the supplied empty-reason behavior.""" parsed = L3ParseResult( outcome="completed_clean", payload={"success": True}, @@ -470,11 +727,131 @@ def test_completed_clean_success_empty_reason_returns_empty(self): source="stdout", ) skill_result = dataclasses.replace(_DEFAULT_SKILL_RESULT) - status, reason = classify_dispatch_outcome(parsed, skill_result) + status, reason = classify_dispatch_outcome( + parsed, + skill_result, + success_reasons=None, + ) assert status == DispatchStatus.SUCCESS assert reason == "" +class TestDeclaredSuccessReasonContract: + @pytest.mark.parametrize( + "payload", + [ + pytest.param({"success": True}, id="missing"), + pytest.param({"success": True, "reason": ""}, id="empty"), + pytest.param({"success": True, "reason": "already_done"}, id="undeclared"), + pytest.param( + {"success": True, "reason": " implementation_complete "}, + id="whitespace-is-not-trimmed-for-admission", + ), + ], + ) + def test_declared_recipe_rejects_missing_empty_or_nonexact_reason(self, payload): + parsed = L3ParseResult( + outcome="completed_clean", + payload=payload, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=frozenset({"implementation_complete"}), + ) + + assert status == DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + + def test_declared_reason_is_accepted_exactly(self): + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": True, "reason": "implementation_complete"}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=frozenset({"implementation_complete"}), + ) + + assert status == DispatchStatus.SUCCESS + assert reason == "implementation_complete" + + def test_failure_payload_is_not_subject_to_success_reason_admission(self): + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": False, "reason": "implementation_complete"}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=frozenset({"implementation_complete"}), + ) + + assert status == DispatchStatus.FAILURE + assert reason == "implementation_complete" + + def test_stdout_cannot_claim_the_sidecar_reserved_reason(self): + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": True, "reason": "sidecar_recovery"}, + raw_body=None, + parse_error=None, + source="stdout", + ) + + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=frozenset({"implementation_complete"}), + ) + + assert status == DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + + def test_every_bundled_declared_reason_is_admitted_from_fresh_recipe_data(self): + from autoskillit.recipe.io import builtin_recipes_dir, load_recipe + + checked_reasons = 0 + for recipe_path in sorted(builtin_recipes_dir().glob("*.yaml")): + recipe = load_recipe(recipe_path) + declared_reasons = recipe.declared_success_reasons() + if declared_reasons is None: + continue + + for declared_reason in declared_reasons: + parsed = L3ParseResult( + outcome="completed_clean", + payload={"success": True, "reason": declared_reason}, + raw_body=None, + parse_error=None, + source="stdout", + ) + status, reason = classify_dispatch_outcome( + parsed, + dataclasses.replace(_DEFAULT_SKILL_RESULT), + success_reasons=declared_reasons, + ) + + assert status == DispatchStatus.SUCCESS, recipe_path.name + assert reason == declared_reason, recipe_path.name + checked_reasons += 1 + + assert checked_reasons > 0 + + class TestClassifyDispatchOutcomeParsedNone: def test_parsed_none_with_progress_is_resumable(self): skill_result = dataclasses.replace( diff --git a/tests/fleet/test_dispatch_outcome_classifier_timeout.py b/tests/fleet/test_dispatch_outcome_classifier_timeout.py index cc87ebbb64..da053a3a49 100644 --- a/tests/fleet/test_dispatch_outcome_classifier_timeout.py +++ b/tests/fleet/test_dispatch_outcome_classifier_timeout.py @@ -238,6 +238,7 @@ def test_timeout_context_exhausted_is_failure(self): subtype="timeout", ) assert status == DispatchStatus.FAILURE + assert reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED def test_timeout_api_error_is_resumable(self): """Timeout + api_error infra category → RESUMABLE (infrastructure, not abandon).""" diff --git a/tests/fleet/test_error_code_categorization.py b/tests/fleet/test_error_code_categorization.py index bfb8fc4045..3cd3768ae6 100644 --- a/tests/fleet/test_error_code_categorization.py +++ b/tests/fleet/test_error_code_categorization.py @@ -7,7 +7,10 @@ from autoskillit.core import FaultDomain from autoskillit.core import FleetErrorCode as FEC from autoskillit.fleet.campaign_state.state_records import CampaignState, DispatchRecord -from autoskillit.fleet.campaign_state.state_recovery import has_failed_dispatch +from autoskillit.fleet.campaign_state.state_recovery import ( + has_blocking_dispatch, + has_failed_dispatch, +) from autoskillit.fleet.campaign_state.state_transitions import DispatchStatus pytestmark = [pytest.mark.layer("fleet"), pytest.mark.small, pytest.mark.feature("fleet")] @@ -41,12 +44,69 @@ def test_timeout_is_infrastructure_failure(self, tmp_path): class TestAllFleetErrorCodesHaveCategory: def test_all_fleet_error_codes_have_infrastructure_or_logic_category(self): """Every FleetErrorCode must have an explicit infrastructure/logic classification.""" - from autoskillit.fleet.campaign_state.state_error_codes import get_error_category + from autoskillit.fleet.campaign_state.state_error_codes import ( + _ERROR_CODE_CATEGORIES, + get_error_category, + ) for code in FEC: if code.startswith("fleet_"): + assert code in _ERROR_CODE_CATEGORIES, f"{code} has no explicit category entry" category = get_error_category(code) assert category in ( FaultDomain.INFRASTRUCTURE, FaultDomain.LOGIC, ), f"{code} has no explicit category" + + @pytest.mark.parametrize( + "code", + [ + FEC.FLEET_L3_RESULT_CONTRACT_VIOLATION, + FEC.FLEET_RESET_NOT_FOUND, + FEC.FLEET_RESET_INVALID_TARGET, + FEC.FLEET_RESET_STILL_RUNNING, + FEC.FLEET_INVALID_BACKEND, + ], + ) + def test_logic_error_codes_are_not_infrastructure_recovery(self, code): + from autoskillit.fleet.campaign_state.state_error_codes import ( + _ERROR_CODE_CATEGORIES, + _INFRASTRUCTURE_FAILURE_REASONS, + get_error_category, + ) + + assert _ERROR_CODE_CATEGORIES[code] is FaultDomain.LOGIC + assert get_error_category(code) is FaultDomain.LOGIC + assert code not in _INFRASTRUCTURE_FAILURE_REASONS + + def test_context_exhaustion_is_logic_and_blocks_campaign_resume(self, tmp_path): + from autoskillit.fleet.campaign_state.state import _write_state as write_state + from autoskillit.fleet.campaign_state.state_error_codes import ( + _ERROR_CODE_CATEGORIES, + _INFRASTRUCTURE_FAILURE_REASONS, + get_error_category, + ) + + code = FEC.FLEET_L3_CONTEXT_EXHAUSTED + state_path = tmp_path / "state.json" + state = CampaignState( + schema_version=5, + campaign_id="test-context-id", + campaign_name="test-context", + manifest_path="manifest.yaml", + started_at=0.0, + dispatches=[ + DispatchRecord( + name="step_1", + status=DispatchStatus.FAILURE, + reason=code, + ) + ], + ) + write_state(state_path, state) + + assert _ERROR_CODE_CATEGORIES[code] is FaultDomain.LOGIC + assert get_error_category(code) is FaultDomain.LOGIC + assert code not in _INFRASTRUCTURE_FAILURE_REASONS + assert has_failed_dispatch(state_path) + assert has_blocking_dispatch(state_path) diff --git a/tests/fleet/test_error_envelope.py b/tests/fleet/test_error_envelope.py index 3534328903..1f82234c73 100644 --- a/tests/fleet/test_error_envelope.py +++ b/tests/fleet/test_error_envelope.py @@ -36,7 +36,9 @@ def test_fleet_error_code_enum_has_expected_codes(self): "fleet_lock_not_initialized", "fleet_l3_timeout", "fleet_l3_no_result_block", + "fleet_l3_context_exhausted", "fleet_l3_parse_failed", + "fleet_l3_result_contract_violation", "fleet_l3_startup_or_crash", "fleet_budget_exceeded", "fleet_quota_exhausted", diff --git a/tests/fleet/test_food_truck_prompt.py b/tests/fleet/test_food_truck_prompt.py index 9b8f029c94..c3d725affc 100644 --- a/tests/fleet/test_food_truck_prompt.py +++ b/tests/fleet/test_food_truck_prompt.py @@ -2,14 +2,33 @@ from __future__ import annotations +import re + import pytest from autoskillit.core._plugin_ids import DIRECT_PREFIX +from autoskillit.core.types.launch._type_dispatch_identity import DispatchIdentity from autoskillit.fleet._prompts import _build_food_truck_prompt from tests.contracts._anti_fab_helpers import FABRICATION_GUARD_RE pytestmark = [pytest.mark.layer("fleet"), pytest.mark.small, pytest.mark.feature("fleet")] +_RESERVED_FAILURE_REASONS = { + "fleet_quota_exhausted", + "timeout", + "open_kitchen_failed", + "missing_on_failure", + "degraded_tool_response", +} + + +def _reserved_failure_reasons(contract: str) -> set[str]: + marker = "reserved failure reasons are " + lower_contract = contract.lower() + start = lower_contract.index(marker) + len(marker) + end = lower_contract.index(".", start) + return set(re.findall(r'"([^"]+)"', contract[start:end])) + def test_food_truck_prompt_documents_stop_action(): """L3 food truck prompt must explain how to handle action:stop steps.""" @@ -309,7 +328,7 @@ def test_food_truck_prompt_degraded_rule_triggers_sentinel_on_empty_content(): ) -def test_food_truck_prompt_degraded_reason_in_reason_enum(): +def test_food_truck_prompt_reserved_failure_codes_match_sentinel_contract(): prompt = _build_food_truck_prompt( recipe="test-recipe", task="Test task", @@ -319,11 +338,12 @@ def test_food_truck_prompt_degraded_reason_in_reason_enum(): campaign_id="test-campaign", l3_timeout_sec=300, ) - fields_idx = prompt.index("Fields:", prompt.index("--- SECTION 8:")) - reason_region = prompt[fields_idx : fields_idx + 300] - assert "degraded_tool_response" in reason_region, ( - "degraded_tool_response must appear in Section 8 reason enum" - ) + section_start = prompt.index("--- SECTION 8:") + section_end = prompt.index("The sentinel markers", section_start) + launch_contract = prompt[section_start:section_end] + resume_contract = DispatchIdentity.fresh().sentinel_contract + assert _reserved_failure_reasons(launch_contract) == _RESERVED_FAILURE_REASONS + assert _reserved_failure_reasons(resume_contract) == _RESERVED_FAILURE_REASONS ok_idx = prompt.index("FAILURE PREDICATE — open_kitchen") deg_idx = prompt.index("FAILURE PREDICATE — DEGRADED RESPONSE") assert ok_idx != deg_idx, "Degraded and open_kitchen predicates must be distinct blocks" diff --git a/tests/fleet/test_gate_state_persistence.py b/tests/fleet/test_gate_state_persistence.py index 4958fd0c29..abf619d430 100644 --- a/tests/fleet/test_gate_state_persistence.py +++ b/tests/fleet/test_gate_state_persistence.py @@ -155,7 +155,6 @@ async def test_dispatch_food_truck_updates_campaign_state_on_success( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d1", dispatched_session_id="s1", @@ -189,7 +188,6 @@ async def test_dispatch_food_truck_updates_campaign_state_on_failure( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="d1", dispatched_session_id="s1", @@ -222,7 +220,6 @@ async def test_dispatch_food_truck_skips_campaign_state_without_env( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d1", dispatched_session_id="s1", @@ -258,7 +255,6 @@ async def test_dispatch_food_truck_uses_recipe_name_when_dispatch_name_is_none( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d1", dispatched_session_id="s1", @@ -330,7 +326,6 @@ async def test_write_dispatch_to_campaign_state_preserves_timing( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d-timing", dispatched_session_id="s-timing", @@ -400,7 +395,6 @@ async def test_campaign_state_token_usage_nonzero( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d-token", dispatched_session_id="s-token", @@ -470,7 +464,6 @@ async def test_campaign_state_record_field_completeness( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d-field", dispatched_session_id="s-field", @@ -515,7 +508,6 @@ async def test_write_dispatch_to_campaign_state_fallback_reconstruction( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d-fallback", dispatched_session_id="s-fallback", @@ -795,7 +787,6 @@ async def test_dispatch_food_truck_writes_campaign_state_without_dispatch_name( async def _fake_execute(**kwargs): return DispatchResult( DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="d1", dispatched_session_id="s1", diff --git a/tests/fleet/test_result_parser.py b/tests/fleet/test_result_parser.py index 26f03cb49f..197208fb8b 100644 --- a/tests/fleet/test_result_parser.py +++ b/tests/fleet/test_result_parser.py @@ -484,7 +484,7 @@ def test_parse_additional_jsonl_paths_scanned_oldest_first(tmp_path: Path) -> No tmp_path, [ f"---l3-result::{DISPATCH_ID}---\n" - f'{{"summary": "older"}}\n' + f'{{"success": true, "summary": "older"}}\n' f"---end-l3-result::{DISPATCH_ID}---" ], "older.jsonl", @@ -493,7 +493,7 @@ def test_parse_additional_jsonl_paths_scanned_oldest_first(tmp_path: Path) -> No tmp_path, [ f"---l3-result::{DISPATCH_ID}---\n" - f'{{"summary": "newer"}}\n' + f'{{"success": true, "summary": "newer"}}\n' f"---end-l3-result::{DISPATCH_ID}---" ], "newer.jsonl", @@ -516,7 +516,7 @@ def test_parse_primary_jsonl_wins_over_additional(tmp_path: Path) -> None: tmp_path, [ f"---l3-result::{DISPATCH_ID}---\n" - f'{{"summary": "primary"}}\n' + f'{{"success": true, "summary": "primary"}}\n' f"---end-l3-result::{DISPATCH_ID}---" ], "primary.jsonl", @@ -525,7 +525,7 @@ def test_parse_primary_jsonl_wins_over_additional(tmp_path: Path) -> None: tmp_path, [ f"---l3-result::{DISPATCH_ID}---\n" - f'{{"summary": "additional"}}\n' + f'{{"success": true, "summary": "additional"}}\n' f"---end-l3-result::{DISPATCH_ID}---" ], "additional.jsonl", @@ -546,7 +546,7 @@ def test_parse_additional_jsonl_paths_nonexistent_skipped(tmp_path: Path) -> Non tmp_path, [ f"---l3-result::{DISPATCH_ID}---\n" - f'{{"summary": "existing"}}\n' + f'{{"success": true, "summary": "existing"}}\n' f"---end-l3-result::{DISPATCH_ID}---" ], "existing.jsonl", @@ -581,7 +581,7 @@ def test_ansi_only_stdout_no_sentinel() -> None: def test_hr_split_open_sentinel_recovered() -> None: """HR-split open sentinel (--- on own line before dispatch_id---) is recovered.""" - payload = {"status": "ok"} + payload = {"success": True, "status": "ok"} stdout = f"output text\n---\nl3-result::{DISPATCH_ID}---\n{json.dumps(payload)}\n{_close()}" result = parse_l3_result_block(stdout=stdout, expected_dispatch_id=DISPATCH_ID) assert result.outcome == "completed_clean" @@ -590,7 +590,7 @@ def test_hr_split_open_sentinel_recovered() -> None: def test_clean_sentinel_regression_guard() -> None: """Clean (non-split) sentinels continue to work after normalization is applied.""" - payload = {"status": "ok"} + payload = {"success": True, "status": "ok"} stdout = f"output\n{_open()}\n{json.dumps(payload)}\n{_close()}\n" result = parse_l3_result_block(stdout=stdout, expected_dispatch_id=DISPATCH_ID) assert result.outcome == "completed_clean" @@ -702,3 +702,44 @@ def test_resume_line_offset_zero_preserves_existing_behavior(tmp_path: Path) -> ) # Without offset, the prior-ID sentinel IS found (existing behavior) assert result.outcome == "completed_clean" + + +@pytest.mark.parametrize( + ("payload_json", "field"), + [ + pytest.param('{"reason":"finished"}', "success", id="missing-success"), + pytest.param('{"success":1}', "success", id="integer-success"), + pytest.param('{"success":"false"}', "success", id="string-success"), + pytest.param('{"success":true,"reason":7}', "reason", id="integer-reason"), + pytest.param('{"success":false,"reason":null}', "reason", id="null-reason"), + ], +) +def test_invalid_success_or_reason_fields_are_dirty_with_field_specific_error( + payload_json: str, field: str +) -> None: + result = parse_l3_result_block( + stdout=make_stdout(payload_json), + expected_dispatch_id=DISPATCH_ID, + ) + + assert result.outcome == "completed_dirty" + assert result.parse_error is not None + assert field in result.parse_error + + +@pytest.mark.parametrize( + "payload_json", + [ + pytest.param('{"success":true}', id="missing-reason"), + pytest.param('{"success":true,"reason":""}', id="empty-reason"), + ], +) +def test_missing_or_empty_string_reason_remains_parse_clean(payload_json: str) -> None: + result = parse_l3_result_block( + stdout=make_stdout(payload_json), + expected_dispatch_id=DISPATCH_ID, + ) + + assert result.outcome == "completed_clean" + assert result.payload == json.loads(payload_json) + assert result.parse_error is None diff --git a/tests/fleet/test_resume_precondition.py b/tests/fleet/test_resume_precondition.py index 014f97d1ad..eccd3ae693 100644 --- a/tests/fleet/test_resume_precondition.py +++ b/tests/fleet/test_resume_precondition.py @@ -183,6 +183,35 @@ def test_prepare_resume_halts_on_failure_when_continue_on_failure_false( assert preflight.halt is True assert preflight.halted_reason is not None + def test_context_exhausted_failure_cannot_be_reset_on_resume(self, tmp_path: Path) -> None: + prepare_resume = self._prepare_resume() + reason = FEC.FLEET_L3_CONTEXT_EXHAUSTED + sp = _seed_state( + tmp_path, + [ + DispatchRecord( + name="d1", + status=DispatchStatus.FAILURE, + reason=reason, + dispatched_session_id="context-exhausted-session", + ) + ], + ) + original_state = sp.read_bytes() + + preflight = prepare_resume(sp, "d1", continue_on_failure=True) + + assert preflight is not None + assert preflight.halt is True + assert preflight.reset_performed is False + assert preflight.halted_reason is not None + assert sp.read_bytes() == original_state + state = read_state(sp) + assert state is not None + d1 = next(d for d in state.dispatches if d.name == "d1") + assert d1.status is DispatchStatus.FAILURE + assert d1.reason == reason + # ---------------------------------------------------------------------------- # Universal-coverage tests — TestPrepareResumeIsUniversal diff --git a/tests/fleet/test_resume_preflight.py b/tests/fleet/test_resume_preflight.py index ba876c9fe0..bcd7369f0c 100644 --- a/tests/fleet/test_resume_preflight.py +++ b/tests/fleet/test_resume_preflight.py @@ -12,6 +12,7 @@ _make_no_sentinel, _mock_backend_with_locator, _noop_quota_refresher, + _setup_bundled_implementation_dispatch, _setup_dispatch, ) @@ -19,6 +20,55 @@ class TestResumeJSONLPreflight: + def test_unreadable_exact_resume_log_does_not_select_prior_fallback(self, tmp_path): + from typing import NoReturn + + from autoskillit.core import FleetErrorCode + from autoskillit.fleet.dispatch._execution import _resolve_resume_log + + primary_id = "unreadable-primary" + fallback_id = "readable-fallback" + primary_log = tmp_path / "primary.jsonl" + fallback_log = tmp_path / "fallback.jsonl" + primary_log.touch() + fallback_log.touch() + + class _UnreadableLocator: + def __init__(self) -> None: + self.session_ids: list[str] = [] + + def session_log_path(self, _cwd: str, session_id: str) -> Path: + self.session_ids.append(session_id) + return { + primary_id: primary_log, + fallback_id: fallback_log, + }[session_id] + + def session_context_was_compacted(self, _cwd: str, session_id: str) -> bool: + assert session_id == primary_id + raise PermissionError("selected session log cannot be read") + + locator = _UnreadableLocator() + fail_closed_calls: list[tuple[FleetErrorCode, str, str]] = [] + + def fail_closed( + error_code: FleetErrorCode, message: str, selected_failed_id: str + ) -> NoReturn: + fail_closed_calls.append((error_code, message, selected_failed_id)) + raise AssertionError("unreadable log must not reach the resume fallback") + + with pytest.raises(PermissionError, match="cannot be read"): + _resolve_resume_log( + locator=locator, + project_dir=str(tmp_path), + resume_session_id=primary_id, + prior_session_chain=[fallback_id], + fail_closed=fail_closed, + ) + + assert locator.session_ids == [primary_id] + assert fail_closed_calls == [] + @pytest.mark.anyio async def test_missing_lineage_degrades_to_fresh_capture_before_jsonl_preflight( self, tool_ctx, monkeypatch, tmp_path @@ -119,11 +169,12 @@ async def test_active_resume_fallback_reaches_executor_and_classification( ) from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state from autoskillit.fleet._api import execute_dispatch + from autoskillit.fleet._prompts import _build_food_truck_prompt from autoskillit.fleet.campaign_state.state import upsert_dispatch_record_by_name from autoskillit.fleet.campaign_state.state_outcomes import DispatchRejected from tests.fakes import _DEFAULT_SKILL_RESULT - _setup_dispatch(tool_ctx, monkeypatch) + _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) dispatches_dir = tool_ctx.temp_dir / "dispatches" dispatches_dir.mkdir(parents=True, exist_ok=True) @@ -149,12 +200,12 @@ async def test_active_resume_fallback_reaches_executor_and_classification( tool_ctx.kitchen_id, "camp", "", - [DispatchRecord(name="test-recipe")], + [DispatchRecord(name="implementation")], ) upsert_dispatch_record_by_name( prev_state_path, DispatchRecord( - name="test-recipe", + name="implementation", status=DispatchStatus.RESUMABLE, dispatched_session_id="nonexistent-primary", session_chain=["chain-session-id"], @@ -188,12 +239,15 @@ def _mock_session_log_path(_cwd: str, session_id: str) -> Path | None: with structlog.testing.capture_logs() as logs: result = await execute_dispatch( tool_ctx=tool_ctx, - recipe="test-recipe", + recipe="implementation", task="t", ingredients=None, dispatch_name=None, timeout_sec=None, - prompt_builder=lambda **_: "prompt", + prompt_builder=lambda **kwargs: _build_food_truck_prompt( + mcp_prefix="mcp__autoskillit__", + **kwargs, + ), quota_refresher=_noop_quota_refresher, resume_session_id="nonexistent-primary", prior_dispatch_id=prior_id, @@ -204,11 +258,140 @@ def _mock_session_log_path(_cwd: str, session_id: str) -> Path | None: ) assert len(tool_ctx.executor.dispatch_calls) == 1 assert tool_ctx.executor.dispatch_calls[0].resume_session_id == "chain-session-id" + assert "implementation_complete" in tool_ctx.executor.dispatch_calls[0].orchestrator_prompt + assert "fleet_quota_exhausted" in tool_ctx.executor.dispatch_calls[0].orchestrator_prompt + assert '"quota_exhausted"' not in tool_ctx.executor.dispatch_calls[0].orchestrator_prompt continuity_logs = [ log for log in logs if log.get("event") == "session_id_continuity_mismatch" ] assert continuity_logs[0]["resume_session_id"] == "chain-session-id" + @pytest.mark.anyio + @pytest.mark.parametrize("selected_location", ["primary", "fallback"]) + async def test_compacted_selected_resume_is_refused_with_exact_identity( + self, tool_ctx, monkeypatch, tmp_path, selected_location + ): + from autoskillit.core import ( + FleetErrorCode, + ManagedHeadlessSessionKind, + NativeShellCaptureMode, + new_managed_launch_id, + resolve_native_shell_capture_decision, + ) + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.fleet._api import execute_dispatch + from autoskillit.fleet._prompts import _build_food_truck_prompt + from autoskillit.fleet.campaign_state.state import ( + read_state, + upsert_dispatch_record_by_name, + ) + from autoskillit.fleet.campaign_state.state_outcomes import DispatchCompleted + from autoskillit.fleet.dispatch import _api as dispatch_api + from autoskillit.fleet.dispatch._execution import ExecutionResult + + _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) + + primary_id = "compacted-primary-session" + fallback_id = "compacted-fallback-session" + selected_id = primary_id if selected_location == "primary" else fallback_id + primary_log = tmp_path / "primary.jsonl" + fallback_log = tmp_path / "fallback.jsonl" + fallback_log.touch() + if selected_location == "primary": + primary_log.touch() + + prior_id = "00000000-0000-0000-0000-000000000107" + dispatches_dir = tool_ctx.temp_dir / "dispatches" + dispatches_dir.mkdir(parents=True, exist_ok=True) + state_path = dispatches_dir / f"{prior_id}.json" + lineage = tool_ctx.managed_headless_session_lineage_store.create( + lineage_anchor=tool_ctx.project_dir.resolve(), + launch_id=new_managed_launch_id(), + decision=resolve_native_shell_capture_decision(NativeShellCaptureMode.CAPTURE), + backend="mock-backend", + session_kind=ManagedHeadlessSessionKind.FOOD_TRUCK, + dispatch_id=prior_id, + ) + lineage = tool_ctx.managed_headless_session_lineage_store.bind_final_native_session_id( + lineage_anchor=tool_ctx.project_dir.resolve(), + launch_id=lineage.launch_id, + session_id=primary_id, + expected_generation=lineage.generation, + expected_record_digest=lineage.record_digest, + ) + write_initial_state( + state_path, + tool_ctx.kitchen_id, + "camp", + "", + [DispatchRecord(name="implementation")], + ) + upsert_dispatch_record_by_name( + state_path, + DispatchRecord( + name="implementation", + status=DispatchStatus.RESUMABLE, + dispatched_session_id=primary_id, + session_chain=[fallback_id], + managed_lineage_ref=lineage.reference, + ), + ) + + tool_ctx.backend = _mock_backend_with_locator( + project_log_dir=tmp_path, + session_log_path_side_effect=lambda _cwd, session_id: { + primary_id: primary_log, + fallback_id: fallback_log, + }.get(session_id), + ) + locator = tool_ctx.backend.session_locator.return_value + locator.session_context_was_compacted.side_effect = lambda _cwd, session_id: ( + session_id == selected_id + ) + + execution_results: list[ExecutionResult] = [] + original_run_execution = dispatch_api.run_execution + + async def capture_execution_result(**kwargs): + result = await original_run_execution(**kwargs) + execution_results.append(result) + return result + + monkeypatch.setattr(dispatch_api, "run_execution", capture_execution_result) + + result = await execute_dispatch( + tool_ctx=tool_ctx, + recipe="implementation", + task="t", + ingredients=None, + dispatch_name=None, + timeout_sec=None, + prompt_builder=lambda **kwargs: _build_food_truck_prompt( + mcp_prefix="mcp__autoskillit__", + **kwargs, + ), + quota_refresher=_noop_quota_refresher, + resume_session_id=primary_id, + prior_dispatch_id=prior_id, + ) + + assert isinstance(result.outcome, DispatchCompleted) + assert result.outcome.dispatch_status is DispatchStatus.REFUSED + assert result.outcome.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED + assert result.outcome.dispatched_session_id == selected_id + assert tool_ctx.executor.dispatch_calls == [] + assert len(execution_results) == 1 + assert execution_results[0].effective_resume_session_id == selected_id + locator.session_context_was_compacted.assert_called_once_with( + str(tool_ctx.project_dir), selected_id + ) + state = read_state(state_path) + assert state is not None + dispatch = next(d for d in state.dispatches if d.name == "implementation") + assert dispatch.status is DispatchStatus.REFUSED + assert dispatch.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED + assert dispatch.dispatched_session_id == selected_id + class TestSessionChainContinuity: @pytest.mark.anyio @@ -263,6 +446,62 @@ async def test_unverified_session_id_cannot_reach_continuity_check( class TestResumeSuccessGuard: + @pytest.mark.anyio + async def test_context_exhausted_prior_id_is_refused_without_reset_or_spawn( + self, tool_ctx, monkeypatch + ): + from autoskillit.core import FleetErrorCode + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.fleet._api import execute_dispatch + from autoskillit.fleet.campaign_state.state import read_state + from autoskillit.fleet.campaign_state.state_outcomes import DispatchRejected + + _setup_dispatch(tool_ctx, monkeypatch) + + dispatches_dir = tool_ctx.temp_dir / "dispatches" + dispatches_dir.mkdir(parents=True, exist_ok=True) + prior_id = "00000000-0000-0000-0000-000000000109" + state_path = dispatches_dir / f"{prior_id}.json" + write_initial_state( + state_path, + tool_ctx.kitchen_id, + "camp", + "", + [ + DispatchRecord( + name="test-recipe", + status=DispatchStatus.FAILURE, + dispatch_id="context-exhausted-dispatch", + dispatched_session_id="context-exhausted-session", + reason=FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED, + ) + ], + ) + original_state = state_path.read_bytes() + + result = await execute_dispatch( + tool_ctx=tool_ctx, + recipe="test-recipe", + task="t", + ingredients=None, + dispatch_name=None, + timeout_sec=None, + prompt_builder=lambda **_: "prompt", + quota_refresher=_noop_quota_refresher, + resume_session_id="context-exhausted-session", + prior_dispatch_id=prior_id, + ) + + assert isinstance(result.outcome, DispatchRejected) + assert result.outcome.dispatch_id == "context-exhausted-dispatch" + assert len(tool_ctx.executor.dispatch_calls) == 0 + assert state_path.read_bytes() == original_state + state = read_state(state_path) + assert state is not None + prior = next(d for d in state.dispatches if d.name == "test-recipe") + assert prior.status is DispatchStatus.FAILURE + assert prior.reason == FleetErrorCode.FLEET_L3_CONTEXT_EXHAUSTED + @pytest.mark.anyio async def test_resume_rejected_when_prior_dispatch_succeeded(self, tool_ctx, monkeypatch): """_run_dispatch returns cached SUCCESS when prior dispatch already succeeded.""" @@ -313,6 +552,120 @@ async def test_resume_rejected_when_prior_dispatch_succeeded(self, tool_ctx, mon assert result.outcome.reason == "completed_clean" assert len(tool_ctx.executor.dispatch_calls) == 0 + @pytest.mark.parametrize( + ("stored_reason", "message_fragment"), + [ + pytest.param("", "missing", id="empty-reason"), + pytest.param("other_recipe_complete", "not declared", id="undeclared-reason"), + ], + ) + @pytest.mark.anyio + async def test_resume_rejects_invalid_declared_historical_success_before_commit( + self, tool_ctx, monkeypatch, stored_reason, message_fragment + ): + from autoskillit.core import FleetErrorCode + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.fleet._api import execute_dispatch + from autoskillit.fleet.campaign_state.state_outcomes import DispatchRejected + + _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) + + dispatches_dir = tool_ctx.temp_dir / "dispatches" + dispatches_dir.mkdir(parents=True, exist_ok=True) + prior_id = "00000000-0000-0000-0000-000000000107" + state_path = dispatches_dir / f"{prior_id}.json" + write_initial_state( + state_path, + tool_ctx.kitchen_id, + "camp", + "", + [ + DispatchRecord( + name="implementation", + status=DispatchStatus.SUCCESS, + dispatch_id="historical-implementation-dispatch", + dispatched_session_id="historical-implementation-session", + reason=stored_reason, + ) + ], + ) + original_state = state_path.read_bytes() + + result = await execute_dispatch( + tool_ctx=tool_ctx, + recipe="implementation", + task="t", + ingredients=None, + dispatch_name=None, + timeout_sec=None, + prompt_builder=lambda **_: "prompt", + quota_refresher=_noop_quota_refresher, + resume_session_id="historical-implementation-session", + prior_dispatch_id=prior_id, + ) + + assert isinstance(result.outcome, DispatchRejected) + assert result.outcome.error_code == FleetErrorCode.FLEET_L3_RESULT_CONTRACT_VIOLATION + assert result.outcome.dispatch_id == "historical-implementation-dispatch" + assert message_fragment in result.outcome.message + assert result.per_dispatch_state_path is None + assert len(tool_ctx.executor.dispatch_calls) == 0 + assert state_path.read_bytes() == original_state + assert list(dispatches_dir.glob("*.json")) == [state_path] + assert not any( + effect["name"] == "commit" + for effect in result.outcome.effect_provenance.to_dict()["effects"] + ) + + @pytest.mark.anyio + async def test_resume_reuses_the_stored_declared_success_reason(self, tool_ctx, monkeypatch): + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.fleet._api import execute_dispatch + from autoskillit.fleet.campaign_state.state_outcomes import DispatchCompleted + + _setup_bundled_implementation_dispatch(tool_ctx, monkeypatch) + + dispatches_dir = tool_ctx.temp_dir / "dispatches" + dispatches_dir.mkdir(parents=True, exist_ok=True) + prior_id = "00000000-0000-0000-0000-000000000108" + state_path = dispatches_dir / f"{prior_id}.json" + write_initial_state( + state_path, + tool_ctx.kitchen_id, + "camp", + "", + [ + DispatchRecord( + name="implementation", + status=DispatchStatus.SUCCESS, + dispatch_id="historical-implementation-dispatch", + dispatched_session_id="historical-implementation-session", + reason="implementation_complete", + ) + ], + ) + original_state = state_path.read_bytes() + + result = await execute_dispatch( + tool_ctx=tool_ctx, + recipe="implementation", + task="t", + ingredients=None, + dispatch_name=None, + timeout_sec=None, + prompt_builder=lambda **_: "prompt", + quota_refresher=_noop_quota_refresher, + resume_session_id="historical-implementation-session", + prior_dispatch_id=prior_id, + ) + + assert isinstance(result.outcome, DispatchCompleted) + assert result.outcome.success is True + assert result.outcome.dispatch_status is DispatchStatus.SUCCESS + assert result.outcome.reason == "implementation_complete" + assert len(tool_ctx.executor.dispatch_calls) == 0 + assert state_path.read_bytes() == original_state + @pytest.mark.anyio async def test_resume_proceeds_when_prior_dispatch_resumable( self, tool_ctx, monkeypatch, tmp_path diff --git a/tests/fleet/test_state_schema.py b/tests/fleet/test_state_schema.py index b08e3a0f33..630ebe6f97 100644 --- a/tests/fleet/test_state_schema.py +++ b/tests/fleet/test_state_schema.py @@ -925,7 +925,6 @@ def test_dispatch_completed_fields_cover_envelope_needs(self): from autoskillit.fleet import DispatchCompleted, DispatchStatus completed = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="test-dispatch-id", dispatched_session_id="test-session-id", diff --git a/tests/hooks/test_auto_compact_guard.py b/tests/hooks/test_auto_compact_guard.py index 10794d817b..ae4ffe28d7 100644 --- a/tests/hooks/test_auto_compact_guard.py +++ b/tests/hooks/test_auto_compact_guard.py @@ -1,4 +1,4 @@ -"""Tests for the Codex PreCompact automatic-compaction veto.""" +"""Tests for backend-specific PreCompact automatic-compaction blocking.""" from __future__ import annotations @@ -17,19 +17,28 @@ SCRIPT = Path(__file__).resolve().parents[2] / "src/autoskillit/hooks/guards/auto_compact_guard.py" -def _run(event: object, *, backend: str = "codex") -> tuple[int, str]: +def _run( + event: object, + *, + backend: str = "codex", + session_type: str | None = None, +) -> tuple[int, str, str]: + env = {**production_interpreter_env(), "AUTOSKILLIT_AGENT_BACKEND": backend} + env.pop("AUTOSKILLIT_SESSION_TYPE", None) + if session_type is not None: + env["AUTOSKILLIT_SESSION_TYPE"] = session_type result = subprocess.run( [sys.executable, str(SCRIPT)], input=json.dumps(event), capture_output=True, text=True, - env={**production_interpreter_env(), "AUTOSKILLIT_AGENT_BACKEND": backend}, + env=env, ) - return result.returncode, result.stdout + return result.returncode, result.stdout, result.stderr -def test_auto_compact_guard_vetoes_only_the_codex_automatic_trigger() -> None: - code, stdout = _run({"hook_event_name": "PreCompact", "trigger": "auto"}) +def test_auto_compact_guard_preserves_codex_automatic_trigger_veto() -> None: + code, stdout, stderr = _run({"hook_event_name": "PreCompact", "trigger": "auto"}) assert code == 0 assert json.loads(stdout) == { @@ -40,18 +49,42 @@ def test_auto_compact_guard_vetoes_only_the_codex_automatic_trigger() -> None: "Start a new session, or compact manually and resume deliberately." ), } + assert stderr == "" + + +def test_auto_compact_guard_blocks_claude_orchestrator_automatic_trigger() -> None: + code, stdout, stderr = _run( + {"hook_event_name": "PreCompact", "trigger": "auto"}, + backend="claude-code", + session_type="orchestrator", + ) + + assert code == 2 + assert stdout == "" + assert "compaction" in stderr.lower() @pytest.mark.parametrize( - ("event", "backend"), + ("event", "backend", "session_type"), [ - ({"hook_event_name": "PreCompact", "trigger": "manual"}, "codex"), - ({"hook_event_name": "PreCompact", "trigger": "auto"}, "claude-code"), - ({"trigger": "auto"}, "codex"), + ({"hook_event_name": "PreCompact", "trigger": "manual"}, "codex", None), + ({"hook_event_name": "PreCompact", "trigger": "auto"}, "claude-code", "skill"), + ({"hook_event_name": "PreCompact", "trigger": "auto"}, "claude-code", None), + ( + {"hook_event_name": "PreCompact", "trigger": "manual"}, + "claude-code", + "orchestrator", + ), + ({"trigger": "auto"}, "codex", None), ], ) -def test_auto_compact_guard_leaves_other_invocations_inert(event: object, backend: str) -> None: - code, stdout = _run(event, backend=backend) +def test_auto_compact_guard_leaves_other_invocations_inert( + event: object, + backend: str, + session_type: str | None, +) -> None: + code, stdout, stderr = _run(event, backend=backend, session_type=session_type) assert code == 0 assert stdout == "" + assert stderr == "" diff --git a/tests/hooks/test_capture_lifecycle.py b/tests/hooks/test_capture_lifecycle.py index cc8f45ac7b..205362a364 100644 --- a/tests/hooks/test_capture_lifecycle.py +++ b/tests/hooks/test_capture_lifecycle.py @@ -3448,7 +3448,10 @@ def test_capture_capacity_spec_exercises_each_field(field_name: str) -> None: CaptureCapacitySpec(**values) -def test_store_open_contention_uses_the_hot_path_lock_wait(tmp_path: Path) -> None: +def test_store_open_contention_uses_the_hot_path_lock_wait( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: """The no-budget factory shape still has a bounded lock acquisition.""" project = tmp_path / "project" anchor, root, _store = _open_store(project, _Clock()) @@ -3457,6 +3460,15 @@ def test_store_open_contention_uses_the_hot_path_lock_wait(tmp_path: Path) -> No ) assert acquired.wait(timeout=1) assert holder_failures == [] + + lock_attempt_started = threading.Event() + acquire_flock = CaptureLifecycleStore._acquire_flock + + def signal_lock_attempt(store: CaptureLifecycleStore, fd: int) -> None: + lock_attempt_started.set() + acquire_flock(store, fd) + + monkeypatch.setattr(CaptureLifecycleStore, "_acquire_flock", signal_lock_attempt) executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) future = executor.submit( CaptureLifecycleStore.from_open_authorities, @@ -3465,11 +3477,13 @@ def test_store_open_contention_uses_the_hot_path_lock_wait(tmp_path: Path) -> No lock_wait=HOT_PATH_LOCK_WAIT, ) try: - started = time.monotonic() + # Start the observation only once the worker reaches the contended + # acquisition; executor startup and factory setup are outside the lock + # wait budget. The extra second allows for scheduler delay under xdist. + result_timeout = HOT_PATH_LOCK_WAIT.max_wait_seconds + 1.0 + assert lock_attempt_started.wait(timeout=result_timeout) with pytest.raises(LockContended): - future.result(timeout=2.5) - elapsed = time.monotonic() - started - assert elapsed < 2.25, f"store-open lock contention was not bounded: {elapsed}s" + future.result(timeout=result_timeout) finally: release.set() try: diff --git a/tests/hooks/test_codex_hooks_format_contract.py b/tests/hooks/test_codex_hooks_format_contract.py index 00dfd8e6d9..b011550453 100644 --- a/tests/hooks/test_codex_hooks_format_contract.py +++ b/tests/hooks/test_codex_hooks_format_contract.py @@ -3,10 +3,13 @@ from __future__ import annotations import tomllib +from collections.abc import Callable +from pathlib import Path +from typing import Any import pytest -from autoskillit.execution import _serialize_toml +from autoskillit.execution import BACKEND_REGISTRY, _serialize_toml from autoskillit.execution.backends._codex_hooks import ( generate_codex_hooks_config, sync_managed_codex_hooks_to_config, @@ -17,6 +20,49 @@ pytestmark = [pytest.mark.layer("hooks"), pytest.mark.medium] +def _generate_claude_hooks_config(_tmp_path: Path) -> dict[str, Any]: + return generate_hooks_json()["hooks"] + + +def _generate_codex_runtime_hooks_config(tmp_path: Path) -> dict[str, Any]: + config_path = tmp_path / "config.toml" + root = projection_shaped_hook_root(tmp_path) + sync_managed_codex_hooks_to_config( + config_path, + route="parent", + plugin_dir=root.plugin_dir, + ) + return tomllib.loads(config_path.read_text(encoding="utf-8"))["hooks"] + + +_BACKEND_BLOCKING_CONFIG_GENERATORS: dict[str, Callable[[Path], dict[str, Any]]] = { + "claude-code": _generate_claude_hooks_config, + "codex": _generate_codex_runtime_hooks_config, +} + + +def test_generated_precompact_blocker_covers_every_registered_backend(tmp_path: Path) -> None: + assert set(_BACKEND_BLOCKING_CONFIG_GENERATORS) == set(BACKEND_REGISTRY) + + for backend_name, generate_config in _BACKEND_BLOCKING_CONFIG_GENERATORS.items(): + hooks = generate_config(tmp_path) + entries = hooks.get("PreCompact", []) + assert len(entries) == 1, f"{backend_name} must generate exactly one PreCompact entry" + + entry = entries[0] + assert entry["matcher"] == "auto" + assert "async" not in entry + commands = entry["hooks"] + assert len(commands) == 1 + command = commands[0] + assert command["type"] == "command" + assert "auto_compact_guard" in command["command"] + + if backend_name == "codex": + assert all("async" not in hook for hook in commands) + assert len(command["trusted_hash"]) == 64 + + class TestCodexTomlFormatContract: def test_user_prompt_expansion_is_not_emitted_for_codex(self): assert "UserPromptExpansion" not in generate_codex_hooks_config() @@ -83,15 +129,8 @@ def test_shell_capture_hook_is_wired_with_identical_matchers(self): def test_runtime_precompact_hook_is_synchronous_and_destination_only(self, tmp_path): assert "PreCompact" not in generate_codex_hooks_config() - config_path = tmp_path / "config.toml" - root = projection_shaped_hook_root(tmp_path) - sync_managed_codex_hooks_to_config( - config_path, - route="parent", - plugin_dir=root.plugin_dir, - ) - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - entries = config["hooks"]["PreCompact"] + config = _generate_codex_runtime_hooks_config(tmp_path) + entries = config["PreCompact"] assert len(entries) == 1 entry = entries[0] assert entry["matcher"] == "auto" diff --git a/tests/hooks/test_hook_output.py b/tests/hooks/test_hook_output.py index 56138a8eed..041e4d86c3 100644 --- a/tests/hooks/test_hook_output.py +++ b/tests/hooks/test_hook_output.py @@ -22,7 +22,14 @@ _EXPECTED_CHANNEL_EVENTS = { "deny": frozenset({"PreToolUse"}), "block": frozenset( - {"PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SubagentStop"} + { + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "SubagentStop", + "PreCompact", + } ), "context": frozenset({"SessionStart", "PreToolUse", "PostToolUse", "UserPromptExpansion"}), "rewrite_input": frozenset({"PreToolUse"}), diff --git a/tests/hooks/test_hook_protocol_contract.py b/tests/hooks/test_hook_protocol_contract.py index 7c69b4a0fb..227a42ade7 100644 --- a/tests/hooks/test_hook_protocol_contract.py +++ b/tests/hooks/test_hook_protocol_contract.py @@ -21,6 +21,13 @@ pytestmark = [pytest.mark.layer("hooks"), pytest.mark.small] _EMITTER = "autoskillit.hooks._runtime._hook_output" + +# The static scanner sees both backend branches of this shared guard. Each pair +# marks the channel that cannot execute for that registered backend. +_UNREACHABLE_CHANNELS_BY_BACKEND_SCRIPT: dict[tuple[str, str], frozenset[str]] = { + ("claude", "guards/auto_compact_guard.py"): frozenset({"halt"}), + ("codex", "guards/auto_compact_guard.py"): frozenset({"block"}), +} _CODEX_ROUTES: tuple[tuple[str, str | None], ...] = ( ("unmanaged", None), ("parent", "parent"), @@ -281,11 +288,21 @@ def test_known_unsupported_channels_are_declared() -> None: violations = [] for backend, _route, hook_def in _emitted_hooks(): for script in hook_def.scripts: + unreachable_channels = _UNREACHABLE_CHANNELS_BY_BACKEND_SCRIPT.get( + (backend, script), frozenset() + ) for channel, events in scan_script_channels(script).items(): event = hook_def.event_type - if (backend, channel, event) in KNOWN_UNSUPPORTED_CHANNELS and ( - None in events or event in events - ): + event_is_emitted = None in events or event in events + if not event_is_emitted: + continue + if channel in unreachable_channels: + assert (backend, channel, event) in KNOWN_UNSUPPORTED_CHANNELS, ( + f"{backend} {script} can only bypass {channel}/{event} because its " + "backend branch is unreachable; keep that protocol limitation declared" + ) + continue + if (backend, channel, event) in KNOWN_UNSUPPORTED_CHANNELS: violations.append((backend, channel, event, script)) assert not violations, f"unsupported hook channels are still emitted: {sorted(violations)}" diff --git a/tests/hooks/test_hook_protocol_oracle.py b/tests/hooks/test_hook_protocol_oracle.py index eb52ff0e88..a66d8c8e79 100644 --- a/tests/hooks/test_hook_protocol_oracle.py +++ b/tests/hooks/test_hook_protocol_oracle.py @@ -305,6 +305,17 @@ def test_claude_exit_two_blocks_documented_events(event: str) -> None: assert verdict.message == "blocked by policy" +def test_claude_precompact_exit_two_blocks_with_stderr_reason() -> None: + verdict = claude_verdict( + "PreCompact", + exit_code=2, + stdout="", + stderr="automatic compaction blocked", + ) + assert verdict.status == STATUS_BLOCKED + assert verdict.message == "automatic compaction blocked" + + def test_claude_exit_two_prefers_a_valid_json_block_reason() -> None: verdict = claude_verdict( "Stop", diff --git a/tests/hooks/test_hook_registry.py b/tests/hooks/test_hook_registry.py index b8411d2271..8fc33caff7 100644 --- a/tests/hooks/test_hook_registry.py +++ b/tests/hooks/test_hook_registry.py @@ -10,6 +10,7 @@ from autoskillit.hook_registry import ( HOOK_REGISTRY, HOOKS_DIR, + _count_hook_registry_drift, _extract_script_basenames, _is_own_hook, canonical_script_basenames, @@ -59,6 +60,26 @@ def test_extract_script_basenames_canonical() -> None: assert result == expected +def test_shared_published_and_runtime_hook_does_not_report_registry_drift( + tmp_path: Path, +) -> None: + guard_script = "guards/auto_compact_guard.py" + guard_definitions = [hook for hook in HOOK_REGISTRY if guard_script in hook.scripts] + + assert {hook.runtime_only for hook in guard_definitions} == {False, True} + + deployed = generate_hooks_json() + deployed_scripts = _extract_script_basenames(deployed["hooks"]) + assert guard_script in deployed_scripts + + settings_path = tmp_path / "settings.json" + settings_path.write_text(json.dumps(deployed), encoding="utf-8") + drift = _count_hook_registry_drift(settings_path) + + assert drift.missing == 0 + assert drift.orphaned == 0 + + # HR-BASENAME-2: different path prefixes -> returns same basenames def test_extract_script_basenames_different_prefix() -> None: foreign_dir = ( diff --git a/tests/infra/conftest.py b/tests/infra/conftest.py index 182b45f5c8..074a4e932b 100644 --- a/tests/infra/conftest.py +++ b/tests/infra/conftest.py @@ -53,7 +53,6 @@ def _dispatch_food_truck_json_producer() -> dict: provenance = DispatchEffectProvenance(operation_id="formatter-coverage") base = DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d1", dispatched_session_id="s1", @@ -68,7 +67,6 @@ def _dispatch_food_truck_json_producer() -> dict: health_report={"status": "healthy", "findings": []}, ) completed_failure = DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="d3", dispatched_session_id="s3", diff --git a/tests/infra/test_pretty_output_formatters.py b/tests/infra/test_pretty_output_formatters.py index 0d995cc0a6..5d774b0838 100644 --- a/tests/infra/test_pretty_output_formatters.py +++ b/tests/infra/test_pretty_output_formatters.py @@ -1039,14 +1039,14 @@ def test_dispatch_food_truck_error_envelope_preserves_diagnostics(): assert "dispatch_id: d-rejected-001" in text -# PHK-FMT-DISPATCH-COMPLETED-FAIL: DispatchCompleted(success=False) must not be conflated +# PHK-FMT-DISPATCH-COMPLETED-FAIL: completed failure envelopes must retain their fields def test_completed_failure_through_formatter_preserves_all_fields(): - """A DispatchCompleted(success=False) envelope carries critical fields that + """A DispatchCompleted with FAILURE status carries critical fields that distinguish it from DispatchRejected: dispatched_session_id, dispatch_status, reason, token_usage, elapsed_seconds, lifespan_started. These fields must ALL survive formatting — they prove the subprocess actually ran. The bug: the formatter's `if not success:` branch strips these fields because it conflates - DispatchCompleted(success=False) with DispatchRejected. + completed failures with DispatchRejected. """ from tests.infra._pretty_output_helpers import _wrap_for_claude_code diff --git a/tests/infra/test_pretty_output_hook_infra.py b/tests/infra/test_pretty_output_hook_infra.py index 67b8dd2ca6..5461612f1d 100644 --- a/tests/infra/test_pretty_output_hook_infra.py +++ b/tests/infra/test_pretty_output_hook_infra.py @@ -586,9 +586,8 @@ def test_shape_field_obligations_per_kind(): def test_json_producer_includes_completed_failure(): - """_dispatch_food_truck_json_producer must include a DispatchCompleted(success=False) - shape so the coverage contract exercises both success states and the union of keys - includes 'kind'. + """_dispatch_food_truck_json_producer must include a FAILURE-status completion + so the coverage contract exercises both success states and includes 'kind'. Asserts on the individual completed_failure envelope (not the merged dict) to catch regressions where this specific shape drops 'kind'. @@ -603,7 +602,6 @@ def test_json_producer_includes_completed_failure(): f"_dispatch_food_truck_json_producer must emit 'kind' field: {sorted(keys)}" ) failure = DispatchCompleted( - success=False, dispatch_status=DispatchStatus.FAILURE, dispatch_id="d-verify", dispatched_session_id="s-verify", @@ -612,6 +610,5 @@ def test_json_producer_includes_completed_failure(): ) failure_env = json.loads(failure.to_envelope()) assert "kind" in failure_env, ( - f"DispatchCompleted(success=False).to_envelope() must emit 'kind': " - f"{sorted(failure_env.keys())}" + f"DispatchCompleted with FAILURE status must emit 'kind': {sorted(failure_env.keys())}" ) diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 4411d10b42..03264df8ff 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -453,6 +453,30 @@ def test_fmt_open_kitchen_combined_response(): assert "run_cmd" in formatted +@pytest.mark.parametrize("tool_name", ["load_recipe", "open_kitchen"]) +def test_dispatch_success_reasons_are_omitted_from_formatted_recipe_text( + tool_name: str, +) -> None: + payload = { + "content": "name: my-recipe\nsteps:\n done:\n action: stop\n", + "valid": True, + "suggestions": [], + "kitchen": "open", + "version": "1.0.0", + "_dispatch_success_reasons": ["dispatch_reason_complete"], + } + + formatted = _format_response( + f"mcp__autoskillit__{tool_name}", + json.dumps(payload), + pipeline=False, + ) + + assert formatted is not None + assert "_dispatch_success_reasons" not in formatted + assert "dispatch_reason_complete" not in formatted + + def test_fmt_open_kitchen_combined_includes_ingredients_table(): """Combined open_kitchen response includes pre-formatted ingredients table.""" payload = { diff --git a/tests/recipe/test_api.py b/tests/recipe/test_api.py index 8818d7cbe3..96854d59c8 100644 --- a/tests/recipe/test_api.py +++ b/tests/recipe/test_api.py @@ -1510,6 +1510,198 @@ def test_load_result_includes_merged_sub_recipe_stop_semantics(tmp_path: Path) - assert "stop_child_child_done" in result["orchestration_rules"] +@pytest.mark.parametrize( + ("include_child", "expected_reasons", "child_stop_active"), + [ + ("true", ["child_complete"], True), + ("false", None, False), + ], + ids=["composed-child", "pruned-child"], +) +def test_load_result_success_reasons_follow_composed_and_pruned_graph( + tmp_path: Path, + fresh_load_cache, + include_child: str, + expected_reasons: list[str] | None, + child_stop_active: bool, +) -> None: + """Declarations track the finalized graph and cached copies stay isolated.""" + from autoskillit.recipe._api import load_and_validate + + _setup_project_recipe( + tmp_path, + "declaration-parent", + """\ +name: declaration-parent +description: parent with a reasonless terminal and optional child +kitchen_rules: [Follow routes.] +ingredients: + include_child: + description: Include the child terminal. + default: "false" + hidden: true +steps: + child: + sub_recipe: declaration-child + gate: include_child + on_success: parent_done + on_failure: escalate + parent_done: + action: stop + message: >- + Parent terminal. + Example sentinel: {"success": true} +""", + ) + sub_recipes = tmp_path / ".autoskillit" / "recipes" / "sub-recipes" + sub_recipes.mkdir() + (sub_recipes / "declaration-child.yaml").write_text( + """\ +name: declaration-child +description: child with a declared success terminal +kitchen_rules: [Stop at the child terminal.] +steps: + work: + action: confirm + message: Complete the child work. + on_success: child_done + on_failure: done + child_done: + action: stop + message: >- + Child terminal. + Example sentinel: {"success": true, "reason": "child_complete"} +""" + ) + + def load_result() -> dict[str, Any]: + return load_and_validate( + "declaration-parent", + project_dir=tmp_path, + temp_dir=tmp_path / "cache", + ingredient_overrides={"include_child": include_child}, + ) + + first = load_result() + + assert first["valid"], first["errors"] + assert first["_dispatch_success_reasons"] == expected_reasons + semantics = first["stop_step_semantics"] + assert "For stop step 'parent_done'" in semantics + assert ("For stop step 'declaration_child_child_done'" in semantics) is child_stop_active + + if first["_dispatch_success_reasons"] is not None: + first["_dispatch_success_reasons"].append("caller_mutation") + + cached = load_result() + + assert cached["_dispatch_success_reasons"] == expected_reasons + assert cached["stop_step_semantics"] == semantics + if cached["_dispatch_success_reasons"] is not None: + cached["_dispatch_success_reasons"].append("second_caller_mutation") + third = load_result() + assert third["_dispatch_success_reasons"] == expected_reasons + + +def test_surviving_nested_sub_recipe_placeholder_invalidates_load(tmp_path: Path) -> None: + """One-level composition cannot serve an unchecked nested placeholder.""" + from autoskillit.recipe._api import load_and_validate + + _setup_project_recipe( + tmp_path, + "nested-placeholder-parent", + """\ +name: nested-placeholder-parent +description: parent with a composed child +kitchen_rules: [Follow routes.] +ingredients: + include_child: + description: Include the child. + default: "true" + hidden: true +steps: + child: + sub_recipe: nested-placeholder-child + gate: include_child + on_success: done + on_failure: escalate + done: + action: stop + message: 'Example sentinel: {"success": true}' +""", + ) + sub_recipes = tmp_path / ".autoskillit" / "recipes" / "sub-recipes" + sub_recipes.mkdir() + (sub_recipes / "nested-placeholder-child.yaml").write_text( + """\ +name: nested-placeholder-child +description: child with an unresolved nested placeholder +kitchen_rules: [Follow routes.] +ingredients: + include_nested: + description: Include the nested leaf. + default: "false" +steps: + nested: + sub_recipe: nested-placeholder-leaf + gate: include_nested + on_success: done + on_failure: escalate +""" + ) + (sub_recipes / "nested-placeholder-leaf.yaml").write_text( + """\ +name: nested-placeholder-leaf +description: leaf that composition does not recursively expand +kitchen_rules: [Follow routes.] +steps: + done: + action: stop + message: 'Example sentinel: {"success": true, "reason": "leaf_complete"}' +""" + ) + + result = load_and_validate( + "nested-placeholder-parent", + project_dir=tmp_path, + temp_dir=tmp_path / "cache", + ingredient_overrides={"include_child": "true"}, + ) + + assert result["valid"] is False + assert any("sub_recipe" in error.lower() for error in result["errors"]) + assert result.get("_dispatch_success_reasons") is None + + +def test_recipe_repository_result_keeps_internal_success_reasons( + tmp_path: Path, fresh_load_cache +) -> None: + """The fleet-facing repository retains the loader's internal declaration list.""" + from autoskillit.recipe.repository import DefaultRecipeRepository + + _setup_project_recipe( + tmp_path, + "repository-declaration", + """\ +name: repository-declaration +description: recipe result used by repository callers +kitchen_rules: [Follow routes.] +steps: + done: + action: stop + message: 'Example sentinel: {"success": true, "reason": "repository_done"}' +""", + ) + + result = DefaultRecipeRepository().load_and_validate( + "repository-declaration", + tmp_path, + temp_dir=tmp_path / "cache", + ) + + assert result["_dispatch_success_reasons"] == ["repository_done"] + + def test_load_result_excludes_pruned_stop_semantics(tmp_path: Path) -> None: """Stop guidance excludes guarded stops removed from the active recipe.""" from autoskillit.recipe._api import load_and_validate diff --git a/tests/recipe/test_bundled_success_reasons.py b/tests/recipe/test_bundled_success_reasons.py new file mode 100644 index 0000000000..03eede219f --- /dev/null +++ b/tests/recipe/test_bundled_success_reasons.py @@ -0,0 +1,38 @@ +"""Declared success reasons in bundled recipe stop examples.""" + +from __future__ import annotations + +import pytest + +from autoskillit.recipe.io import builtin_recipes_dir, load_recipe + +pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] + +_EXPECTED_SUCCESS_REASONS = { + "full-audit": frozenset({"completed"}), + "implement-findings": frozenset({"implement-findings complete"}), + "implementation": frozenset( + {"already_done", "implementation_complete", "merge_unconfirmed", "no_changes"} + ), + "implementation-groups": frozenset({"implementation_groups_complete", "merge_unconfirmed"}), + "merge-prs": frozenset({"merge complete"}), + "planner": frozenset({"planning complete"}), + "remediation": frozenset( + {"already_done", "merge_unconfirmed", "no_changes", "remediation_complete"} + ), + "research": frozenset({"no_pr_url", "research complete"}), + "research-archive": frozenset({"no_pr_url", "research_archive_complete"}), +} + + +def test_bundled_recipe_declarations_match_success_stop_examples() -> None: + """Each bundled recipe reports only reasons present in successful stop examples.""" + recipes = [ + load_recipe(recipe_path) for recipe_path in sorted(builtin_recipes_dir().glob("*.yaml")) + ] + + assert recipes + assert set(_EXPECTED_SUCCESS_REASONS) <= {recipe.name for recipe in recipes} + for recipe in recipes: + actual = recipe.declared_success_reasons() + assert actual == _EXPECTED_SUCCESS_REASONS.get(recipe.name), recipe.name diff --git a/tests/recipe/test_schema.py b/tests/recipe/test_schema.py index dea43e64df..8e51cc7c20 100644 --- a/tests/recipe/test_schema.py +++ b/tests/recipe/test_schema.py @@ -4,6 +4,7 @@ import ast import dataclasses +import json import pathlib import pytest @@ -178,6 +179,164 @@ def test_recipe_step_has_no_on_retry_field() -> None: assert "on_retry" not in field_names +@pytest.mark.parametrize( + ("step_specs", "expected"), + [ + ( + [ + ("valid", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ( + "failed", + "stop", + 'Example sentinel: {"success": false, "reason": "failure"}', + ), + ], + frozenset({"done"}), + ), + ( + [ + ("valid", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ("reasonless", "stop", 'Example sentinel: {"success": true}'), + ], + frozenset({"done"}), + ), + ( + [ + ("valid", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ("malformed", "stop", "Example sentinel: {not-json}"), + ], + frozenset({"done"}), + ), + ( + [ + ("valid", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ( + "non_string_reason", + "stop", + 'Example sentinel: {"success": true, "reason": 42}', + ), + ], + frozenset({"done"}), + ), + ( + [("reasonless", "stop", 'Example sentinel: {"success": true}')], + None, + ), + ( + [ + ( + "string_success", + "stop", + 'Example sentinel: {"success": "true", "reason": "ignored"}', + ) + ], + None, + ), + ( + [ + ( + "numeric_success", + "stop", + 'Example sentinel: {"success": 1, "reason": "ignored"}', + ) + ], + None, + ), + ( + [], + None, + ), + ( + [ + ( + "confirm", + "confirm", + 'Example sentinel: {"success": true, "reason": "ignored"}', + ) + ], + None, + ), + ( + [("spaced", "stop", 'Example sentinel: {"success": true, "reason": " done "}')], + frozenset({"done"}), + ), + ( + [("blank", "stop", 'Example sentinel: {"success": true, "reason": " "}')], + None, + ), + ( + [ + ("first", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ("duplicate", "stop", 'Example sentinel: {"success": true, "reason": "done"}'), + ], + frozenset({"done"}), + ), + ], + ids=[ + "valid-plus-failure", + "valid-plus-reasonless-success", + "valid-plus-malformed-json", + "valid-plus-non-string-reason", + "reasonless-only", + "non-boolean-string-success", + "non-boolean-numeric-success", + "no-stops", + "non-stop-success-example", + "trim-declared-reason", + "blank-reason", + "duplicate-declaration", + ], +) +def test_recipe_declared_success_reasons(step_specs, expected) -> None: + """Only strict successful stop sentinels with usable reasons are declared.""" + from autoskillit.recipe.schema import Recipe, RecipeStep + + recipe = Recipe( + name="success-reasons", + description="success declaration extraction", + steps={ + name: RecipeStep(action=action, message=message) + for name, action, message in step_specs + }, + ) + + assert recipe.declared_success_reasons() == expected + + +@pytest.mark.parametrize( + "reason", + ["closing } brace", "opening { brace", 'escaped " quote } and \\ backslash', "trailing \\"], +) +def test_declared_success_reasons_preserve_braces_and_escapes_in_strings(reason: str) -> None: + from autoskillit.recipe.schema import Recipe, RecipeStep + + sentinel = json.dumps({"success": True, "reason": reason, "nested": {"values": [1, 2]}}) + recipe = Recipe( + name="quoted-braces", + description="JSON string extraction", + steps={"done": RecipeStep(action="stop", message=f"Example sentinel: {sentinel}")}, + ) + + assert recipe.declared_success_reasons() == frozenset({reason}) + + +@pytest.mark.parametrize("trigger", ["Sentinel:", "SENTINEL JSON:", "EXAMPLE SENTINEL:"]) +def test_declared_success_reasons_accept_case_variations_in_trigger(trigger: str) -> None: + from autoskillit.recipe.schema import Recipe, RecipeStep + + recipe = Recipe( + name="capitalized-sentinel", + description="case-insensitive sentinel triggers", + steps={ + "done": RecipeStep( + action="stop", message=f'{trigger} {{"success": true, "reason": "done"}}' + ) + }, + ) + + assert recipe.declared_success_reasons() == frozenset({"done"}) + + def test_step_retry_dataclass_removed() -> None: """StepRetry dataclass must not exist in recipe.schema.""" import autoskillit.recipe.schema as schema diff --git a/tests/server/test_progress_heartbeat_wiring.py b/tests/server/test_progress_heartbeat_wiring.py index 1a1eeeeb20..533fb15fad 100644 --- a/tests/server/test_progress_heartbeat_wiring.py +++ b/tests/server/test_progress_heartbeat_wiring.py @@ -75,7 +75,6 @@ async def _slow_execute_dispatch(**kwargs): await anyio.sleep(0.03) return DispatchResult( DispatchCompleted( - success=True, dispatch_status=DispatchStatus.SUCCESS, dispatch_id="d1", dispatched_session_id="s1", diff --git a/tests/server/test_tools_dispatch_halt.py b/tests/server/test_tools_dispatch_halt.py index dd49aadc0c..fbfd9a4367 100644 --- a/tests/server/test_tools_dispatch_halt.py +++ b/tests/server/test_tools_dispatch_halt.py @@ -39,6 +39,45 @@ def _write_campaign_state(state_path: Path, dispatches: list[dict]) -> None: ) +def _setup_declared_campaign_dispatch(tool_ctx, recipe_name: str) -> None: + from autoskillit.recipe.repository import DefaultRecipeRepository + + tool_ctx.worker_capacity = DefaultManagedWorkerCapacity(max_concurrent=1) + recipe_dir = tool_ctx.project_dir / ".autoskillit" / "recipes" + recipe_dir.mkdir(parents=True, exist_ok=True) + (recipe_dir / f"{recipe_name}.yaml").write_text( + f"""\ +name: {recipe_name} +description: A recipe with one declared success terminal. +kind: standard +kitchen_rules: [Stop after the successful terminal is reached.] +ingredients: {{}} +steps: + complete: + action: stop + message: 'Example sentinel: {{"success": true, "reason": "campaign_recipe_complete"}}' +""" + ) + repo = DefaultRecipeRepository() + tool_ctx.recipes = repo + tool_ctx.executor = InMemoryHeadlessExecutor() + validation_result = repo.load_and_validate( + recipe_name, + tool_ctx.project_dir, + temp_dir=tool_ctx.temp_dir, + ) + error_findings = [ + item + for item in validation_result.get("suggestions", []) + if item.get("severity") == "error" + ] + assert validation_result.get("valid") is True, ( + "Historical-success fixture must pass the real recipe loader; " + f"errors={validation_result.get('errors')!r}, findings={error_findings!r}" + ) + assert validation_result.get("_dispatch_success_reasons") == ["campaign_recipe_complete"] + + class TestDispatchFoodTruckHaltEnforcement: @pytest.fixture(autouse=True) def _set_fleet_session(self, monkeypatch): @@ -105,6 +144,110 @@ async def test_dispatch_proceeds_when_no_failures_in_state( result = json.loads(await dispatch_food_truck(recipe="test-recipe", task="t")) assert "dispatch_id" in result + @pytest.mark.parametrize( + ("stored_reason", "message_fragment"), + [ + pytest.param("", "missing", id="empty-reason"), + pytest.param("other_recipe_complete", "not declared", id="undeclared-reason"), + ], + ) + @pytest.mark.anyio + async def test_campaign_shortcut_rejects_invalid_success_without_mutation_or_commit( + self, + tool_ctx_kitchen_open, + monkeypatch, + tmp_path, + stored_reason, + message_fragment, + ): + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.server.tools.tools_fleet_dispatch import dispatch_food_truck + + recipe_name = "declared-campaign-recipe" + state_path = tmp_path / "campaign-state.json" + monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_STATE_PATH", str(state_path)) + monkeypatch.setenv("AUTOSKILLIT_CONTINUE_ON_FAILURE", "false") + _setup_declared_campaign_dispatch(tool_ctx_kitchen_open, recipe_name) + write_initial_state( + state_path, + tool_ctx_kitchen_open.kitchen_id, + "test-campaign", + "/fake/manifest.yaml", + [ + DispatchRecord( + name=recipe_name, + status=DispatchStatus.SUCCESS, + dispatch_id="historical-campaign-dispatch", + dispatched_session_id="historical-campaign-session", + reason=stored_reason, + ) + ], + ) + original_state = state_path.read_bytes() + + envelope = json.loads( + await dispatch_food_truck( + recipe=recipe_name, + task="t", + dispatch_name=recipe_name, + ) + ) + + assert envelope["success"] is False + assert envelope["error"] == "fleet_l3_result_contract_violation" + assert envelope["dispatch_id"] == "historical-campaign-dispatch" + assert message_fragment in envelope["user_visible_message"] + assert tool_ctx_kitchen_open.executor.dispatch_calls == [] + assert state_path.read_bytes() == original_state + assert not any( + effect["name"] == "commit" and effect["phase"] == "confirmed" + for effect in envelope["effect_provenance"]["effects"] + ) + + @pytest.mark.anyio + async def test_campaign_shortcut_reuses_declared_reason_without_rewriting_history( + self, tool_ctx_kitchen_open, monkeypatch, tmp_path + ): + from autoskillit.fleet import DispatchRecord, DispatchStatus, write_initial_state + from autoskillit.server.tools.tools_fleet_dispatch import dispatch_food_truck + + recipe_name = "declared-campaign-recipe" + state_path = tmp_path / "campaign-state.json" + monkeypatch.setenv("AUTOSKILLIT_CAMPAIGN_STATE_PATH", str(state_path)) + monkeypatch.setenv("AUTOSKILLIT_CONTINUE_ON_FAILURE", "false") + _setup_declared_campaign_dispatch(tool_ctx_kitchen_open, recipe_name) + write_initial_state( + state_path, + tool_ctx_kitchen_open.kitchen_id, + "test-campaign", + "/fake/manifest.yaml", + [ + DispatchRecord( + name=recipe_name, + status=DispatchStatus.SUCCESS, + dispatch_id="historical-campaign-dispatch", + dispatched_session_id="historical-campaign-session", + reason="campaign_recipe_complete", + ) + ], + ) + original_state = state_path.read_bytes() + + envelope = json.loads( + await dispatch_food_truck( + recipe=recipe_name, + task="t", + dispatch_name=recipe_name, + ) + ) + + assert envelope["success"] is True + assert envelope["dispatch_status"] == "success" + assert envelope["reason"] == "campaign_recipe_complete" + assert envelope["dispatch_id"] == "historical-campaign-dispatch" + assert tool_ctx_kitchen_open.executor.dispatch_calls == [] + assert state_path.read_bytes() == original_state + @pytest.mark.anyio async def test_dispatch_proceeds_when_state_file_missing( self, tool_ctx_kitchen_open, monkeypatch, tmp_path @@ -150,7 +293,6 @@ async def test_campaign_state_write_uses_envelope_dispatch_status( AsyncMock( return_value=DispatchResult( outcome=DispatchCompleted( - success=False, dispatch_status=_DS.RESUMABLE, dispatch_id="test-dispatch-id", dispatched_session_id="sess-abc", diff --git a/tests/server/test_tools_kitchen_recipe_admission.py b/tests/server/test_tools_kitchen_recipe_admission.py index e4cdad84ae..ccc810d482 100644 --- a/tests/server/test_tools_kitchen_recipe_admission.py +++ b/tests/server/test_tools_kitchen_recipe_admission.py @@ -49,6 +49,7 @@ async def test_open_kitchen_with_recipe_returns_combined_response(tmp_path, monk "valid": True, "suggestions": [], "diagram": None, + "_dispatch_success_reasons": ["recipe_complete"], } mock_ctx.recipes.load_and_validate.return_value = _with_finalized_projection( mock_ctx.recipes.load_and_validate.return_value, @@ -68,6 +69,7 @@ async def test_open_kitchen_with_recipe_returns_combined_response(tmp_path, monk result = json.loads(result_str) assert result["success"] is True assert result["kitchen"] == "open" + assert "_dispatch_success_reasons" not in result assert "version" in result assert "content" in result assert "test-recipe" in result["content"] diff --git a/tests/server/test_tools_load_recipe.py b/tests/server/test_tools_load_recipe.py index cbd4168001..bf3bd90403 100644 --- a/tests/server/test_tools_load_recipe.py +++ b/tests/server/test_tools_load_recipe.py @@ -99,6 +99,21 @@ async def test_load_recipe_rejects_skill_names_before_recipe_work( - "Use AutoSkillit tools only" """ +_DISPATCH_REASON_RECIPE = """\ +name: dispatch-reason +description: Recipe declaring an allowed successful dispatch result +kitchen_rules: + - >- + Never use native Read, Grep, Glob, Edit, Write, Bash, Agent, WebFetch, WebSearch, + or NotebookEdit. +steps: + done: + action: stop + message: >- + Done. Example sentinel: + {"success": true, "reason": "dispatch_reason_complete"} +""" + class TestLoadRecipeTools: """Tests for kitchen-gated load_recipe tool.""" @@ -121,6 +136,37 @@ async def test_load_returns_json_with_content(self, tmp_path, monkeypatch): assert "name: test" in result["content"] assert "description: Test recipe" in result["content"] + @pytest.mark.anyio + async def test_dispatch_success_reasons_are_server_carrier_only( + self, tmp_path, monkeypatch, tool_ctx_kitchen_open + ): + """Server callers can read declared reasons while load_recipe omits the carrier.""" + monkeypatch.chdir(tmp_path) + recipes_dir = tmp_path / ".autoskillit" / "recipes" + recipes_dir.mkdir(parents=True) + (recipes_dir / "dispatch-reason.yaml").write_text(_DISPATCH_REASON_RECIPE) + + from autoskillit.server.tools._serve_helpers import serve_recipe + + loaded = serve_recipe( + tool_ctx_kitchen_open, + "dispatch-reason", + caller_overrides=None, + config_default={}, + session_overrides={}, + config_layer={}, + resolved_defaults={}, + temp_dir=tool_ctx_kitchen_open.temp_dir, + ) + response = json.loads(await load_recipe(name="dispatch-reason")) + + assert loaded["valid"] is True, ( + f"errors={loaded.get('errors')!r}; suggestions={loaded.get('suggestions')!r}" + ) + assert loaded["_dispatch_success_reasons"] == ["dispatch_reason_complete"] + assert response["valid"] is True + assert "_dispatch_success_reasons" not in response + # SS3 @pytest.mark.anyio async def test_load_unknown_preserves_active_execution( @@ -270,12 +316,10 @@ async def test_load_recipe_ingredients_only_strips_content(self, tmp_path, monke monkeypatch.chdir(tmp_path) recipes_dir = tmp_path / ".autoskillit" / "recipes" recipes_dir.mkdir(parents=True) - (recipes_dir / "test.yaml").write_text( - "name: test\ndescription: Test recipe\nsteps:\n" - " done:\n action: stop\n message: Done\n" - ) - result = json.loads(await load_recipe(name="test", ingredients_only=True)) + (recipes_dir / "dispatch-reason.yaml").write_text(_DISPATCH_REASON_RECIPE) + result = json.loads(await load_recipe(name="dispatch-reason", ingredients_only=True)) assert "content" not in result assert "orchestration_rules" not in result assert "stop_step_semantics" not in result + assert "_dispatch_success_reasons" not in result assert "suggestions" in result diff --git a/tests/server/test_tools_recipe_pull.py b/tests/server/test_tools_recipe_pull.py index b536679250..f619639ffb 100644 --- a/tests/server/test_tools_recipe_pull.py +++ b/tests/server/test_tools_recipe_pull.py @@ -253,6 +253,7 @@ def test_compile_identity_and_artifact_share_one_source_projection(tool_ctx) -> payload = _payload() payload["custom_generation_input"] = {"value": 3} payload["initialization_id"] = "caller-owned-stale-id" + payload["_dispatch_success_reasons"] = ["recipe_complete"] prepared = prepare_recipe_delivery_generation( payload, @@ -267,6 +268,18 @@ def test_compile_identity_and_artifact_share_one_source_projection(tool_ctx) -> assert prepared.canonical_artifact_payload["custom_generation_input"] == {"value": 3} assert "initialization_id" not in source_payload assert "initialization_id" not in prepared.canonical_artifact_payload + assert "_dispatch_success_reasons" not in source_payload + assert "_dispatch_success_reasons" not in prepared.canonical_artifact_payload + + _, finalized = _persist_finalized_generation(tool_ctx, payload) + assert finalized.normalized_compile_key == prepared.normalized_compile_key + + artifact_root = recipe_artifact._artifact_root(tool_ctx.temp_dir) + payload_files = list(artifact_root.rglob("payload.json")) + assert len(payload_files) == 1 + persisted = json.loads(payload_files[0].read_text(encoding="utf-8")) + assert persisted == prepared.canonical_artifact_payload + assert "_dispatch_success_reasons" not in persisted def _persist_finalized_generation(