diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index 60bcbbe7..04dddf90 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -49,6 +49,10 @@ Communication doctrine: - The user outranks you everywhere; steering attributed [User] wins over yours. - Journal decisions as you make them (journal_append, kind=decision) — the next lead reads the journal, not your transcript. -- Use sleep_for to set your own check-in cadence when the team is quiet; your timer - wakes arrive with a board digest so a nothing's-wrong wake costs one glance. +- NEVER end a turn with work in flight and no check-in timer set. After assigning — + and at the end of every wake while items are active — call sleep_for: start at 3–5 + minutes; when a wake finds nothing changed, double the interval (cap ~20 minutes); + tighten back when things get hot. Your timer wakes arrive with a board digest, so + a nothing's-wrong wake costs one glance. (The harness has a backstop if you + forget, but relying on it means slower reactions — own your cadence.) - Report to the user plainly: what moved, what's blocked, what needs their decision. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 4060b356..00f2b6ed 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -213,6 +213,9 @@ class SessionManager: self.chat_store = ChatStore(base / "chat.db") self.teams = TeamRegistry(base / "teams.json") self._team_inflight: set[str] = set() + # Lead-session last-turn timestamps for the check-in backstop (monotonic-ish + # wall clock; restart resets the clock rather than firing a wake storm). + self._team_last_alive: dict[str, float] = {} self._loop: Optional[asyncio.AbstractEventLoop] = None # Personas: registry + lifecycle state under this manager's data dir. Installed as the # process singleton so agents.get_agent resolves persona ids (incl. third-party) here. @@ -1846,8 +1849,60 @@ class SessionManager: actor=team.lead_actor, is_lead=True, ) + delivered += await self._maybe_backstop_lead(team) return delivered + # The lead owns its cadence (sleep_for, stretch-when-quiet); this backstop only + # exists because prompts aren't guarantees. A forgotten timer must never orphan + # a running team — and it de-facto covers a worker dying without a transition + # (its item goes stale; the backstop wake surfaces it in the digest). + TEAM_LEAD_BACKSTOP_SECS = 600 + + def _lead_backstop_due(self, team) -> bool: + sid = team.lead_session + if self.is_running(sid) or sid in self._team_inflight: + return False + if self.wakes.pending(sid): + return False # a timer is set — the lead is on cadence, not forgotten + # Restart-safe: the first observation starts the clock instead of waking. + last = self._team_last_alive.setdefault(sid, time.time()) + if time.time() - last < self.TEAM_LEAD_BACKSTOP_SECS: + return False + try: + items = self.team_store.list_items(team.space, self._user_actor()) + except Exception: + return False + return any( + i["state"] in ("in_progress", "blocked", "review") for i in items + ) + + async def _maybe_backstop_lead(self, team) -> int: + if not self._lead_backstop_due(team): + return 0 + if not self.teams.count_wake(team.team_id, cap=self.TEAM_WAKE_CAP_PER_HOUR): + return 0 + sid = team.lead_session + self._team_last_alive[sid] = time.time() + message = ( + "⏰ Backstop check — work is in flight but you had no check-in timer" + " set.\n\n" + + (self.team_staleness_digest(sid) or "Board state unavailable.") + + "\n\nGlance, act only if something needs you, and set your next" + " check-in with sleep_for (start 3–5 minutes; stretch when quiet)." + ) + self._team_inflight.add(sid) + + async def _deliver() -> None: + try: + await self.deliver_to_session( + sid, message, source=self._board_source(team, message) + ) + finally: + self._team_inflight.discard(sid) + + asyncio.create_task(_deliver()) + return 1 + async def _drain_team_member( self, team, *, session_id: str, actor: str, is_lead: bool ) -> int: @@ -3724,6 +3779,8 @@ class SessionManager: # Team sessions: a finished turn is the moment new board events exist (an # assign, a review transition) — kick the queue drain now instead of waiting # for the next scheduler tick. Cheap no-op for teamless sessions. + if self.teams.for_lead_session(session_id): + self._team_last_alive[session_id] = time.time() if self._loop is not None and ( self.teams.for_lead_session(session_id) or self.teams.for_worker_session(session_id) @@ -4633,6 +4690,9 @@ class SessionManager: # sleeping (a self-wake is pending) / idle — a count-less dot that never bubbles. "attention": len(self.inbox.pending(session_id=r.session_id)), "liveness": self._session_liveness(r.session_id), + # When sleeping: the next timer fire (ISO) — drives the "sleeping + # until…" strip so a scheduled agent never reads as a dead one. + "sleeping_until": self._sleeping_until(r.session_id), # Channels this session listens to (inbound subscriptions) — drives the per-session # "connections" indicator. "subscriptions": [ @@ -4686,6 +4746,14 @@ class SessionManager: row["status"] = active["state"] if active else "idle" return row + def _sleeping_until(self, session_id: str) -> Optional[str]: + fires = [ + w.fire_at + for w in self.wakes.pending(session_id) + if w.kind == "timer" and w.fire_at + ] + return min(fires) if fires else None + def _session_liveness(self, session_id: str) -> str: if self.is_running(session_id): return "working" diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index fbe67a3d..1811ca31 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -900,6 +900,9 @@ export async function mockApi(page: import("@playwright/test").Page) { chat_enabled: !!msg.enable_chat, chat_unread: msg.enable_chat ? 1 : 0, }; + // The lead sets its check-in timer after staffing — it shows as sleeping. + lead.liveness = "sleeping"; + lead.sleeping_until = new Date(Date.now() + 4 * 60_000).toISOString(); for (const [actor, persona, status, item] of [ ["nia", "swe-worker", "in_progress", "#1 in progress"], ["webb", "design-worker", "idle", "idle"], diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index f46cf156..b3d311d0 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -86,6 +86,20 @@ test("enabling chat at the gate adds the # team chat row; posting works with men await expect(page.getByTestId("teamchat-view")).toHaveCount(0); }); +test("a sleeping lead shows the strip; Ask for a status wakes it", async ({ page }) => { + await proposeTeam(page); + await page.getByTestId("teamreq-approve").click(); + await expect(page.getByText(/Team created/)).toBeVisible(); + // open the lead's session — it set a check-in timer, so it's sleeping + await page.getByText("Build the statements page").click(); + const strip = page.getByTestId("sleep-strip"); + await expect(strip).toBeVisible({ timeout: 12_000 }); + await expect(strip).toContainText("Sleeping until"); + await expect(strip).toContainText("while the team works"); + await page.getByTestId("sleep-status-btn").click(); + await expect(page.getByText(/Echo: Quick status check/)).toBeVisible(); +}); + test("with chat declined at the gate, no chat row renders", async ({ page }) => { await proposeTeam(page); await page.getByTestId("teamreq-approve").click(); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index a9a31608..f7df71d0 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1918,6 +1918,34 @@ export function App() { }} /> )} + {/* A scheduled agent must never read as a dead one: while a self-wake is + pending and no turn is running, say so and offer the obvious action. */} + {activeInfo?.liveness === "sleeping" && !running && ( +