mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 23:00:16 +00:00
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:
@@ -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 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.
|
- Report to the user plainly: what moved, what's blocked, what needs their decision.
|
||||||
|
|||||||
@@ -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 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(
|
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"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user