yeschef-cli 0.1.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.
yeschef/hub/store.py ADDED
@@ -0,0 +1,1621 @@
1
+ """SQLite-backed hub state.
2
+
3
+ Single-writer, WAL mode, guarded by one re-entrant lock. Every method is synchronous and
4
+ short; async handlers call straight in. All timestamps are assigned here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import secrets
13
+ import sqlite3
14
+ import threading
15
+ from pathlib import Path
16
+
17
+ from ..models import (
18
+ DEFAULT_TASK_TIMEOUT_S,
19
+ HEARTBEAT_TTL_S,
20
+ INPUT_REQUIRED_TTL_S,
21
+ MAX_ARTIFACT_BYTES,
22
+ Agent,
23
+ AgentKind,
24
+ ErrorCode,
25
+ EventKind,
26
+ HubError,
27
+ Message,
28
+ Room,
29
+ RoomPolicy,
30
+ Task,
31
+ TaskEvent,
32
+ TaskState,
33
+ TurnPolicy,
34
+ new_id,
35
+ not_found,
36
+ now,
37
+ )
38
+ from .events import EventBus
39
+
40
+ SCHEMA_PATH = Path(__file__).with_name("schema.sql")
41
+ MAX_TASK_ATTEMPTS = 2
42
+ FLOOR_NUDGE_S = 20.0
43
+ """Re-send a floor grant when its online holder has said nothing for this long."""
44
+
45
+
46
+ def estimate_tokens(text: str) -> int:
47
+ return max(1, len(text) // 4)
48
+
49
+
50
+ def _hash_token(token: str) -> str:
51
+ return hashlib.sha256(token.encode()).hexdigest()
52
+
53
+
54
+ def _dm_key(a: str, b: str) -> str:
55
+ return "\x1f".join(sorted((a, b)))
56
+
57
+
58
+ class Store:
59
+ def __init__(self, path: str | Path, bus: EventBus | None = None) -> None:
60
+ self.path = str(path)
61
+ self.bus = bus or EventBus()
62
+ self._lock = threading.RLock()
63
+ self._artifact_blobs: dict[str, bytes] = {} # in-memory stores only (tests)
64
+ if self.path != ":memory:":
65
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
66
+ self._db = sqlite3.connect(self.path, check_same_thread=False)
67
+ self._db.row_factory = sqlite3.Row
68
+ self._db.executescript(SCHEMA_PATH.read_text())
69
+ # Idempotent migrations for columns added after a database was created.
70
+ import contextlib as _ctx
71
+ import sqlite3 as _sqlite3
72
+
73
+ with _ctx.suppress(_sqlite3.OperationalError):
74
+ self._db.execute("ALTER TABLE tasks ADD COLUMN project TEXT")
75
+ self._db.commit()
76
+ with _ctx.suppress(_sqlite3.OperationalError):
77
+ self._db.execute("ALTER TABLE artifacts ADD COLUMN fetched_at REAL")
78
+ self._db.commit()
79
+ with _ctx.suppress(_sqlite3.OperationalError):
80
+ self._db.execute("ALTER TABLE tasks ADD COLUMN output_mode TEXT")
81
+ self._db.commit()
82
+ with _ctx.suppress(_sqlite3.OperationalError):
83
+ self._db.execute("ALTER TABLE tasks ADD COLUMN data TEXT")
84
+ self._db.commit()
85
+ with _ctx.suppress(_sqlite3.OperationalError):
86
+ self._db.execute("ALTER TABLE tasks ADD COLUMN input_required_at REAL")
87
+ self._db.commit()
88
+ self._db.commit()
89
+
90
+ def close(self) -> None:
91
+ with self._lock:
92
+ self._db.close()
93
+
94
+ # ---------------------------------------------------------------- agents
95
+
96
+ def register_agent(
97
+ self,
98
+ name: str,
99
+ kind: AgentKind = AgentKind.WORKER,
100
+ node: str | None = None,
101
+ backend: str | None = None,
102
+ tags: list[str] | None = None,
103
+ presented_token: str | None = None,
104
+ privileged: bool = False,
105
+ ) -> tuple[Agent, str]:
106
+ """Register, or re-register with proof of ownership.
107
+
108
+ Claiming a name that already has a token requires either that token or a
109
+ privileged caller — otherwise anyone reaching the hub could take over an
110
+ agent's identity and lock the real one out.
111
+ """
112
+ if not name or "\x1f" in name:
113
+ raise HubError(ErrorCode.INVALID, "invalid agent name")
114
+ token = secrets.token_urlsafe(24)
115
+ ts = now()
116
+ with self._lock:
117
+ existing = self._db.execute(
118
+ "SELECT created_at, token_hash FROM agents WHERE name = ?", (name,)
119
+ ).fetchone()
120
+ if existing and existing["token_hash"] and not privileged:
121
+ matches = presented_token is not None and secrets.compare_digest(
122
+ existing["token_hash"], _hash_token(presented_token)
123
+ )
124
+ if not matches:
125
+ raise HubError(
126
+ ErrorCode.FORBIDDEN,
127
+ f"agent '{name}' is already registered; present its token to re-register",
128
+ 403,
129
+ )
130
+ created_at = existing["created_at"] if existing else ts
131
+ self._db.execute(
132
+ """INSERT INTO agents (name, kind, node, backend, tags, token_hash, last_seen,
133
+ created_at)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
135
+ ON CONFLICT(name) DO UPDATE SET
136
+ kind = excluded.kind, node = excluded.node, backend = excluded.backend,
137
+ tags = excluded.tags, token_hash = excluded.token_hash,
138
+ last_seen = excluded.last_seen""",
139
+ (
140
+ name,
141
+ str(kind),
142
+ node,
143
+ backend,
144
+ json.dumps(tags or []),
145
+ _hash_token(token),
146
+ ts,
147
+ created_at,
148
+ ),
149
+ )
150
+ self._db.commit()
151
+ agent = self.get_agent(name)
152
+ assert agent is not None
153
+ return agent, token
154
+
155
+ def ensure_identity(self, name: str, kind: AgentKind = AgentKind.CLAUDE) -> Agent:
156
+ """Lazily create an addressable identity (used by Claude Code sessions)."""
157
+ with self._lock:
158
+ row = self._db.execute("SELECT * FROM agents WHERE name = ?", (name,)).fetchone()
159
+ ts = now()
160
+ if row is None:
161
+ self._db.execute(
162
+ """INSERT INTO agents (name, kind, tags, last_seen, created_at)
163
+ VALUES (?, ?, '[]', ?, ?)""",
164
+ (name, str(kind), ts, ts),
165
+ )
166
+ else:
167
+ self._db.execute("UPDATE agents SET last_seen = ? WHERE name = ?", (ts, name))
168
+ self._db.commit()
169
+ agent = self.get_agent(name)
170
+ assert agent is not None
171
+ return agent
172
+
173
+ def rename_identity(self, old: str, new: str, kind: AgentKind = AgentKind.CLAUDE) -> Agent:
174
+ """Re-label a session identity, carrying room membership across."""
175
+ if old == new:
176
+ return self.ensure_identity(new, kind)
177
+ self.ensure_identity(new, kind)
178
+ with self._lock:
179
+ self._db.execute(
180
+ """UPDATE OR IGNORE room_members SET agent = ? WHERE agent = ?""", (new, old)
181
+ )
182
+ self._db.execute("DELETE FROM room_members WHERE agent = ?", (old,))
183
+ self._db.execute("UPDATE rooms SET floor_holder = ? WHERE floor_holder = ?", (new, old))
184
+ self._db.execute("UPDATE rooms SET created_by = ? WHERE created_by = ?", (new, old))
185
+ self._db.execute("UPDATE messages SET sender = ? WHERE sender = ?", (new, old))
186
+ self._db.execute("UPDATE tasks SET created_by = ? WHERE created_by = ?", (new, old))
187
+ self._db.execute("UPDATE tasks SET assignee = ? WHERE assignee = ?", (new, old))
188
+ self._db.execute("DELETE FROM agents WHERE name = ? AND kind = 'claude'", (old,))
189
+ self._db.commit()
190
+ agent = self.get_agent(new)
191
+ assert agent is not None
192
+ return agent
193
+
194
+ def verify_token(self, name: str, token: str) -> bool:
195
+ with self._lock:
196
+ row = self._db.execute(
197
+ "SELECT token_hash FROM agents WHERE name = ?", (name,)
198
+ ).fetchone()
199
+ if row is None or not row["token_hash"]:
200
+ return False
201
+ return secrets.compare_digest(row["token_hash"], _hash_token(token))
202
+
203
+ def identify_token(self, token: str) -> str | None:
204
+ """Resolve a bearer token to an agent name, for routes that only need 'someone valid'."""
205
+ digest = _hash_token(token)
206
+ with self._lock:
207
+ row = self._db.execute(
208
+ "SELECT name FROM agents WHERE token_hash = ?", (digest,)
209
+ ).fetchone()
210
+ return row["name"] if row else None
211
+
212
+ def heartbeat(self, name: str) -> None:
213
+ with self._lock:
214
+ self._db.execute("UPDATE agents SET last_seen = ? WHERE name = ?", (now(), name))
215
+ self._db.commit()
216
+
217
+ def get_agent(self, name: str) -> Agent | None:
218
+ with self._lock:
219
+ row = self._db.execute("SELECT * FROM agents WHERE name = ?", (name,)).fetchone()
220
+ return self._row_to_agent(row) if row else None
221
+
222
+ def require_agent(self, name: str) -> Agent:
223
+ agent = self.get_agent(name)
224
+ if agent is None:
225
+ raise not_found(f"agent '{name}'")
226
+ return agent
227
+
228
+ def list_agents(self, kind: AgentKind | None = None) -> list[Agent]:
229
+ sql = "SELECT * FROM agents"
230
+ args: tuple = ()
231
+ if kind is not None:
232
+ sql += " WHERE kind = ?"
233
+ args = (str(kind),)
234
+ sql += " ORDER BY name"
235
+ with self._lock:
236
+ rows = self._db.execute(sql, args).fetchall()
237
+ return [self._row_to_agent(r) for r in rows]
238
+
239
+ def _row_to_agent(self, row: sqlite3.Row) -> Agent:
240
+ return Agent(
241
+ name=row["name"],
242
+ kind=AgentKind(row["kind"]),
243
+ node=row["node"],
244
+ backend=row["backend"],
245
+ tags=json.loads(row["tags"]),
246
+ last_seen=row["last_seen"],
247
+ created_at=row["created_at"],
248
+ )
249
+
250
+ # ----------------------------------------------------------------- rooms
251
+
252
+ def create_room(
253
+ self,
254
+ topic: str,
255
+ created_by: str,
256
+ participants: list[str] | None = None,
257
+ policy: RoomPolicy | None = None,
258
+ open_room: bool = False,
259
+ room_id: str | None = None,
260
+ dm_key: str | None = None,
261
+ ) -> Room:
262
+ # Backstops are applied here so no code path can create an unbounded room.
263
+ policy = (policy or RoomPolicy()).bounded()
264
+ rid = room_id or new_id("room")
265
+ ts = now()
266
+ members = list(dict.fromkeys([created_by, *(participants or [])]))
267
+ with self._lock:
268
+ self._db.execute(
269
+ """INSERT INTO rooms (id, topic, created_by, open, policy_json, dm_key,
270
+ last_activity, created_at)
271
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
272
+ (
273
+ rid,
274
+ topic,
275
+ created_by,
276
+ int(open_room),
277
+ json.dumps(policy.to_dict()),
278
+ dm_key,
279
+ ts,
280
+ ts,
281
+ ),
282
+ )
283
+ for pos, member in enumerate(members):
284
+ self._db.execute(
285
+ """INSERT OR IGNORE INTO room_members (room_id, agent, ring_pos, joined_at)
286
+ VALUES (?, ?, ?, ?)""",
287
+ (rid, member, pos, ts),
288
+ )
289
+ self._db.commit()
290
+ room = self.get_room(rid)
291
+ assert room is not None
292
+ if policy.turn_policy is TurnPolicy.ROUND_ROBIN:
293
+ self._grant_floor(room, self._first_worker(room))
294
+ room = self.get_room(rid) or room
295
+ invitees = [m for m in members if m != created_by]
296
+ self.bus.publish_many(
297
+ invitees, EventKind.ROOM_INVITE, {"room": room.to_dict(), "by": created_by}
298
+ )
299
+ return room
300
+
301
+ def get_or_create_dm(self, a: str, b: str) -> Room:
302
+ key = _dm_key(a, b)
303
+ with self._lock:
304
+ row = self._db.execute("SELECT id FROM rooms WHERE dm_key = ?", (key,)).fetchone()
305
+ if row:
306
+ room = self.get_room(row["id"])
307
+ if room is not None and not room.archived:
308
+ return room
309
+ if room is not None:
310
+ # The old thread hit a policy backstop. Release the key so the pair can
311
+ # keep talking in a fresh room instead of being wedged forever.
312
+ with self._lock:
313
+ self._db.execute("UPDATE rooms SET dm_key = NULL WHERE id = ?", (room.id,))
314
+ self._db.commit()
315
+ return self.create_room(
316
+ topic=f"{a} ↔ {b}", created_by=a, participants=[b], dm_key=key, room_id=new_id("dm")
317
+ )
318
+
319
+ def get_room(self, room_id: str) -> Room | None:
320
+ with self._lock:
321
+ row = self._db.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
322
+ if row is None:
323
+ return None
324
+ members = [
325
+ r["agent"]
326
+ for r in self._db.execute(
327
+ "SELECT agent FROM room_members WHERE room_id = ? ORDER BY ring_pos, joined_at",
328
+ (room_id,),
329
+ ).fetchall()
330
+ ]
331
+ return Room(
332
+ id=row["id"],
333
+ topic=row["topic"],
334
+ created_by=row["created_by"],
335
+ open=bool(row["open"]),
336
+ policy=RoomPolicy.from_dict(json.loads(row["policy_json"])),
337
+ archived=bool(row["archived"]),
338
+ archived_reason=row["archived_reason"],
339
+ dm_key=row["dm_key"],
340
+ floor_holder=row["floor_holder"],
341
+ created_at=row["created_at"],
342
+ members=members,
343
+ )
344
+
345
+ def require_room(self, room_id: str) -> Room:
346
+ room = self.get_room(room_id)
347
+ if room is None:
348
+ raise not_found(f"room '{room_id}'")
349
+ return room
350
+
351
+ def list_rooms(self, agent: str | None = None, include_archived: bool = False) -> list[Room]:
352
+ sql = "SELECT r.id FROM rooms r"
353
+ args: list = []
354
+ clauses = []
355
+ if agent:
356
+ sql += " JOIN room_members m ON m.room_id = r.id"
357
+ clauses.append("m.agent = ?")
358
+ args.append(agent)
359
+ if not include_archived:
360
+ clauses.append("r.archived = 0")
361
+ if clauses:
362
+ sql += " WHERE " + " AND ".join(clauses)
363
+ sql += " ORDER BY r.last_activity DESC"
364
+ with self._lock:
365
+ ids = [r["id"] for r in self._db.execute(sql, args).fetchall()]
366
+ return [room for room in (self.get_room(i) for i in ids) if room is not None]
367
+
368
+ def join_room(self, room_id: str, agent: str, privileged: bool = False) -> Room:
369
+ room = self.require_room(room_id)
370
+ if room.archived:
371
+ raise HubError(ErrorCode.CONFLICT, "room is archived", 409)
372
+ if agent in room.members:
373
+ return room
374
+ if not room.open and not privileged:
375
+ raise HubError(ErrorCode.FORBIDDEN, "room is invite-only", 403)
376
+ with self._lock:
377
+ pos = self._db.execute(
378
+ "SELECT COALESCE(MAX(ring_pos), -1) + 1 AS p FROM room_members WHERE room_id = ?",
379
+ (room_id,),
380
+ ).fetchone()["p"]
381
+ self._db.execute(
382
+ "INSERT OR IGNORE INTO room_members (room_id, agent, ring_pos, joined_at) "
383
+ "VALUES (?, ?, ?, ?)",
384
+ (room_id, agent, pos, now()),
385
+ )
386
+ self._db.commit()
387
+ room = self.require_room(room_id)
388
+ self.bus.publish_many(
389
+ [m for m in room.members if m != agent],
390
+ EventKind.ROOM_INVITE,
391
+ {"room": room.to_dict(), "joined": agent},
392
+ )
393
+ return room
394
+
395
+ def leave_room(self, room_id: str, agent: str) -> None:
396
+ room = self.require_room(room_id)
397
+ with self._lock:
398
+ self._db.execute(
399
+ "DELETE FROM room_members WHERE room_id = ? AND agent = ?", (room_id, agent)
400
+ )
401
+ self._db.commit()
402
+ if room.policy.turn_policy is TurnPolicy.ROUND_ROBIN:
403
+ fresh = self.get_room(room_id)
404
+ if fresh and self._floor_holder(room_id) == agent:
405
+ self._grant_floor(fresh, self._first_worker(fresh))
406
+
407
+ def archive_room(
408
+ self, room_id: str, reason: str, by: str | None = None, privileged: bool = False
409
+ ) -> Room:
410
+ """`by` is checked for membership unless the caller is privileged."""
411
+ if by is not None and not privileged:
412
+ room = self.require_room(room_id)
413
+ if by not in room.members:
414
+ raise HubError(ErrorCode.FORBIDDEN, "not a member of this room", 403)
415
+ with self._lock:
416
+ self._db.execute(
417
+ "UPDATE rooms SET archived = 1, archived_reason = ?, floor_holder = NULL "
418
+ "WHERE id = ? AND archived = 0",
419
+ (reason, room_id),
420
+ )
421
+ self._db.commit()
422
+ room = self.require_room(room_id)
423
+ self.bus.publish_many(
424
+ room.members, EventKind.ROOM_ARCHIVED, {"room_id": room_id, "reason": reason}
425
+ )
426
+ return room
427
+
428
+ # ------------------------------------------------------------ floor control
429
+
430
+ def _floor_holder(self, room_id: str) -> str | None:
431
+ with self._lock:
432
+ row = self._db.execute(
433
+ "SELECT floor_holder FROM rooms WHERE id = ?", (room_id,)
434
+ ).fetchone()
435
+ return row["floor_holder"] if row else None
436
+
437
+ def _worker_ring(self, room: Room) -> list[str]:
438
+ """Members that take scheduled turns. Claude identities interject, unscheduled."""
439
+ ring = []
440
+ for name in room.members:
441
+ agent = self.get_agent(name)
442
+ if agent is not None and agent.kind is AgentKind.WORKER:
443
+ ring.append(name)
444
+ return ring
445
+
446
+ def _first_worker(self, room: Room) -> str | None:
447
+ ring = self._worker_ring(room)
448
+ return ring[0] if ring else None
449
+
450
+ def _grant_floor(self, room: Room, holder: str | None) -> None:
451
+ with self._lock:
452
+ self._db.execute("UPDATE rooms SET floor_holder = ? WHERE id = ?", (holder, room.id))
453
+ self._db.commit()
454
+ if holder:
455
+ self.bus.publish(
456
+ holder, EventKind.FLOOR_GRANTED, {"room_id": room.id, "topic": room.topic}
457
+ )
458
+
459
+ def yield_floor(self, room_id: str, agent: str) -> Room:
460
+ """Pass the turn without speaking.
461
+
462
+ Without this a round-robin dialogue stalls for good whenever the agent holding
463
+ the floor has nothing to say or its model call fails.
464
+ """
465
+ room = self.require_room(room_id)
466
+ if room.policy.turn_policy is not TurnPolicy.ROUND_ROBIN or room.archived:
467
+ return room
468
+ if self._floor_holder(room_id) != agent:
469
+ raise HubError(ErrorCode.CONFLICT, "you do not hold the floor", 409)
470
+ self._advance_floor(room, agent)
471
+ return self.require_room(room_id)
472
+
473
+ def _advance_floor(self, room: Room, sender: str) -> None:
474
+ """Worker turn passes to the next in ring; a Claude interjection re-anchors."""
475
+ ring = self._worker_ring(room)
476
+ if not ring:
477
+ self._grant_floor(room, None)
478
+ return
479
+ if sender in ring:
480
+ nxt = ring[(ring.index(sender) + 1) % len(ring)]
481
+ else:
482
+ nxt = ring[0]
483
+ self._grant_floor(room, nxt)
484
+
485
+ # -------------------------------------------------------------- messages
486
+
487
+ def post_message(
488
+ self,
489
+ room_id: str,
490
+ sender: str,
491
+ body: str,
492
+ data: dict | None = None,
493
+ reply_to: str | None = None,
494
+ mentions: list[str] | None = None,
495
+ client_msg_id: str | None = None,
496
+ tokens: int | None = None,
497
+ ) -> Message:
498
+ room = self.require_room(room_id)
499
+ if room.archived:
500
+ raise HubError(ErrorCode.CONFLICT, f"room is archived ({room.archived_reason})", 409)
501
+
502
+ if sender not in room.members:
503
+ if room.open:
504
+ room = self.join_room(room_id, sender)
505
+ else:
506
+ raise HubError(ErrorCode.FORBIDDEN, "not a member of this room", 403)
507
+
508
+ agent = self.get_agent(sender)
509
+ is_claude = agent is not None and agent.kind is AgentKind.CLAUDE
510
+ round_robin = room.policy.turn_policy is TurnPolicy.ROUND_ROBIN
511
+
512
+ mentions = mentions if mentions is not None else _parse_mentions(body, room.members)
513
+ cost = tokens if tokens is not None else estimate_tokens(body)
514
+ ts = now()
515
+ mid = new_id("msg")
516
+
517
+ # Floor check, idempotency, sequence assignment, insert, and floor hand-off all
518
+ # happen under one lock. Split across transactions, two concurrent posts from the
519
+ # floor holder both passed the turn check and it took two turns in one round.
520
+ with self._lock:
521
+ if round_robin and not is_claude:
522
+ holder = self._floor_holder(room_id)
523
+ if holder is None:
524
+ # An unseeded ring would otherwise mean no turn enforcement at all.
525
+ holder = self._first_worker(room)
526
+ if holder is not None:
527
+ self._grant_floor(room, holder)
528
+ if holder is not None and holder != sender:
529
+ raise HubError(
530
+ ErrorCode.CONFLICT, f"not your turn (floor held by '{holder}')", 409
531
+ )
532
+
533
+ if client_msg_id:
534
+ dupe = self._db.execute(
535
+ "SELECT * FROM messages WHERE sender = ? AND client_msg_id = ?",
536
+ (sender, client_msg_id),
537
+ ).fetchone()
538
+ if dupe:
539
+ return self._row_to_message(dupe)
540
+
541
+ seq = self._db.execute(
542
+ "SELECT next_seq FROM rooms WHERE id = ?", (room_id,)
543
+ ).fetchone()["next_seq"]
544
+ self._db.execute(
545
+ """INSERT INTO messages (id, room_id, seq, sender, body, data_json, reply_to,
546
+ mentions_json, client_msg_id, created_at)
547
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
548
+ (
549
+ mid,
550
+ room_id,
551
+ seq,
552
+ sender,
553
+ body,
554
+ json.dumps(data) if data is not None else None,
555
+ reply_to,
556
+ json.dumps(mentions),
557
+ client_msg_id,
558
+ ts,
559
+ ),
560
+ )
561
+ # max_messages bounds AGENT chatter; the operator steering a room is the
562
+ # safety valve and must not burn its budget by speaking.
563
+ counted = 0 if sender.startswith(("claude:", "operator:")) else 1
564
+ self._db.execute(
565
+ """UPDATE rooms SET next_seq = next_seq + 1,
566
+ message_count = message_count + ?,
567
+ total_tokens = total_tokens + ?, last_activity = ?
568
+ WHERE id = ?""",
569
+ (counted, cost, ts, room_id),
570
+ )
571
+ self._db.commit()
572
+ row = self._db.execute("SELECT * FROM messages WHERE id = ?", (mid,)).fetchone()
573
+ message = self._row_to_message(row)
574
+ if round_robin:
575
+ self._advance_floor(room, sender)
576
+
577
+ self.bus.publish_many(
578
+ [m for m in room.members if m != sender],
579
+ EventKind.MESSAGE,
580
+ {"message": message.to_dict(), "room_topic": room.topic},
581
+ )
582
+ self._enforce_room_policy(room_id, body, data, is_claude)
583
+ return message
584
+
585
+ def _enforce_room_policy(
586
+ self, room_id: str, body: str, data: dict | None = None, from_operator: bool = False
587
+ ) -> None:
588
+ """Bound every room by construction: stop phrase, message cap, token budget."""
589
+ room = self.get_room(room_id)
590
+ if room is None or room.archived:
591
+ return
592
+ policy = room.policy
593
+ with self._lock:
594
+ row = self._db.execute(
595
+ "SELECT message_count, total_tokens FROM rooms WHERE id = ?", (room_id,)
596
+ ).fetchone()
597
+ # The stop phrase is how *agents* signal they have converged. An operator who
598
+ # writes it — in the seed goal ("say X when you agree") or mid-conversation — is
599
+ # setting the rule, not ending the room; archive_room is the deliberate exit.
600
+ if policy.stop_phrase and policy.stop_phrase in body and not from_operator:
601
+ # Floor: small models emit the terminator on turn ONE, archiving the room
602
+ # before anyone else has spoken and locking the operator out. Honor the
603
+ # phrase only once every worker participant has taken a turn.
604
+ worker_members = [m for m in room.members if not m.startswith(("claude:", "operator:"))]
605
+ with self._lock:
606
+ spoken = {
607
+ r["sender"]
608
+ for r in self._db.execute(
609
+ "SELECT DISTINCT sender FROM messages WHERE room_id = ?",
610
+ (room_id,),
611
+ ).fetchall()
612
+ }
613
+ if all(m in spoken for m in worker_members):
614
+ self.archive_room(room_id, "stop_phrase")
615
+ return
616
+ # Floor not met: the phrase does NOT end the room yet — but we must still
617
+ # fall through to the message/token caps below, or a worker looping on the
618
+ # stop phrase (with another member silent) would grow the room unbounded.
619
+ # A worker restating itself verbatim is a dead debate — archive before it
620
+ # burns the whole budget on repeats (the reader wades through identical turns).
621
+ with self._lock:
622
+ recent = self._db.execute(
623
+ "SELECT sender, body FROM messages WHERE room_id = ? ORDER BY seq DESC LIMIT 3",
624
+ (room_id,),
625
+ ).fetchall()
626
+ if (
627
+ len(recent) == 3
628
+ and recent[0]["sender"] == recent[2]["sender"]
629
+ and recent[0]["body"] == recent[2]["body"]
630
+ and len(recent[0]["body"]) > 80
631
+ ):
632
+ self.archive_room(room_id, "degenerate: duplicate turns")
633
+ elif policy.max_messages is not None and row["message_count"] >= policy.max_messages:
634
+ self.archive_room(room_id, "max_messages")
635
+ elif policy.max_total_tokens is not None and row["total_tokens"] >= policy.max_total_tokens:
636
+ self.archive_room(room_id, "max_total_tokens")
637
+
638
+ def fetch_messages(
639
+ self, room_id: str, after_seq: int = 0, limit: int = 100, tail: bool = False
640
+ ) -> list[Message]:
641
+ """Messages in seq order.
642
+
643
+ `tail=True` returns the most recent `limit` instead of the oldest — what an agent
644
+ needs when building its context window, since a long room would otherwise leave it
645
+ replying to the start of a conversation that has moved on.
646
+ """
647
+ with self._lock:
648
+ if tail:
649
+ rows = self._db.execute(
650
+ """SELECT * FROM messages WHERE room_id = ? AND seq > ?
651
+ ORDER BY seq DESC LIMIT ?""",
652
+ (room_id, after_seq, limit),
653
+ ).fetchall()
654
+ rows = list(reversed(rows))
655
+ else:
656
+ rows = self._db.execute(
657
+ """SELECT * FROM messages WHERE room_id = ? AND seq > ?
658
+ ORDER BY seq LIMIT ?""",
659
+ (room_id, after_seq, limit),
660
+ ).fetchall()
661
+ return [self._row_to_message(r) for r in rows]
662
+
663
+ def fetch_inbox(
664
+ self, agent: str, after_cursor: int = 0, limit: int = 100, room_id: str | None = None
665
+ ) -> tuple[list[dict], int]:
666
+ """Cross-room cursor fetch for one identity. Cursor is the global message rowid."""
667
+ sql = """SELECT m.*, m.rowid AS cursor FROM messages m
668
+ JOIN room_members rm ON rm.room_id = m.room_id AND rm.agent = ?
669
+ WHERE m.rowid > ? AND m.sender != ?"""
670
+ args: list = [agent, after_cursor, agent]
671
+ if room_id:
672
+ sql += " AND m.room_id = ?"
673
+ args.append(room_id)
674
+ sql += " ORDER BY m.rowid LIMIT ?"
675
+ args.append(limit)
676
+ with self._lock:
677
+ rows = self._db.execute(sql, args).fetchall()
678
+ top = self._db.execute("SELECT COALESCE(MAX(rowid), 0) AS c FROM messages").fetchone()
679
+ out = []
680
+ for row in rows:
681
+ item = self._row_to_message(row).to_dict()
682
+ item["cursor"] = row["cursor"]
683
+ out.append(item)
684
+ if out:
685
+ cursor = out[-1]["cursor"]
686
+ elif room_id is not None:
687
+ # A filtered read saw nothing; jumping to the global max here would skip
688
+ # unread messages sitting in the caller's other rooms.
689
+ cursor = after_cursor
690
+ else:
691
+ cursor = max(after_cursor, top["c"])
692
+ return out, cursor
693
+
694
+ def _row_to_message(self, row: sqlite3.Row) -> Message:
695
+ return Message(
696
+ id=row["id"],
697
+ room_id=row["room_id"],
698
+ seq=row["seq"],
699
+ sender=row["sender"],
700
+ body=row["body"],
701
+ data=json.loads(row["data_json"]) if row["data_json"] else None,
702
+ reply_to=row["reply_to"],
703
+ mentions=json.loads(row["mentions_json"]),
704
+ created_at=row["created_at"],
705
+ )
706
+
707
+ # ----------------------------------------------------------------- tasks
708
+
709
+ def submit_task(
710
+ self,
711
+ title: str,
712
+ spec: str,
713
+ created_by: str,
714
+ assignee: str | None = None,
715
+ selector: str | None = None,
716
+ priority: int = 0,
717
+ timeout_s: float = DEFAULT_TASK_TIMEOUT_S,
718
+ dedupe_key: str | None = None,
719
+ project: str | None = None,
720
+ output_mode: str | None = None,
721
+ data: str | None = None,
722
+ ) -> Task:
723
+ if not assignee and not selector:
724
+ # "Whoever is idle": the natural routing intent — first online worker
725
+ # claims it. (Sessions were bounced and had to hand-build union selectors
726
+ # spanning the line.)
727
+ selector = "*"
728
+ if dedupe_key:
729
+ with self._lock:
730
+ row = self._db.execute(
731
+ "SELECT id FROM tasks WHERE dedupe_key = ?", (dedupe_key,)
732
+ ).fetchone()
733
+ if row:
734
+ existing = self.get_task(row["id"])
735
+ if existing is not None and existing.state in (
736
+ TaskState.FAILED,
737
+ TaskState.CANCELLED,
738
+ ):
739
+ # A dead task must not swallow a fresh attempt: release the key
740
+ # so the resubmit (possibly with a new spec/assignee) proceeds.
741
+ with self._lock:
742
+ self._db.execute(
743
+ "UPDATE tasks SET dedupe_key = NULL WHERE id = ?",
744
+ (existing.id,),
745
+ )
746
+ self._db.commit()
747
+ elif existing is not None:
748
+ return existing
749
+ tid = new_id("task")
750
+ ts = now()
751
+ try:
752
+ with self._lock:
753
+ self._db.execute(
754
+ """INSERT INTO tasks (id, title, spec, created_by, state, assignee, selector,
755
+ priority, timeout_s, dedupe_key, project,
756
+ output_mode, data, created_at)
757
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
758
+ (
759
+ tid,
760
+ title,
761
+ spec,
762
+ created_by,
763
+ str(TaskState.QUEUED),
764
+ assignee,
765
+ selector,
766
+ priority,
767
+ timeout_s,
768
+ dedupe_key,
769
+ project,
770
+ output_mode,
771
+ data,
772
+ ts,
773
+ ),
774
+ )
775
+ self._db.commit()
776
+ except sqlite3.IntegrityError:
777
+ # The dedupe_key check-then-insert is not atomic under concurrent submits;
778
+ # a UNIQUE collision means another caller won the race — return their task
779
+ # rather than 500 (Claude retries dispatches on the hot path).
780
+ if dedupe_key:
781
+ with self._lock:
782
+ row = self._db.execute(
783
+ "SELECT id FROM tasks WHERE dedupe_key = ?", (dedupe_key,)
784
+ ).fetchone()
785
+ if row and (winner := self.get_task(row["id"])) is not None:
786
+ return winner
787
+ raise
788
+ task = self.get_task(tid)
789
+ assert task is not None
790
+ self._log_task(tid, "submitted", {"by": created_by, "target": assignee or selector})
791
+ self._announce_task(task)
792
+ return task
793
+
794
+ def _candidates(self, task: Task) -> list[str]:
795
+ if task.assignee:
796
+ return [task.assignee]
797
+ if not task.selector:
798
+ return []
799
+ return [a.name for a in self.list_agents(kind=AgentKind.WORKER) if a.matches(task.selector)]
800
+
801
+ def _announce_task(self, task: Task) -> None:
802
+ self.bus.publish_many(
803
+ self._candidates(task), EventKind.TASK_ASSIGNED, {"task": task.to_dict()}
804
+ )
805
+
806
+ def claim_task(self, task_id: str, agent: str) -> Task:
807
+ """Atomic: first caller wins, everyone else gets 409."""
808
+ ts = now()
809
+ with self._lock:
810
+ cur = self._db.execute(
811
+ """UPDATE tasks SET state = ?, assignee = ?, claimed_at = ?, attempts = attempts + 1
812
+ WHERE id = ? AND state = ?""",
813
+ (str(TaskState.CLAIMED), agent, ts, task_id, str(TaskState.QUEUED)),
814
+ )
815
+ self._db.commit()
816
+ claimed = cur.rowcount == 1
817
+ task = self.get_task(task_id)
818
+ if task is None:
819
+ raise not_found(f"task '{task_id}'")
820
+ if not claimed:
821
+ raise HubError(
822
+ ErrorCode.CONFLICT, f"task already {task.state} (held by {task.assignee})", 409
823
+ )
824
+ self._log_task(task_id, "claimed", {"by": agent})
825
+ self._notify_task_watchers(task)
826
+ return task
827
+
828
+ def next_task_for(self, agent_name: str) -> Task | None:
829
+ """Highest-priority queued task this agent could claim."""
830
+ agent = self.get_agent(agent_name)
831
+ if agent is None:
832
+ return None
833
+ with self._lock:
834
+ rows = self._db.execute(
835
+ """SELECT * FROM tasks WHERE state = ? ORDER BY priority DESC, created_at""",
836
+ (str(TaskState.QUEUED),),
837
+ ).fetchall()
838
+ for row in rows:
839
+ task = self._row_to_task(row)
840
+ if task.assignee == agent_name:
841
+ return task
842
+ if task.selector and agent.matches(task.selector):
843
+ return task
844
+ return None
845
+
846
+ def update_progress(
847
+ self, task_id: str, agent: str, pct: float | None = None, message: str | None = None
848
+ ) -> Task:
849
+ """Record progress and promote claimed → working, atomically.
850
+
851
+ Checking the state and then writing in a second transaction let an in-flight
852
+ progress call resurrect a task that had just completed, leaving it permanently
853
+ non-terminal.
854
+ """
855
+ with self._lock:
856
+ self._require_holder(task_id, agent)
857
+ self._db.execute(
858
+ """UPDATE tasks
859
+ SET state = CASE WHEN state = ? THEN ? ELSE state END,
860
+ progress_pct = COALESCE(?, progress_pct),
861
+ progress_msg = COALESCE(?, progress_msg)
862
+ WHERE id = ? AND state IN (?, ?, ?)""",
863
+ (
864
+ str(TaskState.CLAIMED),
865
+ str(TaskState.WORKING),
866
+ pct,
867
+ message,
868
+ task_id,
869
+ str(TaskState.CLAIMED),
870
+ str(TaskState.WORKING),
871
+ str(TaskState.INPUT_REQUIRED),
872
+ ),
873
+ )
874
+ self._db.commit()
875
+ self._log_task(task_id, "progress", {"pct": pct, "message": message})
876
+ fresh = self.get_task(task_id)
877
+ assert fresh is not None
878
+ self._notify_task_watchers(fresh)
879
+ return fresh
880
+
881
+ def complete_task(self, task_id: str, agent: str, result: dict | None) -> Task:
882
+ self._require_holder(task_id, agent)
883
+ return self._finish(task_id, TaskState.COMPLETED, result=result, expect_holder=agent)
884
+
885
+ def fail_task(self, task_id: str, agent: str, error: str, result: dict | None = None) -> Task:
886
+ """`result` may carry partial output/usage — a failed run's tokens still cost
887
+ electricity, and the accounting should not have holes."""
888
+ self._require_holder(task_id, agent)
889
+ return self._finish(
890
+ task_id, TaskState.FAILED, result=result, error=error, expect_holder=agent
891
+ )
892
+
893
+ def cancel_task(
894
+ self, task_id: str, by: str, privileged: bool = False, reason: str | None = None
895
+ ) -> Task:
896
+ task = self.get_task(task_id)
897
+ if task is None:
898
+ raise not_found(f"task '{task_id}'")
899
+ if not privileged and by not in {task.created_by, task.assignee}:
900
+ raise HubError(
901
+ ErrorCode.FORBIDDEN, "only the requester or the assignee can cancel a task", 403
902
+ )
903
+ if task.state.terminal:
904
+ raise HubError(ErrorCode.CONFLICT, f"task already {task.state}", 409)
905
+ holder = task.assignee
906
+ # Cancel provenance is not a failure: it lives in result, error stays null so
907
+ # audit queries can tell a deliberate cancel from a genuine failure.
908
+ cancel_meta = {"cancelled_by": by, "cancel_reason": reason}
909
+ finished = self._finish(task_id, TaskState.CANCELLED, result=cancel_meta)
910
+ if holder:
911
+ self.bus.publish(holder, EventKind.TASK_CANCELLED, {"task_id": task_id, "by": by})
912
+ return finished
913
+
914
+ def reassign_task(self, task_id: str, assignee: str, by: str) -> Task:
915
+ """Requeue a live task onto a different worker, preserving id and history."""
916
+ task = self.require_task(task_id)
917
+ holder = task.assignee
918
+ with self._lock:
919
+ self._db.execute(
920
+ """UPDATE tasks SET assignee = ?, state = ?, claimed_at = NULL,
921
+ progress_pct = NULL, progress_msg = ?
922
+ WHERE id = ? AND state NOT IN (?, ?, ?)""",
923
+ (
924
+ assignee,
925
+ str(TaskState.QUEUED),
926
+ f"reassigned from {holder} by {by}",
927
+ task_id,
928
+ str(TaskState.COMPLETED),
929
+ str(TaskState.FAILED),
930
+ str(TaskState.CANCELLED),
931
+ ),
932
+ )
933
+ self._db.commit()
934
+ if holder and holder != assignee:
935
+ self.bus.publish(holder, EventKind.TASK_CANCELLED, {"task_id": task_id, "by": by})
936
+ self._log_task(task_id, "reassigned", {"from": holder, "to": assignee, "by": by})
937
+ fresh = self.require_task(task_id)
938
+ self._announce_task(fresh)
939
+ return fresh
940
+
941
+ def request_input(self, task_id: str, agent: str, question: str) -> Task:
942
+ """Agent needs a human/Claude answer: opens the task room and parks the task."""
943
+ self._require_holder(task_id, agent)
944
+ room = self.ensure_task_room(task_id)
945
+ self._set_state(task_id, TaskState.INPUT_REQUIRED)
946
+ with self._lock:
947
+ # Summaries carry progress, so the question rides along — a wait_task
948
+ # return alone tells the caller what the worker is asking.
949
+ self._db.execute(
950
+ "UPDATE tasks SET progress_msg = ?, input_required_at = ? WHERE id = ?",
951
+ (f"awaiting input: {question[:300]}", now(), task_id),
952
+ )
953
+ self._db.commit()
954
+ self.post_message(room.id, agent, question)
955
+ self._log_task(task_id, "input_required", {"question": question})
956
+ task = self.get_task(task_id)
957
+ assert task is not None
958
+ self._notify_task_watchers(task)
959
+ return task
960
+
961
+ def provide_input(self, task_id: str, by: str, message: str) -> Task:
962
+ task = self.get_task(task_id)
963
+ if task is None:
964
+ raise not_found(f"task '{task_id}'")
965
+ with self._lock:
966
+ # The parked question is stale the moment an answer lands.
967
+ self._db.execute(
968
+ "UPDATE tasks SET progress_msg = ? WHERE id = ? AND state = ?",
969
+ ("input received, resuming", task_id, str(TaskState.INPUT_REQUIRED)),
970
+ )
971
+ self._db.commit()
972
+ if task.state is not TaskState.INPUT_REQUIRED:
973
+ raise HubError(ErrorCode.CONFLICT, f"task is {task.state}, not input_required", 409)
974
+ room = self.ensure_task_room(task_id)
975
+ if by not in room.members:
976
+ self.join_room(room.id, by)
977
+ self.post_message(room.id, by, message)
978
+ self._set_state(task_id, TaskState.WORKING)
979
+ self._log_task(task_id, "input_provided", {"by": by})
980
+ fresh = self.get_task(task_id)
981
+ assert fresh is not None
982
+ if fresh.assignee:
983
+ self.bus.publish(
984
+ fresh.assignee,
985
+ EventKind.TASK_UPDATED,
986
+ {"task": fresh.to_dict(), "input": message, "by": by},
987
+ )
988
+ return fresh
989
+
990
+ def ensure_task_room(self, task_id: str) -> Room:
991
+ """A task grows a room the moment anyone needs to discuss it."""
992
+ task = self.get_task(task_id)
993
+ if task is None:
994
+ raise not_found(f"task '{task_id}'")
995
+ if task.room_id:
996
+ room = self.get_room(task.room_id)
997
+ if room is not None:
998
+ return room
999
+ members = [m for m in (task.created_by, task.assignee) if m]
1000
+ room_id = f"room_{task.id}"
1001
+ with self._lock:
1002
+ # Re-check under the lock: request_input and task_room can arrive together.
1003
+ existing = self._db.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
1004
+ if existing:
1005
+ room = self.get_room(room_id)
1006
+ if room is not None:
1007
+ return room
1008
+ try:
1009
+ room = self.create_room(
1010
+ topic=f"task: {task.title}",
1011
+ created_by=task.created_by,
1012
+ participants=members[1:],
1013
+ room_id=room_id,
1014
+ )
1015
+ except sqlite3.IntegrityError:
1016
+ # Another caller created it between the check and the insert.
1017
+ room = self.get_room(room_id)
1018
+ if room is None:
1019
+ raise
1020
+ return room
1021
+ with self._lock:
1022
+ self._db.execute("UPDATE tasks SET room_id = ? WHERE id = ?", (room.id, task_id))
1023
+ self._db.commit()
1024
+ return room
1025
+
1026
+ def get_task(self, task_id: str) -> Task | None:
1027
+ with self._lock:
1028
+ row = self._db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
1029
+ return self._row_to_task(row) if row else None
1030
+
1031
+ def require_task(self, task_id: str) -> Task:
1032
+ task = self.get_task(task_id)
1033
+ if task is None:
1034
+ begins = self.history_begins_at()
1035
+ if begins:
1036
+ import datetime as _dt
1037
+
1038
+ iso = _dt.datetime.fromtimestamp(begins, _dt.UTC).strftime("%Y-%m-%d %H:%M UTC")
1039
+ raise HubError(
1040
+ ErrorCode.NOT_FOUND,
1041
+ f"task '{task_id}' not found — this hub's task history begins "
1042
+ f"{iso}; older ids are gone. list_tasks shows what exists.",
1043
+ 404,
1044
+ )
1045
+ if task is None:
1046
+ raise not_found(f"task '{task_id}'")
1047
+ return task
1048
+
1049
+ def list_tasks(
1050
+ self,
1051
+ state: TaskState | None = None,
1052
+ assignee: str | None = None,
1053
+ created_by: str | None = None,
1054
+ project: str | None = None,
1055
+ limit: int = 100,
1056
+ ) -> list[Task]:
1057
+ sql = "SELECT * FROM tasks"
1058
+ clauses, args = [], []
1059
+ if project:
1060
+ clauses.append("project = ?")
1061
+ args.append(project)
1062
+ if state is not None:
1063
+ clauses.append("state = ?")
1064
+ args.append(str(state))
1065
+ if assignee:
1066
+ clauses.append("assignee = ?")
1067
+ args.append(assignee)
1068
+ if created_by:
1069
+ clauses.append("created_by = ?")
1070
+ args.append(created_by)
1071
+ if clauses:
1072
+ sql += " WHERE " + " AND ".join(clauses)
1073
+ sql += " ORDER BY created_at DESC LIMIT ?"
1074
+ args.append(limit)
1075
+ with self._lock:
1076
+ rows = self._db.execute(sql, args).fetchall()
1077
+ return [self._row_to_task(r) for r in rows]
1078
+
1079
+ def task_events(self, task_id: str, limit: int = 50) -> list[TaskEvent]:
1080
+ with self._lock:
1081
+ rows = self._db.execute(
1082
+ "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
1083
+ (task_id, limit),
1084
+ ).fetchall()
1085
+ events = [
1086
+ TaskEvent(
1087
+ id=r["id"],
1088
+ task_id=r["task_id"],
1089
+ kind=r["kind"],
1090
+ payload=json.loads(r["payload_json"]),
1091
+ created_at=r["created_at"],
1092
+ )
1093
+ for r in rows
1094
+ ]
1095
+ events.reverse()
1096
+ return events
1097
+
1098
+ def _require_holder(self, task_id: str, agent: str) -> Task:
1099
+ task = self.get_task(task_id)
1100
+ if task is None:
1101
+ raise not_found(f"task '{task_id}'")
1102
+ if task.assignee != agent:
1103
+ raise HubError(ErrorCode.FORBIDDEN, f"task is held by '{task.assignee}'", 403)
1104
+ if task.state.terminal:
1105
+ raise HubError(ErrorCode.CONFLICT, f"task already {task.state}", 409)
1106
+ return task
1107
+
1108
+ def _set_state(self, task_id: str, state: TaskState) -> None:
1109
+ with self._lock:
1110
+ self._db.execute("UPDATE tasks SET state = ? WHERE id = ?", (str(state), task_id))
1111
+ self._db.commit()
1112
+
1113
+ def _finish(
1114
+ self,
1115
+ task_id: str,
1116
+ state: TaskState,
1117
+ result: dict | None = None,
1118
+ error: str | None = None,
1119
+ expect_holder: str | None = None,
1120
+ ) -> Task:
1121
+ """Move a task to a terminal state, once.
1122
+
1123
+ The guard matters: the sweep and a finishing agent can race, and an unguarded
1124
+ write would let a timeout overwrite a result the agent had already delivered.
1125
+ First writer wins; the loser sees a conflict instead of silently erasing work.
1126
+ """
1127
+ with self._lock:
1128
+ cursor = self._db.execute(
1129
+ """UPDATE tasks SET state = ?, result_json = ?, error = ?, finished_at = ?,
1130
+ progress_pct = CASE WHEN ? = 'completed' THEN 100.0
1131
+ ELSE progress_pct END,
1132
+ progress_msg = ?
1133
+ WHERE id = ? AND state NOT IN (?, ?, ?)
1134
+ AND (? IS NULL OR assignee = ?)""",
1135
+ (
1136
+ str(state),
1137
+ json.dumps(result) if result is not None else None,
1138
+ error,
1139
+ now(),
1140
+ str(state),
1141
+ str(state),
1142
+ task_id,
1143
+ str(TaskState.COMPLETED),
1144
+ str(TaskState.FAILED),
1145
+ str(TaskState.CANCELLED),
1146
+ expect_holder,
1147
+ expect_holder,
1148
+ ),
1149
+ )
1150
+ self._db.commit()
1151
+ applied = cursor.rowcount == 1
1152
+ task = self.get_task(task_id)
1153
+ if task is None:
1154
+ raise not_found(f"task '{task_id}'")
1155
+ if not applied:
1156
+ raise HubError(ErrorCode.CONFLICT, f"task already {task.state}", 409)
1157
+ self._log_task(task_id, str(state), {"error": error} if error else {})
1158
+ self._notify_task_watchers(task)
1159
+ return task
1160
+
1161
+ def _log_task(self, task_id: str, kind: str, payload: dict) -> None:
1162
+ with self._lock:
1163
+ self._db.execute(
1164
+ "INSERT INTO task_events (id, task_id, kind, payload_json, created_at) "
1165
+ "VALUES (?, ?, ?, ?, ?)",
1166
+ (new_id("ev"), task_id, kind, json.dumps(payload), now()),
1167
+ )
1168
+ self._db.commit()
1169
+
1170
+ def _notify_task_watchers(self, task: Task) -> None:
1171
+ watchers = {task.created_by}
1172
+ if task.assignee:
1173
+ watchers.add(task.assignee)
1174
+ self.bus.publish_many(sorted(watchers), EventKind.TASK_UPDATED, {"task": task.to_dict()})
1175
+
1176
+ def _row_to_task(self, row: sqlite3.Row) -> Task:
1177
+ return Task(
1178
+ id=row["id"],
1179
+ title=row["title"],
1180
+ spec=row["spec"],
1181
+ created_by=row["created_by"],
1182
+ state=TaskState(row["state"]),
1183
+ assignee=row["assignee"],
1184
+ selector=row["selector"],
1185
+ priority=row["priority"],
1186
+ timeout_s=row["timeout_s"],
1187
+ dedupe_key=row["dedupe_key"],
1188
+ project=row["project"] if "project" in row.keys() else None,
1189
+ output_mode=row["output_mode"] if "output_mode" in row.keys() else None,
1190
+ data=row["data"] if "data" in row.keys() else None,
1191
+ input_required_at=(
1192
+ row["input_required_at"] if "input_required_at" in row.keys() else None
1193
+ ),
1194
+ room_id=row["room_id"],
1195
+ progress_pct=row["progress_pct"],
1196
+ progress_msg=row["progress_msg"],
1197
+ result=json.loads(row["result_json"]) if row["result_json"] else None,
1198
+ error=row["error"],
1199
+ attempts=row["attempts"],
1200
+ created_at=row["created_at"],
1201
+ claimed_at=row["claimed_at"],
1202
+ finished_at=row["finished_at"],
1203
+ )
1204
+
1205
+ # ----------------------------------------------------------------- stats
1206
+
1207
+ def history_begins_at(self) -> float | None:
1208
+ with self._lock:
1209
+ row = self._db.execute("SELECT MIN(created_at) AS t FROM tasks").fetchone()
1210
+ return row["t"]
1211
+
1212
+ def unretrieved_result_entries(self, limit: int = 3, project: str | None = None) -> list[dict]:
1213
+ """A few identifying rows for the uncollected counter, so a session can judge
1214
+ relevance (same project or not) without extra calls."""
1215
+ rows = self._unretrieved_rows(project)
1216
+ return [{"id": r.id, "title": r.title, "project": r.project} for r in rows[:limit]]
1217
+
1218
+ def _unretrieved_rows(self, project: str | None = None):
1219
+ """Completed tasks with files none of which anyone ever fetched.
1220
+
1221
+ Fetches the whole fetched-artifact-id set once (a single query) rather than a
1222
+ COUNT per task — whoami calls this at session start and must return fast.
1223
+ """
1224
+ with self._lock:
1225
+ fetched_ids = {
1226
+ r["id"]
1227
+ for r in self._db.execute(
1228
+ "SELECT id FROM artifacts WHERE fetched_at IS NOT NULL"
1229
+ ).fetchall()
1230
+ }
1231
+ out = []
1232
+ for t in self.list_tasks(state=TaskState.COMPLETED, project=project, limit=500):
1233
+ ids = [
1234
+ f["artifact_id"] for f in (t.result or {}).get("files") or [] if "artifact_id" in f
1235
+ ]
1236
+ if ids and not any(i in fetched_ids for i in ids):
1237
+ out.append(t)
1238
+ return out
1239
+
1240
+ def unretrieved_results(self, project: str | None = None) -> int:
1241
+ return len(self._unretrieved_rows(project))
1242
+
1243
+ def mark_collected(self, task) -> None:
1244
+ """Fetching a result counts as collecting it — the requester has seen the
1245
+ work; its files stay fetchable but stop reading as orphaned."""
1246
+ ids = [
1247
+ f["artifact_id"] for f in (task.result or {}).get("files") or [] if "artifact_id" in f
1248
+ ]
1249
+ if not ids:
1250
+ return
1251
+ with self._lock:
1252
+ self._db.execute(
1253
+ f"UPDATE artifacts SET fetched_at = COALESCE(fetched_at, ?) "
1254
+ f"WHERE id IN ({','.join('?' * len(ids))})",
1255
+ [now(), *ids],
1256
+ )
1257
+ self._db.commit()
1258
+
1259
+ def dismiss_results(self, task_ids: list[str] | None = None, dismiss_all: bool = False) -> int:
1260
+ """Mark uncollected results' artifacts fetched without pulling them."""
1261
+ targets = (
1262
+ self._unretrieved_rows()
1263
+ if dismiss_all
1264
+ else [self.require_task(t) for t in task_ids or []]
1265
+ )
1266
+ n = 0
1267
+ for t in targets:
1268
+ ids = [
1269
+ f["artifact_id"] for f in (t.result or {}).get("files") or [] if "artifact_id" in f
1270
+ ]
1271
+ if not ids:
1272
+ continue
1273
+ with self._lock:
1274
+ self._db.execute(
1275
+ f"UPDATE artifacts SET fetched_at = COALESCE(fetched_at, ?) "
1276
+ f"WHERE id IN ({','.join('?' * len(ids))})",
1277
+ [now(), *ids],
1278
+ )
1279
+ self._db.commit()
1280
+ n += 1
1281
+ return n
1282
+
1283
+ def task_counts(self, project: str | None = None) -> dict:
1284
+ """Aggregate counts by state and by assignee — a survey without task bodies."""
1285
+ where, args = "", []
1286
+ if project:
1287
+ where, args = " WHERE project = ?", [project]
1288
+ with self._lock:
1289
+ by_state = self._db.execute(
1290
+ f"SELECT state, COUNT(*) AS n FROM tasks{where} GROUP BY state", args
1291
+ ).fetchall()
1292
+ by_assignee = self._db.execute(
1293
+ f"SELECT assignee, COUNT(*) AS n FROM tasks{where} GROUP BY assignee",
1294
+ args,
1295
+ ).fetchall()
1296
+ return {
1297
+ "total": sum(r["n"] for r in by_state),
1298
+ "by_state": {r["state"]: r["n"] for r in by_state},
1299
+ "by_assignee": {r["assignee"] or "(none)": r["n"] for r in by_assignee},
1300
+ }
1301
+
1302
+ def worker_stats(self) -> list[dict]:
1303
+ """Per-worker throughput and success rate from the durable record."""
1304
+ with self._lock:
1305
+ rows = self._db.execute(
1306
+ """SELECT assignee AS name,
1307
+ COUNT(*) AS total,
1308
+ SUM(state = 'completed') AS completed,
1309
+ SUM(state IN ('failed', 'cancelled')) AS failed,
1310
+ COALESCE(SUM(CASE WHEN state = 'completed'
1311
+ THEN finished_at - claimed_at END), 0) AS work_s
1312
+ FROM tasks
1313
+ WHERE assignee IS NOT NULL AND claimed_at IS NOT NULL
1314
+ GROUP BY assignee""",
1315
+ ).fetchall()
1316
+ return [
1317
+ {
1318
+ "name": r["name"],
1319
+ "total": r["total"],
1320
+ "completed": r["completed"],
1321
+ "failed": r["failed"],
1322
+ "success_rate": round(r["completed"] / r["total"], 3) if r["total"] else None,
1323
+ "work_seconds": round(r["work_s"], 1),
1324
+ }
1325
+ for r in rows
1326
+ ]
1327
+
1328
+ def active_task_counts(self) -> dict[str, int]:
1329
+ """Live tasks per assignee, so the line can show busy/idle directly."""
1330
+ with self._lock:
1331
+ rows = self._db.execute(
1332
+ """SELECT assignee, COUNT(*) AS n FROM tasks
1333
+ WHERE assignee IS NOT NULL AND state IN (?, ?, ?)
1334
+ GROUP BY assignee""",
1335
+ (
1336
+ str(TaskState.CLAIMED),
1337
+ str(TaskState.WORKING),
1338
+ str(TaskState.INPUT_REQUIRED),
1339
+ ),
1340
+ ).fetchall()
1341
+ return {row["assignee"]: row["n"] for row in rows}
1342
+
1343
+ def lifetime_stats(self) -> dict:
1344
+ """What the kitchen has cooked for you, computed from the durable record.
1345
+
1346
+ Derived on demand from tasks/rooms rather than kept as a counter, so it can
1347
+ never drift from the truth and needs no migration.
1348
+ """
1349
+ done = str(TaskState.COMPLETED)
1350
+ with self._lock:
1351
+ tasks_row = self._db.execute(
1352
+ """SELECT COUNT(*) AS done,
1353
+ COALESCE(SUM(finished_at - claimed_at), 0) AS work_s
1354
+ FROM tasks WHERE state = ? AND claimed_at IS NOT NULL""",
1355
+ (done,),
1356
+ ).fetchone()
1357
+ # The failed/cancelled tail is real worker-time too — hiding it would let
1358
+ # the headline oversell. Report it beside the completed hours.
1359
+ tail_row = self._db.execute(
1360
+ """SELECT COUNT(*) AS n,
1361
+ COALESCE(SUM(finished_at - claimed_at), 0) AS work_s
1362
+ FROM tasks
1363
+ WHERE state IN (?, ?) AND claimed_at IS NOT NULL""",
1364
+ (str(TaskState.FAILED), str(TaskState.CANCELLED)),
1365
+ ).fetchone()
1366
+ submitted = self._db.execute("SELECT COUNT(*) AS n FROM tasks").fetchone()["n"]
1367
+ task_tokens = self._db.execute(
1368
+ """SELECT COALESCE(SUM(COALESCE(json_extract(result_json, '$.tokens'), 0)), 0)
1369
+ AS tokens
1370
+ FROM tasks WHERE state = ?""",
1371
+ (done,),
1372
+ ).fetchone()["tokens"]
1373
+ room_row = self._db.execute(
1374
+ "SELECT COALESCE(SUM(total_tokens), 0) AS tokens, "
1375
+ "COALESCE(SUM(message_count), 0) AS messages FROM rooms"
1376
+ ).fetchone()
1377
+ return {
1378
+ "tasks_submitted": submitted,
1379
+ "tasks_completed": tasks_row["done"],
1380
+ "tasks_failed_cancelled": tail_row["n"],
1381
+ "work_seconds": round(tasks_row["work_s"], 1),
1382
+ "work_seconds_failed": round(tail_row["work_s"], 1),
1383
+ # Task tokens are the work; room tokens are inter-worker debate chatter.
1384
+ # Kept separate so "kept local" can't quietly absorb the debates.
1385
+ "task_tokens": task_tokens,
1386
+ "room_tokens": room_row["tokens"],
1387
+ "local_tokens": task_tokens + room_row["tokens"],
1388
+ "messages": room_row["messages"],
1389
+ }
1390
+
1391
+ @staticmethod
1392
+ def format_stats(stats: dict) -> str:
1393
+ """One compact human line, e.g. for tool-response footers.
1394
+
1395
+ Deliberately NOT phrased as dollar savings: local tokens are not saved Claude
1396
+ tokens one-for-one (local models are weaker and need verification), so the line
1397
+ states what happened — work run locally — and shows the failed tail rather than
1398
+ a single rosy number a CFO could misread.
1399
+ """
1400
+
1401
+ def clock(sec: float) -> str:
1402
+ return f"{sec / 3600.0:.1f}h" if sec >= 3600 else f"{sec / 60.0:.0f}m"
1403
+
1404
+ line = (
1405
+ f"kitchen lifetime: {stats['tasks_completed']} tickets cooked · "
1406
+ f"~{stats['task_tokens']:,} tokens generated locally · "
1407
+ f"{clock(stats['work_seconds'])} of local compute"
1408
+ )
1409
+ tail = stats.get("tasks_failed_cancelled") or 0
1410
+ if tail:
1411
+ line += f" (+{tail} failed/cancelled, {clock(stats['work_seconds_failed'])} more)"
1412
+ return line
1413
+
1414
+ # -------------------------------------------------------------- artifacts
1415
+
1416
+ def _artifact_dir(self) -> Path | None:
1417
+ if self.path == ":memory:":
1418
+ return None
1419
+ return Path(self.path).parent / "artifacts"
1420
+
1421
+ def save_artifact(self, name: str, mime: str, content: bytes, created_by: str) -> dict:
1422
+ """Store a file a worker produced, so a requester on another machine can pull it."""
1423
+ if len(content) > MAX_ARTIFACT_BYTES:
1424
+ raise HubError(
1425
+ ErrorCode.INVALID,
1426
+ f"artifact exceeds {MAX_ARTIFACT_BYTES // (1024 * 1024)}MB cap",
1427
+ )
1428
+ artifact_id = new_id("art")
1429
+ digest = hashlib.sha256(content).hexdigest()
1430
+ directory = self._artifact_dir()
1431
+ if directory is None:
1432
+ self._artifact_blobs[artifact_id] = content
1433
+ else:
1434
+ directory.mkdir(parents=True, exist_ok=True)
1435
+ (directory / artifact_id).write_bytes(content)
1436
+ with self._lock:
1437
+ self._db.execute(
1438
+ "INSERT INTO artifacts (id, name, mime, bytes, sha256, created_by, created_at) "
1439
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
1440
+ (artifact_id, name, mime, len(content), digest, created_by, now()),
1441
+ )
1442
+ self._db.commit()
1443
+ return {
1444
+ "id": artifact_id,
1445
+ "name": name,
1446
+ "mime": mime,
1447
+ "bytes": len(content),
1448
+ "sha256": digest,
1449
+ }
1450
+
1451
+ def get_artifact(self, artifact_id: str, collect: bool = True) -> tuple[dict, bytes]:
1452
+ """collect=False reads the bytes WITHOUT marking the result collected — for
1453
+ previews/inspection, which must not zero out the "uncollected work" nudge."""
1454
+ with self._lock:
1455
+ row = self._db.execute(
1456
+ "SELECT * FROM artifacts WHERE id = ?", (artifact_id,)
1457
+ ).fetchone()
1458
+ if row and collect:
1459
+ self._db.execute(
1460
+ "UPDATE artifacts SET fetched_at = COALESCE(fetched_at, ?) WHERE id = ?",
1461
+ (now(), artifact_id),
1462
+ )
1463
+ self._db.commit()
1464
+ if row is None:
1465
+ raise not_found(f"artifact '{artifact_id}'")
1466
+ directory = self._artifact_dir()
1467
+ if directory is None:
1468
+ content = self._artifact_blobs.get(artifact_id)
1469
+ else:
1470
+ path = directory / artifact_id
1471
+ content = path.read_bytes() if path.exists() else None
1472
+ if content is None:
1473
+ raise not_found(f"artifact '{artifact_id}' content")
1474
+ meta = {
1475
+ "id": row["id"],
1476
+ "name": row["name"],
1477
+ "mime": row["mime"],
1478
+ "bytes": row["bytes"],
1479
+ "sha256": row["sha256"],
1480
+ "created_by": row["created_by"],
1481
+ }
1482
+ return meta, content
1483
+
1484
+ # --------------------------------------------------------------- janitor
1485
+
1486
+ def sweep(self) -> dict[str, int]:
1487
+ """Reclaim tasks from lost agents, time out overruns, archive idle rooms."""
1488
+ ref = now()
1489
+ stats = {"reclaimed": 0, "timed_out": 0, "rooms_archived": 0, "floors_recovered": 0}
1490
+
1491
+ with self._lock:
1492
+ rows = self._db.execute(
1493
+ "SELECT * FROM tasks WHERE state IN (?, ?, ?, ?)",
1494
+ (
1495
+ str(TaskState.QUEUED),
1496
+ str(TaskState.CLAIMED),
1497
+ str(TaskState.WORKING),
1498
+ str(TaskState.INPUT_REQUIRED),
1499
+ ),
1500
+ ).fetchall()
1501
+ for row in rows:
1502
+ task = self._row_to_task(row)
1503
+ # A task nobody ever claimed still has a deadline — otherwise work dispatched
1504
+ # to an offline agent sits in `queued` forever and the requester is never told.
1505
+ started = task.claimed_at if task.claimed_at is not None else task.created_at
1506
+ if ref - started > task.timeout_s:
1507
+ if task.claimed_at is None:
1508
+ # Never claimed: the assignee was offline or no worker matched —
1509
+ # a bare 'timeout' read as a slow run and hid the real cause.
1510
+ cause = (
1511
+ f"assignee {task.assignee} never came online"
1512
+ if task.assignee
1513
+ else f"no online worker matched selector {task.selector!r}"
1514
+ )
1515
+ err = f"unclaimed for {int(ref - started)}s — {cause}"
1516
+ else:
1517
+ err = "timeout"
1518
+ with contextlib.suppress(HubError):
1519
+ self._finish(task.id, TaskState.FAILED, error=err)
1520
+ stats["timed_out"] += 1
1521
+ continue
1522
+ if task.state in (TaskState.QUEUED, TaskState.INPUT_REQUIRED):
1523
+ # Queued waits on an agent to appear; input_required waits on a human —
1524
+ # but not forever: a stranded question failed one task for 41 minutes
1525
+ # before anyone noticed. Fail with the question in the error so the
1526
+ # requester sees WHAT was asked, not just that time passed.
1527
+ asked_at = task.input_required_at or task.claimed_at or task.created_at
1528
+ if task.state is TaskState.INPUT_REQUIRED and ref - asked_at > INPUT_REQUIRED_TTL_S:
1529
+ question = (task.progress_msg or "").removeprefix("awaiting input: ")
1530
+ with contextlib.suppress(HubError):
1531
+ self._finish(
1532
+ task.id,
1533
+ TaskState.FAILED,
1534
+ error=(
1535
+ "no input arrived within "
1536
+ f"{int(INPUT_REQUIRED_TTL_S / 60)}m; the worker was "
1537
+ f"asking: {question[:300]}"
1538
+ ),
1539
+ )
1540
+ stats["timed_out"] += 1
1541
+ continue
1542
+ if task.state is TaskState.QUEUED:
1543
+ self._announce_task(task)
1544
+ continue
1545
+ holder = self.get_agent(task.assignee) if task.assignee else None
1546
+ if holder is None or ref - holder.last_seen > HEARTBEAT_TTL_S:
1547
+ if task.attempts >= MAX_TASK_ATTEMPTS:
1548
+ with contextlib.suppress(HubError):
1549
+ self._finish(task.id, TaskState.FAILED, error="agent_lost")
1550
+ else:
1551
+ with self._lock:
1552
+ cursor = self._db.execute(
1553
+ """UPDATE tasks SET state = ?, assignee = NULL, claimed_at = NULL
1554
+ WHERE id = ? AND state IN (?, ?)""",
1555
+ (
1556
+ str(TaskState.QUEUED),
1557
+ task.id,
1558
+ str(TaskState.CLAIMED),
1559
+ str(TaskState.WORKING),
1560
+ ),
1561
+ )
1562
+ self._db.commit()
1563
+ requeued_ok = cursor.rowcount == 1
1564
+ if not requeued_ok:
1565
+ continue # the agent finished after all; leave its result alone
1566
+ self._log_task(task.id, "requeued", {"reason": "agent_lost"})
1567
+ requeued = self.get_task(task.id)
1568
+ if requeued is not None:
1569
+ self._announce_task(requeued)
1570
+ stats["reclaimed"] += 1
1571
+
1572
+ with self._lock:
1573
+ room_rows = self._db.execute(
1574
+ "SELECT id, policy_json, last_activity, floor_holder FROM rooms WHERE archived = 0"
1575
+ ).fetchall()
1576
+ for row in room_rows:
1577
+ policy = RoomPolicy.from_dict(json.loads(row["policy_json"]))
1578
+ if (
1579
+ policy.idle_timeout_s is not None
1580
+ and ref - row["last_activity"] >= policy.idle_timeout_s
1581
+ ):
1582
+ self.archive_room(row["id"], "idle_timeout")
1583
+ stats["rooms_archived"] += 1
1584
+ continue
1585
+ if policy.turn_policy is TurnPolicy.ROUND_ROBIN:
1586
+ stats["floors_recovered"] += self._recover_floor(
1587
+ row["id"], row["floor_holder"], ref, row["last_activity"]
1588
+ )
1589
+ return stats
1590
+
1591
+ def _recover_floor(
1592
+ self, room_id: str, holder: str | None, ref: float, last_activity: float = 0.0
1593
+ ) -> int:
1594
+ """Hand the floor on — or nudge it — when a dialogue has gone quiet.
1595
+
1596
+ Three stall shapes: no holder was ever seeded; the holder went offline; or the
1597
+ holder is online but its FLOOR_GRANTED event was lost (e.g. delivered before the
1598
+ seed message existed). The last one gets a re-grant, which the harness treats
1599
+ idempotently.
1600
+ """
1601
+ room = self.get_room(room_id)
1602
+ if room is None or room.archived:
1603
+ return 0
1604
+ if holder is None:
1605
+ candidate = self._first_worker(room)
1606
+ if candidate is None:
1607
+ return 0
1608
+ self._grant_floor(room, candidate)
1609
+ return 1
1610
+ agent = self.get_agent(holder)
1611
+ if agent is None or ref - agent.last_seen > HEARTBEAT_TTL_S:
1612
+ self._advance_floor(room, holder)
1613
+ return 1
1614
+ if last_activity and ref - last_activity > FLOOR_NUDGE_S:
1615
+ self._grant_floor(room, holder) # online holder, silent room: nudge again
1616
+ return 1
1617
+ return 0
1618
+
1619
+
1620
+ def _parse_mentions(body: str, members: list[str]) -> list[str]:
1621
+ return [m for m in members if f"@{m}" in body]