fix(process): wait timeout result reads as status, not failure

process(action='wait') hitting its window returned status='timeout'
with a terse note — models read it as an error and re-issued identical
waits (process is the #1 exact-duplicate tool call in production: 511
dupes in a 400k-msg window; wait is 57% of all process actions).

The timeout result now carries:
- process_running: true — machine-readable 'this is a status, not a
  failure'
- an explicit note: 'Wait window of Ns elapsed — the process is still
  running. This is not an error. Uptime: Ms.' plus the right next step:
  when notify_on_complete is set, 'you will be notified on exit — do
  more work instead of waiting again'; otherwise a pointer to
  notify_on_complete for next time.
- the clamp note (requested > max) now composes with the status note
  instead of replacing it.

Exited/interrupted results are unchanged.
This commit is contained in:
Teknium 2026-08-02 11:35:15 -07:00
parent e7aa06c3a6
commit 0b149ca030
2 changed files with 88 additions and 3 deletions

View File

@ -0,0 +1,64 @@
"""Tests for process wait timeout-result clarity (not-an-error semantics)."""
import pytest
from tools.process_registry import ProcessRegistry
@pytest.fixture
def registry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
return ProcessRegistry()
def _spawn_sleeper(registry, notify=False):
session = registry.spawn_local("sleep 30", cwd="/tmp", task_id="t-waitclar")
session.notify_on_complete = notify
return session.id
class TestWaitTimeoutClarity:
def test_wait_timeout_marks_process_running(self, registry):
sid = _spawn_sleeper(registry)
try:
r = registry.wait(sid, timeout=1)
assert r["status"] == "timeout"
assert r["process_running"] is True
assert "not an error" in r["timeout_note"]
assert "Uptime" in r["timeout_note"]
finally:
registry.kill_process(sid)
def test_wait_timeout_suggests_notify_when_unset(self, registry):
sid = _spawn_sleeper(registry, notify=False)
try:
r = registry.wait(sid, timeout=1)
assert "notify_on_complete=true" in r["timeout_note"]
finally:
registry.kill_process(sid)
def test_wait_timeout_defers_to_notify_when_set(self, registry):
sid = _spawn_sleeper(registry, notify=True)
try:
r = registry.wait(sid, timeout=1)
assert "you will be notified on exit" in r["timeout_note"]
finally:
registry.kill_process(sid)
def test_clamped_wait_keeps_clamp_note_and_running_semantics(self, registry, monkeypatch):
monkeypatch.setenv("TERMINAL_TIMEOUT", "1")
sid = _spawn_sleeper(registry)
try:
r = registry.wait(sid, timeout=600)
assert r["status"] == "timeout"
assert "clamped" in r["timeout_note"]
assert "not an error" in r["timeout_note"]
assert r["process_running"] is True
finally:
registry.kill_process(sid)
def test_exited_process_unaffected(self, registry):
session = registry.spawn_local("true", cwd="/tmp", task_id="t-waitclar")
r = registry.wait(session.id, timeout=10)
assert r["status"] == "exited"
assert "process_running" not in r

View File

@ -1598,11 +1598,32 @@ class ProcessRegistry:
"status": "timeout",
"command": session.command,
"output": strip_ansi(session.output_buffer[-1000:]),
# A wait window elapsing is NOT a failure — 511 exact-duplicate
# process calls in a production window show models re-issuing
# identical waits after misreading this result as an error.
"process_running": True,
}
if timeout_note:
result["timeout_note"] = timeout_note
uptime = time.time() - session.started_at if session.started_at else None
base_note = (
f"Wait window of {effective_timeout}s elapsed — the process is "
"still running. This is not an error."
)
if uptime is not None:
base_note += f" Uptime: {int(uptime)}s."
if session.notify_on_complete:
base_note += (
" notify_on_complete is set: you will be notified on exit — "
"do more work instead of waiting again."
)
else:
result["timeout_note"] = f"Waited {effective_timeout}s, process still running"
base_note += (
" Poll again later or use terminal(background=true, "
"notify_on_complete=true) next time for automatic notification."
)
if timeout_note:
result["timeout_note"] = f"{timeout_note}. {base_note}"
else:
result["timeout_note"] = base_note
return result
def kill_process(