feat(sessions): Session-Task linking with scoped context management

Major feature: Sessions are now linked to tasks with smart routing and context loading, ensuring agents have proper discussion context.

## Session-Task Relationship (Many-to-Many)
  - Added SessionTaskTable junction table linking sessions to tasks
  - Sessions can link to multiple tasks, tasks can have multiple sessions
  - is_primary flag marks the main discussion session for a task
  - relationship_type: discussion, planning, review, retrospective
  - Subtasks auto-inherit parent task's session

## BACKLOG Status + Activation Flow
  - Tasks now created with BACKLOG status (not PENDING)
  - PMs must create session BEFORE activating task
  - roboco_task_activate() transitions BACKLOG → PENDING
  - Prevents race condition where dev starts before session exists
  - Flow: CREATE (backlog) → SESSION → ACTIVATE (pending) → spawn

## Session Scopes
  - SessionScope enum: initiative, cell, task
  - initiative: Cross-cell coordination (Main PM, #dev-all)
  - cell: Cell-specific work (Cell PM default)
  - task: Individual task execution (dev level)
  - Enables future smart context loading by scope

## Message Routing to Task Sessions
  - When task_id provided in roboco_message_send(), routes to task's primary session instead of channel's active session
  - New API endpoint: GET /sessions/for-task/{task_id}
  - TaskResponse now includes linked sessions array

## Dev Session Access
  - New tool: roboco_session_history_for_task(task_id)
  - Devs can now see their task's discussion history
  - Messages tagged with task_id for filtering

## Communication Guidelines
  - Added "When to Post / When NOT to Post" to all 9 agent blueprints
  - Devs/QA/Doc should use task tools for status, journal for reasoning
  - Sessions reserved for coordination that needs response
  - Reduces noise: no "Starting work" or "Made progress" chat messages

Files changed:
  - DB: SessionTaskTable, SessionScope column
  - Services: messaging.py (linking), task.py (activation)
  - MCP: 5 new session tools, message routing update
  - API: session-task endpoints, TaskResponse sessions
  - Blueprints: All 13 updated with session/activation workflow
This commit is contained in:
Renn F
2025-12-22 18:04:31 +01:00
parent 4e06bdb842
commit b7611c24fc
36 changed files with 2118 additions and 92 deletions
+102 -11
View File
@@ -304,27 +304,61 @@ async def _handle_channel_history(
}
async def _get_task_primary_session(client: ApiClient, task_id: str) -> str | None:
"""Get the primary session ID for a task, if one exists."""
resp = await client.get(f"/sessions/for-task/{task_id}")
if not resp.ok:
return None
sessions = resp.json()
if not sessions:
return None
# Find primary session
for session in sessions:
if session.get("is_primary"):
return str(session.get("session_id"))
# Fall back to first session if no primary marked
return str(sessions[0].get("session_id")) if sessions else None
async def _handle_message_send(
client: ApiClient,
agent_id: str,
data: SendMessageInput,
) -> dict[str, Any]:
"""Handle message sending."""
"""Handle message sending.
If task_id is provided, routes to that task's primary session.
Otherwise, routes to the channel's current active session.
"""
if validation_error := _validate_message_send(
agent_id, data.channel_slug, data.content, data.message_type
):
return validation_error
# Get channel by slug
channel_result = await _get_channel_by_slug(client, data.channel_slug)
if isinstance(channel_result, dict):
return channel_result # Error response
channel_id = channel_result
session_id: str | None = None
routed_to_task_session = False
session_result = await _get_or_create_session(client, channel_id)
if isinstance(session_result, dict):
return session_result
session_id = session_result
# If task_id provided, try to route to task's primary session
if data.task_id:
session_id = await _get_task_primary_session(client, data.task_id)
if session_id:
routed_to_task_session = True
# Fall back to channel's active session
if not session_id:
# Get channel by slug
channel_result = await _get_channel_by_slug(client, data.channel_slug)
if isinstance(channel_result, dict):
return channel_result # Error response
channel_id = channel_result
session_result = await _get_or_create_session(client, channel_id)
if isinstance(session_result, dict):
return session_result
session_id = session_result
# Resolve mentions (slugs) to UUIDs using shared cache
resolved_mentions: list[str] = []
@@ -352,11 +386,16 @@ async def _handle_message_send(
"SEND_FAILED", "Failed to send message", {"api_error": resp.text}
)
guidance = "Message sent successfully."
if routed_to_task_session:
guidance = f"Message sent to task {data.task_id}'s session."
return {
"status": "sent",
"message": resp.json(),
"channel": data.channel_slug,
"guidance": "Message sent successfully.",
"routed_to_task_session": routed_to_task_session,
"guidance": guidance,
}
@@ -529,6 +568,58 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
)
return await _handle_report_blocker(client, agent_id, data)
@mcp.tool()
async def roboco_session_history_for_task(
task_id: str,
limit: int = 50,
) -> dict[str, Any]:
"""
Get message history from your task's work session.
Use this to see the discussion context for a task you're working on.
Returns messages from the task's primary session.
Args:
task_id: The task ID to get session history for
limit: Maximum number of messages to return (default 50)
"""
# Get task's primary session
session_id = await _get_task_primary_session(client, task_id)
if not session_id:
return {
"error": "NO_SESSION",
"message": f"Task {task_id} has no linked session.",
"guidance": (
"This task doesn't have a work session yet. "
"The PM should create one before work begins."
),
}
# Get messages from the session
resp = await client.get(
"/messages",
params={"session_id": session_id, "limit": limit},
)
if not resp.ok:
return format_error_response(
"FETCH_FAILED",
"Failed to fetch session history",
{"api_error": resp.text},
)
messages = resp.json()
return {
"task_id": task_id,
"session_id": session_id,
"message_count": len(messages),
"messages": messages,
"guidance": (
"This is the discussion history for your task. "
"Use roboco_message_send with task_id to add to it."
),
}
return mcp
+36
View File
@@ -216,3 +216,39 @@ class TaskPauseInput(BaseModel):
remaining_work: list[str] = Field(
default_factory=list, description="List of remaining sub-tasks"
)
# =============================================================================
# SESSION-TASK SCHEMAS (PM Tools)
# =============================================================================
class SessionCreateForTasksInput(BaseModel):
"""Input for creating a session linked to tasks (PM only)."""
task_ids: list[str] = Field(
..., min_length=1, description="Task IDs to link to the session"
)
channel_slug: str = Field(..., description="Channel where session is created")
scope: str = Field(
default="cell",
description="Scope level: initiative (Main PM), cell (Cell PM), task (dev)",
)
relationship_type: str = Field(
default="discussion",
description="Type: discussion, planning, review, retrospective",
)
class SessionLinkTaskInput(BaseModel):
"""Input for linking a session to a task (PM only)."""
session_id: str = Field(..., description="Session ID to link")
task_id: str = Field(..., description="Task ID to link")
is_primary: bool = Field(
default=False, description="Mark as primary session for this task"
)
relationship_type: str = Field(
default="discussion",
description="Type: discussion, planning, review, retrospective",
)
+138 -1
View File
@@ -23,6 +23,10 @@ Tools:
- roboco_task_assign: Assign task to agent (PM only)
- roboco_task_cancel: Cancel a task (PM/Board only)
- roboco_task_escalate: Escalate task up hierarchy (all agents)
- roboco_session_create_for_tasks: Create work session for tasks (PM only)
- roboco_session_link_task: Link session to task (PM only)
- roboco_session_unlink_task: Unlink session from task (PM only)
- roboco_session_get_for_task: Get sessions for a task (all agents)
"""
from typing import Any
@@ -30,6 +34,8 @@ from typing import Any
from mcp.server.fastmcp import FastMCP
from roboco.mcp.schemas import (
SessionCreateForTasksInput,
SessionLinkTaskInput,
TaskAssignInput,
TaskBlockInput,
TaskCreateInput,
@@ -39,6 +45,11 @@ from roboco.mcp.schemas import (
from roboco.mcp.tasks.handlers import (
handle_agent_idle,
handle_docs_complete,
handle_session_create_for_tasks,
handle_session_get_for_task,
handle_session_link_task,
handle_session_unlink_task,
handle_task_activate,
handle_task_assign,
handle_task_block,
handle_task_cancel,
@@ -61,7 +72,7 @@ from roboco.mcp.tasks.handlers import (
from roboco.mcp.utils import ApiClient
def create_task_mcp_server(agent_id: str) -> FastMCP:
def create_task_mcp_server(agent_id: str) -> FastMCP: # noqa: PLR0915
"""
Create a Task MCP server for a specific agent.
@@ -522,6 +533,132 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
)
return await handle_task_escalate(client, input_data, agent_id)
# =========================================================================
# PM SESSION TOOLS
# =========================================================================
@mcp.tool()
async def roboco_session_create_for_tasks(
data: SessionCreateForTasksInput,
) -> dict[str, Any]:
"""
Create a work session linked to one or more tasks (PM only).
Use this to:
- Create a discussion context for a task or set of related tasks
- Enable assigned agents to communicate about the work
- Set up planning/review sessions for complex tasks
SCOPE LEVELS:
- "initiative": Cross-cell sessions in #dev-all (Main PM only)
- "cell": Cell-specific sessions in team channel (Cell PM default)
- "task": Individual task execution (Developer level)
ENFORCEMENT:
- Only PMs and management can create task-linked sessions
- Cell PMs can only create sessions in their team's channel
Args:
data: SessionCreateForTasksInput with task_ids, channel_slug,
scope (initiative/cell/task), and relationship_type
Returns:
Created session with task links
"""
return await handle_session_create_for_tasks(client, data, agent_id)
@mcp.tool()
async def roboco_session_link_task(
data: SessionLinkTaskInput,
) -> dict[str, Any]:
"""
Link an existing session to a task (PM only).
Use this to:
- Add additional tasks to an existing session
- Link related tasks to the same discussion context
- Mark a session as primary for a specific task
ENFORCEMENT:
- Only PMs and management can link sessions to tasks
- One primary session per task (use is_primary carefully)
Args:
data: SessionLinkTaskInput with session_id, task_id,
optional is_primary and relationship_type
Returns:
Created link confirmation
"""
return await handle_session_link_task(client, data, agent_id)
@mcp.tool()
async def roboco_session_unlink_task(
session_id: str,
task_id: str,
) -> dict[str, Any]:
"""
Remove a task from a session (PM only).
Use this to:
- Remove tasks that are no longer relevant to the session
- Clean up session-task links after task completion
ENFORCEMENT:
- Only PMs and management can unlink sessions from tasks
Args:
session_id: Session ID to unlink from
task_id: Task ID to unlink
Returns:
Unlink confirmation
"""
return await handle_session_unlink_task(client, session_id, task_id, agent_id)
@mcp.tool()
async def roboco_session_get_for_task(task_id: str) -> dict[str, Any]:
"""
Get all sessions linked to a task.
Use this to:
- Find the discussion context for a task you're working on
- Check if a task has a primary session
- See all related sessions (planning, review, etc.)
Args:
task_id: Task ID to query sessions for
Returns:
List of sessions with their relationship types
"""
return await handle_session_get_for_task(client, task_id, agent_id)
@mcp.tool()
async def roboco_task_activate(task_id: str) -> dict[str, Any]:
"""
Activate a task from BACKLOG to PENDING status (PM only).
This is the FINAL STEP in task setup. After creating and assigning
a task, you MUST:
1. Create a session: roboco_session_create_for_tasks()
2. Activate the task: roboco_task_activate()
Only after activation will the orchestrator spawn agents to work on it.
ENFORCEMENT:
- Only PMs and management can activate tasks
- Task must be in BACKLOG status
- Task MUST have at least one linked session
Args:
task_id: The task UUID to activate
Returns:
Activated task with PENDING status
"""
return await handle_task_activate(client, task_id, agent_id)
return mcp
+12
View File
@@ -17,6 +17,7 @@ from roboco.mcp.tasks.handlers.lifecycle import (
handle_task_complete,
)
from roboco.mcp.tasks.handlers.management import (
handle_task_activate,
handle_task_assign,
handle_task_create,
handle_task_escalate,
@@ -28,6 +29,12 @@ from roboco.mcp.tasks.handlers.review import (
handle_task_submit_verification,
)
from roboco.mcp.tasks.handlers.scan import handle_task_get, handle_task_scan
from roboco.mcp.tasks.handlers.sessions import (
handle_session_create_for_tasks,
handle_session_get_for_task,
handle_session_link_task,
handle_session_unlink_task,
)
from roboco.mcp.tasks.handlers.work import (
handle_task_plan,
handle_task_progress,
@@ -37,6 +44,11 @@ from roboco.mcp.tasks.handlers.work import (
__all__ = [
"handle_agent_idle",
"handle_docs_complete",
"handle_session_create_for_tasks",
"handle_session_get_for_task",
"handle_session_link_task",
"handle_session_unlink_task",
"handle_task_activate",
"handle_task_assign",
"handle_task_block",
"handle_task_cancel",
+50 -3
View File
@@ -132,9 +132,7 @@ def _validate_create_permissions(agent_id: str) -> dict[str, Any] | None:
return None
def _validate_cell_pm_team(
agent_id: str, requested_team: str
) -> dict[str, Any] | None:
def _validate_cell_pm_team(agent_id: str, requested_team: str) -> dict[str, Any] | None:
"""Validate Cell PM team restrictions for task creation. Returns error or None."""
role = get_agent_role(agent_id)
agent_team = get_agent_team(agent_id)
@@ -326,3 +324,52 @@ async def handle_task_escalate(
"They will be notified and can reassign or provide guidance."
)
return format_task_response(task, "ESCALATED", guidance)
async def handle_task_activate(
client: ApiClient, task_id: str, agent_id: str
) -> dict[str, Any]:
"""
Handle task activation from BACKLOG to PENDING (PM only).
This is the final step in PM setup. After creating a session and
linking the task, the PM activates it to make it ready for work.
The orchestrator will then spawn agents to claim and work on it.
REQUIRES: Task must have at least one linked session.
"""
if not can_create_tasks(agent_id):
return format_error_response(
"PERMISSION_DENIED",
"Only PMs and management can activate tasks",
{"role": get_agent_role(agent_id)},
)
try:
resp = await client.post(f"/tasks/{task_id}/activate")
except Exception as e:
return format_error_response(
"CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}",
)
if resp.is_status(status.HTTP_404_NOT_FOUND):
return format_error_response("NOT_FOUND", f"Task {task_id} not found")
if resp.is_status(status.HTTP_400_BAD_REQUEST):
detail = resp.json().get("detail", "Activation failed")
return format_error_response("ACTIVATION_FAILED", detail)
if not resp.ok:
return format_error_response(
"ACTIVATION_FAILED",
"Failed to activate task",
{"status_code": resp.status_code, "detail": resp.text},
)
task = resp.json()
guidance = (
"Task activated. Status is now PENDING. "
"Orchestrator will spawn agents to work on it."
)
return format_task_response(task, "ACTIVATED", guidance)
+2 -6
View File
@@ -20,9 +20,7 @@ from roboco.services.task import extract_original_developer
def _has_work_evidence(task: dict[str, Any]) -> bool:
"""Check if task has evidence of work done."""
return bool(
task.get("commits")
or task.get("progress_updates")
or task.get("checkpoints")
task.get("commits") or task.get("progress_updates") or task.get("checkpoints")
)
@@ -186,9 +184,7 @@ async def handle_task_qa_pass(
if error := await _check_self_review(task, agent_id, client):
return error
pass_resp = await client.post(
f"/tasks/{task_id}/pass-qa", json={"notes": qa_notes}
)
pass_resp = await client.post(f"/tasks/{task_id}/pass-qa", json={"notes": qa_notes})
if not pass_resp.ok:
return format_error_response(
"QA_FAILED",
+5 -1
View File
@@ -27,7 +27,11 @@ async def handle_task_scan(
assigned_resp = await client.get("/tasks/my")
assigned_data = assigned_resp.json() if assigned_resp.ok else []
active_statuses = {
"pending", "claimed", "in_progress", "verifying", "needs_revision"
"pending",
"claimed",
"in_progress",
"verifying",
"needs_revision",
}
assigned_tasks = [t for t in assigned_data if t.get("status") in active_statuses]
+260
View File
@@ -0,0 +1,260 @@
"""
Task MCP Server Session Handlers
PM-specific session-task handlers for the Task MCP server.
Enables PMs to create work sessions linked to tasks.
"""
from typing import Any
from fastapi import status
from roboco.agents_config import (
can_create_tasks,
get_agent_role,
get_agent_team,
)
from roboco.mcp.schemas import SessionCreateForTasksInput, SessionLinkTaskInput
from roboco.mcp.utils import ApiClient, format_error_response
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _validate_pm_permissions(agent_id: str) -> dict[str, Any] | None:
"""Validate agent has PM permissions. Returns error or None."""
if not can_create_tasks(agent_id):
return format_error_response(
"PERMISSION_DENIED",
"Only PMs and management can manage session-task links",
{"role": get_agent_role(agent_id)},
)
return None
def _validate_channel_access(agent_id: str, channel_slug: str) -> dict[str, Any] | None:
"""Validate Cell PM channel restrictions. Returns error or None."""
role = get_agent_role(agent_id)
agent_team = get_agent_team(agent_id)
if role != "cell_pm":
return None # Main PM and board can access any channel
# Cell PM channel restrictions
team_channels = {
"backend": ["backend-cell"],
"frontend": ["frontend-cell"],
"ux_ui": ["uxui-cell"],
}
allowed = team_channels.get(agent_team or "", [])
if channel_slug not in allowed:
return format_error_response(
"CHANNEL_ACCESS_DENIED",
"Cell PM can only create sessions in their team channel",
{"channel": channel_slug, "allowed": allowed},
)
return None
def _format_session_response(
session: dict[str, Any],
links: list[dict[str, Any]],
status_code: str,
guidance: str,
) -> dict[str, Any]:
"""Format session response with guidance."""
return {
"status": status_code,
"session": session,
"task_links": links,
"guidance": guidance,
}
# =============================================================================
# SESSION HANDLERS
# =============================================================================
async def handle_session_create_for_tasks(
client: ApiClient,
input_data: SessionCreateForTasksInput,
agent_id: str,
) -> dict[str, Any]:
"""Handle session creation linked to tasks (PM only)."""
if error := _validate_pm_permissions(agent_id):
return error
if error := _validate_channel_access(agent_id, input_data.channel_slug):
return error
payload = {
"task_ids": input_data.task_ids,
"channel_slug": input_data.channel_slug,
"scope": input_data.scope,
"relationship_type": input_data.relationship_type,
}
try:
resp = await client.post("/sessions/for-tasks", json=payload)
except Exception as e:
return format_error_response(
"CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}",
)
if not resp.is_status(status.HTTP_201_CREATED):
return format_error_response(
"CREATE_FAILED",
"Failed to create session for tasks",
{"status_code": resp.status_code, "detail": resp.text},
)
data = resp.json()
session = data.get("session", {})
links = data.get("links", [])
guidance = (
f"Work session created. Session ID: {session.get('id', 'unknown')}. "
f"Linked to {len(links)} task(s). "
f"First task is marked as primary. "
"Assigned agents can now discuss in this session."
)
return _format_session_response(session, links, "CREATED", guidance)
async def handle_session_link_task(
client: ApiClient,
input_data: SessionLinkTaskInput,
agent_id: str,
) -> dict[str, Any]:
"""Handle linking a session to a task (PM only)."""
if error := _validate_pm_permissions(agent_id):
return error
payload = {
"task_id": input_data.task_id,
"is_primary": input_data.is_primary,
"relationship_type": input_data.relationship_type,
}
try:
resp = await client.post(
f"/sessions/{input_data.session_id}/tasks", json=payload
)
except Exception as e:
return format_error_response(
"CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}",
)
if resp.is_status(status.HTTP_409_CONFLICT):
return format_error_response(
"ALREADY_LINKED",
"Session is already linked to this task",
{"session_id": input_data.session_id, "task_id": input_data.task_id},
)
if not resp.ok:
return format_error_response(
"LINK_FAILED",
"Failed to link session to task",
{"status_code": resp.status_code, "detail": resp.text},
)
link = resp.json()
primary_note = " (marked as primary)" if input_data.is_primary else ""
return {
"status": "LINKED",
"link": link,
"guidance": (
f"Session linked to task{primary_note}. "
"Task's assigned agent can now access this session."
),
}
async def handle_session_unlink_task(
client: ApiClient,
session_id: str,
task_id: str,
agent_id: str,
) -> dict[str, Any]:
"""Handle unlinking a session from a task (PM only)."""
if error := _validate_pm_permissions(agent_id):
return error
try:
resp = await client.delete(f"/sessions/{session_id}/tasks/{task_id}")
except Exception as e:
return format_error_response(
"CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}",
)
if resp.is_status(status.HTTP_404_NOT_FOUND):
return format_error_response(
"NOT_FOUND",
"Session-task link not found",
{"session_id": session_id, "task_id": task_id},
)
if not resp.ok:
return format_error_response(
"UNLINK_FAILED",
"Failed to unlink session from task",
{"status_code": resp.status_code, "detail": resp.text},
)
return {
"status": "UNLINKED",
"guidance": "Session unlinked from task. Task agent no longer has access.",
}
async def handle_session_get_for_task(
client: ApiClient,
task_id: str,
_agent_id: str, # Kept for handler signature consistency
) -> dict[str, Any]:
"""Handle getting sessions for a task."""
# Any agent can query sessions for tasks they have access to
try:
resp = await client.get(f"/tasks/{task_id}/sessions")
except Exception as e:
return format_error_response(
"CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}",
)
if resp.is_status(status.HTTP_404_NOT_FOUND):
return format_error_response("NOT_FOUND", f"Task {task_id} not found")
if not resp.ok:
return format_error_response(
"FETCH_FAILED",
"Failed to fetch sessions for task",
{"status_code": resp.status_code, "detail": resp.text},
)
data = resp.json()
sessions = data.get("sessions", [])
primary = next((s for s in sessions if s.get("is_primary")), None)
guidance = f"Found {len(sessions)} session(s) for this task."
if primary:
guidance += f" Primary session: {primary.get('session_id', 'unknown')}."
else:
guidance += " No primary session set."
return {
"status": "OK",
"sessions": sessions,
"primary_session_id": primary.get("session_id") if primary else None,
"guidance": guidance,
}
+3 -3
View File
@@ -16,9 +16,9 @@ from roboco.mcp.tasks.handlers._helpers import (
)
from roboco.mcp.utils import ApiClient, format_error_response
ACTIVE_PROGRESS_STATUSES = frozenset({
"in_progress", "verifying", "awaiting_qa", "awaiting_documentation"
})
ACTIVE_PROGRESS_STATUSES = frozenset(
{"in_progress", "verifying", "awaiting_qa", "awaiting_documentation"}
)
def _format_plan_response(