Files
openworker/coworker/teams/registry.py
T
Rohit C Prasad cf436d1069 # team chat: own chat store, named workers, mention wakes, cancel interrupt (OPE-99)
ChatStore = groups + append-only messages + per-member cursors; agent posts wake mentions only, user posts wake everyone; post_chat(record_on_item) also lands the answer as an item comment.
Leads name workers (the callname is the handle everywhere); worker digests auto-carry the roster; gate checkbox is the user's call; canceling an assigned item now interrupts an in-flight worker.
2026-08-16 16:22:23 -07:00

141 lines
4.7 KiB
Python

"""Team registry — which sessions form a team: one lead, its workers, their board.
A team is created at the staffing gate ("Create team & start"): worker sessions are
PRE-SPAWNED as durable state on disk (spawn ≠ first turn — an unassigned worker costs
zero tokens; its first model turn fires when the first assignment lands). The registry
is the roster the wake plumbing walks each tick, and the tie that scopes staleness
digests by role membership.
"""
from __future__ import annotations
import json
import threading
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@dataclass
class TeamWorker:
actor: str # the lead-given NAME — board actor id, assignee handle, @mention target
persona: str
session_id: str
model: str = ""
reason: str = "" # why the lead staffed it — surfaces in teammates' rosters
@dataclass
class Team:
team_id: str
space: str
lead_session: str
lead_actor: str
workers: list[TeamWorker] = field(default_factory=list)
chat_enabled: bool = False
chat_group: str = "" # ChatStore group_id when chat is enabled
paused: bool = False # budget/user pause: the wake gate skips a paused team
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
# Rolling budget gate: automatic wakes this hour (reset when the hour rolls).
wake_hour: str = ""
wakes_this_hour: int = 0
class TeamRegistry:
def __init__(self, path: Optional[str | Path] = None) -> None:
self.path = Path(path) if path else None
self._lock = threading.Lock()
self._teams: dict[str, Team] = {}
if self.path and self.path.is_file():
for raw in json.loads(self.path.read_text(encoding="utf-8")).get(
"teams", []
):
workers = [TeamWorker(**w) for w in raw.pop("workers", [])]
team = Team(**{**raw, "workers": []})
team.workers = workers
self._teams[team.team_id] = team
def _save(self) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(
{"teams": [asdict(t) for t in self._teams.values()]}, indent=2
),
encoding="utf-8",
)
def create(
self,
*,
space: str,
lead_session: str,
lead_actor: str,
workers: list[TeamWorker],
chat_enabled: bool = False,
chat_group: str = "",
) -> Team:
team = Team(
team_id=uuid.uuid4().hex[:12],
space=space,
lead_session=lead_session,
lead_actor=lead_actor,
workers=workers,
chat_enabled=chat_enabled,
chat_group=chat_group,
)
with self._lock:
self._teams[team.team_id] = team
self._save()
return team
def all(self) -> list[Team]:
return list(self._teams.values())
def get(self, team_id: str) -> Optional[Team]:
return self._teams.get(team_id)
def for_lead_session(self, session_id: str) -> Optional[Team]:
for team in self._teams.values():
if team.lead_session == session_id:
return team
return None
def for_worker_session(self, session_id: str) -> Optional[tuple[Team, TeamWorker]]:
for team in self._teams.values():
for worker in team.workers:
if worker.session_id == session_id:
return team, worker
return None
def set_paused(self, team_id: str, paused: bool) -> None:
with self._lock:
team = self._teams.get(team_id)
if team is not None:
team.paused = paused
self._save()
def count_wake(self, team_id: str, *, cap: int) -> bool:
"""The budget gate at the wake gate: count one automatic wake against the
team's rolling hour; False = over cap (the caller skips the wake and the
team reads as paused-for-budget until the hour rolls). A runaway loop
stops BETWEEN turns, never mid-flight."""
hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
with self._lock:
team = self._teams.get(team_id)
if team is None:
return False
if team.wake_hour != hour:
team.wake_hour, team.wakes_this_hour = hour, 0
if team.wakes_this_hour >= cap:
self._save()
return False
team.wakes_this_hour += 1
self._save()
return True