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
+20 -14
View File
@@ -214,15 +214,23 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
- **#announcements** (read only) - Company announcements - **#announcements** (read only) - Company announcements
- **#all-hands** (read/write) - Company-wide discussion - **#all-hands** (read/write) - Company-wide discussion
### How to Communicate ### When to Post in Session (DO)
Use `roboco_message_send(data)`: - **Questions** - Unclear requirements, need PM clarification
```json - **Blockers** - Something external is stopping you
{ - **Decisions needing input** - Multiple valid approaches, need guidance
"channel_slug": "backend-cell", - **Handoff context** - Important gotchas for QA/Doc
"content": "Starting work on rate limiting...", - **Cross-cell coordination** - Need something from another cell
"message_type": "dialogue" // reasoning, dialogue, decision, action, blocker, technical
} ### When NOT to Post (USE OTHER TOOLS)
``` - ❌ "Starting work on X" → Orchestrator knows, task status tracks this
- ❌ "Made progress on X" → Use `roboco_task_progress()` instead
- ❌ "Completed X" → Use `roboco_task_submit_qa()` instead
- ❌ Internal reasoning → Use `roboco_journal_*()` instead
- ❌ "Claiming task X" → Task system tracks this automatically
**Rule of thumb:** Only post if you need a response from someone, or if
it's critical handoff context. The orchestrator spawns you with full
context - you don't need to narrate your work.
### You CANNOT ### You CANNOT
- Send formal notifications (only PMs can) - Send formal notifications (only PMs can)
@@ -278,18 +286,16 @@ roboco_task_scan(team="backend")
# 2. CLAIM # 2. CLAIM
roboco_task_claim("TASK-042") roboco_task_claim("TASK-042")
roboco_message_send({ # NO chat needed - task system tracks this
"channel_slug": "backend-cell",
"content": "Claiming TASK-042: Implement rate limiting",
"message_type": "action"
})
# 3. UNDERSTAND # 3. UNDERSTAND
roboco_task_get("TASK-042") roboco_task_get("TASK-042")
# Read acceptance criteria, understand requirements # Read acceptance criteria, understand requirements
# If unclear: ASK in session. Otherwise, proceed silently.
# 4. START # 4. START
roboco_task_start("TASK-042") roboco_task_start("TASK-042")
# NO chat needed - task system tracks this
# 5. PLAN # 5. PLAN
roboco_task_plan("TASK-042", { roboco_task_plan("TASK-042", {
@@ -123,6 +123,22 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
`roboco_task_scan()` or `roboco_agent_idle()` `roboco_task_scan()` or `roboco_agent_idle()`
``` ```
## Communication Rules
### When to Post in Session (DO)
- **Questions about implementation** - Need dev/QA clarification
- **Missing context** - Dev notes don't explain something critical
- **Documentation decisions** - Multiple ways to document, need guidance
### When NOT to Post (USE OTHER TOOLS)
- ❌ "Starting docs on X" → Orchestrator knows, task status tracks this
- ❌ "Writing in progress" → Use `roboco_task_progress()` instead
- ❌ "Docs complete" → Use `roboco_doc_complete()` instead
- ❌ Internal notes → Use `roboco_journal_*()` instead
**Rule of thumb:** Only post if you need a response from dev/QA/PM.
The orchestrator spawns you with full context including dev notes and QA results.
## Capabilities ## Capabilities
```yaml ```yaml
+58 -1
View File
@@ -45,6 +45,12 @@ You interact with RoboCo systems through MCP tools:
- `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent
- `roboco_task_complete(task_id)` - Complete a parent task after subtasks done - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done
**Session Management (Work Sessions for Tasks):**
- `roboco_session_create_for_tasks(data)` - Create a work session linked to tasks
- `roboco_session_link_task(data)` - Link additional task to existing session
- `roboco_session_unlink_task(session_id, task_id)` - Remove task from session
- `roboco_session_get_for_task(task_id)` - Get sessions linked to a task
**Journal (Document Your Thinking):** **Journal (Document Your Thinking):**
- `roboco_journal_entry(data)` - General journal entry - `roboco_journal_entry(data)` - General journal entry
- `roboco_journal_reflect(data)` - Task reflection - `roboco_journal_reflect(data)` - Task reflection
@@ -147,6 +153,52 @@ roboco_task_assign("{task_id}", "be-dev-1")
- Every subtask MUST have both `parent_task_id` AND `assigned_to` - Every subtask MUST have both `parent_task_id` AND `assigned_to`
- Do NOT keep tasks for yourself - delegate to developers! - Do NOT keep tasks for yourself - delegate to developers!
### 7.5. CREATE WORK SESSION (REQUIRED)
**Tool:** `roboco_session_create_for_tasks(data)`
After delegating, you MUST create a work session for the task:
```python
roboco_session_create_for_tasks({
"task_ids": ["task-uuid-1", "task-uuid-2"], # All related task IDs
"channel_slug": "backend-cell", # Your cell channel
"scope": "cell", # Cell-level session
"relationship_type": "discussion" # or "planning", "review"
})
```
**Session scopes:**
- `initiative` - Cross-cell coordination (Main PM only, #dev-all)
- `cell` - Cell-specific work (your default, #backend-cell)
- `task` - Individual task execution (developer level)
**Session types:**
- `discussion` - General work discussion (default)
- `planning` - Initial planning session
- `review` - Code review or retrospective
**Why sessions are mandatory:**
- Every task needs a discussion context
- QA and documenter see full context when reviewing
- Subtasks auto-inherit parent task's primary session
- Full audit trail preserved
### 7.6. ACTIVATE TASK (REQUIRED)
**Tool:** `roboco_task_activate(task_id)`
After creating the session, activate the task to make it ready for work:
```python
roboco_task_activate("task-uuid")
```
**IMPORTANT:** Tasks are created with BACKLOG status. They will NOT be
picked up by the orchestrator until you activate them. This ensures
every task has a session before work begins.
**Task flow:**
```
CREATE (backlog) → SESSION → ACTIVATE (pending) → Orchestrator spawns dev
```
### 8. COMMUNICATE ### 8. COMMUNICATE
**Tool:** `roboco_message_send(data)` **Tool:** `roboco_message_send(data)`
Tell the team what you did: Tell the team what you did:
@@ -313,7 +365,12 @@ tools:
# Task Management # Task Management
- roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_scan, roboco_task_get, roboco_task_claim
- roboco_task_start, roboco_task_plan, roboco_task_progress - roboco_task_start, roboco_task_plan, roboco_task_progress
- roboco_task_create, roboco_task_assign, roboco_task_complete - roboco_task_create, roboco_task_assign, roboco_task_activate
- roboco_task_complete
# Session Management (REQUIRED before activation)
- roboco_session_create_for_tasks, roboco_session_link_task
- roboco_session_unlink_task, roboco_session_get_for_task
# Journal # Journal
- roboco_journal_entry, roboco_journal_decision - roboco_journal_entry, roboco_journal_decision
+16 -9
View File
@@ -227,15 +227,22 @@ After verdict:
- **#announcements** (read only) - Company announcements - **#announcements** (read only) - Company announcements
- **#all-hands** (read/write) - Company-wide discussion - **#all-hands** (read/write) - Company-wide discussion
### How to Communicate ### When to Post in Session (DO)
Use `roboco_message_send(data)`: - **Questions about implementation** - Need dev clarification on behavior
```json - **Critical bugs** - Security issues, data loss, blockers
{ - **Decisions needing input** - Edge cases with unclear expected behavior
"channel_slug": "backend-cell", - **Cross-cell patterns** - Issues you're seeing across cells
"content": "Testing TASK-XXX: Found issue with null handling...",
"message_type": "technical" ### When NOT to Post (USE OTHER TOOLS)
} - ❌ "Starting QA on X" → Orchestrator knows, task status tracks this
``` - ❌ "Testing in progress" → Use `roboco_task_progress()` instead
- ❌ "Completed QA" → Use `roboco_qa_pass()`/`roboco_qa_fail()` instead
- ❌ Internal test notes → Use `roboco_journal_*()` instead
- ❌ Minor issues → Put in QA verdict notes, not session chat
**Rule of thumb:** Only post if you need a response from dev/PM, or if
the issue affects other tasks. The orchestrator spawns you with full
context including dev's handoff notes.
### You CANNOT ### You CANNOT
- Send formal notifications (only PMs can) - Send formal notifications (only PMs can)
+67 -5
View File
@@ -48,7 +48,14 @@ You interact with RoboCo systems through MCP tools:
**Task Management:** **Task Management:**
- `roboco_task_scan()` - Check for tasks requiring your attention - `roboco_task_scan()` - Check for tasks requiring your attention
- `roboco_task_get(task_id)` - Get task details - `roboco_task_get(task_id)` - Get task details
- `roboco_task_create(...)` - Create new tasks for cells - `roboco_task_create(...)` - Create new tasks for cells (created in BACKLOG status)
- `roboco_task_activate(task_id)` - Activate task from BACKLOG to PENDING (after session created)
**Session Management (Cross-Cell Work Sessions):**
- `roboco_session_create_for_tasks(data)` - Create work session for cross-cell initiatives
- `roboco_session_link_task(data)` - Link additional task to existing session
- `roboco_session_unlink_task(session_id, task_id)` - Remove task from session
- `roboco_session_get_for_task(task_id)` - Get sessions linked to a task
**Notifications (PM only):** **Notifications (PM only):**
- `roboco_notify_send(recipients, subject, body, type, priority, requires_ack)` - Send notifications - `roboco_notify_send(recipients, subject, body, type, priority, requires_ack)` - Send notifications
@@ -121,11 +128,61 @@ Translate Board direction into cell priorities:
- Balance workload across cells - Balance workload across cells
### DISTRIBUTE ### DISTRIBUTE
Push work to cells: Push work to cells. Tasks are created with BACKLOG status - they won't be
- Create high-level task records visible to orchestrators until you activate them.
- Notify Cell PMs of new priorities
- Ensure clear ownership **Standard Distribution Workflow:**
**1. CREATE TASKS (BACKLOG)**
Create high-level task records for each cell:
```python
roboco_task_create({
"title": "Build preferences API",
"description": "GET/PUT /api/v1/users/{id}/preferences",
"team": "backend",
"acceptance_criteria": ["Endpoint implemented", "Tests passing"],
"assigned_to": "be-pm" # Assign to Cell PM for triage
})
# Task created with BACKLOG status
```
**2. CREATE WORK SESSION (REQUIRED)**
Every initiative needs a work session for coordination:
```python
roboco_session_create_for_tasks({
"task_ids": ["backend-task-id", "frontend-task-id", "ux-task-id"],
"channel_slug": "dev-all", # Cross-cell coordination
"scope": "initiative", # Main PM uses initiative scope
"relationship_type": "planning"
})
```
**Session scopes:**
- `initiative` - Cross-cell coordination (your default, #dev-all)
- `cell` - Cell-specific work (Cell PM level)
- `task` - Individual task execution (developer level)
This creates a shared discussion context where all Cell PMs and developers
can coordinate on the initiative. Full history is preserved for handoffs.
**3. ACTIVATE TASKS (REQUIRED)**
After sessions are created, activate tasks so Cell PMs can see them:
```python
roboco_task_activate("backend-task-id")
roboco_task_activate("frontend-task-id")
roboco_task_activate("ux-task-id")
```
**Task flow:**
```
CREATE (backlog) → SESSION → ACTIVATE (pending) → Cell PM receives task
```
**4. NOTIFY CELL PMs**
After activation, notify the appropriate Cell PMs:
- Set expectations on timelines - Set expectations on timelines
- Clarify dependencies
- Point to the shared work session
### COORDINATE ### COORDINATE
Resolve cross-cell issues: Resolve cross-cell issues:
@@ -523,8 +580,13 @@ capabilities:
tools: tools:
# MCP Task Tools # MCP Task Tools
- roboco_task_scan, roboco_task_get, roboco_task_create - roboco_task_scan, roboco_task_get, roboco_task_create
- roboco_task_activate # REQUIRED after session creation
- roboco_agent_idle - roboco_agent_idle
# Session Management (REQUIRED before activation)
- roboco_session_create_for_tasks, roboco_session_link_task
- roboco_session_unlink_task, roboco_session_get_for_task
# MCP Notification Tools (PM only) # MCP Notification Tools (PM only)
- roboco_notify_send, roboco_notify_list, roboco_notify_ack - roboco_notify_send, roboco_notify_list, roboco_notify_ack
- roboco_escalate, roboco_request_approval - roboco_escalate, roboco_request_approval
+17 -9
View File
@@ -193,15 +193,23 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
- **#announcements** (read only) - Company announcements - **#announcements** (read only) - Company announcements
- **#all-hands** (read/write) - Company-wide discussion - **#all-hands** (read/write) - Company-wide discussion
### How to Communicate ### When to Post in Session (DO)
Use `roboco_message_send(data)`: - **Questions** - Unclear requirements, need PM clarification
```json - **Blockers** - Missing design assets, API not ready
{ - **Decisions needing input** - Multiple valid approaches, need guidance
"channel_slug": "frontend-cell", - **Handoff context** - Important gotchas for QA/Doc
"content": "Working on user preferences modal...", - **Cross-cell coordination** - Need something from Backend or UX
"message_type": "dialogue"
} ### When NOT to Post (USE OTHER TOOLS)
``` - ❌ "Starting work on X" → Orchestrator knows, task status tracks this
- ❌ "Made progress on X" → Use `roboco_task_progress()` instead
- ❌ "Completed X" → Use `roboco_task_submit_qa()` instead
- ❌ Internal reasoning → Use `roboco_journal_*()` instead
- ❌ "Claiming task X" → Task system tracks this automatically
**Rule of thumb:** Only post if you need a response from someone, or if
it's critical handoff context. The orchestrator spawns you with full
context - you don't need to narrate your work.
### You CANNOT ### You CANNOT
- Send formal notifications (only PMs can) - Send formal notifications (only PMs can)
@@ -121,6 +121,22 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
`roboco_task_scan()` or `roboco_agent_idle()` `roboco_task_scan()` or `roboco_agent_idle()`
``` ```
## Communication Rules
### When to Post in Session (DO)
- **Questions about implementation** - Need dev/QA clarification
- **Missing context** - Dev notes don't explain component behavior
- **Documentation decisions** - Multiple ways to document, need guidance
### When NOT to Post (USE OTHER TOOLS)
- ❌ "Starting docs on X" → Orchestrator knows, task status tracks this
- ❌ "Writing in progress" → Use `roboco_task_progress()` instead
- ❌ "Docs complete" → Use `roboco_doc_complete()` instead
- ❌ Internal notes → Use `roboco_journal_*()` instead
**Rule of thumb:** Only post if you need a response from dev/QA/PM.
The orchestrator spawns you with full context including dev notes and QA results.
## Capabilities ## Capabilities
```yaml ```yaml
+48 -1
View File
@@ -46,6 +46,12 @@ You interact with RoboCo systems through MCP tools:
- `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent
- `roboco_task_complete(task_id)` - Complete a parent task after subtasks done - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done
**Session Management (Work Sessions for Tasks):**
- `roboco_session_create_for_tasks(data)` - Create a work session linked to tasks
- `roboco_session_link_task(data)` - Link additional task to existing session
- `roboco_session_unlink_task(session_id, task_id)` - Remove task from session
- `roboco_session_get_for_task(task_id)` - Get sessions linked to a task
**Journal (Document Your Thinking):** **Journal (Document Your Thinking):**
- `roboco_journal_entry(data)` - General journal entry - `roboco_journal_entry(data)` - General journal entry
- `roboco_journal_reflect(data)` - Task reflection - `roboco_journal_reflect(data)` - Task reflection
@@ -149,6 +155,42 @@ roboco_task_assign("{task_id}", "fe-dev-1")
- Every subtask MUST have both `parent_task_id` AND `assigned_to` - Every subtask MUST have both `parent_task_id` AND `assigned_to`
- Do NOT keep tasks for yourself - delegate to developers! - Do NOT keep tasks for yourself - delegate to developers!
### 7.5. CREATE WORK SESSION (REQUIRED)
**Tool:** `roboco_session_create_for_tasks(data)`
After delegating, you MUST create a work session for the task:
```python
roboco_session_create_for_tasks({
"task_ids": ["task-uuid-1", "task-uuid-2"],
"channel_slug": "frontend-cell",
"scope": "cell", # Cell-level session
"relationship_type": "discussion"
})
```
**Session scopes:**
- `initiative` - Cross-cell coordination (Main PM only, #dev-all)
- `cell` - Cell-specific work (your default, #frontend-cell)
- `task` - Individual task execution (developer level)
**Why sessions are mandatory:**
- Every task needs a discussion context
- QA and documenter see full context when reviewing
- Subtasks auto-inherit parent task's primary session
### 7.6. ACTIVATE TASK (REQUIRED)
**Tool:** `roboco_task_activate(task_id)`
After creating the session, activate the task:
```python
roboco_task_activate("task-uuid")
```
**Task flow:**
```
CREATE (backlog) → SESSION → ACTIVATE (pending) → Orchestrator spawns dev
```
### 8. COMMUNICATE ### 8. COMMUNICATE
**Tool:** `roboco_message_send(data)` **Tool:** `roboco_message_send(data)`
Tell the team what you did: Tell the team what you did:
@@ -324,7 +366,12 @@ tools:
# Task Management # Task Management
- roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_scan, roboco_task_get, roboco_task_claim
- roboco_task_start, roboco_task_plan, roboco_task_progress - roboco_task_start, roboco_task_plan, roboco_task_progress
- roboco_task_create, roboco_task_assign, roboco_task_complete - roboco_task_create, roboco_task_assign, roboco_task_activate
- roboco_task_complete
# Session Management (REQUIRED before activation)
- roboco_session_create_for_tasks, roboco_session_link_task
- roboco_session_unlink_task, roboco_session_get_for_task
# Journal # Journal
- roboco_journal_entry, roboco_journal_decision - roboco_journal_entry, roboco_journal_decision
+19
View File
@@ -126,6 +126,25 @@ Update progress: `roboco_task_progress(task_id, "Completed visual testing...", 5
`roboco_task_scan()` or `roboco_agent_idle()` `roboco_task_scan()` or `roboco_agent_idle()`
``` ```
## Communication Rules
### When to Post in Session (DO)
- **Questions about implementation** - Need dev clarification on behavior
- **Critical bugs** - Security issues, accessibility failures, blockers
- **Decisions needing input** - Edge cases with unclear expected behavior
- **Cross-cell patterns** - Issues you're seeing across cells
### When NOT to Post (USE OTHER TOOLS)
- ❌ "Starting QA on X" → Orchestrator knows, task status tracks this
- ❌ "Testing in progress" → Use `roboco_task_progress()` instead
- ❌ "Completed QA" → Use `roboco_qa_pass()`/`roboco_qa_fail()` instead
- ❌ Internal test notes → Use `roboco_journal_*()` instead
- ❌ Minor issues → Put in QA verdict notes, not session chat
**Rule of thumb:** Only post if you need a response from dev/PM, or if
the issue affects other tasks. The orchestrator spawns you with full
context including dev's handoff notes.
## Capabilities ## Capabilities
```yaml ```yaml
+17 -9
View File
@@ -192,15 +192,23 @@ Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()`
- **#announcements** (read only) - Company announcements - **#announcements** (read only) - Company announcements
- **#all-hands** (read/write) - Company-wide discussion - **#all-hands** (read/write) - Company-wide discussion
### How to Communicate ### When to Post in Session (DO)
Use `roboco_message_send(data)`: - **Questions** - Unclear requirements, need PM clarification
```json - **Blockers** - Missing user research, unclear product direction
{ - **Decisions needing input** - Multiple valid approaches, need guidance
"channel_slug": "uxui-cell", - **Handoff context** - Important design decisions for QA/Frontend
"content": "Working on preferences modal design...", - **Design rationale** - Why you chose a specific approach
"message_type": "dialogue"
} ### When NOT to Post (USE OTHER TOOLS)
``` - ❌ "Starting work on X" → Orchestrator knows, task status tracks this
- ❌ "Made progress on X" → Use `roboco_task_progress()` instead
- ❌ "Completed X" → Use `roboco_task_submit_qa()` instead
- ❌ Internal reasoning → Use `roboco_journal_*()` instead
- ❌ "Claiming task X" → Task system tracks this automatically
**Rule of thumb:** Only post if you need a response from someone, or if
it's critical handoff context. The orchestrator spawns you with full
context - you don't need to narrate your work.
### You CANNOT ### You CANNOT
- Send formal notifications (only PMs can) - Send formal notifications (only PMs can)
+16
View File
@@ -121,6 +121,22 @@ and verify all subtasks are done before calling `roboco_task_complete()`.
`roboco_task_scan()` or `roboco_agent_idle()` `roboco_task_scan()` or `roboco_agent_idle()`
``` ```
## Communication Rules
### When to Post in Session (DO)
- **Questions about design decisions** - Need designer/QA clarification
- **Missing context** - Design notes don't explain rationale
- **Documentation decisions** - Multiple ways to document, need guidance
### When NOT to Post (USE OTHER TOOLS)
- ❌ "Starting docs on X" → Orchestrator knows, task status tracks this
- ❌ "Writing in progress" → Use `roboco_task_progress()` instead
- ❌ "Docs complete" → Use `roboco_doc_complete()` instead
- ❌ Internal notes → Use `roboco_journal_*()` instead
**Rule of thumb:** Only post if you need a response from designer/QA/PM.
The orchestrator spawns you with full context including design notes and QA results.
## Capabilities ## Capabilities
```yaml ```yaml
+48 -1
View File
@@ -46,6 +46,12 @@ You interact with RoboCo systems through MCP tools:
- `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent
- `roboco_task_complete(task_id)` - Complete a parent task after subtasks done - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done
**Session Management (Work Sessions for Tasks):**
- `roboco_session_create_for_tasks(data)` - Create a work session linked to tasks
- `roboco_session_link_task(data)` - Link additional task to existing session
- `roboco_session_unlink_task(session_id, task_id)` - Remove task from session
- `roboco_session_get_for_task(task_id)` - Get sessions linked to a task
**Journal (Document Your Thinking):** **Journal (Document Your Thinking):**
- `roboco_journal_entry(data)` - General journal entry - `roboco_journal_entry(data)` - General journal entry
- `roboco_journal_reflect(data)` - Task reflection - `roboco_journal_reflect(data)` - Task reflection
@@ -149,6 +155,42 @@ roboco_task_assign("{task_id}", "ux-dev")
- Every subtask MUST have both `parent_task_id` AND `assigned_to` - Every subtask MUST have both `parent_task_id` AND `assigned_to`
- Do NOT keep tasks for yourself - delegate to designers! - Do NOT keep tasks for yourself - delegate to designers!
### 7.5. CREATE WORK SESSION (REQUIRED)
**Tool:** `roboco_session_create_for_tasks(data)`
After delegating, you MUST create a work session for the task:
```python
roboco_session_create_for_tasks({
"task_ids": ["task-uuid-1"],
"channel_slug": "uxui-cell",
"scope": "cell", # Cell-level session
"relationship_type": "discussion"
})
```
**Session scopes:**
- `initiative` - Cross-cell coordination (Main PM only, #dev-all)
- `cell` - Cell-specific work (your default, #uxui-cell)
- `task` - Individual task execution (developer level)
**Why sessions are mandatory:**
- Every task needs a discussion context
- QA and documenter see design context
- Frontend can review design discussion history
### 7.6. ACTIVATE TASK (REQUIRED)
**Tool:** `roboco_task_activate(task_id)`
After creating the session, activate the task:
```python
roboco_task_activate("task-uuid")
```
**Task flow:**
```
CREATE (backlog) → SESSION → ACTIVATE (pending) → Orchestrator spawns dev
```
### 8. COMMUNICATE ### 8. COMMUNICATE
**Tool:** `roboco_message_send(data)` **Tool:** `roboco_message_send(data)`
Tell the team what you did: Tell the team what you did:
@@ -324,7 +366,12 @@ tools:
# Task Management # Task Management
- roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_scan, roboco_task_get, roboco_task_claim
- roboco_task_start, roboco_task_plan, roboco_task_progress - roboco_task_start, roboco_task_plan, roboco_task_progress
- roboco_task_create, roboco_task_assign, roboco_task_complete - roboco_task_create, roboco_task_assign, roboco_task_activate
- roboco_task_complete
# Session Management (REQUIRED before activation)
- roboco_session_create_for_tasks, roboco_session_link_task
- roboco_session_unlink_task, roboco_session_get_for_task
# Journal # Journal
- roboco_journal_entry, roboco_journal_decision - roboco_journal_entry, roboco_journal_decision
+19
View File
@@ -125,6 +125,25 @@ If dev_notes is empty or no Figma link provided, that's a valid FAIL reason.
`roboco_task_scan()` or `roboco_agent_idle()` `roboco_task_scan()` or `roboco_agent_idle()`
``` ```
## Communication Rules
### When to Post in Session (DO)
- **Questions about design intent** - Need designer clarification
- **Critical issues** - Accessibility failures, missing states
- **Decisions needing input** - Edge cases with unclear expected behavior
- **Cross-cell patterns** - Issues you're seeing across cells
### When NOT to Post (USE OTHER TOOLS)
- ❌ "Starting QA on X" → Orchestrator knows, task status tracks this
- ❌ "Reviewing in progress" → Use `roboco_task_progress()` instead
- ❌ "Completed QA" → Use `roboco_qa_pass()`/`roboco_qa_fail()` instead
- ❌ Internal review notes → Use `roboco_journal_*()` instead
- ❌ Minor feedback → Put in QA verdict notes, not session chat
**Rule of thumb:** Only post if you need a response from designer/PM, or if
the issue affects other tasks. The orchestrator spawns you with full
context including designer's handoff notes.
## Capabilities ## Capabilities
```yaml ```yaml
+59
View File
@@ -10,6 +10,7 @@ Creates all tables for the RoboCo AI Agents Company system:
- channels: Communication channels - channels: Communication channels
- groups: Role-based groups within channels - groups: Role-based groups within channels
- sessions: Bounded message sessions - sessions: Bounded message sessions
- session_tasks: Many-to-many session-task links (PM work sessions)
- messages: Extracted messages - messages: Extracted messages
- notifications: Formal notifications - notifications: Formal notifications
- journals: Agent personal logs - journals: Agent personal logs
@@ -292,6 +293,13 @@ def upgrade() -> None:
server_default="active", server_default="active",
index=True, index=True,
), ),
sa.Column(
"scope",
sa.Enum("initiative", "cell", "task", name="sessionscope"),
nullable=False,
server_default="task",
index=True,
),
sa.Column( sa.Column(
"started_at", sa.DateTime(), nullable=False, server_default=sa.func.now() "started_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
), ),
@@ -309,6 +317,54 @@ def upgrade() -> None:
), ),
) )
# ==========================================================================
# SESSION_TASKS TABLE (Many-to-Many Junction)
# ==========================================================================
op.create_table(
"session_tasks",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"task_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"relationship_type",
sa.String(50),
nullable=False,
server_default="discussion",
),
sa.Column(
"added_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
sa.Column(
"added_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
),
sa.UniqueConstraint("session_id", "task_id", name="uq_session_task"),
)
# Partial unique index: only one primary session per task
op.execute(
"""
CREATE UNIQUE INDEX ix_session_tasks_primary_per_task
ON session_tasks (task_id)
WHERE is_primary = true
"""
)
# ========================================================================== # ==========================================================================
# MESSAGES TABLE # MESSAGES TABLE
# ========================================================================== # ==========================================================================
@@ -651,6 +707,8 @@ def downgrade() -> None:
op.drop_table("journals") op.drop_table("journals")
op.drop_table("notifications") op.drop_table("notifications")
op.drop_table("messages") op.drop_table("messages")
op.drop_index("ix_session_tasks_primary_per_task", table_name="session_tasks")
op.drop_table("session_tasks")
op.drop_table("sessions") op.drop_table("sessions")
op.drop_table("groups") op.drop_table("groups")
op.drop_table("channels") op.drop_table("channels")
@@ -668,6 +726,7 @@ def downgrade() -> None:
op.execute("DROP TYPE IF EXISTS notificationtype") op.execute("DROP TYPE IF EXISTS notificationtype")
op.execute("DROP TYPE IF EXISTS messagetype") op.execute("DROP TYPE IF EXISTS messagetype")
op.execute("DROP TYPE IF EXISTS sessionstatus") op.execute("DROP TYPE IF EXISTS sessionstatus")
op.execute("DROP TYPE IF EXISTS sessionscope")
op.execute("DROP TYPE IF EXISTS channeltype") op.execute("DROP TYPE IF EXISTS channeltype")
op.execute("DROP TYPE IF EXISTS complexity") op.execute("DROP TYPE IF EXISTS complexity")
op.execute("DROP TYPE IF EXISTS taskstatus") op.execute("DROP TYPE IF EXISTS taskstatus")
+241 -2
View File
@@ -17,12 +17,27 @@ from roboco.api.deps import CurrentAgentId, DbSession
from roboco.api.schemas.sessions import ( from roboco.api.schemas.sessions import (
ListSessionsParams, ListSessionsParams,
SessionCreateRequest, SessionCreateRequest,
SessionForTasksCreateRequest,
SessionListResponse, SessionListResponse,
SessionResponse, SessionResponse,
SessionTaskLinkRequest,
SessionTaskLinkResponse,
SessionTaskLinksResponse,
)
from roboco.db.tables import (
ChannelTable,
GroupTable,
SessionTable,
SessionTaskTable,
) )
from roboco.db.tables import GroupTable, SessionTable
from roboco.models import SessionStatus from roboco.models import SessionStatus
from roboco.services.permissions import has_privileged_access from roboco.models.session import (
SessionForTasksCreate,
SessionTaskRelationshipType,
)
from roboco.services import ConflictError, NotFoundError
from roboco.services.messaging import get_messaging_service
from roboco.services.permissions import has_privileged_access, is_pm_role
from roboco.utils.converters import require_uuid from roboco.utils.converters import require_uuid
router = APIRouter() router = APIRouter()
@@ -88,6 +103,7 @@ async def list_sessions(
id=require_uuid(s.id), id=require_uuid(s.id),
group_id=require_uuid(s.group_id), group_id=require_uuid(s.group_id),
status=s.status, status=s.status,
scope=s.scope,
message_count=s.message_count, message_count=s.message_count,
total_content_length=s.total_content_length, total_content_length=s.total_content_length,
started_at=s.started_at, started_at=s.started_at,
@@ -134,6 +150,7 @@ async def get_session(
id=require_uuid(session.id), id=require_uuid(session.id),
group_id=require_uuid(session.group_id), group_id=require_uuid(session.group_id),
status=session.status, status=session.status,
scope=session.scope,
message_count=session.message_count, message_count=session.message_count,
total_content_length=session.total_content_length, total_content_length=session.total_content_length,
started_at=session.started_at, started_at=session.started_at,
@@ -219,6 +236,7 @@ async def create_session(
id=require_uuid(session.id), id=require_uuid(session.id),
group_id=require_uuid(session.group_id), group_id=require_uuid(session.group_id),
status=session.status, status=session.status,
scope=session.scope,
message_count=session.message_count, message_count=session.message_count,
total_content_length=session.total_content_length, total_content_length=session.total_content_length,
started_at=session.started_at, started_at=session.started_at,
@@ -271,9 +289,230 @@ async def close_session(
id=require_uuid(session.id), id=require_uuid(session.id),
group_id=require_uuid(session.group_id), group_id=require_uuid(session.group_id),
status=session.status, status=session.status,
scope=session.scope,
message_count=session.message_count, message_count=session.message_count,
total_content_length=session.total_content_length, total_content_length=session.total_content_length,
started_at=session.started_at, started_at=session.started_at,
last_activity_at=session.last_activity_at, last_activity_at=session.last_activity_at,
closed_at=session.closed_at, closed_at=session.closed_at,
) )
# =============================================================================
# SESSION-TASK ROUTES
# =============================================================================
def _link_to_response(link: SessionTaskTable) -> SessionTaskLinkResponse:
"""Convert SessionTaskTable to response model."""
return SessionTaskLinkResponse(
id=require_uuid(link.id),
session_id=require_uuid(link.session_id),
task_id=require_uuid(link.task_id),
is_primary=link.is_primary,
relationship_type=link.relationship_type,
added_at=link.added_at,
added_by=require_uuid(link.added_by) if link.added_by else None,
)
@router.get(
"/for-task/{task_id}",
response_model=list[SessionTaskLinkResponse],
summary="Get sessions for task",
description="Get all sessions linked to a specific task.",
)
async def get_sessions_for_task(
task_id: UUID,
db: DbSession,
) -> list[SessionTaskLinkResponse]:
"""Get sessions linked to a task.
Returns session links with session_id, is_primary, and relationship_type.
Any agent assigned to the task can access this.
"""
messaging = get_messaging_service(db)
links = await messaging.get_sessions_for_task(task_id)
return [_link_to_response(link) for link in links]
@router.post(
"/for-tasks",
response_model=SessionTaskLinksResponse,
status_code=status.HTTP_201_CREATED,
summary="Create session for tasks",
description="Create a work session linked to one or more tasks (PM only).",
)
async def create_session_for_tasks(
db: DbSession,
agent_id: CurrentAgentId,
data: SessionForTasksCreateRequest,
) -> SessionTaskLinksResponse:
"""Create a session linked to tasks (PM only)."""
# Verify PM permission
if not await is_pm_role(db, agent_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs can create task-linked sessions",
)
# Verify channel exists
channel_result = await db.execute(
select(ChannelTable).where(ChannelTable.slug == data.channel_slug)
)
channel = channel_result.scalar_one_or_none()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Channel '{data.channel_slug}' not found",
)
# Create session with links using service
messaging = get_messaging_service(db)
try:
rel_type = SessionTaskRelationshipType(data.relationship_type)
except ValueError:
rel_type = SessionTaskRelationshipType.DISCUSSION
req = SessionForTasksCreate(
task_ids=data.task_ids,
channel_slug=data.channel_slug,
relationship_type=rel_type,
)
try:
session, links = await messaging.create_session_for_tasks(req, agent_id)
except NotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
session_response = SessionResponse(
id=require_uuid(session.id),
group_id=require_uuid(session.group_id),
status=session.status,
scope=session.scope,
message_count=session.message_count,
total_content_length=session.total_content_length,
started_at=session.started_at,
last_activity_at=session.last_activity_at,
closed_at=session.closed_at,
)
return SessionTaskLinksResponse(
session=session_response,
links=[_link_to_response(link) for link in links],
)
@router.post(
"/{session_id}/tasks",
response_model=SessionTaskLinkResponse,
status_code=status.HTTP_201_CREATED,
summary="Link task to session",
description="Link a task to an existing session (PM only).",
)
async def link_task_to_session(
db: DbSession,
agent_id: CurrentAgentId,
session_id: UUID,
data: SessionTaskLinkRequest,
) -> SessionTaskLinkResponse:
"""Link a task to a session (PM only)."""
if not await is_pm_role(db, agent_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs can link tasks to sessions",
)
messaging = get_messaging_service(db)
try:
rel_type = SessionTaskRelationshipType(data.relationship_type)
except ValueError:
rel_type = SessionTaskRelationshipType.DISCUSSION
try:
link = await messaging.link_session_to_task(
session_id=session_id,
task_id=data.task_id,
added_by=agent_id,
is_primary=data.is_primary,
relationship_type=rel_type,
)
except NotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
except ConflictError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
) from e
return _link_to_response(link)
@router.delete(
"/{session_id}/tasks/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Unlink task from session",
description="Remove a task from a session (PM only).",
)
async def unlink_task_from_session(
db: DbSession,
agent_id: CurrentAgentId,
session_id: UUID,
task_id: UUID,
) -> None:
"""Unlink a task from a session (PM only)."""
if not await is_pm_role(db, agent_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs can unlink tasks from sessions",
)
messaging = get_messaging_service(db)
removed = await messaging.unlink_session_from_task(session_id, task_id)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session-task link not found",
)
@router.get(
"/{session_id}/tasks",
response_model=list[SessionTaskLinkResponse],
summary="Get tasks for session",
description="Get all tasks linked to a session.",
)
async def get_tasks_for_session(
db: DbSession,
_agent_id: CurrentAgentId,
session_id: UUID,
) -> list[SessionTaskLinkResponse]:
"""Get all tasks linked to a session."""
# Verify session exists
session_result = await db.execute(
select(SessionTable).where(SessionTable.id == session_id)
)
if not session_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
messaging = get_messaging_service(db)
links = await messaging.get_tasks_for_session(session_id)
return [_link_to_response(link) for link in links]
+118 -5
View File
@@ -15,6 +15,10 @@ from roboco.api.deps import (
DbSession, DbSession,
PermissionServiceDep, PermissionServiceDep,
) )
from roboco.api.schemas.sessions import (
SessionTaskLinkResponse,
TaskSessionsResponse,
)
from roboco.api.schemas.tasks import ( from roboco.api.schemas.tasks import (
CheckpointRequest, CheckpointRequest,
ClaimRequest, ClaimRequest,
@@ -25,6 +29,7 @@ from roboco.api.schemas.tasks import (
SoftBlockRequest, SoftBlockRequest,
TaskCountResponse, TaskCountResponse,
TaskResponse, TaskResponse,
TaskSessionLinkResponse,
TaskUpdate, TaskUpdate,
TeamTasksQuery, TeamTasksQuery,
task_list_to_response, task_list_to_response,
@@ -35,12 +40,14 @@ from roboco.db.tables import AgentTable
from roboco.models.base import TaskStatus, Team from roboco.models.base import TaskStatus, Team
from roboco.models.task import TaskCreate from roboco.models.task import TaskCreate
from roboco.services.audit import get_audit_service from roboco.services.audit import get_audit_service
from roboco.services.messaging import get_messaging_service
from roboco.services.permissions import TaskAction from roboco.services.permissions import TaskAction
from roboco.services.task import ( from roboco.services.task import (
TaskCreateRequest, TaskCreateRequest,
extract_original_developer, extract_original_developer,
get_task_service, get_task_service,
) )
from roboco.utils.converters import require_uuid
router = APIRouter() router = APIRouter()
@@ -283,14 +290,33 @@ async def get_task(
task_id: UUID, task_id: UUID,
db: DbSession, db: DbSession,
) -> TaskResponse: ) -> TaskResponse:
"""Get a specific task.""" """Get a specific task with linked sessions."""
service = get_task_service(db) service = get_task_service(db)
task = await service.get(task_id) task = await service.get(task_id)
if not task: if not task:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
) )
return task_to_response(task)
# Get linked sessions for this task
messaging = get_messaging_service(db)
session_links = await messaging.get_sessions_for_task(task_id)
# Build response with sessions
response = task_to_response(task)
response.sessions = [
TaskSessionLinkResponse(
session_id=require_uuid(link.session_id),
channel_slug=link.session.group.channel.slug,
scope=link.session.scope,
is_primary=link.is_primary,
relationship_type=link.relationship_type,
)
for link in session_links
if link.session and link.session.group and link.session.group.channel
]
return response
@router.put("/{task_id}", response_model=TaskResponse) @router.put("/{task_id}", response_model=TaskResponse)
@@ -396,9 +422,7 @@ async def get_subtasks(
# ============================================================================= # =============================================================================
async def _resolve_claim_agent_id( async def _resolve_claim_agent_id(db: DbSession, agent_id_str: str) -> UUID:
db: DbSession, agent_id_str: str
) -> UUID:
"""Resolve agent ID from UUID string or slug.""" """Resolve agent ID from UUID string or slug."""
try: try:
return UUID(agent_id_str) return UUID(agent_id_str)
@@ -1068,3 +1092,92 @@ async def add_commit(
) )
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
# =============================================================================
# TASK ACTIVATION (PM ONLY)
# =============================================================================
@router.post("/{task_id}/activate", response_model=TaskResponse)
async def activate_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""
Activate a task from BACKLOG to PENDING status (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.
REQUIRES: Task must have at least one linked session.
"""
# Check PM permission
if not permissions.can_perform_task_action(agent, "create_tasks"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs and management can activate tasks",
)
service = get_task_service(db)
try:
task = await service.activate(task_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
await db.commit()
return task_to_response(task)
# =============================================================================
# SESSION-TASK ENDPOINTS
# =============================================================================
@router.get("/{task_id}/sessions", response_model=TaskSessionsResponse)
async def get_sessions_for_task(
task_id: UUID,
db: DbSession,
_agent: CurrentAgentContext, # Kept for auth dependency
) -> TaskSessionsResponse:
"""Get all sessions linked to a task."""
# Verify task exists
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
# Get sessions
messaging = get_messaging_service(db)
links = await messaging.get_sessions_for_task(task_id)
# Find primary session
primary_link = next((link for link in links if link.is_primary), None)
return TaskSessionsResponse(
task_id=task_id,
sessions=[
SessionTaskLinkResponse(
id=require_uuid(link.id),
session_id=require_uuid(link.session_id),
task_id=require_uuid(link.task_id),
is_primary=link.is_primary,
relationship_type=link.relationship_type,
added_at=link.added_at,
added_by=require_uuid(link.added_by) if link.added_by else None,
)
for link in links
],
primary_session_id=(
require_uuid(primary_link.session_id) if primary_link else None
),
)
+64
View File
@@ -10,6 +10,7 @@ from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.models import SessionStatus from roboco.models import SessionStatus
from roboco.models.session import SessionScope
class ListSessionsParams(BaseModel): class ListSessionsParams(BaseModel):
@@ -26,6 +27,7 @@ class SessionResponse(BaseModel):
id: UUID id: UUID
group_id: UUID group_id: UUID
status: SessionStatus status: SessionStatus
scope: SessionScope
message_count: int message_count: int
total_content_length: int total_content_length: int
started_at: datetime started_at: datetime
@@ -48,3 +50,65 @@ class SessionCreateRequest(BaseModel):
max_message_count: int | None = 100 max_message_count: int | None = 100
max_content_length: int | None = 50000 max_content_length: int | None = 50000
timeout_seconds: int = 300 timeout_seconds: int = 300
# =============================================================================
# SESSION-TASK SCHEMAS
# =============================================================================
class SessionForTasksCreateRequest(BaseModel):
"""Request to create a session linked to tasks (PM only)."""
task_ids: list[UUID] = Field(..., min_length=1)
channel_slug: str
scope: SessionScope = Field(
default=SessionScope.CELL,
description="Scope level: initiative (Main PM), cell (Cell PM), task (dev)",
)
relationship_type: str = Field(
default="discussion",
description="Type: discussion, planning, review, retrospective",
)
max_time_window_minutes: int | None = 30
max_message_count: int | None = 100
max_content_length: int | None = 50000
timeout_seconds: int = 300
class SessionTaskLinkRequest(BaseModel):
"""Request to link a task to a session."""
task_id: UUID
is_primary: bool = False
relationship_type: str = Field(
default="discussion",
description="Type: discussion, planning, review, retrospective",
)
class SessionTaskLinkResponse(BaseModel):
"""Response for a session-task link."""
id: UUID
session_id: UUID
task_id: UUID
is_primary: bool
relationship_type: str
added_at: datetime
added_by: UUID | None
class SessionTaskLinksResponse(BaseModel):
"""Response containing session with its task links."""
session: SessionResponse
links: list[SessionTaskLinkResponse]
class TaskSessionsResponse(BaseModel):
"""Response containing sessions linked to a task."""
task_id: UUID
sessions: list[SessionTaskLinkResponse]
primary_session_id: UUID | None
+14
View File
@@ -11,6 +11,7 @@ from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.models.base import Complexity, TaskStatus, Team from roboco.models.base import Complexity, TaskStatus, Team
from roboco.models.session import SessionScope
from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -50,6 +51,16 @@ class CommitRefResponse(BaseModel):
author_agent_id: UUID | None = None author_agent_id: UUID | None = None
class TaskSessionLinkResponse(BaseModel):
"""A session linked to this task."""
session_id: UUID
channel_slug: str
scope: SessionScope
is_primary: bool
relationship_type: str
class SubTaskResponse(BaseModel): class SubTaskResponse(BaseModel):
"""A sub-task within a task plan.""" """A sub-task within a task plan."""
@@ -230,6 +241,9 @@ class TaskResponse(BaseModel):
self_verified: bool self_verified: bool
qa_verified: bool | None qa_verified: bool | None
# Linked Sessions (for agent context)
sessions: list[TaskSessionLinkResponse] = []
class Config: class Config:
from_attributes = True from_attributes = True
+93
View File
@@ -41,6 +41,7 @@ from roboco.models.base import (
TaskStatus, TaskStatus,
Team, Team,
) )
from roboco.models.session import SessionScope
# ============================================================================= # =============================================================================
# AGENT TABLE # AGENT TABLE
@@ -211,6 +212,10 @@ class TaskTable(Base):
parent_task: Mapped["TaskTable | None"] = relationship( parent_task: Mapped["TaskTable | None"] = relationship(
"TaskTable", remote_side=[id], lazy="select" "TaskTable", remote_side=[id], lazy="select"
) )
# Session links (many-to-many via SessionTaskTable)
session_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable", back_populates="task", lazy="select"
)
__table_args__ = ( __table_args__ = (
# Composite indexes for common queries # Composite indexes for common queries
@@ -390,6 +395,11 @@ class SessionTable(Base):
Enum(SessionStatus), nullable=False, default=SessionStatus.ACTIVE, index=True Enum(SessionStatus), nullable=False, default=SessionStatus.ACTIVE, index=True
) )
# Scope (for smart context loading)
scope: Mapped[SessionScope] = mapped_column(
Enum(SessionScope), nullable=False, default=SessionScope.TASK, index=True
)
# Timestamps # Timestamps
started_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
@@ -415,6 +425,10 @@ class SessionTable(Base):
messages: Mapped[list["MessageTable"]] = relationship( messages: Mapped[list["MessageTable"]] = relationship(
"MessageTable", back_populates="session", lazy="select" "MessageTable", back_populates="session", lazy="select"
) )
# Task links (many-to-many via SessionTaskTable)
task_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable", back_populates="session", lazy="select"
)
__table_args__ = ( __table_args__ = (
# Composite indexes for common queries # Composite indexes for common queries
@@ -423,6 +437,85 @@ class SessionTable(Base):
) )
# =============================================================================
# SESSION-TASK JUNCTION TABLE
# =============================================================================
class SessionTaskTable(Base):
"""
Junction table for many-to-many Session Task relationship.
Enables PMs to create work sessions as discussion contexts for tasks.
A task can have multiple sessions (planning, review, retrospective).
A session can discuss multiple related tasks.
"""
__tablename__ = "session_tasks"
# Identity
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
# Foreign Keys (indexes defined in __table_args__)
session_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
)
task_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
)
# Relationship Metadata
is_primary: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
) # Primary discussion session for this task
relationship_type: Mapped[str] = mapped_column(
String(50), default="discussion", nullable=False
) # "discussion", "planning", "review", "retrospective"
# Audit
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
added_by: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True, # Allow NULL if PM is deleted
)
# Relationships
session: Mapped["SessionTable"] = relationship(
"SessionTable", back_populates="task_links", lazy="joined"
)
task: Mapped["TaskTable"] = relationship(
"TaskTable", back_populates="session_links", lazy="joined"
)
added_by_agent: Mapped["AgentTable | None"] = relationship(
"AgentTable", lazy="select"
)
__table_args__ = (
# Each session-task pair is unique
UniqueConstraint("session_id", "task_id", name="uq_session_task"),
# Partial unique index: only one primary session per task
Index(
"ix_session_tasks_primary_per_task",
"task_id",
unique=True,
postgresql_where=(is_primary == True), # noqa: E712
),
# Fast lookups
Index("ix_session_tasks_task_id", "task_id"),
Index("ix_session_tasks_session_id", "session_id"),
Index("ix_session_tasks_type", "relationship_type"),
)
# ============================================================================= # =============================================================================
# MESSAGE TABLE # MESSAGE TABLE
# ============================================================================= # =============================================================================
+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( async def _handle_message_send(
client: ApiClient, client: ApiClient,
agent_id: str, agent_id: str,
data: SendMessageInput, data: SendMessageInput,
) -> dict[str, Any]: ) -> 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( if validation_error := _validate_message_send(
agent_id, data.channel_slug, data.content, data.message_type agent_id, data.channel_slug, data.content, data.message_type
): ):
return validation_error return validation_error
# Get channel by slug session_id: str | None = None
channel_result = await _get_channel_by_slug(client, data.channel_slug) routed_to_task_session = False
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 task_id provided, try to route to task's primary session
if isinstance(session_result, dict): if data.task_id:
return session_result session_id = await _get_task_primary_session(client, data.task_id)
session_id = session_result 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 # Resolve mentions (slugs) to UUIDs using shared cache
resolved_mentions: list[str] = [] resolved_mentions: list[str] = []
@@ -352,11 +386,16 @@ 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,
"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) 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 return mcp
+36
View File
@@ -216,3 +216,39 @@ class TaskPauseInput(BaseModel):
remaining_work: list[str] = Field( remaining_work: list[str] = Field(
default_factory=list, description="List of remaining sub-tasks" 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_assign: Assign task to agent (PM only)
- roboco_task_cancel: Cancel a task (PM/Board only) - roboco_task_cancel: Cancel a task (PM/Board only)
- roboco_task_escalate: Escalate task up hierarchy (all agents) - 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 from typing import Any
@@ -30,6 +34,8 @@ from typing import Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from roboco.mcp.schemas import ( from roboco.mcp.schemas import (
SessionCreateForTasksInput,
SessionLinkTaskInput,
TaskAssignInput, TaskAssignInput,
TaskBlockInput, TaskBlockInput,
TaskCreateInput, TaskCreateInput,
@@ -39,6 +45,11 @@ from roboco.mcp.schemas import (
from roboco.mcp.tasks.handlers import ( from roboco.mcp.tasks.handlers import (
handle_agent_idle, handle_agent_idle,
handle_docs_complete, 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_assign,
handle_task_block, handle_task_block,
handle_task_cancel, handle_task_cancel,
@@ -61,7 +72,7 @@ from roboco.mcp.tasks.handlers import (
from roboco.mcp.utils import ApiClient 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. 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) 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 return mcp
+12
View File
@@ -17,6 +17,7 @@ from roboco.mcp.tasks.handlers.lifecycle import (
handle_task_complete, handle_task_complete,
) )
from roboco.mcp.tasks.handlers.management import ( from roboco.mcp.tasks.handlers.management import (
handle_task_activate,
handle_task_assign, handle_task_assign,
handle_task_create, handle_task_create,
handle_task_escalate, handle_task_escalate,
@@ -28,6 +29,12 @@ from roboco.mcp.tasks.handlers.review import (
handle_task_submit_verification, handle_task_submit_verification,
) )
from roboco.mcp.tasks.handlers.scan import handle_task_get, handle_task_scan 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 ( from roboco.mcp.tasks.handlers.work import (
handle_task_plan, handle_task_plan,
handle_task_progress, handle_task_progress,
@@ -37,6 +44,11 @@ from roboco.mcp.tasks.handlers.work import (
__all__ = [ __all__ = [
"handle_agent_idle", "handle_agent_idle",
"handle_docs_complete", "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_assign",
"handle_task_block", "handle_task_block",
"handle_task_cancel", "handle_task_cancel",
+50 -3
View File
@@ -132,9 +132,7 @@ def _validate_create_permissions(agent_id: str) -> dict[str, Any] | None:
return None return None
def _validate_cell_pm_team( def _validate_cell_pm_team(agent_id: str, requested_team: str) -> dict[str, Any] | None:
agent_id: str, requested_team: str
) -> dict[str, Any] | None:
"""Validate Cell PM team restrictions for task creation. Returns error or None.""" """Validate Cell PM team restrictions for task creation. Returns error or None."""
role = get_agent_role(agent_id) role = get_agent_role(agent_id)
agent_team = get_agent_team(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." "They will be notified and can reassign or provide guidance."
) )
return format_task_response(task, "ESCALATED", 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: def _has_work_evidence(task: dict[str, Any]) -> bool:
"""Check if task has evidence of work done.""" """Check if task has evidence of work done."""
return bool( return bool(
task.get("commits") task.get("commits") or task.get("progress_updates") or task.get("checkpoints")
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): if error := await _check_self_review(task, agent_id, client):
return error return error
pass_resp = await client.post( pass_resp = await client.post(f"/tasks/{task_id}/pass-qa", json={"notes": qa_notes})
f"/tasks/{task_id}/pass-qa", json={"notes": qa_notes}
)
if not pass_resp.ok: if not pass_resp.ok:
return format_error_response( return format_error_response(
"QA_FAILED", "QA_FAILED",
+5 -1
View File
@@ -27,7 +27,11 @@ async def handle_task_scan(
assigned_resp = await client.get("/tasks/my") assigned_resp = await client.get("/tasks/my")
assigned_data = assigned_resp.json() if assigned_resp.ok else [] assigned_data = assigned_resp.json() if assigned_resp.ok else []
active_statuses = { 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] 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 from roboco.mcp.utils import ApiClient, format_error_response
ACTIVE_PROGRESS_STATUSES = frozenset({ ACTIVE_PROGRESS_STATUSES = frozenset(
"in_progress", "verifying", "awaiting_qa", "awaiting_documentation" {"in_progress", "verifying", "awaiting_qa", "awaiting_documentation"}
}) )
def _format_plan_response( def _format_plan_response(
+8
View File
@@ -70,6 +70,10 @@ from roboco.models.session import (
Session, Session,
SessionConfig, SessionConfig,
SessionCreate, SessionCreate,
SessionForTasksCreate,
SessionTaskLink,
SessionTaskLinkCreate,
SessionTaskRelationshipType,
) )
from roboco.models.task import ( from roboco.models.task import (
Checkpoint, Checkpoint,
@@ -131,7 +135,11 @@ __all__ = [
"Session", "Session",
"SessionConfig", "SessionConfig",
"SessionCreate", "SessionCreate",
"SessionForTasksCreate",
"SessionStatus", "SessionStatus",
"SessionTaskLink",
"SessionTaskLinkCreate",
"SessionTaskRelationshipType",
"Task", "Task",
"TaskCreate", "TaskCreate",
"TaskPlan", "TaskPlan",
+2 -1
View File
@@ -19,7 +19,8 @@ from pydantic import BaseModel, ConfigDict, Field
class TaskStatus(str, Enum): class TaskStatus(str, Enum):
"""Task lifecycle states.""" """Task lifecycle states."""
PENDING = "pending" BACKLOG = "backlog" # PM setup phase - session must be created before activation
PENDING = "pending" # Ready for work - orchestrator can spawn agents
CLAIMED = "claimed" CLAIMED = "claimed"
IN_PROGRESS = "in_progress" IN_PROGRESS = "in_progress"
BLOCKED = "blocked" BLOCKED = "blocked"
+2
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass
from uuid import UUID from uuid import UUID
from roboco.models.base import AgentRole, ChannelType, MessageType from roboco.models.base import AgentRole, ChannelType, MessageType
from roboco.models.session import SessionScope
@dataclass @dataclass
@@ -43,6 +44,7 @@ class SessionCreateRequest:
max_message_count: int | None = 100 max_message_count: int | None = 100
max_content_length: int | None = 50000 max_content_length: int | None = 50000
timeout_seconds: int = 300 timeout_seconds: int = 300
scope: SessionScope = SessionScope.TASK
@dataclass @dataclass
+112
View File
@@ -3,9 +3,15 @@ Session Model
Sessions group messages within boundaries (time, count, content length). Sessions group messages within boundaries (time, count, content length).
They are automatically created and closed based on configuration. They are automatically created and closed based on configuration.
Session-Task Relationships:
PMs can create work sessions as discussion contexts for tasks.
A session can discuss multiple related tasks.
A task can have multiple sessions (planning, review, retrospective).
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import StrEnum
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import Field from pydantic import Field
@@ -16,6 +22,44 @@ from roboco.models.base import (
TimestampMixin, TimestampMixin,
) )
# =============================================================================
# SESSION SCOPE (Context Level)
# =============================================================================
class SessionScope(StrEnum):
"""
Scope level for sessions - determines context loading strategy.
Sessions at different scopes serve different purposes:
- INITIATIVE: Cross-cell coordination (Main PM, #dev-all)
- CELL: Cell-specific work (Cell PM, #backend-cell)
- TASK: Individual task execution (Developer level)
When loading context for an agent:
- Load their scope's sessions fully
- Load parent scope sessions as summaries/references
"""
INITIATIVE = "initiative" # Cross-cell, Main PM level
CELL = "cell" # Cell-specific, Cell PM level
TASK = "task" # Individual task execution
# =============================================================================
# SESSION-TASK RELATIONSHIP TYPES
# =============================================================================
class SessionTaskRelationshipType(StrEnum):
"""Type of relationship between a session and a task."""
DISCUSSION = "discussion" # General discussion about the task
PLANNING = "planning" # Planning session for the task
REVIEW = "review" # Review/retrospective session
RETROSPECTIVE = "retrospective" # Post-completion reflection
# ============================================================================= # =============================================================================
# SUPPORTING MODELS # SUPPORTING MODELS
# ============================================================================= # =============================================================================
@@ -72,6 +116,12 @@ class Session(TimestampMixin):
# State # State
status: SessionStatus = Field(default=SessionStatus.ACTIVE) status: SessionStatus = Field(default=SessionStatus.ACTIVE)
# Scope (for smart context loading)
scope: SessionScope = Field(
default=SessionScope.TASK,
description="Session scope level - initiative, cell, or task",
)
# Timestamps # Timestamps
started_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) started_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_activity_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) last_activity_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -97,3 +147,65 @@ class SessionCreate(RobocoBase):
group_id: UUID group_id: UUID
config: SessionConfig | None = None config: SessionConfig | None = None
# =============================================================================
# SESSION-TASK LINK MODELS
# =============================================================================
class SessionTaskLink(TimestampMixin):
"""
Represents a link between a session and a task.
Used for reading/displaying session-task relationships.
"""
id: UUID = Field(default_factory=uuid4, description="Link ID")
session_id: UUID = Field(..., description="Session ID")
task_id: UUID = Field(..., description="Task ID")
# Relationship metadata
is_primary: bool = Field(
default=False, description="Is this the primary discussion session for the task"
)
relationship_type: SessionTaskRelationshipType = Field(
default=SessionTaskRelationshipType.DISCUSSION,
description="Type of session-task relationship",
)
# Audit
added_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
added_by: UUID | None = Field(default=None, description="PM who created the link")
class SessionTaskLinkCreate(RobocoBase):
"""Schema for creating a session-task link."""
session_id: UUID = Field(..., description="Session to link")
task_id: UUID = Field(..., description="Task to link")
is_primary: bool = Field(
default=False, description="Mark as primary session for this task"
)
relationship_type: SessionTaskRelationshipType = Field(
default=SessionTaskRelationshipType.DISCUSSION,
description="Type of relationship",
)
class SessionForTasksCreate(RobocoBase):
"""Schema for PM creating a session linked to multiple tasks."""
task_ids: list[UUID] = Field(..., min_length=1, description="Tasks to link")
channel_slug: str = Field(..., description="Channel where session is created")
scope: SessionScope = Field(
default=SessionScope.CELL,
description="Session scope level for context loading strategy",
)
config: SessionConfig | None = Field(
default=None, description="Session boundary configuration"
)
relationship_type: SessionTaskRelationshipType = Field(
default=SessionTaskRelationshipType.DISCUSSION,
description="Relationship type for all links",
)
+263 -1
View File
@@ -16,12 +16,14 @@ from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from roboco.db.tables import ( from roboco.db.tables import (
ChannelTable, ChannelTable,
GroupTable, GroupTable,
MessageTable, MessageTable,
SessionTable, SessionTable,
SessionTaskTable,
) )
from roboco.enforcement import validate_channel_access from roboco.enforcement import validate_channel_access
from roboco.events import Event, EventType, get_event_bus from roboco.events import Event, EventType, get_event_bus
@@ -35,7 +37,11 @@ from roboco.models.messaging import (
MessageCreateRequest, MessageCreateRequest,
SessionCreateRequest, SessionCreateRequest,
) )
from roboco.services.base import BaseService from roboco.models.session import (
SessionForTasksCreate,
SessionTaskRelationshipType,
)
from roboco.services.base import BaseService, ConflictError, NotFoundError
# ============================================================================= # =============================================================================
# MESSAGING SERVICE # MESSAGING SERVICE
@@ -281,6 +287,7 @@ class MessagingService(BaseService):
max_content_length=req.max_content_length, max_content_length=req.max_content_length,
timeout_seconds=req.timeout_seconds, timeout_seconds=req.timeout_seconds,
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
scope=req.scope,
) )
self.session.add(session) self.session.add(session)
@@ -386,6 +393,261 @@ class MessagingService(BaseService):
# Create new session # Create new session
return await self.create_session(SessionCreateRequest(group_id=group_id)) 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 sessiongroupchannel 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: def _check_session_boundaries(self, session: SessionTable) -> bool:
"""Check if session has exceeded boundaries. Returns True if should close.""" """Check if session has exceeded boundaries. Returns True if should close."""
# Check message count # Check message count
+8 -6
View File
@@ -30,12 +30,14 @@ HOURS_PER_DAY = 24
SECONDS_PER_HOUR = 3600 SECONDS_PER_HOUR = 3600
# Active task statuses for queries # Active task statuses for queries
ACTIVE_STATUSES = frozenset({ ACTIVE_STATUSES = frozenset(
TaskStatus.CLAIMED, {
TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED,
TaskStatus.VERIFYING, TaskStatus.IN_PROGRESS,
TaskStatus.AWAITING_QA, TaskStatus.VERIFYING,
}) TaskStatus.AWAITING_QA,
}
)
# Health thresholds # Health thresholds
CRITICAL_BLOCKED_RATIO = 0.3 CRITICAL_BLOCKED_RATIO = 0.3
+25
View File
@@ -431,3 +431,28 @@ async def has_privileged_access(db: "AsyncSession", agent_id: UUID) -> bool:
) )
role = result.scalar_one_or_none() role = result.scalar_one_or_none()
return role in PRIVILEGED_ROLES if role else False return role in PRIVILEGED_ROLES if role else False
# Roles that can manage tasks and sessions (PMs and board)
PM_ROLES = frozenset({AgentRole.CELL_PM, AgentRole.MAIN_PM})
MANAGEMENT_ROLES = frozenset(
{AgentRole.CEO, AgentRole.PRODUCT_OWNER, AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
async def is_pm_role(db: "AsyncSession", agent_id: UUID) -> bool:
"""
Check if agent has a PM or management role.
PM roles (Cell PM, Main PM) and management roles (CEO, Product Owner)
can create task-linked sessions and assign work.
"""
from roboco.db.tables import AgentTable
result = await db.execute(
select(AgentTable.role).where(
(AgentTable.id == agent_id) | (AgentTable.slug == str(agent_id))
)
)
role = result.scalar_one_or_none()
return role in MANAGEMENT_ROLES if role else False
+124 -3
View File
@@ -12,7 +12,7 @@ from uuid import UUID
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import AgentTable, TaskTable from roboco.db.tables import AgentTable, SessionTaskTable, TaskTable
from roboco.enforcement import ( from roboco.enforcement import (
TaskOwnershipError, TaskOwnershipError,
validate_task_ownership, validate_task_ownership,
@@ -115,7 +115,15 @@ class TaskService(BaseService):
# ========================================================================= # =========================================================================
async def create(self, req: TaskCreateRequest) -> TaskTable: async def create(self, req: TaskCreateRequest) -> TaskTable:
"""Create a new task.""" """
Create a new task.
Tasks are created with BACKLOG status by default. PM must:
1. Create a session for the task
2. Call activate() to transition to PENDING
This ensures every task has a session before work begins.
"""
task = TaskTable( task = TaskTable(
title=req.title, title=req.title,
description=req.description, description=req.description,
@@ -126,11 +134,19 @@ class TaskService(BaseService):
parent_task_id=req.parent_task_id, parent_task_id=req.parent_task_id,
target_date=req.target_date, target_date=req.target_date,
estimated_complexity=req.estimated_complexity, estimated_complexity=req.estimated_complexity,
status=TaskStatus.PENDING, status=TaskStatus.BACKLOG,
) )
self.session.add(task) self.session.add(task)
await self.session.flush() await self.session.flush()
# Inherit parent task's primary session for subtasks
if req.parent_task_id:
await self._inherit_parent_session(
task_id=cast("UUID", task.id),
parent_task_id=req.parent_task_id,
created_by=req.created_by,
)
self.log.info( self.log.info(
"Task created", "Task created",
task_id=str(task.id), task_id=str(task.id),
@@ -139,6 +155,111 @@ class TaskService(BaseService):
) )
return task return task
async def _inherit_parent_session(
self,
task_id: UUID,
parent_task_id: UUID,
created_by: UUID,
) -> SessionTaskTable | None:
"""
Inherit the parent task's primary session for a subtask.
When a subtask is created, it automatically joins the parent task's
primary discussion session (if one exists). This enables context
continuity across the task hierarchy.
Args:
task_id: The new subtask ID
parent_task_id: The parent task ID
created_by: PM who created the subtask
Returns:
Created link if parent had a primary session, None otherwise
"""
# Find parent's primary session
result = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.task_id == parent_task_id,
SessionTaskTable.is_primary.is_(True),
)
)
parent_link = result.scalar_one_or_none()
if not parent_link:
return None
# Create link for subtask (not primary - parent owns the primary)
link = SessionTaskTable(
session_id=parent_link.session_id,
task_id=task_id,
is_primary=False, # Subtasks don't become primary
relationship_type=parent_link.relationship_type,
added_by=created_by,
)
self.session.add(link)
await self.session.flush()
self.log.info(
"Subtask inherited parent session",
task_id=str(task_id),
parent_task_id=str(parent_task_id),
session_id=str(parent_link.session_id),
)
return link
async def activate(self, task_id: UUID) -> TaskTable:
"""
Activate a task from BACKLOG to PENDING status.
This is a PM-only operation that transitions a task from setup
phase to ready-for-work phase. The orchestrator will then spawn
agents to work on it.
REQUIRES: Task must have at least one linked session.
Args:
task_id: The task to activate
Returns:
The activated task
Raises:
ValueError: If task not found, not in BACKLOG, or has no session
"""
task = await self.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.BACKLOG:
raise ValueError(
f"Task {task_id} is not in BACKLOG status (current: {task.status})"
)
# Check if task has at least one linked session
result = await self.session.execute(
select(SessionTaskTable).where(SessionTaskTable.task_id == task_id).limit(1)
)
session_link = result.scalar_one_or_none()
if not session_link:
raise ValueError(
f"Task {task_id} has no linked session. "
"Create a session with roboco_session_create_for_tasks "
"before activating."
)
# Transition to PENDING
task.status = TaskStatus.PENDING
await self.session.flush()
self.log.info(
"Task activated",
task_id=str(task_id),
session_id=str(session_link.session_id),
)
return task
async def get(self, task_id: UUID) -> TaskTable | None: async def get(self, task_id: UUID) -> TaskTable | None:
"""Get a task by ID.""" """Get a task by ID."""
result = await self.session.execute( result = await self.session.execute(