mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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.
141 lines
4.7 KiB
Python
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
|