2828from autoskillit .fleet ._outcome import _checkpoint_to_dict , classify_dispatch_outcome
2929from autoskillit .fleet .result_parser import parse_l3_result_block
3030from autoskillit .fleet .state import DispatchStatus
31+ from autoskillit .fleet .state_recovery import ResumePreflight , prepare_resume
3132from autoskillit .fleet .state_types import (
3233 DispatchCompleted ,
3334 DispatchRejected ,
4546ENVELOPE_STDERR_MAX = 2000
4647
4748
49+ class DispatchSpawnFailed (RuntimeError ):
50+ """Spawn-time failure signal for the ``execute_dispatch`` callback path.
51+
52+ Attributes:
53+ error_code: FleetErrorCode identifying the failure category.
54+ message: Human-readable description of the spawn failure.
55+ """
56+
57+ def __init__ (self , error_code : FleetErrorCode , message : str ) -> None :
58+ super ().__init__ (message )
59+ self .error_code = error_code
60+ self .message = message
61+
62+
4863@asynccontextmanager
4964async def _dispatch_heartbeat (
5065 dispatches_dir : Path ,
@@ -132,9 +147,28 @@ def _write_pid(
132147 dispatched_create_time : float = 0.0 ,
133148 identity_degraded : bool = False ,
134149 issue_url : str = "" ,
135- ) -> None :
136- """on_spawn callback: atomically mark dispatch as running with dispatched_pid."""
150+ * ,
151+ enforce_max_resume_attempts : bool = False ,
152+ ) -> str | None :
153+ """on_spawn callback: atomically mark dispatch as running with dispatched_pid.
154+
155+ L2 — Fail-closed: if ``mark_dispatch_running`` raises (e.g. illegal state
156+ transition), the spawned child is killed via ``kill_process_tree`` (the
157+ canonical sync kill primitive used by ``_dispatch_reaper``) and the
158+ exception's message string is returned to the caller via closure-scoped
159+ state. Raising the exception from ``_on_spawn`` is NOT safe because
160+ ``_execute_claude_headless`` catches runner exceptions and returns
161+ ``SkillResult.crashed`` — the propagated exception would never reach the
162+ outer ``execute_dispatch`` wrapper. The caller therefore inspects the
163+ returned error string (or the closure-scoped ``_spawn_error`` list) and
164+ translates it into a ``FLEET_L3_STARTUP_OR_CRASH`` envelope.
165+
166+ Returns:
167+ None on success; the formatted error message string on failure (also
168+ recorded via the side-effect of having killed the child).
169+ """
137170 from autoskillit .core import read_boot_id
171+ from autoskillit .execution import kill_process_tree
138172 from autoskillit .fleet import mark_dispatch_running
139173
140174 try :
@@ -149,9 +183,23 @@ def _write_pid(
149183 sidecar_path = sidecar_path ,
150184 identity_degraded = identity_degraded ,
151185 issue_url = issue_url ,
186+ enforce_max_resume_attempts = enforce_max_resume_attempts ,
152187 )
153- except Exception :
154- logger .warning ("_write_pid: failed to mark dispatch running" , exc_info = True )
188+ return None
189+ except Exception as exc :
190+ # Fail-closed: kill the child before the state record can diverge.
191+ if pid :
192+ try :
193+ kill_process_tree (pid , timeout = 2.0 )
194+ except Exception :
195+ logger .warning (
196+ "_write_pid: kill_process_tree failed for pid=%d" ,
197+ pid ,
198+ exc_info = True ,
199+ )
200+ cause = getattr (exc , "__cause__" , None ) or getattr (exc , "__context__" , None )
201+ cause_str = f" caused by { type (cause ).__name__ } : { cause } " if cause is not None else ""
202+ return f"_on_spawn transition failed: { type (exc ).__name__ } : { exc } { cause_str } "
155203
156204
157205def _post_dispatch_cleanup (
@@ -479,24 +527,60 @@ async def _run_dispatch(
479527 }
480528 prior_session_chain : list [str ] = []
481529 prior_dispatched_session_id = ""
530+ # Hoisted: closure-captured by _on_spawn to derive is_resume_branch for
531+ # the enforce_max_resume_attempts kwarg on _write_pid. A non-None value
532+ # means the resume branch executed prepare_resume (vs. fresh dispatch path).
533+ preflight : ResumePreflight | None = None
482534 if resume_session_id and prior_dispatch_id :
483535 try :
484536 handle = DispatchStateHandle .open_continued (dispatches_dir , prior_dispatch_id )
485537 prior_state = read_state (handle .state_path )
486- if prior_state :
487- for d in prior_state .dispatches :
488- if d .name == effective_name :
489- if d .status == DispatchStatus .SUCCESS :
490- logger .info (
491- "resume_skipped_prior_success" ,
492- dispatch_name = effective_name ,
493- prior_dispatch_id = prior_dispatch_id ,
494- )
495- return _build_success_short_circuit (d , handle )
496- prior_session_chain = list (d .session_chain )
497- prior_dispatched_session_id = d .dispatched_session_id
498- break
499- except (OSError , ValueError , KeyError , TypeError ):
538+ if prior_state is None :
539+ # Corrupt or missing prior state — degrade to fresh.
540+ handle = DispatchStateHandle .create_fresh (
541+ dispatches_dir ,
542+ campaign_id ,
543+ effective_name ,
544+ "" ,
545+ [DispatchRecord (name = effective_name , caller_session_id = caller_session_id )],
546+ recipe_snapshot ,
547+ )
548+ else :
549+ # Single precondition chokepoint. Funnels every resume entry
550+ # point through one function so the bug class becomes
551+ # structurally impossible.
552+ preflight = prepare_resume (
553+ handle .state_path ,
554+ effective_name ,
555+ continue_on_failure = True ,
556+ )
557+ if preflight is not None :
558+ if preflight .short_circuit is not None :
559+ logger .info (
560+ "resume_skipped_prior_success" ,
561+ dispatch_name = effective_name ,
562+ prior_dispatch_id = prior_dispatch_id ,
563+ )
564+ return _build_success_short_circuit (preflight .short_circuit , handle )
565+ if preflight .halt :
566+ return DispatchResult (
567+ outcome = DispatchRejected (
568+ error_code = FleetErrorCode .FLEET_CAMPAIGN_HALTED ,
569+ message = preflight .halted_reason
570+ or "Resume refused by precondition chokepoint" ,
571+ dispatch_id = handle .identity .dispatch_id ,
572+ ),
573+ per_dispatch_state_path = None ,
574+ )
575+ prior_session_chain = preflight .prior_session_chain
576+ prior_dispatched_session_id = preflight .prior_dispatched_session_id
577+ except (OSError , KeyError , TypeError ):
578+ # OSError covers FileNotFoundError from DispatchStateHandle.open_continued
579+ # when the prior state file is missing (fail-open to fresh dispatch) and
580+ # write failures from reset_blocking_dispatch. ValueError is intentionally
581+ # NOT caught so ResumeCountExceeded (a ValueError subclass raised by the
582+ # cap check in mark_dispatch_running) propagates instead of being silently
583+ # degraded to a create_fresh path.
500584 logger .warning ("failed to read prior session chain from state" , exc_info = True )
501585 handle = DispatchStateHandle .create_fresh (
502586 dispatches_dir ,
@@ -668,6 +752,9 @@ def _reject_with_state(error_code: FleetErrorCode, message: str) -> DispatchResu
668752 _dispatched_create_time : list [float ] = []
669753 _dispatched_boot_id : list [str ] = []
670754 _dispatched_session_id : list [str ] = []
755+ # Closure-scoped spawn error state (layer L2). See _write_pid docstring
756+ # for why raising from on_spawn would not propagate.
757+ _spawn_error : list [str ] = []
671758
672759 # Collect prior dispatch_ids from attempt_history for defense-in-depth parsing
673760 prior_ids : list [str ] = []
@@ -705,7 +792,16 @@ def _on_spawn(pid: int, ticks: int) -> None:
705792 _dispatched_ticks .append (ticks )
706793 _dispatched_create_time .append (create_time )
707794 _dispatched_boot_id .append (boot_id )
708- _write_pid (
795+ # Resume branch iff preflight was returned by prepare_resume above.
796+ # Cap enforcement (MAX_CONSECUTIVE_RESUME_ATTEMPTS) lives one layer down
797+ # in mark_dispatch_running.
798+ is_resume_branch = preflight is not None
799+ # _write_pid returns None on success or an error string on failure.
800+ # We record the error in closure-scoped _spawn_error rather than
801+ # raising — raising from on_spawn would be swallowed by the executor
802+ # and converted to SkillResult.crashed, never reaching the outer
803+ # execute_dispatch wrapper.
804+ err = _write_pid (
709805 state_path ,
710806 effective_name ,
711807 dispatch_id ,
@@ -715,7 +811,10 @@ def _on_spawn(pid: int, ticks: int) -> None:
715811 create_time ,
716812 identity_degraded = (ticks == 0 ),
717813 issue_url = _issue_urls_raw ,
814+ enforce_max_resume_attempts = is_resume_branch ,
718815 )
816+ if err is not None :
817+ _spawn_error .append (err )
719818
720819 def _on_session_id (session_id : str ) -> None :
721820 from autoskillit .fleet .state import mark_dispatch_session_identity
@@ -777,6 +876,16 @@ def _on_session_id(session_id: str) -> None:
777876 else None ,
778877 )
779878
879+ # L2 fail-closed spawn gate: check closure-scoped error state.
880+ # If _on_spawn recorded a transition failure (and killed the child
881+ # via kill_process_tree), translate it to a structured envelope
882+ # instead of letting the dispatch proceed on a stale record.
883+ if _spawn_error :
884+ return _reject_with_state (
885+ FleetErrorCode .FLEET_L3_STARTUP_OR_CRASH ,
886+ _spawn_error [0 ],
887+ )
888+
780889 ended_at = max (time .time (), started_at + 1e-6 )
781890 _dispatch_completed_normally = True
782891 except asyncio .CancelledError :
0 commit comments