2727from . import localio
2828from . import proc , receipt_schema , runguard
2929from . import run_control
30+ from . import run_lifecycle
3031from .result_integrity import validate_final_output
3132from .run_receipts import (
3233 agent_result_from_worker as _agent_result_from_worker ,
@@ -489,6 +490,17 @@ def _write_json(path: Path, payload: object) -> None:
489490 # run.json is polled by `brigade runs watch/steer/interrupt` while the run
490491 # rewrites it, so the write must be atomic or a concurrent reader can
491492 # observe a truncated file.
493+ if path .name == "run.json" and isinstance (payload , dict ):
494+ status = payload .get ("status" )
495+ if isinstance (status , str ) and status :
496+ run_lifecycle .record_lifecycle_transition (
497+ path .parent ,
498+ status = status ,
499+ # The receipt payload identifies the lock workspace
500+ # (lock_workspace or cwd); the run directory layout does not.
501+ workspace = runguard .resolve_run_lock_workspace (payload , path .parent ),
502+ incoming_snapshot = payload ,
503+ )
492504 localio .write_text_atomic (path , json .dumps (payload , indent = 2 , sort_keys = True ) + "\n " )
493505
494506
@@ -1937,7 +1949,7 @@ def record_artifact_collection(
19371949 payload ["artifact_collection" ] = collection
19381950 try :
19391951 _write_json (run_path , receipt_schema .stamp_run_receipt (payload ))
1940- except OSError as exc :
1952+ except ( OSError , run_lifecycle . LifecycleJournalError ) as exc :
19411953 raise runguard .RetainRunLockError (f"failed to update run receipt after artifact collection: { exc } " ) from exc
19421954
19431955
@@ -2010,7 +2022,7 @@ def record_run_termination(
20102022 )
20112023 try :
20122024 _write_json (run_path , receipt_schema .stamp_run_receipt (payload ))
2013- except OSError as exc :
2025+ except ( OSError , run_lifecycle . LifecycleJournalError ) as exc :
20142026 raise runguard .RetainRunLockError (f"failed to write terminal run receipt: { exc } " ) from exc
20152027
20162028
@@ -2038,7 +2050,7 @@ def record_dispatch_stage(output_dir: Path, *, stage: int, seats: tuple[str, ...
20382050 )
20392051 try :
20402052 _write_json (run_path , receipt_schema .stamp_run_receipt (payload ))
2041- except OSError as exc :
2053+ except ( OSError , run_lifecycle . LifecycleJournalError ) as exc :
20422054 raise runguard .RetainRunLockError (f"failed to write dispatch stage receipt: { exc } " ) from exc
20432055
20442056
@@ -2067,7 +2079,7 @@ def record_result_processing(output_dir: Path, *, seat: str) -> None:
20672079 payload .pop ("active_seats" , None )
20682080 try :
20692081 _write_json (run_path , receipt_schema .stamp_run_receipt (payload ))
2070- except OSError as exc :
2082+ except ( OSError , run_lifecycle . LifecycleJournalError ) as exc :
20712083 raise runguard .RetainRunLockError (f"failed to record result-processing phase: { exc } " ) from exc
20722084
20732085
@@ -2148,6 +2160,7 @@ def _run_payload(
21482160 include_git : bool = True ,
21492161 pre_run_snapshot : dict [str , object ] | None = None ,
21502162 scheduler : dict [str , object ] | None = None ,
2163+ lifecycle_journal_requested : bool | None = None ,
21512164) -> dict [str , object ]:
21522165 payload : dict [str , object ] = {
21532166 "schema" : receipt_schema .RUN_RECEIPT_SCHEMA ,
@@ -2182,6 +2195,8 @@ def _run_payload(
21822195 payload ["cwd" ] = str (cwd )
21832196 if scheduler is not None :
21842197 payload ["scheduler" ] = scheduler
2198+ if lifecycle_journal_requested :
2199+ payload ["lifecycle_journal_requested" ] = True
21852200 if resolution := _roster_resolution_payload (roster ):
21862201 payload ["roster" ] = resolution
21872202 if lock_workspace is not None :
@@ -2280,6 +2295,19 @@ def record_run_start(
22802295 output_dir = output_dir .expanduser ().resolve ()
22812296 started_at = started_at or datetime .now (timezone .utc )
22822297 output_dir .mkdir (parents = True , exist_ok = True )
2298+ run_json = output_dir / "run.json"
2299+ run_json_exists = run_json .is_file ()
2300+ existing_requested = False
2301+ if run_json_exists :
2302+ try :
2303+ existing = json .loads (run_json .read_text ())
2304+ except (OSError , json .JSONDecodeError ):
2305+ existing = None
2306+ if isinstance (existing , dict ) and existing .get ("lifecycle_journal_requested" ) is True :
2307+ existing_requested = True
2308+ lifecycle_requested = existing_requested or (
2309+ not run_json_exists and run_lifecycle .is_lifecycle_journaling_enabled ()
2310+ )
22832311 _write_json (
22842312 output_dir / "run.json" ,
22852313 _run_payload (
@@ -2301,6 +2329,7 @@ def record_run_start(
23012329 scheduler = (
23022330 {"requested" : scheduler , "used" : None , "fallback_reason" : None } if scheduler is not None else None
23032331 ),
2332+ lifecycle_journal_requested = True if lifecycle_requested else None ,
23042333 ),
23052334 )
23062335 _write_json (output_dir / "roster.json" , _roster_payload (roster ))
@@ -2488,6 +2517,15 @@ def scheduler_resolved(used: str, fallback_reason: str | None) -> None:
24882517 def _payload (** kwargs : Any ) -> dict [str , object ]:
24892518 if "skill_route_policy" not in kwargs and skill_policy is not None :
24902519 kwargs ["skill_route_policy" ] = skill_policy
2520+ if output_dir is not None and "lifecycle_journal_requested" not in kwargs :
2521+ run_path = output_dir / "run.json"
2522+ if run_path .is_file ():
2523+ try :
2524+ existing = json .loads (run_path .read_text ())
2525+ except (OSError , json .JSONDecodeError ):
2526+ existing = None
2527+ if isinstance (existing , dict ) and existing .get ("lifecycle_journal_requested" ) is True :
2528+ kwargs ["lifecycle_journal_requested" ] = True
24912529 return _run_payload (
24922530 lock_workspace = lock_workspace ,
24932531 pre_run_snapshot = pre_run_snapshot_payload ,
0 commit comments