nexor-runner 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.
- nexor_runner/__init__.py +3 -0
- nexor_runner/__main__.py +6 -0
- nexor_runner/activity.py +477 -0
- nexor_runner/agora.py +395 -0
- nexor_runner/api.py +809 -0
- nexor_runner/binaries.py +54 -0
- nexor_runner/cards.py +137 -0
- nexor_runner/channel.py +595 -0
- nexor_runner/chatmodel.py +148 -0
- nexor_runner/cli.py +642 -0
- nexor_runner/config.py +124 -0
- nexor_runner/env.py +29 -0
- nexor_runner/importer.py +236 -0
- nexor_runner/oc_chat.py +687 -0
- nexor_runner/oc_map.py +289 -0
- nexor_runner/opencode_service.py +404 -0
- nexor_runner/remote.py +469 -0
- nexor_runner/service.py +631 -0
- nexor_runner/sessions.py +684 -0
- nexor_runner/systemd.py +146 -0
- nexor_runner/termpty.py +379 -0
- nexor_runner/tmux.py +115 -0
- nexor_runner/ws.py +255 -0
- nexor_runner-0.2.0.dist-info/METADATA +688 -0
- nexor_runner-0.2.0.dist-info/RECORD +28 -0
- nexor_runner-0.2.0.dist-info/WHEEL +4 -0
- nexor_runner-0.2.0.dist-info/entry_points.txt +3 -0
- nexor_runner-0.2.0.dist-info/licenses/LICENSE +21 -0
nexor_runner/__init__.py
ADDED
nexor_runner/__main__.py
ADDED
nexor_runner/activity.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Per-session activity: is the agent working, idle, or blocked on an approval prompt?
|
|
2
|
+
|
|
3
|
+
Everything here is read-only and cheap. Per runtime:
|
|
4
|
+
|
|
5
|
+
- Claude Code: the transcript `<claude>/projects/<cwd-slug>/<session-id>.jsonl` is append-only;
|
|
6
|
+
only its tail is read, incrementally (a stored offset, so an unchanged file costs one `stat`).
|
|
7
|
+
- Codex: the rollout `<codex>/sessions/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`, same method.
|
|
8
|
+
- OpenCode: three GETs to the local engine (`/api/session/active`, `/api/session/<id>`,
|
|
9
|
+
`/api/session/<id>/permission`), answered from a short cache.
|
|
10
|
+
- A tmux pane capture (one per session that looks like it is working, plus the sessions that have
|
|
11
|
+
no readable source) adds what the files cannot say: a visible approval menu means `waiting`,
|
|
12
|
+
`esc to interrupt` means `working`.
|
|
13
|
+
|
|
14
|
+
The result per session is `activity` (`working` | `idle` | `waiting` | `unknown`),
|
|
15
|
+
`activity_since` (ISO 8601 UTC, or null) and `last_turn_end` (ISO 8601 UTC, or null).
|
|
16
|
+
Nothing here raises into the caller: a source that cannot be read is `unknown`.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections.abc import Callable, Mapping
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import opencode_service
|
|
35
|
+
from .tmux import Tmux, TmuxError
|
|
36
|
+
|
|
37
|
+
WORKING, IDLE, WAITING, UNKNOWN = "working", "idle", "waiting", "unknown"
|
|
38
|
+
ACTIVITIES = (WORKING, IDLE, WAITING, UNKNOWN)
|
|
39
|
+
|
|
40
|
+
WINDOWS = (256 << 10, 2 << 20) # first look at a file: its last 256 KiB, then 2 MiB if undecided
|
|
41
|
+
MAX_CHUNK = 1 << 20 # never read more than this from one file in one go
|
|
42
|
+
PANE_TTL = 2.0 # seconds a pane capture is reused
|
|
43
|
+
ENGINE_TTL = 3.0 # seconds the engine's answers are reused (active set, permissions)
|
|
44
|
+
SESSION_TTL = 10.0 # seconds an engine session record (time.idle) is reused
|
|
45
|
+
URL_TTL = 60.0 # seconds the engine URL is reused (`opencode service status` is a subprocess)
|
|
46
|
+
URL_FAIL_TTL = 10.0
|
|
47
|
+
MISS_TTL = 15.0 # seconds before looking again for a transcript/rollout that was not found
|
|
48
|
+
LIST_TTL = 30.0 # seconds the engine session list (title lookup) is reused
|
|
49
|
+
ENGINE_TIMEOUT = 2.0
|
|
50
|
+
PANE_LINES = 30
|
|
51
|
+
|
|
52
|
+
WORKING_PANE_RE = re.compile(r"esc to interrupt", re.IGNORECASE)
|
|
53
|
+
# The numbered approval menu of Claude Code and Codex: `❯ 1. Yes ...`, `› 1. Yes, proceed (y)`.
|
|
54
|
+
WAITING_PANE_RE = re.compile(r"^[ \t]*(?:[❯›>][ \t]*)?1\.[ \t]+Yes\b", re.MULTILINE)
|
|
55
|
+
SKIP_PREFIXES = ("<local-command-", "<command-name>", "<command-message>")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Act:
|
|
60
|
+
"""What a source says. `since` None = the source cannot say when it began."""
|
|
61
|
+
activity: str = UNKNOWN
|
|
62
|
+
since: str | None = None
|
|
63
|
+
last_end: str | None = None
|
|
64
|
+
compacted: bool = False # reader scratch: a compaction boundary was just seen
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def iso(value: object) -> str | None:
|
|
68
|
+
"""ISO 8601 UTC with milliseconds from an ISO string or an epoch in milliseconds."""
|
|
69
|
+
try:
|
|
70
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
|
71
|
+
dt = datetime.fromtimestamp(value / 1000, UTC)
|
|
72
|
+
elif isinstance(value, str) and value:
|
|
73
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
74
|
+
if dt.tzinfo is None:
|
|
75
|
+
dt = dt.replace(tzinfo=UTC)
|
|
76
|
+
else:
|
|
77
|
+
return None
|
|
78
|
+
except (ValueError, OverflowError, OSError):
|
|
79
|
+
return None
|
|
80
|
+
return dt.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def classify_pane(text: str) -> str | None:
|
|
84
|
+
"""`waiting` when an approval menu is on screen, `working` when the client says it can be
|
|
85
|
+
interrupted, else None. Only the bottom of the pane counts (the menu sits there)."""
|
|
86
|
+
tail = "\n".join(text.splitlines()[-PANE_LINES:])
|
|
87
|
+
if WAITING_PANE_RE.search(tail):
|
|
88
|
+
return WAITING
|
|
89
|
+
if WORKING_PANE_RE.search(tail):
|
|
90
|
+
return WORKING
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- transcript / rollout readers ---------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _text_of(content: object) -> str:
|
|
97
|
+
if isinstance(content, str):
|
|
98
|
+
return content
|
|
99
|
+
if isinstance(content, list):
|
|
100
|
+
return "".join(b.get("text", "") for b in content
|
|
101
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _end(st: Act, ts: str | None) -> None:
|
|
106
|
+
"""A turn ended. Several records announce it (the closing assistant entry, then the turn
|
|
107
|
+
summary): the first one counts."""
|
|
108
|
+
if st.activity != IDLE:
|
|
109
|
+
st.activity, st.since, st.last_end = IDLE, ts, ts
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def claude_step(st: Act, o: dict) -> None:
|
|
113
|
+
"""Fold one transcript entry into the state. Only conversation entries count: the file also
|
|
114
|
+
holds titles, hook output, attachments, cost and bridge records."""
|
|
115
|
+
if o.get("isSidechain"): # a subagent's own thread, not this session's turn
|
|
116
|
+
return
|
|
117
|
+
ts = iso(o.get("timestamp"))
|
|
118
|
+
kind, msg = o.get("type"), o.get("message")
|
|
119
|
+
compacted, st.compacted = st.compacted, False
|
|
120
|
+
if kind == "system" and o.get("subtype") == "compact_boundary":
|
|
121
|
+
st.compacted = True # the summary entry that follows is not a prompt
|
|
122
|
+
elif kind == "system" and o.get("subtype") == "turn_duration":
|
|
123
|
+
_end(st, ts) # written right after the closing assistant entry
|
|
124
|
+
elif kind == "assistant" and isinstance(msg, dict):
|
|
125
|
+
stop = msg.get("stop_reason")
|
|
126
|
+
if stop in ("end_turn", "stop_sequence", "max_tokens", "refusal"):
|
|
127
|
+
_end(st, ts)
|
|
128
|
+
else: # tool_use, or a block written before the stop reason is known
|
|
129
|
+
if st.activity != WORKING:
|
|
130
|
+
st.since = ts
|
|
131
|
+
st.activity = WORKING
|
|
132
|
+
elif kind == "user" and isinstance(msg, dict):
|
|
133
|
+
text = _text_of(msg.get("content")).lstrip()
|
|
134
|
+
if text.startswith("[Request interrupted by user"):
|
|
135
|
+
_end(st, ts)
|
|
136
|
+
elif (text.startswith(SKIP_PREFIXES) or o.get("isMeta") or o.get("isCompactSummary")
|
|
137
|
+
or compacted):
|
|
138
|
+
return # slash-command and compaction bookkeeping: not a turn
|
|
139
|
+
else: # a prompt or a tool result: the model has the floor
|
|
140
|
+
if st.activity != WORKING:
|
|
141
|
+
st.since = ts
|
|
142
|
+
st.activity = WORKING
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def codex_step(st: Act, o: dict) -> None:
|
|
146
|
+
"""Fold one rollout line into the state: turns open with `task_started` and close with
|
|
147
|
+
`task_complete` or `turn_aborted` (event_msg payloads)."""
|
|
148
|
+
if o.get("type") != "event_msg" or not isinstance(o.get("payload"), dict):
|
|
149
|
+
return
|
|
150
|
+
ts, kind = iso(o.get("timestamp")), o["payload"].get("type")
|
|
151
|
+
if kind == "task_started":
|
|
152
|
+
if st.activity != WORKING:
|
|
153
|
+
st.since = ts
|
|
154
|
+
st.activity = WORKING
|
|
155
|
+
elif kind in ("task_complete", "turn_aborted"):
|
|
156
|
+
_end(st, ts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Follower:
|
|
160
|
+
"""Folds an append-only JSONL file into an `Act`, reading only what was appended.
|
|
161
|
+
|
|
162
|
+
The first look takes the tail of the file (a bigger window when the tail holds no turn
|
|
163
|
+
boundary yet); after that only bytes past the stored offset are read. A partial last line
|
|
164
|
+
is left for the next poll, and a file that shrank is read again from its tail."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, path: Path, step: Callable[[Act, dict], None]):
|
|
167
|
+
self.path, self.step = path, step
|
|
168
|
+
self.offset: int | None = None
|
|
169
|
+
self.state = Act()
|
|
170
|
+
|
|
171
|
+
def _read(self, start: int, end: int, drop_first: bool) -> tuple[list[bytes], int]:
|
|
172
|
+
with self.path.open("rb") as fh:
|
|
173
|
+
fh.seek(start)
|
|
174
|
+
data = fh.read(end - start)
|
|
175
|
+
if drop_first: # `start` may be mid-line
|
|
176
|
+
cut = data.find(b"\n")
|
|
177
|
+
if cut < 0:
|
|
178
|
+
return [], end if end - start >= MAX_CHUNK else start
|
|
179
|
+
data, start = data[cut + 1:], start + cut + 1
|
|
180
|
+
last = data.rfind(b"\n")
|
|
181
|
+
if last < 0: # no complete line yet; a pathological one is skipped
|
|
182
|
+
return [], end if len(data) >= MAX_CHUNK else start
|
|
183
|
+
return data[:last].split(b"\n"), start + last + 1
|
|
184
|
+
|
|
185
|
+
def _fold(self, lines: list[bytes]) -> None:
|
|
186
|
+
for raw in lines:
|
|
187
|
+
if not raw.strip():
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
obj = json.loads(raw)
|
|
191
|
+
except ValueError:
|
|
192
|
+
continue
|
|
193
|
+
if isinstance(obj, dict):
|
|
194
|
+
self.step(self.state, obj)
|
|
195
|
+
|
|
196
|
+
def poll(self) -> Act:
|
|
197
|
+
size = self.path.stat().st_size
|
|
198
|
+
if self.offset is not None and size < self.offset:
|
|
199
|
+
self.offset, self.state = None, Act() # truncated or replaced
|
|
200
|
+
if self.offset is None:
|
|
201
|
+
for window in WINDOWS:
|
|
202
|
+
start = max(0, size - window)
|
|
203
|
+
lines, self.offset = self._read(start, size, drop_first=start > 0)
|
|
204
|
+
self.state = Act()
|
|
205
|
+
self._fold(lines)
|
|
206
|
+
if start == 0 and self.state.activity == UNKNOWN:
|
|
207
|
+
self.state.activity = IDLE # the whole file, and nobody has said anything
|
|
208
|
+
if self.state.activity != UNKNOWN:
|
|
209
|
+
break
|
|
210
|
+
elif size > self.offset:
|
|
211
|
+
start = max(self.offset, size - MAX_CHUNK)
|
|
212
|
+
lines, self.offset = self._read(start, size, drop_first=start > self.offset)
|
|
213
|
+
self._fold(lines)
|
|
214
|
+
return Act(self.state.activity, self.state.since, self.state.last_end)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- the tracker --------------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
class _Gone(Exception):
|
|
220
|
+
"""The engine does not know this session (404)."""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _slug(cwd: str) -> str:
|
|
224
|
+
return re.sub(r"[^A-Za-z0-9]", "-", cwd)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ActivityTracker:
|
|
228
|
+
"""Keeps the per-session caches and answers `annotate(rows)`. Thread-safe."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, tmux: Tmux | None, env: Mapping[str, str],
|
|
231
|
+
clock: Callable[[], float] = time.time,
|
|
232
|
+
opener: Callable = urllib.request.urlopen,
|
|
233
|
+
log: Callable[[str], None] | None = None):
|
|
234
|
+
self.tmux, self.env, self.clock, self.opener = tmux, env, clock, opener
|
|
235
|
+
self.log = log or (lambda m: print(f"[activity] {m}", file=sys.stderr, flush=True))
|
|
236
|
+
self.lock = threading.RLock()
|
|
237
|
+
self.followers: dict[tuple[str, str], Follower | None] = {} # (client, id) -> follower
|
|
238
|
+
self.missed: dict[tuple[str, str], float] = {} # (client, id) -> when it was not found
|
|
239
|
+
self.seen: dict[str, tuple[str, str | None]] = {} # name -> (activity, since)
|
|
240
|
+
self.panes: dict[str, tuple[float, str | None]] = {}
|
|
241
|
+
self.engine: dict[str, tuple[float, object]] = {} # cache key -> (when, value)
|
|
242
|
+
self.warned: set[str] = set()
|
|
243
|
+
|
|
244
|
+
# -- public ----------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def annotate(self, rows: list[dict]) -> None:
|
|
247
|
+
"""Add `activity`, `activity_since` and `last_turn_end` to every row (`name`, `client`,
|
|
248
|
+
`session_id`, `handle`, `project`). Never raises."""
|
|
249
|
+
with self.lock:
|
|
250
|
+
now = self.clock()
|
|
251
|
+
for r in rows:
|
|
252
|
+
try:
|
|
253
|
+
act = self._one(r, now)
|
|
254
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError,
|
|
255
|
+
opencode_service.EngineError, TmuxError) as exc:
|
|
256
|
+
self._warn(f"{r.get('name')}: {type(exc).__name__}")
|
|
257
|
+
act = Act()
|
|
258
|
+
self._stamp(r, act, now)
|
|
259
|
+
alive = {r["name"] for r in rows}
|
|
260
|
+
for gone in [n for n in self.seen if n not in alive]:
|
|
261
|
+
del self.seen[gone]
|
|
262
|
+
self._prune({(r.get("client"), r.get("session_id")) for r in rows}, now)
|
|
263
|
+
|
|
264
|
+
# -- internals -------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _warn(self, what: str) -> None:
|
|
267
|
+
if what not in self.warned and len(self.warned) < 50: # once per distinct problem
|
|
268
|
+
self.warned.add(what)
|
|
269
|
+
self.log(f"cannot read activity: {what}")
|
|
270
|
+
|
|
271
|
+
def _prune(self, live: set[tuple[object, object]], now: float) -> None:
|
|
272
|
+
for cache in (self.followers, self.missed):
|
|
273
|
+
for key in [k for k in cache if k not in live]:
|
|
274
|
+
del cache[key]
|
|
275
|
+
for cache in (self.engine, self.panes): # entries nobody asked for in a while
|
|
276
|
+
for key in [k for k, v in cache.items() if now - v[0] > 600]:
|
|
277
|
+
del cache[key]
|
|
278
|
+
|
|
279
|
+
def _stamp(self, row: dict, act: Act, now: float) -> None:
|
|
280
|
+
since = act.since
|
|
281
|
+
if act.activity == UNKNOWN:
|
|
282
|
+
since = None
|
|
283
|
+
elif since is None: # the source cannot say: keep what was seen, else when it changed
|
|
284
|
+
prev = self.seen.get(row["name"])
|
|
285
|
+
since = prev[1] if prev and prev[0] == act.activity and prev[1] else iso(now * 1000)
|
|
286
|
+
self.seen[row["name"]] = (act.activity, since)
|
|
287
|
+
row["activity"], row["activity_since"], row["last_turn_end"] = (
|
|
288
|
+
act.activity, since, act.last_end)
|
|
289
|
+
|
|
290
|
+
def _one(self, row: dict, now: float) -> Act:
|
|
291
|
+
client = row.get("client")
|
|
292
|
+
if client == "cld":
|
|
293
|
+
act = self._file_act(row, now, "cld")
|
|
294
|
+
elif client == "cdx":
|
|
295
|
+
act = self._file_act(row, now, "cdx")
|
|
296
|
+
elif client == "oc":
|
|
297
|
+
return self._oc_act(row, now)
|
|
298
|
+
else:
|
|
299
|
+
return Act()
|
|
300
|
+
if act is None: # no transcript/rollout: the screen is all there is
|
|
301
|
+
pane = self._pane(row["name"], now)
|
|
302
|
+
return Act(pane) if pane in (WORKING, WAITING, IDLE) else Act()
|
|
303
|
+
if act.activity == WORKING and self._pane(row["name"], now) == WAITING:
|
|
304
|
+
return Act(WAITING, None, act.last_end)
|
|
305
|
+
return act
|
|
306
|
+
|
|
307
|
+
def _pane(self, name: str, now: float) -> str | None:
|
|
308
|
+
"""`working` / `waiting` / `idle` (a readable pane that shows neither) / None (no pane)."""
|
|
309
|
+
hit = self.panes.get(name)
|
|
310
|
+
if hit and now - hit[0] < PANE_TTL:
|
|
311
|
+
return hit[1]
|
|
312
|
+
value = None
|
|
313
|
+
if self.tmux is not None:
|
|
314
|
+
try:
|
|
315
|
+
text = self.tmux.capture_tail(name, PANE_LINES)
|
|
316
|
+
except TmuxError:
|
|
317
|
+
text = ""
|
|
318
|
+
value = classify_pane(text) or ("idle" if text.strip() else None)
|
|
319
|
+
self.panes[name] = (now, value)
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
# -- Claude Code and Codex: files ------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def _claude_path(self, sid: str, cwd: str | None) -> Path | None:
|
|
325
|
+
home = Path(self.env.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
326
|
+
projects = home / "projects"
|
|
327
|
+
if cwd:
|
|
328
|
+
direct = projects / _slug(cwd) / f"{sid}.jsonl"
|
|
329
|
+
if direct.is_file():
|
|
330
|
+
return direct
|
|
331
|
+
return next(iter(projects.glob(f"*/{sid}.jsonl")), None)
|
|
332
|
+
|
|
333
|
+
def _codex_path(self, sid: str) -> Path | None:
|
|
334
|
+
home = Path(self.env.get("CODEX_HOME") or Path.home() / ".codex")
|
|
335
|
+
for f in (home / "sessions").glob(f"*/*/*/rollout-*-{sid}.jsonl"):
|
|
336
|
+
if self._guardian(f):
|
|
337
|
+
continue
|
|
338
|
+
return f
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _guardian(path: Path) -> bool:
|
|
343
|
+
"""A guardian_review rollout is the approval reviewer's own thread, not a session."""
|
|
344
|
+
try:
|
|
345
|
+
with path.open("rb") as fh:
|
|
346
|
+
head = fh.read(65536).split(b"\n", 1)[0]
|
|
347
|
+
meta = json.loads(head).get("payload") or {}
|
|
348
|
+
except (OSError, ValueError, AttributeError):
|
|
349
|
+
return False
|
|
350
|
+
return meta.get("thread_source") == "guardian_review"
|
|
351
|
+
|
|
352
|
+
def _file_act(self, row: dict, now: float, client: str) -> Act | None:
|
|
353
|
+
sid = row.get("session_id")
|
|
354
|
+
if not isinstance(sid, str) or not sid or not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", sid):
|
|
355
|
+
return None
|
|
356
|
+
key = (client, sid)
|
|
357
|
+
if key not in self.followers:
|
|
358
|
+
if now - self.missed.get(key, -1e9) < MISS_TTL:
|
|
359
|
+
return None
|
|
360
|
+
path = (self._claude_path(sid, row.get("project")) if client == "cld"
|
|
361
|
+
else self._codex_path(sid))
|
|
362
|
+
if path is None:
|
|
363
|
+
self.missed[key] = now
|
|
364
|
+
return None
|
|
365
|
+
self.followers[key] = Follower(path, claude_step if client == "cld" else codex_step)
|
|
366
|
+
follower = self.followers[key]
|
|
367
|
+
try:
|
|
368
|
+
return follower.poll()
|
|
369
|
+
except FileNotFoundError: # the file went away: look it up again next time
|
|
370
|
+
del self.followers[key]
|
|
371
|
+
self.missed[key] = now
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
# -- OpenCode: the engine --------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def _cached(self, key: str, ttl: float, now: float, fetch: Callable[[], object]) -> object:
|
|
377
|
+
hit = self.engine.get(key)
|
|
378
|
+
if hit and now - hit[0] < ttl:
|
|
379
|
+
return hit[1]
|
|
380
|
+
value = fetch()
|
|
381
|
+
self.engine[key] = (now, value)
|
|
382
|
+
return value
|
|
383
|
+
|
|
384
|
+
def _engine_url(self, now: float) -> str:
|
|
385
|
+
hit = self.engine.get("url")
|
|
386
|
+
if hit and now - hit[0] < (URL_TTL if hit[1] else URL_FAIL_TTL):
|
|
387
|
+
if hit[1]:
|
|
388
|
+
return hit[1]
|
|
389
|
+
raise opencode_service.EngineError("the OpenCode engine is not running")
|
|
390
|
+
try:
|
|
391
|
+
url = opencode_service.status_url(self.env)
|
|
392
|
+
except opencode_service.EngineError:
|
|
393
|
+
url = None
|
|
394
|
+
self.engine["url"] = (now, url)
|
|
395
|
+
if not url:
|
|
396
|
+
raise opencode_service.EngineError("the OpenCode engine is not running")
|
|
397
|
+
return url
|
|
398
|
+
|
|
399
|
+
def _get(self, path: str, now: float) -> object:
|
|
400
|
+
down = self.engine.get("down")
|
|
401
|
+
if down and now - down[0] < URL_FAIL_TTL: # one timeout per window, not one per row
|
|
402
|
+
raise opencode_service.EngineError("the engine is not reachable")
|
|
403
|
+
url = self._engine_url(now)
|
|
404
|
+
pw = opencode_service.engine_password(self.env)
|
|
405
|
+
auth = "Basic " + base64.b64encode(f"opencode:{pw}".encode()).decode()
|
|
406
|
+
req = urllib.request.Request(url.rstrip("/") + path, headers={"Authorization": auth},
|
|
407
|
+
method="GET")
|
|
408
|
+
try:
|
|
409
|
+
with self.opener(req, timeout=ENGINE_TIMEOUT) as resp:
|
|
410
|
+
body = json.loads(resp.read() or b"null")
|
|
411
|
+
except urllib.error.HTTPError as exc:
|
|
412
|
+
if exc.code == 404:
|
|
413
|
+
raise _Gone from None
|
|
414
|
+
raise opencode_service.EngineError(f"the engine answered HTTP {exc.code}") from None
|
|
415
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
416
|
+
self.engine.pop("url", None) # it may have moved (the engine restarted)
|
|
417
|
+
self.engine["down"] = (now, True)
|
|
418
|
+
raise opencode_service.EngineError(
|
|
419
|
+
f"the engine is not reachable: {type(exc).__name__}") from None
|
|
420
|
+
return body.get("data") if isinstance(body, dict) else None
|
|
421
|
+
|
|
422
|
+
def _oc_resolve(self, row: dict, now: float) -> str | None:
|
|
423
|
+
"""The engine session id: the row's own, else the newest engine session titled with the
|
|
424
|
+
handle (the runner titles its sessions with it; see oc_map)."""
|
|
425
|
+
sid = row.get("session_id")
|
|
426
|
+
if isinstance(sid, str) and sid.startswith("ses_") and re.fullmatch(r"[\w-]+", sid):
|
|
427
|
+
try:
|
|
428
|
+
self._cached(f"s:{sid}", SESSION_TTL, now,
|
|
429
|
+
lambda: self._get(f"/api/session/{sid}", now))
|
|
430
|
+
return sid
|
|
431
|
+
except _Gone:
|
|
432
|
+
self.engine.pop(f"s:{sid}", None)
|
|
433
|
+
handle = row.get("handle")
|
|
434
|
+
if not handle:
|
|
435
|
+
return None
|
|
436
|
+
listing = self._cached("list", LIST_TTL, now, lambda: opencode_service.list_sessions(
|
|
437
|
+
self.env, self._engine_url(now), timeout=ENGINE_TIMEOUT, opener=self.opener,
|
|
438
|
+
page=100, max_pages=1))
|
|
439
|
+
titled = [s for s in listing if s["title"] == handle]
|
|
440
|
+
return max(titled, key=lambda s: s["stamp"])["id"] if titled else None
|
|
441
|
+
|
|
442
|
+
def _oc_act(self, row: dict, now: float) -> Act:
|
|
443
|
+
sid = self._oc_resolve(row, now)
|
|
444
|
+
if sid is None:
|
|
445
|
+
return Act()
|
|
446
|
+
try:
|
|
447
|
+
rec = self._cached(f"s:{sid}", SESSION_TTL, now,
|
|
448
|
+
lambda: self._get(f"/api/session/{sid}", now))
|
|
449
|
+
active = self._cached("active", ENGINE_TTL, now,
|
|
450
|
+
lambda: self._get("/api/session/active", now))
|
|
451
|
+
blocked = self._cached(f"p:{sid}", ENGINE_TTL, now, lambda: self._blocked(sid, now))
|
|
452
|
+
except _Gone:
|
|
453
|
+
return Act()
|
|
454
|
+
idle = rec.get("time", {}).get("idle") if isinstance(rec, dict) else None
|
|
455
|
+
last_end = iso(idle)
|
|
456
|
+
if blocked:
|
|
457
|
+
return Act(WAITING, None, last_end)
|
|
458
|
+
if isinstance(active, dict) and sid in active:
|
|
459
|
+
return Act(WORKING, None, last_end)
|
|
460
|
+
if isinstance(active, dict):
|
|
461
|
+
if idle is not None and self.seen.get(row["name"], ("",))[0] in (WORKING, WAITING):
|
|
462
|
+
# just finished: the cached record predates the turn end
|
|
463
|
+
self.engine.pop(f"s:{sid}", None)
|
|
464
|
+
rec = self._cached(f"s:{sid}", SESSION_TTL, now,
|
|
465
|
+
lambda: self._get(f"/api/session/{sid}", now))
|
|
466
|
+
idle = rec.get("time", {}).get("idle") if isinstance(rec, dict) else idle
|
|
467
|
+
last_end = iso(idle)
|
|
468
|
+
return Act(IDLE, last_end, last_end)
|
|
469
|
+
return Act()
|
|
470
|
+
|
|
471
|
+
def _blocked(self, sid: str, now: float) -> bool:
|
|
472
|
+
for what in ("permission", "form"): # an approval request, or a question to the user
|
|
473
|
+
pending = self._get(f"/api/session/{sid}/{what}", now)
|
|
474
|
+
if isinstance(pending, list) and pending:
|
|
475
|
+
return True
|
|
476
|
+
return False
|
|
477
|
+
|