Skip to content

Commit 0063329

Browse files
thomasahleclaude
andcommitted
Show unmanaged Codex panes in acw status table
Panes running Codex but not managed by acw now appear in the status table as "unmanaged" (dimmed), making it easy to see all running agents and identify candidates for `acw start`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bce572f commit 0063329

4 files changed

Lines changed: 197 additions & 18 deletions

File tree

bin/acw_runtime.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,42 @@ def build_status_entries_from_snapshot(
574574
)
575575
)
576576

577+
for pane in snapshot.panes:
578+
if pane.pane_id in watched_panes:
579+
continue
580+
if not pane.has_codex_process:
581+
continue
582+
if pane.detected_thread and context.is_thread_id(pane.detected_thread):
583+
if runtime_session_for_thread(model, pane.detected_thread):
584+
continue # already covered above as unwatched_pane
585+
window_name = pane.window_label or pane.window_name or ""
586+
entries.append(
587+
(
588+
{
589+
"entity_kind": "unmanaged_pane",
590+
"runtime_id": "",
591+
"pid": "",
592+
"cwd": "",
593+
"pane": pane.pane_id,
594+
"thread": pane.detected_thread or "",
595+
"name": window_name,
596+
"session_name": "",
597+
"state": "",
598+
"watch": "",
599+
"msg_inline": "",
600+
"paused": "",
601+
"runtime_status": "",
602+
"runtime_detail": "",
603+
"generation": "",
604+
"worker_label": "",
605+
"anomalies": (),
606+
"anomaly_detail": "",
607+
},
608+
pane.pane_id,
609+
pane.window_label or pane.window_name or "",
610+
)
611+
)
612+
577613
for session in snapshot.sessions:
578614
if session.thread_id in claimed_session_threads:
579615
continue

bin/acw_status.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"warn": "dark_orange",
1919
"error": "bold red",
2020
"dead": "dim",
21+
"unmanaged": "dim",
2122
}
2223

2324

@@ -142,6 +143,8 @@ def compute_state(
142143
) -> str:
143144
if row.get("entity_kind") == "dead_session":
144145
return "dead"
146+
if row.get("entity_kind") == "unmanaged_pane":
147+
return "unmanaged"
145148
if row.get("anomalies"):
146149
return "error"
147150
runtime_status = row.get("runtime_status", "")

test/support/manager_scenarios.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ def test_manager_stop_forgets_managed_session(self):
250250
self.assertIsNone(self.harness.manager_session_row(first_turn.thread_id), self.harness.diagnostics())
251251

252252
status = self.harness.run_manager("status")
253-
self.assertNotIn("stopcase", status.stdout, self.harness.diagnostics())
254-
self.assertNotIn(first_turn.thread_id, status.stdout, self.harness.diagnostics())
253+
# After stop, the pane still runs Codex so it shows as unmanaged
254+
self.assertIn("unmanaged", status.stdout, self.harness.diagnostics())
255255

256256
def test_manager_stop_without_target_forgets_all_managed_sessions(self):
257257
self.harness.rename_window("formal")
@@ -289,8 +289,8 @@ def test_manager_stop_without_target_forgets_all_managed_sessions(self):
289289
self.assertIsNone(self.harness.manager_runtime_row(aot_turn.thread_id), self.harness.diagnostics())
290290

291291
status = self.harness.run_manager("status")
292-
self.assertNotIn("formal", status.stdout, self.harness.diagnostics())
293-
self.assertNotIn("aot", status.stdout, self.harness.diagnostics())
292+
# After stop, panes still run Codex so they show as unmanaged
293+
self.assertIn("unmanaged", status.stdout, self.harness.diagnostics())
294294

295295
def test_manager_status_details_reads_live_sqlite_state_ref(self):
296296
self.harness.rename_window("details")

test/test_watchd_unit.py

Lines changed: 154 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)