Flag hung seats and memory pressure
A turn that has run 15 minutes while producing under 50 tokens a minute is a stalled API stream: flagged on the page with an Esc button, and notified once. The container's memory use is shown in the header and notified near the limit. Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ
This commit is contained in:
@@ -11,9 +11,11 @@ as an installable web app (PWA):
|
|||||||
- menus and permission prompts are read off the seat's screen and shown as labelled buttons (plus Esc)
|
- menus and permission prompts are read off the seat's screen and shown as labelled buttons (plus Esc)
|
||||||
- an agent that stopped with a question gets a reply box
|
- an agent that stopped with a question gets a reply box
|
||||||
- queue items blocked on a human seat are decisions, recorded with `rig queue resolve` so the owner wakes up
|
- queue items blocked on a human seat are decisions, recorded with `rig queue resolve` so the owner wakes up
|
||||||
|
- a seat whose turn has run long without producing tokens is flagged as **hung** (a stalled API stream), with an Esc button
|
||||||
|
- the container's memory use (cgroup v2) in the header, with a warning near the limit
|
||||||
- tap a seat to see its screen (refreshes every 5 s) or type a message to it
|
- tap a seat to see its screen (refreshes every 5 s) or type a message to it
|
||||||
- optional push notifications through [ntfy](https://ntfy.sh): new commits, a seat waiting for an answer
|
- optional push notifications through [ntfy](https://ntfy.sh): new commits, a seat waiting for an answer
|
||||||
(only if it is still waiting on the next poll), a new request to the operator, a lead's task finished
|
(only if it is still waiting on the next poll), a new request to the operator, a lead's task finished, a hung seat, memory running out
|
||||||
|
|
||||||
It reads everything through the documented `rig` CLI (`--json`), so it does not depend on daemon internals.
|
It reads everything through the documented `rig` CLI (`--json`), so it does not depend on daemon internals.
|
||||||
Python 3 standard library only, no build step.
|
Python 3 standard library only, no build step.
|
||||||
@@ -42,6 +44,8 @@ python3 -m unittest # tests
|
|||||||
| `RIG_POCKET_DONE_DEST` | `^orch-lead@` | regex: notify when a task to such a seat closes |
|
| `RIG_POCKET_DONE_DEST` | `^orch-lead@` | regex: notify when a task to such a seat closes |
|
||||||
| `RIG_POCKET_HUMAN_DEST` | `^operator-agent@kernel$` | regex: notify when such a seat gets a new task |
|
| `RIG_POCKET_HUMAN_DEST` | `^operator-agent@kernel$` | regex: notify when such a seat gets a new task |
|
||||||
| `RIG_POCKET_HUMAN_SEATS` | `^operator-human@` | regex: human seats (not scanned for prompts; items blocked on them are decisions) |
|
| `RIG_POCKET_HUMAN_SEATS` | `^operator-human@` | regex: human seats (not scanned for prompts; items blocked on them are decisions) |
|
||||||
|
| `RIG_POCKET_STALL_MIN` / `_STALL_RATE` | `15` / `50` | a turn this many minutes long, under this many tokens per minute, is hung |
|
||||||
|
| `RIG_POCKET_MEM_WARN` | `0.9` | notify when memory use passes this fraction of the limit |
|
||||||
| `RIG_POCKET_HUMAN_SEAT` | `operator-human@kernel` | the seat a decision is recorded as |
|
| `RIG_POCKET_HUMAN_SEAT` | `operator-human@kernel` | the seat a decision is recorded as |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|||||||
@@ -53,6 +53,39 @@ def clean(line):
|
|||||||
return FRAME.sub("", line).strip()
|
return FRAME.sub("", line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
SPINNER = re.compile(r"\((?:(\d+)h )?(?:(\d+)m )?(\d+)s · [↓↑] ([\d.]+)(k?) tokens")
|
||||||
|
STALL_MIN = float(os.environ.get("RIG_POCKET_STALL_MIN", "15"))
|
||||||
|
STALL_RATE = float(os.environ.get("RIG_POCKET_STALL_RATE", "50")) # tokens per minute
|
||||||
|
MEM_WARN = float(os.environ.get("RIG_POCKET_MEM_WARN", "0.9"))
|
||||||
|
|
||||||
|
|
||||||
|
def stalled(screen):
|
||||||
|
"""Minutes of a turn that has gone on long while barely producing tokens (a hung API stream), else None."""
|
||||||
|
m = None
|
||||||
|
for m in SPINNER.finditer(screen):
|
||||||
|
pass
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
h, mins, secs, tokens, k = m.groups()
|
||||||
|
minutes = int(h or 0) * 60 + int(mins or 0) + int(secs) / 60
|
||||||
|
tokens = float(tokens) * (1000 if k else 1)
|
||||||
|
if minutes >= STALL_MIN and tokens / minutes < STALL_RATE:
|
||||||
|
return round(minutes)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def memory():
|
||||||
|
"""Memory use of this container (cgroup v2), as a fraction of its limit, or None."""
|
||||||
|
try:
|
||||||
|
cur = int(Path("/sys/fs/cgroup/memory.current").read_text())
|
||||||
|
limit = Path("/sys/fs/cgroup/memory.max").read_text().strip()
|
||||||
|
if limit == "max":
|
||||||
|
limit = next(int(l.split()[1]) * 1024 for l in open("/proc/meminfo") if l.startswith("MemTotal"))
|
||||||
|
return {"used": cur, "limit": int(limit), "fraction": round(cur / int(limit), 3)}
|
||||||
|
except (OSError, ValueError, StopIteration):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_ask(screen, idle):
|
def parse_ask(screen, idle):
|
||||||
"""What a seat is waiting for, read off its screen.
|
"""What a seat is waiting for, read off its screen.
|
||||||
|
|
||||||
@@ -152,6 +185,7 @@ def collect():
|
|||||||
"lastActivity": n.get("lastActivity"),
|
"lastActivity": n.get("lastActivity"),
|
||||||
"waiting": ask is not None,
|
"waiting": ask is not None,
|
||||||
"ask": ask,
|
"ask": ask,
|
||||||
|
"stalled": stalled(screen) if screen and not ask else None,
|
||||||
})
|
})
|
||||||
rigs.append({"name": r["name"], "status": r.get("status"), "uptime": r.get("uptime"),
|
rigs.append({"name": r["name"], "status": r.get("status"), "uptime": r.get("uptime"),
|
||||||
"attention": r.get("attentionCount", 0), "seats": seats})
|
"attention": r.get("attentionCount", 0), "seats": seats})
|
||||||
@@ -172,7 +206,7 @@ def collect():
|
|||||||
commits.append({"hash": full, "short": short, "when": when, "refs": refs, "subject": subject})
|
commits.append({"hash": full, "short": short, "when": when, "refs": refs, "subject": subject})
|
||||||
repos.append({"name": os.path.basename(p), "commits": commits})
|
repos.append({"name": os.path.basename(p), "commits": commits})
|
||||||
|
|
||||||
return {"updated": time.time(), "rigs": rigs, "queue": queue, "repos": repos}
|
return {"updated": time.time(), "rigs": rigs, "queue": queue, "repos": repos, "memory": memory()}
|
||||||
|
|
||||||
|
|
||||||
def notify(title, body, priority=3, tags=""):
|
def notify(title, body, priority=3, tags=""):
|
||||||
@@ -201,6 +235,8 @@ class Watcher:
|
|||||||
self.notified_waiting = set()
|
self.notified_waiting = set()
|
||||||
self.queue_state = None
|
self.queue_state = None
|
||||||
self.notified_gates = set()
|
self.notified_gates = set()
|
||||||
|
self.notified_stalled = set()
|
||||||
|
self.mem_warned = False
|
||||||
|
|
||||||
def events(self, snap):
|
def events(self, snap):
|
||||||
out = []
|
out = []
|
||||||
@@ -241,6 +277,20 @@ class Watcher:
|
|||||||
out.append((f"{n} venter på svar", ask_text(s.get("ask")), 4, "warning"))
|
out.append((f"{n} venter på svar", ask_text(s.get("ask")), 4, "warning"))
|
||||||
if not s["waiting"]:
|
if not s["waiting"]:
|
||||||
self.notified_waiting.discard(n)
|
self.notified_waiting.discard(n)
|
||||||
|
if s.get("stalled") and n not in self.notified_stalled:
|
||||||
|
self.notified_stalled.add(n)
|
||||||
|
out.append((f"{n} ser ud til at hænge",
|
||||||
|
f"Samme tur i {s['stalled']} min uden at producere noget. Åbn og send Esc.", 4, "hourglass"))
|
||||||
|
if not s.get("stalled"):
|
||||||
|
self.notified_stalled.discard(n)
|
||||||
|
|
||||||
|
mem = snap.get("memory")
|
||||||
|
if mem and mem["fraction"] >= MEM_WARN and not self.mem_warned:
|
||||||
|
self.mem_warned = True
|
||||||
|
out.append(("Containeren er ved at løbe tør for hukommelse",
|
||||||
|
f"{mem['used'] / 2**30:.1f} af {mem['limit'] / 2**30:.1f} GB brugt. Agenterne kan gå i stå.", 5, "rotating_light"))
|
||||||
|
elif mem and mem["fraction"] < MEM_WARN - 0.1:
|
||||||
|
self.mem_warned = False
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -20,13 +20,16 @@ const ago = (iso) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const seatTag = (s) => {
|
const seatTag = (s) => {
|
||||||
|
if (s.stalled) return $('span', { class: 'wt-tag wt-tag--bad' }, `hænger (${s.stalled} min)`);
|
||||||
if (s.waiting) return $('span', { class: 'wt-tag wt-tag--warn' }, 'venter på svar');
|
if (s.waiting) return $('span', { class: 'wt-tag wt-tag--warn' }, 'venter på svar');
|
||||||
if (s.status && s.status !== 'running') return $('span', { class: 'wt-tag wt-tag--bad' }, s.status);
|
if (s.status && s.status !== 'running') return $('span', { class: 'wt-tag wt-tag--bad' }, s.status);
|
||||||
if (s.activity === 'running' || s.activity === 'working') return $('span', { class: 'wt-tag wt-tag--accent' }, 'arbejder');
|
if (s.activity === 'running' || s.activity === 'working') return $('span', { class: 'wt-tag wt-tag--accent' }, 'arbejder');
|
||||||
return $('span', { class: 'wt-tag' }, 'ledig');
|
return $('span', { class: 'wt-tag' }, 'ledig');
|
||||||
};
|
};
|
||||||
|
|
||||||
const QSTATE = { 'in-progress': 'wt-tag--accent', blocked: '', parked: '', done: 'wt-tag--good', failed: 'wt-tag--bad' };
|
const gb = (bytes) => (bytes / 2 ** 30).toFixed(1);
|
||||||
|
|
||||||
|
const QSTATE ={ 'in-progress': 'wt-tag--accent', blocked: '', parked: '', done: 'wt-tag--good', failed: 'wt-tag--bad' };
|
||||||
|
|
||||||
let snap = null;
|
let snap = null;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
@@ -67,8 +70,13 @@ function replyBox(placeholder, label, onSend) {
|
|||||||
// What a seat is asking, with the controls to answer it.
|
// What a seat is asking, with the controls to answer it.
|
||||||
function askBlock(s, after) {
|
function askBlock(s, after) {
|
||||||
const a = s.ask;
|
const a = s.ask;
|
||||||
if (!a) return null;
|
|
||||||
const done = (ok) => { if (after) after(); setTimeout(load, 1500); return ok; };
|
const done = (ok) => { if (after) after(); setTimeout(load, 1500); return ok; };
|
||||||
|
if (!a && s.stalled) {
|
||||||
|
return $('div', { class: 'wt-stack' },
|
||||||
|
$('span', { class: 'wt-muted' }, `Samme tur i ${s.stalled} min uden at producere noget. Esc afbryder, bagefter kan du bede den fortsætte.`),
|
||||||
|
$('button', { class: 'wt-btn', onclick: async () => done(await post('api/send', { seat: s.name, key: 'esc' })) }, 'Esc (afbryd turen)'));
|
||||||
|
}
|
||||||
|
if (!a) return null;
|
||||||
if (a.kind === 'menu') {
|
if (a.kind === 'menu') {
|
||||||
return $('div', { class: 'wt-stack' },
|
return $('div', { class: 'wt-stack' },
|
||||||
a.title ? $('span', {}, a.title) : null,
|
a.title ? $('span', {}, a.title) : null,
|
||||||
@@ -85,7 +93,14 @@ function askBlock(s, after) {
|
|||||||
|
|
||||||
function overview() {
|
function overview() {
|
||||||
const view = [];
|
const view = [];
|
||||||
const waiting = snap.rigs.flatMap((r) => r.seats.filter((s) => s.waiting));
|
const mem = snap.memory;
|
||||||
|
if (mem && mem.fraction >= 0.9) {
|
||||||
|
view.push($('section', { class: 'wt-card wt-card--raised wt-stack' },
|
||||||
|
$('div', { class: 'wt-row wt-row--between' }, $('h2', { class: 'wt-card-title' }, 'Hukommelse'),
|
||||||
|
$('span', { class: 'wt-tag wt-tag--bad' }, `${Math.round(mem.fraction * 100)} %`)),
|
||||||
|
$('span', {}, `${gb(mem.used)} af ${gb(mem.limit)} GB brugt. Agenterne kan gå i stå; giv containeren mere RAM eller stop et team.`)));
|
||||||
|
}
|
||||||
|
const waiting = snap.rigs.flatMap((r) => r.seats.filter((s) => s.waiting || s.stalled));
|
||||||
const gates = snap.queue.filter((q) => q.gate);
|
const gates = snap.queue.filter((q) => q.gate);
|
||||||
if (waiting.length || gates.length) {
|
if (waiting.length || gates.length) {
|
||||||
view.push($('section', { class: 'wt-card wt-card--raised wt-stack' },
|
view.push($('section', { class: 'wt-card wt-card--raised wt-stack' },
|
||||||
@@ -160,7 +175,7 @@ function seatView(name) {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!snap) return;
|
if (!snap) return;
|
||||||
document.getElementById('updated').textContent = ago(snap.updated);
|
document.getElementById('updated').textContent = ago(snap.updated) + (snap.memory ? ` · ${gb(snap.memory.used)}/${gb(snap.memory.limit)} GB` : '');
|
||||||
const m = location.hash.match(/^#\/seat\/(.+)$/);
|
const m = location.hash.match(/^#\/seat\/(.+)$/);
|
||||||
const view = document.getElementById('view');
|
const view = document.getElementById('view');
|
||||||
// keep the open seat view, and never wipe an answer being typed
|
// keep the open seat view, and never wipe an answer being typed
|
||||||
|
|||||||
@@ -133,6 +133,26 @@ class ParseAskTest(unittest.TestCase):
|
|||||||
def test_numbered_list_without_cursor_is_not_a_menu(self):
|
def test_numbered_list_without_cursor_is_not_a_menu(self):
|
||||||
self.assertIsNone(server.parse_ask("● Plan:\n 1. engine\n 2. rooms\n", idle=False))
|
self.assertIsNone(server.parse_ask("● Plan:\n 1. engine\n 2. rooms\n", idle=False))
|
||||||
|
|
||||||
|
def test_stalled_turn(self):
|
||||||
|
self.assertEqual(server.stalled("· Ionizing… (34m 31s · ↓ 439 tokens · thinking some more)"), 35)
|
||||||
|
self.assertIsNone(server.stalled("✶ Levitating… (10m 40s · ↓ 30.1k tokens)")) # short and busy
|
||||||
|
self.assertIsNone(server.stalled("✻ Doing… (16m 3s · ↓ 3.4k tokens)")) # long but producing
|
||||||
|
self.assertEqual(server.stalled("* Thinking… (1h 2m 0s · ↓ 900 tokens)"), 62)
|
||||||
|
self.assertIsNone(server.stalled("✻ Churned for 1m 24s · done 4:26 PM"))
|
||||||
|
|
||||||
|
def test_stall_notifies_once(self):
|
||||||
|
w = server.Watcher()
|
||||||
|
s = snap()
|
||||||
|
s["rigs"][0]["seats"][0]["stalled"] = 34
|
||||||
|
self.assertEqual(w.events(s)[-1][0], "dev-qa@team ser ud til at hænge")
|
||||||
|
self.assertEqual(w.events(s), [])
|
||||||
|
|
||||||
|
def test_memory_warning_once(self):
|
||||||
|
w = server.Watcher()
|
||||||
|
s = {**snap(), "memory": {"used": 95, "limit": 100, "fraction": 0.95}}
|
||||||
|
self.assertTrue(w.events(s)[-1][0].startswith("Containeren"))
|
||||||
|
self.assertEqual(w.events(s), [])
|
||||||
|
|
||||||
def test_ask_text_for_notifications(self):
|
def test_ask_text_for_notifications(self):
|
||||||
self.assertIn("2. Yes, and", server.ask_text(server.parse_ask(PERMISSION, idle=False)))
|
self.assertIn("2. Yes, and", server.ask_text(server.parse_ask(PERMISSION, idle=False)))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user