Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions s11_background_tasks/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class BackgroundManager:
self._ready.append(task_id)
```

command が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。Shell は独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。これは lifecycle cleanup であって sandbox ではなく、別の session を作った process は group から離れられる
command が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。POSIX では Shell を独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。Windows には POSIX の process-group signal がないため、実行中の Shell process に `Popen.terminate()` と `Popen.kill()` を使う。これは lifecycle cleanup であって sandbox ではなく、管理対象の Shell から離れたり別の session を作ったりした process は残る場合がある

### collect_background_results: 通知収集

Expand Down Expand Up @@ -160,9 +160,9 @@ python s11_background_tasks/code.py

以下のプロンプトを試してください:

1. `Run pip list in the background and find all Python files in this directory`
2. `Run npm install (use run_in_background) and while waiting, read package.json`
3. `Run a short sleep in the background, then list all Markdown files`
1. `Run python -m pip list in the background and find all Python files in this directory`
2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`
3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files`

観察ポイント:`run_in_background` を明示的に設定すると、コマンドがバックグラウンドに送られるか?`bg_id` は返されるか?後続のターンで完了結果が `<task_notification>` 形式で収集されるか?

Expand Down
8 changes: 4 additions & 4 deletions s11_background_tasks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class BackgroundManager:
self._ready.append(task_id)
```

A non-zero exit code or worker exception becomes `failed`. The shell starts in its own process group. When the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path, the runtime stops that original group. This is lifecycle cleanup, not a sandbox: a process that creates another session can leave the group.
A non-zero exit code or worker exception becomes `failed`. On POSIX, the shell starts in its own process group, which the runtime stops when the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path. Windows has no POSIX process-group signals, so the runtime uses `Popen.terminate()` and `Popen.kill()` for a still-running shell process. This is lifecycle cleanup, not a sandbox: a process that escapes the managed shell or creates another session may survive it.

### collect_background_results: Notification Collection

Expand Down Expand Up @@ -160,9 +160,9 @@ python s11_background_tasks/code.py

Try these prompts:

1. `Run pip list in the background and find all Python files in this directory`
2. `Run npm install (use run_in_background) and while waiting, read package.json`
3. `Run a short sleep in the background, then list all Markdown files`
1. `Run python -m pip list in the background and find all Python files in this directory`
2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`
3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files`

What to observe: After explicitly setting `run_in_background`, is the command dispatched to the background? Is a `bg_id` returned? Are completed results collected in `<task_notification>` format on a later turn?

Expand Down
8 changes: 4 additions & 4 deletions s11_background_tasks/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class BackgroundManager:
self._ready.append(task_id)
```

命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。这只是生命周期清理,并不是沙箱;另建 session 的进程仍可能离开该进程组
命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。在 POSIX 上,Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。Windows 没有 POSIX 进程组信号,因此运行时会对仍在运行的 Shell 进程调用 `Popen.terminate()` 和 `Popen.kill()`。这只是生命周期清理,并不是沙箱;脱离受管 Shell 或另建 session 的进程仍可能存活

### collect_background_results: 通知收集

Expand Down Expand Up @@ -160,9 +160,9 @@ python s11_background_tasks/code.py

试试这些 prompt:

1. `Run pip list in the background and find all Python files in this directory`
2. `Run npm install (use run_in_background) and while waiting, read package.json`
3. `Run a short sleep in the background, then list all Markdown files`
1. `Run python -m pip list in the background and find all Python files in this directory`
2. `Run npm --prefix web install (use run_in_background) and while waiting, read web/package.json`
3. `Run python -c "import time; time.sleep(3); print('done')" in the background, then list all Markdown files`

观察重点:显式设置 `run_in_background` 后,命令有没有被送到后台?`bg_id` 是否返回?后续轮次有没有以 `<task_notification>` 格式收集完成结果?

Expand Down
33 changes: 30 additions & 3 deletions s11_background_tasks/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@

SYSTEM = (
f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
"Set run_in_background to true only for independent Bash commands."
"Set run_in_background to true only for independent shell commands."
+ (
" On Windows, the bash tool uses cmd.exe; use cmd-compatible or "
"cross-platform commands."
if os.name == "nt"
else ""
)
)


Expand All @@ -56,6 +62,23 @@

def _stop_process_group(process: subprocess.Popen):
"""Stop processes that remain in the command's original process group."""
if os.name == "nt":
# Windows has no os.killpg or SIGKILL. Use Popen's native methods
# without changing the background-task protocol demonstrated here.
if process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=0.2)
except subprocess.TimeoutExpired:
try:
process.kill()
except OSError:
pass
except OSError:
pass
return

for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(process.pid, sig)
Expand Down Expand Up @@ -383,15 +406,19 @@ def collect(self) -> list[str]:

notifications = []
for task_id, task, result in ready:
notifications.append(
notification = (
f"<task_notification>\n"
f" <task_id>{task_id}</task_id>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{result[:500]}</summary>\n"
f"</task_notification>"
)
print(f" [background] collected {task_id}: {task['status']}")
notifications.append(notification)
print(
f" [background] collected {task_id} "
f"as <task_notification>: {task['status']}"
)
return notifications


Expand Down
68 changes: 66 additions & 2 deletions tests/test_background_tasks.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import copy
import importlib.util
import os
import subprocess
import sys
import tempfile
import threading
import time
import types
from pathlib import Path
Expand Down Expand Up @@ -63,6 +65,50 @@ def wait_until(predicate, timeout: float = 2.0) -> bool:
return False


def test_windows_process_cleanup_avoids_posix_only_signals():
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
calls = []

class RunningProcess:
def poll(self):
return None

def terminate(self):
calls.append("terminate")

def wait(self, timeout):
calls.append(("wait", timeout))
return 0

def kill(self):
calls.append("kill")

original_os_name = lesson.os.name
try:
lesson.os.name = "nt"
lesson._stop_process_group(RunningProcess())
finally:
lesson.os.name = original_os_name

assert calls == ["terminate", ("wait", 0.2)]

calls.clear()

class StubbornProcess(RunningProcess):
def wait(self, timeout):
calls.append(("wait", timeout))
raise subprocess.TimeoutExpired("test", timeout)

try:
lesson.os.name = "nt"
lesson._stop_process_group(StubbornProcess())
finally:
lesson.os.name = original_os_name

assert calls == ["terminate", ("wait", 0.2), "kill"]


def test_s11_keeps_the_s04_kernel_and_adds_one_bash_option():
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
Expand Down Expand Up @@ -122,12 +168,30 @@ def test_background_bash_passes_permission_before_dispatch():
def test_completed_result_is_collected_once_before_a_later_llm_call():
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
worker_started = threading.Event()
release_worker = threading.Event()

def controlled_command(command):
assert command == "controlled command"
worker_started.set()
assert release_worker.wait(timeout=2)
return "ready", 0

lesson._run_bash_process = controlled_command
block = types.SimpleNamespace(
id="tool_ready",
name="bash",
input={"command": "printf ready", "run_in_background": True},
input={"command": "controlled command", "run_in_background": True},
)
task_id = lesson.start_background_task(block)
start_result = lesson.execute_tool(block)
task_id = next(iter(lesson.background_tasks))

assert worker_started.wait(timeout=2)
assert task_id == "bg_0001"
assert task_id in start_result
assert lesson.background_tasks[task_id]["status"] == "running"

release_worker.set()
assert wait_until(
lambda: lesson.background_tasks[task_id]["status"] == "completed"
)
Expand Down
Loading