diff --git a/ouroboros/tools/shell.py b/ouroboros/tools/shell.py index acc83f8e1..f5a3d2d4f 100644 --- a/ouroboros/tools/shell.py +++ b/ouroboros/tools/shell.py @@ -532,7 +532,7 @@ def _run_shell( _record_scratch_fingerprints(ctx, scratch_abs) return ( f"⚠️ TOOL_TIMEOUT (run_command): command exceeded the per-command timeout of {timeout_sec}s " - f"and its process group was killed; a child it detached may survive untracked (root={binding.root}, cwd={work_dir}). NOTE: this is the per-command " + f"and the host killed what it could still reach of its process tree; a background child may survive untracked (root={binding.root}, cwd={work_dir}). NOTE: this is the per-command " f"FOREGROUND timeout, NOT the task deadline. For genuinely long-running compute (training, " f"sampling, large builds/downloads), start it with start_service and poll " f"service_status/service_logs while you do other work, or pass an explicit timeout_sec= " @@ -681,7 +681,7 @@ def get_tools() -> List[ToolEntry]: "cmd MUST be an array of strings, never one shell string. A builtin as cmd[0] (cd, export, ...) " "is refused: use cwd= or [\"sh\", \"-c\", \"cd x && a | b\"] (also for pipes/chaining). " "A background child (&, nohup) is no service: holding stdout/stderr it stalls the call until " - "timeout_sec, and afterwards nothing tracks or stops it (use start_service). " + "timeout_sec, and once the call returns nothing tracks it (use start_service). " "Prefer read_file, search_code/query_code, write_file/edit_text to cat/head/sed, grep/find, redirects." ), "parameters": {"type": "object", "properties": { diff --git a/tests/test_run_command_schema_truth.py b/tests/test_run_command_schema_truth.py index efd265a8e..f4a33651d 100644 --- a/tests/test_run_command_schema_truth.py +++ b/tests/test_run_command_schema_truth.py @@ -61,9 +61,12 @@ def test_description_states_the_bare_builtin_refusal_and_the_sh_c_escape(): def test_description_states_what_happens_to_a_background_child(): description = _schema()["description"] - # What the empirical tests below prove: the call stalls until timeout_sec - # while the child holds stdio, and the child survives untracked either way. - for word in ("&", "nohup", "stalls the call until timeout_sec", "nothing tracks or stops it", "start_service"): + # What the empirical tests below prove on every platform: the call stalls until + # timeout_sec while the child holds stdio, and nothing tracks the child afterwards. + # Whether the timeout kill still REACHES it is the platform's business (Linux can + # resolve the exited shell's process group and kills it, macOS cannot and it + # survives), so the description promises neither a cleanup nor a survival. + for word in ("&", "nohup", "stalls the call until timeout_sec", "once the call returns nothing tracks it", "start_service"): assert word in description, word assert "killed" not in description and "cleaned up" not in description # no promise the host does not keep @@ -92,10 +95,11 @@ def test_background_child_holding_stdout_makes_the_call_wait_until_timeout(tmp_p try: assert result.startswith("⚠️ TOOL_TIMEOUT"), result assert elapsed >= 0.9, elapsed - # "nothing tracks or stops it": the shell exited at once, so the timeout's - # group kill had no tree left — the backgrounded child is still running. + # "once the call returns nothing tracks it": the shell exited at once. On + # Linux the timeout kill still resolves the dead shell's process group and + # takes the child with it; on macOS it cannot and the child keeps running. + # Either way the host holds no handle on it. alive = subprocess.run(["pgrep", "-f", marker], capture_output=True, text=True).stdout.split() - assert alive, "the backgrounded child did not outlive the timeout kill" assert not any(str(proc.pid) in alive for proc in list(_active_subprocesses)), "nothing tracks it" finally: subprocess.run(["pkill", "-f", marker], check=False) diff --git a/tests/test_shell_extraction.py b/tests/test_shell_extraction.py index f3e2f05c0..4aebefa1b 100644 --- a/tests/test_shell_extraction.py +++ b/tests/test_shell_extraction.py @@ -112,9 +112,11 @@ def test_shell_catalog_schema_bytes_and_handler_owners_are_stable(): # Workflow scope: explicit saved-setting references and lazy-output guidance. # run_command states its real contract: only a bare builtin as cmd[0] is # refused, a background child stalls the call and is never tracked after it - # (tests/test_run_command_schema_truth.py). + # (tests/test_run_command_schema_truth.py). Rolled once more: the sentence no + # longer says the child is never STOPPED, which is false where the timeout kill + # still reaches the exited shell's process group (Linux). assert hashlib.sha256(schema_bytes).hexdigest() == ( - "61a0e6006f609e84e7e9f71b82e376896a0fc9287b295fe75e6fe22d96e870e7" + "7d7a07b763cec4e8fc95bdfb733c2b8362bb8c0f0648b33825b56e52dccda4ce" ) original = json.loads(schema_bytes) for schema in original: @@ -129,7 +131,7 @@ def test_shell_catalog_schema_bytes_and_handler_owners_are_stable(): ) assert hashlib.sha256(json.dumps(original, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() == ( - "d1ee448cd3c283f769dfc2a0de3d2771d7270720931223d64757575ab42a54f5" + "c6504272bceed19cc138a9cc8ee98a04db2f6ac3b41d70fdacbc4fa4022542bc" ) assert { entry.name: (entry.handler.__module__, entry.handler.__name__)