Skip to content

Commit f5148ae

Browse files
authored
Rectify: Codex L2 Liveness Policy (#4217)
## Summary Codex L2 food trucks can be killed by the raw stdout byte-idle watchdog while doing legitimate long MCP work. The immediate failure is not just a low timeout; it is an architectural mismatch between independent timeout scalars and actual backend liveness. Today, Codex builders copy `run_skill.stream_idle_timeout_ms` into `CmdSpec.process_idle_timeout_ms`, and `_execute_claude_headless()` treats that value as a lower cap on the outer AutoSkillit process idle watchdog. For Codex, that collapses default L2 food-truck idle from fleet's intended `1800s` to `600s`, while `run_skill.mcp_tool_timeout_sec` and fleet session deadlines allow much longer blocking calls. Codex `item.started` / `item.updated status=in_progress` MCP records are ignored as liveness, so a healthy in-flight MCP call can look identical to a hung stdout stream. The immunity plan is to replace raw timeout-field propagation with one resolved session liveness contract plus a process-level liveness supervisor. The contract must be resolved once per backend and session kind, then consumed by every watcher. Codex operation lifecycle events must become first-class liveness inputs, bounded by MCP/session deadlines rather than by an arbitrary marker-suppression cap. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/remediation-20260708-205019-355728/.autoskillit/temp/rectify/rectify_codex_l2_liveness_policy_2026-07-08_211800.md` Closes #4202 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr -->
1 parent 1e76bde commit f5148ae

51 files changed

Lines changed: 2323 additions & 110 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.autoskillit/test-source-map.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3753,6 +3753,9 @@
37533753
"tests/server/test_tools_run_skill_retry.py",
37543754
"tests/server/test_tools_status_kitchen.py"
37553755
],
3756+
"src/autoskillit/execution/headless/_headless_liveness.py": [
3757+
"tests/contracts/test_headless_liveness_contracts.py"
3758+
],
37563759
"src/autoskillit/execution/headless/_headless_path_tokens.py": [
37573760
"tests/arch/test_pty_coherence.py",
37583761
"tests/execution/test_api_error_signal_invariants.py",
@@ -3950,6 +3953,9 @@
39503953
"tests/execution/test_termination_action.py",
39513954
"tests/execution/test_termination_executor.py"
39523955
],
3956+
"src/autoskillit/execution/process/_liveness_supervisor.py": [
3957+
"tests/execution/process/test_liveness_supervisor.py"
3958+
],
39533959
"src/autoskillit/execution/process/_process_io.py": [
39543960
"tests/execution/test_boundary_pty_dispatch.py",
39553961
"tests/execution/test_linux_tracing_pty_integration.py",

src/autoskillit/core/__init__.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ from .types import IssueLabelState as IssueLabelState
287287
from .types import KillReason as KillReason
288288
from .types import LabelDef as LabelDef
289289
from .types import LensEntry as LensEntry
290+
from .types import LivenessSource as LivenessSource
290291
from .types import LoadReport as LoadReport
291292
from .types import LoadResult as LoadResult
292293
from .types import MarketplaceInstall as MarketplaceInstall
@@ -301,6 +302,8 @@ from .types import ModelTranslation as ModelTranslation
301302
from .types import NamedResume as NamedResume
302303
from .types import NdjsonDriftOutcome as NdjsonDriftOutcome
303304
from .types import NoResume as NoResume
305+
from .types import OperationLiveness as OperationLiveness
306+
from .types import OperationStatus as OperationStatus
304307
from .types import OutputFormat as OutputFormat
305308
from .types import OutputPatternResolver as OutputPatternResolver
306309
from .types import PackDef as PackDef
@@ -328,6 +331,7 @@ from .types import RetryReason as RetryReason
328331
from .types import ServeOverridesSnapshot as ServeOverridesSnapshot
329332
from .types import SessionCheckpoint as SessionCheckpoint
330333
from .types import SessionEvent as SessionEvent
334+
from .types import SessionLivenessSpec as SessionLivenessSpec
331335
from .types import SessionLocator as SessionLocator
332336
from .types import SessionOutcome as SessionOutcome
333337
from .types import SessionSkillManager as SessionSkillManager

src/autoskillit/core/types/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL
2727
| `_type_protocols_infra.py` | Protocols: `GateState`, `BackgroundSupervisor`, `FleetLock`, `QuotaRefreshTask`, `TokenFactory`, `CampaignProtector` |
2828
| `_type_protocols_backend.py` | Protocols: `StreamParser`, `ResultParser`, `EnvPolicy`, `SessionLocator`, `CodingAgentBackend` |
2929
| `_type_checkpoint.py` | `SessionCheckpoint` frozen dataclass and `compute_remaining()` helper for session resume |
30-
| `_type_backend.py` | `BackendCapabilities` frozen dataclass, `CLAUDE_CODE_CAPABILITIES` constant, `CmdSpec`, `SkillSessionConfig`, `ClaudeEventData`, `CodexEventData`, `SessionEvent`, `AgentSessionResult` |
30+
| `_type_backend.py` | `BackendCapabilities` frozen dataclass, `CLAUDE_CODE_CAPABILITIES` constant, `CmdSpec`, `SkillSessionConfig`, `OperationLiveness`, `SessionLivenessSpec`, `ClaudeEventData`, `CodexEventData`, `SessionEvent`, `AgentSessionResult` |
3131
| `_type_capture.py` | `CaptureEntrySpec` and `CaptureValueTypeError` for typed capture contract enforcement |
3232
| `_type_dispatch_identity.py` | `DispatchIdentity` frozen value object, `PromptContractError`, and `assert_prompt_sentinel` for sentinel contract enforcement |
3333
| `_type_helpers.py` | Text processing and skill-name extraction utilities |

src/autoskillit/core/types/_type_backend.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
from ._type_checkpoint import SessionCheckpoint
12-
from ._type_enums import BackendEventKind, OutputFormat
12+
from ._type_enums import BackendEventKind, LivenessSource, OperationStatus, OutputFormat
1313
from ._type_plugin_source import PluginSource
1414
from ._type_results import ValidatedAddDir
1515

@@ -25,6 +25,8 @@
2525
"CmdOrigin",
2626
"CmdSpec",
2727
"ModelTranslation",
28+
"OperationLiveness",
29+
"SessionLivenessSpec",
2830
"SkillSessionConfig",
2931
"ClaudeEventData",
3032
"CodexEventData",
@@ -357,6 +359,46 @@ class CodexEventData:
357359
command: str | None = None
358360

359361

362+
@dataclass(frozen=True, slots=True)
363+
class OperationLiveness:
364+
"""Liveness signal for a single in-flight backend operation."""
365+
366+
operation_id: str
367+
item_type: str
368+
status: OperationStatus
369+
started_monotonic: float | None = None
370+
updated_monotonic: float | None = None
371+
raw: Mapping[str, Any] = field(default_factory=dict)
372+
373+
374+
@dataclass(frozen=True, slots=True)
375+
class SessionLivenessSpec:
376+
"""Resolved per-session liveness contract for process watchers."""
377+
378+
stdout_idle_timeout_sec: float | None
379+
stale_threshold_sec: float
380+
operation_deadline_sec: float
381+
mcp_tool_timeout_sec: float
382+
wall_timeout_sec: float
383+
explicit_idle_disabled: bool
384+
caller_session_id: str
385+
authorized_sources: frozenset[LivenessSource] = field(
386+
default_factory=lambda: frozenset(
387+
{
388+
LivenessSource.STDOUT_GROWTH,
389+
LivenessSource.CHANNEL_B_GROWTH,
390+
LivenessSource.EXECUTION_MARKER,
391+
LivenessSource.OPERATION_IN_FLIGHT,
392+
}
393+
)
394+
)
395+
396+
@property
397+
def is_idle_disabled(self) -> bool:
398+
"""Whether the outer stdout-idle watchdog is disabled."""
399+
return self.explicit_idle_disabled or self.stdout_idle_timeout_sec is None
400+
401+
360402
@dataclass(frozen=True, slots=True)
361403
class SessionEvent:
362404
"""A single parsed event emitted by a running backend session."""
@@ -367,6 +409,7 @@ class SessionEvent:
367409
session_id: str | None = None
368410
exit_code: int | None = None
369411
backend_data: ClaudeEventData | CodexEventData | None = None
412+
operation_liveness: OperationLiveness | None = None
370413

371414

372415
@dataclass(frozen=True, slots=True)

src/autoskillit/core/types/_type_enums.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"BackendEventKind",
3838
"CodexEventType",
3939
"CodexItemType",
40+
"OperationStatus",
41+
"LivenessSource",
4042
"SynthesisStrategy",
4143
]
4244

@@ -589,3 +591,34 @@ class SynthesisStrategy(StrEnum):
589591
ELECTRE_III = "electre_iii"
590592
DEX = "dex"
591593
CUSTOM = "custom"
594+
595+
596+
class OperationStatus(StrEnum):
597+
"""Lifecycle states for a backend operation (tool call, MCP call, etc.).
598+
599+
Emitted by backend stream parsers as first-class liveness signals. Used
600+
by ``ProcessLivenessSupervisor`` to suppress byte-idle termination while
601+
an in-flight operation is legitimately silent on stdout.
602+
"""
603+
604+
STARTED = "started"
605+
PROGRESS = "progress"
606+
COMPLETED = "completed"
607+
FAILED = "failed"
608+
CANCELLED = "cancelled"
609+
610+
611+
class LivenessSource(StrEnum):
612+
"""Authorized sources that count as legitimate liveness for process supervision.
613+
614+
Centralizes the liveness taxonomy so that no watcher can invent its own
615+
predicate. The supervisor owns the decision; watchers only report their
616+
raw observations and consult the supervisor.
617+
"""
618+
619+
STDOUT_GROWTH = "stdout_growth"
620+
CHANNEL_B_GROWTH = "channel_b_growth"
621+
EXECUTION_MARKER = "execution_marker"
622+
CHILD_PROCESS_ACTIVITY = "child_process_activity"
623+
API_CONNECTION = "api_connection"
624+
OPERATION_IN_FLIGHT = "operation_in_flight"

src/autoskillit/execution/backends/AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations
3737
6. **Add a `FeatureDef`** — add an entry to `FEATURE_REGISTRY` in `core/types/_type_constants_features.py` with `default_enabled=False` and `requires_backend_alignment=True`.
3838

3939
7. **Extend test coverage** — add tests to `tests/execution/backends/test_backend_registry.py`, `tests/contracts/test_backend_compliance.py`, and `tests/contracts/test_backend_protocol.py`.
40+
41+
## Backend liveness contract
42+
43+
- Backend stream parsers emit raw observations only. They must not decide whether a
44+
process is healthy, stale, or killable.
45+
- Codex lifecycle records for long-running tool items (`item.started`, `item.updated`,
46+
`item.completed`) must preserve the raw backend record and emit `OperationLiveness`
47+
when `item.id` is stable and `item.type` is one of the long-running operation types.
48+
- `item.started` and `item.updated` remain `BackendEventKind.IGNORED`; they are liveness
49+
signals, not result evidence, write evidence, or command/MCP completion evidence.
50+
- Parent stdout-idle behavior is owned by the resolved `SessionLivenessSpec` in
51+
`execution/headless/_headless_liveness.py`. Backend builders may set child env hints,
52+
but `_execute_claude_headless()` must normalize `AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT`
53+
from the resolved spec before invoking the runner.

src/autoskillit/execution/backends/_codex_parse.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from __future__ import annotations
44

55
import json
6-
from collections.abc import Sequence
6+
import time
7+
from collections.abc import Mapping, Sequence
78
from dataclasses import dataclass, field
89
from typing import Any
910

@@ -16,12 +17,29 @@
1617
CodexEventData,
1718
CodexEventType,
1819
CodexItemType,
20+
OperationLiveness,
21+
OperationStatus,
1922
SessionEvent,
2023
fast_loads,
2124
get_logger,
2225
)
2326
from autoskillit.execution.process import _marker_is_standalone
2427

28+
# Item-type enum members whose lifecycle records are eligible for
29+
# OperationLiveness emission. Membership drives liveness, not result
30+
# extraction. Membership must include every Codex item type that can produce
31+
# a legitimately silent stdout period while in-flight (long-running tool calls
32+
# or subagent delegations).
33+
_LIVENESS_ITEM_TYPES: frozenset[str] = frozenset(
34+
{
35+
CodexItemType.MCP_TOOL_CALL.value,
36+
CodexItemType.FUNCTION_CALL.value,
37+
CodexItemType.COMMAND_EXECUTION.value,
38+
CodexItemType.COLLAB_TOOL_CALL.value,
39+
CodexItemType.WEB_SEARCH.value,
40+
}
41+
)
42+
2543
logger = get_logger(__name__)
2644

2745

@@ -213,6 +231,80 @@ def _check_marker_text(self, text: str) -> None:
213231
if self.completion_marker and _marker_is_standalone(text, self.completion_marker):
214232
self._saw_marker = True
215233

234+
@staticmethod
235+
def _extract_item_id(obj: Mapping[str, Any]) -> str:
236+
"""Return the stable ``item.id`` from a Codex NDJSON record, or ``""``.
237+
238+
The Codex NDJSON envelope places operation identity on ``item.id``
239+
for ``item.started``/``item.updated``/``item.completed`` records.
240+
Records without an id are skipped from OperationLiveness emission so
241+
the supervisor never carries an invented correlation handle.
242+
"""
243+
if not isinstance(obj, Mapping):
244+
return ""
245+
item = obj.get("item")
246+
if isinstance(item, Mapping):
247+
raw_id = item.get("id")
248+
if isinstance(raw_id, str) and raw_id:
249+
return raw_id
250+
return ""
251+
252+
def _make_operation_liveness(
253+
self,
254+
obj: Mapping[str, Any],
255+
*,
256+
status: OperationStatus,
257+
) -> OperationLiveness | None:
258+
"""Build an ``OperationLiveness`` for long-running in-flight items.
259+
260+
Returns ``None`` when the record lacks a stable operation id or the
261+
item type is not in ``_LIVENESS_ITEM_TYPES``. Callers MUST treat
262+
``None`` as "no liveness signal" — never invent an id to recover a
263+
non-emission.
264+
"""
265+
if not isinstance(obj, Mapping):
266+
return None
267+
op_id = self._extract_item_id(obj)
268+
if not op_id:
269+
return None
270+
item = obj.get("item")
271+
item_type: str = ""
272+
if isinstance(item, Mapping):
273+
raw_type = item.get("type")
274+
if isinstance(raw_type, str):
275+
item_type = raw_type
276+
if item_type not in _LIVENESS_ITEM_TYPES:
277+
return None
278+
now = time.monotonic()
279+
started = now if status == OperationStatus.STARTED else None
280+
updated = now if status in (OperationStatus.PROGRESS, OperationStatus.COMPLETED) else None
281+
return OperationLiveness(
282+
operation_id=op_id,
283+
item_type=item_type,
284+
status=status,
285+
started_monotonic=started,
286+
updated_monotonic=updated,
287+
raw=obj,
288+
)
289+
290+
@staticmethod
291+
def _make_item_event_data(
292+
obj: Mapping[str, Any],
293+
*,
294+
record_type: str,
295+
) -> CodexEventData | None:
296+
item = obj.get("item")
297+
if not isinstance(item, Mapping):
298+
return None
299+
raw_type = item.get("type")
300+
item_type = raw_type if isinstance(raw_type, str) else ""
301+
return CodexEventData(
302+
record_type=record_type,
303+
thread_id="",
304+
item_type=item_type,
305+
raw=obj,
306+
)
307+
216308
def parse_line(self, line: str) -> SessionEvent | None:
217309
line = line.strip()
218310
if not line:
@@ -242,11 +334,25 @@ def parse_line(self, line: str) -> SessionEvent | None:
242334
session_id=obj.get("payload", {}).get("id", "") or None,
243335
)
244336

245-
if event_type in (CodexEventType.TURN_STARTED, CodexEventType.ITEM_STARTED):
337+
if event_type == CodexEventType.TURN_STARTED:
338+
return SessionEvent(
339+
kind=BackendEventKind.IGNORED,
340+
is_terminal=False,
341+
has_marker=False,
342+
)
343+
344+
if event_type == CodexEventType.ITEM_STARTED:
345+
# ``item.started`` may also emit OperationLiveness for long-running
346+
# item types. We only emit when ``item["id"]`` is a non-empty
347+
# stable string — inventing an id would break supervisor
348+
# correlation and double-count lifecycle records.
349+
op_liveness = self._make_operation_liveness(obj, status=OperationStatus.STARTED)
246350
return SessionEvent(
247351
kind=BackendEventKind.IGNORED,
248352
is_terminal=False,
249353
has_marker=False,
354+
backend_data=self._make_item_event_data(obj, record_type="item.started"),
355+
operation_liveness=op_liveness,
250356
)
251357

252358
if event_type == CodexEventType.ITEM_COMPLETED:
@@ -297,6 +403,7 @@ def parse_line(self, line: str) -> SessionEvent | None:
297403
CodexItemType.COLLAB_TOOL_CALL,
298404
CodexItemType.WEB_SEARCH,
299405
):
406+
op_liveness = self._make_operation_liveness(obj, status=OperationStatus.COMPLETED)
300407
return SessionEvent(
301408
kind=BackendEventKind.TOOL_OUTPUT,
302409
is_terminal=False,
@@ -307,6 +414,7 @@ def parse_line(self, line: str) -> SessionEvent | None:
307414
item_type=item_type.value,
308415
raw=obj,
309416
),
417+
operation_liveness=op_liveness,
310418
)
311419

312420
if item_type in (CodexItemType.REASONING, CodexItemType.TODO_LIST):
@@ -365,10 +473,13 @@ def parse_line(self, line: str) -> SessionEvent | None:
365473
)
366474

367475
if event_type == CodexEventType.ITEM_UPDATED:
476+
op_liveness = self._make_operation_liveness(obj, status=OperationStatus.PROGRESS)
368477
return SessionEvent(
369478
kind=BackendEventKind.IGNORED,
370479
is_terminal=False,
371480
has_marker=False,
481+
backend_data=self._make_item_event_data(obj, record_type="item.updated"),
482+
operation_liveness=op_liveness,
372483
)
373484

374485
self.ndjson_unknown_event_count += 1

src/autoskillit/execution/backends/codex.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ def build_skill_session_cmd(
815815
env=env,
816816
cwd=cwd,
817817
is_resume=bool(resume_session_id),
818-
process_idle_timeout_ms=stream_idle_timeout_ms,
818+
process_idle_timeout_ms=0,
819819
)
820820

821821
def build_food_truck_cmd(
@@ -920,7 +920,7 @@ def build_food_truck_cmd(
920920
env=env,
921921
cwd=cwd,
922922
is_resume=bool(resume_session_id),
923-
process_idle_timeout_ms=stream_idle_timeout_ms,
923+
process_idle_timeout_ms=0,
924924
)
925925

926926
def build_interactive_cmd(

src/autoskillit/execution/headless/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Headless Claude session orchestration — command prep, subprocess invocation, r
99
| `__init__.py` | Facade: `run_headless_core()`, `DefaultHeadlessExecutor`, re-exports |
1010
| `_headless_helpers.py` | Session helpers: `_session_log_dir`, `_resolve_model`, `PostSessionMetrics` |
1111
| `_headless_execute.py` | Subprocess core: `_execute_claude_headless()` — shared skill/fleet path |
12+
| `_headless_liveness.py` | Resolves per-session liveness specs for parent process watchers |
1213
| `_headless_git.py` | Git LOC tracking: `_capture_git_head_sha()`, `_compute_loc_changed()` |
1314
| `_headless_path_tokens.py` | Path-token extraction and output-path validation from assistant messages |
1415
| `_headless_recovery.py` | Session recovery: `_recover_from_separate_marker`, `_synthesize_from_write_artifacts` |

src/autoskillit/execution/headless/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,4 +510,5 @@ async def dispatch_food_truck(
510510
model_identity=model_identity,
511511
on_session_id_resolved=on_session_id_resolved,
512512
step_backend=dispatch_backend,
513+
_is_food_truck=True,
513514
)

0 commit comments

Comments
 (0)