idle timeout for codegen

This commit is contained in:
Denton Social
2026-09-24 04:53:06 -05:00
parent 0a9b602ec7
commit e92b3e7228
5 changed files with 176 additions and 22 deletions
+6
View File
@@ -221,6 +221,12 @@ unit tests (24) + box integration tests (2).
backends) — do not drop one for the other. Echoing is console-only; the backends) — do not drop one for the other. Echoing is console-only; the
returned content is identical either way. Integration tests already force returned content is identical either way. Integration tests already force
streaming; see it with `-s` on the box. streaming; see it with `-s` on the box.
- `codegen.idle_warn` (default 60s) / `codegen.idle_timeout` (default 180s)
surface a silent stream: a wedged generation prints a warning at `idle_warn`
seconds with no tokens, then raises `CodegenError` (→ graceful stub) at
`idle_timeout` — instead of blocking on the 1200s total budget. A streaming
stall with zero output usually means the ollama ROCm runner wedged;
`sudo systemctl restart ollama` is the recovery.
### Code principles ### Code principles
- **No mocking.** The decision engine is always real SemIf; the LLM is always a - **No mocking.** The decision engine is always real SemIf; the LLM is always a
+5 -2
View File
@@ -13,10 +13,13 @@
}, },
"llm": {"base_url": "http://localhost:11434/v1", "model": "qwen3.5:4b"}, "llm": {"base_url": "http://localhost:11434/v1", "model": "qwen3.5:4b"},
"codegen": { "codegen": {
"_comment": "OpenAI-compatible model that writes runnable skill bodies. Larger/slower than the decision or self-assessment model. No max_tokens cap: qwen38-iq3s reasons extensively (~7 min) before emitting the body; reasoning is filtered automatically. timeout is seconds.", "_comment": "OpenAI-compatible model that writes runnable skill bodies. Larger/slower than the decision or self-assessment model. No max_tokens cap: qwen38-iq3s reasons extensively (~7 min) before emitting the body; reasoning is filtered automatically. timeout is total seconds; idle_warn/idle_timeout surface a silent (wedged) stream in minutes instead of blocking on the total timeout.",
"base_url": "http://localhost:11434/v1", "base_url": "http://localhost:11434/v1",
"model": "qwen38-iq3s", "model": "qwen38-iq3s",
"timeout": 1200 "timeout": 1200,
"stream": false,
"idle_warn": 60,
"idle_timeout": 180
}, },
"skill_bodies": "data/skills", "skill_bodies": "data/skills",
"skills": { "skills": {
+2
View File
@@ -54,6 +54,8 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
model=codegen_cfg.get("model", "qwen38-iq3s"), model=codegen_cfg.get("model", "qwen38-iq3s"),
timeout=float(codegen_cfg.get("timeout", 1200.0)), timeout=float(codegen_cfg.get("timeout", 1200.0)),
stream=bool(codegen_cfg.get("stream", False)), stream=bool(codegen_cfg.get("stream", False)),
idle_warn=float(codegen_cfg.get("idle_warn", 60.0)),
idle_timeout=float(codegen_cfg.get("idle_timeout", 180.0)),
) )
log = DecisionLog(config.get("log", "data/decisions.jsonl")) log = DecisionLog(config.get("log", "data/decisions.jsonl"))
trace = TraceLog(config.get("trace", "data/runs.jsonl")) trace = TraceLog(config.get("trace", "data/runs.jsonl"))
+90 -11
View File
@@ -12,6 +12,9 @@ from __future__ import annotations
import ast import ast
import json import json
import select
import sys
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -44,7 +47,10 @@ class CodegenClient:
With `stream=True` the response is read as an SSE token stream and echoed With `stream=True` the response is read as an SSE token stream and echoed
to stdout as it arrives — including the chain-of-thought — so a long to stdout as it arrives — including the chain-of-thought — so a long
body write shows live progress. Echoing is console-only; the returned body write shows live progress. Echoing is console-only; the returned
content is identical either way. content is identical either way. A silent stream (no bytes for
`idle_warn` seconds) prints a warning, and one that stays silent for
`idle_timeout` seconds raises CodegenError instead of blocking on the
total timeout — a wedged generation is surfaced in minutes, not ~20.
""" """
def __init__( def __init__(
@@ -53,11 +59,15 @@ class CodegenClient:
model: str, model: str,
timeout: float = 1200.0, timeout: float = 1200.0,
stream: bool = False, stream: bool = False,
idle_warn: float = 60.0,
idle_timeout: float = 180.0,
): ):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.model = model self.model = model
self.timeout = timeout self.timeout = timeout
self.stream = stream self.stream = stream
self.idle_warn = idle_warn
self.idle_timeout = idle_timeout
def chat( def chat(
self, self,
@@ -100,21 +110,81 @@ class CodegenClient:
reasoning (chain-of-thought) is echoed to the console but never part reasoning (chain-of-thought) is echoed to the console but never part
of the result. Backends disagree on the field name: ollama streams it of the result. Backends disagree on the field name: ollama streams it
as `reasoning`, DeepSeek/vllm-style as `reasoning_content`, so read both. as `reasoning`, DeepSeek/vllm-style as `reasoning_content`, so read both.
"""
import sys
Reads are gated by `select` so the socket never blocks-and-times-out:
a socket that delivers no bytes for `idle_warn` seconds prints a
warning, and `idle_timeout` seconds of silence raises CodegenError.
The total wall-clock budget is `self.timeout`, so a slow-but-streaming
generation is never cut short. Thresholds <= 0 disable that check. If
the underlying socket can't be reached, falls back to a plain blocking
read (relying on the outer TimeoutError handling).
"""
parts: list[str] = []
sock = self._stream_socket(response)
if sock is None:
return self._read_stream_blocking(response)
start = time.monotonic()
last_activity = start
warned = False
while True:
ready, _, _ = select.select([sock], [], [], 1.0)
now = time.monotonic()
if not ready:
idle = now - last_activity
if self.idle_warn > 0 and not warned and idle >= self.idle_warn:
sys.stdout.write(
f"\n[codegen] no tokens for {idle:.0f}s — still waiting, "
f"will fail after {self.idle_timeout:.0f}s of silence\n"
)
sys.stdout.flush()
warned = True
if self.idle_timeout > 0 and idle >= self.idle_timeout:
raise CodegenError(
f"codegen stream stalled: no tokens for {idle:.0f}s "
f"(idle_timeout={self.idle_timeout:.0f}s)"
)
if now - start >= self.timeout:
raise CodegenError(
f"codegen request exceeded {self.timeout:.0f}s total budget"
)
continue
try:
raw = response.readline()
except OSError as exc:
raise CodegenError(f"codegen stream read failed: {exc}") from exc
if not raw:
break
last_activity = time.monotonic()
if not self._consume_frame(raw.decode("utf-8").strip(), parts):
break
if parts:
sys.stdout.write("\n")
sys.stdout.flush()
return "".join(parts)
def _read_stream_blocking(self, response) -> str:
"""Fallback reader when the response socket can't be located."""
parts: list[str] = [] parts: list[str] = []
for raw in response: for raw in response:
line = raw.decode("utf-8").strip() if not self._consume_frame(raw.decode("utf-8").strip(), parts):
break
if parts:
sys.stdout.write("\n")
sys.stdout.flush()
return "".join(parts)
@staticmethod
def _consume_frame(line: str, parts: list[str]) -> bool:
"""Process one SSE line. Returns False on [DONE] (stop reading)."""
if not line.startswith("data:"): if not line.startswith("data:"):
continue return True
data = line[len("data:") :].strip() data = line[len("data:") :].strip()
if data == "[DONE]": if data == "[DONE]":
break return False
try: try:
chunk = json.loads(data) chunk = json.loads(data)
except ValueError: except ValueError:
continue return True
choice = chunk.get("choices", [{}])[0] choice = chunk.get("choices", [{}])[0]
delta = choice.get("delta", {}) or {} delta = choice.get("delta", {}) or {}
text = delta.get("content") or "" text = delta.get("content") or ""
@@ -123,10 +193,19 @@ class CodegenClient:
sys.stdout.write(text + reasoning) sys.stdout.write(text + reasoning)
sys.stdout.flush() sys.stdout.flush()
parts.append(text) parts.append(text)
if parts: return True
sys.stdout.write("\n")
sys.stdout.flush() @staticmethod
return "".join(parts) def _stream_socket(response):
"""Reach the underlying socket across Python-version response shapes."""
fp = getattr(response, "fp", response)
raw = getattr(fp, "raw", None)
sock = getattr(raw, "_sock", None) if raw is not None else None
if sock is None:
sock = getattr(fp, "sock", None)
if sock is None:
sock = getattr(response, "sock", None)
return sock
def build_skill_body_prompt( def build_skill_body_prompt(
+64
View File
@@ -235,6 +235,23 @@ class _SilentOpenAI(BaseHTTPRequestHandler):
pass pass
class _StalledStreamOpenAI(BaseHTTPRequestHandler):
"""Sends response headers then holds the connection with zero body bytes;
the streaming client must hit its idle/stall watchdog."""
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
self.rfile.read(length)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
self.wfile.flush()
time.sleep(5)
def log_message(self, format, *args):
pass
def test_chat_timeout_raises_codegen_error(): def test_chat_timeout_raises_codegen_error():
handler = type("Handler", (_SilentOpenAI,), {}) handler = type("Handler", (_SilentOpenAI,), {})
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
@@ -252,6 +269,53 @@ def test_chat_timeout_raises_codegen_error():
httpd.server_close() httpd.server_close()
def test_chat_stream_stall_fails_fast():
"""A silent stream must fail at idle_timeout, not the total budget."""
handler = type("Handler", (_StalledStreamOpenAI,), {})
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
client = CodegenClient(
base_url=f"http://127.0.0.1:{httpd.server_address[1]}/v1",
model="test",
timeout=10,
stream=True,
idle_warn=1.0,
idle_timeout=2.0,
)
started = time.monotonic()
with pytest.raises(CodegenError, match="stalled"):
client.chat([{"role": "user", "content": "hi"}])
elapsed = time.monotonic() - started
assert elapsed < 8.0, f"stall should fail fast, took {elapsed:.1f}s"
finally:
httpd.shutdown()
httpd.server_close()
def test_chat_stream_stall_warns_before_failing(capsys):
handler = type("Handler", (_StalledStreamOpenAI,), {})
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
client = CodegenClient(
base_url=f"http://127.0.0.1:{httpd.server_address[1]}/v1",
model="test",
timeout=10,
stream=True,
idle_warn=1.0,
idle_timeout=3.0,
)
with pytest.raises(CodegenError, match="stalled"):
client.chat([{"role": "user", "content": "hi"}])
captured = capsys.readouterr().out
assert "no tokens for" in captured
assert "will fail after" in captured
finally:
httpd.shutdown()
httpd.server_close()
def _sse_frame(payload: dict) -> str: def _sse_frame(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n" return f"data: {json.dumps(payload)}\n\n"