@@ -208,6 +208,7 @@ def test_main_help_prints_command_summary(self):
208208 self .assertIn ("Usage:" , text )
209209 self .assertIn ("Commands:" , text )
210210 self .assertIn ("doctor" , text )
211+ self .assertIn ("recover" , text )
211212 self .assertIn ("Examples:" , text )
212213 self .assertEqual ("" , err .getvalue ())
213214
@@ -895,7 +896,7 @@ def test_status_summary_includes_duplicate_live_watchers_hidden_by_thread_merge(
895896 self .assertIn ("0:6:formal/%6" , text )
896897 self .assertIn ("0:10:aot2/%10" , text )
897898 self .assertIn ("Invariant error: thread 11111111…1111 has managed runtimes on panes %10, %6" , text )
898- self .assertIn ("Hint: run acw repair --dry-run for live invariant fixes." , text )
899+ self .assertIn ("Hint: run acw repair for live invariant fixes." , text )
899900
900901 def test_status_summary_flags_duplicate_agent_name_on_multiple_threads (self ):
901902 snapshot = _snapshot (
@@ -985,7 +986,7 @@ def test_status_summary_prints_doctor_recommendation_for_warn(self):
985986 text = out .getvalue ()
986987 self .assertIn ("Recommendation: run acw doctor %7" , text )
987988
988- def test_status_summary_prints_cleanup_hint_for_dead_sessions (self ):
989+ def test_status_summary_prints_recover_and_cleanup_hint_for_dead_sessions (self ):
989990 snapshot = _snapshot (sessions = (_session_record (THREAD , name = "formal" ),))
990991 with patch .object (acw , "_build_runtime_snapshot" , return_value = snapshot ):
991992 with patch .object (acw , "_read_state_json" , return_value = {"health" : "ok" }):
@@ -995,7 +996,10 @@ def test_status_summary_prints_cleanup_hint_for_dead_sessions(self):
995996 out = io .StringIO ()
996997 with redirect_stdout (out ):
997998 acw .cmd_status ([])
998- self .assertIn ("Hint: run acw cleanup to remove dead sessions." , out .getvalue ())
999+ self .assertIn (
1000+ "Hint: run acw recover to restart dead sessions, or acw cleanup to remove them." ,
1001+ out .getvalue (),
1002+ )
9991003
10001004 def test_status_summary_prefers_repair_for_pane_local_invariant_errors (self ):
10011005 snapshot = _snapshot (
@@ -1903,7 +1907,7 @@ def test_doctor_report_errors_when_live_pane_thread_cannot_be_detected(self):
19031907 rendered ,
19041908 )
19051909
1906- def test_status_summary_skips_unmanaged_unresolved_live_codex_pane (self ):
1910+ def test_status_summary_shows_unmanaged_unresolved_live_codex_pane (self ):
19071911 snapshot = _snapshot (
19081912 panes = (_pane_record (
19091913 "%7" ,
@@ -1923,8 +1927,8 @@ def test_status_summary_skips_unmanaged_unresolved_live_codex_pane(self):
19231927 with redirect_stdout (out ):
19241928 acw .cmd_status ([])
19251929 text = out .getvalue ()
1926- self .assertIn ("Sessions: 0 " , text )
1927- self .assertIn ("(none) " , text )
1930+ self .assertIn ("Sessions: 1 " , text )
1931+ self .assertIn ("unmanaged " , text )
19281932
19291933 def test_doctor_plain_output_shows_recommended_command (self ):
19301934 report = acw .DoctorReport (
@@ -2210,18 +2214,21 @@ def test_status_target_matches_saved_session_name_without_live_pane(self):
22102214 self .assertIn ("Sessions: 1" , text )
22112215 self .assertIn ("formal" , text )
22122216
2213- def test_status_skips_unmanaged_live_codex_pane_without_saved_session (self ):
2217+ def test_status_shows_unmanaged_live_codex_pane_without_saved_session (self ):
22142218 snapshot = _snapshot (
2215- panes = (_pane_record ("%9" , THREAD , window_label = "0:9:acw" , window_name = "acw" ),),
2219+ panes = (_pane_record ("%9" , THREAD , window_label = "0:9:acw" , window_name = "acw" , has_codex_process = True ),),
22162220 )
22172221 with patch .object (acw , "_build_runtime_snapshot" , return_value = snapshot ):
2218- out = io .StringIO ()
2219- with redirect_stdout (out ):
2220- acw .cmd_status ([])
2222+ with patch .object (acw , "_read_state_json" , return_value = {}):
2223+ with patch .object (acw , "_thread_times" , return_value = ("-" , "-" )):
2224+ with patch .object (acw , "_last_agent_snippet_for_pane" , return_value = "" ):
2225+ with patch .object (acw , "_is_pid_stopped" , return_value = False ):
2226+ out = io .StringIO ()
2227+ with redirect_stdout (out ):
2228+ acw .cmd_status ([])
22212229 text = out .getvalue ()
2222- self .assertIn ("Sessions: 0" , text )
2223- self .assertIn ("(none)" , text )
2224- self .assertNotIn ("acw/%9" , text )
2230+ self .assertIn ("Sessions: 1" , text )
2231+ self .assertIn ("unmanaged" , text )
22252232
22262233 def test_status_shows_unwatched_live_codex_pane_when_saved_session_exists (self ):
22272234 snapshot = _snapshot (
@@ -2621,6 +2628,139 @@ def test_restart_target_with_restart_codex_fails_when_thread_unknown(self):
26212628 stop_watchers .assert_not_called ()
26222629 self .assertIn ("could not determine thread_id for pane=%7" , err .getvalue ())
26232630
2631+ def test_recover_target_restarts_saved_dead_session_on_saved_pane (self ):
2632+ model = acw ._build_runtime_model (_snapshot (
2633+ panes = (_pane_record (
2634+ "%50" ,
2635+ "" ,
2636+ window_label = "0:50:bugs" ,
2637+ window_name = "bugs" ,
2638+ has_codex_process = False ,
2639+ ),),
2640+ sessions = (_session_record (THREAD , name = "bugs" , pane = "%50" , message = "continue through docs" ),),
2641+ ))
2642+
2643+ with patch .object (acw , "_build_runtime_model" , return_value = model ):
2644+ with patch .object (acw , "_daemon_rpc_restart_codex" ) as restart_codex :
2645+ with patch .object (
2646+ acw ,
2647+ "_daemon_rpc_upsert_session" ,
2648+ return_value = {
2649+ "thread" : THREAD ,
2650+ "pane" : "%50" ,
2651+ "runtime_status" : "running" ,
2652+ "runtime_id" : "runtime-9999" ,
2653+ "daemon_pid" : "9999" ,
2654+ },
2655+ ) as upsert :
2656+ out = io .StringIO ()
2657+ with redirect_stdout (out ), redirect_stderr (io .StringIO ()):
2658+ acw .cmd_recover (["bugs" ])
2659+
2660+ restart_codex .assert_called_once_with ("%50" , THREAD )
2661+ upsert .assert_called_once_with (
2662+ THREAD ,
2663+ pane = "%50" ,
2664+ name = "bugs" ,
2665+ message = "continue through docs" ,
2666+ paused = False ,
2667+ generation = 1 ,
2668+ )
2669+ text = out .getvalue ()
2670+ self .assertIn ("recover: preparing pane=%50" , text )
2671+ self .assertIn (f"recover: relaunching Codex in pane=%50 thread_id={ THREAD } " , text )
2672+ self .assertIn ("recover: starting runtime for pane=%50" , text )
2673+ self .assertIn (f"recovered: pane=%50 thread_id={ THREAD } daemon_pid=9999" , text )
2674+
2675+ def test_recover_target_fails_without_saved_pane (self ):
2676+ model = acw ._build_runtime_model (_snapshot (
2677+ sessions = (_session_record (THREAD , name = "bugs" , message = "continue through docs" ),),
2678+ ))
2679+
2680+ err = io .StringIO ()
2681+ with patch .object (acw , "_build_runtime_model" , return_value = model ):
2682+ with redirect_stderr (err ):
2683+ with self .assertRaises (SystemExit ) as ctx :
2684+ acw .cmd_recover (["bugs" ])
2685+
2686+ self .assertEqual (1 , ctx .exception .code )
2687+ self .assertIn ("has no saved pane" , err .getvalue ())
2688+
2689+ def test_recover_target_fails_when_saved_pane_runs_other_thread (self ):
2690+ model = acw ._build_runtime_model (_snapshot (
2691+ panes = (_pane_record ("%50" , OTHER_THREAD , window_label = "0:50:bugs" , window_name = "bugs" ),),
2692+ sessions = (_session_record (THREAD , name = "bugs" , pane = "%50" , message = "continue through docs" ),),
2693+ ))
2694+
2695+ err = io .StringIO ()
2696+ with patch .object (acw , "_build_runtime_model" , return_value = model ):
2697+ with redirect_stderr (err ):
2698+ with self .assertRaises (SystemExit ) as ctx :
2699+ acw .cmd_recover (["bugs" ])
2700+
2701+ self .assertEqual (1 , ctx .exception .code )
2702+ self .assertIn ("is already running thread" , err .getvalue ())
2703+ self .assertIn ("use acw restart or acw repair" , err .getvalue ())
2704+
2705+ def test_recover_without_target_recovers_both_sessions_including_missing_pane (self ):
2706+ model = acw ._build_runtime_model (_snapshot (
2707+ panes = (_pane_record (
2708+ "%50" ,
2709+ "" ,
2710+ window_label = "0:50:bugs" ,
2711+ window_name = "bugs" ,
2712+ has_codex_process = False ,
2713+ ),),
2714+ sessions = (
2715+ _session_record (THREAD , name = "bugs" , pane = "%50" , message = "continue through docs" ),
2716+ _session_record (OTHER_THREAD , name = "aot" , pane = "%60" , message = "continue aot" ),
2717+ ),
2718+ ))
2719+
2720+ with patch .object (acw , "_build_runtime_model" , return_value = model ):
2721+ with patch .object (acw , "_daemon_rpc_restart_codex" ) as restart_codex :
2722+ with patch .object (
2723+ acw ,
2724+ "_daemon_rpc_upsert_session" ,
2725+ return_value = {
2726+ "thread" : THREAD ,
2727+ "pane" : "%50" ,
2728+ "runtime_status" : "running" ,
2729+ "runtime_id" : "runtime-9999" ,
2730+ "daemon_pid" : "9999" ,
2731+ },
2732+ ) as upsert :
2733+ with patch .object (acw , "run_tmux" , return_value = "%58\n " ):
2734+ with patch .object (acw , "_read_session_state" , return_value = {"name" : "aot" }):
2735+ with patch .object (acw , "_write_session_state" ):
2736+ out = io .StringIO ()
2737+ err = io .StringIO ()
2738+ with redirect_stdout (out ), redirect_stderr (err ):
2739+ acw .cmd_recover ([])
2740+
2741+ self .assertEqual (restart_codex .call_count , 2 )
2742+ self .assertEqual (upsert .call_count , 2 )
2743+
2744+ def test_recover_without_target_skips_sessions_with_managed_runtime_state_on_saved_pane (self ):
2745+ model = acw ._build_runtime_model (_snapshot (
2746+ watchers = (_watcher_record ("%60" , OTHER_THREAD , pid = "2222" ),),
2747+ sessions = (
2748+ _session_record (THREAD , name = "bugs" , pane = "%60" , message = "continue through docs" ),
2749+ ),
2750+ ))
2751+
2752+ with patch .object (acw , "_build_runtime_model" , return_value = model ):
2753+ with patch .object (acw , "_daemon_rpc_restart_codex" ) as restart_codex :
2754+ out = io .StringIO ()
2755+ err = io .StringIO ()
2756+ with redirect_stdout (out ), redirect_stderr (err ):
2757+ acw .cmd_recover ([])
2758+
2759+ restart_codex .assert_not_called ()
2760+ self .assertEqual ("" , err .getvalue ())
2761+ self .assertIn ("recover: skipped 1 session(s) that are not auto-recoverable" , out .getvalue ())
2762+ self .assertIn ("managed runtime state" , out .getvalue ())
2763+
26242764 def test_codex_top_level_descendants_returns_direct_child_of_shell (self ):
26252765 graph = (
26262766 {
0 commit comments