mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(sessions): Session-Task linking with scoped context management
Major feature: Sessions are now linked to tasks with smart routing and context loading, ensuring agents have proper discussion context.
## Session-Task Relationship (Many-to-Many)
- Added SessionTaskTable junction table linking sessions to tasks
- Sessions can link to multiple tasks, tasks can have multiple sessions
- is_primary flag marks the main discussion session for a task
- relationship_type: discussion, planning, review, retrospective
- Subtasks auto-inherit parent task's session
## BACKLOG Status + Activation Flow
- Tasks now created with BACKLOG status (not PENDING)
- PMs must create session BEFORE activating task
- roboco_task_activate() transitions BACKLOG → PENDING
- Prevents race condition where dev starts before session exists
- Flow: CREATE (backlog) → SESSION → ACTIVATE (pending) → spawn
## Session Scopes
- SessionScope enum: initiative, cell, task
- initiative: Cross-cell coordination (Main PM, #dev-all)
- cell: Cell-specific work (Cell PM default)
- task: Individual task execution (dev level)
- Enables future smart context loading by scope
## Message Routing to Task Sessions
- When task_id provided in roboco_message_send(), routes to task's primary session instead of channel's active session
- New API endpoint: GET /sessions/for-task/{task_id}
- TaskResponse now includes linked sessions array
## Dev Session Access
- New tool: roboco_session_history_for_task(task_id)
- Devs can now see their task's discussion history
- Messages tagged with task_id for filtering
## Communication Guidelines
- Added "When to Post / When NOT to Post" to all 9 agent blueprints
- Devs/QA/Doc should use task tools for status, journal for reasoning
- Sessions reserved for coordination that needs response
- Reduces noise: no "Starting work" or "Made progress" chat messages
Files changed:
- DB: SessionTaskTable, SessionScope column
- Services: messaging.py (linking), task.py (activation)
- MCP: 5 new session tools, message routing update
- API: session-task endpoints, TaskResponse sessions
- Blueprints: All 13 updated with session/activation workflow
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Task MCP Server Session Handlers
|
||||||
|
|
||||||
|
PM-specific session-task handlers for the Task MCP server.
|
||||||
|
Enables PMs to create work sessions linked to tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
from roboco.agents_config import (
|
||||||
|
can_create_tasks,
|
||||||
|
get_agent_role,
|
||||||
|
get_agent_team,
|
||||||
|
)
|
||||||
|
from roboco.mcp.schemas import SessionCreateForTasksInput, SessionLinkTaskInput
|
||||||
|
from roboco.mcp.utils import ApiClient, format_error_response
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELPER FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pm_permissions(agent_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate agent has PM permissions. Returns error or None."""
|
||||||
|
if not can_create_tasks(agent_id):
|
||||||
|
return format_error_response(
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
"Only PMs and management can manage session-task links",
|
||||||
|
{"role": get_agent_role(agent_id)},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_channel_access(agent_id: str, channel_slug: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate Cell PM channel restrictions. Returns error or None."""
|
||||||
|
role = get_agent_role(agent_id)
|
||||||
|
agent_team = get_agent_team(agent_id)
|
||||||
|
|
||||||
|
if role != "cell_pm":
|
||||||
|
return None # Main PM and board can access any channel
|
||||||
|
|
||||||
|
# Cell PM channel restrictions
|
||||||
|
team_channels = {
|
||||||
|
"backend": ["backend-cell"],
|
||||||
|
"frontend": ["frontend-cell"],
|
||||||
|
"ux_ui": ["uxui-cell"],
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed = team_channels.get(agent_team or "", [])
|
||||||
|
if channel_slug not in allowed:
|
||||||
|
return format_error_response(
|
||||||
|
"CHANNEL_ACCESS_DENIED",
|
||||||
|
"Cell PM can only create sessions in their team channel",
|
||||||
|
{"channel": channel_slug, "allowed": allowed},
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_session_response(
|
||||||
|
session: dict[str, Any],
|
||||||
|
links: list[dict[str, Any]],
|
||||||
|
status_code: str,
|
||||||
|
guidance: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Format session response with guidance."""
|
||||||
|
return {
|
||||||
|
"status": status_code,
|
||||||
|
"session": session,
|
||||||
|
"task_links": links,
|
||||||
|
"guidance": guidance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SESSION HANDLERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_session_create_for_tasks(
|
||||||
|
client: ApiClient,
|
||||||
|
input_data: SessionCreateForTasksInput,
|
||||||
|
agent_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle session creation linked to tasks (PM only)."""
|
||||||
|
if error := _validate_pm_permissions(agent_id):
|
||||||
|
return error
|
||||||
|
|
||||||
|
if error := _validate_channel_access(agent_id, input_data.channel_slug):
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"task_ids": input_data.task_ids,
|
||||||
|
"channel_slug": input_data.channel_slug,
|
||||||
|
"scope": input_data.scope,
|
||||||
|
"relationship_type": input_data.relationship_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.post("/sessions/for-tasks", json=payload)
|
||||||
|
except Exception as e:
|
||||||
|
return format_error_response(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
f"Failed to connect to API: {type(e).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.is_status(status.HTTP_201_CREATED):
|
||||||
|
return format_error_response(
|
||||||
|
"CREATE_FAILED",
|
||||||
|
"Failed to create session for tasks",
|
||||||
|
{"status_code": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
session = data.get("session", {})
|
||||||
|
links = data.get("links", [])
|
||||||
|
|
||||||
|
guidance = (
|
||||||
|
f"Work session created. Session ID: {session.get('id', 'unknown')}. "
|
||||||
|
f"Linked to {len(links)} task(s). "
|
||||||
|
f"First task is marked as primary. "
|
||||||
|
"Assigned agents can now discuss in this session."
|
||||||
|
)
|
||||||
|
|
||||||
|
return _format_session_response(session, links, "CREATED", guidance)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_session_link_task(
|
||||||
|
client: ApiClient,
|
||||||
|
input_data: SessionLinkTaskInput,
|
||||||
|
agent_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle linking a session to a task (PM only)."""
|
||||||
|
if error := _validate_pm_permissions(agent_id):
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"task_id": input_data.task_id,
|
||||||
|
"is_primary": input_data.is_primary,
|
||||||
|
"relationship_type": input_data.relationship_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/sessions/{input_data.session_id}/tasks", json=payload
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return format_error_response(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
f"Failed to connect to API: {type(e).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_409_CONFLICT):
|
||||||
|
return format_error_response(
|
||||||
|
"ALREADY_LINKED",
|
||||||
|
"Session is already linked to this task",
|
||||||
|
{"session_id": input_data.session_id, "task_id": input_data.task_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
return format_error_response(
|
||||||
|
"LINK_FAILED",
|
||||||
|
"Failed to link session to task",
|
||||||
|
{"status_code": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
link = resp.json()
|
||||||
|
primary_note = " (marked as primary)" if input_data.is_primary else ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "LINKED",
|
||||||
|
"link": link,
|
||||||
|
"guidance": (
|
||||||
|
f"Session linked to task{primary_note}. "
|
||||||
|
"Task's assigned agent can now access this session."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_session_unlink_task(
|
||||||
|
client: ApiClient,
|
||||||
|
session_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle unlinking a session from a task (PM only)."""
|
||||||
|
if error := _validate_pm_permissions(agent_id):
|
||||||
|
return error
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.delete(f"/sessions/{session_id}/tasks/{task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
return format_error_response(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
f"Failed to connect to API: {type(e).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
|
return format_error_response(
|
||||||
|
"NOT_FOUND",
|
||||||
|
"Session-task link not found",
|
||||||
|
{"session_id": session_id, "task_id": task_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
return format_error_response(
|
||||||
|
"UNLINK_FAILED",
|
||||||
|
"Failed to unlink session from task",
|
||||||
|
{"status_code": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "UNLINKED",
|
||||||
|
"guidance": "Session unlinked from task. Task agent no longer has access.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_session_get_for_task(
|
||||||
|
client: ApiClient,
|
||||||
|
task_id: str,
|
||||||
|
_agent_id: str, # Kept for handler signature consistency
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle getting sessions for a task."""
|
||||||
|
# Any agent can query sessions for tasks they have access to
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"/tasks/{task_id}/sessions")
|
||||||
|
except Exception as e:
|
||||||
|
return format_error_response(
|
||||||
|
"CONNECTION_ERROR",
|
||||||
|
f"Failed to connect to API: {type(e).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
|
return format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
return format_error_response(
|
||||||
|
"FETCH_FAILED",
|
||||||
|
"Failed to fetch sessions for task",
|
||||||
|
{"status_code": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
sessions = data.get("sessions", [])
|
||||||
|
primary = next((s for s in sessions if s.get("is_primary")), None)
|
||||||
|
|
||||||
|
guidance = f"Found {len(sessions)} session(s) for this task."
|
||||||
|
if primary:
|
||||||
|
guidance += f" Primary session: {primary.get('session_id', 'unknown')}."
|
||||||
|
else:
|
||||||
|
guidance += " No primary session set."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "OK",
|
||||||
|
"sessions": sessions,
|
||||||
|
"primary_session_id": primary.get("session_id") if primary else None,
|
||||||
|
"guidance": guidance,
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@ from roboco.mcp.tasks.handlers._helpers import (
|
|||||||
)
|
)
|
||||||
from roboco.mcp.utils import ApiClient, format_error_response
|
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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 session→group→channel loaded)
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
select(SessionTaskTable)
|
||||||
|
.where(SessionTaskTable.task_id == task_id)
|
||||||
|
.options(
|
||||||
|
joinedload(SessionTaskTable.session)
|
||||||
|
.joinedload(SessionTable.group)
|
||||||
|
.joinedload(GroupTable.channel)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if relationship_type:
|
||||||
|
query = query.where(
|
||||||
|
SessionTaskTable.relationship_type == relationship_type.value
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(SessionTaskTable.added_at.desc())
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return list(result.scalars().unique().all())
|
||||||
|
|
||||||
|
async def get_primary_session_for_task(
|
||||||
|
self,
|
||||||
|
task_id: UUID,
|
||||||
|
) -> SessionTaskTable | None:
|
||||||
|
"""
|
||||||
|
Get the primary session for a task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task to get primary session for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Primary session-task link, or None if no primary session
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(SessionTaskTable).where(
|
||||||
|
SessionTaskTable.task_id == task_id,
|
||||||
|
SessionTaskTable.is_primary.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_tasks_for_session(
|
||||||
|
self,
|
||||||
|
session_id: UUID,
|
||||||
|
) -> list[SessionTaskTable]:
|
||||||
|
"""
|
||||||
|
Get all tasks linked to a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to get tasks for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of session-task links (with task relationship loaded)
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(SessionTaskTable)
|
||||||
|
.where(SessionTaskTable.session_id == session_id)
|
||||||
|
.order_by(SessionTaskTable.added_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create_session_for_tasks(
|
||||||
|
self,
|
||||||
|
req: SessionForTasksCreate,
|
||||||
|
pm_agent_id: UUID,
|
||||||
|
) -> tuple[SessionTable, list[SessionTaskTable]]:
|
||||||
|
"""
|
||||||
|
Create a new session linked to one or more tasks (PM operation).
|
||||||
|
|
||||||
|
This is the main entry point for PMs to create work sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Session creation request with task IDs
|
||||||
|
pm_agent_id: PM agent creating the session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created session, list of created links)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If channel not found
|
||||||
|
ValueError: If no groups found in channel
|
||||||
|
"""
|
||||||
|
# Get channel
|
||||||
|
channel = await self.get_channel_by_slug(req.channel_slug)
|
||||||
|
if not channel:
|
||||||
|
raise NotFoundError(f"Channel '{req.channel_slug}' not found")
|
||||||
|
|
||||||
|
# Get first group in channel (or create default)
|
||||||
|
groups = await self.list_groups_in_channel(cast("UUID", channel.id))
|
||||||
|
if not groups:
|
||||||
|
raise ValueError(f"No groups found in channel '{req.channel_slug}'")
|
||||||
|
group = groups[0]
|
||||||
|
|
||||||
|
# Create session with config and scope
|
||||||
|
session_req = SessionCreateRequest(
|
||||||
|
group_id=cast("UUID", group.id),
|
||||||
|
max_message_count=(req.config.max_message_count if req.config else None),
|
||||||
|
max_content_length=(req.config.max_content_length if req.config else None),
|
||||||
|
timeout_seconds=(req.config.timeout_seconds if req.config else 300),
|
||||||
|
scope=req.scope,
|
||||||
|
)
|
||||||
|
session = await self.create_session(session_req)
|
||||||
|
|
||||||
|
# Link all tasks
|
||||||
|
links: list[SessionTaskTable] = []
|
||||||
|
for i, task_id in enumerate(req.task_ids):
|
||||||
|
# First task is primary
|
||||||
|
is_primary = i == 0
|
||||||
|
link = await self.link_session_to_task(
|
||||||
|
session_id=cast("UUID", session.id),
|
||||||
|
task_id=task_id,
|
||||||
|
added_by=pm_agent_id,
|
||||||
|
is_primary=is_primary,
|
||||||
|
relationship_type=req.relationship_type,
|
||||||
|
)
|
||||||
|
links.append(link)
|
||||||
|
|
||||||
|
self.log.info(
|
||||||
|
"Session created for tasks",
|
||||||
|
session_id=str(session.id),
|
||||||
|
task_count=len(req.task_ids),
|
||||||
|
channel_slug=req.channel_slug,
|
||||||
|
pm_agent_id=str(pm_agent_id),
|
||||||
|
)
|
||||||
|
return session, links
|
||||||
|
|
||||||
def _check_session_boundaries(self, session: SessionTable) -> bool:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user