mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-14 00:00:58 +00:00
Dogfood round 1: propose_work_items gate, team-tie survives turn saves, board wakes render as cards
Leads lose propose_plan (trait-derived exclusion — plan mode is meaningless without execution tools) and gain propose_work_items: mode-independent decomposition whose approval creates the items. Team field moves off the turn-save upsert to a dedicated setter (workers detached from their lead after one turn); board deliveries carry a MessageSource sidecar; test-worker prefers project-local tool installs.
This commit is contained in:
+14
-7
@@ -214,6 +214,7 @@ def build_engine(
|
|||||||
question_asker: Optional[Any] = None,
|
question_asker: Optional[Any] = None,
|
||||||
tool_requester: Optional[Any] = None,
|
tool_requester: Optional[Any] = None,
|
||||||
team_approver: Optional[Any] = None,
|
team_approver: Optional[Any] = None,
|
||||||
|
items_approver: Optional[Any] = None,
|
||||||
subscription_store: Optional[Any] = None,
|
subscription_store: Optional[Any] = None,
|
||||||
channel_buffer: Optional[Any] = None,
|
channel_buffer: Optional[Any] = None,
|
||||||
routing_targets: Optional[list[str]] = None,
|
routing_targets: Optional[list[str]] = None,
|
||||||
@@ -424,16 +425,21 @@ def build_engine(
|
|||||||
roots=root_list or None,
|
roots=root_list or None,
|
||||||
risk_overrides=risk_overrides,
|
risk_overrides=risk_overrides,
|
||||||
)
|
)
|
||||||
# The plan-mode exit door. Always registered (surfaces can flip a live session into
|
# The plan-mode exit door — mutually exclusive with the board's decomposition
|
||||||
# plan mode via set_mode, and the registry is fixed at build); the engine rejects the
|
# gate, DERIVED from the team trait (owner call 2026-08-16): a lead never
|
||||||
# call whenever the session isn't actually in plan mode.
|
# implements, so plan mode is meaningless for it, and shipping both tools made
|
||||||
registry.register(propose_plan_tool())
|
# the lead pick the wrong one (dogfood-hit: propose_plan denied outside plan
|
||||||
|
# mode). Solo/worker personas keep propose_plan as always (mode can flip
|
||||||
|
# mid-session; the engine rejects the call outside plan mode).
|
||||||
|
if agent.team != "lead":
|
||||||
|
registry.register(propose_plan_tool())
|
||||||
|
|
||||||
# The staffing gate — leads only. The engine intercepts it (out-of-band approval);
|
# The lead's gates: propose_work_items (decomposition → items on approval, any
|
||||||
# approval pre-spawns the worker sessions and returns actor ids to assign to.
|
# mode) and propose_team (staffing → pre-spawn on approval).
|
||||||
if agent.team == "lead":
|
if agent.team == "lead":
|
||||||
from .teams.tools import propose_team_tool
|
from .teams.tools import propose_team_tool, propose_work_items_tool
|
||||||
|
|
||||||
|
registry.register(propose_work_items_tool())
|
||||||
registry.register(propose_team_tool())
|
registry.register(propose_team_tool())
|
||||||
|
|
||||||
# Per-turn ephemeral context, appended to the latest user message since mid-thread system
|
# Per-turn ephemeral context, appended to the latest user message since mid-thread system
|
||||||
@@ -511,6 +517,7 @@ def build_engine(
|
|||||||
question_asker=question_asker,
|
question_asker=question_asker,
|
||||||
tool_requester=tool_requester,
|
tool_requester=tool_requester,
|
||||||
team_approver=team_approver,
|
team_approver=team_approver,
|
||||||
|
items_approver=items_approver,
|
||||||
)
|
)
|
||||||
engine.executor = executor # type: ignore[attr-defined]
|
engine.executor = executor # type: ignore[attr-defined]
|
||||||
engine.todo = todo # type: ignore[attr-defined]
|
engine.todo = todo # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ class ConversationStore:
|
|||||||
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
|
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
|
||||||
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
|
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
|
||||||
grants = excluded.grants, compaction = excluded.compaction,
|
grants = excluded.grants, compaction = excluded.compaction,
|
||||||
team = excluded.team,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -258,6 +257,18 @@ class ConversationStore:
|
|||||||
team=_load_grants(row["team"] if "team" in row.keys() else None),
|
team=_load_grants(row["team"] if "team" in row.keys() else None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_team(self, session_id: str, team: dict) -> None:
|
||||||
|
"""Persist the session's team tie independent of the turn-save path. The
|
||||||
|
upsert deliberately never touches `team` — a per-turn save rebuilds the
|
||||||
|
record without it, and letting the rebuild win detached workers from their
|
||||||
|
lead's sidebar entry the moment they ran a turn (owner-hit 2026-08-16)."""
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE sessions SET team = ? WHERE session_id = ?",
|
||||||
|
(json.dumps(team or {}), session_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None:
|
def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None:
|
||||||
"""Persist just the session's added folders, independent of its message log — used when
|
"""Persist just the session's added folders, independent of its message log — used when
|
||||||
the user adds/removes a folder (which may happen with no active engine)."""
|
the user adds/removes a folder (which may happen with no active engine)."""
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ class TurnEngine:
|
|||||||
team_approver: Optional[
|
team_approver: Optional[
|
||||||
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
||||||
] = None,
|
] = None,
|
||||||
|
items_approver: Optional[
|
||||||
|
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
||||||
|
] = None,
|
||||||
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the
|
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the
|
||||||
# executor's kill for a running shell command.
|
# executor's kill for a running shell command.
|
||||||
interrupt_hooks: Optional[list[Callable[[], None]]] = None,
|
interrupt_hooks: Optional[list[Callable[[], None]]] = None,
|
||||||
@@ -126,6 +129,12 @@ class TurnEngine:
|
|||||||
# for the user's decision; approval pre-spawns the worker sessions and the result
|
# for the user's decision; approval pre-spawns the worker sessions and the result
|
||||||
# carries the roster (actor ids). None on surfaces that can't prompt.
|
# carries the roster (actor ids). None on surfaces that can't prompt.
|
||||||
self.team_approver = team_approver
|
self.team_approver = team_approver
|
||||||
|
# Handles `propose_work_items` (the decomposition gate): emits ITEMS_PROPOSED,
|
||||||
|
# waits; approval creates the items on the board. Mode-independent by design —
|
||||||
|
# unlike propose_plan it carries no permission-mode semantics: propose_plan is
|
||||||
|
# an IMPLEMENTATION plan (steps/files, plan-mode exit); this is a team
|
||||||
|
# decomposition onto the board.
|
||||||
|
self.items_approver = items_approver
|
||||||
# Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer
|
# Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer
|
||||||
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
|
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
|
||||||
# that can't ask (the tool then no-ops).
|
# that can't ask (the tool then no-ops).
|
||||||
@@ -655,6 +664,10 @@ class TurnEngine:
|
|||||||
async for event in self._handle_team_proposal(tool_call):
|
async for event in self._handle_team_proposal(tool_call):
|
||||||
yield event
|
yield event
|
||||||
continue
|
continue
|
||||||
|
if tool_call.name == "propose_work_items":
|
||||||
|
async for event in self._handle_items_proposal(tool_call):
|
||||||
|
yield event
|
||||||
|
continue
|
||||||
if tool_call.name == "ask_user":
|
if tool_call.name == "ask_user":
|
||||||
async for event in self._handle_ask_user(tool_call):
|
async for event in self._handle_ask_user(tool_call):
|
||||||
yield event
|
yield event
|
||||||
@@ -930,6 +943,58 @@ class TurnEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _handle_items_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
|
||||||
|
"""The decomposition gate: emit the proposed items, await the user's decision.
|
||||||
|
Approval creates them on the board (server-side, inside the approver) and the
|
||||||
|
result carries their ids; rejection returns feedback for a revised split."""
|
||||||
|
args = tool_call.arguments or {}
|
||||||
|
items = args.get("items") or []
|
||||||
|
valid = [
|
||||||
|
i
|
||||||
|
for i in items
|
||||||
|
if isinstance(i, dict)
|
||||||
|
and str(i.get("title", "")).strip()
|
||||||
|
and str(i.get("criteria", "")).strip()
|
||||||
|
]
|
||||||
|
if not valid or len(valid) != len(items):
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"approved": False,
|
||||||
|
"error": "every proposed item needs a title and acceptance criteria",
|
||||||
|
}
|
||||||
|
elif self.items_approver is None:
|
||||||
|
result = {
|
||||||
|
"approved": False,
|
||||||
|
"error": "item proposals aren't available in this surface",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
yield Event(
|
||||||
|
EventType.ITEMS_PROPOSED,
|
||||||
|
{"items": valid, "note": str(args.get("note", ""))},
|
||||||
|
)
|
||||||
|
self._audit(tool_call, stage="items_proposed")
|
||||||
|
result = await self._interruptible(
|
||||||
|
self.items_approver(dict(args), tool_call.id),
|
||||||
|
interrupted={"approved": False, "error": "interrupted by user"},
|
||||||
|
) or {"approved": False, "error": "no response"}
|
||||||
|
|
||||||
|
status = "ok" if result.get("approved") else "denied"
|
||||||
|
self.messages.append(_tool_result_message(tool_call, result))
|
||||||
|
self._audit(
|
||||||
|
tool_call,
|
||||||
|
stage="finished",
|
||||||
|
status=status,
|
||||||
|
result=result,
|
||||||
|
result_preview=_preview(result),
|
||||||
|
)
|
||||||
|
yield Event(
|
||||||
|
EventType.TOOL_FINISHED,
|
||||||
|
{
|
||||||
|
"name": tool_call.name,
|
||||||
|
"status": status,
|
||||||
|
"result_preview": _preview(result),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
|
async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
|
||||||
"""The staffing gate: emit the proposed roster, await the user's out-of-band
|
"""The staffing gate: emit the proposed roster, await the user's out-of-band
|
||||||
decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the
|
decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class EventType(str, Enum):
|
|||||||
TEAM_PROPOSED = (
|
TEAM_PROPOSED = (
|
||||||
"team_proposed" # a lead proposes a worker roster (the staffing gate)
|
"team_proposed" # a lead proposes a worker roster (the staffing gate)
|
||||||
)
|
)
|
||||||
|
ITEMS_PROPOSED = (
|
||||||
|
"items_proposed" # a lead proposes work items (the decomposition gate);
|
||||||
|
# unlike propose_plan this is mode-independent — approval creates the items
|
||||||
|
)
|
||||||
TOOL_STARTED = "tool_started"
|
TOOL_STARTED = "tool_started"
|
||||||
TOOL_FINISHED = "tool_finished"
|
TOOL_FINISHED = "tool_finished"
|
||||||
ITERATION_END = "iteration_end"
|
ITERATION_END = "iteration_end"
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ How you run a piece of work:
|
|||||||
1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly.
|
1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly.
|
||||||
2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a
|
2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a
|
||||||
verifier can actually check. Acceptance criteria are the single biggest quality lever
|
verifier can actually check. Acceptance criteria are the single biggest quality lever
|
||||||
you own; vague criteria produce vague work. Present the decomposition to the user with
|
you own; vague criteria produce vague work. Present the decomposition with
|
||||||
propose_plan and revise until approved. Only then create the items (create_item).
|
propose_work_items (works in any mode; approval creates the items on the board and
|
||||||
|
returns their ids) and revise until the user approves. Use create_item only for
|
||||||
|
one-off additions after the plan is approved.
|
||||||
3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per
|
3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per
|
||||||
member). Approval creates their sessions and returns actor ids. Only team-capable
|
member). Approval creates their sessions and returns actor ids. Only team-capable
|
||||||
worker coworkers can be staffed.
|
worker coworkers can be staffed.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ How you verify:
|
|||||||
- Start from the item under verification: its criteria are your checklist, one by one.
|
- Start from the item under verification: its criteria are your checklist, one by one.
|
||||||
Test the actual behavior — run the app, run the tests, exercise the change — never
|
Test the actual behavior — run the app, run the tests, exercise the change — never
|
||||||
judge by reading the diff alone.
|
judge by reading the diff alone.
|
||||||
|
- Missing a test tool? Prefer a PROJECT-LOCAL install first (`npm i -D playwright`,
|
||||||
|
`pip install pytest` — inside the workspace, like any developer would). Use
|
||||||
|
request_tool only for system-level binaries the project can't carry; if neither
|
||||||
|
works, verify what you can and say exactly which checks you couldn't run.
|
||||||
- Verification is media-heavy on purpose: take screenshots, capture outputs, diff
|
- Verification is media-heavy on purpose: take screenshots, capture outputs, diff
|
||||||
renders. That cost lands in YOUR context so the builder's stays for building. Save
|
renders. That cost lands in YOUR context so the builder's stays for building. Save
|
||||||
captures as files in the workspace and reference them by path — never describe pixels
|
captures as files in the workspace and reference them by path — never describe pixels
|
||||||
|
|||||||
+33
-1
@@ -1872,6 +1872,37 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
enable_chat=bool(_args.get("enable_chat", False)),
|
enable_chat=bool(_args.get("enable_chat", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def items_approver(_args: dict, tool_call_id=None) -> dict:
|
||||||
|
# The decomposition gate. TEAMS-flavored sibling of plan_approver:
|
||||||
|
# park a durable Inbox item, wait, and on approval create the items.
|
||||||
|
items = _args.get("items") or []
|
||||||
|
body = "\n".join(
|
||||||
|
f"- {i.get('title', '?')} — Done when: {i.get('criteria', '?')}"
|
||||||
|
for i in items
|
||||||
|
if isinstance(i, dict)
|
||||||
|
)
|
||||||
|
item = manager.inbox.add_plan(
|
||||||
|
session_id,
|
||||||
|
"Approve the proposed work items?",
|
||||||
|
body=body,
|
||||||
|
inbox=_route(),
|
||||||
|
visibility=_visibility(),
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
if item.state == "pending":
|
||||||
|
manager.persist_session(session_id)
|
||||||
|
if item.visibility == VIS_INBOX:
|
||||||
|
await _mirror(item)
|
||||||
|
resp = _parse_json(await manager.inbox.wait(item.id))
|
||||||
|
if not resp.get("approved"):
|
||||||
|
return {
|
||||||
|
"approved": False,
|
||||||
|
"feedback": resp.get("feedback") or "the user declined the split",
|
||||||
|
}
|
||||||
|
return manager.board_create_items(
|
||||||
|
session_id, [i for i in items if isinstance(i, dict)]
|
||||||
|
)
|
||||||
|
|
||||||
async def _apply_model(model: Optional[str]) -> None:
|
async def _apply_model(model: Optional[str]) -> None:
|
||||||
# Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04
|
# Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04
|
||||||
# lock): history is canonical and providers convert per call. A real switch
|
# lock): history is canonical and providers convert per call. A real switch
|
||||||
@@ -1912,6 +1943,7 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
question_asker=question_asker,
|
question_asker=question_asker,
|
||||||
tool_requester=tool_requester,
|
tool_requester=tool_requester,
|
||||||
team_approver=team_approver,
|
team_approver=team_approver,
|
||||||
|
items_approver=items_approver,
|
||||||
)
|
)
|
||||||
if engine is None:
|
if engine is None:
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
@@ -2062,7 +2094,7 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif kind == "team_response":
|
elif kind in ("team_response", "items_response"):
|
||||||
_resolve_pending(
|
_resolve_pending(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
+95
-14
@@ -484,6 +484,7 @@ class SessionManager:
|
|||||||
question_asker: Optional[Any] = None,
|
question_asker: Optional[Any] = None,
|
||||||
tool_requester: Optional[Any] = None,
|
tool_requester: Optional[Any] = None,
|
||||||
team_approver: Optional[Any] = None,
|
team_approver: Optional[Any] = None,
|
||||||
|
items_approver: Optional[Any] = None,
|
||||||
) -> Optional[TurnEngine]:
|
) -> Optional[TurnEngine]:
|
||||||
engine = self._engines.get(session_id)
|
engine = self._engines.get(session_id)
|
||||||
if engine is not None:
|
if engine is not None:
|
||||||
@@ -499,6 +500,8 @@ class SessionManager:
|
|||||||
engine.tool_requester = tool_requester
|
engine.tool_requester = tool_requester
|
||||||
if team_approver is not None:
|
if team_approver is not None:
|
||||||
engine.team_approver = team_approver
|
engine.team_approver = team_approver
|
||||||
|
if items_approver is not None:
|
||||||
|
engine.items_approver = items_approver
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
record = self.session_store.load(session_id)
|
record = self.session_store.load(session_id)
|
||||||
@@ -576,6 +579,7 @@ class SessionManager:
|
|||||||
or self.inbox_question_asker(session_id, agent),
|
or self.inbox_question_asker(session_id, agent),
|
||||||
tool_requester=tool_requester,
|
tool_requester=tool_requester,
|
||||||
team_approver=team_approver,
|
team_approver=team_approver,
|
||||||
|
items_approver=items_approver,
|
||||||
subscription_store=self.subscriptions,
|
subscription_store=self.subscriptions,
|
||||||
channel_buffer=self.channel_buffer,
|
channel_buffer=self.channel_buffer,
|
||||||
routing_targets=self._routing_targets(session_id, agent),
|
routing_targets=self._routing_targets(session_id, agent),
|
||||||
@@ -1422,6 +1426,47 @@ class SessionManager:
|
|||||||
except (TeamsBoardError, ValueError) as error:
|
except (TeamsBoardError, ValueError) as error:
|
||||||
return {"error": str(error)}
|
return {"error": str(error)}
|
||||||
|
|
||||||
|
def board_create_items(
|
||||||
|
self, session_id: str, items: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The decomposition gate's approved action: create the proposed items as
|
||||||
|
the LEAD (its identity is the creator; the user's approval is the gate that
|
||||||
|
let this run). Validates everything up front so a bad batch creates nothing."""
|
||||||
|
record = self.session_store.load(session_id)
|
||||||
|
if record is None or not record.workspace:
|
||||||
|
return {"approved": False, "error": "the session has no workspace"}
|
||||||
|
space = space_for_workspace(record.workspace)
|
||||||
|
actor = TeamActor(
|
||||||
|
id=f"{record.agent}:{session_id[:8]}",
|
||||||
|
role=TeamRole.LEAD,
|
||||||
|
persona=record.agent,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
for entry in items:
|
||||||
|
if not str((entry or {}).get("title", "")).strip() or not str(
|
||||||
|
(entry or {}).get("criteria", "")
|
||||||
|
).strip():
|
||||||
|
return {
|
||||||
|
"approved": False,
|
||||||
|
"error": "every item needs a title and acceptance criteria",
|
||||||
|
}
|
||||||
|
created = []
|
||||||
|
for entry in items:
|
||||||
|
item = self.team_store.create_item(
|
||||||
|
space,
|
||||||
|
actor,
|
||||||
|
title=str(entry["title"]),
|
||||||
|
criteria=str(entry["criteria"]),
|
||||||
|
description=str(entry.get("description", "")),
|
||||||
|
case=str(entry.get("case", "")) or None,
|
||||||
|
)
|
||||||
|
created.append({"id": item["id"], "title": item["title"]})
|
||||||
|
return {
|
||||||
|
"approved": True,
|
||||||
|
"items": created,
|
||||||
|
"note": "items created on the board — staff and assign to start work",
|
||||||
|
}
|
||||||
|
|
||||||
def journal_overview(self) -> list[dict[str, Any]]:
|
def journal_overview(self) -> list[dict[str, Any]]:
|
||||||
return self.journal_store.overview(self._user_actor())
|
return self.journal_store.overview(self._user_actor())
|
||||||
|
|
||||||
@@ -1585,14 +1630,20 @@ class SessionManager:
|
|||||||
mode=record.mode,
|
mode=record.mode,
|
||||||
messages=[],
|
messages=[],
|
||||||
agent=pid,
|
agent=pid,
|
||||||
team={
|
|
||||||
"role": "worker",
|
|
||||||
"actor": actor,
|
|
||||||
"lead_session": session_id,
|
|
||||||
"space": space,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Written via the dedicated setter: the turn-save upsert never touches
|
||||||
|
# `team`, so a worker's first turn can't detach it from its lead.
|
||||||
|
self.session_store.set_team(
|
||||||
|
worker_sid,
|
||||||
|
{
|
||||||
|
"team_id": "", # patched below once the team exists
|
||||||
|
"role": "worker",
|
||||||
|
"actor": actor,
|
||||||
|
"lead_session": session_id,
|
||||||
|
"space": space,
|
||||||
|
},
|
||||||
|
)
|
||||||
workers.append(
|
workers.append(
|
||||||
TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model)
|
TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model)
|
||||||
)
|
)
|
||||||
@@ -1604,14 +1655,26 @@ class SessionManager:
|
|||||||
chat_enabled=enable_chat,
|
chat_enabled=enable_chat,
|
||||||
)
|
)
|
||||||
for worker in workers:
|
for worker in workers:
|
||||||
|
self.session_store.set_team(
|
||||||
|
worker.session_id,
|
||||||
|
{
|
||||||
|
"team_id": team.team_id,
|
||||||
|
"role": "worker",
|
||||||
|
"actor": worker.actor,
|
||||||
|
"lead_session": session_id,
|
||||||
|
"space": space,
|
||||||
|
},
|
||||||
|
)
|
||||||
self._emit_session_created(worker.session_id, worker.persona)
|
self._emit_session_created(worker.session_id, worker.persona)
|
||||||
record.team = {
|
self.session_store.set_team(
|
||||||
"team_id": team.team_id,
|
session_id,
|
||||||
"role": "lead",
|
{
|
||||||
"actor": team.lead_actor,
|
"team_id": team.team_id,
|
||||||
"space": space,
|
"role": "lead",
|
||||||
}
|
"actor": team.lead_actor,
|
||||||
self.session_store.save(record)
|
"space": space,
|
||||||
|
},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"approved": True,
|
"approved": True,
|
||||||
"team_id": team.team_id,
|
"team_id": team.team_id,
|
||||||
@@ -1665,10 +1728,11 @@ class SessionManager:
|
|||||||
return 0
|
return 0
|
||||||
message = self._team_digest(team, directs, subs, is_lead=is_lead)
|
message = self._team_digest(team, directs, subs, is_lead=is_lead)
|
||||||
self._team_inflight.add(session_id)
|
self._team_inflight.add(session_id)
|
||||||
|
source = self._board_source(team, message)
|
||||||
|
|
||||||
async def _deliver() -> None:
|
async def _deliver() -> None:
|
||||||
try:
|
try:
|
||||||
await self.deliver_to_session(session_id, message)
|
await self.deliver_to_session(session_id, message, source=source)
|
||||||
# Consume only after the turn dispatched: a crash before this replays
|
# Consume only after the turn dispatched: a crash before this replays
|
||||||
# the batch next tick (at-least-once, never silently lost).
|
# the batch next tick (at-least-once, never silently lost).
|
||||||
if directs:
|
if directs:
|
||||||
@@ -1734,6 +1798,23 @@ class SessionManager:
|
|||||||
" Journal evidence as you go."
|
" Journal evidence as you go."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _board_source(team, message: str) -> dict[str, Any]:
|
||||||
|
"""Display-only MessageSource sidecar for board deliveries — the same
|
||||||
|
mechanism connector messages use, so the GUI renders a structured card
|
||||||
|
instead of a fake user bubble (owner ask 2026-08-16). The framed message
|
||||||
|
stays the model-facing text; this only shapes presentation."""
|
||||||
|
return {
|
||||||
|
"connector": "board",
|
||||||
|
"kind": "channel",
|
||||||
|
"channel_id": team.space,
|
||||||
|
"channel_name": "Team board",
|
||||||
|
"sender_id": "board",
|
||||||
|
"sender_name": "Board",
|
||||||
|
"ts": time.time(),
|
||||||
|
"text": message,
|
||||||
|
}
|
||||||
|
|
||||||
def team_staleness_digest(self, session_id: str) -> str:
|
def team_staleness_digest(self, session_id: str) -> str:
|
||||||
"""Attached to a lead's TIMER wakes: pure code over the board — a
|
"""Attached to a lead's TIMER wakes: pure code over the board — a
|
||||||
nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by
|
nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by
|
||||||
|
|||||||
@@ -256,6 +256,68 @@ _PROPOSE_TEAM_SCHEMA = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# The decomposition gate's schema carrier — the board-flavored sibling of
|
||||||
|
# propose_plan, usable in ANY permission mode (proposing costs nothing; the board
|
||||||
|
# only ever holds accepted work). The engine intercepts it; approval creates the
|
||||||
|
# items and returns their ids.
|
||||||
|
_PROPOSE_ITEMS_SCHEMA = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "propose_work_items",
|
||||||
|
"description": (
|
||||||
|
"Present your decomposition to the user as proposed WORK ITEMS for the"
|
||||||
|
" team board. Approval creates them on the board (ids come back in the"
|
||||||
|
" result); rejection returns feedback to revise. Each item needs a"
|
||||||
|
" title and acceptance criteria — what gets verified before it can be"
|
||||||
|
" done. This is not propose_plan: it carries no implementation steps"
|
||||||
|
" and works in any mode — it is how a lead plans and coordinates via"
|
||||||
|
" the board."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"criteria": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"case": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["title", "criteria"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"note": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def propose_work_items_tool() -> object:
|
||||||
|
def propose_work_items(items: Optional[list] = None, note: str = "") -> dict:
|
||||||
|
"""Present proposed work items ({title, criteria, description?, case?})
|
||||||
|
for the user's approval; approval creates them on the board."""
|
||||||
|
return {
|
||||||
|
"approved": False,
|
||||||
|
"error": "item proposals aren't available in this surface",
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped = ai.tool(
|
||||||
|
propose_work_items,
|
||||||
|
metadata=ai.ToolMetadata(
|
||||||
|
category="team",
|
||||||
|
risk_level="low",
|
||||||
|
capabilities=["team"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
def propose_team_tool() -> object:
|
def propose_team_tool() -> object:
|
||||||
def propose_team(
|
def propose_team(
|
||||||
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
|
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
|
||||||
|
|||||||
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
return; // suspended on the approval
|
return; // suspended on the approval
|
||||||
}
|
}
|
||||||
|
// Agent teams: the decomposition gate — the lead proposes work items and
|
||||||
|
// SUSPENDS until the items_response verdict arrives (approval creates them).
|
||||||
|
if (/propose the split/i.test(msg.text)) {
|
||||||
|
send("items_proposed", {
|
||||||
|
items: [
|
||||||
|
{ title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" },
|
||||||
|
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
|
||||||
|
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
|
||||||
|
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" },
|
||||||
|
],
|
||||||
|
note: "Shared journal case: statements.",
|
||||||
|
});
|
||||||
|
return; // suspended on the items decision
|
||||||
|
}
|
||||||
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
|
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
|
||||||
// SUSPENDS until the team_response verdict arrives.
|
// SUSPENDS until the team_response verdict arrives.
|
||||||
if (/staff the team/i.test(msg.text)) {
|
if (/staff the team/i.test(msg.text)) {
|
||||||
@@ -841,6 +855,16 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
|
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
|
||||||
}
|
}
|
||||||
send("turn_done");
|
send("turn_done");
|
||||||
|
} else if (msg.type === "items_response") {
|
||||||
|
if (msg.approved) {
|
||||||
|
seedBoard(); // "created on the board" — the board fetch now shows them
|
||||||
|
send("assistant_message", {
|
||||||
|
text: "Items created on the board — staffing next.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
send("assistant_message", { text: "Understood — reworking the split." });
|
||||||
|
}
|
||||||
|
send("turn_done");
|
||||||
} else if (msg.type === "team_response") {
|
} else if (msg.type === "team_response") {
|
||||||
if (msg.approved) {
|
if (msg.approved) {
|
||||||
// Server-side create_team pre-spawned the workers; surface them in the
|
// Server-side create_team pre-spawned the workers; surface them in the
|
||||||
|
|||||||
@@ -12,6 +12,35 @@ async function proposeTeam(page: import("@playwright/test").Page) {
|
|||||||
await expect(page.getByTestId("teamreq-card")).toBeVisible();
|
await expect(page.getByTestId("teamreq-card")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the decomposition gate shows items with criteria; approval lands them on the board", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
|
||||||
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
const card = page.getByTestId("itemsreq-card");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card).toContainText("Proposed work items — 4");
|
||||||
|
await expect(card).toContainText("Done when:");
|
||||||
|
// 3 visible + expander with the true remainder
|
||||||
|
await expect(card.getByText("Verification pass")).toHaveCount(0);
|
||||||
|
await card.getByRole("button", { name: /1 more item/ }).click();
|
||||||
|
await expect(card.getByText("Verification pass")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("itemsreq-approve").click();
|
||||||
|
await expect(page.getByText(/Items created on the board/)).toBeVisible();
|
||||||
|
await expect(page.getByTestId("board-rail")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declining the split returns feedback to the lead", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
|
||||||
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
await page.getByTestId("itemsreq-card").waitFor();
|
||||||
|
await page.getByRole("button", { name: "Not now" }).click();
|
||||||
|
await expect(page.getByText(/reworking the split/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
|
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
|
||||||
await proposeTeam(page);
|
await proposeTeam(page);
|
||||||
const card = page.getByTestId("teamreq-card");
|
const card = page.getByTestId("teamreq-card");
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
|||||||
import { PlanCard } from "./components/PlanCard";
|
import { PlanCard } from "./components/PlanCard";
|
||||||
import { BoardOverlay } from "./components/BoardPanel";
|
import { BoardOverlay } from "./components/BoardPanel";
|
||||||
import { TeamRequestCard } from "./components/TeamRequestCard";
|
import { TeamRequestCard } from "./components/TeamRequestCard";
|
||||||
|
import { WorkItemsCard } from "./components/WorkItemsCard";
|
||||||
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||||
|
|
||||||
const newId = () =>
|
const newId = () =>
|
||||||
@@ -770,6 +771,18 @@ export function App() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
case "items_proposed":
|
||||||
|
// The decomposition gate — approval creates the items on the board.
|
||||||
|
if (unattendedRef.current) break;
|
||||||
|
setItems((p) => [
|
||||||
|
...p,
|
||||||
|
{
|
||||||
|
kind: "itemsreq",
|
||||||
|
items: Array.isArray(d.items) ? d.items : [],
|
||||||
|
note: d.note || "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
break;
|
||||||
case "question_requested":
|
case "question_requested":
|
||||||
// ask_user in an attended session — answered inline (not routed to the Inbox).
|
// ask_user in an attended session — answered inline (not routed to the Inbox).
|
||||||
setItems((p) => [
|
setItems((p) => [
|
||||||
@@ -1035,6 +1048,12 @@ export function App() {
|
|||||||
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
|
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
|
||||||
sessionRef.current?.respondTeam(approved, feedback);
|
sessionRef.current?.respondTeam(approved, feedback);
|
||||||
};
|
};
|
||||||
|
const respondItemsReq = (approved: boolean, feedback?: string) => {
|
||||||
|
setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected"));
|
||||||
|
dropSessionInbox("plan");
|
||||||
|
sessionRef.current?.respondItems(approved, feedback);
|
||||||
|
if (approved) setTimeout(refreshBoard, 400); // the items just landed
|
||||||
|
};
|
||||||
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
|
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
|
||||||
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
|
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
|
||||||
dropSessionInbox("directory");
|
dropSessionInbox("directory");
|
||||||
@@ -1409,6 +1428,7 @@ export function App() {
|
|||||||
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
|
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
|
||||||
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
|
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
|
||||||
const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved);
|
const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved);
|
||||||
|
const pendingItemsReq = [...items].reverse().find((i) => i.kind === "itemsreq" && !i.resolved);
|
||||||
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
|
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
|
||||||
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
|
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
|
||||||
// workspace folder for project-scoped sessions). Renders only once the session has history;
|
// workspace folder for project-scoped sessions). Renders only once the session has history;
|
||||||
@@ -1924,6 +1944,8 @@ export function App() {
|
|||||||
// parked in the Inbox and surfaced via the answer-in-context card below.
|
// parked in the Inbox and surfaced via the answer-in-context card below.
|
||||||
!unattended && pendingPlan?.kind === "planreq" ? (
|
!unattended && pendingPlan?.kind === "planreq" ? (
|
||||||
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
||||||
|
) : !unattended && pendingItemsReq?.kind === "itemsreq" ? (
|
||||||
|
<WorkItemsCard item={pendingItemsReq} onRespond={respondItemsReq} />
|
||||||
) : !unattended && pendingTeam?.kind === "teamreq" ? (
|
) : !unattended && pendingTeam?.kind === "teamreq" ? (
|
||||||
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
|
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
|
||||||
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
||||||
@@ -2141,6 +2163,18 @@ function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLastItemsReq(items: Item[], resolved: "approved" | "rejected"): Item[] {
|
||||||
|
const copy = [...items];
|
||||||
|
for (let i = copy.length - 1; i >= 0; i--) {
|
||||||
|
const it = copy[i];
|
||||||
|
if (it.kind === "itemsreq" && !it.resolved) {
|
||||||
|
copy[i] = { ...it, resolved };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLastQuestion(items: Item[], answer: string): Item[] {
|
function resolveLastQuestion(items: Item[], answer: string): Item[] {
|
||||||
const copy = [...items];
|
const copy = [...items];
|
||||||
for (let i = copy.length - 1; i >= 0; i--) {
|
for (let i = copy.length - 1; i >= 0; i--) {
|
||||||
|
|||||||
@@ -2197,6 +2197,14 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
respondItems(approved: boolean, feedback?: string) {
|
||||||
|
this.send({
|
||||||
|
type: "items_response",
|
||||||
|
approved,
|
||||||
|
...(feedback ? { feedback } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
|
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
|
||||||
respondQuestion(answer: string) {
|
respondQuestion(answer: string) {
|
||||||
this.send({ type: "question_response", answer });
|
this.send({ type: "question_response", answer });
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// The decomposition gate (agent teams): a lead proposes work items; approval
|
||||||
|
// creates them on the board. The board-flavored sibling of PlanCard — items with
|
||||||
|
// acceptance criteria as the primary text, 3 visible + expander with the true
|
||||||
|
// count in the header, no in-card reply surface (editing happens by replying).
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Item } from "../types";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export function WorkItemsCard({
|
||||||
|
item,
|
||||||
|
onRespond,
|
||||||
|
}: {
|
||||||
|
item: Extract<Item, { kind: "itemsreq" }>;
|
||||||
|
onRespond: (approved: boolean, feedback?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const visible = expanded ? item.items : item.items.slice(0, 3);
|
||||||
|
const hidden = item.items.length - visible.length;
|
||||||
|
return (
|
||||||
|
<div className="dirreq-card itemsreq-card" data-testid="itemsreq-card">
|
||||||
|
<div className="itemsreq-head">
|
||||||
|
<Icon name="table" size={15} />
|
||||||
|
<span className="itemsreq-title">
|
||||||
|
Proposed work items — {item.items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.note && <div className="itemsreq-note">{item.note}</div>}
|
||||||
|
{visible.map((entry, i) => (
|
||||||
|
<div className="itemsreq-item" key={i}>
|
||||||
|
<span className="itemsreq-num">{i + 1}.</span>
|
||||||
|
<span className="itemsreq-body">
|
||||||
|
<span className="itemsreq-item-title">{entry.title}</span>
|
||||||
|
<span className="itemsreq-ac">
|
||||||
|
<b>Done when:</b> {entry.criteria}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hidden > 0 && (
|
||||||
|
<button className="itemsreq-more" onClick={() => setExpanded(true)}>
|
||||||
|
+ {hidden} more item{hidden === 1 ? "" : "s"}
|
||||||
|
<Icon name="chevronDown" size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="dirreq-actions">
|
||||||
|
<span className="itemsreq-grant">
|
||||||
|
Reply to edit the split; approval creates these on the board.
|
||||||
|
</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={() => onRespond(false)}>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
data-testid="itemsreq-approve"
|
||||||
|
onClick={() => onRespond(true)}
|
||||||
|
>
|
||||||
|
Approve items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1734,3 +1734,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
|
|||||||
.team-dot.in_progress { background: var(--ok-dot); }
|
.team-dot.in_progress { background: var(--ok-dot); }
|
||||||
.team-dot.blocked { background: var(--danger); }
|
.team-dot.blocked { background: var(--danger); }
|
||||||
.team-dot.review { background: var(--warn-ink); }
|
.team-dot.review { background: var(--warn-ink); }
|
||||||
|
|
||||||
|
/* Decomposition gate (proposed work items) */
|
||||||
|
.itemsreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.itemsreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.itemsreq-title { font-weight: 600; font-size: 13px; color: var(--ink); }
|
||||||
|
.itemsreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.itemsreq-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); }
|
||||||
|
.itemsreq-num { color: var(--faint); font-size: 12px; padding-top: 1px; }
|
||||||
|
.itemsreq-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.itemsreq-item-title { font-size: 12.5px; color: var(--ink); }
|
||||||
|
.itemsreq-ac { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.itemsreq-ac b { font-weight: 600; }
|
||||||
|
.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; }
|
||||||
|
.itemsreq-grant { font-size: 11.5px; color: var(--faint); }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type EventType =
|
|||||||
| "question_requested"
|
| "question_requested"
|
||||||
| "plan_proposed"
|
| "plan_proposed"
|
||||||
| "team_proposed"
|
| "team_proposed"
|
||||||
|
| "items_proposed"
|
||||||
| "tool_started"
|
| "tool_started"
|
||||||
| "tool_finished"
|
| "tool_finished"
|
||||||
| "iteration_end"
|
| "iteration_end"
|
||||||
@@ -164,6 +165,13 @@ export type Item =
|
|||||||
note?: string;
|
note?: string;
|
||||||
resolved?: "approved" | "rejected";
|
resolved?: "approved" | "rejected";
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
// The decomposition gate: a lead proposes work items; approval creates them.
|
||||||
|
kind: "itemsreq";
|
||||||
|
items: { title: string; criteria: string; description?: string }[];
|
||||||
|
note?: string;
|
||||||
|
resolved?: "approved" | "rejected";
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
||||||
kind: "question";
|
kind: "question";
|
||||||
|
|||||||
@@ -231,3 +231,38 @@ def test_team_options_lists_only_enabled_workers(manager):
|
|||||||
assert {"swe-worker", "design-worker", "test-worker"} <= workers
|
assert {"swe-worker", "design-worker", "test-worker"} <= workers
|
||||||
assert "swe-lead" not in workers # leads staff, they aren't staffed
|
assert "swe-lead" not in workers # leads staff, they aren't staffed
|
||||||
assert "security" not in workers # solo coworkers are not team-eligible
|
assert "security" not in workers # solo coworkers are not team-eligible
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch):
|
||||||
|
from coworker.agents.base import Agent
|
||||||
|
from coworker.sessions import SessionRecord
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = manager.create_team("lead-sid", [{"persona": "swe-worker"}])
|
||||||
|
wid = result["workers"][0]["session_id"]
|
||||||
|
# A per-turn save rebuilds the record WITHOUT the team field (the engine doesn't
|
||||||
|
# carry it) — owner-hit 2026-08-16: this detached workers from the lead's entry.
|
||||||
|
record = manager.session_store.load(wid)
|
||||||
|
manager.session_store.save(
|
||||||
|
SessionRecord(
|
||||||
|
session_id=wid,
|
||||||
|
workspace=record.workspace,
|
||||||
|
model=record.model,
|
||||||
|
mode=record.mode,
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
agent=record.agent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert manager.session_store.load(wid).team["lead_session"] == "lead-sid"
|
||||||
|
assert manager.session_store.load("lead-sid").team["role"] == "lead"
|
||||||
|
|||||||
Reference in New Issue
Block a user