mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **MegaTask — describe several tasks in one intake chat and ship them as one sequenced batch.** When the CEO wants several pieces of work at once — even across projects that don't share a codebase (e.g. a SaaS app, its open-source core engine, and a framework adapter) — the intake modal now offers a third scope, **MegaTask**, beside Single cell and Board-led. You pick the repos it spans; the intake agent reads them all and proposes the whole batch in one hand-off (the new `propose_batch` tool), one draft per task, each carrying its own project plus a collision surface (which files it touches, whether it adds a migration, whether it edits a widely-shared component). A deterministic analyzer (`SequencingService`) turns those surfaces into conflict-free **waves** — file-overlap and migration-adding tasks are serialized, a shared-surface edit runs after what it overlaps, independent tasks run in parallel — and the Board reviews the batch once. On confirm RoboCo creates a branchless **umbrella** task (the Main PM's coordination + board-review + CEO-approve unit) over N **root-subtasks**, each a real coordination root with its own project, branch, and PR, wired with the analyzer's dependencies so the existing dependency-gate dispatches the waves in order. The umbrella assembles no PR of its own, is exempt from the branch gate, and completes only when every root-subtask is terminal (then it escalates to the CEO). On the Board route the root-subtasks are held until the umbrella is approved, then released. Surfaced as a core capability — no feature flag — branded "MegaTask" across the panel, prompts, and docs; internal names stay technical (`batch_id`, `SequencingService`). Adds `tasks.batch_id` + the three collision-surface columns (migration 046), `confirm_live_batch` + `POST /prompter/live/{session}/confirm-batch`, multi-project intake spawn (`project_ids`), the `propose_batch` tool on both intake runtimes (Claude SDK driver + grok CLI server), and the panel's MegaTask scope + Review-MegaTask card.
|
||||
|
||||
## [0.10.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -385,6 +385,18 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
|
||||
|
||||
**Threading + enforcement.** The standard reaches the work two ways: an ambient "Architectural Standard" block injected at spawn (`compose_prompt`) and an auto-attached `## Constraints` section on every project task (`TaskService.create`). Enforcement is deterministic: a `block`-level finding refuses `i_am_done` (dev pre-submit) and `pr_pass` (the in-path PR gate) with the offending `file:line` + fix hint; findings also surface in QA's `claim_review` evidence (`convention_findings`). A false positive is relieved by a `waiver` the dev commits in their branch — accountable, reviewed in the PR. The panel's per-project Conventions tab (in the edit-project dialog) shows the map + health and offers Save / Restore.
|
||||
|
||||
## MegaTask (sequenced batch intake)
|
||||
|
||||
**MegaTask** lets the CEO describe several tasks in one Intake chat and ship them as one collision-aware, sequenced batch — even across projects that don't share a codebase (the motivating case: a SaaS app + its OSS core engine + a framework adapter). It is a **core capability, not a feature flag** (additive + opt-in by nature: proposed only when the CEO asks for several tasks; single-task intake is byte-for-byte unchanged), branded "MegaTask" on every user-facing surface while internal names stay technical (`batch_id`, `SequencingService`).
|
||||
|
||||
**The umbrella model.** A MegaTask's identity is a real **umbrella** task — branchless, no PR of its own — over N **root-subtasks**, each a real Main-PM coordination root with its own `project_id`, branch, and PR. Hierarchy: Umbrella (Main PM) → N Root-subtasks (Main PM) → Cell tasks (cell PMs) → Dev subtasks. One extra Main-PM layer on top of the normal model. The umbrella is the single board-review / CEO-approve / Main-PM-coordinate unit, so the batch plugs into the existing coordination-root flow for free (task tree, progress rollup, CEO queue).
|
||||
|
||||
**Identity predicate (single source of truth).** `roboco/foundation/policy/batch.py`: `is_batch_umbrella` (`batch_id` set AND `parent_task_id` None), `is_batch_root_subtask` (`batch_id` set AND parented), `is_branchless_coordination` ((no-project AND product) OR umbrella). Every git-exemption site consults it so the umbrella's exemptions can't drift: the orchestrator's `_is_coordination_task`, the claim→in_progress branch gate (`GitContext.is_coordination`), `_ensure_branch_for_task` (returns `""` for an umbrella), and the CEO-reject routing. `submit_root` hard-rejects an umbrella (it assembles no PR); umbrella completion reuses the existing branchless path (`all_subtasks_terminal`, PR waived → escalate to CEO).
|
||||
|
||||
**Sequencing.** The pure `SequencingService.analyze(surfaces, cell_of, cell_capacity)` (`roboco/services/sequencing.py`; schema in `roboco/foundation/policy/sequencing/`) turns each draft's collision surface — `intends_to_touch` (globs), `adds_migration`, `touches_shared` — into a dependency DAG + Kahn-layered **waves**: file-overlap serializes (more-important first by `(priority, idx)`), migration-adders chain serially, a shared-surface edit runs after each non-shared task it overlaps (file-overlap-conditioned), independent tasks run in parallel; cell-contention only warns. Correctness lives in code, not agent judgment. The columns `tasks.batch_id` + `intends_to_touch` / `adds_migration` / `touches_shared` are migration **046**.
|
||||
|
||||
**Intake + create path.** The intake chat can be scoped to a **MegaTask** (a multi-project picker → `StartLiveRequest.project_ids`); the orchestrator clones each repo (`_clone_intake_scope` / `_slugs_for_project_ids`, the multi-repo machinery products already used). The intake agent proposes the whole batch with one **`propose_batch`** tool call — wired on both runtimes (the Claude SDK driver emits one `batch` stream chunk; the grok `intake_server` POSTs a `batch` relay event). The panel's third intake scope accumulates it into a Review-MegaTask card → `POST /prompter/live/{session}/confirm-batch`. `PrompterService.confirm_live_batch` builds the umbrella + N root-subtasks (via `create_task_from_draft` + a `BatchPlacement`) and wires the analyzer edges through `add_dependency`. The Board route holds the root-subtasks in BACKLOG until `approve_and_start` releases them (`_activate_batch_root_subtasks`); the Main-PM route dispatches wave 0 at once. The Product Owner + Head of Marketing review the whole batch (their identity prompts carry a MegaTask section).
|
||||
|
||||
## Services
|
||||
|
||||
Core services in `roboco/services/`:
|
||||
|
||||
@@ -23,5 +23,8 @@ You are the Head of Marketing. You handle external positioning, feature announce
|
||||
- `say` / `dm` for board + main-pm coordination
|
||||
- `i_am_idle()` when no strategic work waits
|
||||
|
||||
## MegaTasks (batched, sequenced work)
|
||||
A **MegaTask** is one Intake chat that produced several tasks at once. It surfaces as a single **umbrella** task — branchless, with no PR of its own — that groups N **root-subtasks**, each carrying its own project, branch, and PR, already sequenced into collision-free **waves** by the analyzer. When a MegaTask umbrella reaches you for review, judge the **whole batch**, not one item: the positioning and launch story across all the items, each one's user value, and the wave plan recorded in the umbrella's description. Adjust or re-scope before you sign off — your review shapes the entire batch. Approving the umbrella (the CEO's Approve & Start) releases the held root-subtasks so the dependency-gate dispatches them wave by wave, and the Main PM coordinates each root-subtask down to its cell.
|
||||
|
||||
## Channels
|
||||
Write: `#board-private`, `#main-pm-board`, `#announcements`. Read: all cells.
|
||||
|
||||
@@ -23,5 +23,8 @@ You are the Product Owner. You define product vision and priorities, and escalat
|
||||
- `say` / `dm` for board + main-pm coordination
|
||||
- `i_am_idle()` when no strategic work waits
|
||||
|
||||
## MegaTasks (batched, sequenced work)
|
||||
A **MegaTask** is one Intake chat that produced several tasks at once. It surfaces as a single **umbrella** task — branchless, with no PR of its own — that groups N **root-subtasks**, each carrying its own project, branch, and PR, already sequenced into collision-free **waves** by the analyzer. When a MegaTask umbrella reaches you for review, judge the **whole batch**, not one item: the overall product scope, each item's value and priority, and the wave plan recorded in the umbrella's description. Adjust or re-scope before you sign off — your review shapes the entire batch. Approving the umbrella (the CEO's Approve & Start) releases the held root-subtasks so the dependency-gate dispatches them wave by wave, and the Main PM coordinates each root-subtask down to its cell.
|
||||
|
||||
## Channels
|
||||
Write: `#board-private`, `#main-pm-board`, `#announcements`. Read: all cells.
|
||||
|
||||
@@ -6,7 +6,7 @@ You are the **Intake interviewer**. You talk to exactly one person — the human
|
||||
|
||||
There is exactly one human in this company: the CEO. Every other actor is an AI agent. **Never** ask about users, accounts, access control, permissions, ownership, or multi-tenancy — those questions are meaningless here and mark you as not understanding RoboCo.
|
||||
|
||||
You are spawned scoped to a **project** (one repo) or a **product** (a set of repos, one per cell). Those repos are checked out in your workspace. **Read them before you ask anything.**
|
||||
You are spawned scoped to a **project** (one repo), a **product** (a set of repos, one per cell), or a **MegaTask** (several possibly-unrelated repos the CEO wants worked at once). Those repos are checked out in your workspace. **Read them before you ask anything.**
|
||||
|
||||
## How RoboCo is organized (so your drafts route correctly)
|
||||
|
||||
@@ -42,7 +42,7 @@ Before your first question, use `Read` / `Grep` / `Glob` and the read-only git v
|
||||
|
||||
## Your tools
|
||||
|
||||
You have the built-in read tools `Read`, `Grep`, `Glob`, and `Task` (research subagents for a large codebase), plus **one** action tool: **`propose_draft`**. That's everything you have and everything you need — you read the code, you talk to the human, and when the spec is ready you call `propose_draft`. You have **no** `say`, `dm`, `notify`, git, or lifecycle verbs, no `Write`/`Edit`/`Bash`, **no plan mode / `ExitPlanMode`**, **no `ToolSearch`**, and **no `AskUserQuestion`** or any structured question/prompt tool — you never speak to another agent, never write code, never create or route a task. **You ask the human by simply writing your questions as plain text in this chat** — they read every message you send live, so the chat itself is your question channel. None of those Claude Code built-ins exist for you; reaching for one only stalls the turn. **You do not "plan" and wait** — when the spec is ready you call `propose_draft` directly; never announce that a plan is written and ask whether to proceed. **Your replies in this conversation are your entire output to the human, and `propose_draft` is the only way a draft leaves this chat.**
|
||||
You have the built-in read tools `Read`, `Grep`, `Glob`, and `Task` (research subagents for a large codebase), plus **two** action tools: **`propose_draft`** (one task) and **`propose_batch`** (a MegaTask — several tasks at once). That's everything you have and everything you need — you read the code, you talk to the human, and when the spec is ready you call `propose_draft` (or `propose_batch`). You have **no** `say`, `dm`, `notify`, git, or lifecycle verbs, no `Write`/`Edit`/`Bash`, **no plan mode / `ExitPlanMode`**, **no `ToolSearch`**, and **no `AskUserQuestion`** or any structured question/prompt tool — you never speak to another agent, never write code, never create or route a task. **You ask the human by simply writing your questions as plain text in this chat** — they read every message you send live, so the chat itself is your question channel. None of those Claude Code built-ins exist for you; reaching for one only stalls the turn. **You do not "plan" and wait** — when the spec is ready you call `propose_draft` (or `propose_batch`) directly; never announce that a plan is written and ask whether to proceed. **Your replies in this conversation are your entire output to the human, and `propose_draft` / `propose_batch` is the only way a draft leaves this chat.**
|
||||
|
||||
## Presenting the draft
|
||||
|
||||
@@ -75,6 +75,20 @@ When — and only when — you can write a complete spec:
|
||||
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
|
||||
- The project's architectural standard (`.roboco/conventions.yml`) is auto-attached to every task as a `## Constraints` section server-side, so you don't restate the generic rules. Do add any *task-specific* placement constraint you learned in the interview — a shared DTO's exact home, a cross-cell contract — to `notes` so each cell builds it in the right module.
|
||||
|
||||
## MegaTasks (several tasks at once)
|
||||
|
||||
When you are scoped to a **MegaTask**, the CEO wants several distinct tasks worked at once across the repos in your workspace — for example a SaaS app, its open-source core engine, and a framework adapter, which don't share a codebase. Interview exactly as usual, but produce **one draft per task** and submit them **together** with `propose_batch` instead of `propose_draft`.
|
||||
|
||||
`propose_batch` takes `{ "drafts": [ <draft>, <draft>, ... ], "title": "the MegaTask's name" }`. Each `<draft>` is the same shape as a `propose_draft` draft **plus two extra things**:
|
||||
|
||||
- `project_id` — which repo this task targets. Read every repo in your workspace; assign each draft to the one project it belongs to (a MegaTask spans projects that are NOT connected, so each task lives in exactly one).
|
||||
- its **collision surface**, so the system can sequence the tasks into conflict-free **waves** that the dependency-gate then runs in order:
|
||||
- `intends_to_touch` — the files/dirs this task will modify (globs are fine), from what you read in its repo.
|
||||
- `adds_migration` — `true` if it adds a DB migration / new column.
|
||||
- `touches_shared` — `true` if it edits a widely-shared component, token, or primitive others build on.
|
||||
|
||||
Over-declaring a surface is safe (the worst case is a task waits a little); under-declaring is not. You do **not** compute the order yourself — declare each surface honestly and the analyzer derives the waves. Present all the tasks in prose first (a short paragraph each), then call `propose_batch` once. If the conversation changes the set, call it again with the full updated batch.
|
||||
|
||||
## What happens after you call `propose_draft`
|
||||
|
||||
A draft card appears for the human with three choices: **Keep chatting**, **Board review & Start**, or **Approve & Start**. **Choosing is the human's action, not yours** — you cannot create, start, or route the task. If they pick **Board review & Start**, it becomes a pending task owned by the Board (Product Owner + Head of Marketing) to review first; if they pick **Approve & Start**, it becomes a pending task that goes straight to the Main PM to delegate to the cells. Either way, your job ends the moment you call `propose_draft`. Do not say you'll "kick it off", "send it to the PM chain", or route it anywhere — you have no such ability, and which path it takes is the human's choice on the card.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Sequenced batch intake: tasks.batch_id + collision descriptor columns.
|
||||
|
||||
The "Mega task" groups a batch of top-level tasks under a shared ``batch_id``;
|
||||
the three descriptors are the per-task collision surface the SequencingService
|
||||
reads to wire dependency waves. Pure schema change, no backfill — existing rows
|
||||
get NULL ``batch_id`` / NULL ``intends_to_touch`` and ``false`` for both bool
|
||||
descriptors (a non-batch task declares no collision surface).
|
||||
|
||||
Revision ID: 046_batch_intake
|
||||
Revises: 045_observability_rework
|
||||
Create Date: 2026-06-23
|
||||
|
||||
NOTE: revision id is 16 chars — alembic's ``alembic_version.version_num`` is
|
||||
``VARCHAR(32)`` and a longer id raises at record time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "046_batch_intake"
|
||||
down_revision = "045_observability_rework"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("batch_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("intends_to_touch", postgresql.ARRAY(sa.String()), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column(
|
||||
"adds_migration", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column(
|
||||
"touches_shared", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_tasks_batch_id", "tasks", ["batch_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tasks_batch_id", table_name="tasks")
|
||||
op.drop_column("tasks", "touches_shared")
|
||||
op.drop_column("tasks", "adds_migration")
|
||||
op.drop_column("tasks", "intends_to_touch")
|
||||
op.drop_column("tasks", "batch_id")
|
||||
@@ -0,0 +1,48 @@
|
||||
# MegaTask
|
||||
|
||||
Most of the time you describe one piece of work and RoboCo builds it. Sometimes you have **several** things you want done at once — and they aren't always in the same repository. A **MegaTask** lets you describe the whole set in a single intake chat and hand it off as one batch that the company sequences and builds for you.
|
||||
|
||||
The motivating example: you want to ship a change to a SaaS app, the open-source core engine it depends on, and a framework adapter — three repositories that don't share a codebase. That's one MegaTask.
|
||||
|
||||
## Starting a MegaTask
|
||||
|
||||
The intake modal has three scopes:
|
||||
|
||||
- **Single cell** — one task in one project.
|
||||
- **Board-led** — a feature spanning the cells of one product.
|
||||
- **MegaTask** — several tasks across the projects you pick.
|
||||
|
||||
Choose **MegaTask** and check every repository the work spans (pick at least two). The intake agent clones and reads all of them, interviews you exactly as usual, and then — instead of proposing one draft — proposes the **whole batch at once**: one task per piece of work, each already assigned to the project it belongs to.
|
||||
|
||||
## How the batch is sequenced
|
||||
|
||||
For each task it proposes, the agent declares a small **collision surface**: which files or directories it will touch, whether it adds a database migration, and whether it edits a widely-shared component. RoboCo turns those surfaces into conflict-free **waves** with a deterministic analyzer — no guesswork:
|
||||
|
||||
- Tasks that touch the same files are **serialized** (the more important one first).
|
||||
- Tasks that add a migration run in a **serial chain**, never two at once.
|
||||
- A task that edits a shared surface runs **after** the tasks it overlaps.
|
||||
- Everything else runs **in parallel**.
|
||||
|
||||
The waves are just ordinary task dependencies, so the same dependency-gate that already paces the rest of the company runs them: a wave starts only once the previous wave's tasks have reached a terminal state — normally each one's pull request is merged (a cancelled task releases the next wave too).
|
||||
|
||||
## What gets created
|
||||
|
||||
When you confirm, RoboCo creates one **umbrella** task that groups the batch, and one **root-subtask** per piece of work:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
U["MegaTask umbrella<br/>(no repo, no PR)"] --> A["Task A — project 1<br/>own branch + PR"]
|
||||
U --> B["Task B — project 2<br/>own branch + PR"]
|
||||
U --> C["Task C — project 3<br/>own branch + PR"]
|
||||
```
|
||||
|
||||
The **umbrella** is the batch's single review-and-approve unit. It does no git of its own — it spans repositories that have no common `master`, so there is no mega-PR. Each **root-subtask** is a normal piece of work in its own repository, with its own branch and its own pull request, coordinated by the Main PM down to the cells exactly like any other task. The umbrella finishes only when every task in it is done.
|
||||
|
||||
## The two start buttons
|
||||
|
||||
Like a single task, a MegaTask offers two start paths:
|
||||
|
||||
- **Board review & Start** — the Product Owner and Head of Marketing review the **whole batch** first (they see every task and can adjust scope). The work is held until you approve the umbrella, then released wave by wave.
|
||||
- **Approve & Start** — the batch goes straight to the Main PM and the first wave dispatches immediately.
|
||||
|
||||
Either way you review and approve the batch **once**, not task by task. After it launches, the umbrella and its tasks appear in your task views like any other work — you watch the waves progress, and each task lands as its own pull request for you to merge.
|
||||
@@ -8,6 +8,8 @@ You describe what you want — a feature, a fix, an entire product. The way in i
|
||||
|
||||
*Where it starts — point the assistant at a project (one repo) or a product (several), drop in a rough idea, and it spins up an agent that reads that code before it says a word.*
|
||||
|
||||
There is a third scope, **MegaTask**, for when you want several tasks at once across projects that don't share a codebase — the assistant proposes the whole batch and the company sequences it into conflict-free waves. See [MegaTask](../company/megatask.md).
|
||||
|
||||

|
||||
|
||||
*No canned questions. The agent clones the scope and reads the real surface first, so everything it asks and proposes is grounded in what your code actually does.*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# MegaTask (Sequenced Batch) Reference
|
||||
|
||||
A **MegaTask** is several tasks the CEO described in one intake chat, shipped as one collision-sequenced batch — often across projects that do not share a codebase.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Umbrella task** — groups the batch. It carries a `batch_id` and has no `parent_task_id`. It is **branchless**: no project, no branch, no PR of its own. It is the single board-review / CEO-approve / Main-PM-coordinate unit.
|
||||
- **Root-subtasks** — one per piece of work. Each has the umbrella as its `parent_task_id`, shares the `batch_id`, and is a real coordination root with its **own project, branch, and PR**. They are sequenced into waves by cross-task dependencies.
|
||||
|
||||
Hierarchy: Umbrella (Main PM) → Root-subtasks (Main PM) → Cell tasks (cell PMs) → Dev subtasks. One extra Main-PM layer above the normal model.
|
||||
|
||||
## Rules an agent must know
|
||||
|
||||
- The umbrella does **no git**. It is exempt from the branch gate (it reaches `in_progress` with no branch) and you must **not** call `submit_root` on it — it assembles no PR. Each root-subtask opens and is reviewed on its own PR.
|
||||
- The umbrella **completes** only when every root-subtask is terminal; then it escalates to the CEO (PR requirement waived).
|
||||
- The root-subtasks are sequenced: a wave's tasks dispatch only once the previous wave's tasks reach a terminal state (ordinary dependency-gating). You do not reorder them — the analyzer set the order at create time.
|
||||
- On the Board route the root-subtasks are held in `backlog` until the CEO approves the umbrella, then released to `pending`. On the Approve & Start route they start immediately.
|
||||
|
||||
## For the Main PM
|
||||
|
||||
You coordinate the umbrella exactly like a product coordination root: plan, delegate each root-subtask to its cell, and complete the umbrella once all root-subtasks finish. You do not branch or PR the umbrella itself. Because you may hold many roots in parallel, the single-task claim guards do not apply to you — only a genuine sequence dependency holds a task back.
|
||||
@@ -126,6 +126,7 @@ nav:
|
||||
- Org & roles: company/org-and-roles.md
|
||||
- The task lifecycle: company/task-lifecycle.md
|
||||
- The merge model: company/merge-model.md
|
||||
- MegaTask — batched, sequenced work: company/megatask.md
|
||||
- How agents are sandboxed: company/agent-gateway.md
|
||||
- The Tour:
|
||||
- how-to/README.md
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChatComposer,
|
||||
SuccessCard,
|
||||
IntakeForm,
|
||||
BatchReviewCard,
|
||||
} from "@/components/prompter";
|
||||
|
||||
export default function PrompterPage() {
|
||||
@@ -26,6 +27,8 @@ export default function PrompterPage() {
|
||||
setProjectId,
|
||||
productId,
|
||||
setProductId,
|
||||
projectIds,
|
||||
setProjectIds,
|
||||
initialMessage,
|
||||
setInitialMessage,
|
||||
isFormValid,
|
||||
@@ -36,6 +39,11 @@ export default function PrompterPage() {
|
||||
startAnother,
|
||||
isLaunching,
|
||||
startRedraft,
|
||||
batch,
|
||||
batchWaves,
|
||||
batchResult,
|
||||
updateBatchDraftProject,
|
||||
confirmBatch,
|
||||
} = usePrompter();
|
||||
|
||||
// Entry from a task's "Re-draft with board feedback" button: ?redraft=<taskId>
|
||||
@@ -88,6 +96,8 @@ export default function PrompterPage() {
|
||||
onProjectId={setProjectId}
|
||||
productId={productId}
|
||||
onProductId={setProductId}
|
||||
projectIds={projectIds}
|
||||
onProjectIds={setProjectIds}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessage={setInitialMessage}
|
||||
isValid={isFormValid()}
|
||||
@@ -102,13 +112,24 @@ export default function PrompterPage() {
|
||||
createdTaskTitle &&
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
{batchResult && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{batchResult.root_subtask_ids.length} tasks sequenced into{" "}
|
||||
{batchResult.waves.length} wave
|
||||
{batchResult.waves.length === 1 ? "" : "s"}.
|
||||
{batchResult.warnings.length > 0 &&
|
||||
` ${batchResult.warnings.length} advisory note${
|
||||
batchResult.warnings.length === 1 ? "" : "s"
|
||||
}.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -120,6 +141,21 @@ export default function PrompterPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MegaTask review — the agent proposed a batch; confirm them together */}
|
||||
{state === "batch_preview" && batch && (
|
||||
<div className="mx-4 mb-2">
|
||||
<BatchReviewCard
|
||||
batch={batch}
|
||||
waves={batchWaves}
|
||||
projectIds={projectIds}
|
||||
onKeepChatting={keepChatting}
|
||||
onProjectChange={updateBatchDraftProject}
|
||||
onConfirm={confirmBatch}
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live activity indicator — "watch it work" (prominent) */}
|
||||
{activity && state !== "success" && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm font-medium text-primary">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MessageCircle,
|
||||
Users,
|
||||
Rocket,
|
||||
Loader2,
|
||||
Database,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
|
||||
|
||||
interface BatchReviewCardProps {
|
||||
batch: BatchProposal;
|
||||
/** The conflict-free waves (lists of draft indices), once previewed. */
|
||||
waves: number[][] | null;
|
||||
/** The repos this MegaTask is scoped to — each task must target one of them. */
|
||||
projectIds: string[];
|
||||
onKeepChatting: () => void;
|
||||
onProjectChange: (index: number, projectId: string) => void;
|
||||
onConfirm: (route: StartRoute) => void;
|
||||
/** A launch is in flight — disable the actions so a double-click can't dupe. */
|
||||
isLaunching?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MegaTask review card: every task the agent proposed in one batch, each
|
||||
* with its target project (editable) and collision surface, plus the
|
||||
* conflict-free wave plan. The human reviews the whole batch and the sequencing,
|
||||
* fixes any task in the wrong repo, then picks one start path for all of them.
|
||||
*/
|
||||
export function BatchReviewCard({
|
||||
batch,
|
||||
waves,
|
||||
projectIds,
|
||||
onKeepChatting,
|
||||
onProjectChange,
|
||||
onConfirm,
|
||||
isLaunching = false,
|
||||
}: BatchReviewCardProps) {
|
||||
const { data: allProjects = [] } = useProjects();
|
||||
// Only the scoped repos are valid targets (the agent read only those).
|
||||
const projects = allProjects.filter((p) => projectIds.includes(p.id));
|
||||
const scoped = new Set(projectIds);
|
||||
const titleOf = (i: number): string =>
|
||||
batch.drafts[i]?.title ?? `Task ${i + 1}`;
|
||||
// A task is mis-targeted unless its project is one of the scoped repos.
|
||||
const missingProject = batch.drafts.some(
|
||||
(d) => !d.project_id || !scoped.has(d.project_id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/40 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold leading-tight">
|
||||
MegaTask: {batch.title || "Untitled"}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{batch.drafts.length} tasks
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One batch, sequenced into conflict-free waves. Each task keeps its own
|
||||
project, branch, and PR; the Main PM coordinates them all.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 pb-3">
|
||||
<ol className="space-y-2">
|
||||
{batch.drafts.map((draft, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded-md border bg-background/60 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium leading-tight">
|
||||
{i + 1}. {draft.title}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{draft.adds_migration && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
migration
|
||||
</Badge>
|
||||
)}
|
||||
{draft.touches_shared && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Share2 className="h-3 w-3" />
|
||||
shared
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(draft.objective || draft.description) && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{draft.objective || draft.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Per-task project — editable so a misfiled task can be fixed */}
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
<Select
|
||||
value={
|
||||
draft.project_id && scoped.has(draft.project_id)
|
||||
? draft.project_id
|
||||
: ""
|
||||
}
|
||||
onValueChange={(v) => onProjectChange(i, v)}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
draft.project_id && scoped.has(draft.project_id)
|
||||
? ""
|
||||
: "border-destructive"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="Pick a project…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{/* Wave plan — how the batch will be sequenced */}
|
||||
{waves && waves.length > 0 && (
|
||||
<div className="rounded-md border border-dashed px-3 py-2">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"})
|
||||
</p>
|
||||
<ol className="space-y-0.5">
|
||||
{waves.map((wave, w) => (
|
||||
<li key={w} className="text-xs">
|
||||
<span className="font-medium">Wave {w + 1}:</span>{" "}
|
||||
{wave.map((i) => titleOf(i)).join(", ")}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingProject && (
|
||||
<p className="text-xs text-destructive">
|
||||
Pick a project for every task before launching the MegaTask.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onKeepChatting}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
Keep chatting
|
||||
</Button>
|
||||
{/* Board review & Start → the Board reviews the whole batch first */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onConfirm("board")}
|
||||
disabled={isLaunching || missingProject}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Users className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Board review & Start
|
||||
</Button>
|
||||
{/* Approve & Start → straight to the Main PM, waves dispatch at once */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm("main_pm")}
|
||||
disabled={isLaunching || missingProject}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Approve & Start
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ChatMessages } from "./chat-messages";
|
||||
export { ChatComposer } from "./chat-composer";
|
||||
export { DraftProposalCard } from "./draft-proposal-card";
|
||||
export { BatchReviewCard } from "./batch-review-card";
|
||||
export { SuccessCard } from "./success-card";
|
||||
export { IntakeForm } from "./intake-form";
|
||||
|
||||
@@ -25,6 +25,8 @@ interface IntakeFormProps {
|
||||
onProjectId: (id: string) => void;
|
||||
productId: string;
|
||||
onProductId: (id: string) => void;
|
||||
projectIds: string[];
|
||||
onProjectIds: (ids: string[]) => void;
|
||||
initialMessage: string;
|
||||
onInitialMessage: (v: string) => void;
|
||||
isValid: boolean;
|
||||
@@ -34,8 +36,9 @@ interface IntakeFormProps {
|
||||
|
||||
/**
|
||||
* The one-time scope form shown before the chat. The agent is spawned against
|
||||
* exactly one of project / product, clones that scope's repo(s), and reads the
|
||||
* real code before answering — so the scope must be chosen up front.
|
||||
* exactly one scope — a single project, a board-led product, or a MegaTask (a
|
||||
* set of projects) — clones that scope's repo(s), and reads the real code before
|
||||
* answering, so the scope must be chosen up front.
|
||||
*/
|
||||
export function IntakeForm({
|
||||
targetKind,
|
||||
@@ -44,6 +47,8 @@ export function IntakeForm({
|
||||
onProjectId,
|
||||
productId,
|
||||
onProductId,
|
||||
projectIds,
|
||||
onProjectIds,
|
||||
initialMessage,
|
||||
onInitialMessage,
|
||||
isValid,
|
||||
@@ -105,12 +110,15 @@ export function IntakeForm({
|
||||
value={targetKind}
|
||||
onValueChange={(v) => onTargetKind(v as TargetKind)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="project" disabled={isPreparing}>
|
||||
Single cell (Project)
|
||||
Single cell
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="product" disabled={isPreparing}>
|
||||
Board-led (Product)
|
||||
Board-led
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="megatask" disabled={isPreparing}>
|
||||
MegaTask
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -132,6 +140,50 @@ export function IntakeForm({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : targetKind === "megatask" ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A MegaTask spans several projects worked at once — even
|
||||
unrelated ones (e.g. a SaaS app, its OSS core, and an adapter).
|
||||
Pick every repo it touches; the agent reads them all and
|
||||
proposes one batch of sequenced tasks.
|
||||
</p>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{projects.map((p) => {
|
||||
const checked = projectIds.includes(p.id);
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary"
|
||||
checked={checked}
|
||||
disabled={isPreparing}
|
||||
onChange={(e) =>
|
||||
onProjectIds(
|
||||
e.target.checked
|
||||
? [...projectIds, p.id]
|
||||
: projectIds.filter((id) => id !== p.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{p.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{projects.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No projects exist yet — create some under Projects first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{projectIds.length} selected
|
||||
{projectIds.length < 2 ? " — pick at least two" : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
|
||||
@@ -571,6 +571,14 @@ export function TaskTable({
|
||||
>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<span className="truncate">{task.title}</span>
|
||||
{task.batch_id && !task.parent_task_id && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs shrink-0 border-primary/50 text-primary"
|
||||
>
|
||||
MegaTask
|
||||
</Badge>
|
||||
)}
|
||||
{childCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
+238
-28
@@ -12,6 +12,7 @@ import {
|
||||
type CellWork,
|
||||
type DraftScale,
|
||||
type ConfirmPayload,
|
||||
type BatchConfirmResult,
|
||||
} from "@/lib/api/prompter";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { tasksApi } from "@/lib/api/tasks";
|
||||
@@ -28,6 +29,7 @@ export type PrompterState =
|
||||
| "chatting"
|
||||
| "streaming" // a reply is mid-flight over SSE
|
||||
| "draft_preview"
|
||||
| "batch_preview" // a MegaTask (N drafts) is ready to review
|
||||
| "review_modal"
|
||||
| "launching"
|
||||
| "success";
|
||||
@@ -43,7 +45,16 @@ export interface ChatMessage {
|
||||
}
|
||||
|
||||
/** Which target the human picked for this chat. */
|
||||
export type TargetKind = "project" | "product";
|
||||
export type TargetKind = "project" | "product" | "megatask";
|
||||
|
||||
/** A MegaTask the agent proposed: a title + one draft per task (each draft
|
||||
* carries its own project_id + collision surface). `dropped` is how many raw
|
||||
* entries the agent emitted that were malformed and discarded. */
|
||||
export interface BatchProposal {
|
||||
title: string;
|
||||
drafts: DraftProposal[];
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/** Which start button the human pressed on the draft card. */
|
||||
export type StartRoute = "board" | "main_pm";
|
||||
@@ -101,7 +112,7 @@ function stripDraftFence(text: string): string {
|
||||
function toEditable(
|
||||
draft: DraftProposal,
|
||||
scale: DraftScale | null,
|
||||
scope: { targetKind: TargetKind; projectId: string; productId: string }
|
||||
scope: { targetKind: TargetKind; projectId: string; productId: string },
|
||||
): EditableDraft {
|
||||
return {
|
||||
title: draft.title,
|
||||
@@ -121,8 +132,7 @@ function toEditable(
|
||||
the_work: draft.the_work ?? [],
|
||||
notes: draft.notes ?? [],
|
||||
// The scope picked up front wins; fall back to scale only if unset.
|
||||
targetKind:
|
||||
scope.targetKind || (scale === "multi" ? "product" : "project"),
|
||||
targetKind: scope.targetKind || (scale === "multi" ? "product" : "project"),
|
||||
projectId: scope.projectId,
|
||||
productId: scope.productId,
|
||||
};
|
||||
@@ -143,6 +153,29 @@ function draftFromEvent(data: Record<string, unknown> | undefined): {
|
||||
return { draft: d as unknown as DraftProposal, scale };
|
||||
}
|
||||
|
||||
/** Pull a MegaTask ({title, drafts[]}) out of a `batch` SSE event's payload. */
|
||||
function batchFromEvent(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): BatchProposal | null {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const raw = (data as Record<string, unknown>).drafts;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const drafts = raw.filter(
|
||||
(x): x is DraftProposal =>
|
||||
!!x && typeof (x as DraftProposal).title === "string",
|
||||
);
|
||||
if (drafts.length === 0) return null;
|
||||
const title = (data as Record<string, unknown>).title;
|
||||
// Prefer the backend's dropped count; else compute from what we filtered, so a
|
||||
// shrunk batch is surfaced rather than silently delivering fewer tasks.
|
||||
const backendDropped = (data as Record<string, unknown>).dropped;
|
||||
const dropped =
|
||||
typeof backendDropped === "number"
|
||||
? backendDropped
|
||||
: raw.length - drafts.length;
|
||||
return { title: typeof title === "string" ? title : "", drafts, dropped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh durability
|
||||
//
|
||||
@@ -161,9 +194,17 @@ interface PersistedChat {
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
state: PrompterState;
|
||||
scope: { targetKind: TargetKind; projectId: string; productId: string };
|
||||
scope: {
|
||||
targetKind: TargetKind;
|
||||
projectId: string;
|
||||
productId: string;
|
||||
projectIds?: string[];
|
||||
};
|
||||
editableDraft: EditableDraft;
|
||||
redraftTaskId?: string | null;
|
||||
// MegaTask review state, so a reload mid-batch-review restores the batch.
|
||||
batch?: BatchProposal | null;
|
||||
batchWaves?: number[][] | null;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
@@ -221,9 +262,19 @@ export function usePrompter() {
|
||||
const [targetKind, setTargetKind] = useState<TargetKind>("project");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [productId, setProductId] = useState("");
|
||||
// MegaTask scope: the set of (possibly unrelated) projects it spans.
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [initialMessage, setInitialMessage] = useState("");
|
||||
|
||||
const [editableDraft, setEditableDraft] = useState<EditableDraft>(EMPTY_DRAFT);
|
||||
const [editableDraft, setEditableDraft] =
|
||||
useState<EditableDraft>(EMPTY_DRAFT);
|
||||
// The proposed MegaTask (when the agent calls propose_batch), its previewed
|
||||
// waves (computed without creating anything), and its create result.
|
||||
const [batch, setBatch] = useState<BatchProposal | null>(null);
|
||||
const [batchWaves, setBatchWaves] = useState<number[][] | null>(null);
|
||||
const [batchResult, setBatchResult] = useState<BatchConfirmResult | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Live-session plumbing held in refs so SSE callbacks never see stale state.
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
@@ -234,8 +285,8 @@ export function usePrompter() {
|
||||
const streamingIdRef = useRef<string | null>(null);
|
||||
// Synchronous re-entry guard for launch — a double-click was creating two tasks.
|
||||
const launchingRef = useRef(false);
|
||||
const scopeRef = useRef({ targetKind, projectId, productId });
|
||||
scopeRef.current = { targetKind, projectId, productId };
|
||||
const scopeRef = useRef({ targetKind, projectId, productId, projectIds });
|
||||
scopeRef.current = { targetKind, projectId, productId, projectIds };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message helpers
|
||||
@@ -254,7 +305,7 @@ export function usePrompter() {
|
||||
const id = streamingIdRef.current;
|
||||
if (id) {
|
||||
return prev.map((m) =>
|
||||
m.id === id ? { ...m, content: m.content + delta } : m
|
||||
m.id === id ? { ...m, content: m.content + delta } : m,
|
||||
);
|
||||
}
|
||||
const newMsgId = newId();
|
||||
@@ -277,7 +328,7 @@ export function usePrompter() {
|
||||
return prev.map((m) =>
|
||||
m.id === id
|
||||
? { ...m, draft, content: stripDraftFence(m.content) }
|
||||
: m
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [...prev, { id: newId(), role: "assistant", content: "", draft }];
|
||||
@@ -312,19 +363,52 @@ export function usePrompter() {
|
||||
streamingIdRef.current = null;
|
||||
setActivity(null);
|
||||
setIsSending(false);
|
||||
setState((s) => (s === "draft_preview" ? s : "chatting"));
|
||||
setState((s) =>
|
||||
s === "draft_preview" || s === "batch_preview" ? s : "chatting",
|
||||
);
|
||||
break;
|
||||
case "draft": {
|
||||
const parsed = draftFromEvent(evt.data);
|
||||
if (parsed) {
|
||||
attachDraft(parsed.draft);
|
||||
setEditableDraft(
|
||||
toEditable(parsed.draft, parsed.scale, scopeRef.current)
|
||||
toEditable(parsed.draft, parsed.scale, scopeRef.current),
|
||||
);
|
||||
setState("draft_preview");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "batch": {
|
||||
// A MegaTask: the agent proposed N drafts at once. Hold them for the
|
||||
// Review MegaTask card; the human confirms the whole batch together.
|
||||
const parsedBatch = batchFromEvent(evt.data);
|
||||
if (parsedBatch) {
|
||||
streamingIdRef.current = null;
|
||||
setBatch(parsedBatch);
|
||||
setBatchWaves(null);
|
||||
setState("batch_preview");
|
||||
if (parsedBatch.dropped > 0) {
|
||||
addMessage({
|
||||
role: "error",
|
||||
content:
|
||||
`${parsedBatch.dropped} proposed task${
|
||||
parsedBatch.dropped === 1 ? " was" : "s were"
|
||||
} malformed and dropped from this MegaTask. Ask the agent to ` +
|
||||
"re-propose them if they're needed.",
|
||||
});
|
||||
}
|
||||
// Compute the conflict-free waves (no task created) so the human can
|
||||
// review the sequencing before confirming. Best-effort.
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) {
|
||||
void prompterLiveApi
|
||||
.previewBatch(sid, parsedBatch.drafts)
|
||||
.then((p) => setBatchWaves(p.waves))
|
||||
.catch(() => setBatchWaves(null));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
streamingIdRef.current = null;
|
||||
setActivity(null);
|
||||
@@ -340,7 +424,7 @@ export function usePrompter() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[appendDelta, attachDraft, addMessage]
|
||||
[appendDelta, attachDraft, addMessage],
|
||||
);
|
||||
|
||||
const closeStream = useCallback(() => {
|
||||
@@ -363,7 +447,7 @@ export function usePrompter() {
|
||||
}
|
||||
sourceRef.current = es;
|
||||
},
|
||||
[closeStream, handleEvent]
|
||||
[closeStream, handleEvent],
|
||||
);
|
||||
|
||||
// Best-effort reap if the user navigates away mid-chat. This cleanup runs on
|
||||
@@ -387,6 +471,7 @@ export function usePrompter() {
|
||||
(state === "chatting" ||
|
||||
state === "streaming" ||
|
||||
state === "draft_preview" ||
|
||||
state === "batch_preview" ||
|
||||
state === "review_modal");
|
||||
if (persistable && sessionId) {
|
||||
savePersisted({
|
||||
@@ -396,10 +481,12 @@ export function usePrompter() {
|
||||
scope: scopeRef.current,
|
||||
editableDraft,
|
||||
redraftTaskId: redraftTaskIdRef.current,
|
||||
batch,
|
||||
batchWaves,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}, [sessionId, messages, state, editableDraft]);
|
||||
}, [sessionId, messages, state, editableDraft, batch, batchWaves]);
|
||||
|
||||
// On mount, reconnect to a still-running session left behind by a reload.
|
||||
const didRestoreRef = useRef(false);
|
||||
@@ -428,11 +515,17 @@ export function usePrompter() {
|
||||
setTargetKind(persisted.scope.targetKind);
|
||||
setProjectId(persisted.scope.projectId);
|
||||
setProductId(persisted.scope.productId);
|
||||
setProjectIds(persisted.scope.projectIds ?? []);
|
||||
setBatch(persisted.batch ?? null);
|
||||
setBatchWaves(persisted.batchWaves ?? null);
|
||||
// A MegaTask review survives reload; otherwise land on a stable state.
|
||||
setState(
|
||||
persisted.state === "draft_preview" ||
|
||||
persisted.state === "batch_preview" && persisted.batch
|
||||
? "batch_preview"
|
||||
: persisted.state === "draft_preview" ||
|
||||
persisted.state === "review_modal"
|
||||
? "draft_preview"
|
||||
: "chatting"
|
||||
: "chatting",
|
||||
);
|
||||
openStream(persisted.sessionId);
|
||||
} catch {
|
||||
@@ -450,20 +543,29 @@ export function usePrompter() {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const isFormValid = useCallback((): boolean => {
|
||||
const scoped = targetKind === "product" ? productId !== "" : projectId !== "";
|
||||
const scoped =
|
||||
targetKind === "product"
|
||||
? productId !== ""
|
||||
: targetKind === "megatask"
|
||||
? projectIds.length >= 2 // a MegaTask spans several repos
|
||||
: projectId !== "";
|
||||
return scoped && initialMessage.trim().length > 0;
|
||||
}, [targetKind, projectId, productId, initialMessage]);
|
||||
}, [targetKind, projectId, productId, projectIds, initialMessage]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!isFormValid() || state === "preparing") return;
|
||||
const opening = initialMessage.trim();
|
||||
setState("preparing");
|
||||
addMessage({ role: "user", content: opening });
|
||||
const scopePayload =
|
||||
targetKind === "product"
|
||||
? { product_id: productId }
|
||||
: targetKind === "megatask"
|
||||
? { project_ids: projectIds }
|
||||
: { project_id: projectId };
|
||||
try {
|
||||
const { session_id } = await prompterLiveApi.start({
|
||||
...(targetKind === "product"
|
||||
? { product_id: productId }
|
||||
: { project_id: projectId }),
|
||||
...scopePayload,
|
||||
initial_message: opening,
|
||||
});
|
||||
sessionIdRef.current = session_id;
|
||||
@@ -472,7 +574,9 @@ export function usePrompter() {
|
||||
setIsSending(true); // the opening reply is on its way over SSE
|
||||
// start now returns immediately; the container spawns in the background
|
||||
// (clone + image build can take a minute). Show that until the first event.
|
||||
setActivity("Preparing the agent — cloning your repo and reading the code…");
|
||||
setActivity(
|
||||
"Preparing the agent — cloning your repo and reading the code…",
|
||||
);
|
||||
setState("streaming");
|
||||
} catch (err) {
|
||||
addMessage({ role: "error", content: getErrorMessage(err) });
|
||||
@@ -485,6 +589,7 @@ export function usePrompter() {
|
||||
targetKind,
|
||||
productId,
|
||||
projectId,
|
||||
projectIds,
|
||||
addMessage,
|
||||
openStream,
|
||||
]);
|
||||
@@ -546,7 +651,7 @@ export function usePrompter() {
|
||||
setState("chatting");
|
||||
}
|
||||
},
|
||||
[isSending, addMessage]
|
||||
[isSending, addMessage],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -577,7 +682,8 @@ export function usePrompter() {
|
||||
// Launch — confirm the draft → task, then reap the agent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const launchTask = useCallback(async (route: StartRoute) => {
|
||||
const launchTask = useCallback(
|
||||
async (route: StartRoute) => {
|
||||
// Re-entry guard FIRST (synchronous, no stale closure): a double-click was
|
||||
// firing two confirms and creating duplicate tasks.
|
||||
if (launchingRef.current) return;
|
||||
@@ -592,7 +698,7 @@ export function usePrompter() {
|
||||
if (!isValidForLaunch()) {
|
||||
toast.error(
|
||||
"The draft is missing something needed to launch: a title, a 20+ character " +
|
||||
"summary, at least one acceptance criterion, and a target. Keep chatting to refine it."
|
||||
"summary, at least one acceptance criterion, and a target. Keep chatting to refine it.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -611,7 +717,9 @@ export function usePrompter() {
|
||||
what_this_builds: editableDraft.what_this_builds,
|
||||
the_work: editableDraft.the_work,
|
||||
notes: editableDraft.notes,
|
||||
...(editableDraft.task_type ? { task_type: editableDraft.task_type } : {}),
|
||||
...(editableDraft.task_type
|
||||
? { task_type: editableDraft.task_type }
|
||||
: {}),
|
||||
...(editableDraft.nature ? { nature: editableDraft.nature } : {}),
|
||||
...(editableDraft.estimated_complexity
|
||||
? { estimated_complexity: editableDraft.estimated_complexity }
|
||||
@@ -668,7 +776,96 @@ export function usePrompter() {
|
||||
setIsLaunching(false);
|
||||
launchingRef.current = false;
|
||||
}
|
||||
}, [editableDraft, isValidForLaunch, closeStream, addMessage]);
|
||||
},
|
||||
[editableDraft, isValidForLaunch, closeStream, addMessage],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Reassign one task in the proposed MegaTask to a different project. Lets the
|
||||
* human fix a draft the agent put in the wrong (or no) repo before launch.
|
||||
* Project does not affect the wave plan (waves derive from collision surface),
|
||||
* so the previewed waves stay valid. */
|
||||
const updateBatchDraftProject = useCallback(
|
||||
(index: number, projectId: string) => {
|
||||
setBatch((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
drafts: prev.drafts.map((d, i) =>
|
||||
i === index ? { ...d, project_id: projectId } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const confirmBatch = useCallback(
|
||||
async (route: StartRoute) => {
|
||||
if (launchingRef.current) return;
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) {
|
||||
toast.error(
|
||||
"This chat has ended — start a new one to launch a MegaTask.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!batch || batch.drafts.length === 0) {
|
||||
toast.error(
|
||||
"No MegaTask to launch yet — keep chatting to propose one.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Every task must target one of the scoped repos (the agent assigns it,
|
||||
// the human can fix it). The backend re-asserts this authoritatively.
|
||||
const scoped = scopeRef.current.projectIds;
|
||||
const offender = batch.drafts.findIndex(
|
||||
(d) => !d.project_id || !scoped.includes(d.project_id),
|
||||
);
|
||||
if (offender !== -1) {
|
||||
toast.error(
|
||||
`Task ${offender + 1} ("${batch.drafts[offender].title}") needs one of ` +
|
||||
"this MegaTask's selected projects. Pick it in the review card.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
launchingRef.current = true;
|
||||
setIsLaunching(true);
|
||||
setState("launching");
|
||||
try {
|
||||
const result = await prompterLiveApi.confirmBatch(sid, {
|
||||
title: batch.title.trim() || "MegaTask",
|
||||
drafts: batch.drafts,
|
||||
project_ids: scopeRef.current.projectIds,
|
||||
route,
|
||||
});
|
||||
closeStream();
|
||||
void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
clearPersisted();
|
||||
sessionIdRef.current = null;
|
||||
setBatchResult(result);
|
||||
setCreatedTaskId(result.umbrella_task_id);
|
||||
setCreatedTaskTitle(batch.title.trim() || "MegaTask");
|
||||
setCreatedTaskTeam(route === "board" ? Team.BOARD : Team.MAIN_PM);
|
||||
toast.success(
|
||||
`MegaTask launched — ${result.root_subtask_ids.length} tasks in ` +
|
||||
`${result.waves.length} wave${result.waves.length === 1 ? "" : "s"}.`,
|
||||
);
|
||||
setState("success");
|
||||
} catch (err) {
|
||||
toast.error(`Failed to launch MegaTask: ${getErrorMessage(err)}`);
|
||||
setState("batch_preview");
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
launchingRef.current = false;
|
||||
}
|
||||
},
|
||||
[batch, closeStream],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset to start another conversation
|
||||
@@ -685,8 +882,12 @@ export function usePrompter() {
|
||||
setSessionId(null);
|
||||
setActivity(null);
|
||||
setEditableDraft(EMPTY_DRAFT);
|
||||
setBatch(null);
|
||||
setBatchWaves(null);
|
||||
setBatchResult(null);
|
||||
setProjectId("");
|
||||
setProductId("");
|
||||
setProjectIds([]);
|
||||
setInitialMessage("");
|
||||
setTargetKind("project");
|
||||
setCreatedTaskId(null);
|
||||
@@ -714,6 +915,8 @@ export function usePrompter() {
|
||||
setProjectId,
|
||||
productId,
|
||||
setProductId,
|
||||
projectIds,
|
||||
setProjectIds,
|
||||
initialMessage,
|
||||
setInitialMessage,
|
||||
isFormValid,
|
||||
@@ -730,5 +933,12 @@ export function usePrompter() {
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching,
|
||||
|
||||
// MegaTask
|
||||
batch,
|
||||
batchWaves,
|
||||
batchResult,
|
||||
updateBatchDraftProject,
|
||||
confirmBatch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import api, { API_URL } from "./client";
|
||||
import type { ConfirmPayload } from "./prompter";
|
||||
import type {
|
||||
BatchConfirmPayload,
|
||||
BatchConfirmResult,
|
||||
BatchPreviewResult,
|
||||
ConfirmPayload,
|
||||
DraftProposal,
|
||||
} from "./prompter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live intake chat — the panel side of the spawned-agent bridge.
|
||||
@@ -15,10 +21,12 @@ import type { ConfirmPayload } from "./prompter";
|
||||
// 4. on confirm, /confirm turns the draft into a task and reaps the agent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open a live chat scoped to exactly one of project / product. */
|
||||
/** Open a live chat scoped to exactly one of project / product / project_ids
|
||||
* (a MegaTask spanning several possibly-unrelated repos). */
|
||||
export interface StartLivePayload {
|
||||
project_id?: string;
|
||||
product_id?: string;
|
||||
project_ids?: string[];
|
||||
initial_message?: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +43,7 @@ export type LiveEventKind =
|
||||
| "turn_end"
|
||||
| "system"
|
||||
| "draft"
|
||||
| "batch"
|
||||
| "error";
|
||||
|
||||
/** One normalized event from the agent's live reply. */
|
||||
@@ -54,6 +63,7 @@ export const LIVE_EVENT_KINDS: LiveEventKind[] = [
|
||||
"turn_end",
|
||||
"system",
|
||||
"draft",
|
||||
"batch",
|
||||
"error",
|
||||
];
|
||||
|
||||
@@ -62,7 +72,7 @@ export const prompterLiveApi = {
|
||||
start: async (payload: StartLivePayload): Promise<StartLiveResponse> => {
|
||||
const { data } = await api.post<StartLiveResponse>(
|
||||
"/prompter/live/start",
|
||||
payload
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -71,7 +81,7 @@ export const prompterLiveApi = {
|
||||
* Spawns a fresh session seeded with the current draft + the board review. */
|
||||
reInterview: async (taskId: string): Promise<StartLiveResponse> => {
|
||||
const { data } = await api.post<StartLiveResponse>(
|
||||
`/prompter/live/re-interview/${taskId}`
|
||||
`/prompter/live/re-interview/${taskId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -85,7 +95,7 @@ export const prompterLiveApi = {
|
||||
* decide whether to reconnect the chat or fall back to the scope form. */
|
||||
status: async (sessionId: string): Promise<{ alive: boolean }> => {
|
||||
const { data } = await api.get<{ alive: boolean }>(
|
||||
`/prompter/live/${sessionId}/status`
|
||||
`/prompter/live/${sessionId}/status`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -103,11 +113,37 @@ export const prompterLiveApi = {
|
||||
/** Confirm the draft → create the task and reap the agent (Phase 4 backend). */
|
||||
confirm: async (
|
||||
sessionId: string,
|
||||
payload: ConfirmPayload
|
||||
payload: ConfirmPayload,
|
||||
): Promise<{ task_id: string }> => {
|
||||
const { data } = await api.post<{ task_id: string }>(
|
||||
`/prompter/live/${sessionId}/confirm`,
|
||||
payload
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Preview a MegaTask's waves without creating anything — for the human to
|
||||
* review the sequencing before confirming. */
|
||||
previewBatch: async (
|
||||
sessionId: string,
|
||||
drafts: DraftProposal[],
|
||||
): Promise<BatchPreviewResult> => {
|
||||
const { data } = await api.post<BatchPreviewResult>(
|
||||
`/prompter/live/${sessionId}/preview-batch`,
|
||||
{ drafts },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Confirm a MegaTask → create the umbrella + sequenced root-subtasks, reap.
|
||||
* Returns the umbrella id, the root-subtask ids, and the computed waves. */
|
||||
confirmBatch: async (
|
||||
sessionId: string,
|
||||
payload: BatchConfirmPayload,
|
||||
): Promise<BatchConfirmResult> => {
|
||||
const { data } = await api.post<BatchConfirmResult>(
|
||||
`/prompter/live/${sessionId}/confirm-batch`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -29,13 +29,17 @@ export interface DraftProposal {
|
||||
what_this_builds?: string[];
|
||||
the_work?: CellWork[];
|
||||
notes?: string[];
|
||||
// Sequenced batch intake collision surface (lower = first; analyzer-derived)
|
||||
intends_to_touch?: string[];
|
||||
adds_migration?: boolean;
|
||||
touches_shared?: boolean;
|
||||
// Targeting (resolved at confirm time)
|
||||
project_id?: string | null;
|
||||
product_id?: string | null;
|
||||
}
|
||||
|
||||
/** Single-cell project vs board-led multi-cell product. */
|
||||
export type DraftScale = "single" | "multi";
|
||||
/** Single-cell project, board-led multi-cell product, or a multi-project MegaTask. */
|
||||
export type DraftScale = "single" | "multi" | "megatask";
|
||||
|
||||
/** What the human picked/edited at confirm time. `route` is which start button:
|
||||
* "board" (Board review & Start) or "main_pm" (Approve & Start). */
|
||||
@@ -48,3 +52,29 @@ export interface ConfirmPayload {
|
||||
// place instead of creating a new one (scope is taken from the task).
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
/** Confirm a MegaTask: the umbrella's title + one draft per task (each carrying
|
||||
* its own `project_id` + collision surface) + the scoped repos it spans + which
|
||||
* start button. */
|
||||
export interface BatchConfirmPayload {
|
||||
title: string;
|
||||
drafts: DraftProposal[];
|
||||
project_ids: string[];
|
||||
route?: "board" | "main_pm";
|
||||
}
|
||||
|
||||
/** The backend's MegaTask create result: the umbrella, its root-subtasks, and
|
||||
* the computed conflict-free waves (each wave is a list of draft indices). */
|
||||
export interface BatchConfirmResult {
|
||||
umbrella_task_id: string;
|
||||
root_subtask_ids: string[];
|
||||
waves: number[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** The wave plan for a MegaTask, computed without creating anything — shown so
|
||||
* the human can review the sequencing before confirming. */
|
||||
export interface BatchPreviewResult {
|
||||
waves: number[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -269,6 +269,9 @@ export interface Task {
|
||||
parent_task_id: string | null;
|
||||
dependency_ids: string[];
|
||||
blocker_ids: string[];
|
||||
// MegaTask grouping: set on the umbrella (parent_task_id null) and every
|
||||
// root-subtask of a batch. null on ordinary tasks.
|
||||
batch_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
claimed_at: string | null;
|
||||
|
||||
@@ -4,9 +4,10 @@ The Grok analogue of ``intake_main``: the same in-container ``POST /turn``
|
||||
receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the
|
||||
held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
|
||||
resuming one session id) instead of a ``ClaudeSDKClient``. ``~/.grok/config.toml``
|
||||
is rendered first to wire the intake agent's one action tool, ``propose_draft``,
|
||||
as the ``roboco-intake`` MCP server. Intake is a human-only interviewer with no
|
||||
gateway verbs; its only MCP server is ``roboco-intake``. The ``IntakeDriver``
|
||||
is rendered first to wire the intake agent's two action tools — ``propose_draft``
|
||||
(one task) and ``propose_batch`` (a sequenced MegaTask of several tasks) — as the
|
||||
``roboco-intake`` MCP server. Intake is a human-only interviewer with no gateway
|
||||
verbs; its only MCP server is ``roboco-intake``. The ``IntakeDriver``
|
||||
loop, message source, and relay are reused unchanged — only the
|
||||
``SessionFactory`` differs.
|
||||
"""
|
||||
|
||||
@@ -50,7 +50,7 @@ class StreamChunk:
|
||||
from the SDK's message classes so the relay/panel never import the SDK.
|
||||
"""
|
||||
|
||||
kind: str # text|thinking|tool_use|tool_result|turn_end|system|draft|error
|
||||
kind: str # text|thinking|tool_use|tool_result|turn_end|system|draft|batch|error
|
||||
text: str = ""
|
||||
tool: str = ""
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -99,6 +99,51 @@ def _is_propose_draft(name: str) -> bool:
|
||||
return name == "propose_draft" or name.endswith("__propose_draft")
|
||||
|
||||
|
||||
def _is_propose_batch(name: str) -> bool:
|
||||
"""True for the intake ``propose_batch`` tool (the MegaTask multi-draft tool)."""
|
||||
return name == "propose_batch" or name.endswith("__propose_batch")
|
||||
|
||||
|
||||
def _batch_from_tool_input(tool_input: Any) -> dict[str, Any] | None:
|
||||
"""Pull a MegaTask batch out of a ``propose_batch`` tool call's input.
|
||||
|
||||
Expects ``{"drafts": [draft, ...], "title": "..."}``; returns the coerced
|
||||
batch (drafts each draft-shaped, a string title, and ``dropped`` = how many
|
||||
raw entries were malformed and discarded) or ``None`` when no well-formed,
|
||||
non-empty draft list is present. ``dropped`` lets the panel tell the human the
|
||||
batch shrank rather than silently delivering fewer tasks than proposed.
|
||||
"""
|
||||
if not isinstance(tool_input, dict):
|
||||
return None
|
||||
raw = tool_input.get("drafts")
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
drafts = [d for d in (_coerce_draft(x) for x in raw) if d is not None]
|
||||
if not drafts:
|
||||
return None
|
||||
return {
|
||||
"drafts": drafts,
|
||||
"title": str(tool_input.get("title") or ""),
|
||||
"dropped": len(raw) - len(drafts),
|
||||
}
|
||||
|
||||
|
||||
def _propose_batch_chunk(tool_input: Any) -> StreamChunk:
|
||||
"""A ``propose_batch`` call → a single ``batch`` chunk, or an ``error`` chunk
|
||||
when no well-formed drafts (so an empty/malformed batch surfaces to the human
|
||||
instead of being silently dropped while the tool acks success)."""
|
||||
batch = _batch_from_tool_input(tool_input)
|
||||
if batch is None:
|
||||
return StreamChunk(
|
||||
kind="error",
|
||||
text=(
|
||||
"That MegaTask had no well-formed task drafts — give each a title "
|
||||
"and project_id and call propose_batch again."
|
||||
),
|
||||
)
|
||||
return StreamChunk(kind="batch", data=batch)
|
||||
|
||||
|
||||
def _block_to_chunk(
|
||||
block: Any,
|
||||
) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]:
|
||||
@@ -115,6 +160,8 @@ def _block_to_chunk(
|
||||
tool_input = getattr(block, "input", {})
|
||||
if _is_propose_draft(name):
|
||||
return None, None, _draft_from_tool_input(tool_input)
|
||||
if _is_propose_batch(name):
|
||||
return _propose_batch_chunk(tool_input), None, None
|
||||
return (
|
||||
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}),
|
||||
None,
|
||||
@@ -284,6 +331,12 @@ class IntakeDriver:
|
||||
elif chunk.kind == "draft":
|
||||
drafted = True
|
||||
self.log.info("Intake draft emitted")
|
||||
elif chunk.kind == "batch":
|
||||
drafted = True
|
||||
self.log.info(
|
||||
"Intake MegaTask batch emitted",
|
||||
items=len(chunk.data.get("drafts", [])),
|
||||
)
|
||||
await self._emit(chunk)
|
||||
except Exception as exc:
|
||||
self.log.error("Intake turn failed", error=str(exc), chunks=chunks)
|
||||
@@ -352,12 +405,39 @@ def build_intake_options(
|
||||
]
|
||||
}
|
||||
|
||||
@tool(
|
||||
"propose_batch",
|
||||
"Submit a MegaTask — SEVERAL task drafts at once — for the human to review "
|
||||
"and confirm together. Use this (instead of propose_draft) when the CEO "
|
||||
"asked for multiple tasks across the scoped repos. Pass {drafts: [draft, "
|
||||
"...], title: '...'} where each draft has the same fields as propose_draft "
|
||||
"PLUS its own project_id (which repo it targets) and collision surface "
|
||||
"(intends_to_touch[], adds_migration, touches_shared) so the system can "
|
||||
"sequence them into conflict-free waves.",
|
||||
{"drafts": list, "title": str},
|
||||
)
|
||||
async def _propose_batch(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
# The driver intercepts this call and emits the batch event; the handler
|
||||
# only acknowledges so the agent knows it landed.
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "MegaTask submitted — the human can review it.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
server = create_sdk_mcp_server(
|
||||
name="intake", version="1.0.0", tools=[_propose_draft]
|
||||
name="intake", version="1.0.0", tools=[_propose_draft, _propose_batch]
|
||||
)
|
||||
|
||||
async def _gate(tool_name: str, _input: dict[str, Any], _ctx: Any) -> Any:
|
||||
if tool_name in _INTAKE_BASE_TOOLS or _is_propose_draft(tool_name):
|
||||
if (
|
||||
tool_name in _INTAKE_BASE_TOOLS
|
||||
or _is_propose_draft(tool_name)
|
||||
or _is_propose_batch(tool_name)
|
||||
):
|
||||
return PermissionResultAllow()
|
||||
# The intake's job is to ask questions, so it reaches for AskUserQuestion
|
||||
# by reflex. It isn't wired to the live chat panel (and isn't allowed), so
|
||||
@@ -385,8 +465,9 @@ def build_intake_options(
|
||||
return PermissionResultDeny(
|
||||
message=(
|
||||
f"{tool_name} is not available to the intake agent. Your only tools "
|
||||
"are Read, Grep, Glob, Task, and propose_draft. Ask the human inline; "
|
||||
"when the spec is ready, call propose_draft."
|
||||
"are Read, Grep, Glob, Task, propose_draft, and propose_batch (for a "
|
||||
"MegaTask). Ask the human inline; when the spec is ready, call "
|
||||
"propose_draft (one task) or propose_batch (several)."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -394,7 +475,11 @@ def build_intake_options(
|
||||
system_prompt=system_prompt,
|
||||
cwd=cwd,
|
||||
mcp_servers={"intake": server},
|
||||
allowed_tools=[*_INTAKE_BASE_TOOLS, "mcp__intake__propose_draft"],
|
||||
allowed_tools=[
|
||||
*_INTAKE_BASE_TOOLS,
|
||||
"mcp__intake__propose_draft",
|
||||
"mcp__intake__propose_batch",
|
||||
],
|
||||
model=model,
|
||||
include_partial_messages=True, # live token streaming
|
||||
permission_mode="dontAsk",
|
||||
|
||||
@@ -31,6 +31,8 @@ from roboco.api.deps import (
|
||||
)
|
||||
from roboco.api.schemas.prompter_live import (
|
||||
AgentEvent,
|
||||
BatchConfirmRequest,
|
||||
BatchPreviewRequest,
|
||||
LiveConfirmRequest,
|
||||
LiveMessageRequest,
|
||||
StartLiveRequest,
|
||||
@@ -92,6 +94,8 @@ async def start_live(body: StartLiveRequest, db: DbSession) -> StartLiveResponse
|
||||
)
|
||||
project_slug = project.slug
|
||||
|
||||
project_ids = [str(pid) for pid in body.project_ids] if body.project_ids else None
|
||||
|
||||
session_id = uuid4().hex
|
||||
try:
|
||||
# Non-blocking: opens the relay + spawns the container in the background,
|
||||
@@ -100,6 +104,7 @@ async def start_live(body: StartLiveRequest, db: DbSession) -> StartLiveResponse
|
||||
session_id,
|
||||
project_slug=project_slug,
|
||||
product_id=str(body.product_id) if body.product_id else None,
|
||||
project_ids=project_ids,
|
||||
initial_message=body.initial_message,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -210,6 +215,58 @@ async def confirm_live(
|
||||
return {"task_id": str(task_id)}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/preview-batch")
|
||||
async def preview_live_batch(
|
||||
session_id: str, # noqa: ARG001 — kept for route symmetry; preview is pure
|
||||
body: BatchPreviewRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext, # noqa: ARG001 — auth context only
|
||||
) -> dict[str, Any]:
|
||||
"""Compute a MegaTask's waves from the proposed drafts WITHOUT creating it.
|
||||
|
||||
Lets the panel show the human the conflict-free wave plan before they confirm
|
||||
the batch. Pure compute — no task is created and the live session is left
|
||||
running so the human can still keep chatting.
|
||||
"""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
return service.preview_batch(body.drafts)
|
||||
except ServiceError as e:
|
||||
raise _translate_service_error(e) from e
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/confirm-batch", status_code=status.HTTP_201_CREATED)
|
||||
async def confirm_live_batch(
|
||||
session_id: str,
|
||||
body: BatchConfirmRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Turn the agent's confirmed MegaTask (N drafts) into a sequenced batch, reap.
|
||||
|
||||
Builds the branchless umbrella + one root-subtask per draft, wires the
|
||||
collision-derived dependency waves, and routes the umbrella per ``route``
|
||||
(Board review vs. straight to the Main PM) — each root-subtask keeps its own
|
||||
project / branch / PR. Returns the umbrella id, the root-subtask ids, and the
|
||||
computed waves + warnings for the panel. Always terminal → the live session
|
||||
is reaped once the drafts are tasks.
|
||||
"""
|
||||
service = get_prompter_service(db)
|
||||
try:
|
||||
result = await service.confirm_live_batch(
|
||||
body.title,
|
||||
body.drafts,
|
||||
agent.agent_id,
|
||||
project_ids=body.project_ids,
|
||||
route=body.route,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _translate_service_error(e) from e
|
||||
await db.commit()
|
||||
await get_orchestrator().reap_intake_session(session_id)
|
||||
return result
|
||||
|
||||
|
||||
async def _intake_scope_for_task(
|
||||
db: DbSession, task: Any
|
||||
) -> tuple[str | None, str | None]:
|
||||
|
||||
@@ -138,6 +138,19 @@ def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
|
||||
setattr(task, field, None)
|
||||
|
||||
|
||||
def _reassert_batch_shape(task: Any) -> None:
|
||||
"""Raise HTTP 400 if a mutation broke the task's MegaTask shape. Raised
|
||||
before any commit, so a violation rolls back cleanly."""
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
try:
|
||||
TaskService.assert_batch_shape_intact(task)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
async def _resolve_assigned_to_slug(
|
||||
data: "TaskUpdate", db: AsyncSession
|
||||
) -> "TaskUpdate":
|
||||
@@ -867,6 +880,10 @@ async def update_task(
|
||||
detail="Task update failed unexpectedly",
|
||||
)
|
||||
_apply_null_clears(task, null_clears)
|
||||
# Null-clears apply AFTER service.update() (and its shape guard), so re-assert
|
||||
# the MegaTask shape here too — a cleared parent_task_id / project_id must not
|
||||
# turn a root-subtask into an umbrella-shaped-but-targeted spoof.
|
||||
_reassert_batch_shape(task)
|
||||
if new_status is not None and new_status != task.status:
|
||||
if not has_higher_perms:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -13,16 +13,27 @@ from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class StartLiveRequest(BaseModel):
|
||||
"""Open a live intake chat scoped to a project XOR a product."""
|
||||
"""Open a live intake chat scoped to a project, a product, or a MegaTask.
|
||||
|
||||
Exactly one scope: a single ``project_id`` (single-cell), a ``product_id``
|
||||
(board-led multi-cell), or ``project_ids`` (a MegaTask spanning several
|
||||
possibly-unrelated repos — the agent reads them all and proposes a batch).
|
||||
"""
|
||||
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
project_ids: list[UUID] | None = None
|
||||
initial_message: str | None = Field(default=None, min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _exactly_one_scope(self) -> StartLiveRequest:
|
||||
if bool(self.project_id) == bool(self.product_id):
|
||||
raise ValueError("provide exactly one of project_id / product_id")
|
||||
chosen = sum(
|
||||
1 for scope in (self.project_id, self.product_id, self.project_ids) if scope
|
||||
)
|
||||
if chosen != 1:
|
||||
raise ValueError(
|
||||
"provide exactly one of project_id / product_id / project_ids"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@@ -71,3 +82,34 @@ class LiveConfirmRequest(BaseModel):
|
||||
if bool(self.project_id) == bool(self.product_id):
|
||||
raise ValueError("provide exactly one of project_id / product_id")
|
||||
return self
|
||||
|
||||
|
||||
class BatchConfirmRequest(BaseModel):
|
||||
"""Confirm a MegaTask — a batch of drafts sequenced into collision-free waves.
|
||||
|
||||
Each entry in ``drafts`` is a normal intake draft dict that ALSO carries its
|
||||
own ``project_id`` (the batch spans many projects) and an optional collision
|
||||
surface the analyzer reads (``intends_to_touch`` globs, ``adds_migration``,
|
||||
``touches_shared``). ``route`` is the same start button as a single confirm:
|
||||
``"board"`` (Board reviews the batch first) or ``"main_pm"`` (straight to the
|
||||
Main PM). ``title`` names the umbrella.
|
||||
"""
|
||||
|
||||
title: str = Field(..., min_length=1)
|
||||
drafts: list[dict[str, Any]] = Field(..., min_length=1)
|
||||
# The scoped repos the MegaTask spans (the set the intake agent read). Every
|
||||
# draft must target one of these, and the batch must span at least two.
|
||||
project_ids: list[UUID] = Field(..., min_length=2)
|
||||
route: Literal["board", "main_pm"] = "board"
|
||||
|
||||
|
||||
class BatchPreviewRequest(BaseModel):
|
||||
"""Preview a MegaTask's sequencing without creating anything.
|
||||
|
||||
The panel sends the proposed drafts (each with its collision surface) once
|
||||
the agent proposes a batch, to show the human the conflict-free waves before
|
||||
they confirm. Returns ``{waves, warnings}`` — ``waves`` is a list of waves,
|
||||
each a list of draft indices that run together.
|
||||
"""
|
||||
|
||||
drafts: list[dict[str, Any]] = Field(..., min_length=1)
|
||||
|
||||
@@ -314,6 +314,9 @@ class TaskResponse(BaseModel):
|
||||
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
|
||||
@@ -694,6 +697,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
|
||||
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,
|
||||
|
||||
@@ -283,6 +283,22 @@ class TaskTable(Base):
|
||||
Integer, default=0, nullable=False, index=True
|
||||
)
|
||||
|
||||
# Sequenced batch intake ("Mega task"): a batch of top-level tasks created
|
||||
# together. ``batch_id`` groups them; the three descriptors are the per-task
|
||||
# collision surface the SequencingService reads to compute dependency waves.
|
||||
batch_id: Mapped[UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True, index=True
|
||||
)
|
||||
intends_to_touch: Mapped[list[str] | None] = mapped_column(
|
||||
ARRAY(String), nullable=True
|
||||
)
|
||||
adds_migration: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
touches_shared: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
|
||||
@@ -331,6 +331,12 @@ class GitContext:
|
||||
# does no git work of its own and never gets a branch, so it is exempt from
|
||||
# the claimed->in_progress branch gate (same rationale as is_coordination).
|
||||
is_external_review: bool = False
|
||||
# A MegaTask umbrella assembles no PR of its own (each root-subtask carries
|
||||
# its own), so it escalates to the CEO with no pr_number — exempt from the
|
||||
# awaiting_pm_review->awaiting_ceo_approval pr_number gate. A product fan-out
|
||||
# root is is_coordination too but DOES get a pr_number (via submit_root), so
|
||||
# this is umbrella-specific, not all-coordination.
|
||||
is_umbrella: bool = False
|
||||
|
||||
|
||||
def validate_git_requirements(
|
||||
@@ -393,10 +399,12 @@ def _check_doc_phase_gate(transition: tuple[str, str], git_ctx: GitContext) -> N
|
||||
def _check_ceo_escalation_gate(
|
||||
transition: tuple[str, str], git_ctx: GitContext
|
||||
) -> None:
|
||||
"""awaiting_pm_review -> awaiting_ceo_approval needs a recorded pr_number."""
|
||||
"""awaiting_pm_review -> awaiting_ceo_approval needs a recorded pr_number,
|
||||
EXCEPT a MegaTask umbrella, which is branchless and assembles no PR."""
|
||||
if (
|
||||
transition == ("awaiting_pm_review", "awaiting_ceo_approval")
|
||||
and git_ctx.pr_number is None
|
||||
and not git_ctx.is_umbrella
|
||||
):
|
||||
raise GitRequirementError(
|
||||
transition=transition,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""MegaTask (sequenced batch intake) — pure identity + git-exemption predicates.
|
||||
|
||||
A MegaTask is an *umbrella* task grouping N *root-subtasks*, each a real
|
||||
coordination root with its own project / branch / PR. The umbrella is the
|
||||
batch's identity — the board-review, CEO-approve, and Main-PM-coordinate unit —
|
||||
and it does no git of its own: branchless, never assembles a PR, completes only
|
||||
when every root-subtask is terminal.
|
||||
|
||||
These predicates are the single source of truth every layer consults (the
|
||||
orchestrator's coordination check, the git-requirement gate, the branch-creation
|
||||
short-circuit, the CEO-reject routing) so the umbrella's exemptions cannot drift
|
||||
between sites. Inputs are typed ``object | None`` because callers pass either ORM
|
||||
``UUID | None`` columns or ``dict.get(...)`` values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_batch_umbrella(
|
||||
*, batch_id: object | None, parent_task_id: object | None
|
||||
) -> bool:
|
||||
"""True for a MegaTask umbrella: carries a ``batch_id`` and is top-level.
|
||||
|
||||
The umbrella and its root-subtasks share a ``batch_id``; only the umbrella is
|
||||
parentless (a root-subtask's ``parent_task_id`` is the umbrella), so parentage
|
||||
alone distinguishes them.
|
||||
"""
|
||||
return batch_id is not None and parent_task_id is None
|
||||
|
||||
|
||||
def is_batch_root_subtask(
|
||||
*, batch_id: object | None, parent_task_id: object | None
|
||||
) -> bool:
|
||||
"""True for a MegaTask root-subtask: a batch item under an umbrella."""
|
||||
return batch_id is not None and parent_task_id is not None
|
||||
|
||||
|
||||
def is_branchless_coordination(
|
||||
*,
|
||||
project_id: object | None,
|
||||
product_id: object | None,
|
||||
batch_id: object | None = None,
|
||||
parent_task_id: object | None = None,
|
||||
) -> bool:
|
||||
"""True for a task that does no git of its own (no branch, no PR).
|
||||
|
||||
Two shapes qualify: a product fan-out coordination root (no ``project_id``,
|
||||
carries a ``product_id``), and a MegaTask umbrella (``batch_id`` set,
|
||||
top-level). Both are Main-PM coordination points whose children do the git.
|
||||
|
||||
Relies on the creation-time invariant ``is_valid_batch_shape`` that a
|
||||
``batch_id``-bearing top-level task carries no project/product — so a real
|
||||
umbrella is genuinely branchless and a normal task cannot spoof the exemption
|
||||
by attaching a ``batch_id``.
|
||||
"""
|
||||
if project_id is None and product_id is not None:
|
||||
return True
|
||||
return is_batch_umbrella(batch_id=batch_id, parent_task_id=parent_task_id)
|
||||
|
||||
|
||||
def is_valid_batch_shape(
|
||||
*,
|
||||
batch_id: object | None,
|
||||
parent_task_id: object | None,
|
||||
project_id: object | None,
|
||||
product_id: object | None,
|
||||
) -> bool:
|
||||
"""Guardrail: a ``batch_id`` is only valid on a well-formed MegaTask member.
|
||||
|
||||
A ``batch_id`` is permitted on exactly two shapes:
|
||||
|
||||
- an **umbrella** (no ``parent_task_id``) — which must target NEITHER a
|
||||
project nor a product (it is branchless, grouping root-subtasks that each
|
||||
carry their own repo);
|
||||
- a **root-subtask** (has a ``parent_task_id``) — which must target exactly
|
||||
one of project / product (it does its own git).
|
||||
|
||||
A task without a ``batch_id`` is unconstrained here (the normal targeting
|
||||
rule applies). Denying every other ``batch_id`` shape stops a normal task
|
||||
from spoofing the umbrella's branch-gate / no-PR exemption by attaching a
|
||||
stray ``batch_id`` — the reason :func:`is_branchless_coordination` can trust
|
||||
that a ``batch_id``-bearing top-level task really is a branchless umbrella.
|
||||
"""
|
||||
if batch_id is None:
|
||||
return True
|
||||
if parent_task_id is None: # umbrella
|
||||
return project_id is None and product_id is None
|
||||
# root-subtask: exactly one target
|
||||
return (project_id is None) != (product_id is None)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Sequenced batch intake — pure collision-sequencing schema.
|
||||
|
||||
The deterministic ``SequencingService`` (``roboco/services/sequencing.py``) reads
|
||||
these dataclasses; nothing here touches the DB or any service. ``DraftSurface``
|
||||
is one proposed task's collision surface; ``SequencePlan`` is the dependency DAG
|
||||
(edges) + the topological waves + any cell-contention warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.foundation.policy.sequencing.models import (
|
||||
DraftSurface,
|
||||
SequencePlan,
|
||||
SequencingError,
|
||||
)
|
||||
|
||||
__all__ = ["DraftSurface", "SequencePlan", "SequencingError"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Pure schema for the sequenced-batch collision analyzer.
|
||||
|
||||
A ``DraftSurface`` is the collision surface the Prompter declares for one
|
||||
proposed task (which files/dirs it will touch, whether it adds a migration,
|
||||
whether it edits a widely-shared surface). The analyzer turns a list of them
|
||||
into a ``SequencePlan``: a dependency DAG (``edges``) and the topological
|
||||
``waves`` the existing dependency-gate executes, plus non-blocking ``warnings``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
class SequencingError(ValueError):
|
||||
"""A batch's collision graph cannot be serialized into waves (a cycle, or an
|
||||
edge references a non-existent draft)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DraftSurface:
|
||||
"""One proposed task's collision surface (indexed within the batch).
|
||||
|
||||
``idx`` is the task's position in the batch; ``priority`` is its task
|
||||
priority (lower number = more important, runs first on a collision tie).
|
||||
``project_id`` is the repo the task targets — collisions are scoped to it, so
|
||||
two tasks in different repos never collide on a coincidentally-equal path or a
|
||||
migration (each repo has its own working tree and migration chain).
|
||||
"""
|
||||
|
||||
idx: int
|
||||
priority: int
|
||||
intends_to_touch: list[str]
|
||||
adds_migration: bool
|
||||
touches_shared: bool
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SequencePlan:
|
||||
"""The analyzer's output.
|
||||
|
||||
``edges`` are ``(a, b)`` pairs meaning *b depends on a* (a runs first);
|
||||
``waves`` is the Kahn topological layering each item's ``dependency_ids``
|
||||
are wired from; ``warnings`` are non-blocking advisories (e.g. cell
|
||||
contention) that never add an edge.
|
||||
"""
|
||||
|
||||
edges: list[tuple[int, int]]
|
||||
waves: list[list[int]]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
+94
-21
@@ -1,9 +1,11 @@
|
||||
"""roboco-intake MCP server — the Intake interviewer's ``propose_draft`` tool.
|
||||
"""roboco-intake MCP server — the Intake interviewer's ``propose_draft`` and
|
||||
``propose_batch`` (MegaTask) tools.
|
||||
|
||||
The grok-CLI interactive intake agent calls ``propose_draft`` once the task spec
|
||||
is ready; this delivers the draft to the panel's reviewable draft card by POSTing
|
||||
it straight to the prompter-live relay (the same ``/api/prompter/live/{session}/
|
||||
events`` endpoint the driver's relay sink uses).
|
||||
The grok-CLI interactive intake agent calls ``propose_draft`` (one task) or
|
||||
``propose_batch`` (a sequenced MegaTask of several tasks) once the spec is ready;
|
||||
this delivers it to the panel's reviewable card by POSTing it straight to the
|
||||
prompter-live relay (the same ``/api/prompter/live/{session}/events`` endpoint the
|
||||
driver's relay sink uses).
|
||||
|
||||
WHY IT POSTS DIRECTLY: grok's ``streaming-json`` output does not surface
|
||||
tool-call events (verified live — a tool runs but never appears in the stream),
|
||||
@@ -35,30 +37,18 @@ def _api_base() -> str:
|
||||
)
|
||||
|
||||
|
||||
async def post_draft(
|
||||
async def _post_event(
|
||||
session_id: str,
|
||||
draft: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST the draft to the prompter-live relay; never raises.
|
||||
|
||||
Module-level so it is unit-testable with ``httpx.MockTransport`` (the tool
|
||||
wrapper below only shapes the result string).
|
||||
"""
|
||||
"""POST one relay event to the prompter-live relay; never raises."""
|
||||
owns = client is None
|
||||
http = client or httpx.AsyncClient(timeout=_TIMEOUT)
|
||||
url = f"{_api_base()}/api/prompter/live/{session_id}/events"
|
||||
try:
|
||||
resp = await http.post(
|
||||
url,
|
||||
json={
|
||||
"kind": "draft",
|
||||
"text": "",
|
||||
"tool": "propose_draft",
|
||||
"data": draft,
|
||||
},
|
||||
)
|
||||
resp = await http.post(url, json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
return {"error": "request_failed", "detail": str(exc)}
|
||||
finally:
|
||||
@@ -69,6 +59,38 @@ async def post_draft(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def post_draft(
|
||||
session_id: str,
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST a single draft to the prompter-live relay; never raises.
|
||||
|
||||
Module-level so it is unit-testable with ``httpx.MockTransport`` (the tool
|
||||
wrapper below only shapes the result string).
|
||||
"""
|
||||
return await _post_event(
|
||||
session_id,
|
||||
{"kind": "draft", "text": "", "tool": "propose_draft", "data": draft},
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
async def post_batch(
|
||||
session_id: str,
|
||||
batch: dict[str, Any],
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST a MegaTask batch (``{drafts: [...], title}``) to the relay; never raises."""
|
||||
return await _post_event(
|
||||
session_id,
|
||||
{"kind": "batch", "text": "", "tool": "propose_batch", "data": batch},
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def propose_draft(draft: dict[str, Any]) -> str:
|
||||
"""Submit the finished task draft for the human to review and confirm.
|
||||
@@ -77,6 +99,14 @@ async def propose_draft(draft: dict[str, Any]) -> str:
|
||||
what_this_builds[], the_work[] ({team, summary, items}), notes[],
|
||||
acceptance_criteria[], team, scale, task_type, nature, estimated_complexity,
|
||||
priority.
|
||||
|
||||
Sequenced batch intake (when enabled): if the CEO asks for several tasks at
|
||||
once, propose one draft per item and set each item's collision surface so the
|
||||
system can sequence them into conflict-free waves — intends_to_touch[] (the
|
||||
files/dirs this item will modify, from its grounding), adds_migration (does it
|
||||
add a DB migration / column?), touches_shared (does it edit a widely-shared
|
||||
component, token, or primitive?). Over-declaring a surface is safer than
|
||||
under-declaring; the analyzer derives the ordering from these.
|
||||
"""
|
||||
session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
|
||||
if not session_id:
|
||||
@@ -91,5 +121,48 @@ async def propose_draft(draft: dict[str, Any]) -> str:
|
||||
return f"Could not submit the draft to the panel: {detail}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def propose_batch(drafts: list[dict[str, Any]], title: str = "") -> str:
|
||||
"""Submit a MegaTask — SEVERAL task drafts at once — for the human to confirm.
|
||||
|
||||
Use this instead of ``propose_draft`` when the CEO asked for multiple tasks
|
||||
across the scoped repos. ``drafts`` is a list where each item has the same
|
||||
fields as a single draft PLUS its own ``project_id`` (which repo it targets)
|
||||
and collision surface — ``intends_to_touch[]`` (files/dirs it will modify),
|
||||
``adds_migration`` (adds a DB migration?), ``touches_shared`` (edits a widely
|
||||
shared component?). The system sequences them into conflict-free waves;
|
||||
over-declaring a surface is safer than under-declaring. ``title`` names the
|
||||
MegaTask.
|
||||
"""
|
||||
session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
|
||||
if not session_id:
|
||||
return (
|
||||
"No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the "
|
||||
"MegaTask."
|
||||
)
|
||||
# Drop malformed entries (a draft needs a string title) and refuse to POST an
|
||||
# empty batch — otherwise it would silently vanish on the panel side, telling
|
||||
# the agent it succeeded while nothing appears.
|
||||
raw = drafts or []
|
||||
well_formed = [
|
||||
d for d in raw if isinstance(d, dict) and isinstance(d.get("title"), str)
|
||||
]
|
||||
if not well_formed:
|
||||
return (
|
||||
"That MegaTask had no well-formed task drafts — give each a title and "
|
||||
"project_id and call propose_batch again."
|
||||
)
|
||||
payload = {
|
||||
"drafts": well_formed,
|
||||
"title": title,
|
||||
"dropped": len(raw) - len(well_formed),
|
||||
}
|
||||
result = await post_batch(session_id, payload)
|
||||
if result.get("ok"):
|
||||
return "MegaTask submitted — the human can review it in the panel."
|
||||
detail = result.get("detail") or result.get("error") or "unknown error"
|
||||
return f"Could not submit the MegaTask to the panel: {detail}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
|
||||
@@ -204,6 +204,21 @@ class Task(TimestampMixin):
|
||||
description="Dependency task IDs that have since completed and cleared",
|
||||
)
|
||||
|
||||
# Sequenced batch intake ("Mega task"): the collision surface the analyzer
|
||||
# reads to wire dependency waves across a batch of tasks created together.
|
||||
batch_id: UUID | None = Field(
|
||||
default=None, description="Groups tasks created as one sequenced batch"
|
||||
)
|
||||
intends_to_touch: list[str] | None = Field(
|
||||
default=None, description="Files/dirs this task expects to modify"
|
||||
)
|
||||
adds_migration: bool = Field(
|
||||
default=False, description="Whether this task adds a DB migration"
|
||||
)
|
||||
touches_shared: bool = Field(
|
||||
default=False, description="Whether this task edits a widely-shared surface"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
claimed_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
@@ -446,6 +461,12 @@ class TaskCreateRequest:
|
||||
sequence: int = 0 # Order within siblings (lower = first)
|
||||
dependency_ids: list[UUID] = field(default_factory=list)
|
||||
|
||||
# Sequenced batch intake ("Mega task") collision surface — see Task model.
|
||||
batch_id: UUID | None = None
|
||||
intends_to_touch: list[str] | None = None
|
||||
adds_migration: bool = False
|
||||
touches_shared: bool = False
|
||||
|
||||
# AC identity + linkage (migration 036). acceptance_criteria_ids is generated
|
||||
# in TaskService.create when empty; parent_ac_refs is the parent AC ids a
|
||||
# decomposition child is responsible for (the coverage/roll-up linkage).
|
||||
|
||||
+122
-37
@@ -48,6 +48,7 @@ from roboco.config import settings
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.identity import CELL_TEAMS
|
||||
from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _AGENT_LOOP_BUDGET
|
||||
from roboco.foundation.policy.batch import is_branchless_coordination
|
||||
from roboco.models import AgentRole, Team
|
||||
from roboco.models.runtime import (
|
||||
MODEL_MAP,
|
||||
@@ -88,6 +89,19 @@ _PROBE_TIMEOUT_SECONDS = 10.0
|
||||
_HTTP_TOO_MANY_REQUESTS = 429
|
||||
_HTTP_OK = 200
|
||||
_HTTP_MULTIPLE_CHOICES = 300 # first non-2xx status; 2xx == [_HTTP_OK, this)
|
||||
|
||||
# The orchestrator calls its own write API as a trusted internal actor. Those
|
||||
# routes require an agent identity (X-Agent-ID); a self-call without it is
|
||||
# rejected 401, so silent recovery ops (auto-block / auto-resume / auto-recover
|
||||
# / SLA annotation) no-op and paused/blocked parents wedge. The system identity
|
||||
# holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
|
||||
# path those routes use. EVERY dispatcher client that can reach the API must
|
||||
# carry it — header propagation was previously inconsistent across the separate
|
||||
# AsyncClient call-sites, so only some paths were authenticated.
|
||||
_SYSTEM_API_HEADERS = {
|
||||
"X-Agent-ID": "00000000-0000-0000-0000-000000000000",
|
||||
"X-Agent-Role": "system",
|
||||
}
|
||||
# Consecutive failed recovery probes before the CEO is notified once per episode.
|
||||
_CEO_NOTIFY_THRESHOLD = 10
|
||||
|
||||
@@ -283,16 +297,22 @@ def _read_project_slug(task: dict[str, Any]) -> str | None:
|
||||
|
||||
|
||||
def _is_coordination_task(task: dict[str, Any]) -> bool:
|
||||
"""True for a board/fan-out task that carries a product but no repo of its own.
|
||||
"""True for a task that does no git of its own.
|
||||
|
||||
Such a task does no git work itself: its cell subtasks each resolve a real
|
||||
project from the product's cell->project map (see TaskCreate's
|
||||
project-or-product invariant and migration 018). It therefore has no
|
||||
Two shapes qualify: a board/fan-out coordination root (carries a product, no
|
||||
repo — its cell subtasks resolve a real project from the product's
|
||||
cell->project map), and a MegaTask umbrella (carries a batch_id, top-level —
|
||||
its root-subtasks each carry their own branch/PR). Such a task has no
|
||||
project_slug, branch_name, or git token, and must NOT be git-gated at the
|
||||
spawn-readiness or stuck-detection checks the way a code task is. A task with
|
||||
neither a project nor a product is genuinely unroutable and stays gated.
|
||||
none of project / product / batch is genuinely unroutable and stays gated.
|
||||
"""
|
||||
return not task.get("project_id") and bool(task.get("product_id"))
|
||||
return is_branchless_coordination(
|
||||
project_id=task.get("project_id"),
|
||||
product_id=task.get("product_id"),
|
||||
batch_id=task.get("batch_id"),
|
||||
parent_task_id=task.get("parent_task_id"),
|
||||
)
|
||||
|
||||
|
||||
# A branch is auto-created only at CLAIM (the claimed->in_progress transition).
|
||||
@@ -2643,7 +2663,9 @@ class AgentOrchestrator:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=5.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
task_or_reason = await self._readiness_fetch_task(client, task_id)
|
||||
if isinstance(task_or_reason, str):
|
||||
return task_or_reason
|
||||
@@ -2940,7 +2962,9 @@ class AgentOrchestrator:
|
||||
) -> dict[str, Any] | None:
|
||||
"""Best-effort GET /tasks/{id}; returns task dict or None on failure."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=5.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
resp = await client.get(f"{self._api_url}/tasks/{task_id}")
|
||||
if resp.status_code == http_status.HTTP_200_OK:
|
||||
payload: dict[str, Any] = resp.json()
|
||||
@@ -3120,12 +3144,28 @@ class AgentOrchestrator:
|
||||
# agent reads code with Read/Grep/Glob and talks only to the human).
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _require_one_intake_scope(
|
||||
project_slug: str | None,
|
||||
product_id: str | None,
|
||||
project_ids: list[str] | None,
|
||||
) -> None:
|
||||
"""Exactly one intake scope: a single project, a product, or a MegaTask's
|
||||
explicit project set."""
|
||||
chosen = sum(1 for scope in (project_slug, product_id, project_ids) if scope)
|
||||
if chosen != 1:
|
||||
raise ValueError(
|
||||
"intake scope requires exactly one of project_slug / product_id"
|
||||
" / project_ids"
|
||||
)
|
||||
|
||||
async def start_intake_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
project_slug: str | None = None,
|
||||
product_id: str | None = None,
|
||||
project_ids: list[str] | None = None,
|
||||
initial_message: str | None = None,
|
||||
) -> None:
|
||||
"""Non-blocking start: open the relay now, spawn the container in the bg.
|
||||
@@ -3137,18 +3177,16 @@ class AgentOrchestrator:
|
||||
first reply arrives once the container is up. A spawn failure is pushed
|
||||
onto the relay as an ``error`` event and closes the session, so the panel
|
||||
shows it instead of hanging. Exactly one of ``project_slug`` /
|
||||
``product_id`` must be given.
|
||||
``product_id`` / ``project_ids`` (a MegaTask) must be given.
|
||||
"""
|
||||
if bool(project_slug) == bool(product_id):
|
||||
raise ValueError(
|
||||
"intake scope requires exactly one of project_slug / product_id"
|
||||
)
|
||||
self._require_one_intake_scope(project_slug, product_id, project_ids)
|
||||
self._open_intake_relay(session_id)
|
||||
self._schedule_bg(
|
||||
self._spawn_intake_container_guarded(
|
||||
session_id,
|
||||
project_slug=project_slug,
|
||||
product_id=product_id,
|
||||
project_ids=project_ids,
|
||||
initial_message=initial_message,
|
||||
)
|
||||
)
|
||||
@@ -3159,6 +3197,7 @@ class AgentOrchestrator:
|
||||
*,
|
||||
project_slug: str | None = None,
|
||||
product_id: str | None = None,
|
||||
project_ids: list[str] | None = None,
|
||||
initial_message: str | None = None,
|
||||
) -> AgentInstance:
|
||||
"""Spawn the intake container for one live chat, **synchronously**.
|
||||
@@ -3166,17 +3205,16 @@ class AgentOrchestrator:
|
||||
Opens the relay then clones + launches the container, awaiting the whole
|
||||
thing. Prefer ``start_intake_session`` on the request path; this blocking
|
||||
variant is for direct/internal callers and tests. Exactly one of
|
||||
``project_slug`` / ``product_id`` must be given.
|
||||
``project_slug`` / ``product_id`` / ``project_ids`` (a MegaTask) must be
|
||||
given.
|
||||
"""
|
||||
if bool(project_slug) == bool(product_id):
|
||||
raise ValueError(
|
||||
"intake scope requires exactly one of project_slug / product_id"
|
||||
)
|
||||
self._require_one_intake_scope(project_slug, product_id, project_ids)
|
||||
self._open_intake_relay(session_id)
|
||||
return await self._spawn_intake_container(
|
||||
session_id,
|
||||
project_slug=project_slug,
|
||||
product_id=product_id,
|
||||
project_ids=project_ids,
|
||||
initial_message=initial_message,
|
||||
)
|
||||
|
||||
@@ -3193,6 +3231,7 @@ class AgentOrchestrator:
|
||||
*,
|
||||
project_slug: str | None,
|
||||
product_id: str | None,
|
||||
project_ids: list[str] | None = None,
|
||||
initial_message: str | None,
|
||||
) -> None:
|
||||
"""Background container spawn; surface failures on the relay, not silently."""
|
||||
@@ -3203,6 +3242,7 @@ class AgentOrchestrator:
|
||||
session_id,
|
||||
project_slug=project_slug,
|
||||
product_id=product_id,
|
||||
project_ids=project_ids,
|
||||
initial_message=initial_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -3222,6 +3262,7 @@ class AgentOrchestrator:
|
||||
*,
|
||||
project_slug: str | None,
|
||||
product_id: str | None,
|
||||
project_ids: list[str] | None = None,
|
||||
initial_message: str | None,
|
||||
) -> AgentInstance:
|
||||
"""Clone the scope, launch the SDK-driver container, track the instance.
|
||||
@@ -3236,7 +3277,9 @@ class AgentOrchestrator:
|
||||
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
cwd, cloned = await self._clone_intake_scope(project_slug, product_id)
|
||||
cwd, cloned = await self._clone_intake_scope(
|
||||
project_slug, product_id, project_ids
|
||||
)
|
||||
|
||||
ambient = await self._resolve_conventions_ambient(
|
||||
project_slug, product_id=product_id
|
||||
@@ -3593,15 +3636,20 @@ class AgentOrchestrator:
|
||||
return cmd
|
||||
|
||||
async def _clone_intake_scope(
|
||||
self, project_slug: str | None, product_id: str | None
|
||||
self,
|
||||
project_slug: str | None,
|
||||
product_id: str | None,
|
||||
project_ids: list[str] | None = None,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Clone the chat scope's repo(s); return (container cwd, all paths).
|
||||
|
||||
``project`` → one repo; ``product`` → each distinct cell project (the
|
||||
Main-PM-style distinct-repo set, kept in its deterministic team order so
|
||||
the primary is stable). The agent's cwd is the primary project's intake
|
||||
workspace; for a product the sibling repos sit alongside it under
|
||||
``/data/workspaces`` and are readable via Grep/Glob/Read.
|
||||
the primary is stable); ``project_ids`` → a MegaTask's explicit set of
|
||||
(possibly unrelated) projects, in the order given. The agent's cwd is the
|
||||
primary project's intake workspace; for a multi-repo scope the sibling
|
||||
repos sit alongside it under ``/data/workspaces`` and are readable via
|
||||
Grep/Glob/Read.
|
||||
"""
|
||||
from roboco.db.base import get_session_factory
|
||||
from roboco.services.workspace import WorkspaceService
|
||||
@@ -3609,7 +3657,9 @@ class AgentOrchestrator:
|
||||
team = get_agent_team(INTAKE_AGENT_ID) or "board"
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
slugs = await self._intake_scope_slugs(db, project_slug, product_id)
|
||||
slugs = await self._intake_scope_slugs(
|
||||
db, project_slug, product_id, project_ids
|
||||
)
|
||||
ws = WorkspaceService(db)
|
||||
for slug in slugs:
|
||||
await ws.ensure_workspace(slug, INTAKE_AGENT_ID)
|
||||
@@ -3620,13 +3670,46 @@ class AgentOrchestrator:
|
||||
|
||||
@staticmethod
|
||||
async def _intake_scope_slugs(
|
||||
db: Any, project_slug: str | None, product_id: str | None
|
||||
db: Any,
|
||||
project_slug: str | None,
|
||||
product_id: str | None,
|
||||
project_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve the chat scope to the project slug(s) to clone."""
|
||||
if project_slug:
|
||||
return [project_slug]
|
||||
if not product_id:
|
||||
raise ValueError("intake scope requires project_slug or product_id")
|
||||
if project_ids:
|
||||
return await AgentOrchestrator._slugs_for_project_ids(db, project_ids)
|
||||
if product_id:
|
||||
return await AgentOrchestrator._slugs_for_product(db, product_id)
|
||||
raise ValueError(
|
||||
"intake scope requires project_slug, product_id, or project_ids"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _slugs_for_project_ids(db: Any, project_ids: list[str]) -> list[str]:
|
||||
"""MegaTask scope: the slugs of an explicit set of (unrelated) projects."""
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
project_svc = get_project_service(db)
|
||||
slugs: list[str] = []
|
||||
for pid in project_ids:
|
||||
project = await project_svc.get(UUID(pid))
|
||||
# Fail loud on ANY unresolvable id (matching the single-project route's
|
||||
# 404) rather than silently cloning fewer repos — a partial scope would
|
||||
# let the agent draft against an incomplete workspace with no signal.
|
||||
if not (project and project.slug):
|
||||
raise ValueError(f"MegaTask scope: project {pid} not found")
|
||||
slugs.append(project.slug)
|
||||
if not slugs:
|
||||
raise ValueError("MegaTask scope resolves to no projects")
|
||||
return slugs
|
||||
|
||||
@staticmethod
|
||||
async def _slugs_for_product(db: Any, product_id: str) -> list[str]:
|
||||
"""Product scope: the distinct cell-project slugs, in deterministic order."""
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.services.product import ProductService
|
||||
@@ -4243,7 +4326,9 @@ class AgentOrchestrator:
|
||||
tokens = (0, 0, 0, 0)
|
||||
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
resp = await client.get(sdk_url)
|
||||
if resp.status_code == http_status.HTTP_200_OK:
|
||||
data = resp.json()
|
||||
@@ -4498,7 +4583,9 @@ class AgentOrchestrator:
|
||||
_usage_total_output = 0
|
||||
_usage_total_cost = 0.0
|
||||
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
for agent_id, instance in list(self._instances.items()):
|
||||
if instance.state not in (
|
||||
AgentState.ACTIVE,
|
||||
@@ -5026,7 +5113,9 @@ Start by:
|
||||
"""
|
||||
if not self._instances:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=3.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
for agent_id, instance in list(self._instances.items()):
|
||||
if instance.state not in (
|
||||
AgentState.ACTIVE,
|
||||
@@ -7296,14 +7385,10 @@ Start now: evidence(task_id="{task_id}")
|
||||
except Exception as e:
|
||||
logger.error("Grok cost-budget sweep failed; continuing tick", error=str(e))
|
||||
|
||||
# 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:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, headers=_SYSTEM_API_HEADERS
|
||||
) as client:
|
||||
dispatchers = [
|
||||
("pm_work", self._dispatch_pm_work(client)),
|
||||
("pm_closure_work", self._dispatch_pm_closure_work(client)),
|
||||
|
||||
@@ -21,6 +21,7 @@ import structlog
|
||||
|
||||
from roboco.exceptions import MergeConflictError
|
||||
from roboco.foundation.policy import lifecycle as spec_module
|
||||
from roboco.foundation.policy.batch import is_batch_umbrella
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.content.validators import reject_trivial
|
||||
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
|
||||
@@ -5394,6 +5395,42 @@ class Choreographer:
|
||||
return
|
||||
await self.task.reassign(parent_task_id, pm_agent.id)
|
||||
|
||||
def _submit_root_preflight(
|
||||
self, t: Any, role_str: str, briefing: dict[str, Any]
|
||||
) -> Envelope | None:
|
||||
"""Reject a submit_root that can never assemble a root→master PR.
|
||||
|
||||
Two early refusals collapse here to keep ``submit_root`` within the
|
||||
return-count budget. A MegaTask umbrella assembles no PR of its own — it
|
||||
spans many projects (no single master) and each root-subtask opens its
|
||||
own PR — so it must never enter the in-path review gate; the Main PM
|
||||
completes the umbrella directly once every root-subtask is terminal. And
|
||||
an actor whose role is not in the lifecycle spec cannot drive the verb.
|
||||
Returns the rejection Envelope (caller adds introspection + emits) or
|
||||
None when neither refusal applies.
|
||||
"""
|
||||
if is_batch_umbrella(batch_id=t.batch_id, parent_task_id=t.parent_task_id):
|
||||
return Envelope.invalid_state(
|
||||
message="a MegaTask umbrella assembles no PR of its own",
|
||||
remediate=(
|
||||
"the umbrella spans many projects — each root-subtask opens"
|
||||
" its own PR and is reviewed on its own diff. Do not"
|
||||
" submit_root the umbrella; once every root-subtask is"
|
||||
" terminal, call complete(task_id, notes='...') to escalate"
|
||||
" the MegaTask to the CEO."
|
||||
),
|
||||
context_briefing=briefing,
|
||||
)
|
||||
try:
|
||||
spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
return Envelope.not_authorized(
|
||||
message=f"unknown role '{role_str}'",
|
||||
remediate="role is not declared in the lifecycle spec",
|
||||
context_briefing=briefing,
|
||||
)
|
||||
return None
|
||||
|
||||
async def submit_root(
|
||||
self, main_pm_agent_id: UUID, task_id: UUID, notes: str
|
||||
) -> Envelope:
|
||||
@@ -5420,19 +5457,16 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(main_pm_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "main_pm"
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
# Hard-reject an umbrella (no PR of its own) or an unknown role before the
|
||||
# spec gate; the preflight collapses both into one Envelope-or-None.
|
||||
if env := self._submit_root_preflight(t, role_str, briefing):
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_authorized(
|
||||
message=f"unknown role '{role_str}'",
|
||||
remediate="role is not declared in the lifecycle spec",
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str),
|
||||
env.with_introspection(task=t, role=role_str),
|
||||
agent_id=main_pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="submit_root",
|
||||
)
|
||||
role = spec_module.Role(role_str)
|
||||
spec_ctx = spec_module.Context(
|
||||
actor_id=main_pm_agent_id,
|
||||
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
|
||||
@@ -5631,6 +5665,25 @@ class Choreographer:
|
||||
t = await self.task.escalate_to_ceo(
|
||||
task_id=root_task_id, agent_role="main_pm", notes=notes
|
||||
)
|
||||
# Defense-in-depth: escalate_to_ceo returns None when it refuses (e.g. a
|
||||
# transition guard rejects). Surface that as a clean rejection instead of
|
||||
# dereferencing None below.
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.invalid_state(
|
||||
message="escalate_to_ceo did not apply",
|
||||
remediate=(
|
||||
"ensure the root is in awaiting_pm_review with all"
|
||||
" subtasks terminal, then retry complete"
|
||||
),
|
||||
context_briefing=await self._briefing_for(
|
||||
main_pm_agent_id, root_task_id
|
||||
),
|
||||
),
|
||||
agent_id=main_pm_agent_id,
|
||||
task_id=root_task_id,
|
||||
verb="main_pm_complete",
|
||||
)
|
||||
# CEO acts via the UI, not as an agent the orchestrator spawns. Clear
|
||||
# ``assigned_to`` so no agent gets respawned to chase this task while
|
||||
# it sits in awaiting_ceo_approval.
|
||||
|
||||
+356
-27
@@ -14,13 +14,15 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.foundation.identity import CELL_TEAMS
|
||||
from roboco.foundation.policy.batch import is_batch_umbrella
|
||||
from roboco.foundation.policy.sequencing.models import DraftSurface, SequencePlan
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
Complexity,
|
||||
@@ -44,6 +46,20 @@ _BOARD_REVIEW_ROLES: frozenset[AgentRole] = frozenset(
|
||||
{AgentRole.PRODUCT_OWNER, AgentRole.HEAD_MARKETING, AgentRole.AUDITOR}
|
||||
)
|
||||
|
||||
# Per-cell developer headcount the MegaTask analyzer uses to *warn* (never block)
|
||||
# when a wave puts more same-cell root-subtasks in flight than the cell has devs.
|
||||
# Each delivery cell ships two developers (see the org blueprint in CLAUDE.md);
|
||||
# advisory only, so a coarse constant is sufficient.
|
||||
_CELL_CAPACITY: dict[str, int] = {
|
||||
Team.BACKEND.value: 2,
|
||||
Team.FRONTEND.value: 2,
|
||||
Team.UX_UI.value: 2,
|
||||
}
|
||||
|
||||
# A MegaTask must span at least this many distinct projects — fewer is a
|
||||
# single-repo batch, which is just an ordinary (multi-)task, not a MegaTask.
|
||||
_MIN_MEGATASK_PROJECTS = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadinessTag:
|
||||
@@ -54,6 +70,23 @@ class ReadinessTag:
|
||||
scale: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatchPlacement:
|
||||
"""Where a draft sits inside a MegaTask batch.
|
||||
|
||||
All four are set together by the batch create path and left at their
|
||||
defaults for an ordinary single-draft confirm. ``team_override`` pins the
|
||||
owning team for the whole batch; ``parent_task_id`` is the umbrella (or None
|
||||
for the umbrella itself); ``batch_id`` is the shared batch identity; and
|
||||
``sequence`` is the item's wave index.
|
||||
"""
|
||||
|
||||
parent_task_id: UUID | None = None
|
||||
batch_id: UUID | None = None
|
||||
sequence: int = 0
|
||||
team_override: Team | None = None
|
||||
|
||||
|
||||
class PrompterService:
|
||||
"""Create tasks from confirmed intake drafts.
|
||||
|
||||
@@ -82,6 +115,61 @@ class PrompterService:
|
||||
)
|
||||
return result.scalar_one_or_none() in _BOARD_REVIEW_ROLES
|
||||
|
||||
@staticmethod
|
||||
def _validate_draft_target(
|
||||
project_id: UUID | None,
|
||||
product_id: UUID | None,
|
||||
*,
|
||||
is_umbrella: bool,
|
||||
) -> None:
|
||||
"""A draft targets exactly one of project / product — or neither when it
|
||||
is a MegaTask umbrella (branchless; its root-subtasks carry the projects).
|
||||
"""
|
||||
if project_id is None and product_id is None and not is_umbrella:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"The draft must target a project (single-cell) or a product "
|
||||
"(board-led, multi-cell). Pick one in the confirm step."
|
||||
),
|
||||
field="project_id",
|
||||
)
|
||||
if project_id is not None and product_id is not None:
|
||||
raise ValidationError(
|
||||
message="Set exactly one of project_id or product_id, not both.",
|
||||
field="product_id",
|
||||
)
|
||||
|
||||
async def _resolve_owning_team(
|
||||
self,
|
||||
draft_data: dict[str, Any],
|
||||
*,
|
||||
resolved_product_id: UUID | None,
|
||||
resolved_assigned_to: UUID | None,
|
||||
team_override: Team | None,
|
||||
default_lead: Team,
|
||||
) -> Team:
|
||||
"""Route the owning team for a draft.
|
||||
|
||||
``team_override`` pins the team for a MegaTask batch (umbrella + every
|
||||
root-subtask share one owner). Otherwise: a project target is a
|
||||
single-cell executable owned by the lead cell; a product target is a
|
||||
board-led coordination root whose team follows the start mode (encoded in
|
||||
the assignee) — the "Board review & Start" path assigns a board reviewer,
|
||||
so it stays team=board until approved (else the CEO's Approve & Start
|
||||
gate, which keys on team=board, never appears and the task strands).
|
||||
"Approve & Start" (assignee main-pm) and the post-approval state are
|
||||
team=main_pm.
|
||||
"""
|
||||
if team_override is not None:
|
||||
return team_override
|
||||
if resolved_product_id is None:
|
||||
return self._lead_cell_team(draft_data, default=default_lead)
|
||||
if resolved_assigned_to is not None and await self._assignee_is_board(
|
||||
resolved_assigned_to
|
||||
):
|
||||
return Team.BOARD
|
||||
return Team.MAIN_PM
|
||||
|
||||
async def create_task_from_draft(
|
||||
self,
|
||||
draft_data: dict[str, Any],
|
||||
@@ -89,6 +177,7 @@ class PrompterService:
|
||||
*,
|
||||
status: TaskStatus = TaskStatus.BACKLOG,
|
||||
assigned_to: UUID | None = None,
|
||||
placement: BatchPlacement | None = None,
|
||||
) -> TaskTable:
|
||||
"""Create a Task from a structured draft.
|
||||
|
||||
@@ -102,25 +191,40 @@ class PrompterService:
|
||||
Start", main-pm for "Approve & Start") so the task starts immediately on
|
||||
the chosen review path. An explicit ``assigned_to`` wins over any
|
||||
assignee carried on the draft.
|
||||
|
||||
``placement`` carries the MegaTask batch position (see
|
||||
:class:`BatchPlacement`): a batch umbrella targets neither project nor
|
||||
product (it is branchless), and a root-subtask carries its own project
|
||||
plus the umbrella as parent. The collision descriptors
|
||||
(``intends_to_touch`` / ``adds_migration`` / ``touches_shared``) ride the
|
||||
draft through to the task so the analyzer's surface is persisted.
|
||||
"""
|
||||
place = placement or BatchPlacement()
|
||||
# A draft must carry a title + acceptance criteria — without this a
|
||||
# malformed draft (e.g. an agent's incomplete propose_batch item) hits a
|
||||
# bare KeyError below and surfaces as an opaque 500 instead of a clean,
|
||||
# actionable 400.
|
||||
if not draft_data.get("title"):
|
||||
raise ValidationError(
|
||||
message="This task draft is missing a title.", field="title"
|
||||
)
|
||||
if not draft_data.get("acceptance_criteria"):
|
||||
raise ValidationError(
|
||||
message="This task draft is missing acceptance criteria.",
|
||||
field="acceptance_criteria",
|
||||
)
|
||||
# Recompose the description from the (possibly edited) structured fields —
|
||||
# the task always carries a freshly-composed, consistent description.
|
||||
draft_data["description"] = compose_description(draft_data)
|
||||
|
||||
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
|
||||
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
|
||||
if resolved_project_id is None and resolved_product_id is None:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"The draft must target a project (single-cell) or a product "
|
||||
"(board-led, multi-cell). Pick one in the confirm step."
|
||||
self._validate_draft_target(
|
||||
resolved_project_id,
|
||||
resolved_product_id,
|
||||
is_umbrella=is_batch_umbrella(
|
||||
batch_id=place.batch_id, parent_task_id=place.parent_task_id
|
||||
),
|
||||
field="project_id",
|
||||
)
|
||||
if resolved_project_id is not None and resolved_product_id is not None:
|
||||
raise ValidationError(
|
||||
message="Set exactly one of project_id or product_id, not both.",
|
||||
field="product_id",
|
||||
)
|
||||
|
||||
_lead, task_type, nature, complexity = self._coerce_draft_enums(draft_data)
|
||||
@@ -133,21 +237,13 @@ class PrompterService:
|
||||
with contextlib.suppress(ValueError):
|
||||
resolved_assigned_to = UUID(str(draft_data["assigned_to"]))
|
||||
|
||||
# Adaptive routing. A project target is a single-cell executable task
|
||||
# owned by the lead cell. A product target is a board-led coordination
|
||||
# root whose team follows the start mode (encoded in the assignee): the
|
||||
# "Board review & Start" path assigns a board reviewer, so it must stay
|
||||
# team=board until approved — otherwise the CEO's Approve & Start gate,
|
||||
# which keys on team=board, never appears and the task strands. "Approve
|
||||
# & Start" (assignee main-pm) and the post-approval state are team=main_pm.
|
||||
if resolved_product_id is None:
|
||||
team = self._lead_cell_team(draft_data, default=_lead)
|
||||
elif resolved_assigned_to is not None and await self._assignee_is_board(
|
||||
resolved_assigned_to
|
||||
):
|
||||
team = Team.BOARD
|
||||
else:
|
||||
team = Team.MAIN_PM
|
||||
team = await self._resolve_owning_team(
|
||||
draft_data,
|
||||
resolved_product_id=resolved_product_id,
|
||||
resolved_assigned_to=resolved_assigned_to,
|
||||
team_override=place.team_override,
|
||||
default_lead=_lead,
|
||||
)
|
||||
|
||||
req = TaskCreateRequest(
|
||||
title=draft_data["title"],
|
||||
@@ -163,6 +259,12 @@ class PrompterService:
|
||||
project_id=resolved_project_id,
|
||||
product_id=resolved_product_id,
|
||||
status=status,
|
||||
parent_task_id=place.parent_task_id,
|
||||
batch_id=place.batch_id,
|
||||
sequence=place.sequence,
|
||||
intends_to_touch=_clean_list(draft_data.get("intends_to_touch")) or None,
|
||||
adds_migration=bool(draft_data.get("adds_migration")),
|
||||
touches_shared=bool(draft_data.get("touches_shared")),
|
||||
source="prompter",
|
||||
confirmed_by_human=True,
|
||||
)
|
||||
@@ -224,6 +326,194 @@ class PrompterService:
|
||||
)
|
||||
return UUID(str(task.id))
|
||||
|
||||
def _sequence_drafts(self, drafts: list[dict[str, Any]]) -> SequencePlan:
|
||||
"""Build each draft's collision surface and sequence them into waves.
|
||||
|
||||
Pure (no DB, no side effects). The single source of the wave plan, shared
|
||||
by ``preview_batch`` (panel pre-confirm preview) and ``confirm_live_batch``
|
||||
(create), so the previewed waves are exactly the ones that get wired.
|
||||
"""
|
||||
from roboco.foundation.policy.sequencing.models import SequencingError
|
||||
from roboco.services.sequencing import SequencingService
|
||||
|
||||
surfaces = [
|
||||
DraftSurface(
|
||||
idx=idx,
|
||||
priority=self._coerce_priority(d.get("priority")),
|
||||
intends_to_touch=_clean_list(d.get("intends_to_touch")),
|
||||
adds_migration=bool(d.get("adds_migration")),
|
||||
touches_shared=bool(d.get("touches_shared")),
|
||||
project_id=str(d["project_id"]) if d.get("project_id") else None,
|
||||
)
|
||||
for idx, d in enumerate(drafts)
|
||||
]
|
||||
cell_by_idx = {
|
||||
idx: self._lead_cell_team(d, default=Team.BACKEND).value
|
||||
for idx, d in enumerate(drafts)
|
||||
}
|
||||
try:
|
||||
return SequencingService().analyze(
|
||||
surfaces, lambda i: cell_by_idx[i], _CELL_CAPACITY
|
||||
)
|
||||
except SequencingError as exc:
|
||||
# A cyclic / malformed collision graph is a user-actionable input
|
||||
# problem, not a server fault — surface it as a clean 400.
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"These tasks can't be sequenced into conflict-free waves: "
|
||||
f"{exc}. Adjust what they touch and try again."
|
||||
),
|
||||
field="drafts",
|
||||
) from exc
|
||||
|
||||
def preview_batch(self, drafts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Compute a MegaTask's waves + warnings WITHOUT creating anything.
|
||||
|
||||
The panel calls this once the agent proposes a batch so the human can
|
||||
review the sequencing before confirming. ``waves`` is a list of waves,
|
||||
each a list of draft indices that run together.
|
||||
"""
|
||||
if not drafts:
|
||||
raise ValidationError(
|
||||
message="A MegaTask needs at least one task draft.", field="drafts"
|
||||
)
|
||||
plan = self._sequence_drafts(drafts)
|
||||
return {"waves": plan.waves, "warnings": plan.warnings}
|
||||
|
||||
@staticmethod
|
||||
def _validate_batch_scope(
|
||||
drafts: list[dict[str, Any]], project_ids: list[UUID]
|
||||
) -> None:
|
||||
"""A MegaTask's drafts must each target one of the scoped repos (the only
|
||||
ones the intake agent read), and collectively span at least two distinct
|
||||
projects — otherwise it's a single-repo batch, not a MegaTask.
|
||||
"""
|
||||
scope = {str(p) for p in project_ids}
|
||||
seen: set[str] = set()
|
||||
for idx, draft in enumerate(drafts):
|
||||
pid = draft.get("project_id")
|
||||
if not pid or str(pid) not in scope:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Task {idx + 1} targets a project outside this MegaTask's "
|
||||
"selected repos. Point it at one of the scoped projects."
|
||||
),
|
||||
field="drafts",
|
||||
)
|
||||
seen.add(str(pid))
|
||||
if len(seen) < _MIN_MEGATASK_PROJECTS:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
"A MegaTask must span at least two distinct projects — use a "
|
||||
"single-project task for work in one repo."
|
||||
),
|
||||
field="drafts",
|
||||
)
|
||||
|
||||
async def confirm_live_batch(
|
||||
self,
|
||||
title: str,
|
||||
drafts: list[dict[str, Any]],
|
||||
agent_id: UUID,
|
||||
*,
|
||||
project_ids: list[UUID],
|
||||
route: Literal["board", "main_pm"] = "board",
|
||||
) -> dict[str, Any]:
|
||||
"""Confirm a MegaTask: create the umbrella + N sequenced root-subtasks.
|
||||
|
||||
Each draft carries its own ``project_id`` (one of the scoped ``project_ids``
|
||||
the intake agent read) and a collision surface (``intends_to_touch`` /
|
||||
``adds_migration`` / ``touches_shared``). The pure :class:`SequencingService`
|
||||
turns those surfaces into conflict-free waves; the umbrella (branchless,
|
||||
batch-owning coordination root) groups the root-subtasks, and the existing
|
||||
dependency-gate executes the waves — each root-subtask keeping its own
|
||||
project / branch / PR.
|
||||
|
||||
``route`` picks the start path exactly like a single draft. ``"board"``
|
||||
sends the umbrella to the Board (PO + HoM) for one batch review, with the
|
||||
root-subtasks held in ``BACKLOG`` until the umbrella is approved.
|
||||
``"main_pm"`` hands the umbrella straight to the Main PM and creates the
|
||||
root-subtasks ``PENDING`` so the dependency-gate dispatches wave 0 at once.
|
||||
|
||||
Returns ``{umbrella_task_id, root_subtask_ids, waves, warnings}`` for the
|
||||
panel's wave/DAG review.
|
||||
"""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
if not drafts:
|
||||
raise ValidationError(
|
||||
message="A MegaTask needs at least one task draft.", field="drafts"
|
||||
)
|
||||
self._validate_batch_scope(drafts, project_ids)
|
||||
|
||||
batch_id = uuid4()
|
||||
# 1. Sequence the drafts into conflict-free waves (same plan the panel
|
||||
# previewed via preview_batch — _sequence_drafts is the single source).
|
||||
plan = self._sequence_drafts(drafts)
|
||||
wave_of = {idx: w for w, wave in enumerate(plan.waves) for idx in wave}
|
||||
|
||||
# 2. Route → owning team + umbrella assignee + held status for the items.
|
||||
# "board" holds the root-subtasks until the batch review approves the
|
||||
# umbrella; "main_pm" lets the dependency-gate dispatch wave 0 at once.
|
||||
if route == "main_pm":
|
||||
owning_team = Team.MAIN_PM
|
||||
umbrella_assignee = UUID(AGENT_UUIDS["main-pm"])
|
||||
subtask_status = TaskStatus.PENDING
|
||||
else:
|
||||
owning_team = Team.BOARD
|
||||
umbrella_assignee = UUID(AGENT_UUIDS["product-owner"])
|
||||
subtask_status = TaskStatus.BACKLOG
|
||||
|
||||
# 3. The umbrella: a branchless coordination root that owns the batch and
|
||||
# is the single board-review / CEO-approve / Main-PM-coordinate unit.
|
||||
umbrella = await self.create_task_from_draft(
|
||||
_compose_umbrella_draft(title, drafts, plan),
|
||||
agent_id,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=umbrella_assignee,
|
||||
placement=BatchPlacement(batch_id=batch_id, team_override=owning_team),
|
||||
)
|
||||
umbrella_id = UUID(str(umbrella.id))
|
||||
|
||||
# 4. The root-subtasks: each carries its own project / branch / PR, with
|
||||
# sequence = its wave index and the umbrella as parent.
|
||||
task_of: dict[int, UUID] = {}
|
||||
for idx, draft in enumerate(drafts):
|
||||
sub = await self.create_task_from_draft(
|
||||
dict(draft),
|
||||
agent_id,
|
||||
status=subtask_status,
|
||||
placement=BatchPlacement(
|
||||
parent_task_id=umbrella_id,
|
||||
batch_id=batch_id,
|
||||
sequence=wave_of[idx],
|
||||
team_override=owning_team,
|
||||
),
|
||||
)
|
||||
task_of[idx] = UUID(str(sub.id))
|
||||
|
||||
# 5. Wire the dependency edges. An edge ``(a, b)`` means *b waits on a*,
|
||||
# so b depends on a — the dependency-gate then releases each wave only
|
||||
# once the prior wave's items reach a terminal state.
|
||||
task_service = get_task_service(self._session)
|
||||
for a, b in plan.edges:
|
||||
await task_service.add_dependency(task_of[b], task_of[a])
|
||||
|
||||
self.log.info(
|
||||
"MegaTask batch confirmed",
|
||||
umbrella_task_id=str(umbrella_id),
|
||||
items=len(drafts),
|
||||
waves=len(plan.waves),
|
||||
route=route,
|
||||
)
|
||||
return {
|
||||
"umbrella_task_id": str(umbrella_id),
|
||||
"root_subtask_ids": [str(task_of[i]) for i in range(len(drafts))],
|
||||
"waves": plan.waves,
|
||||
"warnings": plan.warnings,
|
||||
}
|
||||
|
||||
async def update_live_draft(
|
||||
self,
|
||||
task_id: UUID,
|
||||
@@ -549,6 +839,45 @@ def compose_description(draft: dict[str, Any]) -> str:
|
||||
return _text(draft.get("description")) or composed
|
||||
|
||||
|
||||
def _compose_umbrella_draft(
|
||||
title: str, drafts: list[dict[str, Any]], plan: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Build the umbrella's draft from the batch + its computed wave plan.
|
||||
|
||||
The umbrella targets neither project nor product (it is branchless) and
|
||||
carries no collision surface of its own — it exists to group the batch, hold
|
||||
the wave plan in its description for review, and be the one board-review /
|
||||
CEO-approve / Main-PM-coordinate unit. ``task_type=code`` mirrors the existing
|
||||
product coordination roots created from intake (the branch/PR exemption comes
|
||||
from the batch identity, not the type).
|
||||
"""
|
||||
item_titles = [
|
||||
_text(d.get("title")) or f"Task {i + 1}" for i, d in enumerate(drafts)
|
||||
]
|
||||
wave_lines = [
|
||||
f"Wave {w + 1}: " + ", ".join(item_titles[i] for i in wave)
|
||||
for w, wave in enumerate(plan.waves)
|
||||
]
|
||||
return {
|
||||
"title": f"MegaTask: {title}",
|
||||
"objective": (
|
||||
f"Coordinate {len(drafts)} sequenced tasks as one MegaTask. The Board "
|
||||
"reviews the batch once; the Main PM coordinates the root-subtasks, "
|
||||
"which the dependency-gate dispatches in collision-free waves, each "
|
||||
"keeping its own pull request."
|
||||
),
|
||||
"what_this_builds": item_titles,
|
||||
"notes": [*wave_lines, *plan.warnings],
|
||||
"acceptance_criteria": [
|
||||
"Every root-subtask in the MegaTask is completed and merged.",
|
||||
],
|
||||
"task_type": TaskType.CODE.value,
|
||||
"nature": TaskNature.TECHNICAL.value,
|
||||
"estimated_complexity": Complexity.HIGH.value,
|
||||
"priority": 1,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Deterministic collision-sequencing analyzer for sequenced batch intake.
|
||||
|
||||
Turns a batch's per-task collision surfaces into a dependency DAG + execution
|
||||
waves. Correctness lives in CODE, not agent judgment: the analyzer *guarantees*
|
||||
the safety serializations (file overlap, migration chain, shared-last) so a weak
|
||||
Prompter can only ever over-serialize, never miss a declared collision. Pure —
|
||||
no DB, no services; consumed by the batch-create path.
|
||||
|
||||
Rules (evaluated in order; an edge ``(a, b)`` means *b depends on a*, a first):
|
||||
1. File overlap — overlapping surfaces serialize, more-important (lower
|
||||
``(priority, idx)``) first. Mixed shared/non-shared pairs
|
||||
are left to rule 3 so a high-priority shared task can't
|
||||
invert shared-last into a cycle.
|
||||
2. Migration chain — all ``adds_migration`` drafts run serially, ordered by
|
||||
``(priority, idx)`` (Alembic cannot have concurrent heads).
|
||||
3. Shared-last — every non-shared draft that overlaps a ``touches_shared``
|
||||
draft runs before it.
|
||||
4. Cell contention — when a wave puts more drafts on a cell than its capacity,
|
||||
warn (never serialize — that is the orchestrator's job).
|
||||
|
||||
Then: dedupe edges, existence + cycle check, Kahn topological layering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from fnmatch import fnmatch
|
||||
from itertools import pairwise
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.foundation.policy.sequencing.models import (
|
||||
DraftSurface,
|
||||
SequencePlan,
|
||||
SequencingError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class SequencingService:
|
||||
"""Pure analyzer: ``analyze`` is the only public entry point."""
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
surfaces: list[DraftSurface],
|
||||
cell_of: Callable[[int], str],
|
||||
cell_capacity: dict[str, int],
|
||||
) -> SequencePlan:
|
||||
"""Compute the dependency edges + execution waves for a batch."""
|
||||
edges = self._dedupe(
|
||||
self._file_overlap_edges(surfaces)
|
||||
+ self._migration_chain_edges(surfaces)
|
||||
+ self._shared_last_edges(surfaces)
|
||||
)
|
||||
waves = self._toposort(edges, len(surfaces))
|
||||
warnings = self._contention_warnings(waves, cell_of, cell_capacity)
|
||||
return SequencePlan(edges=edges, waves=waves, warnings=warnings)
|
||||
|
||||
# --- rule 1: file overlap ------------------------------------------------
|
||||
def _file_overlap_edges(
|
||||
self, surfaces: list[DraftSurface]
|
||||
) -> list[tuple[int, int]]:
|
||||
edges: list[tuple[int, int]] = []
|
||||
for i, a in enumerate(surfaces):
|
||||
for b in surfaces[i + 1 :]:
|
||||
# Different repos can't share a working-tree path — no collision.
|
||||
if a.project_id != b.project_id:
|
||||
continue
|
||||
# Mixed shared/non-shared overlaps are owned by rule 3.
|
||||
if a.touches_shared != b.touches_shared:
|
||||
continue
|
||||
if self._globs_overlap(a.intends_to_touch, b.intends_to_touch):
|
||||
edges.append(self._order_edge(a, b))
|
||||
return edges
|
||||
|
||||
# --- rule 2: migration chain ---------------------------------------------
|
||||
@staticmethod
|
||||
def _migration_chain_edges(
|
||||
surfaces: list[DraftSurface],
|
||||
) -> list[tuple[int, int]]:
|
||||
# Migrations serialize per project (each repo has its own alembic chain);
|
||||
# two repos' migrations are independent. Within a project, non-shared
|
||||
# migrations chain BEFORE shared ones (the ``touches_shared`` sort key) so
|
||||
# this never contradicts rule 3's shared-last ordering into a cycle.
|
||||
by_project: dict[object, list[DraftSurface]] = defaultdict(list)
|
||||
for s in surfaces:
|
||||
if s.adds_migration:
|
||||
by_project[s.project_id].append(s)
|
||||
edges: list[tuple[int, int]] = []
|
||||
for group in by_project.values():
|
||||
migs = sorted(group, key=lambda s: (s.touches_shared, s.priority, s.idx))
|
||||
edges.extend((prev.idx, cur.idx) for prev, cur in pairwise(migs))
|
||||
return edges
|
||||
|
||||
# --- rule 3: shared-last -------------------------------------------------
|
||||
def _shared_last_edges(self, surfaces: list[DraftSurface]) -> list[tuple[int, int]]:
|
||||
edges: list[tuple[int, int]] = []
|
||||
for s in surfaces:
|
||||
if not s.touches_shared:
|
||||
continue
|
||||
for other in surfaces:
|
||||
if other.idx == s.idx or other.touches_shared:
|
||||
continue
|
||||
# A shared edit only runs after a NON-shared task in the SAME repo.
|
||||
if other.project_id != s.project_id:
|
||||
continue
|
||||
if self._globs_overlap(other.intends_to_touch, s.intends_to_touch):
|
||||
edges.append((other.idx, s.idx))
|
||||
return edges
|
||||
|
||||
# --- rule 4: cell contention (warnings only) -----------------------------
|
||||
@staticmethod
|
||||
def _contention_warnings(
|
||||
waves: list[list[int]],
|
||||
cell_of: Callable[[int], str],
|
||||
cell_capacity: dict[str, int],
|
||||
) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
for wave_no, wave in enumerate(waves):
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for idx in wave:
|
||||
counts[cell_of(idx)] += 1
|
||||
for cell, n in sorted(counts.items()):
|
||||
cap = cell_capacity.get(cell)
|
||||
if cap is not None and n > cap:
|
||||
warnings.append(
|
||||
f"wave {wave_no}: {n} tasks target {cell} (capacity {cap})"
|
||||
)
|
||||
return warnings
|
||||
|
||||
# --- topological layering (existence + cycle check) ----------------------
|
||||
def _toposort(self, edges: list[tuple[int, int]], n: int) -> list[list[int]]:
|
||||
"""Layer the DAG into waves; raise on an out-of-range edge or a cycle."""
|
||||
self._check_edges_in_range(edges, n)
|
||||
indeg, adj = self._build_graph(edges, n)
|
||||
return self._kahn_layers(indeg, adj, n)
|
||||
|
||||
@staticmethod
|
||||
def _check_edges_in_range(edges: list[tuple[int, int]], n: int) -> None:
|
||||
for a, b in edges:
|
||||
if not (0 <= a < n and 0 <= b < n):
|
||||
raise SequencingError(
|
||||
f"edge ({a}, {b}) references a draft outside 0..{n - 1}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_graph(
|
||||
edges: list[tuple[int, int]], n: int
|
||||
) -> tuple[list[int], dict[int, list[int]]]:
|
||||
indeg = [0] * n
|
||||
adj: dict[int, list[int]] = defaultdict(list)
|
||||
for a, b in edges:
|
||||
adj[a].append(b)
|
||||
indeg[b] += 1
|
||||
return indeg, adj
|
||||
|
||||
def _kahn_layers(
|
||||
self, indeg: list[int], adj: dict[int, list[int]], n: int
|
||||
) -> list[list[int]]:
|
||||
remaining = set(range(n))
|
||||
waves: list[list[int]] = []
|
||||
while remaining:
|
||||
ready = sorted(i for i in remaining if indeg[i] == 0)
|
||||
if not ready:
|
||||
raise SequencingError("collision graph has a cycle")
|
||||
waves.append(ready)
|
||||
remaining -= set(ready)
|
||||
self._relax(indeg, adj, ready)
|
||||
return waves
|
||||
|
||||
@staticmethod
|
||||
def _relax(indeg: list[int], adj: dict[int, list[int]], ready: list[int]) -> None:
|
||||
for i in ready:
|
||||
for nbr in adj[i]:
|
||||
indeg[nbr] -= 1
|
||||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _order_edge(a: DraftSurface, b: DraftSurface) -> tuple[int, int]:
|
||||
"""Edge from the more-important draft (lower ``(priority, idx)``) first."""
|
||||
first, second = (a, b) if (a.priority, a.idx) <= (b.priority, b.idx) else (b, a)
|
||||
return (first.idx, second.idx)
|
||||
|
||||
@staticmethod
|
||||
def _globs_overlap(a: list[str], b: list[str]) -> bool:
|
||||
"""True if any path in ``a`` overlaps any in ``b`` (equality, fnmatch in
|
||||
either direction, or directory-prefix containment)."""
|
||||
for pa in a:
|
||||
for pb in b:
|
||||
if pa == pb or fnmatch(pa, pb) or fnmatch(pb, pa):
|
||||
return True
|
||||
if pa.startswith(pb.rstrip("/") + "/") or pb.startswith(
|
||||
pa.rstrip("/") + "/"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _dedupe(edges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
seen: set[tuple[int, int]] = set()
|
||||
out: list[tuple[int, int]] = []
|
||||
for edge in edges:
|
||||
if edge not in seen:
|
||||
seen.add(edge)
|
||||
out.append(edge)
|
||||
return out
|
||||
+162
-19
@@ -32,6 +32,11 @@ from roboco.enforcement import (
|
||||
validate_task_transition,
|
||||
)
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
from roboco.foundation.policy.batch import (
|
||||
is_batch_umbrella,
|
||||
is_branchless_coordination,
|
||||
is_valid_batch_shape,
|
||||
)
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.content.validators import ContentValidationError
|
||||
from roboco.models.base import (
|
||||
@@ -449,14 +454,25 @@ class TaskService(BaseService):
|
||||
pr_created=bool(task.pr_created),
|
||||
pr_number=task.pr_number,
|
||||
branch_name=str(task.branch_name) if task.branch_name else None,
|
||||
# A coordination/fan-out task (product, no repo of its own) does no
|
||||
# git and never gets a branch — exempt it from the branch gate so it
|
||||
# can reach in_progress and delegate. product_id is a plain column
|
||||
# (no lazy load).
|
||||
is_coordination=(task.project_id is None and task.product_id is not None),
|
||||
# A branchless coordination task does no git and never gets a branch
|
||||
# — exempt it from the branch gate so it can reach in_progress and
|
||||
# delegate. Two shapes qualify: a product fan-out root (product, no
|
||||
# repo) and a MegaTask umbrella (batch_id, top-level). All four are
|
||||
# plain columns (no lazy load).
|
||||
is_coordination=is_branchless_coordination(
|
||||
project_id=task.project_id,
|
||||
product_id=task.product_id,
|
||||
batch_id=task.batch_id,
|
||||
parent_task_id=task.parent_task_id,
|
||||
),
|
||||
# An external-PR review task reviews someone else's PR read-only —
|
||||
# no branch of its own — so it is branch-gate exempt.
|
||||
is_external_review=(getattr(task, "source", "manual") in PR_REVIEW_SOURCES),
|
||||
# A MegaTask umbrella assembles no PR of its own, so it is exempt
|
||||
# from the awaiting_pm_review->awaiting_ceo_approval pr_number gate.
|
||||
is_umbrella=is_batch_umbrella(
|
||||
batch_id=task.batch_id, parent_task_id=task.parent_task_id
|
||||
),
|
||||
)
|
||||
validate_git_requirements(current, target, git_ctx)
|
||||
|
||||
@@ -623,6 +639,59 @@ class TaskService(BaseService):
|
||||
parent_parent = parent.parent_task_id
|
||||
current_id = UUID(str(parent_parent)) if parent_parent else None
|
||||
|
||||
@staticmethod
|
||||
def _require_target_or_umbrella(req: TaskCreateRequest) -> None:
|
||||
"""Service-layer invariant (covers every create path — API, a2a, gateway).
|
||||
|
||||
A task targets a single repo (``project_id``) or fans out across cells via
|
||||
a product (``product_id``) — it must have one or the other, EXCEPT a
|
||||
MegaTask umbrella, which targets neither (it groups N root-subtasks that
|
||||
each carry their own project) and is branchless.
|
||||
"""
|
||||
if req.project_id is not None or req.product_id is not None:
|
||||
return
|
||||
if is_batch_umbrella(batch_id=req.batch_id, parent_task_id=req.parent_task_id):
|
||||
return
|
||||
raise ValueError(
|
||||
"task needs a project_id (the repo it targets) or a product_id "
|
||||
"(a cell->project map for a fan-out task)"
|
||||
)
|
||||
|
||||
async def _validate_batch_membership(self, req: TaskCreateRequest) -> None:
|
||||
"""Guardrail: a ``batch_id`` is only valid on a well-formed MegaTask member.
|
||||
|
||||
``is_valid_batch_shape`` checks the structural shape; for a root-subtask we
|
||||
ALSO verify its parent is the batch umbrella (same ``batch_id``, top-level).
|
||||
Together they deny a stray ``batch_id`` on a normal task — which would
|
||||
otherwise spoof the umbrella's branch-gate / no-PR exemption or mislabel
|
||||
the task as part of a batch. No-op when ``batch_id`` is unset.
|
||||
"""
|
||||
if req.batch_id is None:
|
||||
return
|
||||
if not is_valid_batch_shape(
|
||||
batch_id=req.batch_id,
|
||||
parent_task_id=req.parent_task_id,
|
||||
project_id=req.project_id,
|
||||
product_id=req.product_id,
|
||||
):
|
||||
raise ValueError(
|
||||
"batch_id is only valid on a MegaTask umbrella (targets neither "
|
||||
"project nor product) or a root-subtask (targets exactly one); "
|
||||
"refusing a stray batch_id on any other task."
|
||||
)
|
||||
if req.parent_task_id is None:
|
||||
return # a well-formed umbrella
|
||||
parent = await self.get(req.parent_task_id)
|
||||
if (
|
||||
parent is None
|
||||
or parent.batch_id != req.batch_id
|
||||
or parent.parent_task_id is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"a MegaTask root-subtask's parent must be the batch umbrella "
|
||||
"(same batch_id, top-level)."
|
||||
)
|
||||
|
||||
async def create(self, req: TaskCreateRequest) -> TaskTable:
|
||||
"""
|
||||
Create a new task.
|
||||
@@ -630,14 +699,8 @@ class TaskService(BaseService):
|
||||
Default status is PENDING. PM can pass status=BACKLOG when creating
|
||||
subtasks that need session setup before activation.
|
||||
"""
|
||||
# Service-layer invariant (covers every create path — API, a2a,
|
||||
# gateway): a task targets a single repo (project_id) or fans out across
|
||||
# cells via a product (product_id). It must have one or the other.
|
||||
if req.project_id is None and req.product_id is None:
|
||||
raise ValueError(
|
||||
"task needs a project_id (the repo it targets) or a product_id "
|
||||
"(a cell->project map for a fan-out task)"
|
||||
)
|
||||
self._require_target_or_umbrella(req)
|
||||
await self._validate_batch_membership(req)
|
||||
|
||||
if req.parent_task_id:
|
||||
await self._validate_parent_depth(req.parent_task_id)
|
||||
@@ -664,6 +727,11 @@ 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
|
||||
# Sequenced batch intake collision surface
|
||||
batch_id=req.batch_id,
|
||||
intends_to_touch=req.intends_to_touch,
|
||||
adds_migration=req.adds_migration,
|
||||
touches_shared=req.touches_shared,
|
||||
# Git configuration (all tasks follow git workflow)
|
||||
task_type=req.task_type,
|
||||
project_id=req.project_id,
|
||||
@@ -1288,7 +1356,10 @@ class TaskService(BaseService):
|
||||
- If branch exists: return it
|
||||
- Coordination/fan-out task (carries a product, no repo of its own): no
|
||||
branch — it does no git work
|
||||
- If neither project nor product: raise (genuinely misconfigured)
|
||||
- MegaTask umbrella (batch_id, top-level): branchless by design (spans
|
||||
many projects, assembles no PR) — return "" (its root-subtasks branch)
|
||||
- If neither project nor product nor umbrella: raise (genuinely
|
||||
misconfigured)
|
||||
- Create NEW branch (hierarchical name built by build_branch_name)
|
||||
- Branch created from parent's branch (or default if root)
|
||||
|
||||
@@ -1307,6 +1378,14 @@ class TaskService(BaseService):
|
||||
# Only a task with neither project nor product is misconfigured.
|
||||
if task.product_id:
|
||||
return await self._ensure_coordination_root_branches(task, agent_id)
|
||||
# A MegaTask umbrella is branchless by design: it spans many projects
|
||||
# (no single master to branch off) and assembles no PR of its own —
|
||||
# each root-subtask carries its own project/branch/PR. Return "" so
|
||||
# the claim path treats it as branchless rather than misconfigured.
|
||||
if is_batch_umbrella(
|
||||
batch_id=task.batch_id, parent_task_id=task.parent_task_id
|
||||
):
|
||||
return ""
|
||||
raise ValueError(
|
||||
"Task requires a project_id (a repo) or a product_id (a "
|
||||
"cell->project map) to create a branch. Assign one before "
|
||||
@@ -1555,6 +1634,32 @@ class TaskService(BaseService):
|
||||
apply_structured_note(task, content_type, payload)
|
||||
await self.session.flush()
|
||||
|
||||
@staticmethod
|
||||
def assert_batch_shape_intact(task: TaskTable) -> None:
|
||||
"""A mutation must not break a task's MegaTask shape.
|
||||
|
||||
The branchless predicates (is_batch_umbrella / is_branchless_coordination)
|
||||
trust the create-time ``is_valid_batch_shape`` invariant — so any update
|
||||
path that can change ``parent_task_id`` / ``project_id`` / ``product_id``
|
||||
must re-check it, else a PATCH could turn a root-subtask into an
|
||||
umbrella-shaped-but-targeted task and spoof the branch-gate / no-PR
|
||||
exemption. No-op for a non-batch task (``batch_id`` None/absent) — uses
|
||||
``getattr`` so a partial-caller stub without the column is tolerated.
|
||||
"""
|
||||
if getattr(task, "batch_id", None) is None:
|
||||
return
|
||||
if not is_valid_batch_shape(
|
||||
batch_id=task.batch_id,
|
||||
parent_task_id=getattr(task, "parent_task_id", None),
|
||||
project_id=getattr(task, "project_id", None),
|
||||
product_id=getattr(task, "product_id", None),
|
||||
):
|
||||
raise ValueError(
|
||||
"this update would break the task's MegaTask shape: a batch "
|
||||
"member must stay an umbrella (targets neither project nor "
|
||||
"product) or a root-subtask (exactly one target, parented)."
|
||||
)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
task_id: UUID,
|
||||
@@ -1569,6 +1674,7 @@ class TaskService(BaseService):
|
||||
if hasattr(task, key) and value is not None:
|
||||
setattr(task, key, value)
|
||||
|
||||
self.assert_batch_shape_intact(task)
|
||||
await self.session.flush()
|
||||
|
||||
self.log.info(
|
||||
@@ -4354,8 +4460,13 @@ class TaskService(BaseService):
|
||||
)
|
||||
return None
|
||||
|
||||
# ENFORCEMENT: Tasks must have PR created before CEO approval
|
||||
if not task.pr_number:
|
||||
# ENFORCEMENT: Tasks must have a PR before CEO approval — EXCEPT a
|
||||
# MegaTask umbrella, which is branchless by design (assembles no PR of
|
||||
# its own; each root-subtask carries its own project/branch/PR). It
|
||||
# escalates to the CEO with no pr_number once every root-subtask is done.
|
||||
if not task.pr_number and not is_batch_umbrella(
|
||||
batch_id=task.batch_id, parent_task_id=task.parent_task_id
|
||||
):
|
||||
self.log.warning(
|
||||
"Cannot escalate to CEO - task has no PR",
|
||||
task_id=str(task_id),
|
||||
@@ -4523,6 +4634,10 @@ class TaskService(BaseService):
|
||||
markers.set_approve_and_start_notes(task, notes)
|
||||
|
||||
await self.session.flush()
|
||||
# A MegaTask umbrella holds its root-subtasks in BACKLOG until this gate
|
||||
# (the board reviews the batch first). Now that the CEO approved it,
|
||||
# release them so the dependency-gate dispatches wave 0.
|
||||
await self._activate_batch_root_subtasks(task)
|
||||
await self._emit_task_event(
|
||||
EventType.TASK_STARTED,
|
||||
task_id,
|
||||
@@ -4535,6 +4650,29 @@ class TaskService(BaseService):
|
||||
)
|
||||
return task
|
||||
|
||||
async def _activate_batch_root_subtasks(self, umbrella: TaskTable) -> None:
|
||||
"""Release a MegaTask umbrella's held root-subtasks on CEO approval.
|
||||
|
||||
No-op unless ``umbrella`` is a batch umbrella. The board route creates the
|
||||
root-subtasks in BACKLOG (team=board) so the work waits for the batch
|
||||
review; once the CEO approves the umbrella to the Main PM, flip each held
|
||||
child to PENDING + team=main_pm. The dependency-gate then dispatches the
|
||||
wave-0 items and holds later waves until their predecessors are terminal.
|
||||
Idempotent: a child already past BACKLOG is left untouched.
|
||||
"""
|
||||
if not is_batch_umbrella(
|
||||
batch_id=umbrella.batch_id, parent_task_id=umbrella.parent_task_id
|
||||
):
|
||||
return
|
||||
released = False
|
||||
for child in await self.get_subtasks(cast("UUID", umbrella.id)):
|
||||
if child.batch_id is not None and child.status == TaskStatus.BACKLOG:
|
||||
child.status = TaskStatus.PENDING
|
||||
child.team = cast("Any", Team.MAIN_PM)
|
||||
released = True
|
||||
if released:
|
||||
await self.session.flush()
|
||||
|
||||
async def mark_board_review_complete(self, task_id: UUID) -> bool:
|
||||
"""Flag a board task as board-reviewed without moving it off pending.
|
||||
|
||||
@@ -4627,12 +4765,17 @@ class TaskService(BaseService):
|
||||
# Store the CEO's rejection reason as a marker, not quick_context soup.
|
||||
markers.set_transition_note(task, "ceo_rejection", reason)
|
||||
|
||||
# A coordination/integration root (no repo of its own, carries a
|
||||
# product) has no developer to revise it — NEEDS_REVISION is
|
||||
# A branchless coordination root (a product integration root or a
|
||||
# MegaTask umbrella) has no developer to revise it — NEEDS_REVISION is
|
||||
# developer-claim-only, so it would deadlock the Main PM that owns the
|
||||
# root. Such a root is routed to PENDING below instead; every other task
|
||||
# takes the normal NEEDS_REVISION path back toward its developer.
|
||||
is_coordination_root = task.project_id is None and task.product_id is not None
|
||||
is_coordination_root = is_branchless_coordination(
|
||||
project_id=task.project_id,
|
||||
product_id=task.product_id,
|
||||
batch_id=task.batch_id,
|
||||
parent_task_id=task.parent_task_id,
|
||||
)
|
||||
if not is_coordination_root:
|
||||
self._validate_and_set_status(task, TaskStatus.NEEDS_REVISION, "ceo")
|
||||
|
||||
|
||||
@@ -148,3 +148,63 @@ async def test_succeeds_when_board_review_complete(start_setup: dict) -> None:
|
||||
assert out is not None
|
||||
assert out.assigned_to == start_setup["main_pm"].id
|
||||
assert out.status == TaskStatus.PENDING
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_megatask_umbrella_releases_backlog_root_subtasks(
|
||||
start_setup: dict,
|
||||
) -> None:
|
||||
"""Approving a board-route MegaTask umbrella releases its held root-subtasks:
|
||||
BACKLOG → PENDING and team → main_pm, so the dependency-gate dispatches them."""
|
||||
db = start_setup["db"]
|
||||
po = start_setup["po"]
|
||||
project_id = start_setup["mk"]().project_id # reuse the fixture's project
|
||||
batch_id = uuid4()
|
||||
umbrella = TaskTable(
|
||||
id=uuid4(),
|
||||
title="MegaTask: three things",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=1,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=None, # branchless umbrella: no project, no product
|
||||
product_id=None,
|
||||
batch_id=batch_id,
|
||||
parent_task_id=None,
|
||||
created_by=po.id,
|
||||
team=Team.BOARD,
|
||||
board_review_complete=True,
|
||||
assigned_to=po.id,
|
||||
)
|
||||
db.add(umbrella)
|
||||
await db.flush()
|
||||
subs = [
|
||||
TaskTable(
|
||||
id=uuid4(),
|
||||
title=f"Root-subtask {i}",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
status=TaskStatus.BACKLOG, # held until the batch review approves
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
parent_task_id=umbrella.id,
|
||||
created_by=po.id,
|
||||
team=Team.BOARD,
|
||||
)
|
||||
for i in range(2)
|
||||
]
|
||||
db.add_all(subs)
|
||||
await db.flush()
|
||||
|
||||
out = await start_setup["svc"].approve_and_start(umbrella.id, "x" * 25)
|
||||
assert out is not None
|
||||
assert out.assigned_to == start_setup["main_pm"].id
|
||||
for sub in subs:
|
||||
await db.refresh(sub)
|
||||
assert sub.status == TaskStatus.PENDING
|
||||
assert sub.team == Team.MAIN_PM
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""0.11.0 sequenced batch intake: tasks.batch_id + collision descriptor columns.
|
||||
|
||||
Migration 046 adds ``tasks.batch_id`` (indexed ``ix_tasks_batch_id``) plus the
|
||||
per-task collision surface the SequencingService reads: ``intends_to_touch``
|
||||
(text[]), and ``adds_migration`` / ``touches_shared`` (bool, NOT NULL default
|
||||
false — a non-batch task declares no surface). The real upgrade/downgrade chain
|
||||
is verified separately against a throwaway Postgres; these assertions guard the
|
||||
resulting schema shape and a value round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import TaskNature, TaskStatus, TaskType
|
||||
from sqlalchemy import text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed_backend_project(
|
||||
db_session: AsyncSession,
|
||||
) -> tuple[AgentTable, ProjectTable]:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"be-dev-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="dev",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="B-Proj",
|
||||
slug=f"b-proj-{uuid4().hex[:8]}",
|
||||
git_url="https://example.com/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=agent.id,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.flush()
|
||||
return agent, project
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_columns_and_index_exist(db_session: AsyncSession) -> None:
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT column_name, is_nullable, column_default "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE table_name = 'tasks' AND column_name IN "
|
||||
"('batch_id', 'intends_to_touch', 'adds_migration', 'touches_shared')"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
by_name = {r[0]: (r[1], r[2]) for r in rows}
|
||||
assert set(by_name) == {
|
||||
"batch_id",
|
||||
"intends_to_touch",
|
||||
"adds_migration",
|
||||
"touches_shared",
|
||||
}
|
||||
assert by_name["batch_id"][0] == "YES" # nullable
|
||||
assert by_name["intends_to_touch"][0] == "YES" # nullable
|
||||
assert by_name["adds_migration"][0] == "NO" # NOT NULL
|
||||
assert "false" in (by_name["adds_migration"][1] or "") # default false
|
||||
assert by_name["touches_shared"][0] == "NO"
|
||||
assert "false" in (by_name["touches_shared"][1] or "")
|
||||
idx = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT indexname FROM pg_indexes "
|
||||
"WHERE tablename = 'tasks' AND indexname = 'ix_tasks_batch_id'"
|
||||
)
|
||||
)
|
||||
).first()
|
||||
assert idx is not None, "ix_tasks_batch_id must exist on tasks"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_fields_round_trip(db_session: AsyncSession) -> None:
|
||||
_, project = await _seed_backend_project(db_session)
|
||||
batch = uuid4()
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="t",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=project.id,
|
||||
created_by=project.created_by,
|
||||
team=Team.BACKEND,
|
||||
batch_id=batch,
|
||||
intends_to_touch=["svc/x.py", "page/y.tsx"],
|
||||
adds_migration=True,
|
||||
touches_shared=True,
|
||||
)
|
||||
db_session.add(task)
|
||||
await db_session.flush()
|
||||
fetched = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"SELECT batch_id, intends_to_touch, adds_migration, touches_shared "
|
||||
"FROM tasks WHERE id = :id"
|
||||
),
|
||||
{"id": task.id},
|
||||
)
|
||||
).first()
|
||||
assert fetched is not None
|
||||
assert fetched[0] == batch
|
||||
assert fetched[1] == ["svc/x.py", "page/y.tsx"]
|
||||
assert fetched[2] is True
|
||||
assert fetched[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_batch_task_gets_defaults(db_session: AsyncSession) -> None:
|
||||
_, project = await _seed_backend_project(db_session)
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="t",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=project.id,
|
||||
created_by=project.created_by,
|
||||
team=Team.BACKEND,
|
||||
)
|
||||
db_session.add(task)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(task)
|
||||
assert task.batch_id is None
|
||||
assert task.intends_to_touch is None
|
||||
assert task.adds_migration is False
|
||||
assert task.touches_shared is False
|
||||
@@ -138,6 +138,7 @@ class _FakeOrchestrator:
|
||||
*,
|
||||
project_slug: str | None = None,
|
||||
product_id: str | None = None,
|
||||
project_ids: list[str] | None = None,
|
||||
initial_message: str | None = None,
|
||||
) -> None:
|
||||
# The route is non-blocking now: it calls start_intake_session (returns
|
||||
@@ -147,6 +148,7 @@ class _FakeOrchestrator:
|
||||
"session_id": session_id,
|
||||
"project_slug": project_slug,
|
||||
"product_id": product_id,
|
||||
"project_ids": project_ids,
|
||||
"initial_message": initial_message,
|
||||
}
|
||||
)
|
||||
@@ -193,11 +195,27 @@ async def test_start_product_scope_spawns_and_returns_session(
|
||||
"session_id": session_id,
|
||||
"project_slug": None,
|
||||
"product_id": product_id,
|
||||
"project_ids": None,
|
||||
"initial_message": "build X",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_megatask_scope_passes_project_ids(start_client: dict) -> None:
|
||||
client, orch = start_client["client"], start_client["orch"]
|
||||
ids = [str(uuid4()), str(uuid4())]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/prompter/live/start",
|
||||
json={"project_ids": ids, "initial_message": "three repos"},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert orch.spawned[0]["project_ids"] == ids
|
||||
assert orch.spawned[0]["project_slug"] is None
|
||||
assert orch.spawned[0]["product_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_project_scope_resolves_slug(start_client: dict) -> None:
|
||||
client, orch = start_client["client"], start_client["orch"]
|
||||
@@ -346,3 +364,99 @@ async def test_confirm_validation_error_is_translated_and_not_reaped(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert orch.reaped == [] # a failed confirm must NOT reap the session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MegaTask batch routes — confirm-batch (terminal, reaps) + preview-batch (pure).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _batch_body() -> dict[str, Any]:
|
||||
return {
|
||||
"title": "MegaTask",
|
||||
"drafts": [
|
||||
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(uuid4())},
|
||||
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(uuid4())},
|
||||
],
|
||||
"project_ids": [str(uuid4()), str(uuid4())],
|
||||
"route": "main_pm",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_creates_and_reaps(confirm_client: dict) -> None:
|
||||
client, orch = confirm_client["client"], confirm_client["orch"]
|
||||
result = {
|
||||
"umbrella_task_id": str(uuid4()),
|
||||
"root_subtask_ids": [str(uuid4()), str(uuid4())],
|
||||
"waves": [[0], [1]],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
class _FakeService:
|
||||
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
|
||||
return result
|
||||
|
||||
with patch(
|
||||
"roboco.api.routes.prompter_live.get_prompter_service",
|
||||
lambda _db: _FakeService(),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/prompter/live/s1/confirm-batch", json=_batch_body()
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
assert resp.json() == result
|
||||
assert orch.reaped == ["s1"] # confirm-batch is terminal → reap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_validation_error_not_reaped(confirm_client: dict) -> None:
|
||||
client, orch = confirm_client["client"], confirm_client["orch"]
|
||||
|
||||
class _FakeService:
|
||||
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
|
||||
raise ValidationError(message="bad batch", field="drafts")
|
||||
|
||||
with patch(
|
||||
"roboco.api.routes.prompter_live.get_prompter_service",
|
||||
lambda _db: _FakeService(),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/prompter/live/s1/confirm-batch", json=_batch_body()
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert orch.reaped == [] # a failed confirm must NOT reap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_batch_schema_rejects_too_few_projects(
|
||||
confirm_client: dict,
|
||||
) -> None:
|
||||
client = confirm_client["client"]
|
||||
body = _batch_body()
|
||||
body["project_ids"] = [str(uuid4())] # < 2 → schema 422
|
||||
resp = await client.post("/api/prompter/live/s1/confirm-batch", json=body)
|
||||
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_batch_returns_waves_and_does_not_reap(
|
||||
confirm_client: dict,
|
||||
) -> None:
|
||||
client, orch = confirm_client["client"], confirm_client["orch"]
|
||||
|
||||
class _FakeService:
|
||||
def preview_batch(self, _drafts: Any) -> Any:
|
||||
return {"waves": [[0, 1]], "warnings": []}
|
||||
|
||||
with patch(
|
||||
"roboco.api.routes.prompter_live.get_prompter_service",
|
||||
lambda _db: _FakeService(),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/prompter/live/s1/preview-batch",
|
||||
json={"drafts": [{"title": "A"}, {"title": "B"}]},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"waves": [[0, 1]], "warnings": []}
|
||||
assert orch.reaped == [] # preview creates nothing and leaves the chat alive
|
||||
|
||||
@@ -32,7 +32,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import AgentTable, ProjectTable, WorkSessionTable
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable, WorkSessionTable
|
||||
from roboco.enforcement import TaskLifecycleError
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
@@ -297,6 +297,81 @@ async def test_ensure_branch_no_project_id_raises(
|
||||
await svc._ensure_branch_for_task(task, task_setup["agent_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_branch_batch_umbrella_returns_empty(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
"""A MegaTask umbrella (batch_id, top-level, no project/product) is branchless
|
||||
by design — it must short-circuit to "" rather than hit the misconfigured
|
||||
raise, so the claim path treats it as coordination, not a defect."""
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.project_id = None
|
||||
task.product_id = None
|
||||
task.batch_id = uuid4()
|
||||
task.parent_task_id = None
|
||||
out = await svc._ensure_branch_for_task(task, task_setup["agent_id"])
|
||||
assert out == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_denies_stray_batch_id_on_targeted_top_level_task(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
"""Guardrail: a top-level task that targets a project must NOT carry a
|
||||
batch_id — that umbrella-shaped-but-targeted task would otherwise spoof the
|
||||
branchless exemption. create() refuses it."""
|
||||
svc = task_setup["svc"]
|
||||
# _req sets project_id; adding batch_id with no parent is the spoof shape.
|
||||
with pytest.raises(ValueError, match="batch_id is only valid"):
|
||||
await svc.create(_req(task_setup, batch_id=uuid4()))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_denies_batch_root_subtask_under_non_umbrella_parent(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
"""Guardrail: a batch root-subtask's parent must be the batch umbrella. A
|
||||
child pointed at a normal (non-umbrella) parent, or a mismatched batch, is
|
||||
refused."""
|
||||
svc = task_setup["svc"]
|
||||
parent = await svc.create(_req(task_setup)) # a normal task, no batch_id
|
||||
with pytest.raises(ValueError, match="parent must be the batch umbrella"):
|
||||
await svc.create(_req(task_setup, batch_id=uuid4(), parent_task_id=parent.id))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_breaking_batch_umbrella_shape(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
"""Guardrail completeness: update() re-validates the MegaTask shape, so a
|
||||
PATCH that adds a project to a branchless umbrella (which would spoof the
|
||||
branch-gate / no-PR exemption) is refused — the invariant the branchless
|
||||
predicates trust is enforced on mutation, not only at create."""
|
||||
db = task_setup["db"]
|
||||
svc = task_setup["svc"]
|
||||
umbrella = TaskTable(
|
||||
id=uuid4(),
|
||||
title="MegaTask",
|
||||
description="d",
|
||||
acceptance_criteria=["ac"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=1,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=None, # a valid umbrella targets neither
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=None,
|
||||
team=Team.MAIN_PM,
|
||||
created_by=task_setup["agent_id"],
|
||||
)
|
||||
db.add(umbrella)
|
||||
await db.flush()
|
||||
with pytest.raises(ValueError, match="MegaTask shape"):
|
||||
await svc.update(umbrella.id, project_id=task_setup["project_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_branch_no_project_raises(
|
||||
task_setup: dict,
|
||||
|
||||
@@ -145,6 +145,32 @@ async def test_start_with_plan_advances_to_in_progress(
|
||||
assert started.started_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_batch_umbrella_advances_without_branch(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A MegaTask umbrella (batch_id, no project/product, no branch) is branchless
|
||||
coordination — the claimed->in_progress branch gate must be skipped for it so
|
||||
it can reach in_progress and delegate, exactly like a product fan-out root."""
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.status = TaskStatus.CLAIMED
|
||||
task.assigned_to = task_setup["agent_id"]
|
||||
task.project_id = None
|
||||
task.product_id = None
|
||||
task.batch_id = uuid4()
|
||||
task.parent_task_id = None
|
||||
task.branch_name = None
|
||||
task.plan = {"text": "sequence the waves"}
|
||||
await db_session.flush()
|
||||
started = await svc.start(
|
||||
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
|
||||
)
|
||||
assert started is not None
|
||||
assert started.status == TaskStatus.IN_PROGRESS
|
||||
assert started.branch_name is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_none_when_ownership_fails(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
@@ -694,6 +720,49 @@ async def test_ceo_reject_routes_coordination_task_to_main_pm(
|
||||
assert rejected.claimed_by is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_reject_routes_batch_umbrella_to_main_pm(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A rejected MegaTask umbrella (batch_id, top-level, no project/product) is a
|
||||
branchless coordination root too — it routes to the Main PM to re-plan, never
|
||||
to a developer (needs_revision would deadlock the Main PM that owns it)."""
|
||||
svc = task_setup["svc"]
|
||||
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
|
||||
if await db_session.get(AgentTable, main_pm_id) is None:
|
||||
db_session.add(
|
||||
AgentTable(
|
||||
id=main_pm_id,
|
||||
name="Main PM",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
||||
task.project_id = None # umbrella: no project, no product — carries a batch_id
|
||||
task.product_id = None
|
||||
task.batch_id = uuid4()
|
||||
task.parent_task_id = None
|
||||
await db_session.flush()
|
||||
|
||||
rejected = await svc.ceo_reject(task.id, reason="re-sequence the waves")
|
||||
assert rejected is not None
|
||||
assert rejected.status == TaskStatus.PENDING
|
||||
assert rejected.team == Team.MAIN_PM
|
||||
assert rejected.assigned_to == main_pm_id
|
||||
assert rejected.claimed_by is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_reject_writes_handoff_journal(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
@@ -786,6 +855,28 @@ async def test_escalate_to_ceo_returns_none_when_no_pr(
|
||||
assert out is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_to_ceo_waives_pr_for_batch_umbrella(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A MegaTask umbrella is branchless and assembles no PR — escalate_to_ceo
|
||||
must NOT block it on a missing pr_number, or umbrella completion crashes."""
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
task.status = TaskStatus.AWAITING_PM_REVIEW
|
||||
task.project_id = None # umbrella: branchless, no repo, no PR
|
||||
task.product_id = None
|
||||
task.batch_id = uuid4()
|
||||
task.parent_task_id = None
|
||||
task.pr_number = None
|
||||
await db_session.flush()
|
||||
escalated = await svc.escalate_to_ceo(
|
||||
task.id, agent_role="main_pm", notes="MegaTask ready for CEO sign-off"
|
||||
)
|
||||
assert escalated is not None
|
||||
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_to_ceo_advances_status_with_notes(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
|
||||
@@ -158,6 +158,72 @@ def test_normalize_other_tool_stays_tool_use() -> None:
|
||||
assert chunks[0].tool == "Read"
|
||||
|
||||
|
||||
def test_normalize_propose_batch_becomes_one_batch_chunk() -> None:
|
||||
# A MegaTask: one propose_batch call with N drafts → a single `batch` chunk
|
||||
# carrying all of them + the title (not a tool_use chunk, not N draft chunks).
|
||||
msg = AssistantMessage(
|
||||
[
|
||||
ToolUseBlock(
|
||||
"propose_batch",
|
||||
{
|
||||
"drafts": [
|
||||
{"title": "SaaS work", "acceptance_criteria": ["a"]},
|
||||
{"title": "OSS core work", "acceptance_criteria": ["b"]},
|
||||
],
|
||||
"title": "Guard Core triple",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
chunks = normalize(msg)
|
||||
assert [c.kind for c in chunks] == ["batch"]
|
||||
assert chunks[0].data["title"] == "Guard Core triple"
|
||||
assert [d["title"] for d in chunks[0].data["drafts"]] == [
|
||||
"SaaS work",
|
||||
"OSS core work",
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_propose_batch_namespaced_name() -> None:
|
||||
msg = AssistantMessage(
|
||||
[
|
||||
ToolUseBlock(
|
||||
"mcp__intake__propose_batch",
|
||||
{"drafts": [{"title": "X", "acceptance_criteria": []}]},
|
||||
)
|
||||
]
|
||||
)
|
||||
assert [c.kind for c in normalize(msg)] == ["batch"]
|
||||
|
||||
|
||||
def test_normalize_propose_batch_empty_or_titleless_emits_error() -> None:
|
||||
# No usable drafts → an ERROR chunk (the panel renders it), not silence with
|
||||
# the tool falsely acking success.
|
||||
empty = AssistantMessage([ToolUseBlock("propose_batch", {"drafts": []})])
|
||||
assert [c.kind for c in normalize(empty)] == ["error"]
|
||||
titleless = AssistantMessage(
|
||||
[ToolUseBlock("propose_batch", {"drafts": [{"acceptance_criteria": []}]})]
|
||||
)
|
||||
assert [c.kind for c in normalize(titleless)] == ["error"]
|
||||
|
||||
|
||||
def test_normalize_propose_batch_reports_dropped_count() -> None:
|
||||
# One well-formed, one malformed → batch chunk with dropped=1 so the panel can
|
||||
# tell the human the batch shrank.
|
||||
msg = AssistantMessage(
|
||||
[
|
||||
ToolUseBlock(
|
||||
"propose_batch",
|
||||
{"drafts": [{"title": "Good"}, {"no_title": True}]},
|
||||
)
|
||||
]
|
||||
)
|
||||
chunks = normalize(msg)
|
||||
assert [c.kind for c in chunks] == ["batch"]
|
||||
assert chunks[0].data["dropped"] == 1
|
||||
assert [d["title"] for d in chunks[0].data["drafts"]] == ["Good"]
|
||||
|
||||
|
||||
def test_normalize_propose_draft_without_title_is_ignored() -> None:
|
||||
msg = AssistantMessage(
|
||||
[ToolUseBlock("propose_draft", {"draft": {"acceptance_criteria": []}})]
|
||||
|
||||
@@ -299,6 +299,7 @@ def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
|
||||
created_by=uuid4(),
|
||||
assigned_to=None,
|
||||
parent_task_id=None,
|
||||
batch_id=None,
|
||||
dependency_ids=[],
|
||||
blocker_ids=[],
|
||||
created_at=datetime.now(UTC),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""MegaTask identity + branchless-coordination predicates (the single source of
|
||||
truth the orchestrator / git-gate / branch-creation / reject-routing consult)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.policy.batch import (
|
||||
is_batch_root_subtask,
|
||||
is_batch_umbrella,
|
||||
is_branchless_coordination,
|
||||
is_valid_batch_shape,
|
||||
)
|
||||
|
||||
|
||||
def test_umbrella_is_batch_id_set_and_top_level() -> None:
|
||||
bid = uuid4()
|
||||
assert is_batch_umbrella(batch_id=bid, parent_task_id=None)
|
||||
assert not is_batch_umbrella(batch_id=bid, parent_task_id=uuid4()) # a child
|
||||
assert not is_batch_umbrella(batch_id=None, parent_task_id=None) # a normal root
|
||||
|
||||
|
||||
def test_root_subtask_is_batch_id_set_and_parented() -> None:
|
||||
bid = uuid4()
|
||||
assert is_batch_root_subtask(batch_id=bid, parent_task_id=uuid4())
|
||||
assert not is_batch_root_subtask(batch_id=bid, parent_task_id=None) # the umbrella
|
||||
assert not is_batch_root_subtask(batch_id=None, parent_task_id=uuid4())
|
||||
|
||||
|
||||
def test_branchless_coordination_covers_product_root_and_umbrella() -> None:
|
||||
# product fan-out coordination root: no project, carries a product
|
||||
assert is_branchless_coordination(project_id=None, product_id=uuid4())
|
||||
# MegaTask umbrella: batch_id set, top-level
|
||||
assert is_branchless_coordination(
|
||||
project_id=None, product_id=None, batch_id=uuid4(), parent_task_id=None
|
||||
)
|
||||
|
||||
|
||||
def test_branchless_coordination_excludes_normal_and_root_subtasks() -> None:
|
||||
# a normal project task does its own git
|
||||
assert not is_branchless_coordination(project_id=uuid4(), product_id=None)
|
||||
# a root-subtask (has a parent + a project) is NOT the branchless umbrella
|
||||
assert not is_branchless_coordination(
|
||||
project_id=uuid4(),
|
||||
product_id=None,
|
||||
batch_id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
)
|
||||
# genuinely unroutable (none of project / product / batch) stays gated
|
||||
assert not is_branchless_coordination(project_id=None, product_id=None)
|
||||
|
||||
|
||||
def test_valid_batch_shape_allows_umbrella_and_root_subtask() -> None:
|
||||
bid = uuid4()
|
||||
# umbrella: batch_id, no parent, NO target
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=None, project_id=None, product_id=None
|
||||
)
|
||||
# root-subtask: batch_id, a parent, exactly one target (project)
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=uuid4(), product_id=None
|
||||
)
|
||||
# root-subtask targeting a product instead is also well-formed
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=None, product_id=uuid4()
|
||||
)
|
||||
# no batch_id → unconstrained here
|
||||
assert is_valid_batch_shape(
|
||||
batch_id=None, parent_task_id=None, project_id=uuid4(), product_id=None
|
||||
)
|
||||
|
||||
|
||||
def test_valid_batch_shape_denies_stray_batch_id() -> None:
|
||||
bid = uuid4()
|
||||
# an umbrella-shaped task (batch_id, no parent) that ALSO targets a project —
|
||||
# the spoof that would otherwise get the branchless exemption — is refused.
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=None, project_id=uuid4(), product_id=None
|
||||
)
|
||||
# umbrella with a product is equally malformed
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=None, project_id=None, product_id=uuid4()
|
||||
)
|
||||
# a root-subtask (has a parent) with NO target is malformed
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=None, product_id=None
|
||||
)
|
||||
# a root-subtask with BOTH targets is malformed
|
||||
assert not is_valid_batch_shape(
|
||||
batch_id=bid, parent_task_id=uuid4(), project_id=uuid4(), product_id=uuid4()
|
||||
)
|
||||
@@ -253,3 +253,109 @@ async def test_submit_up_blocks_when_subtask_pending() -> None:
|
||||
assert body["error"] == "tracing_gap"
|
||||
assert str(sub_id) in body["remediate"]
|
||||
task_svc.submit_pm_review.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MegaTask umbrella: no PR assembly + branchless completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_root_rejects_batch_umbrella() -> None:
|
||||
"""A MegaTask umbrella assembles no PR of its own — submit_root must
|
||||
hard-reject it (each root-subtask PRs itself) so it never enters the
|
||||
in-path review gate."""
|
||||
pm_id = uuid4()
|
||||
umbrella_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=umbrella_id,
|
||||
status="in_progress",
|
||||
assigned_to=pm_id,
|
||||
parent_task_id=None,
|
||||
batch_id=uuid4(),
|
||||
branch_name=None,
|
||||
team="main_pm",
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = MagicMock(role="main_pm")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.submit_root(pm_id, umbrella_id, "all root-subtasks shipped")
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "no PR" in body["message"]
|
||||
assert "complete(" in body["remediate"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_pm_complete_allows_batch_umbrella_from_in_progress() -> None:
|
||||
"""A MegaTask umbrella is branchless: with every root-subtask terminal it
|
||||
completes straight from in_progress (no submit_root / PR), walking to
|
||||
awaiting_pm_review and escalating to the CEO — the PR requirement is waived."""
|
||||
pm_id = uuid4()
|
||||
umbrella_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=umbrella_id,
|
||||
status="in_progress",
|
||||
assigned_to=pm_id,
|
||||
parent_task_id=None,
|
||||
batch_id=uuid4(),
|
||||
branch_name=None,
|
||||
team="main_pm",
|
||||
)
|
||||
escalated = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.all_subtasks_terminal.return_value = True
|
||||
task_svc.get_subtasks.return_value = []
|
||||
task_svc.submit_pm_review.return_value = MagicMock(status="awaiting_pm_review")
|
||||
task_svc.escalate_to_ceo.return_value = escalated
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_reflect_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.main_pm_complete(
|
||||
pm_id, umbrella_id, "every root-subtask is terminal; MegaTask ready for CEO"
|
||||
)
|
||||
assert env.error is None
|
||||
# Branchless walk in_progress -> awaiting_pm_review, then escalate. No PR.
|
||||
task_svc.submit_pm_review.assert_awaited_once()
|
||||
task_svc.escalate_to_ceo.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_pm_complete_handles_escalate_returning_none() -> None:
|
||||
"""Defense-in-depth: if escalate_to_ceo refuses (returns None), the verb
|
||||
surfaces an invalid_state rejection instead of dereferencing None."""
|
||||
pm_id = uuid4()
|
||||
umbrella_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=umbrella_id,
|
||||
status="in_progress",
|
||||
assigned_to=pm_id,
|
||||
parent_task_id=None,
|
||||
batch_id=uuid4(),
|
||||
branch_name=None,
|
||||
team="main_pm",
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.all_subtasks_terminal.return_value = True
|
||||
task_svc.get_subtasks.return_value = []
|
||||
task_svc.submit_pm_review.return_value = MagicMock(status="awaiting_pm_review")
|
||||
task_svc.escalate_to_ceo.return_value = None # refused
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_reflect_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.main_pm_complete(pm_id, umbrella_id, "ready for CEO sign-off")
|
||||
assert env.error == "invalid_state"
|
||||
assert "escalate_to_ceo" in env.as_dict()["message"]
|
||||
|
||||
@@ -535,6 +535,7 @@ async def test_submit_root_accepts_main_pm_and_enters_the_gate() -> None:
|
||||
pr_number=None,
|
||||
branch_name="feature/main_pm/root123",
|
||||
parent_task_id=None,
|
||||
batch_id=None, # a normal root carries no batch_id (not a MegaTask umbrella)
|
||||
team="main_pm",
|
||||
)
|
||||
gated = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pr_review"})
|
||||
|
||||
@@ -35,6 +35,34 @@ async def test_post_draft_posts_to_the_relay(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert seen["json"]["data"] == {"title": "Build X"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_draft_forwards_batch_collision_descriptors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A batch draft carries its collision surface through to the relay intact."""
|
||||
monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["json"] = __import__("json").loads(request.content)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
draft = {
|
||||
"title": "Fix charts",
|
||||
"intends_to_touch": ["svc/dashboard.py", "page/metrics.tsx"],
|
||||
"adds_migration": True,
|
||||
"touches_shared": False,
|
||||
}
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft("sess-1", draft, client=client)
|
||||
|
||||
assert result == {"ok": True}
|
||||
data = seen["json"]["data"]
|
||||
assert data["intends_to_touch"] == ["svc/dashboard.py", "page/metrics.tsx"]
|
||||
assert data["adds_migration"] is True
|
||||
assert data["touches_shared"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_draft_reports_http_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
@@ -56,6 +84,69 @@ async def test_post_draft_reports_request_failure() -> None:
|
||||
assert "boom" in result["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_batch_posts_a_batch_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["json"] = __import__("json").loads(request.content)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
batch = {"drafts": [{"title": "A"}, {"title": "B"}], "title": "MegaTask"}
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_batch("sess-1", batch, client=client)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert seen["url"] == "http://orch:8000/api/prompter/live/sess-1/events"
|
||||
assert seen["json"]["kind"] == "batch"
|
||||
assert seen["json"]["tool"] == "propose_batch"
|
||||
assert [d["title"] for d in seen["json"]["data"]["drafts"]] == ["A", "B"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_batch_acks_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
|
||||
async def _ok(_sid: str, _batch: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(intake_server, "post_batch", _ok)
|
||||
msg = await intake_server.propose_batch([{"title": "A"}], "MegaTask")
|
||||
assert "MegaTask submitted" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_batch_requires_a_live_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_PROMPTER_SESSION_ID", raising=False)
|
||||
msg = await intake_server.propose_batch([{"title": "A"}], "MegaTask")
|
||||
assert "No live session id" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_batch_refuses_empty_without_posting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
posted = False
|
||||
|
||||
async def _spy(_sid: str, _batch: dict[str, Any]) -> dict[str, Any]:
|
||||
nonlocal posted
|
||||
posted = True
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(intake_server, "post_batch", _spy)
|
||||
# No titles anywhere → nothing well-formed → don't POST, tell the agent.
|
||||
msg = await intake_server.propose_batch([{"no_title": True}], "MegaTask")
|
||||
assert "no well-formed task drafts" in msg
|
||||
assert posted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_draft_requires_a_live_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -184,6 +184,76 @@ class TestIntakeScopeSlugs:
|
||||
product_id="33333333-3333-3333-3333-333333333333",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_megatask_scope_resolves_explicit_project_ids_in_order(self) -> None:
|
||||
# A MegaTask spans an explicit set of (possibly unrelated) projects; the
|
||||
# slugs are resolved in the given order (the first is the primary cwd).
|
||||
ids = [
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
]
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, pid: Any) -> Any:
|
||||
return SimpleNamespace(slug=f"proj-{str(pid)[0]}")
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
slugs = await AgentOrchestrator._intake_scope_slugs(
|
||||
db=object(),
|
||||
project_slug=None,
|
||||
product_id=None,
|
||||
project_ids=ids,
|
||||
)
|
||||
assert slugs == ["proj-1", "proj-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_megatask_scope_with_unresolvable_project_raises(self) -> None:
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, _pid: Any) -> Any:
|
||||
return None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
),
|
||||
pytest.raises(ValueError, match="not found"),
|
||||
):
|
||||
await AgentOrchestrator._intake_scope_slugs(
|
||||
db=object(),
|
||||
project_slug=None,
|
||||
product_id=None,
|
||||
project_ids=["11111111-1111-1111-1111-111111111111"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_megatask_scope_with_one_unresolvable_id_raises(self) -> None:
|
||||
# A PARTIAL failure (one of N ids invalid) must fail loud, not silently
|
||||
# clone fewer repos than the agent was told it has.
|
||||
good = "11111111-1111-1111-1111-111111111111"
|
||||
bad = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, pid: Any) -> Any:
|
||||
return SimpleNamespace(slug="proj-a") if str(pid) == good else None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
),
|
||||
pytest.raises(ValueError, match="not found"),
|
||||
):
|
||||
await AgentOrchestrator._intake_scope_slugs(
|
||||
db=object(),
|
||||
project_slug=None,
|
||||
product_id=None,
|
||||
project_ids=[good, bad],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
|
||||
@@ -206,7 +276,7 @@ def _wire_spawn_mocks(
|
||||
) -> None:
|
||||
"""Patch every external boundary spawn_intake_session touches."""
|
||||
|
||||
async def _clone(_p: Any, _pr: Any) -> tuple[str, list[str]]:
|
||||
async def _clone(_p: Any, _pr: Any, _pids: Any = None) -> tuple[str, list[str]]:
|
||||
return "/data/workspaces/roboco/board/intake-1", [
|
||||
"/data/workspaces/roboco/board/intake-1"
|
||||
]
|
||||
@@ -271,6 +341,25 @@ class TestSpawnIntakeSession:
|
||||
await orch.spawn_intake_session("s", project_slug="roboco", product_id="p")
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
await orch.spawn_intake_session("s")
|
||||
# A MegaTask scope cannot combine with a single-project scope.
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
await orch.spawn_intake_session(
|
||||
"s", project_slug="roboco", project_ids=["p1"]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_accepts_megatask_project_ids_scope(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
run_calls: list[list[str]] = []
|
||||
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
||||
|
||||
instance = await orch.spawn_intake_session(
|
||||
"sess-mega", project_ids=["11111111-1111-1111-1111-111111111111"]
|
||||
)
|
||||
assert orch._instances[INTAKE_AGENT_ID] is instance
|
||||
assert run_calls # the container actually launched for the MegaTask scope
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_reaps_prior_session_first(
|
||||
|
||||
@@ -48,6 +48,28 @@ def test_not_coordination_when_neither() -> None:
|
||||
assert _is_coordination_task({"project_id": None, "product_id": None}) is False
|
||||
|
||||
|
||||
def test_coordination_task_when_batch_umbrella() -> None:
|
||||
# A MegaTask umbrella carries a batch_id and is top-level (no parent); it does
|
||||
# no git of its own — its root-subtasks each branch/PR — so it is coordination.
|
||||
assert (
|
||||
_is_coordination_task(
|
||||
{"project_id": None, "product_id": None, "batch_id": "b1"}
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_not_coordination_when_batch_root_subtask() -> None:
|
||||
# A MegaTask root-subtask shares the batch_id but has a parent (the umbrella)
|
||||
# and its own project — it does real git, so it is NOT coordination.
|
||||
assert (
|
||||
_is_coordination_task(
|
||||
{"project_id": "r1", "batch_id": "b1", "parent_task_id": "u1"}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _readiness_check_task
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,6 +112,16 @@ def test_readiness_skips_branch_gate_for_coordination_task() -> None:
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_readiness_skips_branch_gate_for_batch_umbrella() -> None:
|
||||
# A MegaTask umbrella (batch_id, no project/branch) coordinates its
|
||||
# root-subtasks and does no git — it must reach in_progress unbranched.
|
||||
orch = _bare_orchestrator()
|
||||
reason = orch._readiness_check_task(
|
||||
"main-pm", _task(batch_id="b1", status="in_progress", branch_name=None)
|
||||
)
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_readiness_still_gates_code_task_without_project() -> None:
|
||||
orch = _bare_orchestrator()
|
||||
reason = orch._readiness_check_task("be-dev-1", _task(status="pending"))
|
||||
@@ -132,6 +164,21 @@ def test_stuck_check_ignores_missing_branch_for_coordination_task() -> None:
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_stuck_check_ignores_missing_branch_for_batch_umbrella() -> None:
|
||||
orch = _bare_orchestrator()
|
||||
issues = orch._check_stuck_conditions(
|
||||
{
|
||||
"project_id": None,
|
||||
"product_id": None,
|
||||
"batch_id": "b1",
|
||||
"branch_name": None,
|
||||
"description": _GOOD_DESC,
|
||||
}
|
||||
)
|
||||
assert "Task missing branch_name" not in issues
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_stuck_check_flags_missing_branch_for_claimed_code_task() -> None:
|
||||
# A claimed code task SHOULD already own a branch (auto-created on claim).
|
||||
orch = _bare_orchestrator()
|
||||
@@ -214,3 +261,13 @@ def test_branch_never_expected_for_coordination_task() -> None:
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_branch_never_expected_for_batch_umbrella() -> None:
|
||||
# A MegaTask umbrella never gets a branch even at in_progress.
|
||||
assert (
|
||||
_branch_is_expected(
|
||||
{"project_id": None, "batch_id": "b1", "status": "in_progress"}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""The orchestrator's internal API calls must carry an authorized identity.
|
||||
|
||||
Regression guard for the wedge where dispatcher ``httpx`` clients were built
|
||||
without an agent identity, so the orchestrator's self-PATCHes (auto-block /
|
||||
auto-resume / auto-recover / SLA annotation) were rejected ``401 Missing
|
||||
X-Agent-ID`` and silently no-op'd — leaving paused/blocked parents stuck and
|
||||
their dependents stranded. The fix gives every API-facing dispatcher client the
|
||||
system identity; these tests lock that the identity is both *present* and
|
||||
*authorized* for task writes (otherwise the self-call would 403 instead of act).
|
||||
"""
|
||||
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import TASK_PERMISSIONS, TaskAction
|
||||
from roboco.runtime.orchestrator import _SYSTEM_API_HEADERS
|
||||
|
||||
|
||||
def test_system_api_headers_match_the_system_identity() -> None:
|
||||
system = _foundation.AGENTS["system"]
|
||||
assert _SYSTEM_API_HEADERS["X-Agent-ID"] == str(system.uuid)
|
||||
assert _SYSTEM_API_HEADERS["X-Agent-Role"] == "system"
|
||||
|
||||
|
||||
def test_system_identity_is_authorized_for_task_writes() -> None:
|
||||
# admin_set_status — the audited override path the orchestrator's
|
||||
# auto-recover / auto-resume drive — is gated behind TaskAction.ASSIGN.
|
||||
# The identity the orchestrator sends must hold it.
|
||||
assert TaskAction.ASSIGN in TASK_PERMISSIONS[AgentRole.SYSTEM]
|
||||
@@ -28,7 +28,7 @@ from roboco.models.base import (
|
||||
Team,
|
||||
)
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.base import ServiceError
|
||||
from roboco.services.base import ServiceError, ValidationError
|
||||
from roboco.services.prompter import (
|
||||
PrompterService,
|
||||
compose_description,
|
||||
@@ -423,3 +423,210 @@ async def test_confirm_live_draft_product_routes_to_main_pm(db_session: Any) ->
|
||||
assert row.team == Team.MAIN_PM
|
||||
assert row.product_id == product_id
|
||||
assert row.project_id is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MegaTask: confirm_live_batch (umbrella + sequenced root-subtasks)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _seed_second_project(db_session: Any, ceo_id: UUID) -> UUID:
|
||||
"""Seed a second project so a MegaTask can span multiple repos."""
|
||||
project_id = uuid4()
|
||||
db_session.add(
|
||||
ProjectTable(
|
||||
id=project_id,
|
||||
name="Intake Test Project 2",
|
||||
slug=f"intake2-{uuid4().hex[:8]}",
|
||||
git_url="https://github.com/example/intake2.git",
|
||||
default_branch="main",
|
||||
protected_branches=["main"],
|
||||
assigned_cell=Team.FRONTEND,
|
||||
created_by=ceo_id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
return project_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_live_batch_builds_umbrella_and_sequenced_subtasks(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""A MegaTask creates one branchless umbrella + N root-subtasks across many
|
||||
projects, with the collision-derived dependency edges wired so the
|
||||
dependency-gate runs the waves in order."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
service = get_prompter_service(db=db_session)
|
||||
|
||||
# A & B both add a migration → serial chain A→B (the migration rule orders
|
||||
# them by priority then index). C is an independent frontend task in another
|
||||
# project, so it runs in parallel with A in wave 0.
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "A: add table",
|
||||
"acceptance_criteria": ["a"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
"intends_to_touch": ["roboco/services/foo.py"],
|
||||
"adds_migration": True,
|
||||
},
|
||||
{
|
||||
"title": "B: extend table",
|
||||
"acceptance_criteria": ["b"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
"intends_to_touch": ["roboco/services/bar.py"],
|
||||
"adds_migration": True,
|
||||
},
|
||||
{
|
||||
"title": "C: frontend widget",
|
||||
"acceptance_criteria": ["c"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project2),
|
||||
"intends_to_touch": ["panel/src/widget.tsx"],
|
||||
},
|
||||
]
|
||||
result = await service.confirm_live_batch(
|
||||
"Three things",
|
||||
drafts,
|
||||
ceo_id,
|
||||
project_ids=[project1, project2],
|
||||
route="main_pm",
|
||||
)
|
||||
|
||||
# A (migration) and C (independent) run in wave 0; B chains after A.
|
||||
assert result["waves"] == [[0, 2], [1]]
|
||||
ids = result["root_subtask_ids"]
|
||||
assert len(ids) == len(drafts)
|
||||
|
||||
umbrella_id = UUID(result["umbrella_task_id"])
|
||||
umbrella = await db_session.get(TaskTable, umbrella_id)
|
||||
assert umbrella.batch_id is not None
|
||||
assert umbrella.parent_task_id is None
|
||||
assert umbrella.project_id is None and umbrella.product_id is None
|
||||
assert umbrella.team == Team.MAIN_PM
|
||||
assert umbrella.status == TaskStatus.PENDING
|
||||
assert umbrella.branch_name is None # branchless
|
||||
|
||||
a, b, c = [await db_session.get(TaskTable, UUID(sid)) for sid in ids]
|
||||
for sub in (a, b, c):
|
||||
assert sub.parent_task_id == umbrella_id
|
||||
assert sub.batch_id == umbrella.batch_id
|
||||
assert sub.team == Team.MAIN_PM
|
||||
assert sub.status == TaskStatus.PENDING
|
||||
assert a.project_id == project1
|
||||
assert b.project_id == project1
|
||||
assert c.project_id == project2
|
||||
# sequence = wave index: A and C in wave 0, B in wave 1.
|
||||
assert (a.sequence, b.sequence, c.sequence) == (0, 1, 0)
|
||||
# Dependency wiring: B waits on A; C is independent.
|
||||
assert UUID(ids[0]) in b.dependency_ids
|
||||
assert c.dependency_ids == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_live_batch_board_route_holds_subtasks_in_backlog(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
"""The "board" route sends the umbrella to the Product Owner for batch review
|
||||
and holds the root-subtasks in BACKLOG until the umbrella is approved."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
service = get_prompter_service(db=db_session)
|
||||
drafts = [
|
||||
{
|
||||
"title": "One",
|
||||
"acceptance_criteria": ["x"],
|
||||
"team": "backend",
|
||||
"project_id": str(project1),
|
||||
},
|
||||
{
|
||||
"title": "Two",
|
||||
"acceptance_criteria": ["y"],
|
||||
"team": "frontend",
|
||||
"project_id": str(project2),
|
||||
},
|
||||
]
|
||||
result = await service.confirm_live_batch(
|
||||
"Two repos", drafts, ceo_id, project_ids=[project1, project2], route="board"
|
||||
)
|
||||
|
||||
umbrella = await db_session.get(TaskTable, UUID(result["umbrella_task_id"]))
|
||||
assert umbrella.team == Team.BOARD
|
||||
assert umbrella.assigned_to == UUID(AGENT_UUIDS["product-owner"])
|
||||
assert umbrella.status == TaskStatus.PENDING
|
||||
sub = await db_session.get(TaskTable, UUID(result["root_subtask_ids"][0]))
|
||||
assert sub.status == TaskStatus.BACKLOG # held until batch review approves
|
||||
assert sub.team == Team.BOARD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_live_batch_rejects_empty(db_session: Any) -> None:
|
||||
_project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
service = get_prompter_service(db=db_session)
|
||||
with pytest.raises(ValidationError):
|
||||
await service.confirm_live_batch(
|
||||
"Empty", [], ceo_id, project_ids=[uuid4(), uuid4()]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_live_batch_rejects_draft_outside_scope(db_session: Any) -> None:
|
||||
"""A draft targeting a project NOT in the scoped project_ids is refused — the
|
||||
intake agent only read the scoped repos."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
service = get_prompter_service(db=db_session)
|
||||
outside = uuid4() # never in scope
|
||||
drafts = [
|
||||
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(project1)},
|
||||
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(outside)},
|
||||
]
|
||||
with pytest.raises(ValidationError, match="outside this MegaTask"):
|
||||
await service.confirm_live_batch(
|
||||
"Scoped", drafts, ceo_id, project_ids=[project1, project2], route="main_pm"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_live_batch_rejects_single_project(db_session: Any) -> None:
|
||||
"""A degenerate batch whose drafts all target one project is not a MegaTask."""
|
||||
project1, ceo_id = await _seed_project_and_ceo(db_session)
|
||||
project2 = await _seed_second_project(db_session, ceo_id)
|
||||
service = get_prompter_service(db=db_session)
|
||||
drafts = [
|
||||
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(project1)},
|
||||
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(project1)},
|
||||
]
|
||||
with pytest.raises(ValidationError, match="at least two distinct projects"):
|
||||
await service.confirm_live_batch(
|
||||
"One repo",
|
||||
drafts,
|
||||
ceo_id,
|
||||
project_ids=[project1, project2],
|
||||
route="main_pm",
|
||||
)
|
||||
|
||||
|
||||
def test_preview_batch_computes_waves_without_creating() -> None:
|
||||
"""preview_batch is pure: it returns the same waves confirm would wire, with
|
||||
no DB session and no task creation."""
|
||||
service = get_prompter_service() # no db — pure compute
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "adds_migration": True, "intends_to_touch": ["a.py"]},
|
||||
{"title": "B", "adds_migration": True, "intends_to_touch": ["b.py"]},
|
||||
{"title": "C", "intends_to_touch": ["c.py"]},
|
||||
]
|
||||
result = service.preview_batch(drafts)
|
||||
# A & B chain on the migration rule; C is independent → [[0, 2], [1]].
|
||||
assert result["waves"] == [[0, 2], [1]]
|
||||
assert isinstance(result["warnings"], list)
|
||||
|
||||
|
||||
def test_preview_batch_rejects_empty() -> None:
|
||||
service = get_prompter_service()
|
||||
with pytest.raises(ValidationError):
|
||||
service.preview_batch([])
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""SequencingService — the deterministic collision-sequencing analyzer.
|
||||
|
||||
The unit tests pin each rule in isolation; the golden test asserts the analyzer
|
||||
reproduces the CEO's own hand-sequencing of the 11-item guard-core-app batch
|
||||
(the effort that motivated the feature, and whose hand-coordination deadlocked
|
||||
the Main PM): S6 alone last, the R1/R3/R4 migration chain, R2/R3/S8 serialized on
|
||||
the shared threat service, and S1/S2/S7 in one parallel wave.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy.sequencing.models import (
|
||||
DraftSurface,
|
||||
SequencingError,
|
||||
)
|
||||
from roboco.services.sequencing import SequencingService
|
||||
|
||||
|
||||
def _backend(_i: int) -> str:
|
||||
return "backend"
|
||||
|
||||
|
||||
def _frontend(_i: int) -> str:
|
||||
return "frontend"
|
||||
|
||||
|
||||
def _wave_of(waves: list[list[int]], idx: int) -> int:
|
||||
return next(w for w, wave in enumerate(waves) if idx in wave)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-rule unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disjoint_surfaces_no_edges() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False),
|
||||
DraftSurface(1, 1, ["b/y.py"], False, False),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert plan.edges == []
|
||||
assert plan.waves == [[0, 1]]
|
||||
|
||||
|
||||
def test_file_overlap_serializes_more_important_first() -> None:
|
||||
# idx 1 has the lower priority NUMBER (more important) → it runs first.
|
||||
s = [
|
||||
DraftSurface(0, 2, ["svc/threats.py"], False, False),
|
||||
DraftSurface(1, 1, ["svc/threats.py"], False, False),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert (1, 0) in plan.edges # more-important runs first, the other waits
|
||||
|
||||
|
||||
def test_migrations_form_serial_chain() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a.py"], True, False),
|
||||
DraftSurface(1, 1, ["b.py"], True, False),
|
||||
DraftSurface(2, 1, ["c.py"], True, False),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert (0, 1) in plan.edges # no two migrations run in parallel
|
||||
assert (1, 2) in plan.edges
|
||||
|
||||
|
||||
def test_touches_shared_runs_last() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["page/a.tsx"], False, False),
|
||||
DraftSurface(1, 1, ["page/b.tsx"], False, False),
|
||||
DraftSurface(2, 1, ["page/a.tsx", "components/shared.tsx"], False, True),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _frontend, {"frontend": 2})
|
||||
assert plan.waves[-1] == [2] # the shared task is the final wave
|
||||
|
||||
|
||||
def test_cycle_is_rejected() -> None:
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService()._toposort([(0, 1), (1, 0)], 2)
|
||||
|
||||
|
||||
def test_existence_check_rejects_out_of_range_edge() -> None:
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService()._toposort([(0, 5)], 2)
|
||||
|
||||
|
||||
def test_shared_migration_chains_after_non_shared_no_cycle() -> None:
|
||||
# Regression: a draft that is BOTH touches_shared AND adds_migration,
|
||||
# overlapping a non-shared migration draft on the same file, used to fabricate
|
||||
# a cycle — rule 2 (migration chain) emitted shared->non-shared while rule 3
|
||||
# (shared-last) emitted non-shared->shared. The migration chain is now
|
||||
# shared-last-aware, so the shared draft is ordered LAST and there is no cycle.
|
||||
s = [
|
||||
DraftSurface(0, 1, ["svc/threats.py"], True, True), # shared migration
|
||||
DraftSurface(1, 1, ["svc/threats.py"], True, False), # non-shared migration
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert plan.waves == [[1], [0]] # non-shared first, shared migration last
|
||||
|
||||
|
||||
def test_cross_project_surfaces_do_not_collide() -> None:
|
||||
# A MegaTask spans repos that don't share a working tree — two migrations in
|
||||
# different projects run in PARALLEL, and a coincidentally-equal path across
|
||||
# repos is not a collision.
|
||||
s = [
|
||||
DraftSurface(0, 1, ["alembic/x.py"], True, False, project_id="proj-a"),
|
||||
DraftSurface(1, 1, ["alembic/x.py"], True, False, project_id="proj-b"),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert plan.waves == [[0, 1]] # independent repos → one parallel wave
|
||||
|
||||
|
||||
def test_cell_contention_warns_not_serializes() -> None:
|
||||
s = [DraftSurface(i, 1, [f"page/{i}.tsx"], False, False) for i in range(3)]
|
||||
plan = SequencingService().analyze(s, _frontend, {"frontend": 2})
|
||||
assert plan.edges == [] # contention never adds an edge
|
||||
assert any("frontend" in w for w in plan.warnings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Golden test — reproduce the CEO's 4-wave plan for the 11-item batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Index map for the guard-core-app items (see obs: wave-based sequencing).
|
||||
R1, R2, R3, R4 = 0, 1, 2, 3
|
||||
S1, S2, S3, S5, S7, S8, S6 = 4, 5, 6, 7, 8, 9, 10
|
||||
|
||||
|
||||
def _guard_core_app_batch() -> list[DraftSurface]:
|
||||
# (idx, priority, intends_to_touch, adds_migration, touches_shared)
|
||||
return [
|
||||
DraftSurface(R1, 1, ["be/services/project_service.py"], True, False),
|
||||
DraftSurface(R2, 1, ["be/services/threats_service.py"], False, False),
|
||||
DraftSurface(
|
||||
R3,
|
||||
1,
|
||||
["be/services/threats_service.py", "be/services/behavioral_service.py"],
|
||||
True,
|
||||
False,
|
||||
),
|
||||
DraftSurface(R4, 1, ["fe/app/rules/page.tsx"], True, False),
|
||||
DraftSurface(S1, 1, ["fe/app/metrics/page.tsx"], False, False),
|
||||
DraftSurface(S2, 1, ["fe/app/settings/page.tsx"], False, False),
|
||||
DraftSurface(S3, 1, ["be/services/dashboard_service.py"], False, False),
|
||||
DraftSurface(S5, 1, ["be/services/audit_service.py"], False, False),
|
||||
DraftSurface(S7, 1, ["fe/app/threats/page.tsx"], False, False),
|
||||
DraftSurface(S8, 1, ["be/services/threats_service.py"], False, False),
|
||||
DraftSurface(S6, 1, ["fe/components/", "fe/app/"], False, True),
|
||||
]
|
||||
|
||||
|
||||
def _cell_of(idx: int) -> str:
|
||||
return "backend" if idx in {R1, R2, R3, S3, S5, S8} else "frontend"
|
||||
|
||||
|
||||
def test_golden_reproduces_ceo_waves() -> None:
|
||||
plan = SequencingService().analyze(
|
||||
_guard_core_app_batch(), _cell_of, {"backend": 2, "frontend": 2}
|
||||
)
|
||||
|
||||
# EXACT partition — the CEO's own 4-wave hand-sequencing, locked. The bar is
|
||||
# "reproduce my exact waves or it's not done", so assert the full partition,
|
||||
# not just the properties below.
|
||||
assert plan.waves == [
|
||||
sorted([R1, R2, S1, S2, S3, S5, S7]), # wave 1: everything unblocked
|
||||
[R3], # wave 2: the shared+migration hinge
|
||||
sorted([R4, S8]), # wave 3: after R3
|
||||
[S6], # wave 4: the shared UI-consistency pass, alone, last
|
||||
]
|
||||
|
||||
# The properties that partition expresses (kept as documentation of WHY):
|
||||
# S6 (the shared UI-consistency pass) runs alone, last.
|
||||
assert plan.waves[-1] == [S6]
|
||||
# R1/R3/R4 form a serial migration chain (no concurrent Alembic heads).
|
||||
assert (R1, R3) in plan.edges
|
||||
assert (R3, R4) in plan.edges
|
||||
# R2/R3/S8 serialize on the shared threats service surface.
|
||||
assert (R2, R3) in plan.edges
|
||||
assert (R3, S8) in plan.edges
|
||||
# The page-isolated frontend work (S1/S2/S7) lands in one parallel wave.
|
||||
assert _wave_of(plan.waves, S1) == _wave_of(plan.waves, S2)
|
||||
assert _wave_of(plan.waves, S2) == _wave_of(plan.waves, S7)
|
||||
Reference in New Issue
Block a user