Added git workflow + fixing some issues

This commit is contained in:
Renn F
2026-04-19 16:13:42 +02:00
parent f474603897
commit 0023c25d60
67 changed files with 5266 additions and 1914 deletions
+10 -1
View File
@@ -81,4 +81,13 @@ alembic/versions/*.pyc
/data
/#recycle
.OLD/
.OLD/
# Panel (Next.js frontend)
panel/node_modules/
panel/.next/
panel/out/
panel/coverage/
panel/tsconfig.tsbuildinfo
panel/.env.local
panel/.env.*.local
+23 -14
View File
@@ -79,7 +79,7 @@ pnpm test
| Cache/Queue | Redis |
| Container Runtime | Docker + Docker Compose |
| Cloud LLM | Claude API (claude-opus-4-5-20251101) |
| Local LLM | Ollama (glm-4.7:cloud for HyDE/RAG) |
| Local LLM | Ollama (glm-5.1:cloud for HyDE/RAG) |
| Embeddings | qwen3-embedding:0.6b (1024 dim) |
| Frontend | React / Next.js (future) |
@@ -145,7 +145,7 @@ Commits are automatically prefixed with the task ID:
### Work Sessions
When a developer claims a git-enabled task, a **WorkSession** is created that tracks:
When a developer claims a task, a **WorkSession** is created that tracks:
- Branch name and base/target branches
- All commits made during the session
- Files modified
@@ -218,16 +218,26 @@ backlog -> pending -> claimed -> in_progress -> [blocked|paused] -> verifying
### Role-Based Transitions
Certain transitions require specific roles:
- **Cancel any task**: PM roles only (`cell_pm`, `main_pm`, `product_owner`, `head_marketing`)
- **QA actions**: Only `qa` role can pass/fail QA
- **Documentation actions**: Only `documenter` role
- **CEO approval**: Only `ceo` role can approve/reject from `awaiting_ceo_approval`
- **PM review completion**: PM roles only
All status transitions are validated through the enforcement layer. Key restrictions:
| Transition | Allowed Roles |
|------------|---------------|
| `backlog``pending` (activate) | PM roles only |
| `pending``claimed` (claim) | Role must match task type (QA for awaiting_qa, etc.) |
| `claimed``pending` (unclaim) | Assignee or PM |
| `awaiting_qa``awaiting_documentation` (pass) | QA only |
| `awaiting_qa``needs_revision` (fail) | QA only |
| `awaiting_documentation``awaiting_pm_review` | Documenter or Developer (parallel completion) |
| `awaiting_pm_review``completed` | PM roles only |
| `awaiting_pm_review``awaiting_ceo_approval` | PM roles only |
| `awaiting_ceo_approval``completed/needs_revision/cancelled` | CEO only |
| Any → `cancelled` | PM roles only |
**Unclaim Operation**: Agents can release claimed tasks back to the pool using `unclaim()`. This transitions `claimed``pending` and optionally reassigns to another agent.
### Git Integration Requirements
For tasks with `requires_git=True`:
All tasks follow git workflow:
1. **claimed -> in_progress**: `branch_name` is auto-set on claim (hierarchical branches)
2. **awaiting_documentation -> awaiting_pm_review**: Requires BOTH `docs_complete=True` AND `pr_created=True`
3. **awaiting_pm_review -> awaiting_ceo_approval**: Must have `pr_number` set
@@ -260,10 +270,9 @@ Major tasks are escalated to CEO for final approval:
### Task Model Key Fields
```python
# Git configuration
# Git configuration (all tasks follow git workflow)
task_type: TaskType # code, documentation, research, planning, design, administrative
requires_git: bool # Whether git workflow applies
project_id: UUID # Project this task works on
project_id: UUID # Project this task works on (required)
branch_name: str # Branch for this task (auto-created on claim)
work_session_id: UUID # Active work session
@@ -365,7 +374,7 @@ ROBOCO_RAG_USE_HYBRID_SEARCH=true
# AI/LLM
ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
ROBOCO_LOCAL_LLM_MODEL=glm-4.7:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-5.1:cloud
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
```
@@ -392,7 +401,7 @@ The startup order is critical due to dependencies:
postgres ──┐
redis ─────┼──> ollama ──> ollama-init ──> orchestrator
│ │ │
│ │ └── Pulls qwen3-embedding:0.6b, glm-4.7:cloud
│ │ └── Pulls qwen3-embedding:0.6b, glm-5.1:cloud
│ └── Healthcheck: ollama list
└── Healthcheck: pg_isready, redis-cli ping
```
+2 -2
View File
@@ -91,7 +91,7 @@ ROBOCO_WORKSPACE_AUTO_CLONE=true
# RAG/LLM
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL=glm-4.7:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-5.1:cloud
```
## Multi-Agent Workspace Structure
@@ -177,7 +177,7 @@ uv run mypy roboco/
| Cache/Queue | Redis |
| RAG Library | piragi |
| Embeddings | qwen3-embedding:0.6b (sentence-transformers) |
| Local LLM | Ollama (glm-4.7:cloud) |
| Local LLM | Ollama (glm-5.1:cloud) |
| Cloud LLM | Claude API (Anthropic) |
| Package Manager | uv |
+9 -8
View File
@@ -89,17 +89,18 @@ Each agent gets their own isolated workspace per project:
## Git Integration
**Not all tasks require git.** Tasks with `requires_git=True` follow the git workflow.
**All tasks follow the git workflow.** Every task creates a branch, commits artifacts, and creates a PR.
### Task Types
| Type | Git Required | Description |
|------|--------------|-------------|
| `code` | Yes | Features, bug fixes, refactors |
| `documentation` | Maybe | Docs in repo need git |
| `research` | No | Investigation, analysis |
| `planning` | No | Architecture, design |
| `administrative` | No | Process, coordination |
| Type | Artifacts | Description |
|------|-----------|-------------|
| `code` | Source code | Features, bug fixes, refactors |
| `documentation` | Docs files | Documentation updates |
| `research` | Research notes | Investigation findings |
| `planning` | Plan docs | Architecture, design documents |
| `design` | Design assets | UX/UI specifications |
| `administrative` | Process docs | Process documentation |
### Branch Naming Convention
+5 -4
View File
@@ -79,7 +79,7 @@ roboco_task_create(
parent_task_id=my_task["id"], # REQUIRED - links to your task
assigned_to="be-dev-1", # Developer - task will flow to QA/Doc automatically
task_type="code",
requires_git=True,
project_slug="roboco", # REQUIRED - all tasks need a project
team="backend",
...
)
@@ -97,8 +97,8 @@ roboco_task_create(
**Task Types for Subtasks:**
- Use `task_type: "code"` for developer work that modifies files
- Use `requires_git: true` for code changes
- Use `task_type: "research"` for investigation without code changes
- Use `task_type: "research"` for investigation (still commits research notes)
- All tasks follow git workflow automatically
### 5. ACTIVATE
`roboco_task_activate()` moves backlog → pending. Now visible to devs.
@@ -141,7 +141,7 @@ roboco_task_unblock(
- ✅ Call `roboco_task_unblock()` with clear resolution notes
- ✅ The system will notify and respawn the developer automatically
### 9. REVIEW PR (Git Tasks)
### 9. REVIEW PR
When subtasks reach `awaiting_pm_review`:
1. Review the PR: `roboco_git_diff(project_slug)` to see changes
2. Check QA notes and documentation
@@ -158,6 +158,7 @@ When ALL subtasks done: reflect + complete your task.
**Task Management:**
- `roboco_task_scan`, `roboco_task_get`, `roboco_task_claim`
- `roboco_task_unclaim` (release claimed task if wrong fit)
- `roboco_task_create`, `roboco_task_assign`, `roboco_task_activate`
- `roboco_task_plan`, `roboco_task_start`, `roboco_task_progress`
- `roboco_task_complete`, `roboco_task_cancel`
+5 -6
View File
@@ -48,11 +48,9 @@ Use `roboco_task_scan(team)` for pending tasks assigned to you or unassigned.
### 3. CLAIM
Use `roboco_task_claim()`. Status: pending → claimed.
### 4. CHECKOUT (Git Tasks)
**For tasks with `requires_git=True`:**
- Branch auto-created when you claimed the task
- **Auto-checkout happens on `roboco_task_start()`** - no manual checkout needed
- System blocks if you have uncommitted changes
### 4. CHECKOUT
Branch is auto-created when you claim the task. Auto-checkout happens on `roboco_task_start()`.
System blocks if you have uncommitted changes.
### 5. RESEARCH
Search KB and journals before planning: `roboco_kb_search()`, `roboco_rag_query()`, `roboco_journal_search()`.
@@ -126,11 +124,12 @@ If PMs request changes:
**Task Management:**
- `roboco_task_scan`, `roboco_task_get`, `roboco_task_claim`
- `roboco_task_unclaim` (release claimed task if wrong fit)
- `roboco_task_plan`, `roboco_task_start`, `roboco_task_progress`
- `roboco_task_block`, `roboco_task_unblock`, `roboco_task_pause`, `roboco_task_escalate`
- `roboco_task_submit_verification`, `roboco_task_submit_qa`
- `roboco_task_submit_pm_review` (non-dev tasks, skips QA)
- `roboco_task_substitute` (graceful exit)
- `roboco_task_substitute` (graceful exit from in_progress)
**Git (Read-Only):**
- `roboco_git_status(project_slug)` - Current branch, staged/unstaged changes
+4 -4
View File
@@ -28,8 +28,7 @@ Use `roboco_task_claim()`. Status: awaiting_documentation → claimed.
### 3. START
Use `roboco_task_start()` then `roboco_message_send()` to announce.
### 4. CHECKOUT (Git Tasks)
**For tasks with `requires_git=True`:**
### 4. CHECKOUT
1. Check branch status: `roboco_git_status(project_slug)`
2. The task's `branch_name` tells you which branch has the code
3. Review dev's commits: `roboco_git_log(project_slug)`
@@ -61,8 +60,8 @@ roboco_docs_write({
Update progress: `roboco_task_progress()`
### 7. COMMIT (Git Tasks)
**For tasks with `requires_git=True`:**
### 7. COMMIT
**Commit your documentation to the branch:**
1. Commit your documentation: `roboco_git_commit(project_slug, message, task_id)`
- Example message: `docs: add API documentation for user endpoints`
2. Push your changes: `roboco_git_push(project_slug, task_id)`
@@ -84,6 +83,7 @@ Use `roboco_task_docs_complete()`. This sets `docs_complete=True`.
**Task Management:**
- `roboco_task_scan`, `roboco_task_get`, `roboco_task_claim`
- `roboco_task_unclaim` (release claimed task if wrong fit)
- `roboco_task_start`, `roboco_task_progress`
- `roboco_task_docs_complete`
- `roboco_task_escalate`, `roboco_task_substitute`
+2 -2
View File
@@ -77,7 +77,7 @@ roboco_task_create(
- Completion tracking breaks
- Your task can't complete
- Set `project_id` for git tasks (branches are auto-created on claim)
- Set `project_id` (required - branches are auto-created on claim)
### 6. ACTIVATE + NOTIFY
`roboco_task_activate()` each task, then `roboco_notify_send()` to each Cell PM. REQUIRED.
@@ -96,7 +96,7 @@ When respawned: scan, read Cell PM journals, update progress, handle escalated b
**DO NOT just send a message and hope they figure it out. CALL UNBLOCK.**
### 9. REVIEW PR (Git Tasks)
### 9. REVIEW PR
When cell tasks reach `awaiting_pm_review` and all subtasks are merged:
1. Review the parent PR (all subtask work combined)
2. Coordinate with Cell PM - **BOTH must approve**
+2 -2
View File
@@ -21,8 +21,7 @@ Use `roboco_task_claim()`. QA can ONLY claim from `awaiting_qa` status.
### 3. START
Use `roboco_task_start()` then `roboco_message_send()` to announce.
### 4. CHECKOUT (Git Tasks)
**For tasks with `requires_git=True`:**
### 4. CHECKOUT
1. Check branch status: `roboco_git_status(project_slug)`
2. The task's `branch_name` tells you which branch to review
3. Review the branch diff vs main: `roboco_git_diff(project_slug)`
@@ -52,6 +51,7 @@ Use `roboco_journal_reflect()` before decision. REQUIRED.
**Task Management:**
- `roboco_task_scan`, `roboco_task_get`, `roboco_task_claim`
- `roboco_task_unclaim` (release claimed task if wrong fit)
- `roboco_task_start`, `roboco_task_progress`
- `roboco_task_qa_pass`, `roboco_task_qa_fail`
- `roboco_task_escalate`, `roboco_task_substitute`
+2 -3
View File
@@ -259,7 +259,6 @@ def upgrade() -> None:
nullable=False,
server_default="code",
),
sa.Column("requires_git", sa.Boolean(), nullable=False, server_default="true"),
sa.Column(
"nature",
sa.Enum("technical", "non_technical", name="tasknature"),
@@ -269,8 +268,8 @@ def upgrade() -> None:
sa.Column(
"project_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("projects.id", ondelete="SET NULL"),
nullable=True,
sa.ForeignKey("projects.id", ondelete="RESTRICT"),
nullable=False,
index=True,
),
sa.Column("branch_name", sa.String(500), nullable=True),
+128
View File
@@ -0,0 +1,128 @@
"""Persistence tables + NotificationType.APPROVAL.
Revision ID: 002_persistence_tables
Revises: 001_initial_schema
Create Date: 2026-04-19
Adds:
- `waiting_records`: durable storage for agents in WAITING_LONG state so
orchestrator restarts don't strand them (previously in-memory dict).
- `audit_log`: queryable audit trail so the auditor role actually has data
to inspect (previously log-only).
- `notificationtype` enum value `approval`: the orchestrator's approval
dispatcher and the frontend both reference this type, but it was missing
from the enum, so every `/notifications?type_filter=approval` call
returned 422 and the dispatcher's query silently matched nothing.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "002_persistence_tables"
down_revision = "001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add the missing 'approval' value to the notificationtype enum.
# IF NOT EXISTS makes this idempotent on re-runs.
op.execute(
"ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'approval'"
)
op.create_table(
"waiting_records",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"agent_id",
sa.String(64),
nullable=False,
unique=True,
index=True,
comment="Agent slug; unique so only one record per agent.",
),
sa.Column(
"task_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"waiting_for",
sa.String(64),
nullable=False,
comment="One of: blocker_resolution, qa_result, answer, assignment",
),
sa.Column(
"waiting_since",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column("context", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_waiting_records_waiting_for", "waiting_records", ["waiting_for"]
)
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"event_type",
sa.String(80),
nullable=False,
index=True,
comment="Dot-separated e.g. task.claimed, session.closed, project.deleted",
),
sa.Column(
"agent_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_type",
sa.String(40),
nullable=True,
comment="e.g. task, session, project, notification",
),
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"severity",
sa.String(16),
nullable=False,
server_default="info",
comment="info | warning | error",
),
sa.Column("details", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
op.create_index(
"ix_audit_log_agent_timestamp", "audit_log", ["agent_id", "timestamp"]
)
op.create_index(
"ix_audit_log_target", "audit_log", ["target_type", "target_id"]
)
def downgrade() -> None:
op.drop_index("ix_audit_log_target", table_name="audit_log")
op.drop_index("ix_audit_log_agent_timestamp", table_name="audit_log")
op.drop_table("audit_log")
op.drop_index("ix_waiting_records_waiting_for", table_name="waiting_records")
op.drop_table("waiting_records")
+4 -4
View File
@@ -79,14 +79,14 @@ services:
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-4.7:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.7:cloud"}' | while read -r line; do
echo "=== Pulling LLM model (glm-5.1:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.1:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-4.7" && echo " glm-4.7: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.1" && echo " glm-5.1: OK"
echo "=== All models ready! ==="
# ==========================================================================
@@ -220,7 +220,7 @@ services:
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud
ROBOCO_LOCAL_LLM_MODEL: glm-5.1:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
+4 -4
View File
@@ -79,14 +79,14 @@ services:
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-4.7:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.7:cloud"}' | while read -r line; do
echo "=== Pulling LLM model (glm-5.1:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.1:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-4.7" && echo " glm-4.7: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.1" && echo " glm-5.1: OK"
echo "=== All models ready! ==="
# ==========================================================================
@@ -220,7 +220,7 @@ services:
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud
ROBOCO_LOCAL_LLM_MODEL: glm-5.1:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
+1 -1
View File
@@ -58,7 +58,7 @@ Environment variables for RoboCo (prefix: `ROBOCO_`).
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-4.7:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5.1:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_BASE_URL` | `http://roboco-ollama:11434/v1` | OpenAI-compat API |
| `ROBOCO_OLLAMA_BASE_URL` | `http://roboco-ollama:11434` | Native Ollama API |
+1 -1
View File
@@ -62,7 +62,7 @@ roboco_task_escalate_to_ceo(
Requirements:
- Task in `awaiting_pm_review`
- PR exists (for git tasks)
- PR exists
- Only PMs can call this
## Cannot Skip Levels
+1 -2
View File
@@ -27,8 +27,7 @@
| Field | Description |
|-------|-------------|
| `requires_git` | Whether git workflow applies |
| `project_id` | Associated project |
| `project_id` | Associated project (required) |
| `branch_name` | Git branch for task |
| `work_session_id` | Active work session |
| `pr_number` | PR number |
+4 -4
View File
@@ -14,7 +14,7 @@
3. Assign work to cell members
4. Complete tasks after full workflow
5. Handle escalations from cell
6. Review and merge PRs for git tasks
6. Review and merge PRs
## What You CAN Do
@@ -58,9 +58,9 @@ roboco_notify_send({
})
```
## Git Tasks
## Git Workflow
For tasks with `requires_git=True`:
All tasks follow the git workflow:
**Branches are auto-created when tasks are claimed:**
- When you claim your task: `feature/team/MAIN_PM_ID/YOUR_ID`
@@ -138,7 +138,7 @@ Create tasks with project selection:
roboco_task_create(
title="Backend task",
team="backend",
project_slug="roboco" # Required for git tasks
project_slug="roboco" # Required
)
```
+1 -1
View File
@@ -132,7 +132,7 @@ Create tasks with project:
roboco_task_create(
title="Backend task",
team="backend",
project_slug="roboco" # Required for git tasks
project_slug="roboco" # Required
)
```
+2 -3
View File
@@ -99,14 +99,13 @@ Lists all agent workspaces for a project. Cell PM sees own cell only.
## Task Creation with Project
When creating git-enabled tasks:
When creating tasks:
```python
roboco_task_create(
title="Add rate limiting",
team="backend",
project_slug="roboco", # Required for git tasks
requires_git=True # Default: True
project_slug="roboco", # Required - all tasks follow git workflow
)
```
+5
View File
@@ -7,6 +7,7 @@
| `roboco_task_get` | Get task details |
| `roboco_task_scan` | Find available tasks |
| `roboco_task_claim` | Take ownership |
| `roboco_task_unclaim` | Release claimed task |
| `roboco_task_start` | Begin work |
## Task Retrieval
@@ -28,6 +29,10 @@ tasks = roboco_task_scan(
# Claim task
roboco_task_claim(task_id)
# Release if you shouldn't work on it
roboco_task_unclaim(task_id)
roboco_task_unclaim(task_id, hand_off_to="be-dev-2")
# Start work (also resumes paused tasks)
roboco_task_start(task_id)
+1 -1
View File
@@ -28,7 +28,7 @@
3. Invalid transition attempted
**Check**:
- For git tasks: branch exists?
- Branch exists?
- For `awaiting_pm_review`: both `docs_complete` AND `pr_created`?
- Is transition valid from current status?
+1 -1
View File
@@ -71,7 +71,7 @@ roboco_task_escalate_to_ceo(
Requirements:
- Task must be in `awaiting_pm_review`
- PR must exist (for git tasks)
- PR must exist
- Only PMs can do this
- **PARENT TASKS ONLY** - Subtasks cannot be escalated to CEO
+29 -1
View File
@@ -41,7 +41,35 @@ roboco_task_claim(task_id)
- **One at a time**: Don't claim multiple in_progress tasks
- **Self-review prevention**: QA cannot claim tasks they developed
- **Self-documentation prevention**: Documenter cannot claim tasks they developed
- **Git requirement**: For git tasks, branch must exist before starting
- **Branch requirement**: Branch auto-created on claim
## Releasing a Claimed Task
If you claimed a task but realize you shouldn't work on it, use `unclaim`:
```python
# Release back to pool
roboco_task_unclaim(task_id)
# Hand off to specific agent
roboco_task_unclaim(task_id, hand_off_to="be-dev-2")
# Result:
# - status: pending
# - assigned_to: None (or hand_off_to agent)
# - You can now claim new work
```
**When to use unclaim:**
- Task is out of your team's scope
- Task requires a different role
- You need to prioritize other work
- Better suited for another agent
**Restrictions:**
- Only works on `claimed` status (not yet started)
- You must be the agent who claimed it
- If task is `in_progress`, use `roboco_task_substitute` instead
## Status After Claim
+2 -2
View File
@@ -37,9 +37,9 @@ Calling `roboco_task_start()` without a plan returns:
- Message: "Cannot start without a plan"
- Hint: Submit plan first
## Git Tasks
## Git Workflow
For tasks with `requires_git=True`:
All tasks follow the git workflow:
- **Branches are auto-created when you claim the task**
- Root tasks: branch created from default branch (main/master)
- Subtasks: branch forked from parent's branch
+4 -3
View File
@@ -31,8 +31,8 @@
### Developer Flow
```
pending → claimed → in_progress → verifying → awaiting_qa
↑ ↓
└── needs_revision ←──────┘
↑ ↓
└─ unclaim └── needs_revision ←──────┘
```
### QA Flow
@@ -58,9 +58,10 @@ backlog → pending (via roboco_task_activate)
| Transition | Allowed Roles |
|------------|---------------|
| `backlog → pending` | cell_pm, main_pm |
| `claimed → pending` (unclaim) | assignee or PM |
| `awaiting_qa → awaiting_documentation` | qa only |
| `awaiting_qa → needs_revision` | qa only |
| `awaiting_documentation → awaiting_pm_review` | documenter only |
| `awaiting_documentation → awaiting_pm_review` | documenter, developer (parallel) |
| `awaiting_pm_review → completed` | cell_pm, main_pm |
| `awaiting_pm_review → awaiting_ceo_approval` | cell_pm, main_pm (parent tasks only) |
| `awaiting_ceo_approval → completed` | ceo only |
+1 -1
View File
@@ -7,7 +7,7 @@ authors = [
]
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.13"
requires-python = ">=3.10,<3.15"
dependencies = [
# Core
+4 -4
View File
@@ -302,20 +302,20 @@ def get_pm_for_agent(agent_id: str) -> str | None:
# =============================================================================
CHANNEL_ACCESS: Final[dict[str, dict[str, list[str]]]] = {
# Cell channels - members read/write, main-pm read (monitoring), auditor silent
# Cell channels - members + main-pm read/write, auditor silent
"backend-cell": {
"read": [*CELL_MEMBERS["backend"], "main-pm"],
"write": CELL_MEMBERS["backend"],
"write": [*CELL_MEMBERS["backend"], "main-pm"],
"silent": ["auditor"],
},
"frontend-cell": {
"read": [*CELL_MEMBERS["frontend"], "main-pm"],
"write": CELL_MEMBERS["frontend"],
"write": [*CELL_MEMBERS["frontend"], "main-pm"],
"silent": ["auditor"],
},
"uxui-cell": {
"read": [*CELL_MEMBERS["ux_ui"], "main-pm"],
"write": CELL_MEMBERS["ux_ui"],
"write": [*CELL_MEMBERS["ux_ui"], "main-pm"],
"silent": ["auditor"],
},
# Cross-cell role channels
+69 -3
View File
@@ -48,6 +48,7 @@ from roboco.api.schemas.git import (
GitStatusResponse,
)
from roboco.exceptions import GitCommandError, GitTimeoutError
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.git import get_git_service
from roboco.services.project import get_project_service
@@ -440,11 +441,13 @@ async def create_pull_request(
) -> GitCreatePRResponse:
"""Create a pull request using GitHub CLI.
After PR creation, marks pr_created=True on the task.
After PR creation, marks pr_created=True on the task and records
PR info on the associated work session.
Uses templates to auto-generate PR title/body if not provided.
"""
git_service = get_git_service(db)
task_service = get_task_service(db)
work_session_service = get_work_session_service(db)
try:
workspace = await git_service.get_workspace(data.project_slug, agent.agent_id)
@@ -461,12 +464,22 @@ async def create_pull_request(
# Mark pr_created=True on the task
task_uuid = UUID(data.task_id)
await task_service.mark_pr_created(
task = await task_service.mark_pr_created(
task_id=task_uuid,
pr_number=pr_number,
pr_url=pr_url,
)
# Record PR on the work session so CEO approval guard can pass
if task and task.work_session_id:
await work_session_service.create_pr(
require_uuid(task.work_session_id),
pr_number,
pr_url,
)
await db.commit()
return GitCreatePRResponse(
pr_number=pr_number,
pr_url=pr_url,
@@ -476,14 +489,52 @@ async def create_pull_request(
)
async def _auto_complete_on_merge(
db: DbSession,
task_uuid: UUID,
agent: CurrentAgentContext,
) -> None:
"""Auto-transition task after PR merge based on current status + merger role.
- awaiting_ceo_approval + CEO merger ceo_approve ( completed)
- awaiting_pm_review + PM merger complete (may escalate or finish)
Otherwise leave the task alone.
"""
task_service = get_task_service(db)
task = await task_service.get(task_uuid)
if not task:
return
status_value = task.status.value if hasattr(task.status, "value") else task.status
pm_roles = {AgentRole.CELL_PM, AgentRole.MAIN_PM}
if (
status_value == TaskStatus.AWAITING_CEO_APPROVAL.value
and agent.role == AgentRole.CEO
):
await task_service.ceo_approve(task_uuid)
elif (
status_value == TaskStatus.AWAITING_PM_REVIEW.value
and agent.role in pm_roles
):
await task_service.complete(task_uuid, agent.agent_id)
@router.post("/pr/merge", response_model=GitMergePRResponse)
async def merge_pull_request(
data: GitMergePRRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitMergePRResponse:
"""Merge a pull request using GitHub CLI (PM only)."""
"""Merge a pull request using GitHub CLI (PM/CEO).
After merge, records merge on the work session and auto-completes the
task when the merger holds the role required for the current state
(PM for awaiting_pm_review, CEO for awaiting_ceo_approval).
"""
git_service = get_git_service(db)
task_service = get_task_service(db)
work_session_service = get_work_session_service(db)
try:
workspace = await git_service.get_workspace(data.project_slug, agent.agent_id)
@@ -496,6 +547,21 @@ async def merge_pull_request(
except ServiceError as e:
raise _translate_error(e) from e
task_uuid = UUID(data.task_id)
task = await task_service.get(task_uuid)
# Record merge on the work session (satisfies ceo_approve's pr_status guard)
if task and task.work_session_id:
await work_session_service.merge_pr(
require_uuid(task.work_session_id),
agent.agent_id,
)
# Auto-transition task to completed based on merger role + current state
await _auto_complete_on_merge(db, task_uuid, agent)
await db.commit()
return GitMergePRResponse(
pr_number=data.pr_number,
merged=True,
+4 -2
View File
@@ -76,7 +76,8 @@ async def list_notifications(
if params.pending_ack_only:
# Filter to only notifications not fully acknowledged
notifications = [
n for n in all_notifications
n
for n in all_notifications
if not all(t in n.acked_by for t in n.to_agents)
]
else:
@@ -92,7 +93,8 @@ async def list_notifications(
unread_count = sum(1 for n in notifications if response_agent_id not in n.read_by)
pending_ack_count = sum(
1 for n in notifications
1
for n in notifications
if n.requires_ack and response_agent_id not in n.acked_by
)
items = [notification_to_response(n, response_agent_id) for n in notifications]
+70 -18
View File
@@ -47,6 +47,7 @@ from roboco.api.schemas.tasks import (
transform_update_data,
)
from roboco.db.tables import AgentTable, NotificationTable
from roboco.exceptions import TaskLifecycleError
from roboco.models.base import AgentRole, SubstituteReason, TaskStatus, Team
from roboco.models.task import TaskCreate
from roboco.services.audit import get_audit_service
@@ -93,15 +94,15 @@ async def create_task(
detail="Not authorized to create tasks",
)
# Validate: git tasks require project_id
if data.requires_git and not data.project_id:
# Validate: all tasks require project_id
if not data.project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": {
"code": "PROJECT_REQUIRED",
"message": "Tasks with requires_git=True must have project_id set",
"hint": "Specify project_id or set requires_git=False",
"message": "All tasks require project_id",
"hint": "Specify project_id for the git repository",
}
},
)
@@ -122,9 +123,8 @@ async def create_task(
status=data.status,
sequence=data.sequence, # Task ordering within siblings
dependency_ids=data.dependency_ids, # Dependencies for claim filtering
# Git configuration
# Git configuration (all tasks follow git workflow)
task_type=data.task_type,
requires_git=data.requires_git,
project_id=data.project_id,
)
task = await service.create(req)
@@ -540,6 +540,53 @@ async def claim_task(
return task_to_response(task)
@router.post("/{task_id}/unclaim", response_model=TaskResponse)
async def unclaim_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: Annotated[ClaimRequest | None, Body()] = None,
) -> TaskResponse:
"""
Release a claimed task back to the task pool.
Use this when an agent claimed a task but realizes they shouldn't work on it.
Optionally specify a different agent to hand off to.
"""
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"
)
# Only assigned agent can unclaim
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can unclaim this task",
)
# Optionally hand off to a specific agent
return_to: UUID | None = None
if data and data.agent_id:
return_to = await _resolve_claim_agent_id(db, data.agent_id)
task = await service.unclaim(
task_id,
agent_id=agent.agent_id,
agent_role=agent.role,
return_to_assignee=return_to,
)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot unclaim task - must be in CLAIMED status",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/start", response_model=TaskResponse)
async def start_task(
task_id: UUID,
@@ -561,8 +608,8 @@ async def start_task(
detail="Only the assigned agent can start this task",
)
# Pass agent_id for defense-in-depth validation in service layer
task = await service.start(task_id, agent_id=agent.agent_id)
# Pass agent_id and role for defense-in-depth validation in service layer
task = await service.start(task_id, agent_id=agent.agent_id, agent_role=agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -641,7 +688,7 @@ async def soft_block_task(
)
task = await service.soft_block(
task_id, data.reason, data.blocker_type, data.what_needed
task_id, data.reason, data.blocker_type, data.what_needed, agent.role
)
if not task:
raise HTTPException(
@@ -731,7 +778,7 @@ async def unblock_task(
# Remember the assigned agent before unblocking
assigned_agent_id = task.assigned_to
task = await service.unblock(task_id)
task = await service.unblock(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -789,7 +836,7 @@ async def pause_task(
detail="Only the assigned agent can pause this task",
)
task = await service.pause(task_id)
task = await service.pause(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -820,7 +867,7 @@ async def resume_task(
detail="Only the assigned agent can resume this task",
)
task = await service.resume(task_id)
task = await service.resume(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -851,7 +898,7 @@ async def submit_for_verification(
detail="Only the assigned agent can submit for verification",
)
task = await service.submit_for_verification(task_id)
task = await service.submit_for_verification(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -882,7 +929,7 @@ async def submit_for_qa(
detail="Only the assigned agent can submit for QA",
)
task = await service.submit_for_qa(task_id)
task = await service.submit_for_qa(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -941,7 +988,7 @@ async def pass_qa(
)
notes = data.notes if data else None
task = await service.pass_qa(task_id, notes)
task = await service.pass_qa(task_id, notes, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -983,7 +1030,7 @@ async def fail_qa(
detail="Cannot QA review your own task",
)
task = await service.fail_qa(task_id, data.notes)
task = await service.fail_qa(task_id, data.notes, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1121,7 +1168,7 @@ async def submit_for_pm_review(
)
notes = data.notes if data else None
task = await service.submit_for_pm_review(task_id, notes)
task = await service.submit_for_pm_review(task_id, agent.role.value, notes)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1922,12 +1969,17 @@ async def activate_task(
service = get_task_service(db)
try:
task = await service.activate(task_id)
task = await service.activate(task_id, agent.role)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except TaskLifecycleError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
) from e
await db.commit()
return task_to_response(task)
+1 -1
View File
@@ -148,7 +148,7 @@ async def create_session(
"""
Create a new work session (Developer or PM).
Typically created automatically when claiming a git-enabled task.
Typically created automatically when claiming a task.
"""
_require_developer_or_above(agent, "create work sessions")
+50 -91
View File
@@ -1,7 +1,8 @@
"""
Tasks API Schemas
Request/response models for task endpoints.
Request/response models for task endpoints, plus the conversion helpers
that build responses from ORM rows and normalize update payloads.
"""
from datetime import datetime
@@ -226,10 +227,10 @@ class TaskResponse(BaseModel):
sequence: int # Order number within siblings
nature: TaskNature # Technical or non-technical work
# Task Type & Git Configuration
# Task Type & Git Configuration (all tasks follow git workflow)
task_type: TaskType # code, documentation, research, etc.
requires_git: bool # Whether this task requires git workflow
project_id: UUID | None = None # Project this task works on
project_id: UUID # Project this task works on (required)
project_slug: str | None = None # Project slug for MCP/git tool calls
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
docs_complete: bool = False # Documenter has finished
@@ -443,29 +444,23 @@ class TeamTasksQuery(BaseModel):
limit: int = Field(100, ge=1, le=500)
# =============================================================================
# CONVERTERS (from database/dict to response models)
# =============================================================================
def convert_plan(plan_data: dict | None) -> TaskPlanResponse | None:
"""Convert plan JSON dict to TaskPlanResponse."""
if not plan_data:
return None
sub_tasks = []
for st in plan_data.get("sub_tasks", []):
sub_tasks.append(
SubTaskResponse(
id=st.get("id"),
title=st.get("title", ""),
description=st.get("description"),
completed=st.get("completed", False),
order=st.get("order", 0),
estimated_hours=st.get("estimated_hours"),
notes=st.get("notes"),
)
sub_tasks = [
SubTaskResponse(
id=st.get("id"),
title=st.get("title", ""),
description=st.get("description"),
completed=st.get("completed", False),
order=st.get("order", 0),
estimated_hours=st.get("estimated_hours"),
notes=st.get("notes"),
)
for st in plan_data.get("sub_tasks", [])
]
return TaskPlanResponse(
approach=plan_data.get("approach", ""),
@@ -480,20 +475,17 @@ def convert_checkpoints(checkpoints_data: list | None) -> list[CheckpointRespons
"""Convert checkpoints JSON list to CheckpointResponse list."""
if not checkpoints_data:
return []
result = []
for cp in checkpoints_data:
result.append(
CheckpointResponse(
id=cp.get("id"),
timestamp=cp.get("timestamp"),
agent_id=cp.get("agent_id"),
state_summary=cp.get("state_summary", ""),
remaining_work=cp.get("remaining_work", []),
notes=cp.get("notes"),
)
return [
CheckpointResponse(
id=cp.get("id"),
timestamp=cp.get("timestamp"),
agent_id=cp.get("agent_id"),
state_summary=cp.get("state_summary", ""),
remaining_work=cp.get("remaining_work", []),
notes=cp.get("notes"),
)
return result
for cp in checkpoints_data
]
def convert_progress_updates(
@@ -502,36 +494,30 @@ def convert_progress_updates(
"""Convert progress_updates JSON list to ProgressUpdateResponse list."""
if not updates_data:
return []
result = []
for pu in updates_data:
result.append(
ProgressUpdateResponse(
timestamp=pu.get("timestamp"),
agent_id=pu.get("agent_id"),
message=pu.get("message", ""),
percentage=pu.get("percentage"),
)
return [
ProgressUpdateResponse(
timestamp=pu.get("timestamp"),
agent_id=pu.get("agent_id"),
message=pu.get("message", ""),
percentage=pu.get("percentage"),
)
return result
for pu in updates_data
]
def convert_commits(commits_data: list | None) -> list[CommitRefResponse]:
"""Convert commits JSON list to CommitRefResponse list."""
if not commits_data:
return []
result = []
for cm in commits_data:
result.append(
CommitRefResponse(
hash=cm.get("hash", ""),
message=cm.get("message", ""),
timestamp=cm.get("timestamp"),
author_agent_id=cm.get("author_agent_id"),
)
return [
CommitRefResponse(
hash=cm.get("hash", ""),
message=cm.get("message", ""),
timestamp=cm.get("timestamp"),
author_agent_id=cm.get("author_agent_id"),
)
return result
for cm in commits_data
]
def task_to_response(task: "TaskTable") -> TaskResponse:
@@ -545,16 +531,12 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
priority=task.priority,
sequence=task.sequence,
nature=task.nature,
# Task Type & Git Configuration
task_type=task.task_type,
requires_git=task.requires_git,
project_id=to_python_uuid(task.project_id),
# Parallel Execution Tracking
project_id=require_uuid(task.project_id),
project_slug=task.project.slug if getattr(task, "project", None) else None,
docs_complete=task.docs_complete,
pr_created=task.pr_created,
# PM Approval Tracking
pm_approvals=task.pm_approvals or {},
# Ownership
team=task.team,
created_by=require_uuid(task.created_by),
assigned_to=to_python_uuid(task.assigned_to),
@@ -569,48 +551,36 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
completed_at=task.completed_at,
target_date=task.target_date,
estimated_complexity=task.estimated_complexity,
# Planning
plan=convert_plan(task.plan),
# Execution
checkpoints=convert_checkpoints(task.checkpoints),
progress_updates=convert_progress_updates(task.progress_updates),
# Artifacts
commits=convert_commits(task.commits),
# Documentation
dev_notes=task.dev_notes,
qa_notes=task.qa_notes,
auditor_notes=task.auditor_notes,
quick_context=task.quick_context,
# Review Status
self_verified=task.self_verified,
qa_verified=task.qa_verified,
# Git context from task record
branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None),
)
def task_list_to_response(tasks: list["TaskTable"]) -> list[TaskResponse]:
"""Convert list of TaskTable to list of TaskResponse."""
return [task_to_response(t) for t in tasks]
async def enrich_task_with_context(
task_response: TaskResponse,
db: Any,
include_project: bool = True,
include_work_session: bool = True,
) -> TaskResponse:
"""
Enrich a TaskResponse with related context (project, work session).
Call this when full traceability context is needed.
"""
"""Enrich a TaskResponse with related context (project, work session)."""
task_dict = task_response.model_dump()
# Get project info if task has project_id
if include_project:
# Task doesn't have project_id in response yet, need to fetch from related data
# This requires knowing the project_id from the task record
pass # TODO: Add project_id to TaskResponse or fetch via work session
# Get work session info
if include_work_session and hasattr(task_response, "id"):
query = select(WorkSessionTable).where(
WorkSessionTable.task_id == task_response.id
@@ -630,7 +600,6 @@ async def enrich_task_with_context(
pr_status=work_session.pr_status,
)
# Also get project info from work session
if include_project and work_session.project_id:
proj_query = select(ProjectTable).where(
ProjectTable.id == work_session.project_id
@@ -650,11 +619,6 @@ async def enrich_task_with_context(
return TaskResponse(**task_dict)
def task_list_to_response(tasks: list["TaskTable"]) -> list[TaskResponse]:
"""Convert list of TaskTable to list of TaskResponse."""
return [task_to_response(t) for t in tasks]
def parse_uuid_or_none(value: str | None) -> UUID | None:
"""Parse a string to UUID, returning None if empty or None."""
if not value:
@@ -672,10 +636,7 @@ def _parse_uuid_list(id_strings: list[str] | None) -> list[UUID]:
return [UUID(id_str) for id_str in id_strings if id_str]
# Fields that need UUID parsing (single value)
_SINGLE_UUID_FIELDS = ("assigned_to", "parent_task_id")
# Fields that need UUID list parsing
_UUID_LIST_FIELDS = ("dependency_ids", "blocker_ids")
@@ -683,12 +644,10 @@ def transform_update_data(data: TaskUpdate) -> dict:
"""Transform TaskUpdate input to format suitable for database storage."""
updates = data.model_dump(exclude_unset=True)
# Convert single UUID fields
for field in _SINGLE_UUID_FIELDS:
if field in updates:
updates[field] = parse_uuid_or_none(updates[field])
# Convert UUID list fields
for field in _UUID_LIST_FIELDS:
if field in updates:
updates[field] = _parse_uuid_list(updates[field])
+1 -1
View File
@@ -178,7 +178,7 @@ class Settings(BaseSettings):
# Local LLM for RAG (HyDE, reranking, etc.)
local_llm_model: str = Field(
default="glm-4.7:cloud",
default="glm-5.1:cloud",
description="Local LLM for HyDE/RAG (non-thinking models are faster)",
)
local_llm_base_url: str = Field(
+82 -5
View File
@@ -135,20 +135,19 @@ class TaskTable(Base):
)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
# Task Type & Git Configuration
# Task Type & Git Configuration (all tasks follow git workflow)
task_type: Mapped[TaskType] = mapped_column(
Enum(TaskType), nullable=False, default=TaskType.CODE
)
nature: Mapped[TaskNature] = mapped_column(
Enum(TaskNature), nullable=False, default=TaskNature.TECHNICAL
)
requires_git: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Project & Branch (branch auto-created on claim)
project_id: Mapped[UUID | None] = mapped_column(
project_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="SET NULL"),
nullable=True,
ForeignKey("projects.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
branch_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -265,6 +264,9 @@ class TaskTable(Base):
parent_task: Mapped["TaskTable | None"] = relationship(
"TaskTable", remote_side=[id], lazy="select"
)
project: Mapped["ProjectTable"] = relationship(
"ProjectTable", foreign_keys=[project_id], lazy="joined"
)
# Session links (many-to-many via SessionTaskTable)
session_links: Mapped[list["SessionTaskTable"]] = relationship(
"SessionTaskTable", back_populates="task", lazy="select"
@@ -1395,3 +1397,78 @@ class A2AMessageTable(Base):
postgresql_where=(requires_response.is_(True)),
),
)
# =============================================================================
# ORCHESTRATOR WAITING RECORDS
# =============================================================================
class WaitingRecordTable(Base):
"""Persistent backing for orchestrator agents in WAITING_LONG state.
Previously kept only in `AgentOrchestrator._waiting_records` (in-memory),
so an orchestrator restart stranded every waiting agent permanently.
"""
__tablename__ = "waiting_records"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
agent_id: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, index=True
)
task_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
waiting_for: Mapped[str] = mapped_column(String(64), nullable=False)
waiting_since: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
__table_args__ = (Index("ix_waiting_records_waiting_for", "waiting_for"),)
# =============================================================================
# AUDIT LOG
# =============================================================================
class AuditLogTable(Base):
"""Durable audit events for compliance and the Auditor agent.
Replaces log-only audit. Dot-separated event_type (task.claimed,
session.closed, project.deleted, etc.) + JSON details.
"""
__tablename__ = "audit_log"
id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
agent_id: Mapped[UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
)
target_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
target_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
__table_args__ = (
Index("ix_audit_log_agent_timestamp", "agent_id", "timestamp"),
Index("ix_audit_log_target", "target_type", "target_id"),
)
-4
View File
@@ -57,10 +57,8 @@ from roboco.enforcement.task_lifecycle import (
validate_task_transition,
)
from roboco.enforcement.task_ownership import (
TaskClaimContext,
TaskOwnershipError,
can_review_task,
validate_task_claim,
validate_task_ownership,
)
@@ -74,7 +72,6 @@ __all__ = [
"GitRequirementError",
"JournalAccessDeniedError",
"NotificationPermissionError",
"TaskClaimContext",
"TaskLifecycleError",
"TaskOwnershipError",
"can_agent_transition",
@@ -93,7 +90,6 @@ __all__ = [
"validate_git_requirements",
"validate_journal_access",
"validate_notification_permission",
"validate_task_claim",
"validate_task_ownership",
"validate_task_transition",
]
+7
View File
@@ -49,6 +49,13 @@ def validate_a2a_access(from_agent: str, to_agent: str) -> bool:
Raises:
A2AAccessDeniedError: If A2A not permitted
"""
if from_agent == to_agent:
raise A2AAccessDeniedError(
from_agent=from_agent,
to_agent=to_agent,
reason="cannot A2A yourself — use your own journal or task notes instead",
)
allowed, error = can_a2a_direct(from_agent, to_agent)
if not allowed:
+19 -27
View File
@@ -3,15 +3,14 @@ Task Lifecycle State Machine Enforcement
Validates task state transitions follow the defined lifecycle.
Git Integration:
Tasks with requires_git=True have additional requirements:
- awaiting_documentation awaiting_pm_review:
requires BOTH docs_complete AND pr_created
- awaiting_pm_review awaiting_ceo_approval:
PR should exist (pr_number set)
- awaiting_ceo_approval completed: PR should be merged (CEO merges)
Git Integration (all tasks follow git workflow):
- awaiting_documentation awaiting_pm_review:
requires BOTH docs_complete AND pr_created
- awaiting_pm_review awaiting_ceo_approval:
PR should exist (pr_number set)
- awaiting_ceo_approval completed: PR should be merged (CEO merges)
See validate_git_requirements() for enforcement.
See validate_git_requirements() for enforcement.
"""
from dataclasses import dataclass
@@ -117,9 +116,11 @@ ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], list[str]] = {
# QA direct assignment: QA can pass/fail from in_progress when directly assigned
("in_progress", "awaiting_documentation"): ["qa"], # QA pass
("in_progress", "needs_revision"): ["qa"], # QA fail
# Only documenter can claim docs tasks and mark complete
# Only documenter can claim docs tasks
("awaiting_documentation", "claimed"): ["documenter"],
("awaiting_documentation", "awaiting_pm_review"): ["documenter"],
# Parallel completion: either documenter or developer can trigger transition
# (whoever finishes their work last triggers the transition)
("awaiting_documentation", "awaiting_pm_review"): ["documenter", "developer"],
# Only PM can claim PM review tasks
("awaiting_pm_review", "claimed"): _CANCEL_ROLES,
# Only PM can complete tasks (either after PM review or their own work)
@@ -270,9 +271,8 @@ class GitRequirementError(Exception):
@dataclass
class GitContext:
"""Git-related task state for validation."""
"""Git-related task state for validation (all tasks follow git workflow)."""
requires_git: bool = False
docs_complete: bool = False
pr_created: bool = False
pr_number: int | None = None
@@ -287,7 +287,7 @@ def validate_git_requirements(
"""
Validate git-related requirements for task transitions.
For tasks with requires_git=True, additional requirements apply:
All tasks follow git workflow:
- awaiting_documentation awaiting_pm_review:
Requires BOTH docs_complete=True AND pr_created=True
@@ -296,13 +296,13 @@ def validate_git_requirements(
- awaiting_pm_review awaiting_ceo_approval:
Requires pr_number to be set (PR exists)
- claimed in_progress (git tasks):
- claimed in_progress:
Should have branch_name set (auto-created on claim)
Args:
current_status: Current task status
target_status: Target task status
git_ctx: Git context with workflow state (None = no git requirements)
git_ctx: Git context with workflow state
Returns:
True if all requirements met
@@ -310,8 +310,8 @@ def validate_git_requirements(
Raises:
GitRequirementError: If git requirements not met
"""
# Non-git tasks or no context have no git requirements
if git_ctx is None or not git_ctx.requires_git:
# No context means no validation needed (context not provided)
if git_ctx is None:
return True
transition = (current_status, target_status)
@@ -353,7 +353,7 @@ def validate_git_requirements(
),
)
# claimed → in_progress (for git tasks)
# claimed → in_progress
# Should have a branch ready
if transition == ("claimed", "in_progress") and not git_ctx.branch_name:
raise GitRequirementError(
@@ -369,11 +369,7 @@ def validate_git_requirements(
return True
def check_parallel_completion(
docs_complete: bool,
pr_created: bool,
requires_git: bool = True,
) -> bool:
def check_parallel_completion(docs_complete: bool, pr_created: bool) -> bool:
"""
Check if parallel execution in awaiting_documentation is complete.
@@ -386,12 +382,8 @@ def check_parallel_completion(
Args:
docs_complete: Whether documenter finished
pr_created: Whether developer created PR
requires_git: Whether task requires git (if False, only docs needed)
Returns:
True if ready to transition to awaiting_pm_review
"""
if not requires_git:
return docs_complete
return docs_complete and pr_created
-78
View File
@@ -5,9 +5,7 @@ Validates task ownership and claim rules.
"""
from roboco.agents_config import get_agent_role, get_agent_team
from roboco.enforcement.task_lifecycle import is_waiting_state
from roboco.exceptions import RobocoError
from roboco.models.enforcement import TaskClaimContext
class TaskOwnershipError(RobocoError):
@@ -66,17 +64,6 @@ def validate_task_ownership(
role = get_agent_role(agent_id)
agent_team = get_agent_team(agent_id)
# CLAIM action - task must be unassigned
if action == "claim":
if task_assigned_to is not None:
raise TaskOwnershipError(
agent_id=agent_id,
task_id=task_id,
action=action,
message=f"Task is already assigned to {task_assigned_to}",
)
return True
# REASSIGN action - only PMs
if action == "reassign":
if role not in ("cell_pm", "main_pm"):
@@ -112,71 +99,6 @@ def validate_task_ownership(
return True
def validate_task_claim(ctx: TaskClaimContext) -> bool:
"""
Validate agent can claim a specific task.
Rules:
- Task must be in 'pending' status
- Agent cannot have an active task (claimed, in_progress, verifying)
- Agent must resume paused tasks before claiming new ones
- Agent should be in the same team as the task (warning, not error)
Args:
ctx: Task claim context with all required validation data
Returns:
True if can claim
Raises:
TaskOwnershipError: If cannot claim
"""
# Check task is pending
if ctx.task_status != "pending":
msg = (
f"Cannot claim task in '{ctx.task_status}' status. "
"Only 'pending' tasks can be claimed."
)
raise TaskOwnershipError(
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=msg,
)
# Check for paused tasks
if ctx.agent_paused_tasks:
paused_ids = [t.get("id") for t in ctx.agent_paused_tasks]
raise TaskOwnershipError(
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=f"You have {len(ctx.agent_paused_tasks)} paused task(s). "
f"Resume paused work before claiming new tasks. Paused: {paused_ids}",
)
# Check for active tasks
active = [
t for t in ctx.agent_active_tasks if not is_waiting_state(t.get("status", ""))
]
if active:
raise TaskOwnershipError(
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=f"You already have an active task: {active[0].get('id')}. "
"Complete or pause it before claiming new work.",
)
# Check team match (warning only - agents can claim cross-team if needed)
agent_team = get_agent_team(ctx.agent_id)
if agent_team and agent_team != ctx.task_team:
# This is allowed but unusual - could log a warning
pass
return True
def can_review_task(
agent_id: str,
task_developed_by: str | None,
+9 -1
View File
@@ -7,6 +7,8 @@ Replaces the pub/sub-based EventBus with persistence and consumer groups.
import asyncio
import contextlib
import os
import socket
from collections.abc import Callable, Coroutine
from typing import Any
@@ -46,7 +48,13 @@ class StreamEventBus:
group_name: str | None = None,
):
self.redis_url = redis_url or settings.redis_url
self.consumer_name = consumer_name or f"consumer-{id(self)}"
# Default consumer name is stable across restarts of the same process
# (host + pid), so pending messages don't get orphaned to a new
# id(self)-based name every time the orchestrator restarts. Redis
# consumer groups still auto-reassign via xclaim after idle_time.
self.consumer_name = consumer_name or (
f"consumer-{socket.gethostname()}-{os.getpid()}"
)
self.group_name = group_name or self.DEFAULT_GROUP
self._redis: redis.Redis | None = None
self._handlers: dict[EventType, list[EventHandler]] = {}
+12 -2
View File
@@ -171,8 +171,18 @@ async def _check_pending_a2a(
f"Already sent A2A to {target_agent} about this task.",
hint="Wait for their response before sending another message.",
)
except Exception:
pass # Non-critical check, allow send if check fails
except Exception as e:
# Non-critical check; allow send if check fails, but surface the
# failure in logs so repeated API flakiness is visible instead
# of silently bypassing the duplicate-A2A guard.
import structlog
structlog.get_logger().warning(
"A2A pending check failed; allowing send",
from_agent=from_agent,
target_agent=target_agent,
error=str(e),
)
return None
+7 -11
View File
@@ -169,7 +169,7 @@ class TaskCreateInput(BaseModel):
- sequence: Lower numbers execute first (1, 2, 3...)
- dependency_ids: Tasks that must complete before this one can be claimed
PROJECT: For git-enabled tasks, project_slug is required.
PROJECT: project_slug is required (all tasks follow git workflow).
- Use 'roboco' for internal RoboCo codebase work
- Use roboco_project_list() to see available projects
"""
@@ -182,23 +182,19 @@ class TaskCreateInput(BaseModel):
..., min_length=1, description="At least one acceptance criterion"
)
team: str = Field(..., description="Team: backend, frontend, ux_ui")
# Project selection - required for git tasks
project_slug: str | None = Field(
default=None,
# Project selection - required for all tasks (git workflow is mandatory)
project_slug: str = Field(
...,
description=(
"Project slug (e.g., 'roboco', 'roboco-panel'). "
"Required when requires_git=True. Use 'roboco' for internal codebase."
"Required for all tasks. Use 'roboco' for internal codebase."
),
)
requires_git: bool = Field(
default=True,
description="Whether task requires git. If True, project_slug is required.",
)
task_type: str = Field(
default="code",
description=(
"Task type: code (git work), documentation, research, planning, "
"design, administrative. For subtasks, inherits from parent if not set."
"Task type: code, documentation, research, planning, "
"design, administrative. All types follow git workflow."
),
)
parent_task_id: str | None = Field(
+29
View File
@@ -8,6 +8,7 @@ Tools (Core - all agents):
- roboco_task_scan: List available tasks (paused, assigned, available)
- roboco_task_get: Get task details
- roboco_task_claim: Claim a task
- roboco_task_unclaim: Release a claimed task back to the pool
- roboco_task_plan: Submit implementation plan
- roboco_task_start: Start working on task
- roboco_task_progress: Update progress
@@ -92,6 +93,7 @@ from roboco.mcp.tasks.handlers import (
handle_task_submit_verification,
handle_task_substitute,
handle_task_unblock,
handle_task_unclaim,
)
from roboco.mcp.utils import ApiClient
@@ -148,6 +150,33 @@ def _register_core_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None
"""
return await handle_task_claim(client, task_id, agent_id)
@mcp.tool()
async def roboco_task_unclaim(
task_id: str, hand_off_to: str | None = None
) -> dict[str, Any]:
"""
Release a claimed task back to the pool.
Use this when you claimed a task but realize you shouldn't work on it:
- Task is out of your team's scope
- Task requires a different role
- You need to prioritize other work
- Better suited for another agent
ENFORCEMENT:
- Task must be in 'claimed' status (not yet started)
- You must be the agent who claimed it
- If task is in_progress, use roboco_task_substitute instead
Args:
task_id: The task UUID to release
hand_off_to: Optional agent slug to hand off to (e.g., "be-dev-2")
Returns:
Confirmation with next steps
"""
return await handle_task_unclaim(client, task_id, agent_id, hand_off_to)
@mcp.tool()
async def roboco_task_plan(
task_id: str,
+2 -1
View File
@@ -9,7 +9,7 @@ from roboco.mcp.tasks.handlers.blocking import (
handle_task_pause,
handle_task_unblock,
)
from roboco.mcp.tasks.handlers.claim import handle_task_claim
from roboco.mcp.tasks.handlers.claim import handle_task_claim, handle_task_unclaim
from roboco.mcp.tasks.handlers.lifecycle import (
handle_agent_idle,
handle_ceo_approve,
@@ -79,4 +79,5 @@ __all__ = [
"handle_task_submit_verification",
"handle_task_substitute",
"handle_task_unblock",
"handle_task_unclaim",
]
+73 -10
View File
@@ -14,6 +14,7 @@ from roboco.mcp.tasks.handlers._helpers import (
get_project_context,
resolve_agent_uuid_cached,
validate_task_claimable,
validate_task_ownership,
)
from roboco.mcp.utils import ApiClient, format_error_response
@@ -78,18 +79,15 @@ async def _validate_git_requirements(
"""Validate git requirements for hierarchical branching.
Rules:
- Must have project_id (always)
- Must have project_id (always - validated at creation)
- Root tasks: branch auto-created from default branch
- Subtasks: need parent to have branch (for forking)
"""
if not task.get("requires_git"):
return None
# Must have project
# Must have project (should always be true - validated at creation)
if not task.get("project_id"):
return format_error_response(
"PROJECT_REQUIRED",
"Git tasks require a project for branch creation.",
"Tasks require a project for branch creation.",
{"task_id": task_id},
hint="Assign a project to this task.",
)
@@ -104,7 +102,7 @@ async def _validate_git_requirements(
parent_resp = await client.get(f"/tasks/{parent_id}")
if parent_resp.ok:
parent = parent_resp.json()
if parent.get("requires_git") and not parent.get("branch_name"):
if not parent.get("branch_name"):
return format_error_response(
"PARENT_BRANCH_REQUIRED",
"Parent task must be claimed first to create its branch.",
@@ -191,10 +189,9 @@ async def _run_claim_validations(
if error := await _validate_git_requirements(client, task, task_id):
return error
# TODO: Re-enable when sequence workflow is refined
# Validate sibling sequence order (earlier sequence must complete first)
# if error := await _validate_sibling_sequence(client, task):
# return error
if error := await _validate_sibling_sequence(client, task):
return error
return None
@@ -304,3 +301,69 @@ def _build_claim_guidance(claimed_task: dict, original_task: dict) -> str:
"3. Call roboco_task_plan(task_id, approach, steps)\n"
"4. Then call roboco_task_start(task_id)"
)
async def handle_task_unclaim(
client: ApiClient, task_id: str, agent_id: str, hand_off_to: str | None = None
) -> dict[str, Any]:
"""Handle releasing a claimed task back to the pool.
Use this when you claimed a task but realize you shouldn't work on it.
Optionally specify another agent to hand off to.
Args:
client: API client
task_id: Task to unclaim
agent_id: Agent releasing the task
hand_off_to: Optional agent slug to assign the task to
"""
# Fetch task first
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
assert task is not None
# Validate ownership
if error := await validate_task_ownership(task, agent_id, client):
return error
# Validate status
if task.get("status") != "claimed":
return format_error_response(
"INVALID_STATE",
"Can only unclaim tasks in 'claimed' status. "
f"Current: '{task.get('status')}'",
hint="If task is in_progress, use roboco_task_pause() instead.",
)
# Build request payload
payload: dict[str, Any] = {}
if hand_off_to:
payload["agent_id"] = hand_off_to
# Execute unclaim
resp = await client.post(f"/tasks/{task_id}/unclaim", json=payload or None)
if not resp.ok:
return format_error_response(
"UNCLAIM_FAILED",
f"Failed to unclaim task: {resp.text}",
{"status_code": resp.status_code},
)
result = resp.json()
if hand_off_to:
guidance = (
f"Task released and handed off to {hand_off_to}.\n"
"They can now claim and work on it.\n"
"Call roboco_task_scan() to find your next task."
)
else:
guidance = (
"Task released back to the pool.\n"
"It can now be claimed by any eligible agent.\n"
"Call roboco_task_scan() to find your next task."
)
return format_task_response(result, "SCAN_FOR_WORK", guidance)
+13 -3
View File
@@ -667,7 +667,14 @@ async def handle_task_cancel(
async def _check_in_progress_tasks(client: ApiClient) -> dict[str, Any] | None:
"""Check for in-progress tasks. Returns error or None."""
"""Check for in-progress tasks. Returns error or None.
If the scan itself fails (API unreachable etc.), we log and conservatively
allow the idle an unreachable API is a bigger problem that the agent
can't resolve here, and blocking the idle handshake only compounds it.
"""
import structlog
try:
scan_resp = await client.get("/tasks/my", params={"status": "in_progress"})
if scan_resp.ok:
@@ -681,8 +688,11 @@ async def _check_in_progress_tasks(client: ApiClient) -> dict[str, Any] | None:
"You have in-progress tasks. Handle them before going idle.",
{"tasks": task_info},
)
except Exception:
pass
except Exception as e:
structlog.get_logger().warning(
"Failed to check in-progress tasks before idle",
error=str(e),
)
return None
+131 -133
View File
@@ -252,19 +252,15 @@ async def _validate_project(
client: ApiClient,
input_data: TaskCreateInput,
) -> tuple[str | None, dict[str, Any] | None]:
"""Validate project for git-enabled tasks.
"""Validate project for task (all tasks require project).
Returns (project_id, None) on success, or (None, error) on failure.
"""
# Non-git tasks don't need project validation
if not input_data.requires_git:
return None, None
# Git tasks require project_slug
# All tasks require project_slug
if not input_data.project_slug:
return None, format_error_response(
"PROJECT_REQUIRED",
"Git tasks require a project. Set project_slug or requires_git=False.",
"All tasks require a project. Specify project_slug.",
{"hint": "Use roboco_project_list() to see available projects."},
)
@@ -358,8 +354,8 @@ def _build_task_payload(
"nature": input_data.nature,
"status": input_data.status, # Always included, defaults to "backlog"
"sequence": input_data.sequence, # Task ordering (lower = first)
"requires_git": input_data.requires_git,
"task_type": task_type,
"project_id": project_id, # Required for all tasks
}
if input_data.parent_task_id:
payload["parent_task_id"] = input_data.parent_task_id
@@ -367,8 +363,6 @@ def _build_task_payload(
# Each task in the hierarchy gets its own branch forked from parent's branch.
if input_data.dependency_ids:
payload["dependency_ids"] = input_data.dependency_ids
if project_id:
payload["project_id"] = project_id
return payload
@@ -392,6 +386,59 @@ def _format_create_guidance(task: dict[str, Any], assigned_to: str | None) -> st
return guidance
def _validate_description_length(
input_data: TaskCreateInput,
) -> dict[str, Any] | None:
"""Enforce a minimum description length; stricter for subtasks."""
description = input_data.description.strip()
is_subtask = input_data.parent_task_id is not None
min_len = 50 if is_subtask else 30
if len(description) >= min_len:
return None
if is_subtask:
return format_error_response(
"SUBTASK_DESCRIPTION_REQUIRED",
f"Subtasks MUST have detailed descriptions "
f"(min {min_len} chars, got {len(description)}). "
"Explain what to do, why, and expected outcome.",
{
"parent_task_id": input_data.parent_task_id,
"description_length": len(description),
},
)
return format_error_response(
"TASK_DESCRIPTION_REQUIRED",
f"Tasks MUST have meaningful descriptions "
f"(min {min_len} chars, got {len(description)}). "
"Main PM must explain what Cell PM should accomplish.",
{
"description_length": len(description),
"guidance": "Include: goal, context, and acceptance criteria.",
},
)
def _validate_assignee_role(
caller_role: str | None,
assignee: str | None,
task_type: str,
) -> dict[str, Any] | None:
"""PMs cannot assign code tasks to other PMs (or themselves)."""
pm_roles = ("main_pm", "cell_pm")
if caller_role not in pm_roles or task_type != "code" or not assignee:
return None
assignee_role = get_agent_role(assignee)
if assignee_role not in pm_roles:
return None
return format_error_response(
"PM_CANNOT_OWN_CODE_TASKS",
"PMs cannot be assigned code tasks. Assign to a developer.",
{"assignee": assignee, "assignee_role": assignee_role},
hint="Assign to: be-dev-1, be-dev-2, fe-dev-1, etc.",
)
async def _validate_task_create_inputs(
client: ApiClient, input_data: TaskCreateInput, agent_id: str
) -> tuple[str | None, dict[str, Any] | None]:
@@ -402,69 +449,72 @@ async def _validate_task_create_inputs(
if error := _validate_cell_pm_team(agent_id, input_data.team):
return None, error
# ENFORCEMENT: All tasks require meaningful descriptions
# Main PM delegating to Cell PM needs to explain the work clearly
description = input_data.description.strip()
min_root_description_len = 30
min_subtask_description_len = 50
if error := _validate_description_length(input_data):
return None, error
is_subtask = input_data.parent_task_id is not None
min_len = min_subtask_description_len if is_subtask else min_root_description_len
if is_subtask and len(description) < min_len:
# Subtasks need more detail (Cell PM → Dev)
return None, format_error_response(
"SUBTASK_DESCRIPTION_REQUIRED",
f"Subtasks MUST have detailed descriptions "
f"(min {min_len} chars, got {len(description)}). "
"Explain what to do, why, and expected outcome.",
{
"parent_task_id": input_data.parent_task_id,
"description_length": len(description),
},
)
elif not is_subtask and len(description) < min_len:
# Root tasks also need context (Main PM → Cell PM)
return None, format_error_response(
"TASK_DESCRIPTION_REQUIRED",
f"Tasks MUST have meaningful descriptions "
f"(min {min_len} chars, got {len(description)}). "
"Main PM must explain what Cell PM should accomplish.",
{
"description_length": len(description),
"guidance": "Include: goal, context, and acceptance criteria.",
},
)
# Validate assignee BEFORE creating task
assignee = input_data.assigned_to
if assignee:
error = validate_assignee_can_work_on_team(assignee, input_data.team)
if error:
return None, error
if assignee and (
error := validate_assignee_can_work_on_team(assignee, input_data.team)
):
return None, error
# ENFORCEMENT: PMs cannot assign code tasks to PMs (including themselves)
caller_role = get_agent_role(agent_id)
pm_roles = ("main_pm", "cell_pm")
is_pm_assigning_code = (
caller_role in pm_roles
and input_data.task_type == "code"
and assignee
)
if is_pm_assigning_code and assignee: # assignee guaranteed by condition above
assignee_role = get_agent_role(assignee)
if assignee_role in pm_roles:
return None, format_error_response(
"PM_CANNOT_OWN_CODE_TASKS",
"PMs cannot be assigned code tasks. Assign to a developer.",
{"assignee": assignee, "assignee_role": assignee_role},
hint="Assign to: be-dev-1, be-dev-2, fe-dev-1, etc.",
)
if error := _validate_assignee_role(caller_role, assignee, input_data.task_type):
return None, error
# Validate project for git-enabled tasks
return await _validate_project(client, input_data)
async def _resolve_parent_task(
client: ApiClient, parent_task_id: str | None
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Fetch parent task and enforce the claim-before-subtask rule.
Returns (parent_task, error). parent_task is None when no parent_task_id
is given or the fetch fails; error is non-None if the parent is not yet
claimed.
"""
if not parent_task_id:
return None, None
parent_resp = await client.get(f"/tasks/{parent_task_id}")
if not parent_resp.ok:
return None, None
parent_task = parent_resp.json()
if parent_task.get("claimed_by"):
return parent_task, None
return parent_task, format_error_response(
"CLAIM_REQUIRED",
"You must CLAIM this task before creating subtasks.",
{
"parent_task_id": parent_task["id"],
"parent_status": parent_task.get("status"),
"parent_claimed_by": parent_task.get("claimed_by"),
"workflow": "SCAN → CLAIM → PLAN → SUBTASKS",
},
hint=f"Call roboco_task_claim('{parent_task['id']}') first.",
)
async def _apply_assignment(
client: ApiClient, task: dict[str, Any], assigned_to: str | None
) -> tuple[dict[str, Any], str | None]:
"""Apply assignment to a newly-created task. Returns (task, failure_msg)."""
if not assigned_to:
return task, None
assigned_task, assign_error = await assign_task_to_agent(
client, task["id"], assigned_to
)
if assigned_task:
return assigned_task, None
failure_msg = (
assign_error.get("guidance", "Assignment failed") if assign_error else None
)
return task, failure_msg
async def handle_task_create(
client: ApiClient, input_data: TaskCreateInput, agent_id: str
) -> dict[str, Any]:
@@ -473,43 +523,11 @@ async def handle_task_create(
if error:
return error
# Fetch parent task for inheritance (task_type, etc.)
parent_task = None
if input_data.parent_task_id:
parent_resp = await client.get(f"/tasks/{input_data.parent_task_id}")
if parent_resp.ok:
parent_task = parent_resp.json()
# HARD ENFORCEMENT: You must CLAIM a task before creating subtasks for it.
# Workflow: SCAN → CLAIM → PLAN → SUBTASKS. No skipping CLAIM.
# Check claimed_by directly - status can be misleading (paused without claim, etc.)
if parent_task:
parent_claimed_by = parent_task.get("claimed_by")
if not parent_claimed_by:
return format_error_response(
"CLAIM_REQUIRED",
"You must CLAIM this task before creating subtasks.",
{
"parent_task_id": parent_task["id"],
"parent_status": parent_task.get("status"),
"parent_claimed_by": parent_claimed_by,
"workflow": "SCAN → CLAIM → PLAN → SUBTASKS",
},
hint=f"Call roboco_task_claim('{parent_task['id']}') first.",
)
# GUARDRAIL: If parent requires git, child must also require git (hierarchy)
if parent_task and parent_task.get("requires_git") and not input_data.requires_git:
return format_error_response(
"GIT_INHERITANCE_REQUIRED",
"Subtasks of git tasks must also require git.",
{
"parent_task_id": parent_task["id"],
"parent_requires_git": True,
"child_requires_git": input_data.requires_git,
},
hint="Set requires_git=True (or omit it, defaults to True).",
)
parent_task, parent_error = await _resolve_parent_task(
client, input_data.parent_task_id
)
if parent_error:
return parent_error
payload = _build_task_payload(input_data, project_id, parent_task)
@@ -528,30 +546,16 @@ async def handle_task_create(
{"status_code": create_resp.status_code, "detail": create_resp.text},
)
task = create_resp.json()
# CRITICAL: Handle assignment failures explicitly - DO NOT silently discard errors
assignment_failed = False
assignment_error_msg = ""
if input_data.assigned_to:
assigned_task, assign_error = await assign_task_to_agent(
client, task["id"], input_data.assigned_to
)
if assigned_task:
task = assigned_task
elif assign_error:
# Assignment FAILED - this is critical, don't silently ignore!
assignment_failed = True
assignment_error_msg = assign_error.get("guidance", "Assignment failed")
# Log but continue - task was created but assignment failed
task, assignment_failure = await _apply_assignment(
client, create_resp.json(), input_data.assigned_to
)
guidance = _format_create_guidance(task, input_data.assigned_to)
# Add CRITICAL WARNING if assignment failed
if assignment_failed:
if assignment_failure:
guidance += (
f"\n\n🚨 CRITICAL: Assignment to '{input_data.assigned_to}' FAILED! "
f"Error: {assignment_error_msg}. "
f"Error: {assignment_failure}. "
"Task was created but NOT assigned to intended agent. "
"You may need to manually assign using roboco_task_assign()."
)
@@ -634,18 +638,13 @@ async def _check_assignment_guardrails(
},
)
# GUARDRAIL: Git tasks need branch before assigning to developers
if (
task.get("requires_git")
and not task.get("branch_name")
and assignee_role == "developer"
):
# GUARDRAIL: Tasks need branch before assigning to developers
if not task.get("branch_name") and assignee_role == "developer":
return format_error_response(
"NO_BRANCH_FOR_GIT_TASK",
"Git task must have a branch before assigning to developer.",
"NO_BRANCH_FOR_TASK",
"Task must have a branch before assigning to developer.",
{
"task_id": task_id,
"requires_git": True,
"has_branch": False,
},
hint=(
@@ -880,10 +879,9 @@ async def handle_task_activate(
{"role": get_agent_role(agent_id)},
)
# TODO: Re-enable when sequence workflow is refined
# Validate parent is started before activating subtask
# if error := await _validate_activation_sequence(client, task_id):
# return error
if error := await _validate_activation_sequence(client, task_id):
return error
try:
resp = await client.post(f"/tasks/{task_id}/activate")
+5 -6
View File
@@ -130,7 +130,7 @@ async def _safe_checkout(
async def handle_task_start(
client: ApiClient, task_id: str, agent_id: str
) -> dict[str, Any]:
"""Handle task start with auto-checkout for git tasks."""
"""Handle task start with auto-checkout."""
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
@@ -139,12 +139,11 @@ async def handle_task_start(
if error := await validate_task_start(task, agent_id, client):
return error
# Auto-checkout for git tasks
# Auto-checkout for task (all tasks follow git workflow)
branch_name = task.get("branch_name")
project_slug = task.get("project_slug")
requires_git = task.get("requires_git", False)
if requires_git and branch_name and project_slug:
if branch_name and project_slug:
checkout_error = await _safe_checkout(
client, project_slug, branch_name, agent_id
)
@@ -157,9 +156,9 @@ async def handle_task_start(
"START_FAILED", "Failed to start task", {"api_error": start_resp.text}
)
# Build guidance based on whether git checkout happened
# Build guidance with git checkout info
guidance = "Task started. Work through your plan step by step:\n"
if requires_git and branch_name:
if branch_name:
guidance += (
f"✓ Checked out branch: {branch_name}\n"
f" Workspace: /data/workspaces/{project_slug}/...\n"
+9 -10
View File
@@ -134,12 +134,12 @@ ABANDONED = "abandoned" # Session cancelled
### TaskType (from `base.py`)
```python
CODE = "code" # Technical work - requires git workflow
DOCUMENTATION = "documentation" # May or may not need git
RESEARCH = "research" # Investigation/analysis - no git
PLANNING = "planning" # Planning/design tasks - no git
DESIGN = "design" # UX/UI design tasks - no git
ADMINISTRATIVE = "administrative" # Administrative tasks - no git
CODE = "code" # Source code changes
DOCUMENTATION = "documentation" # Documentation updates
RESEARCH = "research" # Research findings committed as notes
PLANNING = "planning" # Plans/architecture committed as docs
DESIGN = "design" # Designs/specs committed as assets
ADMINISTRATIVE = "administrative" # Process docs committed
```
### BranchReason (from `project.py`)
@@ -165,12 +165,11 @@ class Task:
created_by: UUID
assigned_to: UUID | None
# Task Type & Git Configuration
# Task Type
task_type: TaskType # code, documentation, research, planning, design, administrative
requires_git: bool # Whether git workflow applies
# Project & Branch (branch auto-created on claim)
project_id: UUID | None
# Project & Branch (all tasks follow git workflow, branch auto-created on claim)
project_id: UUID
branch_name: str | None
work_session_id: UUID | None
+8 -7
View File
@@ -37,14 +37,14 @@ class TaskStatus(str, Enum):
class TaskType(str, Enum):
"""Task type classification - determines if git workflow applies."""
"""Task classification. ALL types follow git workflow."""
CODE = "code" # Technical - full git workflow
DOCUMENTATION = "documentation" # May or may not need git
RESEARCH = "research" # No git
PLANNING = "planning" # No git
DESIGN = "design" # No git
ADMINISTRATIVE = "administrative" # No git
CODE = "code" # Source code changes
DOCUMENTATION = "documentation" # Documentation updates
RESEARCH = "research" # Research findings committed as notes
PLANNING = "planning" # Plans/architecture committed as docs
DESIGN = "design" # Designs/specs committed as assets
ADMINISTRATIVE = "administrative" # Process docs committed
class TaskNature(str, Enum):
@@ -148,6 +148,7 @@ class NotificationType(str, Enum):
BLOCKER_ESCALATION = "blocker_escalation"
REVIEW_REQUEST = "review_request"
DOCUMENTATION_REQUEST = "documentation_request"
APPROVAL = "approval" # Board-level approval requests (PO/HM/Main PM)
ALERT = "alert"
BROADCAST = "broadcast"
KNOWLEDGE_SHARE = "knowledge_share" # Cross-agent learning notification
-29
View File
@@ -1,29 +0,0 @@
"""
Enforcement Models
Domain types for enforcement rules.
"""
from dataclasses import dataclass
@dataclass
class TaskClaimContext:
"""Context for validating a task claim."""
agent_id: str
task_id: str
task_status: str
task_team: str
agent_active_tasks: list[dict]
agent_paused_tasks: list[dict]
@dataclass
class OwnershipContext:
"""Context for validating task ownership."""
agent_id: str
task_id: str
current_owner: str | None
current_status: str
+3 -3
View File
@@ -26,7 +26,7 @@ class OrchestratorAgentState(str, Enum):
@dataclass
class SpawnGitContext:
"""Git context passed when spawning an agent for a git-enabled task."""
"""Git context passed when spawning an agent for a task."""
project_slug: str | None = None
branch_name: str | None = None
@@ -79,8 +79,8 @@ class WaitingRecord:
# Model mapping for cost optimization
MODEL_MAP: dict[str, str] = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-5-20251101",
"opus": "claude-opus-4-7",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
}
+10 -17
View File
@@ -165,21 +165,16 @@ class Task(TimestampMixin):
default=2, ge=0, le=3, description="0=P0(highest), 3=P3(lowest)"
)
# Task Type & Git Configuration
# Task Type & Git Configuration (all tasks follow git workflow)
task_type: TaskType = Field(
default=TaskType.CODE, description="Type of task (code, research, etc.)"
)
nature: TaskNature = Field(
default=TaskNature.TECHNICAL, description="Technical or non-technical work"
)
requires_git: bool = Field(
default=True, description="Whether this task requires git workflow"
)
# Project & Branch (branch auto-created on claim)
project_id: UUID | None = Field(
default=None, description="Project this task works on"
)
project_id: UUID = Field(..., description="Project this task works on")
branch_name: str | None = Field(
default=None, description="Branch created for this task"
)
@@ -193,9 +188,7 @@ class Task(TimestampMixin):
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
docs_complete: bool = Field(default=False, description="Documenter has finished")
pr_created: bool = Field(
default=False, description="Developer has created PR (if requires_git)"
)
pr_created: bool = Field(default=False, description="Developer has created PR")
# PM Approval Tracking (for AWAITING_PM_REVIEW phase)
pm_approvals: dict[str, bool] = Field(
@@ -302,11 +295,10 @@ class TaskCreate(RobocoBase):
description="Task IDs that must complete before this task can be claimed",
)
# Git configuration
# Git configuration (all tasks follow git workflow)
task_type: TaskType = TaskType.CODE
nature: TaskNature = TaskNature.TECHNICAL
requires_git: bool = True
project_id: UUID | None = None
project_id: UUID # Required - all tasks need a project
class TaskUpdate(RobocoBase):
@@ -327,7 +319,6 @@ class TaskUpdate(RobocoBase):
# Git fields
task_type: TaskType | None = None
nature: TaskNature | None = None
requires_git: bool | None = None
project_id: UUID | None = None
branch_name: str | None = None
pr_number: int | None = None
@@ -346,11 +337,15 @@ class TaskUpdate(RobocoBase):
class TaskCreateRequest:
"""Request data for creating a task via TaskService."""
# Required fields (no defaults)
title: str
description: str
acceptance_criteria: list[str]
team: Team
created_by: UUID
project_id: UUID # Required - all tasks need a project for git workflow
# Optional fields (with defaults)
priority: int = 2
parent_task_id: UUID | None = None
assigned_to: UUID | None = None
@@ -362,8 +357,6 @@ class TaskCreateRequest:
sequence: int = 0 # Order within siblings (lower = first)
dependency_ids: list[UUID] = field(default_factory=list)
# Git configuration
# Git configuration (all tasks follow git workflow)
task_type: TaskType = field(default=TaskType.CODE)
nature: TaskNature = field(default=TaskNature.TECHNICAL)
requires_git: bool = True
project_id: UUID | None = None
+2 -2
View File
@@ -2,7 +2,7 @@
WorkSession Model
Tracks an agent's working session on a task, including branch management,
commits, and PR tracking. Created when a developer claims a git-enabled task.
commits, and PR tracking. Created when a developer claims a task.
"""
from datetime import UTC, datetime
@@ -26,7 +26,7 @@ class WorkSession(TimestampMixin):
"""
A working session linking an agent to a task on a project.
Created when a developer claims a git-enabled task.
Created when a developer claims a task.
Tracks branch, commits, and PR throughout the task lifecycle.
"""
+420 -73
View File
@@ -170,6 +170,7 @@ class AgentOrchestrator:
self._waiting_records: dict[str, WaitingRecord] = {}
self._health_task: asyncio.Task | None = None
self._dispatcher_task: asyncio.Task | None = None
self._sweeper_task: asyncio.Task | None = None
self._running = False
self._lock = asyncio.Lock()
@@ -184,12 +185,17 @@ class AgentOrchestrator:
# Ensure agent image is built
await self._ensure_agent_image()
# Restore any WaitingRecord rows left by a prior orchestrator run so
# agents that were WAITING_LONG at shutdown can still be resolved.
await self.restore_waiting_records()
# Note: Per-agent settings are now generated at spawn time
# via _generate_agent_settings() - no shared settings needed
# Start background tasks
self._health_task = asyncio.create_task(self._health_loop())
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop())
self._sweeper_task = asyncio.create_task(self._sweeper_loop())
logger.info(
"Orchestrator started",
@@ -212,6 +218,11 @@ class AgentOrchestrator:
with contextlib.suppress(asyncio.CancelledError):
await self._dispatcher_task
if self._sweeper_task:
self._sweeper_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._sweeper_task
# Stop all agents
for agent_id in list(self._instances.keys()):
await self.stop_agent(agent_id)
@@ -353,7 +364,6 @@ class AgentOrchestrator:
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-test__*",
# ONLY allow Write/Edit in their OWN workspace
f"Write({workspace_path}/**)",
f"Edit({workspace_path}/**)",
],
@@ -361,20 +371,15 @@ class AgentOrchestrator:
},
"qa": {
"allow": [
# QA gets read-only git access
"mcp__roboco-git__roboco_git_status",
"mcp__roboco-git__roboco_git_log",
"mcp__roboco-git__roboco_git_diff",
"mcp__roboco-test__*",
# QA can READ all cell workspaces to review code
# (Read is already allowed globally, this is for clarity)
],
"deny": [
# QA cannot write anything - review only
"mcp__roboco-git__roboco_git_commit",
"mcp__roboco-git__roboco_git_push",
"mcp__roboco-git__roboco_git_create_pr",
# Block ALL write operations for QA
"Write(*)",
"Edit(*)",
],
@@ -383,10 +388,9 @@ class AgentOrchestrator:
"allow": [
"mcp__roboco-docs__*",
"mcp__roboco-git__*",
# Documenters write to ALL cell workspaces (docs on dev branches)
"mcp__roboco-test__*",
f"Write({cell_workspace_path}/**)",
f"Edit({cell_workspace_path}/**)",
# Also project-level docs
"Write(/app/docs/**)",
"Edit(/app/docs/**)",
"Write(/app/CHANGELOG.md)",
@@ -400,7 +404,7 @@ class AgentOrchestrator:
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
# Cell PM can write to their own workspace
"mcp__roboco-test__*",
f"Write({workspace_path}/**)",
f"Edit({workspace_path}/**)",
],
@@ -410,7 +414,7 @@ class AgentOrchestrator:
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
# Main PM can write to their own workspace
"mcp__roboco-test__*",
f"Write({workspace_path}/**)",
f"Edit({workspace_path}/**)",
],
@@ -420,6 +424,18 @@ class AgentOrchestrator:
"allow": [
"mcp__roboco-git__*",
"mcp__roboco-docs__*",
"mcp__roboco-test__*",
f"Write({workspace_path}/**)",
f"Edit({workspace_path}/**)",
],
"deny": [],
},
"head_marketing": {
"allow": [
"mcp__roboco-docs__*",
"mcp__roboco-git__roboco_git_status",
"mcp__roboco-git__roboco_git_log",
"mcp__roboco-git__roboco_git_diff",
f"Write({workspace_path}/**)",
f"Edit({workspace_path}/**)",
],
@@ -427,10 +443,10 @@ class AgentOrchestrator:
},
"auditor": {
"allow": [
# Auditor is read-only observer - NO write access anywhere
"mcp__roboco-git__roboco_git_status",
"mcp__roboco-git__roboco_git_log",
"mcp__roboco-git__roboco_git_diff",
"mcp__roboco-test__roboco_test_status",
],
"deny": [
"Write(*)",
@@ -439,6 +455,12 @@ class AgentOrchestrator:
},
}
if role not in configs:
logger.warning(
"No Claude Code permissions configured for role; "
"agent will be limited to base_allow/base_deny.",
role=role,
)
return configs.get(role, {"allow": [], "deny": []})
def _generate_agent_settings(
@@ -491,9 +513,13 @@ class AgentOrchestrator:
role, workspace_path, cell_workspace_path
)
# Combine base + role-specific
# Combine base + role-specific.
# defaultMode=bypassPermissions lets unlisted operations proceed
# without an interactive prompt (which would hang a non-TTY agent
# container). Explicit deny rules still apply.
settings: dict[str, Any] = {
"permissions": {
"defaultMode": "bypassPermissions",
"allow": base_allow + role_config["allow"],
"deny": base_deny + role_config["deny"],
},
@@ -566,6 +592,54 @@ class AgentOrchestrator:
# AGENT SPAWNING
# =========================================================================
def _task_git_context(self, task: dict[str, Any]) -> SpawnGitContext | None:
"""Build SpawnGitContext from a task dict for workspace mounting.
Without this, spawned agents fall back to project_slug="default"
and get a Write/Edit permission lock to /data/workspaces/default/...
which does not exist, so the agent's file tools fail.
"""
project_slug = task.get("project_slug")
if not project_slug:
return None
return SpawnGitContext(
project_slug=project_slug,
branch_name=task.get("branch_name"),
)
async def _safe_spawn(
self,
*,
agent_id: str,
task_id: str | None = None,
initial_prompt: str | None = None,
git_context: SpawnGitContext | None = None,
context_label: str = "dispatcher",
) -> AgentInstance | None:
"""Spawn an agent, absorbing errors so one bad spawn doesn't abort the
rest of the dispatcher's loop.
Each dispatcher iterates many tasks; if `spawn_agent` raised, the
remaining tasks were skipped until the next tick. This wrapper logs
and returns None on failure so siblings still get dispatched.
"""
try:
return await self.spawn_agent(
agent_id=agent_id,
task_id=task_id,
initial_prompt=initial_prompt,
git_context=git_context,
)
except Exception as e:
logger.error(
"Spawn failed during dispatch; continuing with next task",
context=context_label,
agent_id=agent_id,
task_id=task_id,
error=str(e),
)
return None
async def spawn_agent(
self,
agent_id: str,
@@ -612,7 +686,19 @@ class AgentOrchestrator:
# Build workspace paths for this agent
# Pattern: {workspaces_root}/{project_slug}/{team}/{agent_slug}/
project_slug = git_context.project_slug if git_context else "default"
project_slug = (
git_context.project_slug
if git_context and git_context.project_slug
else None
)
if not project_slug:
logger.warning(
"Spawning agent without project_slug; workspace fallback used. "
"Agent file tools will be locked to a nonexistent path.",
agent_id=agent_id,
task_id=task_id,
)
project_slug = "default"
workspace_path = f"/data/workspaces/{project_slug}/{team}/{agent_id}"
# Cell workspace path for QA/Docs to access all cell's dev workspaces
cell_workspace_path = f"/data/workspaces/{project_slug}/{team}"
@@ -1015,11 +1101,18 @@ class AgentOrchestrator:
"env": mcp_env,
}
# Docs server - documentation file management
# Only for documenter and cell_pm roles (they write docs)
# Other roles can read via API but don't need the MCP tools
# Docs server - documentation file management.
# Registered for every role that is granted mcp__roboco-docs__* in
# _get_role_permissions; handlers still enforce per-role access.
agent_role = get_agent_role(agent_id)
if agent_role in ("documenter", "cell_pm"):
docs_roles = (
"documenter",
"cell_pm",
"main_pm",
"product_owner",
"head_marketing",
)
if agent_role in docs_roles:
mcp_servers["roboco-docs"] = {
"command": "uv",
"args": [
@@ -1255,6 +1348,8 @@ class AgentOrchestrator:
Mark an agent as WAITING_LONG and terminate.
The agent will be respawned when the wait condition is resolved.
The record is mirrored to `waiting_records` in Postgres so a later
orchestrator restart can still resolve the wait.
"""
record = WaitingRecord(
agent_id=agent_id,
@@ -1265,6 +1360,7 @@ class AgentOrchestrator:
)
self._waiting_records[agent_id] = record
await self._persist_waiting_record(record)
# Stop the agent
await self.stop_agent(agent_id)
@@ -1282,6 +1378,98 @@ class AgentOrchestrator:
task_id=task_id,
)
async def _persist_waiting_record(self, record: WaitingRecord) -> None:
"""Upsert a WaitingRecord into the waiting_records table."""
try:
from uuid import UUID as _UUID
from sqlalchemy import delete
from roboco.db.base import get_session_factory
from roboco.db.tables import WaitingRecordTable
session_factory = get_session_factory()
async with session_factory() as db:
# One record per agent; delete prior then insert.
await db.execute(
delete(WaitingRecordTable).where(
WaitingRecordTable.agent_id == record.agent_id
)
)
row = WaitingRecordTable(
agent_id=record.agent_id,
task_id=(_UUID(record.task_id) if record.task_id else None),
waiting_for=record.waiting_for,
waiting_since=record.waiting_since,
context=record.context,
)
db.add(row)
await db.commit()
except Exception as e:
logger.error(
"Failed to persist waiting record",
agent_id=record.agent_id,
error=str(e),
)
async def _delete_waiting_record(self, agent_id: str) -> None:
"""Delete a persisted waiting record when its wait resolves."""
try:
from sqlalchemy import delete
from roboco.db.base import get_session_factory
from roboco.db.tables import WaitingRecordTable
session_factory = get_session_factory()
async with session_factory() as db:
await db.execute(
delete(WaitingRecordTable).where(
WaitingRecordTable.agent_id == agent_id
)
)
await db.commit()
except Exception as e:
logger.error(
"Failed to delete waiting record",
agent_id=agent_id,
error=str(e),
)
async def restore_waiting_records(self) -> int:
"""Load persisted waiting records into memory on orchestrator start.
Call this from `start()` so agents marked WAITING_LONG before the
previous orchestrator exited can still be resolved.
"""
try:
from sqlalchemy import select
from roboco.db.base import get_session_factory
from roboco.db.tables import WaitingRecordTable
session_factory = get_session_factory()
async with session_factory() as db:
rows = await db.execute(select(WaitingRecordTable))
count = 0
for row in rows.scalars().all():
self._waiting_records[row.agent_id] = WaitingRecord(
agent_id=row.agent_id,
task_id=str(row.task_id) if row.task_id else None,
waiting_for=row.waiting_for,
waiting_since=row.waiting_since,
context=dict(row.context or {}),
)
count += 1
if count:
logger.info(
"Restored waiting records from database",
count=count,
)
return count
except Exception as e:
logger.error("Failed to restore waiting records", error=str(e))
return 0
async def resolve_wait(
self,
agent_id: str,
@@ -1302,15 +1490,24 @@ class AgentOrchestrator:
record = self._waiting_records[agent_id]
del self._waiting_records[agent_id]
await self._delete_waiting_record(agent_id)
# Generate resume prompt
resume_prompt = self._generate_resume_prompt(record, resolution)
# Preserve the original git_context from the prior instance so the
# respawned agent keeps the same workspace mount path.
prior = self._instances.get(agent_id)
prior_git_context = (
prior.config.git_context if prior and prior.config else None
)
# Respawn
return await self.spawn_agent(
agent_id=agent_id,
initial_prompt=resume_prompt,
task_id=record.task_id,
git_context=prior_git_context,
)
def _generate_resume_prompt(
@@ -1385,6 +1582,54 @@ Start by:
except Exception as e:
logger.error("Health check error", error=str(e))
async def _sweeper_loop(self) -> None:
"""Background sweeper for session timeouts and stale notifications.
Addresses two silent-failure surfaces:
- SessionTable.timeout_seconds / max_time_window were never enforced;
sessions stayed ACTIVE forever.
- NotificationTable.expires_at existed but no job ever acted on it.
Runs on its own interval so a slow sweep can't delay agent dispatch.
"""
sweep_interval = 60 # seconds
while self._running:
try:
await asyncio.sleep(sweep_interval)
await self._run_sweep()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Sweeper loop error", error=str(e))
async def _run_sweep(self) -> None:
"""Run one pass of session + notification sweepers."""
from roboco.db.base import get_session_factory
from roboco.services.messaging import get_messaging_service
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
session_factory = get_session_factory()
async with session_factory() as db:
msg_svc = get_messaging_service(db)
try:
closed = await msg_svc.sweep_timed_out_sessions()
if closed:
await db.commit()
except Exception as e:
await db.rollback()
logger.warning("Session sweep failed", error=str(e))
deliv_svc = get_notification_delivery_service(db)
try:
expired = await deliv_svc.sweep_expired_notifications()
if expired:
await db.commit()
except Exception as e:
await db.rollback()
logger.warning("Notification sweep failed", error=str(e))
async def _check_health(self) -> None:
"""Check health of all running agents."""
for agent_id, instance in list(self._instances.items()):
@@ -1427,7 +1672,97 @@ Start by:
await self.spawn_agent(
agent_id=agent_id,
task_id=instance.current_task_id,
git_context=(
instance.config.git_context
if instance.config
else None
),
)
elif instance.error_count == max_retries:
# Exactly at the threshold — escalate once to humans so a
# stranded agent doesn't die silently. Subsequent crashes
# stay quiet to avoid notification spam.
logger.error(
"Agent exceeded max restart attempts; escalating",
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
await self._notify_agent_stranded(
agent_id=agent_id,
error_count=instance.error_count,
task_id=instance.current_task_id,
)
async def _notify_agent_stranded(
self,
agent_id: str,
error_count: int,
task_id: str | None,
) -> None:
"""Create a notification for humans when an agent can't be restarted.
Posts a high-priority notification addressed to the auditor and CEO.
Fire-and-forget: the agent is already dead; don't let our own failure
stop the health loop.
"""
try:
from sqlalchemy import select
from roboco.db.base import get_session_factory
from roboco.db.tables import AgentTable, NotificationTable
from roboco.models.base import (
AgentRole,
NotificationPriority,
NotificationType,
)
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
from roboco.utils.converters import require_uuid
session_factory = get_session_factory()
async with session_factory() as db:
orch_agent = await db.execute(
select(AgentTable).where(AgentTable.role == AgentRole.AUDITOR)
)
auditor = orch_agent.scalar_one_or_none()
ceo_result = await db.execute(
select(AgentTable).where(AgentTable.role == AgentRole.CEO)
)
ceo = ceo_result.scalar_one_or_none()
recipients = [a.id for a in (auditor, ceo) if a is not None]
if not recipients:
logger.warning(
"No auditor/ceo found for stranded-agent notification",
agent_id=agent_id,
)
return
from_agent = auditor.id if auditor else ceo.id # type: ignore[union-attr]
notification = NotificationTable(
type=NotificationType.ALERT,
priority=NotificationPriority.HIGH,
from_agent=from_agent,
to_agents=recipients,
subject=f"Agent stranded: {agent_id}",
body=(
f"Agent '{agent_id}' exceeded max restart attempts "
f"({error_count}) and will not auto-recover. "
f"Task: {task_id or 'none'}. Manual intervention needed."
),
requires_ack=True,
)
db.add(notification)
await db.flush()
delivery = get_notification_delivery_service(db)
await delivery.deliver(require_uuid(notification.id))
await db.commit()
except Exception as e:
logger.error(
"Failed to send stranded-agent notification",
agent_id=agent_id,
error=str(e),
)
# =========================================================================
# STATUS API
@@ -1522,34 +1857,28 @@ Start by:
f"Task {task_id} has inadequate description ({len(description)} chars)"
)
# VALIDATION 2: For git tasks, check project + parent branch requirements
requires_git = task.get("requires_git", False)
if requires_git:
# Must have project
if not task.get("project_id"):
await self._auto_block_task(
client, task_id, "Git task needs project_id"
)
return f"Task {task_id} needs project"
# VALIDATION 2: Check project + parent branch requirements (all tasks use git)
# Must have project (validated at creation, but double-check)
if not task.get("project_id"):
await self._auto_block_task(client, task_id, "Task needs project_id")
return f"Task {task_id} needs project"
# For subtasks, parent must have branch (for forking)
parent_id = task.get("parent_task_id")
if parent_id:
parent_resp = await client.get(f"{self._api_url}/tasks/{parent_id}")
if parent_resp.is_success:
parent = parent_resp.json()
if parent.get("requires_git") and not parent.get("branch_name"):
await self._auto_block_task(
client,
task_id,
"Parent task must be claimed first to create its branch",
)
return f"Task {task_id} waiting for parent branch"
# For subtasks, parent must have branch (for forking)
parent_id = task.get("parent_task_id")
if parent_id:
parent_resp = await client.get(f"{self._api_url}/tasks/{parent_id}")
if parent_resp.is_success:
parent = parent_resp.json()
if not parent.get("branch_name"):
await self._auto_block_task(
client,
task_id,
"Parent task must be claimed first to create its branch",
)
return f"Task {task_id} waiting for parent branch"
# Root task or parent has branch - branch will auto-create on claim
logger.info(
"Git task ready for hierarchical branch creation", task_id=task_id
)
# Root task or parent has branch - branch will auto-create on claim
logger.info("Task ready for hierarchical branch creation", task_id=task_id)
# VALIDATION 3: Check complexity vs subtasks for devs
from roboco.agents_config import get_agent_role
@@ -1804,12 +2133,11 @@ Start by:
result: str | None = None
# Task type takes precedence for non-code work
# NOTE: All tasks follow git workflow now, but some types route to PM
if task_type in ("planning", "research", "administrative"):
result = "cell_pm" if team in cell_teams else "main_pm"
elif task_type == "design" and team not in ("backend", "frontend"):
result = "cell_pm"
elif task_type == "documentation" and not task.get("requires_git"):
result = "cell_pm" if team in cell_teams else "main_pm"
elif team in self._TEAM_ROUTING_MAP:
result = self._TEAM_ROUTING_MAP[team]
@@ -2093,39 +2421,44 @@ Start now: roboco_task_get("{task_id}")
logger.error("Dispatcher loop error", error=str(e))
async def _dispatch_all_work(self) -> None:
"""Run all dispatchers to check for and assign work."""
"""Run all dispatchers to check for and assign work.
Each dispatcher is isolated: if one raises (e.g., a transient API
error), the rest still run in this tick instead of waiting for the
next one.
"""
# Orchestrator uses SYSTEM role for internal API calls
# Using a well-known UUID for the orchestrator identity
headers = {
"X-Agent-ID": "00000000-0000-0000-0000-000000000000",
"X-Agent-Role": "system",
}
dispatchers: list[tuple[str, Any]] = []
async with httpx.AsyncClient(timeout=30.0, headers=headers) as client:
# PM triage first - routes new tasks to appropriate level
await self._dispatch_pm_work(client)
# PM closure - check parent tasks ready to close
await self._dispatch_pm_closure_work(client)
# Task-based dispatchers (check task statuses)
# Dev work only picks up pre-assigned tasks now
await self._dispatch_dev_work(client)
await self._dispatch_qa_work(client)
await self._dispatch_doc_work(client)
await self._dispatch_pm_review_work(client)
await self._dispatch_marketing_work(client)
# Event-based dispatchers (check blockers, notifications)
await self._dispatch_blocker_work(client)
await self._dispatch_escalation_work(client)
await self._dispatch_approval_work(client)
await self._dispatch_a2a_work(client)
# Scheduled dispatchers
await self._dispatch_audit_work(client)
# Proactive enforcement - detect and block stuck tasks
await self._detect_stuck_tasks(client)
dispatchers = [
("pm_work", self._dispatch_pm_work(client)),
("pm_closure_work", self._dispatch_pm_closure_work(client)),
("dev_work", self._dispatch_dev_work(client)),
("qa_work", self._dispatch_qa_work(client)),
("doc_work", self._dispatch_doc_work(client)),
("pm_review_work", self._dispatch_pm_review_work(client)),
("marketing_work", self._dispatch_marketing_work(client)),
("blocker_work", self._dispatch_blocker_work(client)),
("escalation_work", self._dispatch_escalation_work(client)),
("approval_work", self._dispatch_approval_work(client)),
("a2a_work", self._dispatch_a2a_work(client)),
("audit_work", self._dispatch_audit_work(client)),
("detect_stuck_tasks", self._detect_stuck_tasks(client)),
]
for name, coro in dispatchers:
try:
await coro
except Exception as e:
logger.error(
"Dispatcher raised; continuing with next dispatcher",
dispatcher=name,
error=str(e),
)
# =========================================================================
# SMART DISPATCHER - TASK-BASED DISPATCHERS
@@ -2177,6 +2510,7 @@ Start now: roboco_task_get("{task_id}")
agent_id=agent_slug,
task_id=task["id"],
initial_prompt=pm_prompt,
git_context=self._task_git_context(task),
)
continue
@@ -2218,6 +2552,7 @@ Start now: roboco_task_get("{task_id}")
agent_id=agent_id,
task_id=task["id"],
initial_prompt=prompt,
git_context=self._task_git_context(task),
)
async def _dispatch_pm_closure_work(self, client: httpx.AsyncClient) -> None:
@@ -2279,6 +2614,7 @@ Start now: roboco_task_get("{task_id}")
agent_id=pm_id,
task_id=task_id,
initial_prompt=prompt,
git_context=self._task_git_context(task),
)
async def _fetch_subtasks(
@@ -2421,6 +2757,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_slug,
task_id=task["id"],
initial_prompt=self._build_dev_prompt(task),
git_context=self._task_git_context(task),
)
continue
@@ -2437,6 +2774,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_slug,
task_id=task["id"],
initial_prompt=self._build_dev_prompt(task),
git_context=self._task_git_context(task),
)
continue
@@ -2462,6 +2800,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_slug,
task_id=task["id"],
initial_prompt=self._get_prompt_for_agent(agent_slug, task),
git_context=self._task_git_context(task),
)
async def _dispatch_qa_work(self, client: httpx.AsyncClient) -> None:
@@ -2491,6 +2830,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=assigned_slug,
task_id=task["id"],
initial_prompt=self._build_qa_prompt(task),
git_context=self._task_git_context(task),
)
continue
@@ -2517,6 +2857,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_id,
task_id=task["id"],
initial_prompt=self._build_qa_prompt(task),
git_context=self._task_git_context(task),
)
# Only spawn one QA at a time per cell
break
@@ -2547,6 +2888,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=assigned_slug,
task_id=task["id"],
initial_prompt=self._build_doc_prompt(task),
git_context=self._task_git_context(task),
)
continue
@@ -2571,6 +2913,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_id,
task_id=task["id"],
initial_prompt=self._build_doc_prompt(task),
git_context=self._task_git_context(task),
)
break
@@ -2597,6 +2940,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=assigned_slug,
task_id=task["id"],
initial_prompt=self._build_pm_review_prompt(task),
git_context=self._task_git_context(task),
)
continue
@@ -2624,6 +2968,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=pm_id,
task_id=task["id"],
initial_prompt=self._build_pm_review_prompt(task),
git_context=self._task_git_context(task),
)
break
@@ -2649,6 +2994,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id="head-marketing",
task_id=task["id"],
initial_prompt=self._build_marketing_prompt(task),
git_context=self._task_git_context(task),
)
break
@@ -2681,6 +3027,7 @@ Begin with step 1: roboco_task_get("{task_id}")
agent_id=agent_id,
task_id=task["id"],
initial_prompt=self._build_pm_blocker_prompt(task),
git_context=self._task_git_context(task),
)
break
@@ -2829,8 +3176,8 @@ Begin with step 1: roboco_task_get("{task_id}")
def _check_stuck_conditions(self, task: dict[str, Any]) -> list[str]:
"""Check for common stuck conditions (git, description)."""
issues: list[str] = []
if task.get("requires_git") and not task.get("branch_name"):
issues.append("Git task missing branch_name")
if not task.get("branch_name"):
issues.append("Task missing branch_name")
description = (task.get("description") or "").strip()
if len(description) < self._MIN_DESCRIPTION_LEN:
issues.append("Empty or inadequate description")
+208 -2
View File
@@ -2,11 +2,13 @@
Audit Service
Logs permission denials and security events for visibility by Auditor and CEO.
All audit logs are persisted and queryable.
All audit logs are written to structured logs AND persisted to the
`audit_log` table so the Auditor agent can query them.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import ClassVar
from typing import Any, ClassVar
from uuid import UUID
from roboco.models.audit import (
@@ -17,6 +19,34 @@ from roboco.models.audit import (
from roboco.services.base import SingletonService
@dataclass
class _AuditEvent:
"""Bundled fields for an audit row write.
Grouped into a dataclass so `_persist` stays under pylint's arg limit
and so we have one obvious shape for every call site.
"""
event_type: str
agent_id: str | UUID | None = None
target_type: str | None = None
target_id: str | UUID | None = None
severity: str = "info"
details: dict[str, Any] = field(default_factory=dict)
def _coerce_uuid(value: str | UUID | None) -> UUID | None:
"""Best-effort coerce to UUID; returns None for slugs or invalid input."""
if value is None:
return None
if isinstance(value, UUID):
return value
try:
return UUID(str(value))
except (ValueError, AttributeError):
return None
class AuditService(SingletonService):
"""
Service for logging audit events.
@@ -41,6 +71,40 @@ class AuditService(SingletonService):
service_name: ClassVar[str] = "audit"
# =========================================================================
# PERSISTENCE HELPER
# =========================================================================
async def _persist(self, event: _AuditEvent) -> None:
"""Write an audit row to the audit_log table.
Best-effort: failures are logged but never propagate, since audit
failures must not block the operation being audited. Uses its own
session so it doesn't depend on the caller holding one.
"""
try:
from roboco.db.base import get_session_factory
from roboco.db.tables import AuditLogTable
session_factory = get_session_factory()
async with session_factory() as db:
row = AuditLogTable(
event_type=event.event_type,
agent_id=_coerce_uuid(event.agent_id),
target_type=event.target_type,
target_id=_coerce_uuid(event.target_id),
severity=event.severity,
details=dict(event.details or {}),
)
db.add(row)
await db.commit()
except Exception as e:
self.log.error(
"Failed to persist audit event",
event_type=event.event_type,
error=str(e),
)
# =========================================================================
# LOGGING METHODS
# =========================================================================
@@ -66,6 +130,20 @@ class AuditService(SingletonService):
details=ctx.details,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.PERMISSION_DENIED.value,
agent_id=ctx.agent_id,
target_type=ctx.resource,
target_id=ctx.resource_id,
severity="warning",
details={
"action": ctx.action,
"reason": ctx.reason,
**(ctx.details or {}),
},
)
)
async def log_channel_access_denial(
self,
@@ -84,6 +162,19 @@ class AuditService(SingletonService):
reason=reason,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.CHANNEL_ACCESS_DENIED.value,
agent_id=agent_id,
target_type="channel",
severity="warning",
details={
"channel_slug": channel_slug,
"access_type": access_type,
"reason": reason,
},
)
)
async def log_task_action_denial(
self,
@@ -104,6 +195,20 @@ class AuditService(SingletonService):
reason=reason,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.TASK_ACTION_DENIED.value,
agent_id=agent_id,
target_type="task",
target_id=task_id,
severity="warning",
details={
"agent_role": agent_role,
"action": action,
"reason": reason,
},
)
)
async def log_state_transition_denial(
self,
@@ -121,6 +226,21 @@ class AuditService(SingletonService):
reason=ctx.reason,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.STATE_TRANSITION_DENIED.value,
agent_id=ctx.agent_id,
target_type="task",
target_id=ctx.task_id,
severity="warning",
details={
"agent_role": ctx.agent_role,
"current_status": ctx.current_status,
"target_status": ctx.target_status,
"reason": ctx.reason,
},
)
)
async def log_notification_denial(
self,
@@ -139,6 +259,19 @@ class AuditService(SingletonService):
reason=reason,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.NOTIFICATION_DENIED.value,
agent_id=agent_id,
target_type="notification",
severity="warning",
details={
"agent_role": agent_role,
"notification_type": notification_type,
"reason": reason,
},
)
)
async def log_security_event(
self,
@@ -156,6 +289,14 @@ class AuditService(SingletonService):
details=details,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=event_type.value,
agent_id=agent_id,
severity="warning",
details={"description": description, **(details or {})},
)
)
async def log_pm_override(
self,
@@ -180,6 +321,71 @@ class AuditService(SingletonService):
cancelled_subtask_ids=cancelled_subtask_ids,
timestamp=datetime.now(UTC).isoformat(),
)
await self._persist(
_AuditEvent(
event_type=AuditEventType.PM_OVERRIDE.value,
agent_id=agent_id,
target_type="task",
target_id=task_id,
severity="info",
details={
"action": action,
"justification": justification,
"cancelled_subtask_ids": cancelled_subtask_ids or [],
},
)
)
# =========================================================================
# QUERY METHODS
# =========================================================================
async def get_recent_events(
self,
limit: int = 50,
event_type: str | None = None,
agent_id: UUID | None = None,
min_severity: str | None = None,
) -> list[dict[str, Any]]:
"""Fetch recent audit events. Intended for the Auditor/CEO queries.
Returns a list of dicts rather than ORM rows so callers don't need
to keep a session open.
"""
from sqlalchemy import select
from roboco.db.base import get_session_factory
from roboco.db.tables import AuditLogTable
session_factory = get_session_factory()
async with session_factory() as db:
query = select(AuditLogTable).order_by(AuditLogTable.timestamp.desc())
if event_type:
query = query.where(AuditLogTable.event_type == event_type)
if agent_id:
query = query.where(AuditLogTable.agent_id == agent_id)
if min_severity == "warning":
query = query.where(AuditLogTable.severity.in_(("warning", "error")))
elif min_severity == "error":
query = query.where(AuditLogTable.severity == "error")
query = query.limit(limit)
result = await db.execute(query)
rows = list(result.scalars().all())
return [
{
"id": str(r.id),
"event_type": r.event_type,
"agent_id": str(r.agent_id) if r.agent_id else None,
"target_type": r.target_type,
"target_id": str(r.target_id) if r.target_id else None,
"severity": r.severity,
"details": dict(r.details or {}),
"timestamp": r.timestamp.isoformat() if r.timestamp else None,
}
for r in rows
]
# =============================================================================
+43 -8
View File
@@ -382,7 +382,18 @@ class GitService(BaseService):
await self._run_git(workspace, ["fetch", "origin"])
# Create and push branch
await self._run_git(workspace, ["checkout", base_branch])
# Try direct checkout first (works if local branch exists)
checkout_result = await self._run_git(
workspace, ["checkout", base_branch], check=False
)
if checkout_result.returncode != 0:
# Branch doesn't exist locally - create tracking branch from remote
# This handles freshly cloned workspaces where remote branch exists
# but local tracking branch hasn't been created yet
await self._run_git(
workspace,
["checkout", "-b", base_branch, f"origin/{base_branch}"],
)
await self._run_git(workspace, ["pull", "origin", base_branch])
await self._run_git(workspace, ["checkout", "-b", branch_name])
await self._run_git(workspace, ["push", "-u", "origin", branch_name])
@@ -396,10 +407,18 @@ class GitService(BaseService):
"""Checkout a branch.
Fetches from origin first to ensure remote branches are available.
If the branch doesn't exist locally, creates a tracking branch from remote.
"""
# Fetch to ensure we have the latest refs
await self._run_git(workspace, ["fetch", "origin"])
await self._run_git(workspace, ["checkout", branch])
# Try direct checkout first (works if local branch exists)
result = await self._run_git(workspace, ["checkout", branch], check=False)
if result.returncode != 0:
# Branch doesn't exist locally - create tracking branch from remote
await self._run_git(
workspace, ["checkout", "-b", branch, f"origin/{branch}"]
)
async def push(self, workspace: Path, force: bool = False) -> tuple[str, int]:
"""Push commits to remote.
@@ -477,7 +496,7 @@ class GitService(BaseService):
task_type = "feature"
if "/" in source_branch:
task_type = source_branch.split("/")[0]
task_type = source_branch.split("/", maxsplit=1)[0]
criteria = list(task.acceptance_criteria) if task.acceptance_criteria else []
@@ -558,10 +577,18 @@ class GitService(BaseService):
else "main"
)
# Get decrypted token from project (required for PR creation)
git_token = await project_service.get_decrypted_token_by_slug(
request.project_slug
)
# Get decrypted token from project (required for PR creation).
from roboco.utils.crypto import EncryptionError
try:
git_token = await project_service.get_decrypted_token_by_slug(
request.project_slug
)
except EncryptionError as e:
raise GitError(
f"Failed to decrypt git token for project '{request.project_slug}'. "
"The encryption key may have been rotated; re-set the project token."
) from e
if not git_token:
raise GitError(
f"Project '{request.project_slug}' has no git token configured. "
@@ -643,7 +670,15 @@ class GitService(BaseService):
"""
# Get project token for gh CLI
project_service = get_project_service(self.session)
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
from roboco.utils.crypto import EncryptionError
try:
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
except EncryptionError as e:
raise GitError(
f"Failed to decrypt git token for project '{project_slug}'. "
"The encryption key may have been rotated; re-set the project token."
) from e
if not git_token:
raise GitError(
f"Project '{project_slug}' has no git token configured. "
+42 -2
View File
@@ -356,7 +356,7 @@ class MessagingService(BaseService):
# Publish event
try:
bus = get_event_bus()
if bus._redis:
if bus.is_connected():
await bus.publish(
Event(
type=EventType.SESSION_CREATED,
@@ -383,6 +383,46 @@ class MessagingService(BaseService):
)
return result.scalar_one_or_none()
async def sweep_timed_out_sessions(self) -> int:
"""Close sessions whose inactivity exceeds `timeout_seconds`.
`SessionTable.timeout_seconds` and `SessionTable.max_time_window` were
stored but never enforced; sessions stayed ACTIVE indefinitely.
The orchestrator's session-sweeper loop calls this periodically.
Returns the number of sessions closed.
"""
now = datetime.now(UTC)
result = await self.session.execute(
select(SessionTable).where(SessionTable.status == SessionStatus.ACTIVE)
)
active_sessions = list(result.scalars().all())
closed = 0
for session in active_sessions:
last_active = session.last_activity_at or session.started_at
idle = (now - last_active).total_seconds()
timeout_exceeded = (
session.timeout_seconds is not None
and idle >= session.timeout_seconds
)
window_exceeded = (
session.max_time_window is not None
and (now - session.started_at) >= session.max_time_window
)
if not (timeout_exceeded or window_exceeded):
continue
reason = "Inactivity timeout" if timeout_exceeded else "Max time window"
await self.close_session(cast("UUID", session.id), reason)
closed += 1
if closed:
self.log.info("Session sweeper closed sessions", count=closed)
return closed
async def close_session(
self,
session_id: UUID,
@@ -409,7 +449,7 @@ class MessagingService(BaseService):
# Publish event
try:
bus = get_event_bus()
if bus._redis:
if bus.is_connected():
await bus.publish(
Event(
type=EventType.SESSION_CLOSED,
+44 -2
View File
@@ -73,7 +73,7 @@ class NotificationDeliveryService(BaseService):
# Publish to Redis for real-time delivery
try:
bus = get_event_bus()
if bus._redis:
if bus.is_connected():
for recipient_id in notification.to_agents:
await bus.publish(
Event(
@@ -108,6 +108,48 @@ class NotificationDeliveryService(BaseService):
)
return result.scalar_one_or_none()
async def sweep_expired_notifications(self) -> int:
"""Log notifications past their `expires_at` that still require ACK.
`NotificationTable.expires_at` existed but nothing acted on it. This
sweep surfaces notifications that have become stale so an operator
(or an escalation follow-up) can decide what to do. We log rather
than auto-cancel because the notification is the record; rewriting
status would be ambiguous. Returns the count of stale items.
"""
now = datetime.now(UTC)
result = await self.session.execute(
select(NotificationTable).where(
and_(
NotificationTable.expires_at.is_not(None),
NotificationTable.expires_at < now,
NotificationTable.requires_ack.is_(True),
)
)
)
stale = list(result.scalars().all())
# Skip notifications that every recipient has already ACK'd.
unacked = [
n
for n in stale
if any(str(r) not in {str(a) for a in (n.acked_by or [])}
for r in (n.to_agents or []))
]
for n in unacked:
self.log.warning(
"Notification expired without full ACK",
notification_id=str(n.id),
type=n.type.value if n.type else None,
priority=n.priority.value if n.priority else None,
recipient_count=len(n.to_agents or []),
ack_count=len(n.acked_by or []),
expired_at=n.expires_at.isoformat() if n.expires_at else None,
)
return len(unacked)
async def get_pending_for_agent(
self,
agent_id: UUID,
@@ -254,7 +296,7 @@ class NotificationDeliveryService(BaseService):
# Publish ACK event
try:
bus = get_event_bus()
if bus._redis:
if bus.is_connected():
await bus.publish(
Event(
type=EventType.NOTIFICATION_ACKED,
+14 -2
View File
@@ -559,12 +559,24 @@ class OptimalService:
logger.info("OptimalService closed")
def _get_plugin(self, index_type: IndexType) -> BaseIndexPlugin:
"""Get the plugin for an index type."""
"""Get the plugin for an index type.
Raises a clear error when a plugin is missing (e.g. removed or
failed to initialize), rather than leaking a bare KeyError.
"""
if not self._initialized:
raise RuntimeError(
"OptimalService not initialized. Call initialize() first."
)
return self._plugins[index_type]
plugin = self._plugins.get(index_type)
if plugin is None:
available = sorted(t.value for t in self._plugins)
raise RuntimeError(
f"No plugin registered for index type '{index_type.value}'. "
f"Available: {available}. "
"This usually means the index is disabled or failed to initialize."
)
return plugin
# =========================================================================
# INDEXING OPERATIONS (Existing - Backwards Compatible)
@@ -18,6 +18,12 @@ from piragi.types import Citation, Document
from roboco.config import settings
from roboco.models.optimal import IndexType, SearchOutcome, SearchResult
# Apply piragi runtime patches (chunker tokenizer) BEFORE importing piragi
# itself anywhere in the plugin stack. Importing for side effects only.
from roboco.services.optimal_brain import (
piragi_patches as _piragi_patches, # noqa: F401
)
logger = structlog.get_logger()
@@ -34,7 +40,7 @@ class IndexConfig:
use_hybrid_search: bool = True
use_cross_encoder: bool = False
embedding_model: str = "qwen3-embedding:0.6b"
llm_model: str = "glm-4.7:cloud"
llm_model: str = "glm-5.1:cloud"
llm_base_url: str = "http://roboco-ollama:11434/v1"
@classmethod
@@ -0,0 +1,87 @@
"""Runtime patches for piragi 0.7.9.
Importing this module applies module-level monkey-patches that correct
behaviors in the installed piragi version:
1. `piragi.chunking.Chunker` hardcodes
`tokenizer_name="nvidia/llama-embed-nemotron-8b"` as its default, and
that model requires `trust_remote_code=True` at load time. In non-TTY
containers the `Do you wish to run the custom code? [y/N]` prompt is
answered "N", so `AutoTokenizer.from_pretrained` fails and every index
plugin init raises, leaving the whole RAG layer disabled. We don't need
a Qwen-accurate tokenizer for chunking chunk size is only an
approximation so we swap in a public tokenizer that loads without
remote-code prompts.
Apply once, near the top of any module that imports piragi, by doing
`import roboco.services.optimal_brain.piragi_patches # noqa: F401`.
"""
from __future__ import annotations
from typing import Any
from roboco.logging import get_logger
logger = get_logger(__name__)
# Safe default — bert-base-uncased is widely cached, public, and doesn't
# require trust_remote_code. Token counts won't match qwen3 exactly, but
# chunk_size is treated as a target, not a hard limit.
_SAFE_TOKENIZER = "bert-base-uncased"
class _PatchState:
"""Holds apply-once flag without a module-level global statement."""
applied: bool = False
def apply_patches() -> None:
"""Apply all piragi runtime patches. Idempotent."""
if _PatchState.applied:
return
try:
import piragi.chunking as _chunking
import piragi.semantic_chunking as _semantic
original_chunker_init = _chunking.Chunker.__init__
def patched_chunker_init(
self: Any,
chunk_size: int = 512,
chunk_overlap: int = 50,
tokenizer_name: str = _SAFE_TOKENIZER,
) -> None:
# Always swap the nvidia default for the safe one unless the
# caller explicitly passed something else.
effective = (
_SAFE_TOKENIZER
if tokenizer_name == "nvidia/llama-embed-nemotron-8b"
else tokenizer_name
)
original_chunker_init(
self,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
tokenizer_name=effective,
)
_chunking.Chunker.__init__ = patched_chunker_init
# semantic_chunking.Chunker is the same class, but this guards
# against piragi ever adding an import-time bind of the original.
if hasattr(_semantic, "Chunker") and _semantic.Chunker is _chunking.Chunker:
_semantic.Chunker = _chunking.Chunker
_PatchState.applied = True
logger.info(
"Piragi chunker tokenizer default patched",
safe_tokenizer=_SAFE_TOKENIZER,
)
except Exception as e:
logger.error("Failed to apply piragi patches", error=str(e))
apply_patches()
@@ -56,7 +56,7 @@ OLLAMA_EMBEDDING_MODELS = {
def _is_ollama_model(model: str) -> bool:
"""Check if model name is an Ollama embedding model."""
model_base = model.split(":")[0].lower()
model_base = model.split(":", maxsplit=1)[0].lower()
return model_base in OLLAMA_EMBEDDING_MODELS
@@ -157,14 +157,18 @@ async def get_shared_embedder(
)
raise RuntimeError(f"Failed to load embedding model: {e}") from e
# Validate embedder implements required protocol methods
assert _SharedEmbedderHolder.instance is not None
# Validate embedder implements required protocol methods.
# Using explicit check (not assert) so this survives `python -O`.
if _SharedEmbedderHolder.instance is None:
raise RuntimeError(
"Shared embedder construction succeeded but instance is None"
)
_validate_embedder_protocol(_SharedEmbedderHolder.instance, model)
logger.info("Shared embedder created successfully", model=model)
# At this point instance is guaranteed to be set (or exception raised)
assert _SharedEmbedderHolder.instance is not None
if _SharedEmbedderHolder.instance is None:
raise RuntimeError("Shared embedder not initialized")
return _SharedEmbedderHolder.instance
+71 -1
View File
@@ -180,12 +180,28 @@ class ProjectService(BaseService):
)
return project
async def delete(self, project_id: UUID) -> bool:
async def delete(
self,
project_id: UUID,
*,
delete_workspaces: bool = False,
) -> bool:
"""
Delete a project.
Before the delete, abandon any ACTIVE work sessions tied to this
project so they don't remain ACTIVE with no owning project.
Optionally remove cloned workspaces from disk (caller opt-in; safer
default is to leave them for recovery).
The DB-level cascade on tasks is `RESTRICT`, so if any tasks
reference this project the delete still fails at the DB layer
callers should cancel those tasks first.
Args:
project_id: Project to delete
delete_workspaces: If True, remove on-disk workspaces for this
project. Default False so disk cleanup is explicit.
Returns:
True if deleted, False if not found
@@ -194,9 +210,63 @@ class ProjectService(BaseService):
if not project:
return False
from roboco.db.tables import WorkSessionTable
from roboco.models.work_session import WorkSessionStatus
from roboco.services.work_session import get_work_session_service
active_sessions = await self.session.execute(
select(WorkSessionTable).where(
WorkSessionTable.project_id == project_id,
WorkSessionTable.status == WorkSessionStatus.ACTIVE,
)
)
ws_service = get_work_session_service(self.session)
abandoned = 0
for ws in active_sessions.scalars().all():
from roboco.utils.converters import require_uuid
await ws_service.abandon(
require_uuid(ws.id), reason="project deleted"
)
abandoned += 1
if abandoned:
self.log.info(
"Abandoned active work sessions before project delete",
project_id=str(project_id),
count=abandoned,
)
project_slug = project.slug
await self.session.delete(project)
await self.session.flush()
if delete_workspaces:
from roboco.services.workspace import get_workspace_service
ws_svc = get_workspace_service(self.session)
try:
workspaces = await ws_svc.list_workspaces(project_slug)
for ws_info in workspaces:
from pathlib import Path
path = Path(ws_info["path"])
if path.exists():
import shutil
shutil.rmtree(path)
self.log.info(
"Deleted workspaces for project",
project_slug=project_slug,
count=len(workspaces),
)
except Exception as e:
self.log.warning(
"Failed to clean up some workspaces after project delete",
project_slug=project_slug,
error=str(e),
)
self.log.info("Project deleted", project_id=str(project_id))
return True
+358 -82
View File
@@ -180,15 +180,13 @@ class TaskService(BaseService):
validate_task_transition(current, target, agent_role)
# Validate git requirements (raises GitRequirementError if not met)
if task.requires_git:
git_ctx = GitContext(
requires_git=True,
docs_complete=bool(task.docs_complete),
pr_created=bool(task.pr_created),
pr_number=task.pr_number,
branch_name=str(task.branch_name) if task.branch_name else None,
)
validate_git_requirements(current, target, git_ctx)
git_ctx = GitContext(
docs_complete=bool(task.docs_complete),
pr_created=bool(task.pr_created),
pr_number=task.pr_number,
branch_name=str(task.branch_name) if task.branch_name else None,
)
validate_git_requirements(current, target, git_ctx)
# Apply the status change
task.status = new_status
@@ -204,6 +202,39 @@ class TaskService(BaseService):
# CRUD OPERATIONS
# =========================================================================
async def _validate_parent_depth(self, parent_task_id: UUID) -> None:
"""Enforce MAX_TASK_DEPTH at creation time.
Walks up the parent chain counting ancestors. Raises ValueError if
adding a child under this parent would exceed MAX_TASK_DEPTH.
Previously this was only enforced at branch-name generation time,
so invalid hierarchies could be created and only fail later at claim.
"""
from roboco.templates.git.constants import MAX_TASK_DEPTH
current_id: UUID | None = parent_task_id
depth = 0
visited: set[str] = set()
while current_id is not None:
key = str(current_id)
if key in visited:
raise ValueError(
f"Circular reference detected at {key} while validating depth"
)
visited.add(key)
parent = await self.get(current_id)
if parent is None:
raise ValueError(f"Parent task {current_id} not found")
depth += 1
if depth >= MAX_TASK_DEPTH:
raise ValueError(
f"Task hierarchy would exceed MAX_TASK_DEPTH={MAX_TASK_DEPTH}. "
"Create this work as a sibling of the deepest task instead "
"of a further nested subtask."
)
parent_parent = parent.parent_task_id
current_id = UUID(str(parent_parent)) if parent_parent else None
async def create(self, req: TaskCreateRequest) -> TaskTable:
"""
Create a new task.
@@ -211,6 +242,9 @@ class TaskService(BaseService):
Default status is PENDING. PM can pass status=BACKLOG when creating
subtasks that need session setup before activation.
"""
if req.parent_task_id:
await self._validate_parent_depth(req.parent_task_id)
task = TaskTable(
title=req.title,
description=req.description,
@@ -226,9 +260,8 @@ class TaskService(BaseService):
status=req.status if req.status else TaskStatus.PENDING,
sequence=req.sequence, # Task ordering within siblings
dependency_ids=req.dependency_ids, # Task IDs that must complete first
# Git configuration - CRITICAL: These must be passed through
# Git configuration (all tasks follow git workflow)
task_type=req.task_type,
requires_git=req.requires_git,
project_id=req.project_id,
)
self.session.add(task)
@@ -303,7 +336,7 @@ class TaskService(BaseService):
)
return link
async def activate(self, task_id: UUID) -> TaskTable:
async def activate(self, task_id: UUID, agent_role: str = "cell_pm") -> TaskTable:
"""
Activate a task from BACKLOG to PENDING status.
@@ -315,12 +348,14 @@ class TaskService(BaseService):
Args:
task_id: The task to activate
agent_role: Role of the agent performing activation (must be PM)
Returns:
The activated task
Raises:
ValueError: If task not found, not in BACKLOG, or has no session
TaskLifecycleError: If role is not allowed to activate
"""
task = await self.get(task_id)
if not task:
@@ -344,18 +379,18 @@ class TaskService(BaseService):
"before activating."
)
# ENFORCEMENT: Git tasks require project_id before activation
if task.requires_git and not task.project_id:
# ENFORCEMENT: All tasks require project_id (double-check here)
if not task.project_id:
raise ValueError(
f"Cannot activate task '{task.title}' - requires git but no project. "
"Fix: (1) Re-create task with project_slug, OR "
"(2) Set requires_git=False if git not needed."
f"Cannot activate task '{task.title}' - no project set. "
"All tasks require a project for git workflow."
)
# NOTE: Git branch is auto-created on claim, not required at activation
# Transition to PENDING
task.status = TaskStatus.PENDING
# Transition to PENDING with role enforcement
# (PM-only per ROLE_RESTRICTED_TRANSITIONS)
self._validate_and_set_status(task, TaskStatus.PENDING, agent_role)
await self.session.flush()
self.log.info(
@@ -365,31 +400,28 @@ class TaskService(BaseService):
)
return task
async def _ensure_branch_for_git_task(
async def _ensure_branch_for_task(
self,
task: TaskTable,
agent_id: UUID,
) -> str:
"""Auto-create hierarchical branch for git tasks. Raises on failure.
"""Auto-create hierarchical branch for task. Raises on failure.
Strategy:
- If branch exists: return it
- If no project: raise error
- If no project: raise error (should not happen - project_id is required)
- Create NEW branch (hierarchical name built by build_branch_name)
- Branch created from parent's branch (or default if root)
Raises:
ValueError: If branch cannot be created (mandatory for git tasks)
ValueError: If branch cannot be created
"""
if not task.requires_git:
raise ValueError("Task does not require git")
if task.branch_name:
return str(task.branch_name)
if not task.project_id:
raise ValueError(
"Git task requires project_id to create branch. "
"Task requires project_id to create branch. "
"Assign a project before claiming."
)
@@ -689,8 +721,18 @@ class TaskService(BaseService):
- Developers/PMs: can claim PENDING tasks
- QA: can claim AWAITING_QA tasks
- Documenters: can claim PENDING (direct assignment) or AWAITING_DOCUMENTATION
Uses SELECT ... FOR UPDATE to serialize concurrent claim attempts on
the same task, preventing last-write-wins races between two agents
racing for the same pending task.
"""
task = await self.get(task_id)
# Lock the task row for the duration of this transaction so concurrent
# claim attempts serialize at the DB level. `with_for_update` maps to
# PostgreSQL's `SELECT ... FOR UPDATE`.
lock_result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_id).with_for_update()
)
task = lock_result.scalar_one_or_none()
if not task:
return None
@@ -711,6 +753,22 @@ class TaskService(BaseService):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
return None
# Prevent pre-assigned theft: if the task is already assigned to a
# DIFFERENT agent and the claimant is not allowed to reassign, reject.
# PMs with allow_reassign=True can still take over (used for handoffs).
if (
task.assigned_to is not None
and str(task.assigned_to) != str(agent_id)
and not allow_reassign
):
self.log.warning(
"Cannot claim task - assigned to another agent",
task_id=str(task_id),
assigned_to=str(task.assigned_to),
requesting_agent=str(agent_id),
)
return None
# Prevent self-review: QA/Documenter cannot claim tasks they developed
if error := self._validate_not_self_review(task, agent, agent_id):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
@@ -737,13 +795,13 @@ class TaskService(BaseService):
await self.session.flush()
# Auto-create hierarchical branch for git tasks (mandatory - raises on failure)
# Auto-create hierarchical branch (mandatory - raises on failure)
# Must happen BEFORE work session creation (work session needs branch_name)
if task.requires_git and not task.branch_name:
await self._ensure_branch_for_git_task(task, agent_id)
if not task.branch_name:
await self._ensure_branch_for_task(task, agent_id)
await self.session.refresh(task)
# Create work session for git-enabled tasks claimed by developers
# Create work session for tasks claimed by developers
# (now branch exists, so work session can be created)
await self._create_work_session_if_needed(task, agent_id, agent_role)
@@ -754,6 +812,80 @@ class TaskService(BaseService):
return task
async def unclaim(
self,
task_id: UUID,
agent_id: UUID,
agent_role: str | None = None,
return_to_assignee: UUID | None = None,
) -> TaskTable | None:
"""
Release a claimed task back to the task pool.
Use this when an agent claimed a task but realizes they shouldn't work on it
(e.g., Main PM claimed to help with branch but Cell PM should do the work).
This enables the CLAIMED PENDING transition defined in VALID_TRANSITIONS.
Args:
task_id: The task to unclaim
agent_id: The agent releasing the task (must be current assignee)
agent_role: Role of the agent (for transition validation)
return_to_assignee: Optional agent to assign the task to (PM handoff)
Returns:
The task in PENDING state, or None if unclaim not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow unclaiming from CLAIMED status (not IN_PROGRESS, etc.)
if task.status != TaskStatus.CLAIMED:
self.log.warning(
"Cannot unclaim - task not in CLAIMED status",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Verify the agent is the current assignee
if task.assigned_to != agent_id:
self.log.warning(
"Cannot unclaim - not assigned to you",
task_id=str(task_id),
assigned_to=str(task.assigned_to),
requesting_agent=str(agent_id),
)
return None
# Use validated transition (CLAIMED → PENDING is allowed for all roles)
self._validate_and_set_status(task, TaskStatus.PENDING, agent_role)
# Either assign to specified agent or clear assignment
if return_to_assignee:
task.assigned_to = cast("Any", return_to_assignee)
self.log.info(
"Task unclaimed and handed off",
task_id=str(task_id),
from_agent=str(agent_id),
to_agent=str(return_to_assignee),
)
else:
task.assigned_to = None
self.log.info(
"Task unclaimed and returned to pool",
task_id=str(task_id),
from_agent=str(agent_id),
)
# Clear claimed tracking
task.claimed_by = None
task.claimed_at = None
await self.session.flush()
return task
async def _inject_proactive_context(self, task: TaskTable, agent_id: UUID) -> None:
"""Inject proactive knowledge context when task is claimed.
@@ -819,10 +951,9 @@ class TaskService(BaseService):
agent_role: str | None,
) -> WorkSessionTable | None:
"""
Create a WorkSession when a developer claims a git-enabled task.
Create a WorkSession when a developer claims a task.
Only creates a session if:
- Task requires git (requires_git=True)
- Task has a project_id set
- Task has a branch_name set (auto-created on claim)
- Agent is a developer (not QA/Documenter claiming for review)
@@ -839,10 +970,6 @@ class TaskService(BaseService):
if agent_role not in ("developer", None):
return None
# Check if task requires git workflow
if not getattr(task, "requires_git", True):
return None
# Need project and branch to create a session
project_id = getattr(task, "project_id", None)
branch_name = getattr(task, "branch_name", None)
@@ -1400,7 +1527,10 @@ class TaskService(BaseService):
)
async def start(
self, task_id: UUID, agent_id: UUID | None = None
self,
task_id: UUID,
agent_id: UUID | None = None,
agent_role: str | None = None,
) -> TaskTable | None:
"""
Start working on a task.
@@ -1408,6 +1538,7 @@ class TaskService(BaseService):
Args:
task_id: The task to start
agent_id: Optional agent ID to validate ownership
agent_role: Optional agent role for transition validation
Returns:
The started task, or None if not allowed
@@ -1464,12 +1595,23 @@ class TaskService(BaseService):
# Only update started_at if this is the first time starting
if task.started_at is None:
task.started_at = datetime.now(UTC)
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS)
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS, agent_role)
await self.session.flush()
return task
async def block(self, task_id: UUID, blocker_task_id: UUID) -> TaskTable | None:
"""Block a task due to a dependency."""
async def block(
self,
task_id: UUID,
blocker_task_id: UUID,
agent_role: str | None = None,
) -> TaskTable | None:
"""Block a task due to a dependency.
Args:
task_id: The task to block
blocker_task_id: The task causing the block
agent_role: Role of agent performing the block (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1477,7 +1619,7 @@ class TaskService(BaseService):
if blocker_task_id not in task.dependency_ids:
new_deps = [*task.dependency_ids, blocker_task_id]
task.dependency_ids = new_deps
self._validate_and_set_status(task, TaskStatus.BLOCKED)
self._validate_and_set_status(task, TaskStatus.BLOCKED, agent_role)
await self.session.flush()
# Update the blocker task to reference this as blocked
@@ -1517,6 +1659,7 @@ class TaskService(BaseService):
reason: str,
blocker_type: str,
what_needed: str,
agent_role: str | None = None,
) -> TaskTable | None:
"""
Block a task due to an external factor (not a task dependency).
@@ -1532,6 +1675,7 @@ class TaskService(BaseService):
reason: Why the task is blocked
blocker_type: Type of blocker (external/internal/question/dependency)
what_needed: What is needed to unblock
agent_role: Role of agent performing the block (for validation)
Returns:
The blocked task, or None if blocking not allowed
@@ -1555,7 +1699,7 @@ class TaskService(BaseService):
else:
task.dev_notes = blocker_note
task.status = TaskStatus.BLOCKED
self._validate_and_set_status(task, TaskStatus.BLOCKED, agent_role)
await self.session.flush()
# Index blocker as error pattern (fire-and-forget)
@@ -1581,8 +1725,15 @@ class TaskService(BaseService):
)
return task
async def unblock(self, task_id: UUID) -> TaskTable | None:
"""Unblock a task and resume to in_progress."""
async def unblock(
self, task_id: UUID, agent_role: str | None = None
) -> TaskTable | None:
"""Unblock a task and resume to in_progress.
Args:
task_id: The task to unblock
agent_role: Role of agent performing the unblock (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1590,7 +1741,7 @@ class TaskService(BaseService):
if task.status != TaskStatus.BLOCKED:
return None
task.status = TaskStatus.IN_PROGRESS
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS, agent_role)
await self.session.flush()
self.log.info("Task unblocked", task_id=str(task_id))
@@ -1609,8 +1760,15 @@ class TaskService(BaseService):
return task
async def pause(self, task_id: UUID) -> TaskTable | None:
"""Pause a task."""
async def pause(
self, task_id: UUID, agent_role: str | None = None
) -> TaskTable | None:
"""Pause a task.
Args:
task_id: The task to pause
agent_role: Role of agent pausing the task (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1618,7 +1776,7 @@ class TaskService(BaseService):
if task.status != TaskStatus.IN_PROGRESS:
return None
task.status = TaskStatus.PAUSED
self._validate_and_set_status(task, TaskStatus.PAUSED, agent_role)
await self.session.flush()
self.log.info("Task paused", task_id=str(task_id))
@@ -1637,8 +1795,15 @@ class TaskService(BaseService):
return task
async def resume(self, task_id: UUID) -> TaskTable | None:
"""Resume a paused task."""
async def resume(
self, task_id: UUID, agent_role: str | None = None
) -> TaskTable | None:
"""Resume a paused task.
Args:
task_id: The task to resume
agent_role: Role of agent resuming the task (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1646,7 +1811,7 @@ class TaskService(BaseService):
if task.status != TaskStatus.PAUSED:
return None
task.status = TaskStatus.IN_PROGRESS
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS, agent_role)
await self.session.flush()
self.log.info("Task resumed", task_id=str(task_id))
@@ -1665,8 +1830,15 @@ class TaskService(BaseService):
return task
async def submit_for_verification(self, task_id: UUID) -> TaskTable | None:
"""Submit task for self-verification."""
async def submit_for_verification(
self, task_id: UUID, agent_role: str | None = None
) -> TaskTable | None:
"""Submit task for self-verification.
Args:
task_id: The task to verify
agent_role: Role of agent submitting for verification (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1674,14 +1846,21 @@ class TaskService(BaseService):
if task.status != TaskStatus.IN_PROGRESS:
return None
task.status = TaskStatus.VERIFYING
self._validate_and_set_status(task, TaskStatus.VERIFYING, agent_role)
await self.session.flush()
self.log.info("Task submitted for verification", task_id=str(task_id))
return task
async def submit_for_qa(self, task_id: UUID) -> TaskTable | None:
"""Submit task for QA review."""
async def submit_for_qa(
self, task_id: UUID, agent_role: str | None = None
) -> TaskTable | None:
"""Submit task for QA review.
Args:
task_id: The task to submit for QA
agent_role: Role of agent submitting (for validation)
"""
task = await self.get(task_id)
if not task:
return None
@@ -1699,7 +1878,7 @@ class TaskService(BaseService):
# The original developer is preserved in quick_context
task.assigned_to = None
task.self_verified = True
task.status = TaskStatus.AWAITING_QA
self._validate_and_set_status(task, TaskStatus.AWAITING_QA, agent_role)
await self.session.flush()
self.log.info(
@@ -1710,12 +1889,17 @@ class TaskService(BaseService):
return task
async def pass_qa(
self, task_id: UUID, notes: str | None = None
self, task_id: UUID, notes: str | None = None, agent_role: str = "qa"
) -> TaskTable | None:
"""Mark task as passed QA.
QA workflow: awaiting_qa claimed in_progress pass_qa
awaiting_documentation. Accept claimed/in_progress status.
Args:
task_id: The task to pass
notes: Optional QA notes
agent_role: Role of agent passing QA (must be 'qa')
"""
task = await self.get(task_id)
if not task:
@@ -1740,7 +1924,15 @@ class TaskService(BaseService):
# Clear assignment so documenter can claim the task
task.assigned_to = None
task.qa_verified = True
task.status = TaskStatus.AWAITING_DOCUMENTATION
# Reset parallel-phase flags so revisions re-enter the phase cleanly.
# Without this, a task cycled through needs_revision keeps stale
# docs_complete/pr_created from the prior round and skips the phase.
task.docs_complete = False
task.pr_created = False
# Use validated transition - QA role required per ROLE_RESTRICTED_TRANSITIONS
self._validate_and_set_status(
task, TaskStatus.AWAITING_DOCUMENTATION, agent_role
)
await self.session.flush()
# Index positive QA review (fire-and-forget)
@@ -1759,7 +1951,9 @@ class TaskService(BaseService):
self.log.info("Task passed QA", task_id=str(task_id))
return task
async def fail_qa(self, task_id: UUID, notes: str) -> TaskTable | None:
async def fail_qa(
self, task_id: UUID, notes: str, agent_role: str = "qa"
) -> TaskTable | None:
"""
Mark task as failed QA and reassign to original developer.
@@ -1769,6 +1963,11 @@ class TaskService(BaseService):
QA workflow: awaiting_qa claimed in_progress fail_qa needs_revision
So we need to accept tasks in claimed or in_progress status.
Args:
task_id: The task to fail
notes: QA notes explaining why it failed
agent_role: Role of agent failing the task (must be 'qa')
"""
task = await self.get(task_id)
if not task:
@@ -1785,7 +1984,8 @@ class TaskService(BaseService):
task.qa_notes = notes
task.qa_verified = False
task.status = TaskStatus.NEEDS_REVISION
# Use validated transition - QA role required per ROLE_RESTRICTED_TRANSITIONS
self._validate_and_set_status(task, TaskStatus.NEEDS_REVISION, agent_role)
# Store QA agent before reassigning
qa_agent_id = task.assigned_to
@@ -1911,14 +2111,13 @@ class TaskService(BaseService):
else doc_context
)
# For git tasks: check if BOTH docs_complete AND pr_created are true
# Check if BOTH docs_complete AND pr_created are true
# (Developer works in parallel, creating PR)
from roboco.enforcement.task_lifecycle import check_parallel_completion
ready_for_pm = check_parallel_completion(
docs_complete=True, # We just set this
pr_created=task.pr_created,
requires_git=task.requires_git,
)
if ready_for_pm:
@@ -1931,11 +2130,10 @@ class TaskService(BaseService):
self.log.info(
"Documentation complete, awaiting PM review",
task_id=str(task_id),
requires_git=task.requires_git,
pr_created=task.pr_created,
)
else:
# Git task: docs done but PR not yet created
# Docs done but PR not yet created
# Stay in awaiting_documentation, waiting for developer to create PR
self.log.info(
"Documentation complete, waiting for developer to create PR",
@@ -2015,12 +2213,13 @@ class TaskService(BaseService):
ready_for_pm = check_parallel_completion(
docs_complete=task.docs_complete,
pr_created=True, # We just set this
requires_git=task.requires_git,
)
if ready_for_pm:
# Both conditions met - transition to PM review
task.status = TaskStatus.AWAITING_PM_REVIEW
# Both conditions met - transition to PM review using proper validation
self._validate_and_set_status(
task, TaskStatus.AWAITING_PM_REVIEW, "developer"
)
# Clear assignment so PM can claim the task for review
task.assigned_to = None
self.log.info(
@@ -2045,18 +2244,23 @@ class TaskService(BaseService):
async def submit_for_pm_review(
self,
task_id: UUID,
agent_role: str = "cell_pm",
notes: str | None = None,
) -> TaskTable | None:
"""
Submit a task directly for PM review (any assigned agent).
Submit a task directly for PM review (PM, QA, or Documenter only).
Use this for tasks that don't follow the standard dev→QA→docs workflow,
such as PM validation tasks, QA audit tasks, or other directly-assigned work.
Even these tasks must have a branch and PR created to maintain git workflow.
Transitions task from IN_PROGRESS to AWAITING_PM_REVIEW.
Note: Only PM roles, QA, and Documenter can use this method (not developers).
Args:
task_id: The task to submit
agent_role: Role of the agent submitting (must be PM, QA, or documenter)
notes: Optional completion notes
Returns:
@@ -2075,6 +2279,23 @@ class TaskService(BaseService):
)
return None
# Validate git workflow requirements (all tasks must have branch and PR)
if not task.branch_name:
self.log.warning(
"Cannot submit for PM review - no branch (claim task first)",
task_id=str(task_id),
)
return None
if not task.pr_created or not task.pr_number:
self.log.warning(
"Cannot submit for PM review - PR must be created first",
task_id=str(task_id),
pr_created=task.pr_created,
pr_number=task.pr_number,
)
return None
# Check all descendants are in terminal states before escalating
all_descendants = await self.get_all_descendants(task_id)
incomplete = [
@@ -2101,12 +2322,14 @@ class TaskService(BaseService):
else note_entry
)
task.status = TaskStatus.AWAITING_PM_REVIEW
# Role-restricted transition (PM/QA/documenter only)
self._validate_and_set_status(task, TaskStatus.AWAITING_PM_REVIEW, agent_role)
await self.session.flush()
self.log.info(
"Task submitted for PM review",
task_id=str(task_id),
agent_role=agent_role,
)
return task
@@ -2326,12 +2549,11 @@ class TaskService(BaseService):
)
return None
# ENFORCEMENT: Git tasks must have PR created before CEO approval
if task.requires_git and not task.pr_number:
# ENFORCEMENT: Tasks must have PR created before CEO approval
if not task.pr_number:
self.log.warning(
"Cannot escalate to CEO - git task has no PR",
"Cannot escalate to CEO - task has no PR",
task_id=str(task_id),
requires_git=task.requires_git,
pr_created=task.pr_created,
)
return None
@@ -2375,6 +2597,7 @@ class TaskService(BaseService):
CEO approves and completes a task.
Final approval step for major tasks. Only CEO can perform this action.
PR must be merged before approval (CEO merges as final action).
Args:
task_id: The task to approve
@@ -2396,6 +2619,23 @@ class TaskService(BaseService):
)
return None
# Verify PR is merged (CEO merges as final action before approving)
if task.work_session_id:
work_session_result = await self.session.execute(
select(WorkSessionTable).where(
WorkSessionTable.id == task.work_session_id
)
)
work_session = work_session_result.scalar_one_or_none()
if work_session and work_session.pr_status != "merged":
self.log.warning(
"Cannot CEO approve - PR must be merged first",
task_id=str(task_id),
pr_status=work_session.pr_status,
pr_number=work_session.pr_number,
)
return None
# Store CEO notes
if notes:
existing_context = task.quick_context or ""
@@ -2504,6 +2744,21 @@ class TaskService(BaseService):
)
return task
async def _abandon_work_session_for_task(
self, task: TaskTable, reason: str
) -> None:
"""Mark the task's active work session as abandoned, if any.
Without this, cancelled tasks leave their WorkSessionTable row in
ACTIVE status forever, polluting list_active_sessions queries.
"""
if not task.work_session_id:
return
from roboco.services.work_session import get_work_session_service
ws_service = get_work_session_service(self.session)
await ws_service.abandon(require_uuid(task.work_session_id), reason=reason)
async def cancel(
self, task_id: UUID, agent_role: str = "cell_pm"
) -> TaskTable | None:
@@ -2513,13 +2768,33 @@ class TaskService(BaseService):
return None
# Cancel all descendants first (children, grandchildren, etc.)
# Skip tasks already in terminal states (completed or cancelled)
# Skip tasks already in terminal states (completed or cancelled).
# Route every descendant through _validate_and_set_status so role
# restrictions (e.g., only CEO can cancel awaiting_ceo_approval) still
# apply to cascaded cancels — skip descendants that fail validation
# rather than bypassing the rules.
descendants = await self.get_all_descendants(task_id)
cancelled_count = 0
for descendant in descendants:
if descendant.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
descendant.status = TaskStatus.CANCELLED
cancelled_count += 1
if descendant.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
continue
try:
self._validate_and_set_status(
descendant, TaskStatus.CANCELLED, agent_role
)
except Exception as e:
self.log.warning(
"Skipping cascade-cancel of descendant; role not permitted",
descendant_id=str(descendant.id),
descendant_status=descendant.status.value,
agent_role=agent_role,
error=str(e),
)
continue
cancelled_count += 1
await self._abandon_work_session_for_task(
descendant, reason="parent task cancelled"
)
if cancelled_count > 0:
self.log.info(
@@ -2530,6 +2805,7 @@ class TaskService(BaseService):
# Validate transition with PM role requirement
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
await self._abandon_work_session_for_task(task, reason="task cancelled")
await self.session.flush()
# Index lifecycle event (fire-and-forget)
@@ -2595,9 +2871,9 @@ class TaskService(BaseService):
task.dependency_ids = [
dep_id for dep_id in task.dependency_ids if dep_id != completed_task_id
]
# If no more dependencies, unblock
# If no more dependencies, unblock (system action - no role validation)
if not task.dependency_ids and task.status == TaskStatus.BLOCKED:
task.status = TaskStatus.IN_PROGRESS
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS, None)
self.log.info(
"Task auto-unblocked",
task_id=str(task.id),
+2 -2
View File
@@ -34,7 +34,7 @@ class WorkSessionService(BaseService):
Service for managing git work sessions.
Provides:
- Session creation when developer claims a git-enabled task
- Session creation when developer claims a task
- Branch name generation following naming conventions
- Commit and file tracking
- PR lifecycle management
@@ -98,7 +98,7 @@ class WorkSessionService(BaseService):
"""
Create a new work session.
Called when a developer claims a git-enabled task.
Called when a developer claims a task.
Args:
data: Work session creation data
+87 -37
View File
@@ -20,6 +20,7 @@ Example:
import asyncio
import re
import shutil
import subprocess
from pathlib import Path
from uuid import UUID
@@ -33,6 +34,22 @@ from roboco.models.base import Team
logger = get_logger(__name__)
# Per (project_slug, agent_slug) async lock to serialize concurrent
# ensure_workspace calls in the same orchestrator process. Prevents two
# coroutines from both passing the ".git exists?" check and then both
# trying to clone into the same directory.
_ENSURE_WORKSPACE_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock:
"""Return the asyncio.Lock for a (project, agent) pair, creating lazily."""
key = (project_slug, agent_slug)
lock = _ENSURE_WORKSPACE_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_ENSURE_WORKSPACE_LOCKS[key] = lock
return lock
def _inject_token_into_url(git_url: str, token: str | None) -> str:
"""
@@ -56,7 +73,7 @@ def _inject_token_into_url(git_url: str, token: str | None) -> str:
return git_url
# Check if token already present
if "@" in git_url.split("//")[1].split("/")[0]:
if "@" in git_url.split("//")[1].split("/", maxsplit=1)[0]:
return git_url
# Inject token: https://github.com -> https://TOKEN@github.com
@@ -163,6 +180,13 @@ class WorkspaceService:
"""
Ensure workspace exists, cloning if necessary.
Protects against:
- Partial clones (directory exists but `.git` does not) cleans up
the incomplete directory before re-cloning.
- Concurrent callers per (project, agent) asyncio.Lock serializes
ensure_workspace calls so two coroutines can't both try to clone
into the same directory.
Args:
project_slug: Project identifier
agent_id: Agent UUID or slug
@@ -197,45 +221,71 @@ class WorkspaceService:
team = agent.team if agent.team else Team.BACKEND
workspace = self.get_workspace_path(project_slug, team, agent.slug)
# Check if already exists
if (workspace / ".git").exists():
logger.debug(
"Workspace already exists",
workspace=str(workspace),
project=project_slug,
lock = _ensure_lock_for(project_slug, agent.slug)
async with lock:
# Healthy clone — nothing to do.
if (workspace / ".git").exists():
logger.debug(
"Workspace already exists",
workspace=str(workspace),
project=project_slug,
)
return workspace
# Partial clone: directory exists but no `.git`. git clone
# refuses to clone into a non-empty directory, so remove it
# first instead of letting the next clone fail.
if workspace.exists():
logger.warning(
"Removing partial workspace before re-clone",
workspace=str(workspace),
project=project_slug,
)
shutil.rmtree(workspace)
# Get git URL and token from project
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
raise WorkspaceError(f"Project not found: {project_slug}")
if not git_url:
git_url = project.git_url
default_branch = project.default_branch or default_branch
# Get decrypted token from project (per-project token, no global fallback).
# Convert cryptographic failures into a clear WorkspaceError — callers
# would otherwise see an opaque 500.
from roboco.utils.crypto import EncryptionError
try:
git_token = await project_service.get_decrypted_token_by_slug(
project_slug
)
except EncryptionError as e:
raise WorkspaceError(
f"Failed to decrypt git token for project '{project_slug}'. "
"The ROBOCO_ENCRYPTION_KEY may have been rotated or the "
"stored token is corrupted. Re-set the project token."
) from e
# Validate token is set for HTTPS URLs (no global fallback)
if git_url.startswith("https://") and not git_token:
raise WorkspaceError(
f"Project '{project_slug}' requires a git token for HTTPS clone. "
"Configure a GitHub PAT in the project settings."
)
# Clone the repository with agent identity
await self._clone_repo(
workspace,
git_url,
default_branch,
git_token,
agent=agent,
)
return workspace
# Get git URL and token from project
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
raise WorkspaceError(f"Project not found: {project_slug}")
if not git_url:
git_url = project.git_url
default_branch = project.default_branch or default_branch
# Get decrypted token from project (per-project token, no global fallback)
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
# Validate token is set for HTTPS URLs (no global fallback)
if git_url.startswith("https://") and not git_token:
raise WorkspaceError(
f"Project '{project_slug}' requires a git token for HTTPS clone. "
"Configure a GitHub PAT in the project settings."
)
# Clone the repository with agent identity
await self._clone_repo(
workspace,
git_url,
default_branch,
git_token,
agent=agent,
)
return workspace
async def _clone_repo(
self,
workspace: Path,
Generated
+3001 -1153
View File
File diff suppressed because it is too large Load Diff