Files
roboco/roboco/api/schemas/tasks.py
T
889f3689e7 MegaTask (#248)
* feat(batch): batch_id + collision descriptor columns

Sequenced batch intake ("Mega task") foundation: tasks.batch_id (indexed)
groups a batch of top-level tasks created together; intends_to_touch (text[]),
adds_migration and touches_shared (bool, NOT NULL default false) are the
per-task collision surface the SequencingService will read to wire dependency
waves. Mirrored on the Task model + TaskCreateRequest and wired through
TaskService.create. Migration 046 (real upgrade->downgrade->upgrade verified
vs a throwaway pgvector PG); a non-batch task declares no surface (defaults).

Task 1 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): flag + draft collision descriptors

Default-off ROBOCO_BATCH_INTAKE_ENABLED (config + FEATURE_FLAGS + panel card);
the propose_draft tool doc + the TS DraftProposal gain the per-task collision
surface intends_to_touch / adds_migration / touches_shared. The draft is a loose
dict so the descriptors ride it through the relay intact (test asserts the
forwarded payload); the analyzer (Task 3) reads them to wire dependency waves.

Task 2 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): deterministic collision-sequencing analyzer

SequencingService.analyze turns a batch's per-task collision surfaces into a
dependency DAG + execution waves — correctness in CODE, not agent judgment.
Rules in order: file overlap serializes (more-important first), migrations form
a serial chain (no concurrent Alembic heads), touches_shared runs last, cell
contention warns (never serializes); then dedupe, existence + cycle check, and
Kahn topological layering. Pure (no DB/services); SequencingError on a cycle or
out-of-range edge.

Golden test reproduces the CEO's hand-sequenced 4 waves of the 11-item
guard-core-app batch (the effort that deadlocked the Main PM): S6 alone last,
the R1/R3/R4 migration chain, R2/R3/S8 serialized on the shared threat service,
S1/S2/S7 in one parallel wave.

Task 3 of the 0.11.0 sequenced-batch-intake plan.

* chore(batch): brand the user-facing surfaces "MegaTask"

The user-facing name is MegaTask: the feature-flag label is "MegaTask intake",
the panel flag-card and the config description lead with MegaTask. Internal
names stay technical (batch_intake_enabled, batch_id, SequencingService).

* chore(batch): drop the feature flag — MegaTask is a core intake scope

MegaTask is additive and opt-in by its own nature (the Prompter proposes a
batch only when the CEO asks for several tasks; single-task intake is
unchanged), so there is no risk surface a flag protects — 'don't create a
MegaTask' is the off switch. Remove batch_intake_enabled from config, the
FEATURE_FLAGS registry, the panel flag card, and its tests. MegaTask will be
a third scope option in the Intake modal (single-cell / multi-project /
MegaTask), not a toggle.

* feat(batch): MegaTask identity predicate + orchestrator branchless recognition

The single source of truth for the umbrella's exemptions: pure
is_batch_umbrella / is_batch_root_subtask / is_branchless_coordination
(foundation/policy/batch.py) — an umbrella has a batch_id and is top-level; a
root-subtask shares the batch_id but is parented. The orchestrator's
_is_coordination_task now consults is_branchless_coordination, so a MegaTask
umbrella is recognized as doing no git of its own (git-exempt at spawn-readiness
/ stuck-detection) exactly like a product fan-out root. Non-batch behavior is
identical (the predicate reduces to the old no-project+product check; the
orchestrator coordination suite stays green), and the umbrella branch is inert
until the create path exists.

First slice of the MegaTask umbrella enforcement (branchless guard).

* feat(batch): branchless umbrella guard across the git-exemption sites

A MegaTask umbrella does no git of its own — every git-exemption site in
TaskService now consults the shared is_branchless_coordination predicate
instead of an inline product-only check, so the umbrella's exemptions
cannot drift between sites:

- the claimed->in_progress branch gate (GitContext.is_coordination) lets
  an unbranched umbrella reach in_progress and delegate;
- _ensure_branch_for_task short-circuits an umbrella to "" instead of the
  misconfigured raise (the claim path ignores the return, treating it as
  branchless);
- CEO-reject routing sends a rejected umbrella to the Main PM in PENDING
  (needs_revision is developer-claim-only and would deadlock it).

Covers both shapes via the predicate (product fan-out root OR umbrella);
a batch root-subtask keeps its own branch/PR. Adds orchestrator
recognition tests for the umbrella plus claim/branch/reject integration
tests.

* feat(batch): umbrella assembles no PR; completes branchless

submit_root now hard-rejects a MegaTask umbrella up front (a preflight
that also folds in the unknown-role refusal to stay within the
return-count budget): the umbrella spans many projects with no single
master, so each root-subtask opens and is reviewed on its own PR — the
umbrella never enters the in-path review gate. The Main PM completes it
directly once every root-subtask is terminal.

Umbrella completion needs no new code: it is branchless (no branch_name),
so _main_pm_complete_guard already accepts it from in_progress, checks
all_subtasks_terminal, and main_pm_complete walks it to awaiting_pm_review
and escalates to the CEO with no PR creation — exactly the product
fan-out root path. Adds the submit_root-reject and umbrella-completion
gateway tests; pins batch_id=None on the normal-root submit_root test
(a MagicMock auto-attr would otherwise read as an umbrella).

* feat(batch): MegaTask create path — umbrella + sequenced root-subtasks

PrompterService.confirm_live_batch turns N confirmed drafts into a real
MegaTask: it builds each draft's collision surface, runs the pure
SequencingService to get conflict-free waves, creates the branchless
umbrella (batch_id, no project/product), then one root-subtask per draft
(own project, parent=umbrella, sequence=wave index, descriptors), and
wires the analyzer's edges through add_dependency so the existing
dependency-gate runs the waves in order. The route picks the start path
like a single confirm: 'board' holds the root-subtasks in BACKLOG for the
batch review; 'main_pm' creates them PENDING so wave 0 dispatches at once.

create_task_from_draft gains a BatchPlacement (parent/batch/sequence/
team_override) and forwards the collision descriptors; the exactly-one-
target rule (here and the TaskService.create invariant) is relaxed for an
umbrella, which legitimately targets neither. New route
POST /live/{session}/confirm-batch + BatchConfirmRequest mirror the single
confirm. Adds the structural-invariant + board-hold + empty-batch tests.

* feat(batch): release MegaTask root-subtasks on CEO approval; board awareness

The board route holds a MegaTask's root-subtasks in BACKLOG so the work
waits for the batch review. approve_and_start (CEO gate #1, board->Main PM)
now releases them via _activate_batch_root_subtasks: each held child flips
BACKLOG -> PENDING + team=main_pm so the dependency-gate dispatches wave 0.
No-op for a non-umbrella; idempotent (children past BACKLOG untouched).

The Product Owner and Head of Marketing identity prompts gain a MegaTask
section so they review the whole batch + wave plan and adjust scope before
sign-off (they review drafts; the umbrella is their unit). Also extracts
the create() target invariant into _require_target_or_umbrella to keep the
method under the complexity gate after the umbrella exemption. Adds the
umbrella-approval activation test.

* feat(batch): multi-project intake scope for MegaTask

A MegaTask spans several possibly-unrelated repos, so the intake chat can
now be scoped to an explicit project list (not just one project or one
product). StartLiveRequest gains project_ids; /live/start threads it
through start/spawn_intake_session -> _spawn_intake_container ->
_clone_intake_scope. The multi-repo clone machinery already existed for
products; _intake_scope_slugs now also resolves an explicit project_ids
set (split into _slugs_for_project_ids / _slugs_for_product), cloning each
repo with the first as the primary cwd and the siblings readable. Scope
validation is now 'exactly one of project_slug / product_id / project_ids'
via the shared _require_one_intake_scope. Adds scope-resolution, spawn,
and route tests for the MegaTask path.

* feat(batch): propose_batch intake tool (MegaTask multi-draft hand-off)

The intake agent can now hand the panel a whole MegaTask in one tool call.
Both intake paths gain propose_batch alongside propose_draft:
- Claude (intake_driver): a propose_batch tool registered on the in-SDK
  MCP server + allowlisted; the driver intercepts the ToolUseBlock and
  emits ONE StreamChunk(kind="batch") carrying {drafts:[...], title}.
- grok (intake_server): a propose_batch tool that POSTs a "batch" relay
  event via the shared _post_event helper (post_draft/post_batch).

A batch carries N drafts, each the propose_draft shape PLUS its own
project_id (a MegaTask spans unrelated repos) and collision surface so the
analyzer sequences the waves. The prompter prompt documents the MegaTask
scope + when to call propose_batch. Adds Claude-normalize and grok-relay
tests for the batch path.

* feat(batch): MegaTask intake panel — third scope, batch review, waves

The panel now drives a MegaTask end to end. The intake modal gains a
third scope, 'MegaTask', beside Single cell and Board-led: a multi-project
checklist (a MegaTask spans several possibly-unrelated repos), validated
to at least two. start() sends project_ids; use-prompter accumulates the
agent's single propose_batch hand-off as a 'batch' SSE event into a
BatchProposal and lands in a new batch_preview state.

A new BatchReviewCard lists every proposed task with its target project +
collision-surface badges (migration / shared) and offers one start path
for the whole batch — Board review & Start or Approve & Start — wired to
confirmBatch → POST /confirm-batch. The success card shows the sequenced
result: N tasks in M waves (+ any advisory notes). prompter.ts gains the
DraftScale 'megatask' + the BatchConfirm payload/result types; the SSE
client allows the 'batch' kind. Panel typecheck + lint + 113 tests green.

* docs(batch): MegaTask across changelog, CLAUDE.md, site, and RAG

The four documentation obligations for the MegaTask feature:
- CHANGELOG: an Unreleased entry covering the umbrella model, sequencing,
  multi-project intake, propose_batch, and the create/approval path.
- CLAUDE.md: a MegaTask section (identity predicate, umbrella/root-subtask
  hierarchy, sequencing rules, intake + create path, board activation).
- Published site: a user-facing company/megatask.md (scopes, waves, the
  umbrella, the two start buttons) + nav entry; a pointer added to the
  intake chapter of the Tour.
- RAG corpus: workflows/megatask.md so the Main PM (and any agent) can
  retrieve the umbrella's branchless / no-PR / completion rules at runtime.

The runtime concurrent-migration guard is intentionally NOT added: the
analyzer already chains migration-adders into dependencies and the
dependency-gate serializes them, so a separate guard would be dead code.

* feat(batch): batch_id guardrail + wave preview + batch_id on TaskResponse

Guardrail (CEO): a batch_id is denied on any task that is not a well-formed
MegaTask member. is_valid_batch_shape permits batch_id only on an umbrella
(no parent → must target neither project nor product) or a root-subtask
(has a parent → exactly one target); TaskService.create enforces it AND
verifies a root-subtask's parent is the batch umbrella (same batch_id,
top-level). This closes a latent hole: is_batch_umbrella is true for a
batch_id + no-parent task even with a project, so a stray batch_id could
have spoofed the branchless branch-gate / no-PR exemption. (The public
task API never exposed batch_id for write; this guards the service layer.)

Wave preview: PrompterService.preview_batch + POST .../preview-batch
compute a MegaTask's waves from the proposed drafts WITHOUT creating
anything, so the panel can show the sequencing before confirm. Extracted
_sequence_drafts as the single source shared by preview and confirm, so
the previewed waves are exactly the ones wired.

TaskResponse now carries batch_id so the panel can badge the umbrella.

* feat(batch): MegaTask review — project editor, wave preview, persistence, badge

Closes the panel gaps in the MegaTask review experience:
- Per-task project editor: each proposed task gets an inline project
  Select (updateBatchDraftProject), so a task the agent put in the wrong
  or no repo can be fixed before launch — not only by re-chatting. Launch
  stays blocked until every task has a project.
- Wave preview: on a batch proposal the panel fetches POST .../preview-batch
  (no task created) and shows the conflict-free wave plan, so the human
  reviews the sequencing before confirming.
- Refresh durability: the MegaTask review (batch + waves + projectIds) is
  persisted, so a browser reload mid-review restores it like a single draft.
- MegaTask badge: TaskResponse exposes batch_id, the panel Task type
  carries it, and the task table badges the umbrella row 'MegaTask'.

Panel typecheck + lint + 113 tests green.

* test(batch): stub task carries batch_id for task_to_response

task_to_response now serializes batch_id (TaskResponse field), so the
_stub_task SimpleNamespace fixture must provide it — without it the reader
hit AttributeError, failing the 8 task-schema serialization/enrichment
tests. Test-only; the real TaskTable carries the column (migration 046).

* fix(batch): close MegaTask audit gaps — completion crash, analyzer cycle, guardrails

An adversarial multi-agent audit of the feature surfaced 20 verified gaps;
this closes the backend ones.

HIGH:
- Umbrella completion crashed. escalate_to_ceo hard-required a pr_number,
  which a branchless umbrella never has, so main_pm_complete dereferenced
  None. Both pr_number gates now waive a MegaTask umbrella (escalate_to_ceo
  + the awaiting_pm_review->awaiting_ceo_approval lifecycle gate via a new
  GitContext.is_umbrella), and main_pm_complete guards a None return. The
  completion test had mocked escalate_to_ceo, hiding it — now a real
  service test covers the waiver.
- The collision analyzer could fabricate a cycle (a touches_shared +
  adds_migration draft overlapping another migration draft) and raise
  SequencingError — a bare ValueError that escaped as an opaque 500. The
  migration chain is now shared-last-aware (never contradicts rule 3), and
  _sequence_drafts translates SequencingError to a clean 400.

MEDIUM:
- Collisions are now project-scoped: two repos can't collide on a
  coincidental path or serialize independent migrations (DraftSurface
  carries project_id; rules 1/2/3 respect it).
- The batch_id guardrail ran only at create. update() + the PATCH
  null-clear path now re-assert is_valid_batch_shape, so a mutation can't
  break a member's shape and spoof the branchless exemption.
- A draft missing title/acceptance_criteria now raises ValidationError
  (was a bare KeyError -> 500).
- confirm_live_batch re-asserts every draft targets a scoped project and
  the batch spans >=2 distinct projects (project_ids added to the request).
- Route-level tests for confirm-batch / preview-batch.

LOW: strict multi-repo clone (fail loud on any unresolvable project);
malformed/empty propose_batch surfaces an error chunk (Claude) / refuses
to POST (grok) instead of silently acking; dropped malformed drafts are
counted and surfaced; stale grok intake docstrings updated.

* fix(batch): MegaTask panel + doc audit gaps

Frontend half of the audit fixes:
- The confirm payload now carries project_ids (the schema requires it), and
  the panel re-checks every task targets one of the scoped repos before
  launching, naming the offending task.
- The Review-MegaTask project picker is filtered to the scoped repos and
  the per-task validity (border + launch gate) keys off scoped membership,
  so a task can only be (re)pointed at an in-scope project — also fixing the
  case where the agent emitted a non-UUID / unknown project.
- Dropped malformed drafts are surfaced as a chat error so the human knows
  the batch shrank instead of silently confirming fewer tasks.
- Doc wording: a wave releases on the previous wave's terminal state
  (normally a merge; a cancellation releases it too), not strictly 'merged'.

* test(batch): lock the CEO's EXACT 4-wave hand-sequencing as the golden bar

The golden test asserted the constraints (S6 last, the migration chain, the
shared-threats serialization, S1/S2/S7 parallel) but not the full wave
partition. The bar for MegaTask is 'reproduce my exact waves or it's not
done', so assert the exact 4-wave partition the analyzer produces for the
guard-core-app batch:
  wave 1: R1 R2 S1 S2 S3 S5 S7  ·  wave 2: R3  ·  wave 3: R4 S8  ·  wave 4: S6
Confirmed unchanged by the audit's analyzer fixes (no migration is shared;
single project).

* fix(batch): tolerate a stub task in assert_batch_shape_intact

The batch-shape re-validation read task.batch_id directly, but update()'s
partial-caller contract is exercised with a SimpleNamespace stub that has no
batch_id column → AttributeError. Use getattr(..., None) for batch_id and the
shape fields so the guard no-ops on any task lacking the column (a stub, or a
non-batch task) while still enforcing on a real batch member.

* fix(orchestrator): authenticate internal API self-calls with the system identity

The dispatcher httpx clients were built without an agent identity, so the
orchestrator's self-PATCHes to /api/tasks/{id} (auto-block, auto-resume,
auto-recover, SLA annotation) were rejected 401 "Missing X-Agent-ID" and
silently no-op'd. The auto-resume that lifts a PM's paused parent could never
write, so paused/blocked parents stayed wedged and stranded their dependents
(the fe-pm/be-pm respawn churn seen in prod).

Header propagation was inconsistent across the separate AsyncClient call-sites:
only the main dispatch client carried the system identity; the readiness and
sweep clients did not. Hoist the identity into a shared _SYSTEM_API_HEADERS
constant and apply it to every API-facing dispatcher client. The system role
holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
path those write routes use. The external provider-recovery probe client is
intentionally left untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 01:15:57 +02:00

824 lines
26 KiB
Python

"""
Tasks API Schemas
Request/response models for task endpoints, plus the conversion helpers
that build responses from ORM rows and normalize update payloads.
"""
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import select
from roboco.db.tables import ProjectTable, TaskTable, WorkSessionTable
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.models.session import SessionScope
from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list
# =============================================================================
# NESTED RESPONSE MODELS
# =============================================================================
class BoardReviewEntry(BaseModel):
"""One board reviewer's decision-log entry for a task (PO or Head of Marketing).
Surfaced at the CEO's approval/redraft gate so the actual board analysis is
readable instead of a placeholder.
"""
author: str
author_role: str
title: str
content: str
timestamp: str | None = None
class ProgressUpdateResponse(BaseModel):
"""A progress update on a task."""
timestamp: datetime
agent_id: UUID
message: str
percentage: int | None = None
class CheckpointResponse(BaseModel):
"""A saved state checkpoint for task recovery."""
id: UUID
timestamp: datetime
agent_id: UUID
state_summary: str
remaining_work: list[str] = []
notes: str | None = None
class CommitRefResponse(BaseModel):
"""Reference to a git commit."""
hash: str
message: str
timestamp: datetime
author_agent_id: UUID | None = None
class TaskSessionLinkResponse(BaseModel):
"""A session linked to this task."""
session_id: UUID
channel_slug: str
scope: SessionScope
is_primary: bool
relationship_type: str
class WorkSessionSummaryInTask(BaseModel):
"""Work session info embedded in task response."""
id: UUID
branch_name: str
status: str
commits: list[str] = []
files_modified: list[str] = []
pr_number: int | None = None
pr_url: str | None = None
pr_status: str | None = None
class ProjectSummaryInTask(BaseModel):
"""Project info embedded in task response."""
id: UUID
name: str
slug: str
git_url: str
default_branch: str
class SubTaskResponse(BaseModel):
"""A sub-task within a task plan."""
id: UUID
title: str
description: str | None = None
completed: bool = False
order: int
estimated_hours: float | None = None
notes: str | None = None
class TaskPlanResponse(BaseModel):
"""Implementation plan for a task."""
approach: str
sub_tasks: list[SubTaskResponse] = []
technical_considerations: list[str] = []
risks: list[dict[str, str]] = []
open_questions: list[dict[str, Any]] = []
# =============================================================================
# INPUT MODELS (for creating/updating nested data)
# =============================================================================
class SubTaskInput(BaseModel):
"""Input for creating/updating a sub-task."""
id: str # Client-generated ID
title: str
description: str | None = None
completed: bool = False
order: int
estimated_hours: float | None = None
notes: str | None = None
class TaskPlanInput(BaseModel):
"""Input for creating/updating a task plan.
`approach` must be substantive (>=30 chars) — the whole point of the
plan is to force the agent to think before starting work, and a
one-line or empty approach defeats that. sub_tasks can be empty
for small tasks; risks/considerations/open_questions are optional.
"""
approach: str = Field(..., min_length=30)
sub_tasks: list[SubTaskInput] = []
technical_considerations: list[str] = []
risks: list[dict[str, Any]] = []
open_questions: list[dict[str, Any]] = []
class ProgressUpdateInput(BaseModel):
"""Input for adding a progress update."""
timestamp: datetime
agent_id: str # Can be agent slug or "CEO"
message: str
percentage: int | None = None
class CheckpointInput(BaseModel):
"""Input for adding a checkpoint."""
id: str # Client-generated ID
timestamp: datetime
agent_id: str # Can be agent slug or "CEO"
state_summary: str
remaining_work: list[str] = []
notes: str | None = None
class CommitRefInput(BaseModel):
"""Input for linking a commit."""
hash: str
message: str
timestamp: datetime
author_agent_id: str | None = None # Can be agent slug or "CEO"
# =============================================================================
# REQUEST MODELS
# =============================================================================
class TaskUpdate(BaseModel):
"""Request to update a task.
CEO can update any field. All fields are optional for partial updates,
but when present they must satisfy the foundation completeness rules
appropriate to their lifecycle moment. Notably, acceptance_criteria
cannot be set to [] or None — that would blank the criteria post-
creation, violating the Golden Rule "no task without acceptance
criteria".
"""
# Basic info
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, min_length=20)
acceptance_criteria: list[str] | None = Field(default=None, min_length=1)
priority: int | None = Field(default=None, ge=0, le=3)
sequence: int | None = Field(default=None, ge=0) # Order within siblings
target_date: datetime | None = None
estimated_complexity: Complexity | None = None
# Classification
nature: TaskNature | None = None
task_type: TaskType | None = None
project_id: str | None = None # UUID string
# Ownership & assignment
team: Team | None = None
assigned_to: str | None = None # UUID string or null to unassign
# Relationships
parent_task_id: str | None = None # UUID string or null
dependency_ids: list[str] | None = None # List of UUID strings
blocker_ids: list[str] | None = None # List of UUID strings
# Planning
plan: TaskPlanInput | None = None
# Execution tracking
progress_updates: list[ProgressUpdateInput] | None = None
checkpoints: list[CheckpointInput] | None = None
# Artifacts
commits: list[CommitRefInput] | None = None
# Notes
dev_notes: str | None = None
qa_notes: str | None = None
auditor_notes: str | None = None
pr_reviewer_notes: str | None = None
doc_notes: str | None = None
quick_context: str | None = None
# The structured source of truth (panel renders the verdict pill + sections
# from this). Must be serialized or the PR-review verdict + any structured
# rendering are blank even when the DB has them.
notes_structured: dict | None = None
# Lifecycle override — privileged/admin only. Applied by the route as an
# audited force-transition (so an operator can recover a task wedged in a
# state with no valid in-band move), never as a free-form field set.
status: TaskStatus | None = None
@model_validator(mode="before")
@classmethod
def _reject_explicit_blank_acceptance_criteria(cls, data: Any) -> Any:
"""If acceptance_criteria is in the payload at all, it must be non-empty.
Pydantic's `Field(default=None, min_length=1)` does not reject the
explicit `None` case because `None` matches the `Optional` type
annotation. This validator fills the gap so PATCH cannot be used
to blank the criteria post-creation (Golden Rule preservation).
"""
if isinstance(data, dict) and "acceptance_criteria" in data:
value = data["acceptance_criteria"]
if value is None or (isinstance(value, list) and len(value) == 0):
raise ValueError(
"acceptance_criteria cannot be blanked via PATCH "
"(Golden Rule: no task without acceptance criteria). "
"Omit the field if you don't want to change it; pass "
"a non-empty list to replace it."
)
return data
# =============================================================================
# RESPONSE MODELS
# =============================================================================
class TaskResponse(BaseModel):
"""Task response model with full detail."""
# Identity
id: UUID
title: str
description: str
acceptance_criteria: list[str]
# Status
status: TaskStatus
priority: int
sequence: int # Order number within siblings
nature: TaskNature # Technical or non-technical work
# Task Type & Git Configuration (all tasks follow git workflow)
task_type: TaskType # code, documentation, research, etc.
project_id: UUID | None = None # Repo this task targets (None for fan-out)
project_slug: str | None = None # Project slug for MCP/git tool calls
product_id: UUID | None = None
# Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase)
docs_complete: bool = False # Documenter has finished
pr_created: bool = False # Developer has created PR
# Board review handoff: True once PO + Head of Marketing have both reviewed
# a pending board/coordination task. Gates the CEO's Approve & Start button.
board_review_complete: bool = False
# Ownership
team: Team
created_by: UUID
assigned_to: UUID | None
# Relationships
parent_task_id: UUID | None
dependency_ids: list[UUID]
blocker_ids: list[UUID]
# MegaTask grouping: set on the umbrella AND every root-subtask of a batch.
# The umbrella is the one with batch_id set and parent_task_id None.
batch_id: UUID | None = None
# Timestamps
created_at: datetime
updated_at: datetime | None
claimed_at: datetime | None
claimed_by: UUID | None
started_at: datetime | None
completed_at: datetime | None
target_date: datetime | None
last_heartbeat_at: datetime | None = None
# Planning
estimated_complexity: Complexity
plan: TaskPlanResponse | None = None
# Execution
checkpoints: list[CheckpointResponse] = []
progress_updates: list[ProgressUpdateResponse] = []
# Artifacts
commits: list[CommitRefResponse] = []
# Documentation
dev_notes: str | None
qa_notes: str | None
auditor_notes: str | None = None
pr_reviewer_notes: str | None = None
doc_notes: str | None = None
quick_context: str | None
notes_structured: dict | None = None
orchestration_markers: dict | None = None
# Review Status
self_verified: bool
qa_verified: bool | None
# Linked Sessions (for agent context)
sessions: list[TaskSessionLinkResponse] = []
# Git/Development Context (for full traceability)
project: ProjectSummaryInTask | None = None
work_session: WorkSessionSummaryInTask | None = None
branch_name: str | None = None
pr_number: int | None = None
pr_url: str | None = None
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
model_config = ConfigDict(from_attributes=True)
class TaskSummaryResponse(BaseModel):
"""Lightweight task response for list views."""
id: UUID
title: str
status: TaskStatus
priority: int
team: Team
assigned_to: UUID | None
created_at: datetime
updated_at: datetime | None
estimated_complexity: Complexity
nature: TaskNature
model_config = ConfigDict(from_attributes=True)
class ProgressRequest(BaseModel):
"""Request to add progress update.
`message` must be substantive (>=10 chars) — a one-char progress
update satisfies the existence gate but tells no-one anything. The
percentage is bounded [0, 100] by pydantic Field.
"""
message: str = Field(..., min_length=10)
percentage: int | None = Field(default=None, ge=0, le=100)
class CheckpointRequest(BaseModel):
"""Request to add checkpoint."""
state_summary: str
remaining_work: list[str]
notes: str | None = None
class CommitRequest(BaseModel):
"""Request to link a commit."""
hash: str = Field(..., min_length=7, max_length=40)
message: str
class ClaimRequest(BaseModel):
"""Request to claim a task on behalf of an agent.
Used by privileged roles (system, PM) to claim tasks for other agents.
Accepts either a UUID or agent slug (e.g., "be-dev-1").
"""
agent_id: str = Field(..., description="The agent ID (UUID) or slug to claim for")
class QANotes(BaseModel):
"""QA review notes."""
notes: str
class CancelTaskRequest(BaseModel):
"""Request to cancel a task. Reason is required for audit trail."""
reason: str = Field(
...,
min_length=10,
description=(
"Why the task is being cancelled. Appended to dev_notes so the "
"cancellation has an audit trail. Minimum 10 chars."
),
)
class CompleteTaskRequest(BaseModel):
"""Request to complete a task with optional force flag."""
force_with_cancelled: bool = Field(
default=False,
description="Force complete even if some subtasks are cancelled. "
"PM takes responsibility for judging work is done. "
"Only applies to cancelled subtasks, not pending/in_progress.",
)
justification: str | None = Field(
default=None,
description="Required when force_with_cancelled=True. "
"PM's justification for completing despite cancelled subtasks.",
)
class SoftBlockRequest(BaseModel):
"""Request to soft-block a task due to an external factor."""
reason: str = Field(..., description="Why the task is blocked")
blocker_type: str = Field(
..., description="Type of blocker: external, internal, question, dependency"
)
what_needed: str = Field(..., description="What is needed to unblock the task")
# Who can resolve: another agent ("agent") or only a human ("human").
# Default is "agent" for back-compat with existing clients. When "human",
# the dispatcher will NOT respawn agents on this task and only a HITL
# unblock will move it forward.
resolver_type: str = Field(
default="agent",
description=(
"Who resolves: 'agent' (another agent can fix it — dispatcher "
"keeps working) or 'human' (HITL/CEO only — dispatcher stops)"
),
)
class EscalateRequest(BaseModel):
"""Request to escalate a task to PM/management.
Escalation is available to ALL agents (devs, QA, documenters) when blocked.
This bypasses normal notification permissions because escalation is a critical
workflow tool for getting help when stuck.
"""
reason: str = Field(..., description="Why the task is being escalated")
escalate_to: str | None = Field(
None, description="Target agent ID (defaults to cell PM)"
)
class EscalateResponse(BaseModel):
"""Response from an escalation request."""
status: str
task_id: UUID
escalated_to: str
reason: str
message: str
class SubstituteRequest(BaseModel):
"""Request to substitute out of a task.
Allows agents to gracefully release tasks when they can't continue.
This BYPASSES the "can't claim while in_progress" rule.
"""
reason: str = Field(
...,
description=(
"Substitution reason: low_context, out_of_scope_team, "
"out_of_scope_role, task_complete, max_retries, blocked_external"
),
)
details: str = Field(..., description="Human-readable explanation")
suggested_role: str | None = Field(
None, description="Hint for reassignment (developer, qa, pm, documenter)"
)
suggested_team: str | None = Field(
None, description="Hint for reassignment (backend, frontend, ux_ui)"
)
class TaskCountResponse(BaseModel):
"""Task count by category."""
counts: dict[str, int]
class ValidTransitionsResponse(BaseModel):
"""Valid next statuses for a task given its current state."""
valid_statuses: list[TaskStatus]
class ListTasksQuery(BaseModel):
"""Query params for listing tasks."""
team: Team | None = None
status: TaskStatus | None = None
limit: int = Field(100, ge=1, le=500)
offset: int = Field(0, ge=0)
class TeamTasksQuery(BaseModel):
"""Query params for team tasks."""
task_status: TaskStatus | None = None
limit: int = Field(100, ge=1, le=500)
def convert_plan(plan_data: dict | None) -> TaskPlanResponse | None:
"""Convert plan JSON dict to TaskPlanResponse.
Coerces malformed sub_task ids (e.g., agent supplied "1" instead of a
UUID) to a fresh UUID so a single bad write doesn't permanently brick
the read path. The DB-level id is replaced; PMs treat sub_task.order
+ title as the stable handle anyway.
Risks: the schema declares ``risks: list[dict[str, str]]``; a None
severity from a pre-fix gateway write fails the response load with a
500 on every panel poll. Coerce defensively here so existing-bad data
in the DB doesn't brick the read path.
"""
if not plan_data:
return None
def _coerce_id(value: object) -> UUID:
if isinstance(value, UUID):
return value
if isinstance(value, str):
try:
return UUID(value)
except (ValueError, AttributeError):
return uuid4()
return uuid4()
def _coerce_risk(r: object) -> dict[str, str]:
if not isinstance(r, dict):
return {
"description": str(r) if r else "",
"mitigation": "",
"severity": "medium",
}
sev = r.get("severity")
return {
"description": str(r.get("description") or r.get("risk") or ""),
"mitigation": str(r.get("mitigation") or ""),
"severity": str(sev) if sev not in (None, "") else "medium",
}
sub_tasks = [
SubTaskResponse(
id=_coerce_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", ""),
sub_tasks=sub_tasks,
technical_considerations=plan_data.get("technical_considerations", []),
risks=[_coerce_risk(r) for r in (plan_data.get("risks") or [])],
open_questions=plan_data.get("open_questions", []),
)
def convert_checkpoints(checkpoints_data: list | None) -> list[CheckpointResponse]:
"""Convert checkpoints JSON list to CheckpointResponse list."""
if not checkpoints_data:
return []
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"),
)
for cp in checkpoints_data
]
def convert_progress_updates(
updates_data: list | None,
) -> list[ProgressUpdateResponse]:
"""Convert progress_updates JSON list to ProgressUpdateResponse list."""
if not updates_data:
return []
return [
ProgressUpdateResponse(
timestamp=pu.get("timestamp"),
agent_id=pu.get("agent_id"),
message=pu.get("message", ""),
percentage=pu.get("percentage"),
)
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 []
return [
CommitRefResponse(
hash=cm.get("hash", ""),
message=cm.get("message", ""),
timestamp=cm.get("timestamp"),
author_agent_id=cm.get("author_agent_id"),
)
for cm in commits_data
]
def task_to_response(task: "TaskTable") -> TaskResponse:
"""Convert TaskTable to TaskResponse with proper UUID conversion."""
return TaskResponse(
id=require_uuid(task.id),
title=task.title,
description=task.description,
acceptance_criteria=task.acceptance_criteria or [],
status=task.status,
priority=task.priority,
sequence=task.sequence,
nature=task.nature,
task_type=task.task_type,
project_id=to_python_uuid(task.project_id),
product_id=to_python_uuid(task.product_id),
# Don't trigger a lazy load — on freshly-created tasks `project` is
# unloaded, and a sync attribute access here would raise
# MissingGreenlet. Omit the slug rather than force an async round-trip.
project_slug=(
task.project.slug
if "project" not in sa_inspect(task).unloaded and task.project is not None
else None
),
docs_complete=task.docs_complete,
pr_created=task.pr_created,
board_review_complete=task.board_review_complete,
team=task.team,
created_by=require_uuid(task.created_by),
assigned_to=to_python_uuid(task.assigned_to),
parent_task_id=to_python_uuid(task.parent_task_id),
dependency_ids=to_python_uuid_list(task.dependency_ids),
blocker_ids=to_python_uuid_list(task.blocker_ids),
batch_id=to_python_uuid(task.batch_id),
created_at=task.created_at,
updated_at=task.updated_at,
claimed_at=task.claimed_at,
claimed_by=to_python_uuid(task.claimed_by),
started_at=task.started_at,
completed_at=task.completed_at,
target_date=task.target_date,
last_heartbeat_at=getattr(task, "last_heartbeat_at", None),
estimated_complexity=task.estimated_complexity,
plan=convert_plan(task.plan),
checkpoints=convert_checkpoints(task.checkpoints),
progress_updates=convert_progress_updates(task.progress_updates),
commits=convert_commits(task.commits),
dev_notes=task.dev_notes,
qa_notes=task.qa_notes,
auditor_notes=task.auditor_notes,
pr_reviewer_notes=task.pr_reviewer_notes,
doc_notes=task.doc_notes,
quick_context=task.quick_context,
notes_structured=task.notes_structured,
self_verified=task.self_verified,
qa_verified=task.qa_verified,
branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None),
source=getattr(task, "source", "manual"),
confirmed_by_human=getattr(task, "confirmed_by_human", False),
)
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)."""
task_dict = task_response.model_dump()
if include_work_session and hasattr(task_response, "id"):
# A task can have multiple work sessions over its lifetime
# (one per claim/unclaim cycle). Pick the most recent so we
# don't crash with MultipleResultsFound on tasks that have
# been re-claimed.
query = (
select(WorkSessionTable)
.where(WorkSessionTable.task_id == task_response.id)
.order_by(WorkSessionTable.created_at.desc())
.limit(1)
)
result = await db.execute(query)
work_session = result.scalar_one_or_none()
if work_session:
task_dict["work_session"] = WorkSessionSummaryInTask(
id=work_session.id,
branch_name=work_session.branch_name,
status=work_session.status.value if work_session.status else "unknown",
commits=list(work_session.commits or []),
files_modified=list(work_session.files_modified or []),
pr_number=work_session.pr_number,
pr_url=work_session.pr_url,
pr_status=work_session.pr_status,
)
if include_project and work_session.project_id:
proj_query = select(ProjectTable).where(
ProjectTable.id == work_session.project_id
)
proj_result = await db.execute(proj_query)
project = proj_result.scalar_one_or_none()
if project:
task_dict["project"] = ProjectSummaryInTask(
id=project.id,
name=project.name,
slug=project.slug,
git_url=project.git_url,
default_branch=project.default_branch,
)
return TaskResponse(**task_dict)
def parse_uuid_or_none(value: str | None) -> UUID | None:
"""Parse a string to UUID, returning None if empty or None."""
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_uuid_list(id_strings: list[str] | None) -> list[UUID]:
"""Parse a list of UUID strings to UUID objects, filtering empty values."""
if not id_strings:
return []
return [UUID(id_str) for id_str in id_strings if id_str]
_SINGLE_UUID_FIELDS = ("assigned_to", "parent_task_id", "project_id")
_UUID_LIST_FIELDS = ("dependency_ids", "blocker_ids")
def transform_update_data(data: TaskUpdate) -> dict:
"""Transform TaskUpdate input to format suitable for database storage."""
updates = data.model_dump(exclude_unset=True)
for field in _SINGLE_UUID_FIELDS:
if field in updates:
updates[field] = parse_uuid_or_none(updates[field])
for field in _UUID_LIST_FIELDS:
if field in updates:
updates[field] = _parse_uuid_list(updates[field])
return updates