Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
397fd0d
refactor: colocate recipe sentinel extraction with domain schema
Trecek Oct 14, 2026
2824cef
feat: expose active recipe success declarations to dispatch
Trecek Oct 14, 2026
36f2f07
test: repair recipe declaration fixtures and synchronize lock contention
Trecek Oct 14, 2026
aab53a3
test: make the public dispatch-reason recipe fixture valid
Trecek Oct 14, 2026
a02c517
fix: verify fleet success claims against active recipe declarations
Trecek Oct 14, 2026
6a0c536
test: align fleet contract fixtures with active declarations
Trecek Oct 14, 2026
16d2a54
fix: block Claude orchestrator auto-compaction before replacement
Trecek Oct 14, 2026
a453ede
fix: align compaction guard with scope and registry contracts
Trecek Oct 14, 2026
bc6c940
refactor: colocate headless result context with adjudication
Trecek Oct 14, 2026
b1e4157
fix: make native fleet context failures terminal and non-resumable
Trecek Oct 14, 2026
2490789
test: align native context contracts and route captured fixtures
Trecek Oct 14, 2026
ce39c68
fix: reject legacy-compacted sessions using authoritative dispatch id…
Trecek Oct 14, 2026
301e291
fix: persist refused resume outcomes from resumable dispatches
Trecek Oct 14, 2026
08f593e
refactor(review): colocate context exhaustion outcome evidence
Trecek Oct 15, 2026
d06e0d5
refactor(review): remove unused sentinel regex forwarding exports
Trecek Oct 15, 2026
40757a2
fix(review): preserve quoted braces in sentinel JSON examples
Trecek Oct 15, 2026
0d83e37
fix(review): accept case variations in sentinel example triggers
Trecek Oct 15, 2026
5594e04
docs(review): explain resolved transcript error propagation
Trecek Oct 15, 2026
7bc385f
refactor(review): share first resolved session locator lookup
Trecek Oct 15, 2026
4b0e316
refactor(review): preserve success reason sets through prompt constru…
Trecek Oct 15, 2026
b2325b0
refactor(review): share compacted resume refusal handling
Trecek Oct 15, 2026
f0f6524
test(review): remove repeated research archive reason assertion
Trecek Oct 15, 2026
5fccb21
test: persist compile projection through context-owned recipe finaliz…
Trecek Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .autoskillit/test-filter-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
11 changes: 10 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/decisions/0004-recipe-redelivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
21 changes: 18 additions & 3 deletions docs/design/acp-session-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
23 changes: 19 additions & 4 deletions docs/execution/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|-------------------------------|----------|
Expand All @@ -87,14 +87,29 @@ 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 |

`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
Expand Down
38 changes: 25 additions & 13 deletions docs/safety/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -570,19 +581,20 @@ 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 |
| `notify` | Stop, PostToolUse | `systemMessage`, with optional nested PostToolUse context, exit 0 |
| `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
Expand Down
14 changes: 14 additions & 0 deletions src/autoskillit/cli/prompts/_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/cli/prompts/_prompts_campaign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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=<guidance>`. 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:
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/cli/prompts/_prompts_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from autoskillit.cli.prompts._prompts import (
_MCP_RETRY_INSTRUCTION,
CONTEXT_EXHAUSTION_RECOVERY_CLAUSE,
_backend_supplement,
_read_full_sous_chef,
)
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/types/foundation/_type_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions src/autoskillit/core/types/launch/_type_dispatch_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/types/results/_type_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions src/autoskillit/execution/backends/_claude/session_locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading