Skip to content

Commit e4df85f

Browse files
committed
Allow timed out commands to appear in background commands monitor pane
1 parent b107f74 commit e4df85f

6 files changed

Lines changed: 130 additions & 12 deletions

File tree

‎cecli/helpers/background_commands.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def __init__(
283283
self.start_time = time.time()
284284
self.end_time = None
285285
self.persist = persist
286+
self.continued_in_background = False
286287
self.input_buffer = input_buffer or InputBuffer()
287288
self.writer_thread = None
288289
self._stop_event = threading.Event()
@@ -671,6 +672,20 @@ def start_background_command(
671672
except Exception as e:
672673
raise RuntimeError(f"Failed to start background command: {str(e)}")
673674

675+
@classmethod
676+
def mark_continued_in_background(cls, command_key: str) -> bool:
677+
"""Flag a timeout-managed command as continuing after its timeout."""
678+
679+
with cls._lock:
680+
bg_process = cls._background_commands.get(command_key)
681+
682+
if not bg_process:
683+
return False
684+
685+
bg_process.continued_in_background = True
686+
687+
return True
688+
674689
@classmethod
675690
def is_command_running(cls, command_key: str) -> bool:
676691
"""
@@ -826,6 +841,9 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]:
826841
"command": bg_process.command,
827842
"running": bg_process.is_alive(),
828843
"persist": getattr(bg_process, "persist", False),
844+
"continued_in_background": getattr(
845+
bg_process, "continued_in_background", False
846+
),
829847
"buffer_size": bg_process.buffer.size(),
830848
"pages": getattr(bg_process.buffer, "page_count", 0),
831849
"total_chars": getattr(

‎cecli/tools/shell.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Non
519519
return response
520520

521521
# Timeout elapsed, process continues in background
522+
BackgroundCommandManager.mark_continued_in_background(command_key)
522523
coder.io.tool_output(
523524
f"\u23f1\ufe0f Command exceeded {timeout}s timeout, continuing in background...",
524525
type="tool-result",

‎cecli/tui/widgets/background_commands_panel.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class BackgroundCommandsPanel(Vertical):
1818
the selected command's output is re-rendered only when its stream offset
1919
changes. Reading output is non-destructive so the agent keeps its own
2020
incremental view of the same buffer.
21+
22+
Commands that exceed their timeout and keep running appear here once the
23+
timeout fires, so the user can watch them continue.
2124
"""
2225

2326
REFRESH_INTERVAL = 0.5
@@ -112,37 +115,47 @@ def _is_shown(self) -> bool:
112115
return self.parent is None or self.parent.display
113116

114117
def _active_commands(self) -> Dict[str, dict]:
115-
"""Return only running commands started in the background."""
118+
"""Return running commands, including timeouts that continued in the background."""
116119
commands = {}
117120

118121
for command_key, info in BackgroundCommandManager.list_background_commands().items():
119122
if not info.get("running"):
120123
continue
121124

122-
if info.get("persist"):
125+
# Commands started with background=True are always listed; timeout-managed
126+
# commands only once their timeout elapsed and they kept running.
127+
if info.get("persist") and not info.get("continued_in_background"):
123128
continue
124129

125130
commands[command_key] = info
126131

127132
return commands
128133

129134
def _sync_tabs(self, commands: Dict[str, dict]) -> None:
130-
"""Rebuild the tab bar when the set of active commands changes."""
135+
"""Add and remove tabs so they match the active command set.
136+
137+
Tabs must be updated incrementally: ``Tabs.clear`` removes its children
138+
asynchronously, so clearing and immediately re-adding the same IDs
139+
races with the pending removal and raises ``DuplicateIds``.
140+
"""
131141
tabs = self.query_one("#bg-command-tabs", Tabs)
142+
previous_keys = list(self._keys)
132143
self._keys = list(commands)
133144

134145
with self.prevent(Tabs.TabActivated):
135-
tabs.clear()
146+
for command_key in previous_keys:
147+
if command_key not in commands:
148+
tabs.remove_tab(command_key)
136149

137150
for command_key in commands:
138-
tabs.add_tab(Tab(command_key, id=command_key, classes="bg-tab"))
151+
if command_key not in previous_keys:
152+
tabs.add_tab(Tab(command_key, id=command_key, classes="bg-tab"))
139153

140-
if self._active_key in commands:
141-
tabs.active = self._active_key
142-
else:
154+
if self._active_key not in commands:
143155
self._active_key = next(iter(commands), None)
144-
if self._active_key is not None:
145-
tabs.active = self._active_key
156+
157+
if self._active_key is not None:
158+
tabs.active = self._active_key
146159

147160
def _select_tab(self, command_key: str) -> None:
148161
"""Mark a tab active without relying on a user click."""
@@ -163,6 +176,10 @@ def _set_command_line(self, info: dict) -> None:
163176
"""Show the selected command, truncated to the panel width."""
164177
command = " ".join((info.get("command") or "").split())
165178
text = f"command: {command}" if command else "command: (none)"
179+
180+
if info.get("continued_in_background"):
181+
text += " (timed out, still running)"
182+
166183
width = max(20, self.size.width - 6)
167184

168185
if len(text) > width:

‎tests/basic/test_background_commands.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,24 @@ def test_get_background_command_output_stops_finished_commands(monkeypatch):
402402

403403
assert "finished" in output
404404
assert stopped == ["bg_3_0001"]
405+
406+
407+
def test_mark_continued_in_background_flags_registered_command(monkeypatch):
408+
"""Timeout-managed commands are flagged so the panel can list them."""
409+
process = types.SimpleNamespace(continued_in_background=False)
410+
monkeypatch.setattr(BackgroundCommandManager, "_background_commands", {"bg_1_1234": process})
411+
412+
assert BackgroundCommandManager.mark_continued_in_background("bg_1_1234") is True
413+
assert process.continued_in_background is True
414+
assert BackgroundCommandManager.mark_continued_in_background("bg_9_9999") is False
415+
416+
417+
def test_mark_continued_in_background_flags_command(monkeypatch):
418+
"""A timeout-managed command can be flagged as continuing in the background."""
419+
process = types.SimpleNamespace(continued_in_background=False)
420+
monkeypatch.setattr(BackgroundCommandManager, "_background_commands", {"bg_1_1234": process})
421+
422+
assert BackgroundCommandManager.mark_continued_in_background("bg_1_1234") is True
423+
assert process.continued_in_background is True
424+
425+
assert BackgroundCommandManager.mark_continued_in_background("bg_9_9999") is False

‎tests/tools/test_shell_timeout_paging.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ def make_buffer(page_size=4096, pages_dir=None):
5656
monkeypatch.setattr(manager, "_generate_command_key", Mock(return_value=target))
5757
start = Mock(return_value=target)
5858
stop = Mock()
59+
mark = Mock(return_value=True)
5960
monkeypatch.setattr(manager, "start_background_command", start)
6061
monkeypatch.setattr(manager, "stop_background_command", stop)
62+
monkeypatch.setattr(manager, "mark_continued_in_background", mark)
6163
pending_tasks = []
6264

6365
async def pending_wait(*args, **kwargs):
@@ -85,6 +87,7 @@ async def execute(output, threshold=8, enabled=True):
8587
assert not coder.interrupt_event.is_set()
8688
popen.assert_called_once()
8789
start.assert_called_once()
90+
mark.assert_called_once_with(target)
8891
assert start.call_args.kwargs["existing_process"] is process
8992
assert start.call_args.kwargs["existing_buffer"] is state["buffer"]
9093
assert start.call_args.kwargs["persist"] is True

‎tests/tui/test_background_commands_panel.py‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,18 @@
3232
"total_chars": 10,
3333
},
3434
"bg_3_4444": {
35+
"command": "timeout-tool",
36+
"running": True,
37+
"persist": True,
38+
"continued_in_background": True,
39+
"buffer_size": 10,
40+
"total_chars": 10,
41+
},
42+
"bg_4_5555": {
3543
"command": "foreground-tool",
3644
"running": True,
3745
"persist": True,
46+
"continued_in_background": False,
3847
"buffer_size": 10,
3948
"total_chars": 10,
4049
},
@@ -43,6 +52,7 @@
4352
OUTPUTS = {
4453
"bg_0_1111": "ready in 231 ms\nlistening on 5173",
4554
"bg_1_2222": "14 passed\n2 skipped",
55+
"bg_3_4444": "still compiling",
4656
}
4757

4858

@@ -93,7 +103,7 @@ async def _run_panel_checks():
93103
panel.refresh_data()
94104
await pilot.pause()
95105

96-
assert _tab_ids(panel) == ["bg_0_1111", "bg_1_2222"]
106+
assert _tab_ids(panel) == ["bg_0_1111", "bg_1_2222", "bg_3_4444"]
97107

98108
tabs = panel.query_one("#bg-command-tabs", Tabs)
99109
log = panel.query_one("#bg-command-output", RichLog)
@@ -103,7 +113,7 @@ async def _run_panel_checks():
103113
command_line = panel.query_one("#bg-command-line", Static)
104114
assert "npm run dev" in str(command_line.render())
105115

106-
assert panel.border_title == "background commands (2)"
116+
assert panel.border_title == "background commands (3)"
107117
assert panel._active_key == "bg_0_1111"
108118
assert "listening on 5173" in _log_text(panel)
109119

@@ -126,6 +136,14 @@ async def _run_panel_checks():
126136
await pilot.pause()
127137
assert "listening on 5173" in _log_text(panel)
128138

139+
tabs.active = "bg_3_4444"
140+
await pilot.pause()
141+
142+
assert panel._active_key == "bg_3_4444"
143+
assert "timeout-tool" in str(command_line.render())
144+
assert "timed out, still running" in str(command_line.render())
145+
assert "still compiling" in _log_text(panel)
146+
129147

130148
def test_background_commands_panel_tabs_output_and_follow():
131149
asyncio.run(_run_panel_checks())
@@ -299,3 +317,43 @@ def test_open_command_without_tui():
299317
asyncio.run(OpenCommand.execute(io, coder, "commands"))
300318

301319
assert messages == ["/open commands is only available in the TUI."]
320+
321+
322+
def test_background_commands_panel_adds_tabs_incrementally():
323+
"""A command appearing after the initial render must not duplicate tab IDs."""
324+
app = _PanelApp()
325+
commands = {"bg_0_1111": COMMANDS["bg_0_1111"]}
326+
327+
with (
328+
patch.object(
329+
BackgroundCommandManager,
330+
"list_background_commands",
331+
side_effect=lambda: dict(commands),
332+
),
333+
patch.object(
334+
BackgroundCommandManager,
335+
"get_command_output",
336+
side_effect=lambda key: OUTPUTS.get(key, ""),
337+
),
338+
patch.object(
339+
BackgroundCommandManager,
340+
"get_new_command_output",
341+
side_effect=AssertionError("panel must not consume agent output"),
342+
),
343+
):
344+
345+
async def run():
346+
async with app.run_test(size=(120, 40)) as pilot:
347+
panel = app.query_one("#background-commands-panel", BackgroundCommandsPanel)
348+
panel.refresh_data()
349+
await pilot.pause()
350+
351+
assert _tab_ids(panel) == ["bg_0_1111"]
352+
353+
commands["bg_3_4444"] = COMMANDS["bg_3_4444"]
354+
panel.refresh_data()
355+
await pilot.pause()
356+
357+
assert _tab_ids(panel) == ["bg_0_1111", "bg_3_4444"]
358+
359+
asyncio.run(run())

0 commit comments

Comments
 (0)