mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): Wave 1 pre-gateway parity — sessions, progress, notify inbox
Closes empty-panel complaints (Sessions, Progress) and the i_am_idle notification-inbox deadlock identified in the 2026-05-11 gap analysis. All backend service methods already exist; this is pure MCP-surface widening on top of the existing choreographer + ContentActions. New MCP tools (roboco-do): - progress(task_id, message, percentage) — Progress tab writer - open_session(task_id, channel, topic, ...) — Sessions tab writer (PM+) - link_session(session_id, task_id, ...) — Idempotent task↔session - notify_list(unread_only, pending_ack_only, limit) - notify_get(notification_id) - notify_ack(notification_id) Wired through: - roboco/api/schemas/v2/do.py — six new request schemas with Field constraints (Progress.percentage: ge=0, le=100; OpenSession.topic: max_length=200; etc.) - roboco/api/routes/v2/do.py — six new POST routes, thin dispatchers - roboco/services/gateway/content_actions.py — six new ContentActions methods forwarding to TaskService.add_progress, MessagingService.create_session_for_tasks /link_session_to_task, NotificationDeliveryService.list_for_agent / get_for_ recipient_and_mark_read / acknowledge - roboco/mcp/do_server.py — six new typed tool wrappers + registered in _TOOLS - roboco/services/gateway/role_config.py — receivers (list/get/ack) added to every role except auditor (who gets list/get, no ack). Session verbs to PM-or-up. Progress to dev + doc. - agents/prompts/roles/*.md — verb tables updated for developer / QA / documenter / cell_pm / main_pm. i_am_idle line points to notify_list as the deadlock resolution path. Authorization: - progress: assignee + active status (in_progress / verifying / awaiting_qa / awaiting_documentation) - open_session: cell_pm / main_pm / product_owner / head_marketing / ceo - link_session: caller must own the task - notify_ack: caller must be a recipient (ValueError from service maps to not_authorized envelope) Per-file ignore extended: - roboco/services/gateway/**/*.py = [PLC0415, PLR0913] — same rationale as roboco/mcp/**: typed verb signatures are the agent-facing contract; bundling into dataclasses hides field-level schema the LLM needs at the tool layer. Quality: ruff + mypy clean. 503 unit tests pass on touched surfaces. Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
This commit is contained in:
@@ -33,7 +33,10 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl
|
||||
| `say(channel, text)` / `dm(recipient, text)` | Channel post / DM. **Channel slug without `#`. Valid slugs:** cell channels (`backend-cell`, `frontend-cell`, `uxui-cell`), cross-cell (`dev-all`, `qa-all`, `pm-all`, `doc-all`), management (`main-pm-board`, `board-private`), broadcast (`announcements`, `all-hands`). Inventing a slug ("backend-dev", "backend") returns `Channel not found`. | None. |
|
||||
| `notify(target, text, priority?)` | Send a formal ack-required notification to an agent (`be-dev-1`, `ceo`, etc.). `priority` is one of `normal`/`high`/`urgent` (default `normal`). | None. |
|
||||
| `evidence(task_id)` | Inspect a task's PR + commits + diff. | None. |
|
||||
| `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. | None. |
|
||||
| `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. Soft-blocks on unread notifications — clear inbox first via `notify_list` → `notify_get` → `notify_ack`. | None. |
|
||||
| `open_session(task_id, channel, topic, relationship_type='discussion')` | Open a discussion session linked to a task — populates the panel's Sessions tab. Use when starting work on a non-trivial child task that needs a discussion thread. `channel` is a valid slug from the channel list. | Caller must be PM-or-up; task must exist. |
|
||||
| `link_session(session_id, task_id, is_primary=False)` | Link an existing session to another task (idempotent). | You must own the task. |
|
||||
| `notify_list(unread_only=True, limit=20)` / `notify_get(id)` / `notify_ack(id)` | Read and acknowledge notifications. | None. |
|
||||
|
||||
## State → Verb (YOUR cell-PM task)
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
|
||||
| `note(text, scope?)` | Journal entry (`scope ∈ note|decision|reflect|learning|struggle`). | None. |
|
||||
| `say(channel, text)` / `dm(recipient, text, skill?)` | Channel post / direct message. | Channel slug without `#`. |
|
||||
| `evidence(task_id)` | Fetches PR diff, commits, files changed, dev summary. | None. |
|
||||
| `i_am_idle()` | Done for now; soft-blocks if you have unread A2A or @mentions. | No active task locks. |
|
||||
| `i_am_idle()` | Done for now; soft-blocks if you have unread A2A or @mentions. Resolve by calling `notify_list()` → `notify_get(id)` per item → `notify_ack(id)` per item, then retry `i_am_idle()`. | No active task locks. |
|
||||
| `progress(task_id, message, percentage)` | Append a narrative progress entry to the panel's Progress tab. `percentage` is 0..100. Use this in addition to `commit()` — commits are git refs, progress is the human-readable update. | Task assigned to you and in `in_progress`/`verifying`/`awaiting_qa`/`awaiting_documentation`. |
|
||||
| `notify_list(unread_only=True, limit=20)` | Read your notification inbox. | None. |
|
||||
| `notify_get(notification_id)` | Read one notification (also marks it read). | Notification recipient must be you. |
|
||||
| `notify_ack(notification_id)` | Acknowledge a notification. | Notification recipient must be you. |
|
||||
|
||||
## State → Verb
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ You do NOT re-implement the developer's work. You do NOT review or critique the
|
||||
| `note(text, scope?)` | Journal entry. | None. |
|
||||
| `say(channel, text)` / `dm(recipient, text, skill?)` | Channel post / direct message. | Channel slug without `#`. |
|
||||
| `evidence(task_id)` | Re-fetches PR diff and commits if needed. | None. |
|
||||
| `i_am_idle()` | Done for now. | No active doc claim. |
|
||||
| `i_am_idle()` | Done for now. Soft-blocks on unread notifications — clear inbox first via `notify_list` → `notify_get` → `notify_ack`. | No active doc claim. |
|
||||
| `progress(task_id, message, percentage)` | Append a narrative progress entry to the panel's Progress tab (0..100). Use in addition to `commit()`. | Task assigned to you and active. |
|
||||
| `notify_list(unread_only=True, limit=20)` / `notify_get(id)` / `notify_ack(id)` | Read and acknowledge notifications. | None. |
|
||||
|
||||
## State → Verb
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ You merge what your Cell PMs submit (cell PRs into your root branch via `complet
|
||||
| `say(channel, text)` / `dm(recipient, text)` | Channel post / DM. **Channel slug without `#`. Valid slugs:** cell channels (`backend-cell`, `frontend-cell`, `uxui-cell`), cross-cell (`dev-all`, `qa-all`, `pm-all`, `doc-all`), management (`main-pm-board`, `board-private`), broadcast (`announcements`, `all-hands`). Inventing a slug returns `Channel not found`. | None. |
|
||||
| `notify(target, text, priority?)` | Send a formal ack-required notification to an agent (`be-dev-1`, `ceo`, etc.). `priority` is one of `normal`/`high`/`urgent` (default `normal`). | None. |
|
||||
| `evidence(task_id)` | Inspect a task's PR + commits + diff. | None. |
|
||||
| `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. | None. |
|
||||
| `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. Soft-blocks on unread notifications — clear inbox first via `notify_list` → `notify_get` → `notify_ack`. | None. |
|
||||
| `open_session(task_id, channel, topic, relationship_type='discussion')` | Open a strategic discussion session linked to a root task. Populates the panel's Sessions tab. Use when starting work on a cross-cell feature that needs a top-level thread. | Caller is PM-or-up; task exists. |
|
||||
| `link_session(session_id, task_id, is_primary=False)` | Link an existing session to another task. | You must own the task. |
|
||||
| `notify_list(unread_only=True, limit=20)` / `notify_get(id)` / `notify_ack(id)` | Read and acknowledge notifications. | None. |
|
||||
|
||||
## State → Verb (YOUR root task)
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
|
||||
| `note(text, scope?)` | Journal entry. Required: `scope='learning'` before `pass`/`fail`. | None. |
|
||||
| `say(channel, text)` / `dm(recipient, text, skill?)` | Channel post / direct message. | Channel slug without `#`. |
|
||||
| `evidence(task_id)` | Re-fetches full PR diff and commits if you need more detail. | None. |
|
||||
| `i_am_idle()` | Done for now. | No active QA claim. |
|
||||
| `i_am_idle()` | Done for now. Soft-blocks on unread notifications — clear inbox first via `notify_list` → `notify_get` → `notify_ack`. | No active QA claim. |
|
||||
| `notify_list(unread_only=True, limit=20)` / `notify_get(id)` / `notify_ack(id)` | Read and acknowledge notifications addressed to you. | None. |
|
||||
|
||||
## State → Verb
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ select = [
|
||||
# the LLM and bring back invented values like nature='standard'.
|
||||
"roboco/mcp/**/*.py" = ["PLC0415", "PLR0913"]
|
||||
"roboco/services/*.py" = ["PLC0415"]
|
||||
# Gateway methods are typed verb surfaces — agent-facing kwargs reflect the
|
||||
# verb contract (session topic, channel, relationship type, etc.). Bundling
|
||||
# into a dataclass hides the field-by-field schema the LLM needs at the
|
||||
# tool layer; we accept the >5 kwarg signatures here for the same reason
|
||||
# they're accepted in `roboco/mcp/**`.
|
||||
"roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"]
|
||||
"roboco/api/routes/*.py" = ["PLC0415"]
|
||||
"roboco/runtime/*.py" = ["PLC0415"]
|
||||
# Lifecycle validators: foundation/_validate_lifecycle is imported from the
|
||||
|
||||
@@ -11,8 +11,14 @@ from roboco.api.schemas.v2.do import (
|
||||
CommitRequest,
|
||||
DmRequest,
|
||||
EvidenceRequest,
|
||||
LinkSessionRequest,
|
||||
NoteRequest,
|
||||
NotifyAckRequest,
|
||||
NotifyGetRequest,
|
||||
NotifyListRequest,
|
||||
NotifyRequest,
|
||||
OpenSessionRequest,
|
||||
ProgressRequest,
|
||||
SayRequest,
|
||||
)
|
||||
from roboco.services.gateway.content_actions import ContentActions
|
||||
@@ -125,3 +131,103 @@ async def do_evidence(
|
||||
) -> dict:
|
||||
env = await actions.evidence(agent_id=x_agent_id, task_id=body.task_id)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wave 1 — pre-gateway parity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/progress")
|
||||
async def do_progress(
|
||||
request: Request,
|
||||
body: ProgressRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.progress(
|
||||
agent_id=x_agent_id,
|
||||
task_id=body.task_id,
|
||||
message=body.message,
|
||||
percentage=body.percentage,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/open_session")
|
||||
async def do_open_session(
|
||||
request: Request,
|
||||
body: OpenSessionRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.open_session(
|
||||
agent_id=x_agent_id,
|
||||
task_id=body.task_id,
|
||||
channel=body.channel,
|
||||
topic=body.topic,
|
||||
relationship_type=body.relationship_type,
|
||||
group_id=body.group_id,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/link_session")
|
||||
async def do_link_session(
|
||||
request: Request,
|
||||
body: LinkSessionRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.link_session(
|
||||
agent_id=x_agent_id,
|
||||
session_id=body.session_id,
|
||||
task_id=body.task_id,
|
||||
is_primary=body.is_primary,
|
||||
relationship_type=body.relationship_type,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/notify_list")
|
||||
async def do_notify_list(
|
||||
request: Request,
|
||||
body: NotifyListRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.notify_list(
|
||||
agent_id=x_agent_id,
|
||||
unread_only=body.unread_only,
|
||||
pending_ack_only=body.pending_ack_only,
|
||||
limit=body.limit,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/notify_get")
|
||||
async def do_notify_get(
|
||||
request: Request,
|
||||
body: NotifyGetRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.notify_get(
|
||||
agent_id=x_agent_id,
|
||||
notification_id=body.notification_id,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/notify_ack")
|
||||
async def do_notify_ack(
|
||||
request: Request,
|
||||
body: NotifyAckRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.notify_ack(
|
||||
agent_id=x_agent_id,
|
||||
notification_id=body.notification_id,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
@@ -65,3 +65,59 @@ class NotifyRequest(BaseModel):
|
||||
|
||||
class EvidenceRequest(BaseModel):
|
||||
task_id: UUID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Wave 1 — Pre-gateway parity restoration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OpenSessionRequest(BaseModel):
|
||||
"""PM creates a discussion session for one or more tasks.
|
||||
|
||||
Pre-gateway parity for `roboco_session_create_for_tasks`. Populates the
|
||||
panel's Sessions tab.
|
||||
"""
|
||||
|
||||
task_id: UUID
|
||||
channel: str = Field(..., min_length=1)
|
||||
topic: str = Field(..., min_length=1, max_length=200)
|
||||
relationship_type: str = "discussion" # discussion|planning|review|retrospective
|
||||
group_id: UUID | None = None
|
||||
|
||||
|
||||
class LinkSessionRequest(BaseModel):
|
||||
"""Link an existing session to a task. Idempotent."""
|
||||
|
||||
session_id: UUID
|
||||
task_id: UUID
|
||||
is_primary: bool = False
|
||||
relationship_type: str = "discussion"
|
||||
|
||||
|
||||
class ProgressRequest(BaseModel):
|
||||
"""Narrative progress update with 0..100 percentage.
|
||||
|
||||
Pre-gateway parity for `roboco_task_progress`. Populates the panel's
|
||||
Progress tab.
|
||||
"""
|
||||
|
||||
task_id: UUID
|
||||
message: str = Field(..., min_length=1)
|
||||
percentage: int = Field(..., ge=0, le=100)
|
||||
|
||||
|
||||
class NotifyListRequest(BaseModel):
|
||||
"""Read this agent's notification inbox."""
|
||||
|
||||
unread_only: bool = True
|
||||
pending_ack_only: bool = False
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class NotifyGetRequest(BaseModel):
|
||||
notification_id: UUID
|
||||
|
||||
|
||||
class NotifyAckRequest(BaseModel):
|
||||
notification_id: UUID
|
||||
|
||||
@@ -204,6 +204,123 @@ def evidence(task_id: str) -> dict[str, Any]:
|
||||
return _post("/api/v2/do/evidence", {"task_id": task_id})
|
||||
|
||||
|
||||
# ---------- Wave 1 — pre-gateway parity ----------
|
||||
|
||||
|
||||
def progress(task_id: str, message: str, percentage: int) -> dict[str, Any]:
|
||||
"""Append a narrative progress update to YOUR active task.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task you're working on.
|
||||
message: One-paragraph summary of what just landed.
|
||||
percentage: 0..100 inclusive. Rough completion estimate; bump it as
|
||||
you make progress so PM/QA can see velocity.
|
||||
|
||||
Populates the panel's Progress tab. Use this in addition to ``commit``
|
||||
— commits are git refs; progress is narrative.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v2/do/progress",
|
||||
{"task_id": task_id, "message": message, "percentage": percentage},
|
||||
)
|
||||
|
||||
|
||||
def open_session(
|
||||
task_id: str,
|
||||
channel: str,
|
||||
topic: str,
|
||||
relationship_type: str = "discussion",
|
||||
group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""PM creates a discussion session linked to a task.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task this session discusses.
|
||||
channel: Channel slug without `#` (e.g. ``backend-cell``).
|
||||
topic: Short topic for the session (≤200 chars).
|
||||
relationship_type: ``discussion`` | ``planning`` | ``review`` |
|
||||
``retrospective``.
|
||||
group_id: Optional UUID to place the session under a specific group.
|
||||
|
||||
Populates the panel's Sessions tab. Only PM-or-up roles can open sessions
|
||||
— devs / QA / docs participate via channels and DMs.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v2/do/open_session",
|
||||
{
|
||||
"task_id": task_id,
|
||||
"channel": channel,
|
||||
"topic": topic,
|
||||
"relationship_type": relationship_type,
|
||||
"group_id": group_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def link_session(
|
||||
session_id: str,
|
||||
task_id: str,
|
||||
is_primary: bool = False,
|
||||
relationship_type: str = "discussion",
|
||||
) -> dict[str, Any]:
|
||||
"""Link an existing session to a task (idempotent).
|
||||
|
||||
Use when an existing discussion now covers a new task too. You must
|
||||
own the task you're linking; cross-agent linking is denied.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v2/do/link_session",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"task_id": task_id,
|
||||
"is_primary": is_primary,
|
||||
"relationship_type": relationship_type,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_list(
|
||||
unread_only: bool = True,
|
||||
pending_ack_only: bool = False,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Read your notification inbox.
|
||||
|
||||
Call this when ``i_am_idle()`` soft-blocks you with an "unread A2A or
|
||||
@mentions" message — list, read each one with ``notify_get``, ack with
|
||||
``notify_ack``, then idle again.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v2/do/notify_list",
|
||||
{
|
||||
"unread_only": unread_only,
|
||||
"pending_ack_only": pending_ack_only,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_get(notification_id: str) -> dict[str, Any]:
|
||||
"""Read one notification (marks it as read)."""
|
||||
return _post(
|
||||
"/api/v2/do/notify_get",
|
||||
{"notification_id": notification_id},
|
||||
)
|
||||
|
||||
|
||||
def notify_ack(notification_id: str) -> dict[str, Any]:
|
||||
"""Acknowledge a notification you've handled.
|
||||
|
||||
The gateway tracks who has acked which notification — required for
|
||||
``requires_ack=true`` notifications before ``i_am_idle`` will let you
|
||||
exit cleanly.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v2/do/notify_ack",
|
||||
{"notification_id": notification_id},
|
||||
)
|
||||
|
||||
|
||||
# ---------- Tool registry ----------
|
||||
#
|
||||
# Maps the tool name an agent calls (matches manifest entries and the
|
||||
@@ -216,6 +333,12 @@ _TOOLS: dict[str, Any] = {
|
||||
"dm": dm,
|
||||
"notify": notify,
|
||||
"evidence": evidence,
|
||||
"progress": progress,
|
||||
"open_session": open_session,
|
||||
"link_session": link_session,
|
||||
"notify_list": notify_list,
|
||||
"notify_get": notify_get,
|
||||
"notify_ack": notify_ack,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from roboco.foundation.policy import communications as _comms
|
||||
from roboco.foundation.policy.journaling import Scope as _Scope
|
||||
@@ -519,6 +519,282 @@ class ContentActions:
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Wave 1 — pre-gateway parity restoration
|
||||
# =========================================================================
|
||||
|
||||
_SESSION_OPENER_ROLES: ClassVar[frozenset[str]] = frozenset(
|
||||
{"cell_pm", "main_pm", "product_owner", "head_marketing", "ceo"}
|
||||
)
|
||||
|
||||
async def progress(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
message: str,
|
||||
percentage: int,
|
||||
) -> Envelope:
|
||||
"""Append a narrative progress update (pre-gateway parity).
|
||||
|
||||
Caller must be the task's assignee and the task must be in an
|
||||
active status — these are the same constraints the pre-gateway
|
||||
`roboco_task_progress` handler enforced.
|
||||
"""
|
||||
active = {
|
||||
"in_progress",
|
||||
"verifying",
|
||||
"awaiting_qa",
|
||||
"awaiting_documentation",
|
||||
}
|
||||
t = await self.task.get(task_id)
|
||||
if t is None:
|
||||
return Envelope.not_found(message=f"task {task_id} not found")
|
||||
if t.assigned_to != agent_id:
|
||||
return _ownership_violation(task_id)
|
||||
if str(t.status) not in active:
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"task is in {t.status!r}; progress updates only valid "
|
||||
f"in active statuses ({sorted(active)})"
|
||||
),
|
||||
remediate=(
|
||||
"use evidence(task_id) to re-read state; if you're past "
|
||||
"i_am_done, the run has moved on — call i_am_idle()"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
await self.task.add_progress(
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
message=message,
|
||||
percentage=percentage,
|
||||
)
|
||||
return Envelope.ok(
|
||||
status=str(t.status),
|
||||
task_id=str(task_id),
|
||||
next="continue",
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def open_session(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
channel: str,
|
||||
topic: str,
|
||||
relationship_type: str = "discussion",
|
||||
group_id: UUID | None = None,
|
||||
) -> Envelope:
|
||||
"""PM-or-up creates a discussion session linked to a task.
|
||||
|
||||
Pre-gateway parity for `roboco_session_create_for_tasks`. The
|
||||
underlying service de-duplicates: if an ancestor of this task
|
||||
already has a primary session in the same channel, it reuses
|
||||
that session instead of opening a new one.
|
||||
"""
|
||||
from roboco.api.schemas.sessions import SessionForTasksCreateRequest
|
||||
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else ""
|
||||
if role_str not in self._SESSION_OPENER_ROLES:
|
||||
return Envelope.not_authorized(
|
||||
message=(
|
||||
f"role {role_str!r} cannot open sessions; PM roles only"
|
||||
),
|
||||
remediate=(
|
||||
"ask your PM to open_session for you, or escalate_up if "
|
||||
"no session exists for the work you need to discuss"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
req = SessionForTasksCreateRequest(
|
||||
task_ids=[task_id],
|
||||
channel_slug=channel,
|
||||
relationship_type=relationship_type,
|
||||
group_id=group_id,
|
||||
)
|
||||
# ``topic`` isn't part of SessionForTasksCreateRequest yet — pre-gateway
|
||||
# the session topic was implicit (group name). For now we attach it to
|
||||
# the envelope so the panel + journal can surface it; threading it
|
||||
# into the session row is Wave 2 work.
|
||||
_ = topic
|
||||
session, links = await self.messaging.create_session_for_tasks(
|
||||
req=req, pm_agent_id=agent_id
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="session_open",
|
||||
task_id=str(task_id),
|
||||
next="continue",
|
||||
evidence={
|
||||
"session_id": str(session.id),
|
||||
"channel": channel,
|
||||
"topic": topic,
|
||||
"link_count": len(links),
|
||||
},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def link_session(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
session_id: UUID,
|
||||
task_id: UUID,
|
||||
is_primary: bool = False,
|
||||
relationship_type: str = "discussion",
|
||||
) -> Envelope:
|
||||
"""Link an existing session to a task (idempotent).
|
||||
|
||||
Caller must own the task — prevents cross-agent session-link spam.
|
||||
"""
|
||||
from roboco.models.session import SessionTaskRelationshipType
|
||||
|
||||
t = await self.task.get(task_id)
|
||||
if t is None:
|
||||
return Envelope.not_found(message=f"task {task_id} not found")
|
||||
if t.assigned_to is not None and t.assigned_to != agent_id:
|
||||
return _ownership_violation(task_id)
|
||||
try:
|
||||
rel = SessionTaskRelationshipType(relationship_type)
|
||||
except ValueError:
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"invalid relationship_type {relationship_type!r}"
|
||||
),
|
||||
remediate=(
|
||||
"use one of: discussion | planning | review | retrospective"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
link = await self.messaging.link_session_to_task(
|
||||
session_id=session_id,
|
||||
task_id=task_id,
|
||||
added_by=agent_id,
|
||||
is_primary=is_primary,
|
||||
relationship_type=rel,
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="session_linked",
|
||||
task_id=str(task_id),
|
||||
next="continue",
|
||||
evidence={
|
||||
"session_id": str(session_id),
|
||||
"link_id": str(link.id),
|
||||
"is_primary": is_primary,
|
||||
},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def notify_list(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
unread_only: bool = True,
|
||||
pending_ack_only: bool = False,
|
||||
limit: int = 20,
|
||||
) -> Envelope:
|
||||
"""Read this agent's notification inbox.
|
||||
|
||||
Closes the pre-gateway parity gap that left `i_am_idle()` deadlocked:
|
||||
the verb is documented to soft-block on unread notifications, but
|
||||
previously there was no way for the agent to read or acknowledge them.
|
||||
"""
|
||||
items = await self.notifications.list_for_agent(
|
||||
agent_id=agent_id,
|
||||
unread_only=unread_only,
|
||||
pending_ack_only=pending_ack_only,
|
||||
type_filter=None,
|
||||
limit=limit,
|
||||
)
|
||||
notifications = [
|
||||
{
|
||||
"id": str(n.id),
|
||||
"type": str(n.type),
|
||||
"priority": str(n.priority),
|
||||
"subject": n.subject,
|
||||
"body": n.body,
|
||||
"requires_ack": n.requires_ack,
|
||||
"timestamp": n.timestamp.isoformat() if n.timestamp else None,
|
||||
"from_agent": str(n.from_agent) if n.from_agent else None,
|
||||
}
|
||||
for n in items
|
||||
]
|
||||
return Envelope.ok(
|
||||
status="ok",
|
||||
task_id=None,
|
||||
next="continue",
|
||||
evidence={"notifications": notifications, "count": len(notifications)},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def notify_get(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
notification_id: UUID,
|
||||
) -> Envelope:
|
||||
"""Read one notification (also marks it read)."""
|
||||
try:
|
||||
n = await self.notifications.get_for_recipient_and_mark_read(
|
||||
notification_id=notification_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
except Exception:
|
||||
return Envelope.not_found(
|
||||
message=f"notification {notification_id} not found"
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="ok",
|
||||
task_id=None,
|
||||
next="continue",
|
||||
evidence={
|
||||
"id": str(n.id),
|
||||
"type": str(n.type),
|
||||
"priority": str(n.priority),
|
||||
"subject": n.subject,
|
||||
"body": n.body,
|
||||
"requires_ack": n.requires_ack,
|
||||
"from_agent": str(n.from_agent) if n.from_agent else None,
|
||||
},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def notify_ack(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
notification_id: UUID,
|
||||
) -> Envelope:
|
||||
"""Acknowledge a notification.
|
||||
|
||||
Returns ``not_authorized`` if the caller isn't a recipient.
|
||||
"""
|
||||
try:
|
||||
n = await self.notifications.acknowledge(
|
||||
notification_id=notification_id,
|
||||
agent_id=agent_id,
|
||||
ack_type="received",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Envelope.not_authorized(
|
||||
message=str(exc),
|
||||
remediate="only recipients of a notification can ack it",
|
||||
context_briefing={},
|
||||
)
|
||||
if n is None:
|
||||
return Envelope.not_found(
|
||||
message=f"notification {notification_id} not found"
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="acked",
|
||||
task_id=None,
|
||||
next="continue",
|
||||
evidence={"id": str(notification_id), "acked": True},
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
|
||||
def _strip_task_prefix(msg: str) -> str:
|
||||
"""Strip any [task-id] prefix the agent supplied; gateway re-adds canonical."""
|
||||
|
||||
@@ -31,27 +31,45 @@ class RoleConfig:
|
||||
description: str
|
||||
|
||||
|
||||
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack
|
||||
# so `i_am_idle()` doesn't soft-block forever on unread notifications.
|
||||
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack")
|
||||
|
||||
_DEV_FLOW = spec.intents_for_role(spec.Role.DEVELOPER)
|
||||
_DEV_DO = ("commit", "note", "say", "dm", "evidence")
|
||||
_DEV_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
|
||||
|
||||
_QA_FLOW = spec.intents_for_role(spec.Role.QA)
|
||||
_QA_DO = ("note", "say", "dm", "evidence")
|
||||
_QA_DO = ("note", "say", "dm", "evidence", *_NOTIFY_RECEIVER)
|
||||
|
||||
_DOC_FLOW = spec.intents_for_role(spec.Role.DOCUMENTER)
|
||||
_DOC_DO = ("commit", "note", "say", "dm", "evidence")
|
||||
_DOC_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
|
||||
|
||||
_CELL_PM_FLOW = spec.intents_for_role(spec.Role.CELL_PM)
|
||||
_CELL_PM_DO = ("note", "say", "dm", "notify", "evidence")
|
||||
_CELL_PM_DO = (
|
||||
"note", "say", "dm", "notify", "evidence",
|
||||
"open_session", "link_session",
|
||||
*_NOTIFY_RECEIVER,
|
||||
)
|
||||
|
||||
_MAIN_PM_FLOW = spec.intents_for_role(spec.Role.MAIN_PM)
|
||||
_MAIN_PM_DO = ("note", "say", "dm", "notify", "evidence")
|
||||
_MAIN_PM_DO = (
|
||||
"note", "say", "dm", "notify", "evidence",
|
||||
"open_session", "link_session",
|
||||
*_NOTIFY_RECEIVER,
|
||||
)
|
||||
|
||||
_PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER)
|
||||
_HEAD_MARKETING_FLOW = spec.intents_for_role(spec.Role.HEAD_MARKETING)
|
||||
_BOARD_DO = ("note", "say", "dm", "notify", "evidence")
|
||||
_BOARD_DO = (
|
||||
"note", "say", "dm", "notify", "evidence",
|
||||
"open_session", # Board can open strategic sessions but not link arbitrary
|
||||
*_NOTIFY_RECEIVER,
|
||||
)
|
||||
|
||||
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
|
||||
_AUDITOR_DO = ("note", "evidence") # auditor reads, does not chat or escalate
|
||||
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
|
||||
# no ack (silent observer — wouldn't ack notifications).
|
||||
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get")
|
||||
|
||||
|
||||
ROLE_CONFIGS: dict[str, RoleConfig] = {
|
||||
|
||||
Reference in New Issue
Block a user