Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
120f2c7
feat: validate rollout declarations and extend session record schema
Trecek Oct 10, 2026
461932b
feat: persist rollout markers in session summaries and indexes
Trecek Oct 10, 2026
e7a905c
feat: preserve rollout snapshots through runners and crash recovery
Trecek Oct 10, 2026
894153d
refactor: isolate terminal persistence and prepare launch snapshot ca…
Trecek Oct 10, 2026
bfd7ddb
feat: capture rollout declarations after headless launch admission
Trecek Oct 10, 2026
b74df8f
docs: explain rollout declarations and retained cohort comparisons
Trecek Oct 10, 2026
3476cd9
fix: align rollout fixtures and schema guards with launch snapshots
Trecek Oct 10, 2026
a1b7e3e
test: verify direct binding scopes and rollout config consumers
Trecek Oct 10, 2026
9f3ba32
fix(review): remove repeated rollout caution in diagnostics
Trecek Oct 10, 2026
7759f70
fix(review): remove repeated rollout caution in observability
Trecek Oct 10, 2026
826d590
fix(review): diagnose unreadable enrollment sidecars
Trecek Oct 10, 2026
a0e2ecc
fix(review): type terminal persistence context
Trecek Oct 10, 2026
7a5967c
fix(review): share unmarked rollout snapshot helper
Trecek Oct 10, 2026
76276a7
fix(review): share repeated rollout marker test payloads
Trecek Oct 10, 2026
676fd48
fix(review): inventory guarded terminal context import
Trecek Oct 10, 2026
f751b40
fix(ci): provision shared memory for E2E test prechecks
Trecek Oct 10, 2026
9cabb25
fix(test): preserve lifecycle test parametrization
Trecek Oct 10, 2026
10ab0c5
fix(test): allow provisioned E2E Docker arguments
Trecek Oct 11, 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
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
TEST: ${{ matrix.test }}
run: |
set -euo pipefail
docker_args=()
docker_args=(--shm-size=3g)
if [[ '${{ matrix.kind }}' != 'clean-install' ]]; then
docker_args+=(--env MINIMAX_API_KEY --env E2E_SANDBOX_TOKEN)
docker_args+=(--volume "$RUNNER_TEMP/e2e/data:/home/autoskillit/.local/share/autoskillit")
Expand Down
64 changes: 63 additions & 1 deletion docs/developer/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,68 @@ Logs are stored in a **global** directory (not per-project), so they persist acr

`summary.json` contains: `session_id`, `dir_name`, `pid`, `cwd`, `evidence_worktree_path`, `evidence_worktree_source`, `evidence_worktree_detail`, `skill_command`, `success`, `subtype`, `exit_code`, `start_ts`, `snapshot_count`, `anomaly_count`, `peak_rss_kb`, `peak_oom_score`, `peak_fd_ratio`, `session_type`, `session_errors`, `child_outcomes` (see [Child Terminal Reasons](#child-terminal-reasons)).

### Rollout declarations

`summary.json.rollout` and `sessions.jsonl.rollout` store the same optional
declaration. Configure it in `.autoskillit/config.yaml`:

```yaml
rollout:
active:
change_id: "prompt-cost-study"
effective_at: "2026-11-01T18:00:00Z"
scope:
skills: ["make-plan"]
backends: ["claude-code", "codex"]
roles: ["planner"]
expected_effect: "Reduce planning input tokens while preserving requirement coverage."
```

The stored object has these same fields, with `effective_at` normalized to UTC
ISO-8601 (`2026-11-01T18:00:00+00:00` here). An active declaration must be complete;
`change_id` and `expected_effect` must be nonblank strings. Scope has exactly the
`skills`, `backends`, and `roles` lists of nonblank strings; empty lists are valid.
Scope describes the change and does not filter launches. All admitted Claude Code
and Codex headless runs carry the active marker, including launched failures.
Missing `rollout`, absent/null `active`, or clearing `active` means inactive.
New inactive records store null; older rows without the field remain unassigned.

Capture occurs after final launch admission, immediately before the first runner
call. The marker and logical start remain fixed through the same-run nudge and
terminal logging even if configuration changes. Summary `start_ts`, index
`timestamp`, and trace enrollment `logical_run_start_ts` share that instant;
operation timing, nudge-turn timing, and enrollment `enrolled_at` remain separate.
Enrollment schema 3 preserves the marker/start for crash recovery. Older sidecars
recover without a marker and retain the historical trace-mtime fallback.

Declare the prediction and stable change id **before** cutover, launch marked
baseline work, apply the change at the stated UTC boundary, and retain the same
declaration for after runs. Clear `active` when observation ends. A revised
prediction, scope, or cutover requires a new `change_id`; stability across
independent runs is the operator's responsibility. A pre-cutover marked row is
evidence that its prediction was recorded beforehand. A current config or first
after row alone cannot prove that timing; file mtimes do not provide declaration
history. Keeping the prediction allows comparison with observations without
replacing it with an explanation developed after seeing outcomes.

Use the stored run start and cutover as parsed timezone-aware instants, preserving
fractional seconds. The canonical comparison is:

```python
started_at = datetime.fromisoformat(row["timestamp"])
cutover_at = datetime.fromisoformat(rollout["effective_at"])
if started_at < cutover_at:
cohort = "before"
elif started_at >= cutover_at:
cohort = "after"
```

A before/after delta is a prompt to investigate, not proof that the change caused it.
Work difficulty, repository state, and model versions can differ across cohorts.
See the [streaming retained-index query](../operations/observability.md#rollout-cohorts)
for validation and bounded aggregation. Never infer marker membership from hashes,
repository revisions, mtimes, or guessed dates.

### Execution-candidate manifests

Before evaluating a primary binding or configured alternative, `run_skill`
Expand Down Expand Up @@ -178,7 +240,7 @@ Completed sessions with per-request evidence publish `turn_usage.jsonl` before
version-4 token descriptor carries `turn_usage_file`, `turn_usage_count`, and
`turn_usage_schema_version`. The file reference is `null` with count zero when
no series was observed. The JSONL row schema is version 2; the retained
`sessions.jsonl` index uses schema version 16.
`sessions.jsonl` index uses schema version 17.

Each token-bearing row carries non-empty `backend` and `provider_used`, plus
nullable source `message_id`, `request_id`, `timestamp`, and observed `model`.
Expand Down
102 changes: 101 additions & 1 deletion docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ a named normalization contract. A ratio with an `unknown` required measure
renders `unknown`; `unavailable` and `not_applicable` scopes do not contribute
to its denominator, and an observed zero denominator renders `—`.

Token descriptor schema v4, turn sidecar schema v2, session index schema v14,
Token descriptor schema v4, turn sidecar schema v2, session index schema v17,
fleet campaign state v13, and campaign summary v2 retain these records. Legacy
numeric zero is decoded as `unknown` because older formats did not prove an
observation. `context_window_tokens` is positive capacity metadata, not a
Expand Down Expand Up @@ -140,6 +140,106 @@ rows by `dir_name`. Archive-only history excludes the current live survivor
window. The archive retains index metadata, not pruned session artifact
directories, and operational consumers continue to read only `sessions.jsonl`.

## Rollout cohorts

Declare an evaluated change in `.autoskillit/config.yaml` before applying it:

```yaml
rollout:
active:
change_id: "prompt-cost-study"
effective_at: "2026-11-01T18:00:00Z"
scope:
skills: ["make-plan"]
backends: ["claude-code", "codex"]
roles: ["planner"]
expected_effect: "Reduce planning input tokens while preserving requirement coverage."
```

`change_id` identifies an unchanged declaration across the observation window.
`effective_at` is the intentional UTC cutover, not a date inferred from code or
configuration history. `scope` names affected skills, backends, and roles in a
queryable form; its lists describe the change and never govern admission or marker
attachment. `expected_effect` preserves the required, non-null, nonblank prediction
with every run so readers can compare intended effects with subsequent observations.
Empty scope lists are valid. Missing/null `active` is inactive; incomplete or
otherwise invalid active declarations are rejected before generic string coercion.

Declare the prediction and stable id before cutover, launch marked baseline work,
apply the change at the stated UTC boundary, and keep the same declaration active
while observing after runs. Clear `active` when the observation period ends. Use a
new `change_id` for a revised prediction, scope, or cutover. Code validates each
declaration and preserves each admitted run's snapshot; operators maintain stability
across independent runs. Recording the prediction beforehand distinguishes intended
effects from explanations developed after seeing outcomes. A pre-cutover marked
row supplies that evidence; a current configuration or first after row alone does
not establish preregistration.

Summary and index records store the same nested `rollout` object with normalized
UTC `effective_at`. Capture happens immediately before the first admitted launch;
all headless runs then carry it, including failures and runs outside the descriptive
scope. Same-run nudges and recovery keep that snapshot and logical start despite
later configuration changes. The index `timestamp` and summary `start_ts` identify
the admitted logical run. Operation, nudge-turn, and trace-enrollment times have
different purposes. A subsequent independent run captures the current declaration.
New unmarked rows contain null; older absent fields never imply cohort membership.

This query counts one explicit change id while streaming the live retained index.
Set `index_path` to `linux_tracing.log_dir / "sessions.jsonl"` if overriding the log
root. It skips reader non-row entries and absent/null markers, and raises a visible
error for malformed selected declarations or timestamps. It covers retained rows
and excludes archived history; memory and printed output do not grow with row count.

```python
from datetime import datetime, timedelta

from autoskillit.core import default_log_dir
from autoskillit.execution.session_log.session_index import iter_tolerant_session_index_lines

index_path = default_log_dir() / "sessions.jsonl"
change_id = "prompt-cost-study"
counts = {"before": 0, "after": 0}
for end_offset, row in iter_tolerant_session_index_lines(index_path):
if row is None:
continue
rollout = row.get("rollout")
if rollout is None:
continue
if not isinstance(rollout, dict):
raise ValueError(f"Malformed rollout at byte {end_offset}")
if rollout["change_id"] != change_id:
continue
if set(rollout) != {"change_id", "effective_at", "scope", "expected_effect"}:
raise ValueError(f"Incomplete rollout at byte {end_offset}")
prediction = rollout["expected_effect"]
scope = rollout["scope"]
if not isinstance(prediction, str) or not prediction.strip():
raise ValueError(f"Invalid prediction at byte {end_offset}")
if not isinstance(scope, dict) or set(scope) != {"skills", "backends", "roles"}:
raise ValueError(f"Invalid scope at byte {end_offset}")
if any(
not isinstance(values, list)
or any(not isinstance(value, str) or not value.strip() for value in values)
for values in scope.values()
):
raise ValueError(f"Invalid scope values at byte {end_offset}")
started_at = datetime.fromisoformat(row["timestamp"])
cutover_at = datetime.fromisoformat(rollout["effective_at"])
if started_at.utcoffset() is None or cutover_at.utcoffset() != timedelta(0):
raise ValueError(f"Expected aware run start and UTC cutover at byte {end_offset}")
if started_at < cutover_at:
counts["before"] += 1
elif started_at >= cutover_at:
counts["after"] += 1
print(counts)
```

A before/after delta is a prompt to investigate, not proof that the change caused it.
Work difficulty, repository state, and model versions can differ between cohorts.
Compare parsed instants, including fractional seconds: equality is after cutover.
Do not manufacture cohorts from content hashes, repository revisions, file mtimes,
or guessed dates. Existing aggregate trends do not consume rollout markers.

## Native headless OTLP capture

Every headless executor invocation starts one best-effort OTLP/HTTP-JSON sink
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
ResetWorkspaceConfig,
RetiredConfigKeyDef,
ReviewConfig,
RolloutConfig,
RunSkillConfig,
SafetyConfig,
SkillsConfig,
Expand Down Expand Up @@ -104,6 +105,7 @@
"RetiredConfigKeyDef",
"ReviewConfig",
"RunSkillConfig",
"RolloutConfig",
"SafetyConfig",
"SkillsConfig",
"SubsetsConfig",
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/config/_automation_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from autoskillit.config._dataclasses_execution import (
CodexRuntimeConfig,
QuotaGuardConfig,
RolloutConfig,
RunSkillConfig,
)
from autoskillit.config._dataclasses_fleet import FleetConfig, ProcessTetherConfig
Expand Down Expand Up @@ -181,6 +182,7 @@ class AutomationConfig:
safety: SafetyConfig = field(default_factory=SafetyConfig)
read_db: ReadDbConfig = field(default_factory=ReadDbConfig)
run_skill: RunSkillConfig = field(default_factory=RunSkillConfig)
rollout: RolloutConfig = field(default_factory=RolloutConfig)
model: CoreRunConfig = field(default_factory=CoreRunConfig)
worktree_setup: WorktreeSetupConfig = field(default_factory=WorktreeSetupConfig)
migration: MigrationConfig = field(default_factory=MigrationConfig)
Expand Down
60 changes: 59 additions & 1 deletion src/autoskillit/config/_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@

import dataclasses
import types
from collections.abc import Callable
from collections.abc import Callable, Mapping
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, TypeVar, Union, get_args, get_origin, get_type_hints

import regex as re

from autoskillit.config._config_loader import (
_build_packs_config,
_build_subsets_config,
_to_optional_commands,
)
from autoskillit.config._dataclasses_errors import ConfigSchemaError
from autoskillit.config._dataclasses_test_gating import _COMMAND_UNSET
from autoskillit.core import RolloutMarker

if TYPE_CHECKING:
from _typeshed import DataclassInstance
Expand Down Expand Up @@ -190,10 +194,64 @@ def _coerce_value(value: Any, target_type: type, context: str) -> Any:
}


def _rollout_cutover_utc(cutover: Any) -> str:
if not isinstance(cutover, str) or re.search(r"(?:Z|[+-]00:00)$", cutover) is None:
raise ConfigSchemaError(
"rollout.active.effective_at must be an explicit UTC ISO-8601 timestamp"
)
try:
instant = datetime.fromisoformat(cutover.replace("Z", "+00:00"))
except ValueError as exc:
raise ConfigSchemaError(
"rollout.active.effective_at must be an explicit UTC ISO-8601 timestamp"
) from exc
if instant.utcoffset() != timedelta(0):
raise ConfigSchemaError("rollout.active.effective_at must be UTC")
return instant.astimezone(UTC).isoformat()


def _build_rollout_active(
section: dict[str, Any], defaults: dict[str, Any]
) -> RolloutMarker | None:
raw = section.get("active", defaults.get("active"))
if raw is None:
return None
keys = {"change_id", "effective_at", "scope", "expected_effect"}
if not isinstance(raw, Mapping) or set(raw) != keys:
raise ConfigSchemaError(
"rollout.active must contain exactly change_id, effective_at, scope, expected_effect"
)
for name in ("change_id", "expected_effect"):
if not isinstance(raw[name], str) or not raw[name].strip():
raise ConfigSchemaError(f"rollout.active.{name} must be a nonblank string")
effective_at = _rollout_cutover_utc(raw["effective_at"])
scope = raw["scope"]
if not isinstance(scope, Mapping) or set(scope) != {"skills", "backends", "roles"}:
raise ConfigSchemaError(
"rollout.active.scope must contain exactly skills, backends, roles"
)
copied_scope: dict[str, list[str]] = {}
for axis, values in scope.items():
if not isinstance(values, list) or any(
not isinstance(value, str) or not value.strip() for value in values
):
raise ConfigSchemaError(
f"rollout.active.scope.{axis} must be a list of nonblank strings"
)
copied_scope[axis] = list(values)
return RolloutMarker(
change_id=raw["change_id"],
effective_at=effective_at,
scope=copied_scope,
expected_effect=raw["expected_effect"],
)


# Custom field builders that bypass _coerce_value.
# Signature: (section_dict, defaults_dict) -> coerced_value
# The override is responsible for its own key lookup from section_dict.
_FIELD_OVERRIDES: dict[tuple[str, str], Callable[[dict[str, Any], dict[str, Any]], Any]] = {
("rollout", "active"): _build_rollout_active,
# YAML key "default" with None-means-unset semantic.
# ``defs.get(...)`` (no explicit fallback) is structurally defensive — if the
# dataclass field is ever removed without updating this lambda, the .get()
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/config/_config_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
from autoskillit.config._dataclasses_execution import (
QuotaGuardConfig as QuotaGuardConfig,
)
from autoskillit.config._dataclasses_execution import (
RolloutConfig as RolloutConfig,
)
from autoskillit.config._dataclasses_execution import (
RunSkillConfig as RunSkillConfig,
)
Expand Down
7 changes: 6 additions & 1 deletion src/autoskillit/config/_dataclasses_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from dataclasses import dataclass, field
from typing import ClassVar, Literal, cast

from autoskillit.core import CodexRuntimeSpec, OutputFormat
from autoskillit.core import CodexRuntimeSpec, OutputFormat, RolloutMarker


@dataclass
class RolloutConfig:
active: RolloutMarker | None = None


@dataclass
Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@
from autoskillit.config._config_dataclasses import (
ReviewConfig as ReviewConfig,
)
from autoskillit.config._config_dataclasses import (
RolloutConfig as RolloutConfig,
)
from autoskillit.config._config_dataclasses import (
RunSkillConfig as RunSkillConfig,
)
Expand Down Expand Up @@ -271,6 +274,7 @@
"RetiredConfigKeyDef",
"ReviewConfig",
"RunSkillConfig",
"RolloutConfig",
"SafetyConfig",
"SkillsConfig",
"SubsetsConfig",
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,7 @@ from .types import ReviewOperationState as ReviewOperationState
from .types import ReviewReconciliationResult as ReviewReconciliationResult
from .types import ReviewResponseClass as ReviewResponseClass
from .types import RollbackAdmissionEvent as RollbackAdmissionEvent
from .types import RolloutMarker as RolloutMarker
from .types import RolloverEpochEvent as RolloverEpochEvent
from .types import RunSkillCompletionAuthority as RunSkillCompletionAuthority
from .types import RuntimeAdmission as RuntimeAdmission
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/core/types/execution/_type_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any, Protocol, runtime_checkable

from ..foundation._type_enums import ChannelConfirmation, KillReason, TerminationReason
from ..results._type_results_records import RolloutMarker
from ._type_inspector import InspectorCallback, InspectorVerdict

__all__ = [
Expand Down Expand Up @@ -258,6 +259,8 @@ def __call__(
completion_drain_timeout: float = 5.0,
natural_exit_grace_seconds: float = 3.0,
linux_tracing_config: Any | None = None,
rollout: RolloutMarker | None = None,
logical_run_start_ts: str = "",
idle_output_timeout: float | None = None,
max_suppression_seconds: float | None = None,
on_process_spawned: Callable[[int, int], None] | None = None,
Expand Down
Loading
Loading