mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Added git workflow + fixing some issues
This commit is contained in:
+10
-1
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ authors = [
|
||||
]
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.10,<3.15"
|
||||
|
||||
dependencies = [
|
||||
# Core
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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. "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user