mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
+102
-11
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user