Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ projects need.

autoskillit doctor

Doctor runs 33 checks (23 numbered + 5 lettered sub-checks: `2b`, `2c`, `2d`, `4b`, `7b` + 5 backend runtime probes, checks 30–34); up to 39 with the fleet feature enabled.
Doctor runs 34 checks (23 numbered + 5 lettered sub-checks: `2b`, `2c`, `2d`, `4b`, `7b` + 6 backend runtime probes, checks 30–35); up to 40 with the fleet feature enabled.
Enumerated by `run_doctor` in `src/autoskillit/cli/doctor/__init__.py`:

| # | Check | What it verifies |
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/cli/doctor/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# doctor/

Diagnostic health checks for the autoskillit installation (34 checks).
Diagnostic health checks for the autoskillit installation (35 checks).

## Files

Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/cli/doctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
_check_claude_process_state_breakdown,
_check_cli_conformance_probes,
_check_codex_graduation,
_check_codex_ndjson_drift,
_check_codex_version,
_check_quota_cache_schema,
_check_script_binary,
Expand Down Expand Up @@ -221,6 +222,9 @@ def run_doctor(*, output_json: bool = False) -> None:
# Check 34: CLI config-acceptance probe
results.append(_check_cli_conformance_probes(backend=_backend))

# Check 35: Codex NDJSON vocabulary drift
results.append(_check_codex_ndjson_drift(log_dir=cfg.linux_tracing.log_dir, backend=_backend))

# Output
if output_json:
print(
Expand Down
44 changes: 44 additions & 0 deletions src/autoskillit/cli/doctor/_doctor_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,47 @@ def _check_cli_conformance_probes(*, backend: CodingAgentBackend | None = None)
check_name,
f"{cli_binary} rejected config probe (exit {result.returncode})",
)


def _check_codex_ndjson_drift(
*,
log_dir: str = "",
backend: CodingAgentBackend | None = None,
) -> DoctorResult:
check_name = "codex_ndjson_drift"
if backend is None:
return DoctorResult(Severity.OK, check_name, "Skipped (no codex backend)")
backend_name = backend.name
log_root = Path(log_dir).expanduser() if log_dir else default_log_dir()
sessions_path = log_root / "sessions.jsonl"
if not sessions_path.exists():
return DoctorResult(Severity.OK, check_name, "No sessions.jsonl found")

affected = 0
try:
for line in sessions_path.read_text().splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if entry.get("backend") != backend_name:
continue
if (
entry.get("ndjson_unknown_event_count", 0) > 0
or entry.get("ndjson_unknown_item_count", 0) > 0
):
affected += 1
except OSError:
return DoctorResult(Severity.OK, check_name, "Could not read sessions.jsonl")

if affected > 0:
return DoctorResult(
Severity.WARNING,
check_name,
f"{affected} codex session(s) contain unknown NDJSON event types"
" — parser vocabulary may be stale",
)
return DoctorResult(Severity.OK, check_name, "No NDJSON vocabulary drift detected")
1 change: 1 addition & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ from .types import ModelIdentity as ModelIdentity
from .types import ModelTotalEntry as ModelTotalEntry
from .types import ModelTranslation as ModelTranslation
from .types import NamedResume as NamedResume
from .types import NdjsonDriftOutcome as NdjsonDriftOutcome
from .types import NoResume as NoResume
from .types import OutputFormat as OutputFormat
from .types import OutputPatternResolver as OutputPatternResolver
Expand Down
22 changes: 22 additions & 0 deletions src/autoskillit/core/types/_type_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"ProviderOutcome",
"InfraOutcome",
"ApiRetryOutcome",
"NdjsonDriftOutcome",
"SkillResult",
"CleanupResult",
"CloneSuccessResult",
Expand Down Expand Up @@ -265,6 +266,21 @@ class ContaminationOutcome:
subtype: str = ""


@dataclass(frozen=True, slots=True)
class NdjsonDriftOutcome:
"""NDJSON parser vocabulary drift counters.

Aggregates unknown event and item counts emitted by Codex NDJSON parsing
when the parser encounters event/item types not in its known vocabulary.
Surfaced through ``SkillResult.ndjson_drift`` and propagated into
``summary.json``, ``sessions.jsonl``, and ``anomalies.jsonl`` for
diagnostics and the doctor ``codex_ndjson_drift`` check.
"""

unknown_event_count: int = 0
unknown_item_count: int = 0


@dataclass(frozen=True, slots=True)
class WriteEvidence:
"""Bundled write evidence signals — either explicitly constructed or absent."""
Expand Down Expand Up @@ -334,6 +350,8 @@ class SkillResult:
"""API retry event accumulation bundle."""
contamination: ContaminationOutcome = field(default_factory=ContaminationOutcome)
"""Pre-contamination context bundle — populated only when clone_guard fires."""
ndjson_drift: NdjsonDriftOutcome = field(default_factory=NdjsonDriftOutcome)
"""NDJSON parser vocabulary drift counters — populated by Codex sessions."""
completion_required: bool = False

def to_json(self) -> str:
Expand Down Expand Up @@ -369,6 +387,8 @@ def to_json(self) -> str:
"api_retry_exhausted": self.api_retry.exhausted,
"pre_contamination_retry_reason": self.contamination.retry_reason,
"pre_contamination_subtype": self.contamination.subtype,
"ndjson_unknown_event_count": self.ndjson_drift.unknown_event_count,
"ndjson_unknown_item_count": self.ndjson_drift.unknown_item_count,
}
if self.worktree_path is not None:
data["worktree_path"] = self.worktree_path
Expand Down Expand Up @@ -606,4 +626,6 @@ class SessionIndexEntry(TypedDict):
api_retry_exhausted: bool
api_retry_last_error: str
api_retry_last_status: int | None
ndjson_unknown_event_count: int
ndjson_unknown_item_count: int
schema_version: int
1 change: 1 addition & 0 deletions src/autoskillit/execution/anomaly_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class AnomalyKind(StrEnum):
THINKING_ONLY_FINAL_TURN = "thinking_only_final_turn"
API_RETRY_EXHAUSTION = "api_retry_exhaustion"
MODEL_DRIFT = "model_drift"
NDJSON_DRIFT = "ndjson_drift"


class AnomalySeverity(StrEnum):
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/execution/headless/_headless_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ async def _execute_claude_headless(
api_retry_last_error=skill_result.api_retry.last_error,
api_retry_last_status=skill_result.api_retry.last_status,
api_retry_exhausted=skill_result.api_retry.exhausted,
ndjson_unknown_event_count=skill_result.ndjson_drift.unknown_event_count,
ndjson_unknown_item_count=skill_result.ndjson_drift.unknown_item_count,
write_path_warnings=skill_result.write_path_warnings,
write_call_count=skill_result.evidence.write_call_count,
fs_writes_detected=skill_result.evidence.fs_writes_detected,
Expand Down
9 changes: 9 additions & 0 deletions src/autoskillit/execution/headless/_headless_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
InfraExitCategory,
InfraOutcome,
KillReason,
NdjsonDriftOutcome,
ProviderOutcome,
RetryReason,
SessionOutcome,
Expand Down Expand Up @@ -135,6 +136,10 @@ def _make_terminated_result(
provider=ProviderOutcome(provider_used=provider_used, fallback_activated=False),
infra=infra,
api_retry=api_retry,
ndjson_drift=NdjsonDriftOutcome(
unknown_event_count=session.seen_ndjson_unknown_event_count,
unknown_item_count=session.seen_ndjson_unknown_item_count,
),
)


Expand Down Expand Up @@ -727,6 +732,10 @@ def _build_skill_result(
provider=ProviderOutcome(provider_used=provider_used, fallback_activated=False),
infra=InfraOutcome(exit_category=infra_category.value),
api_retry=api_retry,
ndjson_drift=NdjsonDriftOutcome(
unknown_event_count=session.seen_ndjson_unknown_event_count,
unknown_item_count=session.seen_ndjson_unknown_item_count,
),
completion_required=completion_required,
)
if path_contamination:
Expand Down
30 changes: 30 additions & 0 deletions src/autoskillit/execution/session_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ def flush_session_log(
api_retry_last_error: str = "",
api_retry_last_status: int | None = None,
api_retry_exhausted: bool = False,
ndjson_unknown_event_count: int = 0,
ndjson_unknown_item_count: int = 0,
versions: dict[str, Any] | None = None,
model_identity: ModelIdentity = ModelIdentity.unknown(),
max_sessions: int | None = None,
Expand Down Expand Up @@ -326,6 +328,30 @@ def flush_session_log(
}
)

if ndjson_unknown_event_count > 0 or ndjson_unknown_item_count > 0:
from autoskillit.execution.anomaly_detection import (
OUTCOME_ANOMALY_PID_SENTINEL,
OUTCOME_ANOMALY_SEQ_SENTINEL,
AnomalyKind,
AnomalySeverity,
)

anomalies.append(
{
"ts": datetime.now(UTC).isoformat(),
"seq": OUTCOME_ANOMALY_SEQ_SENTINEL,
"event": "anomaly",
"kind": str(AnomalyKind.NDJSON_DRIFT),
"severity": str(AnomalySeverity.WARNING),
"pid": OUTCOME_ANOMALY_PID_SENTINEL,
"detail": {
"ndjson_unknown_event_count": ndjson_unknown_event_count,
"ndjson_unknown_item_count": ndjson_unknown_item_count,
},
"snapshot": {},
}
)

effective_model_id = model_identity.effective_model or _primary_model_identifier(token_usage)
_observed = _primary_model_identifier(token_usage) if token_usage else ""
anomalies.extend(
Expand Down Expand Up @@ -427,6 +453,8 @@ def flush_session_log(
"api_retry_last_error": api_retry_last_error,
"api_retry_last_status": api_retry_last_status,
"api_retry_exhausted": api_retry_exhausted,
"ndjson_unknown_event_count": ndjson_unknown_event_count,
"ndjson_unknown_item_count": ndjson_unknown_item_count,
}
if versions is not None:
summary["versions"] = {
Expand Down Expand Up @@ -555,6 +583,8 @@ def flush_session_log(
"api_retry_exhausted": api_retry_exhausted,
"api_retry_last_error": api_retry_last_error,
"api_retry_last_status": api_retry_last_status,
"ndjson_unknown_event_count": ndjson_unknown_event_count,
"ndjson_unknown_item_count": ndjson_unknown_item_count,
"model_identifier": effective_model_id,
"configured_model": model_identity.configured_model,
"profile_name": model_identity.profile_name,
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/hooks/formatters/_fmt_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ def _fmt_test_check(data: dict, _pipeline: bool) -> str:
"pre_contamination_subtype",
"has_implementation_progress",
"completion_required",
"ndjson_unknown_event_count",
"ndjson_unknown_item_count",
}
)

Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/server/tools/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class RunSkillResult(_RunSkillResultBase, total=False):
api_retry_exhausted: bool
pre_contamination_retry_reason: RetryReason
pre_contamination_subtype: str
ndjson_unknown_event_count: int
ndjson_unknown_item_count: int


class _RunCmdResultBase(TypedDict):
Expand Down
2 changes: 1 addition & 1 deletion tests/arch/test_file_size_budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_pretty_output_below_budget() -> None:
budgets = {
"pretty_output_hook.py": 350,
"_fmt_primitives.py": 200,
"_fmt_execution.py": 330,
"_fmt_execution.py": 332,
"_fmt_dispatch.py": 200,
"_fmt_status.py": 250,
"_fmt_recipe.py": 300,
Expand Down
102 changes: 102 additions & 0 deletions tests/cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,3 +1397,105 @@ def test_doctor_cache_version_mismatch_ok_when_matching(
result = _check_cache_version_mismatch()

assert result.severity == Severity.OK


class TestCheckCodexNdjsonDrift:
"""Verify _check_codex_ndjson_drift doctor check."""

def test_ok_when_no_sessions_jsonl(self, tmp_path: Path) -> None:
"""Returns Severity.OK when sessions.jsonl does not exist."""
from autoskillit.cli.doctor._doctor_runtime import _check_codex_ndjson_drift
from autoskillit.core import Severity

result = _check_codex_ndjson_drift(log_dir=str(tmp_path))
assert result.severity == Severity.OK
assert result.check == "codex_ndjson_drift"

def test_ok_when_zero_counters(self, tmp_path: Path) -> None:
"""Returns Severity.OK when all codex entries have zero drift counters."""
from autoskillit.cli.doctor._doctor_runtime import _check_codex_ndjson_drift
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

sessions_path = tmp_path / "sessions.jsonl"
sessions_path.write_text(
json.dumps(
{
"backend": "codex",
"ndjson_unknown_event_count": 0,
"ndjson_unknown_item_count": 0,
}
)
+ "\n"
+ json.dumps(
{
"backend": "codex",
"ndjson_unknown_event_count": 0,
"ndjson_unknown_item_count": 0,
}
)
+ "\n"
)
result = _check_codex_ndjson_drift(log_dir=str(tmp_path), backend=CodexBackend())
assert result.severity == Severity.OK
assert result.check == "codex_ndjson_drift"

def test_warning_when_nonzero_counters(self, tmp_path: Path) -> None:
"""Returns Severity.WARNING when any codex entry has non-zero counters;
message includes affected session count."""
from autoskillit.cli.doctor._doctor_runtime import _check_codex_ndjson_drift
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

sessions_path = tmp_path / "sessions.jsonl"
sessions_path.write_text(
json.dumps(
{
"backend": "codex",
"ndjson_unknown_event_count": 2,
"ndjson_unknown_item_count": 0,
}
)
+ "\n"
+ json.dumps(
{
"backend": "codex",
"ndjson_unknown_event_count": 0,
"ndjson_unknown_item_count": 3,
}
)
+ "\n"
)
result = _check_codex_ndjson_drift(log_dir=str(tmp_path), backend=CodexBackend())
assert result.severity == Severity.WARNING
assert result.check == "codex_ndjson_drift"
assert "2 codex session" in result.message

def test_non_codex_entries_ignored(self, tmp_path: Path) -> None:
"""Non-codex (claude-code) entries with non-zero counters are not counted."""
from autoskillit.cli.doctor._doctor_runtime import _check_codex_ndjson_drift
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

sessions_path = tmp_path / "sessions.jsonl"
sessions_path.write_text(
json.dumps(
{
"backend": "claude-code",
"ndjson_unknown_event_count": 5,
"ndjson_unknown_item_count": 0,
}
)
+ "\n"
+ json.dumps(
{
"backend": "codex",
"ndjson_unknown_event_count": 0,
"ndjson_unknown_item_count": 0,
}
)
+ "\n"
)
result = _check_codex_ndjson_drift(log_dir=str(tmp_path), backend=CodexBackend())
assert result.severity == Severity.OK
assert result.check == "codex_ndjson_drift"
Loading
Loading