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:
@@ -16,12 +16,14 @@ from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from roboco.db.tables import (
|
||||
ChannelTable,
|
||||
GroupTable,
|
||||
MessageTable,
|
||||
SessionTable,
|
||||
SessionTaskTable,
|
||||
)
|
||||
from roboco.enforcement import validate_channel_access
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
@@ -35,7 +37,11 @@ from roboco.models.messaging import (
|
||||
MessageCreateRequest,
|
||||
SessionCreateRequest,
|
||||
)
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.models.session import (
|
||||
SessionForTasksCreate,
|
||||
SessionTaskRelationshipType,
|
||||
)
|
||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||
|
||||
# =============================================================================
|
||||
# MESSAGING SERVICE
|
||||
@@ -281,6 +287,7 @@ class MessagingService(BaseService):
|
||||
max_content_length=req.max_content_length,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
status=SessionStatus.ACTIVE,
|
||||
scope=req.scope,
|
||||
)
|
||||
|
||||
self.session.add(session)
|
||||
@@ -386,6 +393,261 @@ class MessagingService(BaseService):
|
||||
# Create new session
|
||||
return await self.create_session(SessionCreateRequest(group_id=group_id))
|
||||
|
||||
# =========================================================================
|
||||
# SESSION-TASK LINKING OPERATIONS
|
||||
# =========================================================================
|
||||
|
||||
async def link_session_to_task(
|
||||
self,
|
||||
session_id: UUID,
|
||||
task_id: UUID,
|
||||
added_by: UUID,
|
||||
is_primary: bool = False,
|
||||
relationship_type: SessionTaskRelationshipType = (
|
||||
SessionTaskRelationshipType.DISCUSSION
|
||||
),
|
||||
) -> SessionTaskTable:
|
||||
"""
|
||||
Link a session to a task.
|
||||
|
||||
Args:
|
||||
session_id: Session to link
|
||||
task_id: Task to link
|
||||
added_by: PM who created this link
|
||||
is_primary: Mark as primary discussion session for this task
|
||||
relationship_type: Type of relationship
|
||||
|
||||
Returns:
|
||||
Created link
|
||||
|
||||
Raises:
|
||||
NotFoundError: If session not found
|
||||
ConflictError: If link already exists or primary constraint violated
|
||||
"""
|
||||
# Verify session exists
|
||||
session = await self.get_session(session_id)
|
||||
if not session:
|
||||
raise NotFoundError(f"Session {session_id} not found")
|
||||
|
||||
# Check if link already exists
|
||||
existing = await self.session.execute(
|
||||
select(SessionTaskTable).where(
|
||||
SessionTaskTable.session_id == session_id,
|
||||
SessionTaskTable.task_id == task_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(
|
||||
f"Session {session_id} is already linked to task {task_id}"
|
||||
)
|
||||
|
||||
# If marking as primary, check if task already has a primary session
|
||||
if is_primary:
|
||||
existing_primary = await self.session.execute(
|
||||
select(SessionTaskTable).where(
|
||||
SessionTaskTable.task_id == task_id,
|
||||
SessionTaskTable.is_primary.is_(True),
|
||||
)
|
||||
)
|
||||
if existing_primary.scalar_one_or_none():
|
||||
raise ConflictError(f"Task {task_id} already has a primary session")
|
||||
|
||||
link = SessionTaskTable(
|
||||
session_id=session_id,
|
||||
task_id=task_id,
|
||||
is_primary=is_primary,
|
||||
relationship_type=relationship_type.value,
|
||||
added_by=added_by,
|
||||
)
|
||||
|
||||
self.session.add(link)
|
||||
await self.session.flush()
|
||||
|
||||
self.log.info(
|
||||
"Session linked to task",
|
||||
session_id=str(session_id),
|
||||
task_id=str(task_id),
|
||||
is_primary=is_primary,
|
||||
relationship_type=relationship_type.value,
|
||||
)
|
||||
return link
|
||||
|
||||
async def unlink_session_from_task(
|
||||
self,
|
||||
session_id: UUID,
|
||||
task_id: UUID,
|
||||
) -> bool:
|
||||
"""
|
||||
Remove a session-task link.
|
||||
|
||||
Args:
|
||||
session_id: Session to unlink
|
||||
task_id: Task to unlink
|
||||
|
||||
Returns:
|
||||
True if link was removed, False if not found
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(SessionTaskTable).where(
|
||||
SessionTaskTable.session_id == session_id,
|
||||
SessionTaskTable.task_id == task_id,
|
||||
)
|
||||
)
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
return False
|
||||
|
||||
await self.session.delete(link)
|
||||
await self.session.flush()
|
||||
|
||||
self.log.info(
|
||||
"Session unlinked from task",
|
||||
session_id=str(session_id),
|
||||
task_id=str(task_id),
|
||||
)
|
||||
return True
|
||||
|
||||
async def get_sessions_for_task(
|
||||
self,
|
||||
task_id: UUID,
|
||||
relationship_type: SessionTaskRelationshipType | None = None,
|
||||
) -> list[SessionTaskTable]:
|
||||
"""
|
||||
Get all sessions linked to a task.
|
||||
|
||||
Args:
|
||||
task_id: Task to get sessions for
|
||||
relationship_type: Filter by relationship type
|
||||
|
||||
Returns:
|
||||
List of session-task links (with session→group→channel loaded)
|
||||
"""
|
||||
query = (
|
||||
select(SessionTaskTable)
|
||||
.where(SessionTaskTable.task_id == task_id)
|
||||
.options(
|
||||
joinedload(SessionTaskTable.session)
|
||||
.joinedload(SessionTable.group)
|
||||
.joinedload(GroupTable.channel)
|
||||
)
|
||||
)
|
||||
|
||||
if relationship_type:
|
||||
query = query.where(
|
||||
SessionTaskTable.relationship_type == relationship_type.value
|
||||
)
|
||||
|
||||
query = query.order_by(SessionTaskTable.added_at.desc())
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().unique().all())
|
||||
|
||||
async def get_primary_session_for_task(
|
||||
self,
|
||||
task_id: UUID,
|
||||
) -> SessionTaskTable | None:
|
||||
"""
|
||||
Get the primary session for a task.
|
||||
|
||||
Args:
|
||||
task_id: Task to get primary session for
|
||||
|
||||
Returns:
|
||||
Primary session-task link, or None if no primary session
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(SessionTaskTable).where(
|
||||
SessionTaskTable.task_id == task_id,
|
||||
SessionTaskTable.is_primary.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_tasks_for_session(
|
||||
self,
|
||||
session_id: UUID,
|
||||
) -> list[SessionTaskTable]:
|
||||
"""
|
||||
Get all tasks linked to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session to get tasks for
|
||||
|
||||
Returns:
|
||||
List of session-task links (with task relationship loaded)
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(SessionTaskTable)
|
||||
.where(SessionTaskTable.session_id == session_id)
|
||||
.order_by(SessionTaskTable.added_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_session_for_tasks(
|
||||
self,
|
||||
req: SessionForTasksCreate,
|
||||
pm_agent_id: UUID,
|
||||
) -> tuple[SessionTable, list[SessionTaskTable]]:
|
||||
"""
|
||||
Create a new session linked to one or more tasks (PM operation).
|
||||
|
||||
This is the main entry point for PMs to create work sessions.
|
||||
|
||||
Args:
|
||||
req: Session creation request with task IDs
|
||||
pm_agent_id: PM agent creating the session
|
||||
|
||||
Returns:
|
||||
Tuple of (created session, list of created links)
|
||||
|
||||
Raises:
|
||||
NotFoundError: If channel not found
|
||||
ValueError: If no groups found in channel
|
||||
"""
|
||||
# Get channel
|
||||
channel = await self.get_channel_by_slug(req.channel_slug)
|
||||
if not channel:
|
||||
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
|
||||
|
||||
# Get first group in channel (or create default)
|
||||
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
|
||||
if not groups:
|
||||
raise ValueError(f"No groups found in channel '{req.channel_slug}'")
|
||||
group = groups[0]
|
||||
|
||||
# Create session with config and scope
|
||||
session_req = SessionCreateRequest(
|
||||
group_id=cast("UUID", group.id),
|
||||
max_message_count=(req.config.max_message_count if req.config else None),
|
||||
max_content_length=(req.config.max_content_length if req.config else None),
|
||||
timeout_seconds=(req.config.timeout_seconds if req.config else 300),
|
||||
scope=req.scope,
|
||||
)
|
||||
session = await self.create_session(session_req)
|
||||
|
||||
# Link all tasks
|
||||
links: list[SessionTaskTable] = []
|
||||
for i, task_id in enumerate(req.task_ids):
|
||||
# First task is primary
|
||||
is_primary = i == 0
|
||||
link = await self.link_session_to_task(
|
||||
session_id=cast("UUID", session.id),
|
||||
task_id=task_id,
|
||||
added_by=pm_agent_id,
|
||||
is_primary=is_primary,
|
||||
relationship_type=req.relationship_type,
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
self.log.info(
|
||||
"Session created for tasks",
|
||||
session_id=str(session.id),
|
||||
task_count=len(req.task_ids),
|
||||
channel_slug=req.channel_slug,
|
||||
pm_agent_id=str(pm_agent_id),
|
||||
)
|
||||
return session, links
|
||||
|
||||
def _check_session_boundaries(self, session: SessionTable) -> bool:
|
||||
"""Check if session has exceeded boundaries. Returns True if should close."""
|
||||
# Check message count
|
||||
|
||||
Reference in New Issue
Block a user