Lead cadence: mandatory check-in timer, harness backstop, sleeping strip

The lead must end active turns with a sleep_for (3-5m, stretch when quiet); a 10-minute backstop wakes a lead that forgot while work is in flight.
Sleeping sessions show a strip with the next wake time and an Ask-for-a-status action — a scheduled agent never reads as a dead one.
This commit is contained in:
Rohit C Prasad
2026-08-16 17:00:51 -07:00
committed by Rohit P
parent d0fdcb032e
commit 2e18d9d5c4
8 changed files with 175 additions and 2 deletions
@@ -49,6 +49,10 @@ Communication doctrine:
- The user outranks you everywhere; steering attributed [User] wins over yours. - The user outranks you everywhere; steering attributed [User] wins over yours.
- Journal decisions as you make them (journal_append, kind=decision) — the next lead - Journal decisions as you make them (journal_append, kind=decision) — the next lead
reads the journal, not your transcript. reads the journal, not your transcript.
- Use sleep_for to set your own check-in cadence when the team is quiet; your timer - NEVER end a turn with work in flight and no check-in timer set. After assigning —
wakes arrive with a board digest so a nothing's-wrong wake costs one glance. and at the end of every wake while items are active — call sleep_for: start at 35
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. - Report to the user plainly: what moved, what's blocked, what needs their decision.
+68
View File
@@ -213,6 +213,9 @@ class SessionManager:
self.chat_store = ChatStore(base / "chat.db") self.chat_store = ChatStore(base / "chat.db")
self.teams = TeamRegistry(base / "teams.json") self.teams = TeamRegistry(base / "teams.json")
self._team_inflight: set[str] = set() 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 self._loop: Optional[asyncio.AbstractEventLoop] = None
# Personas: registry + lifecycle state under this manager's data dir. Installed as the # 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. # process singleton so agents.get_agent resolves persona ids (incl. third-party) here.
@@ -1846,8 +1849,60 @@ class SessionManager:
actor=team.lead_actor, actor=team.lead_actor,
is_lead=True, is_lead=True,
) )
delivered += await self._maybe_backstop_lead(team)
return delivered 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 35 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( async def _drain_team_member(
self, team, *, session_id: str, actor: str, is_lead: bool self, team, *, session_id: str, actor: str, is_lead: bool
) -> int: ) -> int:
@@ -3724,6 +3779,8 @@ class SessionManager:
# Team sessions: a finished turn is the moment new board events exist (an # 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 # assign, a review transition) — kick the queue drain now instead of waiting
# for the next scheduler tick. Cheap no-op for teamless sessions. # 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 ( if self._loop is not None and (
self.teams.for_lead_session(session_id) self.teams.for_lead_session(session_id)
or self.teams.for_worker_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. # sleeping (a self-wake is pending) / idle — a count-less dot that never bubbles.
"attention": len(self.inbox.pending(session_id=r.session_id)), "attention": len(self.inbox.pending(session_id=r.session_id)),
"liveness": self._session_liveness(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 # Channels this session listens to (inbound subscriptions) — drives the per-session
# "connections" indicator. # "connections" indicator.
"subscriptions": [ "subscriptions": [
@@ -4686,6 +4746,14 @@ class SessionManager:
row["status"] = active["state"] if active else "idle" row["status"] = active["state"] if active else "idle"
return row 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: def _session_liveness(self, session_id: str) -> str:
if self.is_running(session_id): if self.is_running(session_id):
return "working" return "working"
+3
View File
@@ -900,6 +900,9 @@ export async function mockApi(page: import("@playwright/test").Page) {
chat_enabled: !!msg.enable_chat, chat_enabled: !!msg.enable_chat,
chat_unread: msg.enable_chat ? 1 : 0, 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 [ for (const [actor, persona, status, item] of [
["nia", "swe-worker", "in_progress", "#1 in progress"], ["nia", "swe-worker", "in_progress", "#1 in progress"],
["webb", "design-worker", "idle", "idle"], ["webb", "design-worker", "idle", "idle"],
+14
View File
@@ -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); 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 }) => { test("with chat declined at the gate, no chat row renders", async ({ page }) => {
await proposeTeam(page); await proposeTeam(page);
await page.getByTestId("teamreq-approve").click(); await page.getByTestId("teamreq-approve").click();
+28
View File
@@ -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 && (
<div className="sleep-strip" data-testid="sleep-strip">
<span className="sleep-dot" />
<span className="sleep-text">
Sleeping
{activeInfo.sleeping_until
? ` until ${new Date(activeInfo.sleeping_until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
: ""}
{activeInfo.team?.role === "lead"
? " while the team works — it also wakes on board activity."
: " — it wakes on its trigger."}{" "}
Talk to it anytime.
</span>
<button
className="btn sm"
data-testid="sleep-status-btn"
onClick={() =>
send(
"Quick status check, please — what's moving, what's blocked, and does anything need me?",
)
}
>
Ask for a status
</button>
</div>
)}
<Composer <Composer
mode={mode} mode={mode}
model={model} model={model}
+11
View File
@@ -1781,3 +1781,14 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.chat-view { position: absolute; inset: 0; z-index: 40; background: var(--paper); display: flex; flex-direction: column; } .chat-view { position: absolute; inset: 0; z-index: 40; background: var(--paper); display: flex; flex-direction: column; }
.chat-view-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); } .chat-view-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
.chat-view-body { flex: 1; display: flex; flex-direction: column; min-height: 0; max-width: 860px; width: 100%; margin: 0 auto; } .chat-view-body { flex: 1; display: flex; flex-direction: column; min-height: 0; max-width: 860px; width: 100%; margin: 0 auto; }
/* Sleeping strip — a scheduled agent never reads as a dead one. */
.sleep-strip {
max-width: 760px; margin: 0 auto 8px; display: flex; align-items: center; gap: 9px;
padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px;
background: var(--panel); font-size: 12.5px; color: var(--muted);
}
.sleep-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn-ink); opacity: 0.7; flex-shrink: 0; animation: sleep-pulse 2.4s ease-in-out infinite; }
@keyframes sleep-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.85; } }
.sleep-text { flex: 1; }
.sleep-strip .btn.sm { padding: 4px 10px; font-size: 12px; }
+2
View File
@@ -78,6 +78,8 @@ export interface SessionInfo {
attention?: number; attention?: number;
// working = in-flight turn; sleeping = a self-wake is pending; idle = neither. A count-less dot. // working = in-flight turn; sleeping = a self-wake is pending; idle = neither. A count-less dot.
liveness?: "working" | "sleeping" | "idle"; liveness?: "working" | "sleeping" | "idle";
// When sleeping: the next timer fire (ISO) — drives the "sleeping until…" strip.
sleeping_until?: string | null;
// Channels this session listens to (inbound subscriptions). // Channels this session listens to (inbound subscriptions).
subscriptions?: string[]; subscriptions?: string[];
// §31: set when the session was spawned by a platform mention rather than the user — // §31: set when the session was spawned by a platform mention rather than the user —
+43
View File
@@ -346,3 +346,46 @@ def test_cancel_notice_is_addressed_to_the_assignee(store):
assert len(pending) == 1 assert len(pending) == 1
assert pending[0]["kind"] == "item_transitioned" assert pending[0]["kind"] == "item_transitioned"
assert pending[0]["payload"]["to"] == "canceled" assert pending[0]["payload"]["to"] == "canceled"
def test_lead_backstop_fires_only_for_forgotten_timers(manager, monkeypatch):
import time as _time
from coworker.agents.base import Agent
from coworker.sessions import SessionRecord
from coworker.teams.model import space_for_workspace
worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker")
monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent)
manager.session_store.save(
SessionRecord(
session_id="lead-sid",
workspace=manager.default_workspace,
model="m",
mode="interactive",
messages=[],
agent="swe-lead",
)
)
manager.create_team("lead-sid", [{"persona": "swe-worker", "name": "nia"}])
team = manager.teams.for_lead_session("lead-sid")
space = space_for_workspace(manager.default_workspace)
lead = Actor(id=team.lead_actor, role=Role.LEAD)
# Only an OPEN item → no backstop (nothing is in flight).
item = manager.team_store.create_item(space, lead, title="T", criteria="c")
manager._team_last_alive["lead-sid"] = _time.time() - 700
assert manager._lead_backstop_due(team) is False
# Active item + stale clock + no timer → due.
manager.team_store.assign(space, lead, item["id"], "nia")
worker = Actor(id="nia", role=Role.WORKER)
manager.team_store.transition(space, worker, item["id"], "in_progress")
manager._team_last_alive["lead-sid"] = _time.time() - 700
assert manager._lead_backstop_due(team) is True
# A pending self-wake timer means the lead owns its cadence → never backstop.
manager.wakes.add_timer("lead-sid", __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
))
assert manager._lead_backstop_due(team) is False