Killed "active" session dependency:

1. roboco/mcp/schemas/__init__.py:
    - SendMessageInput.task_id → required
    - AskQuestionInput.task_id → required
    - ReportBlockerInput.task_id → required
  2. roboco/mcp/message_server.py:
    - Removed _get_active_session() entirely (dead code)
    - Simplified _handle_message_send() - no fallback, just uses task's linked session
    - Updated tool signatures for roboco_ask_question and roboco_report_blocker
  3. roboco/api/routes/tasks.py (earlier fix):
    - Added subtask validation before parent completion

  New behavior:
  - All messages MUST have task_id
  - Task's linked session is the only source of truth
  - No session? → Clear error: "Cell PM must create one"
  - No more "active" status guessing
This commit is contained in:
Renn F
2025-12-23 22:58:07 +01:00
parent 204b959733
commit 06720b1978
9 changed files with 66 additions and 89 deletions
+4
View File
@@ -78,3 +78,7 @@ alembic/versions/*.pyc
.DS_Store .DS_Store
.AppleDouble .AppleDouble
.LSOverride .LSOverride
/docs
/data
/#recycle
+3
View File
@@ -225,6 +225,7 @@ Tell the team what you did:
```json ```json
{ {
"channel_slug": "backend-cell", "channel_slug": "backend-cell",
"task_id": "{task_id}",
"content": "Triaged TASK-XXX. Created 3 subtasks, assigned to BE-Dev-1.", "content": "Triaged TASK-XXX. Created 3 subtasks, assigned to BE-Dev-1.",
"message_type": "action" "message_type": "action"
} }
@@ -317,6 +318,7 @@ roboco_task_claim("TASK-042")
# Announce in channel # Announce in channel
roboco_message_send({ roboco_message_send({
"channel_slug": "backend-cell", "channel_slug": "backend-cell",
"task_id": "TASK-042",
"content": "Triaging TASK-042: Implement rate limiting", "content": "Triaging TASK-042: Implement rate limiting",
"message_type": "action" "message_type": "action"
}) })
@@ -363,6 +365,7 @@ roboco_task_create({
# 8. COMMUNICATE # 8. COMMUNICATE
roboco_message_send({ roboco_message_send({
"channel_slug": "backend-cell", "channel_slug": "backend-cell",
"task_id": "TASK-042",
"content": "TASK-042 triaged. 3 subtasks created, assigned to BE-Dev-1.", "content": "TASK-042 triaged. 3 subtasks created, assigned to BE-Dev-1.",
"message_type": "action" "message_type": "action"
}) })
+2
View File
@@ -161,6 +161,7 @@ roboco_task_qa_pass(task_id, {
```json ```json
{ {
"channel_slug": "backend-cell", "channel_slug": "backend-cell",
"task_id": "{task_id}",
"content": "QA PASS for TASK-XXX. Proceeding to documenter, then PM review.", "content": "QA PASS for TASK-XXX. Proceeding to documenter, then PM review.",
"message_type": "action" "message_type": "action"
} }
@@ -200,6 +201,7 @@ roboco_task_qa_fail(task_id, {
```json ```json
{ {
"channel_slug": "backend-cell", "channel_slug": "backend-cell",
"task_id": "{task_id}",
"content": "QA FAIL for TASK-XXX. Issues: [list]. Returning to dev.", "content": "QA FAIL for TASK-XXX. Issues: [list]. Returning to dev.",
"message_type": "blocker" "message_type": "blocker"
} }
+3
View File
@@ -217,6 +217,7 @@ Tell the team what you did:
```json ```json
{ {
"channel_slug": "frontend-cell", "channel_slug": "frontend-cell",
"task_id": "{task_id}",
"content": "Triaged TASK-XXX. Created 3 subtasks, assigned to FE-Dev-1.", "content": "Triaged TASK-XXX. Created 3 subtasks, assigned to FE-Dev-1.",
"message_type": "action" "message_type": "action"
} }
@@ -325,6 +326,7 @@ roboco_task_scan(team="frontend")
roboco_task_claim("TASK-055") roboco_task_claim("TASK-055")
roboco_message_send({ roboco_message_send({
"channel_slug": "frontend-cell", "channel_slug": "frontend-cell",
"task_id": "TASK-055",
"content": "Triaging TASK-055: User preferences modal", "content": "Triaging TASK-055: User preferences modal",
"message_type": "action" "message_type": "action"
}) })
@@ -363,6 +365,7 @@ roboco_task_assign("TASK-055", "fe-dev-1")
# 8. COMMUNICATE # 8. COMMUNICATE
roboco_message_send({ roboco_message_send({
"channel_slug": "frontend-cell", "channel_slug": "frontend-cell",
"task_id": "TASK-055",
"content": "TASK-055 assigned to FE-Dev-1. Design ready, API available.", "content": "TASK-055 assigned to FE-Dev-1. Design ready, API available.",
"message_type": "action" "message_type": "action"
}) })
+3
View File
@@ -217,6 +217,7 @@ Tell the team what you did:
```json ```json
{ {
"channel_slug": "uxui-cell", "channel_slug": "uxui-cell",
"task_id": "{task_id}",
"content": "Triaged TASK-XXX. Assigned to UX-Dev.", "content": "Triaged TASK-XXX. Assigned to UX-Dev.",
"message_type": "action" "message_type": "action"
} }
@@ -330,6 +331,7 @@ roboco_task_scan(team="ux_ui")
roboco_task_claim("TASK-055") roboco_task_claim("TASK-055")
roboco_message_send({ roboco_message_send({
"channel_slug": "uxui-cell", "channel_slug": "uxui-cell",
"task_id": "TASK-055",
"content": "Triaging TASK-055: User preferences modal design", "content": "Triaging TASK-055: User preferences modal design",
"message_type": "action" "message_type": "action"
}) })
@@ -368,6 +370,7 @@ roboco_task_assign("TASK-055", "ux-dev")
# 8. COMMUNICATE # 8. COMMUNICATE
roboco_message_send({ roboco_message_send({
"channel_slug": "uxui-cell", "channel_slug": "uxui-cell",
"task_id": "TASK-055",
"content": "TASK-055 assigned to UX-Dev. Frontend needs by Friday.", "content": "TASK-055 assigned to UX-Dev. Frontend needs by Friday.",
"message_type": "action" "message_type": "action"
}) })
+7 -7
View File
@@ -39,15 +39,15 @@ services:
retries: 5 retries: 5
# ========================================================================== # ==========================================================================
# Agent Image Builder (pre-builds the agent image at compose time) # Agent Base Image Builder (specialized images built on-demand by orchestrator)
# ========================================================================== # ==========================================================================
agent-image: agent-base-image:
build: build:
context: . context: .
dockerfile: docker/agent.Dockerfile dockerfile: docker/agent-base.Dockerfile
image: roboco-agent image: roboco-agent-base
container_name: roboco-agent-builder container_name: roboco-agent-base-builder
entrypoint: ["/bin/sh", "-c", "echo 'Agent image built successfully'"] entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"]
restart: "no" restart: "no"
# ========================================================================== # ==========================================================================
@@ -92,7 +92,7 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
agent-image: agent-base-image:
condition: service_completed_successfully condition: service_completed_successfully
# Default agents to spawn (override in .env or command line) # Default agents to spawn (override in .env or command line)
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"] # command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
+21
View File
@@ -943,6 +943,27 @@ async def complete_task(
detail="Only PMs can complete tasks", detail="Only PMs can complete tasks",
) )
# Check for incomplete subtasks before completing parent
subtasks = await service.get_subtasks(task_id)
incomplete_subtasks = [
st
for st in subtasks
if st.status not in (TaskStatus.completed, TaskStatus.cancelled)
]
if incomplete_subtasks:
max_titles_shown = 3
incomplete_titles = [st.title for st in incomplete_subtasks[:max_titles_shown]]
detail = (
f"Cannot complete task - {len(incomplete_subtasks)} subtask(s) "
f"still pending: {', '.join(incomplete_titles)}"
)
if len(incomplete_subtasks) > max_titles_shown:
detail += f" (+{len(incomplete_subtasks) - max_titles_shown} more)"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)
task = await service.complete(task_id) task = await service.complete(task_id)
if not task: if not task:
raise HTTPException( raise HTTPException(
+19 -78
View File
@@ -163,55 +163,6 @@ async def _get_default_group(
return str(groups[0]["id"]) return str(groups[0]["id"])
async def _get_active_session(
client: ApiClient,
channel_id: str,
agent_id: str | None = None,
channel_slug: str | None = None,
) -> str | dict[str, Any]:
"""Get active session for channel. Returns session_id or error dict.
NOTE: This function does NOT create sessions. Sessions must be created
by PMs using roboco_session_create_for_tasks. If no active session exists,
returns an error guiding the agent to use task_id or ask their PM.
"""
# First get the default group for this channel
group_result = await _get_default_group(
client, channel_id, agent_id=agent_id, channel_slug=channel_slug
)
if isinstance(group_result, dict):
return group_result # Error response (NO_GROUPS with guidance)
group_id = group_result
# Check if group has an active session
resp = await client.get("/sessions", params={"group_id": group_id, "limit": 10})
if resp.ok:
data = resp.json()
items = data.get("items", [])
# Find an active session
for session in items:
if session.get("status") == "active":
return str(session["id"])
# NO auto-creation! Return helpful error instead
return format_error_response(
"NO_ACTIVE_SESSION",
"No active session in this channel. Sessions are created by PMs for tasks.",
{
"guidance": (
"To send messages, you should:\n"
"1. Include task_id in your message call if working on a task "
"(routes to task's session)\n"
"2. If no task session exists, ask your PM to create one using "
"roboco_session_create_for_tasks\n"
"3. All work communication should happen within task sessions"
),
"channel_id": channel_id,
},
)
# ============================================================================= # =============================================================================
# TOOL IMPLEMENTATIONS # TOOL IMPLEMENTATIONS
# ============================================================================= # =============================================================================
@@ -394,29 +345,23 @@ async def _handle_message_send(
): ):
return validation_error return validation_error
session_id: str | None = None # task_id is required - use task's linked session
routed_to_task_session = False
# 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) 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 (NO auto-creation)
if not session_id: if not session_id:
# Get channel by slug # Task has no linked session - PM setup issue
channel_result = await _get_channel_by_slug(client, data.channel_slug) return format_error_response(
if isinstance(channel_result, dict): "NO_TASK_SESSION",
return channel_result # Error response f"Task {data.task_id} has no linked session.",
channel_id = channel_result {
"guidance": (
session_result = await _get_active_session( "This task doesn't have a work session yet.\n"
client, channel_id, agent_id=agent_id, channel_slug=data.channel_slug "Cell PM must create one using "
"roboco_session_create_for_tasks.\n"
"Escalate to your PM if you need a session for this task."
),
"task_id": data.task_id,
},
) )
if isinstance(session_result, dict):
return session_result # Returns NO_GROUPS/NO_ACTIVE_SESSION with guidance
session_id = session_result
# Resolve mentions (slugs) to UUIDs using shared cache # Resolve mentions (slugs) to UUIDs using shared cache
resolved_mentions: list[str] = [] resolved_mentions: list[str] = []
@@ -444,16 +389,12 @@ async def _handle_message_send(
"SEND_FAILED", "Failed to send message", {"api_error": resp.text} "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 { return {
"status": "sent", "status": "sent",
"message": resp.json(), "message": resp.json(),
"channel": data.channel_slug, "channel": data.channel_slug,
"routed_to_task_session": routed_to_task_session, "task_id": data.task_id,
"guidance": guidance, "guidance": f"Message sent to task {data.task_id}'s session.",
} }
@@ -590,8 +531,8 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
async def roboco_ask_question( async def roboco_ask_question(
channel_slug: str, channel_slug: str,
question: str, question: str,
task_id: str,
context: str | None = None, context: str | None = None,
task_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Ask a question in a channel. Ask a question in a channel.
@@ -601,8 +542,8 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
data = AskQuestionInput( data = AskQuestionInput(
channel_slug=channel_slug, channel_slug=channel_slug,
question=question, question=question,
context=context,
task_id=task_id, task_id=task_id,
context=context,
) )
return await _handle_ask_question(client, agent_id, data) return await _handle_ask_question(client, agent_id, data)
@@ -611,7 +552,7 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
channel_slug: str, channel_slug: str,
blocker_description: str, blocker_description: str,
what_needed: str, what_needed: str,
task_id: str | None = None, task_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Report a blocker in a channel. Report a blocker in a channel.
+3 -3
View File
@@ -111,11 +111,11 @@ class SendMessageInput(BaseModel):
channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')") channel_slug: str = Field(..., description="Channel slug (e.g., 'backend-cell')")
content: str = Field(..., description="Message content") content: str = Field(..., description="Message content")
task_id: str = Field(..., description="Task ID (routes to task's session)")
message_type: str = Field( message_type: str = Field(
default="dialogue", default="dialogue",
description="Type: reasoning, dialogue, decision, action, blocker, technical", description="Type: reasoning, dialogue, decision, action, blocker, technical",
) )
task_id: str | None = Field(default=None, description="Optional related task ID")
reply_to: str | None = Field(default=None, description="Message ID to reply to") reply_to: str | None = Field(default=None, description="Message ID to reply to")
mentions: list[str] = Field(default_factory=list, description="Agents to mention") mentions: list[str] = Field(default_factory=list, description="Agents to mention")
@@ -125,8 +125,8 @@ class AskQuestionInput(BaseModel):
channel_slug: str channel_slug: str
question: str question: str
task_id: str # Required - routes to task's session
context: str | None = None context: str | None = None
task_id: str | None = None
class ReportBlockerInput(BaseModel): class ReportBlockerInput(BaseModel):
@@ -135,7 +135,7 @@ class ReportBlockerInput(BaseModel):
channel_slug: str channel_slug: str
blocker_description: str blocker_description: str
what_needed: str what_needed: str
task_id: str | None = None task_id: str # Required - routes to task's session
# ============================================================================= # =============================================================================