agents-inc 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. agents_inc/__init__.py +5 -0
  2. agents_inc/adapters/__init__.py +0 -0
  3. agents_inc/adapters/base.py +38 -0
  4. agents_inc/adapters/claude.py +8 -0
  5. agents_inc/adapters/codex.py +15 -0
  6. agents_inc/adapters/ollama.py +472 -0
  7. agents_inc/artifacts.py +170 -0
  8. agents_inc/bench.py +94 -0
  9. agents_inc/catalog_refresh.py +173 -0
  10. agents_inc/config_schema.py +141 -0
  11. agents_inc/control.py +641 -0
  12. agents_inc/derivation_store.py +142 -0
  13. agents_inc/doctor.py +175 -0
  14. agents_inc/envelope.py +177 -0
  15. agents_inc/free_caps.py +322 -0
  16. agents_inc/free_health.py +349 -0
  17. agents_inc/gateway.py +327 -0
  18. agents_inc/governance.json +138 -0
  19. agents_inc/host_ledger.py +183 -0
  20. agents_inc/hosts/__init__.py +0 -0
  21. agents_inc/hosts/gen_stubs.py +24 -0
  22. agents_inc/install/__init__.py +1 -0
  23. agents_inc/install/bundle.py +104 -0
  24. agents_inc/install/catalog.py +72 -0
  25. agents_inc/install/cli.py +179 -0
  26. agents_inc/install/discovery.py +48 -0
  27. agents_inc/install/dispatch.py +1287 -0
  28. agents_inc/install/doctor.py +92 -0
  29. agents_inc/install/hook.py +153 -0
  30. agents_inc/install/host_wiring.py +240 -0
  31. agents_inc/install/mcp_broker.py +361 -0
  32. agents_inc/install/models_cmd.py +92 -0
  33. agents_inc/install/paths.py +39 -0
  34. agents_inc/install/receipt.py +99 -0
  35. agents_inc/install/runtime.py +460 -0
  36. agents_inc/install/transaction.py +80 -0
  37. agents_inc/install/worker_mcp.py +176 -0
  38. agents_inc/keys.py +64 -0
  39. agents_inc/ledger.py +824 -0
  40. agents_inc/lineup_router.py +306 -0
  41. agents_inc/models.json +406 -0
  42. agents_inc/pipeline.py +361 -0
  43. agents_inc/policy.py +178 -0
  44. agents_inc/protocols.json +197 -0
  45. agents_inc/registry.py +179 -0
  46. agents_inc/reviewer.py +125 -0
  47. agents_inc/router.py +154 -0
  48. agents_inc/routing.json +35 -0
  49. agents_inc/schema.py +122 -0
  50. agents_inc/store.py +520 -0
  51. agents_inc/usage_join.py +221 -0
  52. agents_inc/verifier.py +81 -0
  53. agents_inc-0.2.0.dist-info/METADATA +669 -0
  54. agents_inc-0.2.0.dist-info/RECORD +60 -0
  55. agents_inc-0.2.0.dist-info/WHEEL +5 -0
  56. agents_inc-0.2.0.dist-info/entry_points.txt +2 -0
  57. agents_inc-0.2.0.dist-info/licenses/LICENSE +145 -0
  58. agents_inc-0.2.0.dist-info/top_level.txt +2 -0
  59. workerbees/__init__.py +50 -0
  60. workerbees/keys.py +49 -0
agents_inc/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Canonical implementation package for agents-inc."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__: list[str] = []
File without changes
@@ -0,0 +1,38 @@
1
+ # agents_inc/adapters/base.py
2
+ """Run one tool-free stdin worker. Exit 0 means Returned, never Verified."""
3
+ from __future__ import annotations
4
+ import re, subprocess
5
+ from dataclasses import dataclass
6
+
7
+ _QUOTA = re.compile(r"rate.?limit|quota|usage limit|429|too many requests", re.I)
8
+
9
+ @dataclass
10
+ class WorkerResult:
11
+ status: str # returned | failed | paused
12
+ output: str
13
+ stderr: str
14
+ exit_code: int
15
+ truncated: bool = False
16
+
17
+ def run_worker(cmd: list[str], stdin_text: str, timeout: int = 300, cwd: str | None = None, max_output_bytes: int = 1_000_000) -> WorkerResult:
18
+ try:
19
+ p = subprocess.run(cmd, input=stdin_text, text=True, capture_output=True, timeout=timeout, cwd=cwd, check=False)
20
+ except FileNotFoundError as e:
21
+ return WorkerResult("failed", "", f"WB_CLI_NOT_FOUND: {e}", 127)
22
+ except subprocess.TimeoutExpired:
23
+ return WorkerResult("failed", "", "timeout", 124)
24
+
25
+ stdout, stderr = p.stdout, p.stderr
26
+ truncated = False
27
+ if len(stdout.encode("utf-8", errors="ignore")) > max_output_bytes:
28
+ stdout = stdout.encode("utf-8", errors="ignore")[:max_output_bytes].decode("utf-8", errors="ignore")
29
+ truncated = True
30
+ if len(stderr.encode("utf-8", errors="ignore")) > max_output_bytes:
31
+ stderr = stderr.encode("utf-8", errors="ignore")[:max_output_bytes].decode("utf-8", errors="ignore")
32
+ truncated = True
33
+
34
+ if p.returncode == 0:
35
+ return WorkerResult("returned", stdout, stderr, 0, truncated)
36
+ if _QUOTA.search(stderr or stdout or ""):
37
+ return WorkerResult("paused", stdout, stderr, p.returncode, truncated)
38
+ return WorkerResult("failed", stdout, stderr, p.returncode, truncated)
@@ -0,0 +1,8 @@
1
+ # agents_inc/adapters/claude.py
2
+ """Claude Code stdin worker: logged-in -p, tool-free. --bare is excluded: it disables OAuth (probe 2026-09-05)."""
3
+ DISALLOWED = ["Bash","PowerShell","Read","Edit","Write","Glob","Grep","Task",
4
+ "AskUserQuestion","TodoWrite","WebFetch","WebSearch","NotebookEdit"]
5
+
6
+ def build_cmd(model: str) -> list[str]:
7
+ return ["claude", "-p", "--model", model, "--setting-sources", "", "--strict-mcp-config",
8
+ "--disallowedTools", *DISALLOWED, "--"]
@@ -0,0 +1,15 @@
1
+ # agents_inc/adapters/codex.py
2
+ """Codex stdin worker: exec, read-only sandbox, empty cwd, no inherited env, no web. Prompt on stdin."""
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ def empty_cwd() -> str:
7
+ return tempfile.mkdtemp(prefix="wb-worker-")
8
+
9
+ def build_cmd(model: str, executable: str | None, cwd: str | None = None, effort: str = "medium") -> list[str]:
10
+ if not executable or not Path(executable).is_absolute():
11
+ raise ValueError("WB_CLI_NOT_FOUND: absolute Codex executable required")
12
+ cwd = cwd or empty_cwd()
13
+ return [executable, "exec", "-m", model, "-s", "read-only", "--skip-git-repo-check", "-C", cwd,
14
+ "-c", f"model_reasoning_effort={effort}",
15
+ "-c", 'shell_environment_policy.inherit="none"', "-c", 'web_search="disabled"', "-c", "features.shell_tool=false", "-"]
@@ -0,0 +1,472 @@
1
+ """Local ollama adapter. HTTP behind subprocess, stdlib only, percent-based memory guards."""
2
+ from __future__ import annotations
3
+ import sys, os, json, time, fcntl, threading, subprocess, math, re, http.client
4
+ from pathlib import Path
5
+ from dataclasses import dataclass
6
+
7
+ # Read config from routing.json
8
+ def _load_config() -> dict:
9
+ try:
10
+ routing = json.loads((Path(__file__).parent.parent / "routing.json").read_text())
11
+ return routing.get("local", {})
12
+ except Exception:
13
+ return {}
14
+
15
+ def build_cmd(model: str) -> list[str]:
16
+ """Return [sys.executable, "-m", "agents_inc.adapters.ollama", model]."""
17
+ return [sys.executable, "-m", "agents_inc.adapters.ollama", model]
18
+
19
+ _CONFIG = _load_config()
20
+ _LOCK_HANDLE = None # Module-level lock file handle, kept open for process lifetime
21
+
22
+ def _state_dir() -> Path:
23
+ env_path = os.environ.get("AGENTS_INC_LOCAL_STATE")
24
+ if env_path:
25
+ return Path(env_path)
26
+ return Path.home() / ".cache" / "agents-inc" / "local"
27
+
28
+ def _load_allowlist() -> list[str]:
29
+ return _CONFIG.get("models", [])
30
+
31
+ def mem_snapshot() -> dict | None:
32
+ """Return {total_bytes, free_pct, free_bytes, swap_used_bytes} or None on error."""
33
+ uname = os.uname().sysname
34
+ try:
35
+ if uname == "Darwin":
36
+ # macOS: sysctl calls
37
+ import subprocess
38
+ total_b = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"]).decode().strip())
39
+ free_pct = int(subprocess.check_output(["sysctl", "-n", "kern.memorystatus_level"]).decode().strip())
40
+ swap_output = subprocess.check_output(["sysctl", "-n", "vm.swapusage"]).decode().strip()
41
+ # Parse: "total = 5120.00M used = 3394.75M free = 1725.25M (encrypted)"
42
+ match = re.search(r"used\s*=\s*([\d.]+)\s*M", swap_output)
43
+ swap_used_b = int(float(match.group(1)) * 1024 * 1024) if match else 0
44
+ free_b = int(total_b * free_pct / 100)
45
+ return {"total_bytes": total_b, "free_pct": free_pct, "free_bytes": free_b, "swap_used_bytes": swap_used_b}
46
+ elif uname == "Linux":
47
+ # Linux: /proc/meminfo (kB)
48
+ meminfo = Path("/proc/meminfo").read_text()
49
+ values = {}
50
+ for line in meminfo.split("\n"):
51
+ if not line.strip():
52
+ continue
53
+ parts = line.split(":")
54
+ if len(parts) == 2:
55
+ key = parts[0].strip()
56
+ val_str = parts[1].strip().split()[0]
57
+ try:
58
+ values[key] = int(val_str) * 1024 # Convert kB to bytes
59
+ except ValueError:
60
+ pass
61
+ total_b = values.get("MemTotal", 0)
62
+ available_b = values.get("MemAvailable", 0)
63
+ swap_used_b = values.get("SwapTotal", 0) - values.get("SwapFree", 0)
64
+ if total_b <= 0:
65
+ return None
66
+ free_pct = int(100 * available_b / total_b) if total_b > 0 else 0
67
+ return {"total_bytes": total_b, "free_pct": free_pct, "free_bytes": available_b, "swap_used_bytes": swap_used_b}
68
+ else:
69
+ return None
70
+ except Exception:
71
+ return None
72
+
73
+ def ready(model: str, base_url: str | None = None, opener=None) -> tuple[bool, str]:
74
+ """GET /api/tags timeout connect_timeout_s; model in allowlist AND listed."""
75
+ if model not in _load_allowlist():
76
+ return False, "WB_LOCAL_MODEL_NOT_ALLOWED"
77
+ endpoint = base_url or _CONFIG.get("endpoint", "http://127.0.0.1:11434")
78
+ timeout_s = _CONFIG.get("connect_timeout_s", 2)
79
+ try:
80
+ host, port = endpoint.replace("http://", "").replace("https://", "").split(":")
81
+ port = int(port)
82
+ except Exception:
83
+ return False, "WB_LOCAL_ENDPOINT_INVALID"
84
+ try:
85
+ conn = http.client.HTTPConnection(host, port, timeout=timeout_s)
86
+ conn.request("GET", "/api/tags")
87
+ resp = conn.getresponse()
88
+ body = resp.read().decode()
89
+ conn.close()
90
+ if resp.status != 200:
91
+ return False, "WB_LOCAL_HTTP"
92
+ data = json.loads(body)
93
+ models = [m.get("name") for m in data.get("models", [])]
94
+ return model in models, "" if model in models else "WB_LOCAL_MODEL_NOT_READY"
95
+ except Exception as e:
96
+ return False, f"WB_LOCAL_HTTP: {str(e)[:100]}"
97
+
98
+ def _http_req(base_url: str, path: str, method: str = "GET", body: dict | None = None, timeout_s: float = 2,
99
+ conn_holder: dict | None = None) -> tuple[int, str]:
100
+ """GET or POST to loopback only. Return (status, body_text). Raise ValueError on non-loopback/redirect.
101
+ conn_holder, if given, gets its "conn" key set to the live HTTPConnection right after connect, so a
102
+ watchdog thread can close it out from under an in-flight request (defect 4: abort must close the
103
+ generate conn before unload, or ollama won't unload mid-request)."""
104
+ url = base_url + path
105
+ # Enforce loopback: only 127.0.0.1, localhost, ::1; http scheme
106
+ if url.startswith("http://127.0.0.1:") or url.startswith("http://localhost:"):
107
+ host_port = url.replace("http://", "")
108
+ elif url.startswith("http://[::1]:"):
109
+ host_port = url.replace("http://", "").split("/", 1)[0]
110
+ else:
111
+ raise ValueError("WB_LOCAL_NON_LOOPBACK")
112
+
113
+ try:
114
+ host, rest = host_port.split(":", 1)
115
+ port_and_path = rest.split("/", 1)
116
+ port = int(port_and_path[0])
117
+ path_part = "/" + port_and_path[1] if len(port_and_path) > 1 else "/"
118
+ except Exception:
119
+ raise ValueError("WB_LOCAL_HTTP")
120
+
121
+ try:
122
+ conn = http.client.HTTPConnection(host, port, timeout=timeout_s)
123
+ if conn_holder is not None:
124
+ conn_holder["conn"] = conn
125
+ if method == "GET":
126
+ conn.request("GET", path_part)
127
+ else:
128
+ body_text = json.dumps(body or {})
129
+ conn.request("POST", path_part, body_text, {"Content-Type": "application/json"})
130
+ resp = conn.getresponse()
131
+ # Reject redirects
132
+ if resp.status >= 300 and resp.status < 400:
133
+ raise ValueError("WB_LOCAL_HTTP: redirect")
134
+ result = resp.read().decode()
135
+ conn.close()
136
+ return resp.status, result
137
+ except http.client.HTTPException as e:
138
+ raise ValueError(f"WB_LOCAL_HTTP: {str(e)[:100]}")
139
+
140
+ def admit(model: str, snap: dict | None, model_size: int | None = None) -> tuple[bool, str]:
141
+ """snap None -> reject WB_LOCAL_NO_TELEMETRY; free_pct < min_free_pct -> reject WB_LOCAL_LOW_MEMORY; free_bytes < size*model_headroom -> WB_LOCAL_MODEL_TOO_BIG."""
142
+ if snap is None:
143
+ return False, "WB_LOCAL_NO_TELEMETRY"
144
+ min_free_pct = _CONFIG.get("min_free_pct", 35)
145
+ if snap.get("free_pct", 0) < min_free_pct:
146
+ return False, "WB_LOCAL_LOW_MEMORY"
147
+ # Check model headroom: need free_bytes >= model_size * model_headroom
148
+ if model_size is not None:
149
+ headroom = _CONFIG.get("model_headroom", 1.25)
150
+ needed = int(model_size * headroom)
151
+ if snap.get("free_bytes", 0) < needed:
152
+ return False, "WB_LOCAL_MODEL_TOO_BIG"
153
+ # Projected admission: reject upfront any model whose footprint, once loaded,
154
+ # would leave free_pct within projection_margin_pct of the watchdog's own abort
155
+ # line -- catches models that pass the raw headroom check today but would trip
156
+ # the watchdog abort moments after load (percent-scaled, no absolute GiB).
157
+ total_bytes = snap.get("total_bytes", 0)
158
+ if total_bytes > 0:
159
+ abort_free_pct = _CONFIG.get("abort_free_pct", 25)
160
+ projection_margin_pct = _CONFIG.get("projection_margin_pct", 5)
161
+ projected_free_pct = snap.get("free_pct", 0) - (model_size * headroom / total_bytes * 100)
162
+ if projected_free_pct < abort_free_pct + projection_margin_pct:
163
+ return False, "WB_LOCAL_PROJECTED_LOW_MEMORY"
164
+ return True, ""
165
+
166
+ def _estimate_tokens(prompt: str) -> int:
167
+ """Estimate tokens = ceil(len(prompt.encode())/3)."""
168
+ return math.ceil(len(prompt.encode()) / 3)
169
+
170
+ def _acquire_lock(lock_path: Path) -> bool:
171
+ """Acquire exclusive lock LOCK_EX|LOCK_NB. Return file handle if locked, None if busy."""
172
+ global _LOCK_HANDLE
173
+ try:
174
+ f = open(lock_path, "w")
175
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
176
+ _LOCK_HANDLE = f
177
+ return f
178
+ except (BlockingIOError, OSError):
179
+ return None
180
+
181
+ def _get_breaker_reason(state_dir: Path) -> str | None:
182
+ """Return reason if breaker file present, else None."""
183
+ breaker_path = state_dir / "ollama.disabled"
184
+ if breaker_path.exists():
185
+ try:
186
+ data = json.loads(breaker_path.read_text())
187
+ return data.get("reason")
188
+ except Exception:
189
+ return "WB_LOCAL_DISABLED"
190
+ return None
191
+
192
+ def _write_breaker(state_dir: Path, reason: str) -> None:
193
+ """Write breaker file with reason and timestamp."""
194
+ state_dir.mkdir(parents=True, exist_ok=True)
195
+ breaker_path = state_dir / "ollama.disabled"
196
+ data = {"reason": reason, "ts": time.time()}
197
+ breaker_path.write_text(json.dumps(data))
198
+
199
+ def _reset_breaker(state_dir: Path) -> None:
200
+ """Clear breaker file."""
201
+ breaker_path = state_dir / "ollama.disabled"
202
+ if breaker_path.exists():
203
+ breaker_path.unlink()
204
+
205
+ def _unload_and_verify(endpoint: str, model: str, state_dir: Path) -> bool:
206
+ """POST unload, poll /api/ps until model absent <= unload_verify_s. Return True if verified, False if timeout."""
207
+ unload_verify_s = _CONFIG.get("unload_verify_s", 5)
208
+ try:
209
+ unload_body = {"model": model, "keep_alive": 0}
210
+ _http_req(endpoint, "/api/generate", method="POST", body=unload_body, timeout_s=10)
211
+ except Exception:
212
+ pass
213
+
214
+ # Verify unload
215
+ start_unload = time.time()
216
+ while time.time() - start_unload < unload_verify_s:
217
+ try:
218
+ status, body = _http_req(endpoint, "/api/ps", method="GET", timeout_s=2)
219
+ if status == 200:
220
+ data = json.loads(body)
221
+ loaded = [m.get("name") for m in data.get("models", [])]
222
+ if model not in loaded:
223
+ return True # Unload verified
224
+ except Exception:
225
+ pass
226
+ time.sleep(0.5)
227
+
228
+ # Unload not confirmed
229
+ return False
230
+
231
+ def _log_usage(model: str, input_tokens: int, output_tokens: int) -> None:
232
+ from datetime import datetime, timezone
233
+ path = Path(os.path.expanduser("~")) / ".codex-bridge" / "usage.jsonl"
234
+ entry = {"ts": datetime.now(timezone.utc).isoformat(), "backend": "ollama", "model": model,
235
+ "input_tokens": input_tokens, "output_tokens": output_tokens}
236
+ try:
237
+ path.parent.mkdir(parents=True, exist_ok=True)
238
+ with path.open("a", encoding="utf-8") as f:
239
+ f.write(json.dumps(entry) + "\n")
240
+ except OSError:
241
+ pass
242
+
243
+
244
+ def main():
245
+ """Read prompt from stdin; model arg. Checks: breaker, lock, budget, telemetry+admit. POST /api/generate with watchdog."""
246
+ global _LOCK_HANDLE
247
+
248
+ # Handle --reset flag
249
+ if len(sys.argv) > 1 and sys.argv[1] == "--reset":
250
+ state_dir = _state_dir()
251
+ _reset_breaker(state_dir)
252
+ print("ollama breaker cleared", file=sys.stderr)
253
+ sys.exit(0)
254
+
255
+ if len(sys.argv) < 2:
256
+ print("usage: ollama <model>", file=sys.stderr)
257
+ sys.exit(2)
258
+
259
+ model = sys.argv[1]
260
+ allowlist = _load_allowlist()
261
+ if model not in allowlist:
262
+ print("WB_LOCAL_MODEL_NOT_ALLOWED", file=sys.stderr)
263
+ sys.exit(2)
264
+
265
+ state_dir = _state_dir()
266
+ state_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ # Check breaker
269
+ breaker_reason = _get_breaker_reason(state_dir)
270
+ if breaker_reason:
271
+ print(f"WB_LOCAL_DISABLED: {breaker_reason}", file=sys.stderr)
272
+ sys.exit(2)
273
+
274
+ # Acquire lock
275
+ lock_path = state_dir / "ollama.lock"
276
+ lock_handle = _acquire_lock(lock_path)
277
+ if lock_handle is None:
278
+ print("WB_LOCAL_BUSY", file=sys.stderr)
279
+ sys.exit(2)
280
+
281
+ try:
282
+ # Read prompt from stdin
283
+ prompt = sys.stdin.read()
284
+
285
+ # Check input budget
286
+ max_input_tokens = _CONFIG.get("max_input_tokens", 3072)
287
+ estimated_tokens = _estimate_tokens(prompt)
288
+ if estimated_tokens > max_input_tokens:
289
+ print("WB_LOCAL_INPUT_TOO_LARGE", file=sys.stderr)
290
+ sys.exit(2)
291
+
292
+ # Check telemetry
293
+ snap = mem_snapshot()
294
+ if snap is None:
295
+ print("WB_LOCAL_NO_TELEMETRY", file=sys.stderr)
296
+ sys.exit(2)
297
+
298
+ # Get model size from /api/tags for admit check. Distinguish "couldn't reach tags"
299
+ # (exit 1, no breaker -- server down) from "reachable but model not listed" (exit 2).
300
+ endpoint = _CONFIG.get("endpoint", "http://127.0.0.1:11434")
301
+ model_size = None
302
+ tags_status = None
303
+ try:
304
+ tags_status, tags_body = _http_req(endpoint, "/api/tags", method="GET", timeout_s=_CONFIG.get("connect_timeout_s", 2))
305
+ except Exception:
306
+ tags_status = None
307
+
308
+ if tags_status != 200:
309
+ print("WB_LOCAL_HTTP: tags unavailable", file=sys.stderr)
310
+ sys.exit(1)
311
+
312
+ try:
313
+ data = json.loads(tags_body)
314
+ for m in data.get("models", []):
315
+ if m.get("name") == model:
316
+ model_size = m.get("size")
317
+ break
318
+ except Exception:
319
+ model_size = None
320
+
321
+ if model_size is None:
322
+ print("WB_LOCAL_MODEL_NOT_ALLOWED", file=sys.stderr)
323
+ sys.exit(2)
324
+
325
+ # Check admit
326
+ admitted, reason = admit(model, snap, model_size=model_size)
327
+ if not admitted:
328
+ print(reason, file=sys.stderr)
329
+ sys.exit(2)
330
+
331
+ # POST /api/generate in worker thread, watchdog in main
332
+ deadline_s = _CONFIG.get("deadline_s", 90)
333
+ num_ctx = _CONFIG.get("num_ctx", 4096)
334
+ num_predict = _CONFIG.get("num_predict", 512)
335
+ no_think_models = _CONFIG.get("no_think_models", [])
336
+ watchdog_interval_s = _CONFIG.get("watchdog_interval_s", 2)
337
+ abort_free_pct = _CONFIG.get("abort_free_pct", 25)
338
+ max_swap_growth_pct = _CONFIG.get("max_swap_growth_pct", 2)
339
+ worker_timeout_s = _CONFIG.get("worker_timeout_s", 100)
340
+
341
+ req_body = {
342
+ "model": model,
343
+ "prompt": prompt,
344
+ "stream": False,
345
+ "keep_alive": 0,
346
+ "options": {
347
+ "num_ctx": num_ctx,
348
+ "num_predict": num_predict
349
+ }
350
+ }
351
+ if model in no_think_models:
352
+ req_body["think"] = False
353
+
354
+ # result also doubles as the conn_holder passed to _http_req, so the watchdog can
355
+ # close result["conn"] from the main thread to abort an in-flight generate (defect 4).
356
+ result = {"output": None, "error": None, "http_status": None, "connect_fail": False, "conn": None}
357
+
358
+ def worker_fn():
359
+ try:
360
+ status, body = _http_req(endpoint, "/api/generate", method="POST", body=req_body,
361
+ timeout_s=worker_timeout_s, conn_holder=result)
362
+ if status != 200:
363
+ result["http_status"] = status
364
+ result["error"] = f"WB_LOCAL_HTTP: HTTP {status}"
365
+ result["connect_fail"] = False
366
+ return
367
+ data = json.loads(body)
368
+ result["output"] = data.get("response", "")
369
+ result["usage"] = (data.get("prompt_eval_count", 0), data.get("eval_count", 0))
370
+ except ValueError as e:
371
+ # raised by _http_req for non-loopback/redirect/malformed target -- not a
372
+ # connect-fail, server was reachable enough to answer wrong.
373
+ result["error"] = str(e)
374
+ result["connect_fail"] = False
375
+ except OSError as e:
376
+ # connect refused / timed out / socket-level failure = server down.
377
+ # Spec: no breaker for this case.
378
+ result["error"] = f"WB_LOCAL_HTTP: {str(e)[:100]}"
379
+ result["connect_fail"] = True
380
+ except Exception as e:
381
+ result["error"] = f"WB_LOCAL_HTTP: {str(e)[:100]}"
382
+ result["connect_fail"] = False
383
+
384
+ worker = threading.Thread(target=worker_fn, daemon=True)
385
+ worker.start()
386
+
387
+ start_time = time.time()
388
+ snap_baseline = snap.copy()
389
+ watchdog_abort_reason = None
390
+
391
+ while worker.is_alive():
392
+ elapsed = time.time() - start_time
393
+
394
+ # Check deadline
395
+ if elapsed > deadline_s:
396
+ watchdog_abort_reason = "WB_LOCAL_DEADLINE"
397
+ break
398
+
399
+ # Telemetry lost mid-run -> abort. Do NOT silently continue (defect 1).
400
+ snap_current = mem_snapshot()
401
+ if snap_current is None:
402
+ watchdog_abort_reason = "WB_LOCAL_TELEMETRY_LOST"
403
+ break
404
+
405
+ # Check free memory
406
+ if snap_current.get("free_pct", 0) < abort_free_pct:
407
+ watchdog_abort_reason = "WB_LOCAL_LOW_MEMORY_ABORT"
408
+ break
409
+
410
+ # Check swap growth
411
+ swap_growth_b = snap_current.get("swap_used_bytes", 0) - snap_baseline.get("swap_used_bytes", 0)
412
+ max_growth_b = snap_baseline.get("total_bytes", 1) * max_swap_growth_pct / 100
413
+ if swap_growth_b > max_growth_b:
414
+ watchdog_abort_reason = "WB_LOCAL_SWAP_GROWTH"
415
+ break
416
+
417
+ time.sleep(watchdog_interval_s)
418
+
419
+ # On abort, close the in-flight generate conn first -- ollama won't unload a model
420
+ # mid-request, so the worker's open HTTPConnection must die before we try (defect 4).
421
+ if watchdog_abort_reason and result.get("conn") is not None:
422
+ try:
423
+ result["conn"].close()
424
+ except Exception:
425
+ pass
426
+
427
+ # Unload + verify (always run after any generate path)
428
+ unload_ok = _unload_and_verify(endpoint, model, state_dir)
429
+
430
+ # Handle abort (watchdog triggered)
431
+ if watchdog_abort_reason:
432
+ if not unload_ok:
433
+ # If unload failed, note both reasons
434
+ _write_breaker(state_dir, f"{watchdog_abort_reason}; unload failed")
435
+ else:
436
+ _write_breaker(state_dir, watchdog_abort_reason)
437
+ print(watchdog_abort_reason, file=sys.stderr)
438
+ sys.exit(3)
439
+
440
+ # Handle unload failure (if no watchdog abort)
441
+ if not unload_ok:
442
+ _write_breaker(state_dir, "WB_LOCAL_UNLOAD_UNCONFIRMED")
443
+ print("WB_LOCAL_UNLOAD_UNCONFIRMED", file=sys.stderr)
444
+ sys.exit(4)
445
+
446
+ # Worker done, check result. connect_fail (server down) -> no breaker; any other
447
+ # HTTP failure (non-200, malformed body, redirect) -> breaker.
448
+ if result["error"]:
449
+ reason = result["error"]
450
+ if not result["connect_fail"]:
451
+ _write_breaker(state_dir, reason)
452
+ print(reason, file=sys.stderr)
453
+ sys.exit(1)
454
+
455
+ # Success: log usage (best effort), print response, exit 0
456
+ if result.get("usage"):
457
+ _log_usage(model, *result["usage"])
458
+ if result["output"] is not None:
459
+ print(result["output"], end="")
460
+ sys.exit(0)
461
+
462
+ finally:
463
+ # Close lock handle (do NOT unlink - keeps exclusivity for waiters)
464
+ if _LOCK_HANDLE:
465
+ try:
466
+ _LOCK_HANDLE.close()
467
+ except Exception:
468
+ pass
469
+ _LOCK_HANDLE = None
470
+
471
+ if __name__ == "__main__":
472
+ main()