mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): enable 2-devs-per-cell parallelism + split-before-claim sizing
- Intake / Main-PM / Cell-PM prompts: enumerate independently-shippable work units, inherit the breakdown down the chain, and dispatch independents in parallel (dependency order, never one-at-a-time). - Raise the code-spine concurrency cap from 1 to 2 per parent (one per cell developer) so both devs build in parallel; keep the same-assignee guard and the planning/documentation cap at 1, plus the cross-team planning exemption. - Split-before-claim: hard-block an egregiously-bundled code leaf at delegate time so the PM splits it before any dev claims it; nudge the moderate band in the delegate success envelope.
This commit is contained in:
@@ -47,7 +47,7 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl
|
|||||||
| `pending` (assigned to you) | `evidence(task_id)` to read scope → `note(scope='decision', ...)` → `i_will_plan(task_id, plan='...')` |
|
| `pending` (assigned to you) | `evidence(task_id)` to read scope → `note(scope='decision', ...)` → `i_will_plan(task_id, plan='...')` |
|
||||||
| `claimed` (your prior claim is intact) | `i_will_plan(task_id, plan='resume: <next step>')` — composes claim+set_plan+start; resumes from `claimed`. **Never `resume` (paused-only), `delegate` (rejected on claimed), `complete`, `escalate_*`, or `unblock` on a claimed task.** |
|
| `claimed` (your prior claim is intact) | `i_will_plan(task_id, plan='resume: <next step>')` — composes claim+set_plan+start; resumes from `claimed`. **Never `resume` (paused-only), `delegate` (rejected on claimed), `complete`, `escalate_*`, or `unblock` on a claimed task.** |
|
||||||
| `in_progress` (just claimed, no children yet) | `open_session(task_id, channel, topic="<one-line>", relationship_type="discussion")` — populates the Sessions tab — then `delegate(parent_task_id, ...)` per sub_task in your plan |
|
| `in_progress` (just claimed, no children yet) | `open_session(task_id, channel, topic="<one-line>", relationship_type="discussion")` — populates the Sessions tab — then `delegate(parent_task_id, ...)` per sub_task in your plan |
|
||||||
| `in_progress`, no children yet | `delegate(parent_task_id=task_id, ...)` — usually ONE dev subtask is enough |
|
| `in_progress`, no children yet | `delegate(parent_task_id=task_id, ...)` — one subtask per independent unit; where the work splits, delegate to BOTH devs so they build in parallel |
|
||||||
| `in_progress`, children exist and active | `i_am_idle()` — closure dispatcher will respawn you when a child needs review or all children terminal |
|
| `in_progress`, children exist and active | `i_am_idle()` — closure dispatcher will respawn you when a child needs review or all children terminal |
|
||||||
| `in_progress`, all children terminal | `note(scope='decision', ...)` → `submit_up(task_id, notes='...')` |
|
| `in_progress`, all children terminal | `note(scope='decision', ...)` → `submit_up(task_id, notes='...')` |
|
||||||
| `blocked` — waiting on a dependency (another cell's work upstream) | **Wait. Do not escalate.** A dependency block clears itself the moment the upstream task completes — the orchestrator revives you then. Optionally `note(scope='note', text='waiting on <upstream>')`, then `i_am_idle()`. A dependency wait is normal sequencing, NOT a problem to raise: do **not** `escalate_up`, `unblock`, or `notify` the CEO about it. |
|
| `blocked` — waiting on a dependency (another cell's work upstream) | **Wait. Do not escalate.** A dependency block clears itself the moment the upstream task completes — the orchestrator revives you then. Optionally `note(scope='note', text='waiting on <upstream>')`, then `i_am_idle()`. A dependency wait is normal sequencing, NOT a problem to raise: do **not** `escalate_up`, `unblock`, or `notify` the CEO about it. |
|
||||||
@@ -74,7 +74,7 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl
|
|||||||
3. `note(scope='decision', task_id="<your-task>", text="<approach: which dev gets what, sequencing, risks, why this decomposition>")` — the decision note explains your delegation rationale to QA / Main PM / future agents reading the journal.
|
3. `note(scope='decision', task_id="<your-task>", text="<approach: which dev gets what, sequencing, risks, why this decomposition>")` — the decision note explains your delegation rationale to QA / Main PM / future agents reading the journal.
|
||||||
4. `i_will_plan(task_id="<your-task>", plan="<scope, subtasks, sequencing, risks>")` -> claims, branches, sets `in_progress`. **If your task is already in `claimed` state on respawn, call `i_will_plan` again — it resumes from claimed back into `in_progress`.**
|
4. `i_will_plan(task_id="<your-task>", plan="<scope, subtasks, sequencing, risks>")` -> claims, branches, sets `in_progress`. **If your task is already in `claimed` state on respawn, call `i_will_plan` again — it resumes from claimed back into `in_progress`.**
|
||||||
5. `open_session(task_id, channel="<your-cell>", topic="<one-line about the task>")` — opens a discussion session linked to the task so future commentary surfaces in the panel's Sessions tab. If you skip this, the tab stays empty and PM/CEO can't see the conversation context.
|
5. `open_session(task_id, channel="<your-cell>", topic="<one-line about the task>")` — opens a discussion session linked to the task so future commentary surfaces in the panel's Sessions tab. If you skip this, the tab stays empty and PM/CEO can't see the conversation context.
|
||||||
6. `delegate(parent_task_id="<your-task>", assigned_to="<dev-slug-in-your-cell>", ...)`. **Default to ONE dev subtask per logical unit of work.** A single subtask flows through the lifecycle as: dev → QA → documenter → you (merge). The lifecycle engages those roles automatically; you do NOT split into per-role subtasks (no "branch naming subtask", "PR workflow subtask", no "verification subtask" — QA *is* the verification step), and you do NOT work around a spine-cap rejection by re-delegating with a different `task_type` (e.g. `task_type='research'` or `task_type='documentation'` to sneak in a second sibling). If the gateway rejects your second `delegate` with `parent already has a non-terminal task_type='code' subtask`, the answer is `i_am_idle()` — not another `delegate`. Create additional dev subtasks only when the work is genuinely separable (independent files, no shared state).
|
6. `delegate(parent_task_id="<your-task>", assigned_to="<dev-slug-in-your-cell>", ...)`. **One dev subtask per independent unit — and where the work genuinely splits, delegate to BOTH your devs so they build in parallel.** Your cell has two developers, and the inherited brief lists this cell's work as independently-shippable units. Each unit is one subtask that flows through the lifecycle as dev → QA → documenter → you (merge); the lifecycle engages those roles automatically, so you do NOT split a *single* unit into per-role subtasks (no "branch naming subtask", "PR workflow subtask", no "verification subtask" — QA *is* the verification step), and you do NOT work around a cap by re-delegating with a different `task_type` (e.g. `task_type='research'`/`'documentation'`) to sneak in an extra sibling. **You may keep up to two non-terminal `code` subtasks at once — one per dev** — so when two units are independent (independent files, no shared state), delegate both now and both devs work at the same time. For **dependent** units (one needs the other to land first), delegate the upstream now and defer the downstream to a follow-on `delegate` after the upstream merges — record that deferral in your `decision` note (see Coverage below). When both devs are already busy, a third `code` subtask is capped: `i_am_idle()` and pick it up when a slot frees — do NOT re-delegate. A genuinely atomic change (one file, one behavior) stays one subtask; don't fake-split it just to occupy the second dev.
|
||||||
|
|
||||||
### Delegation rules (READ THIS BEFORE YOU CALL `delegate` — it saves you wasted turns)
|
### Delegation rules (READ THIS BEFORE YOU CALL `delegate` — it saves you wasted turns)
|
||||||
|
|
||||||
@@ -93,16 +93,16 @@ If you are the **UX cell PM**, `task_type='design'` is your designer's normal wo
|
|||||||
|
|
||||||
**2. `documentation` is NOT delegatable — the lifecycle auto-creates it.** You delegate ONLY the `code` subtask. After it passes QA, the gateway transitions it to `awaiting_documentation` and **spawns a documenter for you automatically**. Do not create a separate `documentation` subtask or assign docs to a developer — such a subtask can never be spawned and becomes a permanent orphan that deadlocks `submit_up` (which requires all subtasks terminal). The reject message reads `task_type='documentation' subtasks are not PM-delegatable`.
|
**2. `documentation` is NOT delegatable — the lifecycle auto-creates it.** You delegate ONLY the `code` subtask. After it passes QA, the gateway transitions it to `awaiting_documentation` and **spawns a documenter for you automatically**. Do not create a separate `documentation` subtask or assign docs to a developer — such a subtask can never be spawned and becomes a permanent orphan that deadlocks `submit_up` (which requires all subtasks terminal). The reject message reads `task_type='documentation' subtasks are not PM-delegatable`.
|
||||||
|
|
||||||
**3. The `code` spine is sequential — one non-terminal `code` subtask per parent at a time.** The gateway allows AT MOST one non-terminal `code` subtask under a single parent (the same cap also applies to `planning` and `documentation`). A second `delegate(..., task_type='code')` while the first is still in flight is rejected with `parent already has a non-terminal task_type='code' subtask`. This is BY DESIGN — one repo on one branch shouldn't have two simultaneous code subtasks. When you hit it:
|
**3. The `code` spine is capped at two per parent — one per cell dev.** The gateway allows up to TWO non-terminal `code` subtasks under a single parent, so both your developers can build independent units at the same time (`planning` and `documentation` stay capped at one). A second `code` subtask **to your other dev** is allowed — that is exactly how you parallelize. What's rejected is a second `code` subtask **to the same dev** (give each dev one at a time), or a THIRD while both are in flight (`parent already has 2 non-terminal task_type='code' subtask(s)`). When you hit the cap:
|
||||||
- **Do NOT** retry with a different `task_type` (`research`/`design`) to sneak a second sibling past the cap — that creates orphans.
|
- **Do NOT** retry with a different `task_type` (`research`/`design`) to sneak an extra sibling past the cap — that creates orphans.
|
||||||
- The correct move is `i_am_idle()` — the closure dispatcher respawns you when the in-flight child needs review or completes.
|
- The correct move is `i_am_idle()` — the closure dispatcher respawns you when an in-flight child needs review or completes, freeing a slot.
|
||||||
- Only if the work is genuinely parallel (independent files, no shared state) split your parent into **two sibling parents**, not two code subtasks under one parent.
|
- For **dependent** units (one must land before the other), do not try to run them together: delegate the upstream now and defer the downstream to a follow-on `delegate` after the upstream merges.
|
||||||
|
|
||||||
### Sizing — split oversized subtasks (READ THIS BEFORE DELEGATING)
|
### Sizing — split oversized subtasks (READ THIS BEFORE DELEGATING)
|
||||||
|
|
||||||
One subtask = one focused concern a single developer can finish and a single QA pass can verify. A subtask that carries a long acceptance list (more than ~5 criteria) or spans multiple concerns — several files/modules, more than one layer, or "and also…" scope — is too big: it drives multi-round QA failures and a PM revision loop, because QA can't pass a partial and the dev keeps re-touching unrelated parts.
|
One subtask = one focused concern a single developer can finish and a single QA pass can verify. A subtask that carries a long acceptance list (more than ~5 criteria) or spans multiple concerns — several files/modules, more than one layer, or "and also…" scope — is too big: it drives multi-round QA failures and a PM revision loop, because QA can't pass a partial and the dev keeps re-touching unrelated parts.
|
||||||
|
|
||||||
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 2–4 acceptance criteria and its own dev→QA pass. Sequence them with dependencies when one must land before the next (see the cross-cell sequencing rules). Prefer three small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
|
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 2–4 acceptance criteria and its own dev→QA pass. **Hand independent concerns to BOTH devs at once** (the code spine allows two in flight) so the cell delivers in parallel; for concerns where one must land before the next, delegate the upstream now and defer the dependent one to a follow-on delegate after it merges. Prefer several small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
|
||||||
|
|
||||||
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
|
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
|
||||||
|
|
||||||
@@ -180,11 +180,13 @@ The PM journal is what makes the cell legible to Main PM and CEO. Skipping entri
|
|||||||
- ❌ Calling `complete` on a parent task whose subtasks aren't all terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`. Wait for the closure dispatcher to bring you back.
|
- ❌ Calling `complete` on a parent task whose subtasks aren't all terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`. Wait for the closure dispatcher to bring you back.
|
||||||
- ❌ Assigning a subtask to another cell's developer or to Main PM. Subtasks must go to a dev slug in YOUR cell. The gateway rejects cross-cell delegation chains.
|
- ❌ Assigning a subtask to another cell's developer or to Main PM. Subtasks must go to a dev slug in YOUR cell. The gateway rejects cross-cell delegation chains.
|
||||||
- ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`.
|
- ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`.
|
||||||
- ❌ Concluding "I cannot delegate" after a delegate-rejection that follows
|
- ❌ Concluding "I cannot delegate" after a cap rejection. With two `code`
|
||||||
a successful delegate. The spine-cap reject (`parent already has a
|
subtasks already in flight (both devs busy) the gateway rejects a third
|
||||||
non-terminal task_type='code' subtask`) means a previous delegate
|
(`parent already has 2 non-terminal task_type='code' subtask(s)`) — that
|
||||||
already covered this. Verify with `triage()`; if the dev subtask is
|
means the cell is already at full parallel capacity, not that you failed.
|
||||||
in flight, idle and let the chain progress.
|
Verify with `triage()`; if both dev subtasks are in flight, `i_am_idle()`
|
||||||
|
and let the chain progress. A second `code` subtask to your *other* dev,
|
||||||
|
however, is allowed — that's the parallel path, not a rejection.
|
||||||
|
|
||||||
## When the gateway returns an error
|
## When the gateway returns an error
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ The description is a **brief**, not a spec. The Cell PM and its dev design and b
|
|||||||
- ✅ "Users author a task by conversing with an assistant; the page must end in a human-confirmed task creation. Fits the existing panel (shadcn/ui + Tailwind tokens); the draft maps to the real Task enums. **Design the UX and propose the layout.**"
|
- ✅ "Users author a task by conversing with an assistant; the page must end in a human-confirmed task creation. Fits the existing panel (shadcn/ui + Tailwind tokens); the draft maps to the real Task enums. **Design the UX and propose the layout.**"
|
||||||
- ✅ Goal + the contract to honor (e.g. "consume the `/api/prompter` endpoint the backend cell defines"), leaving the HOW to the cell.
|
- ✅ Goal + the contract to honor (e.g. "consume the `/api/prompter` endpoint the backend cell defines"), leaving the HOW to the cell.
|
||||||
|
|
||||||
Keep it to goal + constraints; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW.
|
**Forward the work-unit breakdown — don't flatten it.** The upstream draft's "The Work" already enumerates this cell's work as independently-shippable units in dependency order. Carry that breakdown into the brief: list the units, note which are independent of each other, and tell the Cell PM to refine each unit into its own developer leaf so both cell developers can build at the same time (aim for at least two parallel units where the work genuinely splits). Do NOT compress the units into one "build all of it" slice — that recreates the oversized-task problem one level down and is how acceptance criteria get dropped. If the upstream draft did not break the work down, do that breakdown yourself before you delegate.
|
||||||
|
|
||||||
|
Keep it to goal + constraints + the unit breakdown; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW.
|
||||||
7. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate.
|
7. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate.
|
||||||
8. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff + cell PM's `reflect` note + each underlying dev/QA/doc journal aggregate -> `note(scope='decision', text='merge rationale')` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch.
|
8. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff + cell PM's `reflect` note + each underlying dev/QA/doc journal aggregate -> `note(scope='decision', text='merge rationale')` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch.
|
||||||
9. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> read every cell's journal aggregate -> `note(scope='reflect', text='<aggregate cross-cell review>')` -> `note(scope='decision', text='complete-rationale')` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there.
|
9. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> read every cell's journal aggregate -> `note(scope='reflect', text='<aggregate cross-cell review>')` -> `note(scope='decision', text='complete-rationale')` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there.
|
||||||
@@ -164,6 +166,7 @@ You are the integration layer between Cells and CEO. Your journal is what tells
|
|||||||
- ❌ Re-researching the codebase from scratch and re-deriving scope the Product Owner already handed you. Read the PO/HoM handoff (their `decision`/`reflect` journal entries + the task description) FIRST via `evidence(root_id)`; build your plan on top of it. Ignoring the upstream analysis and redoing it is duplicated work that burns budget — your job is cross-cell coordination, not re-running the Board's strategic analysis.
|
- ❌ Re-researching the codebase from scratch and re-deriving scope the Product Owner already handed you. Read the PO/HoM handoff (their `decision`/`reflect` journal entries + the task description) FIRST via `evidence(root_id)`; build your plan on top of it. Ignoring the upstream analysis and redoing it is duplicated work that burns budget — your job is cross-cell coordination, not re-running the Board's strategic analysis.
|
||||||
- ❌ Assigning a code subtask directly to a developer slug. Always to a Cell PM. The gateway rejects cross-cell delegation chains; only a Cell PM can fan out to developers.
|
- ❌ Assigning a code subtask directly to a developer slug. Always to a Cell PM. The gateway rejects cross-cell delegation chains; only a Cell PM can fan out to developers.
|
||||||
- ❌ Creating > 12 subtasks under a single root. One subtask per cell that needs work; rarely should a root touch more than three cells. The gateway returns an `invalid_state` envelope whose `message` reads "parent already has N subtasks; cap is 12" past the hard cap.
|
- ❌ Creating > 12 subtasks under a single root. One subtask per cell that needs work; rarely should a root touch more than three cells. The gateway returns an `invalid_state` envelope whose `message` reads "parent already has N subtasks; cap is 12" past the hard cap.
|
||||||
|
- ❌ Flattening the upstream work-unit breakdown into a single "build it all" slice. The draft enumerates each cell's work as independent, dependency-ordered units — forward them so the Cell PM can run both developers in parallel. Collapsing them serializes the cell and is how acceptance criteria get dropped.
|
||||||
- ❌ Calling `delegate` before `i_will_plan`. The gateway returns an `invalid_state` envelope whose `message` reads "parent task <id> is in pending; must be in_progress to accept subtasks" — `remediate` tells you to call `i_will_plan` first.
|
- ❌ Calling `delegate` before `i_will_plan`. The gateway returns an `invalid_state` envelope whose `message` reads "parent task <id> is in pending; must be in_progress to accept subtasks" — `remediate` tells you to call `i_will_plan` first.
|
||||||
- ❌ Running `Bash git ...` or `Bash curl http://orchestrator/...`. You have no commit verb; `complete` and `escalate_to_ceo` cover everything you need. Raw git/curl is denied at the bash-guard layer.
|
- ❌ Running `Bash git ...` or `Bash curl http://orchestrator/...`. You have no commit verb; `complete` and `escalate_to_ceo` cover everything you need. Raw git/curl is denied at the bash-guard layer.
|
||||||
- ❌ Trying to claim a code task yourself. The gateway returns a `not_authorized` envelope whose `message` reads "Main PM cannot claim code tasks. PMs coordinate, never execute code." If a code task lands on you by mistake, escalate.
|
- ❌ Trying to claim a code task yourself. The gateway returns a `not_authorized` envelope whose `message` reads "Main PM cannot claim code tasks. PMs coordinate, never execute code." If a code task lands on you by mistake, escalate.
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ You are spawned scoped to a **project** (one repo) or a **product** (a set of re
|
|||||||
A well-formed task (the house standard):
|
A well-formed task (the house standard):
|
||||||
- **Objective** — the outcome, not the implementation.
|
- **Objective** — the outcome, not the implementation.
|
||||||
- **What This Builds** — the concrete artifacts.
|
- **What This Builds** — the concrete artifacts.
|
||||||
- **The Work** — the per-cell breakdown (one cell for small work; Backend / Frontend / UX-UI for a feature).
|
- **The Work** — the per-cell breakdown (one cell for small work; Backend / Frontend / UX-UI for a feature), each cell's work split into independently-shippable units so its two developers can build in parallel.
|
||||||
- **Notes** — constraints, what to reuse, anything to confirm.
|
- **Notes** — constraints, what to reuse, anything to confirm.
|
||||||
- **Success Criteria** — verifiable acceptance criteria.
|
- **Success Criteria** — verifiable acceptance criteria.
|
||||||
|
|
||||||
|
## Decomposing the work
|
||||||
|
|
||||||
|
Each cell has two developers who work at the same time, so a spec that can only be built in one straight line wastes half the cell. Break every cell's work into the **smallest independently-shippable units** — one unit is a single change a developer can build, test, and open one PR for on its own. Where the work genuinely splits, aim for at least two units per participating cell so both developers can start at once. This is a target, not a quota: don't pad a one-line change into fake pieces.
|
||||||
|
|
||||||
|
Order the units by dependency, never by preference. Put one unit before another only when the second truly needs the first; units with no dependency between them are meant to run **in parallel**, so write them so they can. Within a cell's `items`, list one unit per line, dependency-first, and say plainly when two are independent (e.g. "independent of the API change — runs alongside it"). Call out cross-cell dependencies in Notes — UX usually precedes Frontend and Backend, and a shared contract precedes both sides that consume it.
|
||||||
|
|
||||||
|
Keep each unit to one concern. A unit that bundles several unrelated changes is how acceptance criteria get dropped — split it. But never split so far that you serialize work that could have run together: many small **and parallel** is fast; many small **and serial** is slower than one big task. The PM chain inherits this breakdown — the Main PM maps your units onto cells and each cell PM refines a unit into developer leaves — so the cleaner your units, the better the whole cell delivers.
|
||||||
|
|
||||||
## Read first, then ask
|
## Read first, then ask
|
||||||
|
|
||||||
Before your first question, use `Read` / `Grep` / `Glob` and the read-only git verbs to learn the real surface. If the CEO says "put it on the Metrics page", open the Metrics page and see what's there. If they mention an endpoint, find it. Spawn research subagents (`Task`) when the codebase is large. **Ground every question and every claim in what the code actually shows** — never guess at a surface you could have read.
|
Before your first question, use `Read` / `Grep` / `Glob` and the read-only git verbs to learn the real surface. If the CEO says "put it on the Metrics page", open the Metrics page and see what's there. If they mention an endpoint, find it. Spawn research subagents (`Task`) when the codebase is large. **Ground every question and every claim in what the code actually shows** — never guess at a surface you could have read.
|
||||||
@@ -49,7 +57,7 @@ When — and only when — you can write a complete spec:
|
|||||||
"objective": "The outcome, not the implementation.",
|
"objective": "The outcome, not the implementation.",
|
||||||
"what_this_builds": ["concrete artifact", "another"],
|
"what_this_builds": ["concrete artifact", "another"],
|
||||||
"the_work": [
|
"the_work": [
|
||||||
{"team": "backend", "summary": "what this cell does", "items": ["step", "step"]}
|
{"team": "backend", "summary": "what this cell does", "items": ["one independently-shippable unit", "another unit — dependency-ordered, parallel where it can be"]}
|
||||||
],
|
],
|
||||||
"notes": ["constraint or what to reuse"],
|
"notes": ["constraint or what to reuse"],
|
||||||
"acceptance_criteria": ["verifiable criterion", "another"],
|
"acceptance_criteria": ["verifiable criterion", "another"],
|
||||||
@@ -62,7 +70,7 @@ When — and only when — you can write a complete spec:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `team` is the lead cell for single-cell work: one of `backend`, `frontend`, `ux_ui`. `scale` is `single` (one cell) or `multi` (board-led across cells).
|
- `team` is the lead cell for single-cell work: one of `backend`, `frontend`, `ux_ui`. `scale` is `single` (one cell) or `multi` (board-led across cells). Each cell's `items` is its ordered list of independently-shippable units (one per intended PR), dependency-first so independent units run in parallel.
|
||||||
- Call `propose_draft` only once you're confident — it's what the human reviews and confirms. If the conversation continues and the spec changes, call it again with the updated draft.
|
- Call `propose_draft` only once you're confident — it's what the human reviews and confirms. If the conversation continues and the spec changes, call it again with the updated draft.
|
||||||
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
|
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
|
||||||
|
|
||||||
|
|||||||
@@ -3220,43 +3220,131 @@ class Choreographer:
|
|||||||
# tasks for be-pm; Cell PM created two code tasks for be-dev-1).
|
# tasks for be-pm; Cell PM created two code tasks for be-dev-1).
|
||||||
if guard := await self._delegate_sibling_dedup_guard(parent_task_id, inputs):
|
if guard := await self._delegate_sibling_dedup_guard(parent_task_id, inputs):
|
||||||
return guard
|
return guard
|
||||||
|
# Split-before-claim: reject an egregiously-bundled code leaf so the PM
|
||||||
|
# splits it before any dev can claim it.
|
||||||
|
if guard := self._delegate_sizing_guard(inputs):
|
||||||
|
return guard
|
||||||
# Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP
|
# Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP
|
||||||
return await self._delegate_lifecycle_guards(
|
return await self._delegate_lifecycle_guards(
|
||||||
pm_agent_id, parent_task_id, parent
|
pm_agent_id, parent_task_id, parent
|
||||||
)
|
)
|
||||||
|
|
||||||
_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset({"completed", "cancelled"})
|
_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset({"completed", "cancelled"})
|
||||||
_SPINE_TASK_TYPES: ClassVar[frozenset[str]] = frozenset(
|
# Per-parent concurrency cap by spine task_type. `code` is capped at the
|
||||||
{"code", "planning", "documentation"}
|
# number of developers in a cell (2) so both can build independent units in
|
||||||
)
|
# parallel; `planning` and `documentation` stay sequential (one at a time).
|
||||||
|
_SPINE_TYPE_CAPS: ClassVar[dict[str, int]] = {
|
||||||
|
"code": 2,
|
||||||
|
"planning": 1,
|
||||||
|
"documentation": 1,
|
||||||
|
}
|
||||||
|
# Split-before-claim sizing thresholds for a `code` leaf (by acceptance-
|
||||||
|
# criteria count). Above the nudge count we flag a possible split in the
|
||||||
|
# success envelope; above the hard count we reject so the PM splits the
|
||||||
|
# bundle before any dev can claim it.
|
||||||
|
_SIZING_NUDGE_AC_COUNT: ClassVar[int] = 5
|
||||||
|
_SIZING_HARD_AC_COUNT: ClassVar[int] = 8
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _code_leaf_ac_count(cls, inputs: DelegateInputs) -> int | None:
|
||||||
|
"""Acceptance-criteria count for a ``code`` subtask, else None.
|
||||||
|
|
||||||
|
Only ``code`` leaves are sized this way — ``planning`` briefs (main_pm
|
||||||
|
→ cell_pm) legitimately carry many criteria and are exempt.
|
||||||
|
"""
|
||||||
|
if str(inputs.task_type or "") != "code":
|
||||||
|
return None
|
||||||
|
return len(inputs.acceptance_criteria or [])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _delegate_sizing_guard(cls, inputs: DelegateInputs) -> Envelope | None:
|
||||||
|
"""Hard-block an egregiously-bundled code leaf (split-before-claim).
|
||||||
|
|
||||||
|
A ``code`` subtask carrying more than ``_SIZING_HARD_AC_COUNT``
|
||||||
|
acceptance criteria bundles too many independent concerns into one leaf:
|
||||||
|
QA can't pass a partial, the dev re-touches unrelated parts, and criteria
|
||||||
|
get dropped. Reject it so the PM splits the bundle before a dev ever
|
||||||
|
claims it. Moderate bundling is allowed but flagged (see
|
||||||
|
:meth:`_sizing_hint`).
|
||||||
|
"""
|
||||||
|
ac_count = cls._code_leaf_ac_count(inputs)
|
||||||
|
if ac_count is None or ac_count <= cls._SIZING_HARD_AC_COUNT:
|
||||||
|
return None
|
||||||
|
return Envelope.invalid_state(
|
||||||
|
message=(
|
||||||
|
f"code subtask bundles {ac_count} acceptance criteria — too many "
|
||||||
|
f"independent concerns for one leaf "
|
||||||
|
f"(cap {cls._SIZING_HARD_AC_COUNT})."
|
||||||
|
),
|
||||||
|
remediate=(
|
||||||
|
"Split this into smaller code subtasks before delegating — one "
|
||||||
|
"concern each, 2-4 acceptance criteria per subtask. Hand "
|
||||||
|
"independent concerns to both cell devs in parallel; sequence "
|
||||||
|
"dependent ones. A bundled leaf drives multi-round QA failures "
|
||||||
|
"and drops criteria."
|
||||||
|
),
|
||||||
|
context_briefing={},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sizing_hint(cls, inputs: DelegateInputs) -> str | None:
|
||||||
|
"""Soft nudge for a code leaf in the moderate sizing band.
|
||||||
|
|
||||||
|
Fires above the nudge count and below the hard cap (the hard cap rejects
|
||||||
|
outright). Surfaced in the delegate success envelope, never blocking.
|
||||||
|
"""
|
||||||
|
ac_count = cls._code_leaf_ac_count(inputs)
|
||||||
|
if ac_count is None or ac_count <= cls._SIZING_NUDGE_AC_COUNT:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"This code subtask carries {ac_count} acceptance criteria. If they "
|
||||||
|
"span more than one independent concern, consider splitting it so "
|
||||||
|
"each concern is its own subtask — and hand independents to both "
|
||||||
|
"devs in parallel. (Allowed, just flagged.)"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _spine_type_dup_envelope(
|
def _spine_type_dup_envelope(
|
||||||
new_type: str, sibling: Any, sib_assignee: str
|
new_type: str, sibling: Any, sib_assignee: str, cap: int = 1
|
||||||
) -> Envelope:
|
) -> Envelope:
|
||||||
"""Rule-1 rejection: spine-type concurrency cap hit."""
|
"""Rule-1 rejection: spine-type concurrency cap hit."""
|
||||||
|
capacity = (
|
||||||
|
f"The {new_type!r} spine is sequential — only one non-terminal at a time."
|
||||||
|
if cap == 1
|
||||||
|
else (
|
||||||
|
f"The {new_type!r} spine is capped at {cap} concurrent "
|
||||||
|
f"(one per cell developer) and both are already in flight."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parallel_hint = (
|
||||||
|
"If the work is genuinely parallel (two independent modules), "
|
||||||
|
"split this parent into two sibling parents instead of two code "
|
||||||
|
"subtasks under one parent.\n\n"
|
||||||
|
if cap == 1
|
||||||
|
else (
|
||||||
|
f"You may run up to {cap} code subtasks at once (one per dev) "
|
||||||
|
"when they touch independent files; a further one must wait for "
|
||||||
|
"an in-flight sibling to finish.\n\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
return Envelope.invalid_state(
|
return Envelope.invalid_state(
|
||||||
message=(
|
message=(
|
||||||
f"parent already has a non-terminal "
|
f"parent already has {cap} non-terminal "
|
||||||
f"task_type={new_type!r} subtask "
|
f"task_type={new_type!r} subtask(s) "
|
||||||
f"({sibling.id}, assigned_to={sib_assignee!r}, "
|
f"(e.g. {sibling.id}, assigned_to={sib_assignee!r}, "
|
||||||
f"status={sibling.status}). The {new_type!r} spine is "
|
f"status={sibling.status}). {capacity}"
|
||||||
f"sequential — only one non-terminal at a time."
|
|
||||||
),
|
),
|
||||||
remediate=(
|
remediate=(
|
||||||
"Drive the existing sibling to completion / "
|
"Drive an existing sibling to completion / cancel it before "
|
||||||
"cancel it before delegating another of the same "
|
"delegating another of the same type. "
|
||||||
"type. If the work is genuinely parallel (two "
|
+ parallel_hint
|
||||||
"independent modules), split this parent into "
|
+ "**DO NOT work around this by delegating again with a "
|
||||||
"two sibling parents instead of two code "
|
|
||||||
"subtasks under one parent.\n\n"
|
|
||||||
"**DO NOT work around this by delegating again with a "
|
|
||||||
"different task_type** (e.g. 'documentation' or "
|
"different task_type** (e.g. 'documentation' or "
|
||||||
"'research' as a 'verification' subtask). The lifecycle "
|
"'research' as a 'verification' subtask). The lifecycle "
|
||||||
"handles QA, documentation, and PM-review automatically "
|
"handles QA, documentation, and PM-review automatically "
|
||||||
"after the code subtask finishes — you do not create "
|
"after the code subtask finishes — you do not create "
|
||||||
"auxiliary subtasks for those roles. Call i_am_idle() "
|
"auxiliary subtasks for those roles. Call i_am_idle() "
|
||||||
"now and wait for the existing child to come back."
|
"now and wait for an existing child to come back."
|
||||||
),
|
),
|
||||||
context_briefing={},
|
context_briefing={},
|
||||||
)
|
)
|
||||||
@@ -3295,28 +3383,72 @@ class Choreographer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _sibling_dup_envelope(
|
def _sibling_cap_envelope(
|
||||||
cls, sibling: Any, new_type: str, new_team: str, new_assignee: str
|
cls,
|
||||||
|
siblings: list[Any],
|
||||||
|
new_type: str,
|
||||||
|
new_team: str,
|
||||||
|
new_assignee: str,
|
||||||
) -> Envelope | None:
|
) -> Envelope | None:
|
||||||
"""Apply Rule-1 then Rule-2 against one non-terminal sibling.
|
"""Apply Rule-2 (same-assignee) then Rule-1 (spine concurrency cap).
|
||||||
|
|
||||||
``code`` / ``documentation`` stay capped regardless of team —
|
Rule 2: a PM never gives one agent two non-terminal subtasks of the
|
||||||
a single repo on one branch shouldn't have two simultaneous code
|
same type under one parent.
|
||||||
subtasks. ``planning`` allows cross-team fanout (see
|
|
||||||
:meth:`_is_cross_team_planning`).
|
Rule 1: a parent may hold at most ``_SPINE_TYPE_CAPS[type]`` non-terminal
|
||||||
|
subtasks of a spine type. ``code`` is capped at 2 (one per cell dev) so
|
||||||
|
both developers can build independent units in parallel; ``planning``
|
||||||
|
and ``documentation`` stay at 1. ``planning`` on a different team does
|
||||||
|
not count toward the cap — that's main_pm's legitimate cross-cell fanout
|
||||||
|
(see :meth:`_is_cross_team_planning`).
|
||||||
"""
|
"""
|
||||||
sib_type = str(getattr(sibling, "task_type", ""))
|
live = [
|
||||||
sib_team = str(getattr(sibling, "team", "") or "")
|
s
|
||||||
sib_assignee = str(getattr(sibling, "assigned_to", "") or "")
|
for s in siblings
|
||||||
same_spine_type = new_type in cls._SPINE_TASK_TYPES and sib_type == new_type
|
if str(getattr(s, "status", "")) not in cls._TERMINAL_STATUSES
|
||||||
if same_spine_type and not cls._is_cross_team_planning(
|
]
|
||||||
new_type, new_team, sib_team
|
return cls._same_assignee_rejection(
|
||||||
):
|
live, new_type, new_assignee
|
||||||
return cls._spine_type_dup_envelope(new_type, sibling, sib_assignee)
|
) or cls._spine_cap_rejection(live, new_type, new_team)
|
||||||
if sib_assignee and sib_assignee == new_assignee and sib_type == new_type:
|
|
||||||
return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
|
@classmethod
|
||||||
|
def _same_assignee_rejection(
|
||||||
|
cls, live: list[Any], new_type: str, new_assignee: str
|
||||||
|
) -> Envelope | None:
|
||||||
|
"""Rule 2: a PM never gives one agent two same-type subtasks per parent."""
|
||||||
|
if not new_assignee:
|
||||||
|
return None
|
||||||
|
for sibling in live:
|
||||||
|
if (
|
||||||
|
str(getattr(sibling, "assigned_to", "") or "") == new_assignee
|
||||||
|
and str(getattr(sibling, "task_type", "")) == new_type
|
||||||
|
):
|
||||||
|
return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _spine_cap_rejection(
|
||||||
|
cls, live: list[Any], new_type: str, new_team: str
|
||||||
|
) -> Envelope | None:
|
||||||
|
"""Rule 1: at most ``_SPINE_TYPE_CAPS[type]`` non-terminal same-type."""
|
||||||
|
cap = cls._SPINE_TYPE_CAPS.get(new_type)
|
||||||
|
if cap is None:
|
||||||
|
return None
|
||||||
|
same_spine = [
|
||||||
|
s
|
||||||
|
for s in live
|
||||||
|
if str(getattr(s, "task_type", "")) == new_type
|
||||||
|
and not cls._is_cross_team_planning(
|
||||||
|
new_type, new_team, str(getattr(s, "team", "") or "")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if len(same_spine) < cap:
|
||||||
|
return None
|
||||||
|
blocker = same_spine[0]
|
||||||
|
return cls._spine_type_dup_envelope(
|
||||||
|
new_type, blocker, str(getattr(blocker, "assigned_to", "") or ""), cap=cap
|
||||||
|
)
|
||||||
|
|
||||||
async def _delegate_sibling_dedup_guard(
|
async def _delegate_sibling_dedup_guard(
|
||||||
self,
|
self,
|
||||||
parent_task_id: UUID,
|
parent_task_id: UUID,
|
||||||
@@ -3324,34 +3456,29 @@ class Choreographer:
|
|||||||
) -> Envelope | None:
|
) -> Envelope | None:
|
||||||
"""Block PM-decomposition over-spread (the smoke-run runaway pattern).
|
"""Block PM-decomposition over-spread (the smoke-run runaway pattern).
|
||||||
|
|
||||||
Two rules, both rooted in 'one lifecycle hop per parent':
|
Two rules, both rooted in bounded per-parent concurrency:
|
||||||
|
|
||||||
1. **Same-type concurrency cap**: a parent may have AT MOST one
|
1. **Same-type concurrency cap**: a parent may hold at most
|
||||||
non-terminal subtask of types ``code`` / ``planning`` /
|
``_SPINE_TYPE_CAPS[type]`` non-terminal subtasks of a spine type —
|
||||||
``documentation`` at any given time, regardless of assignee.
|
``code`` is capped at 2 (one per cell developer, so both build
|
||||||
Exception: ``planning`` subtasks on different
|
independent units in parallel); ``planning`` and ``documentation``
|
||||||
teams are allowed in parallel — that's main_pm's legitimate
|
stay at 1. Exception: ``planning`` subtasks on different teams do
|
||||||
cross-cell fanout.
|
not count toward the cap — that's main_pm's legitimate cross-cell
|
||||||
|
fanout.
|
||||||
2. **Same-assignee same-type** (fallback): a PM never delegates two
|
2. **Same-assignee same-type** (fallback): a PM never delegates two
|
||||||
``research``/``design``/``administrative`` subtasks to the same
|
subtasks of the same type to the same agent under the same parent
|
||||||
agent under the same parent.
|
(so the two parallel ``code`` slots always go to different devs).
|
||||||
|
|
||||||
Both rules surface the existing sibling id so the PM can finish
|
Both rules surface an existing sibling id so the PM can finish
|
||||||
or cancel it instead of guessing.
|
or cancel it instead of guessing.
|
||||||
"""
|
"""
|
||||||
siblings = await self.task.get_subtasks(parent_task_id)
|
siblings = await self.task.get_subtasks(parent_task_id)
|
||||||
new_type = str(inputs.task_type or "")
|
return self._sibling_cap_envelope(
|
||||||
new_team = str(inputs.team or "")
|
list(siblings),
|
||||||
new_assignee = str(inputs.assigned_to or "")
|
str(inputs.task_type or ""),
|
||||||
for sibling in siblings:
|
str(inputs.team or ""),
|
||||||
if str(getattr(sibling, "status", "")) in self._TERMINAL_STATUSES:
|
str(inputs.assigned_to or ""),
|
||||||
continue
|
)
|
||||||
envelope = self._sibling_dup_envelope(
|
|
||||||
sibling, new_type, new_team, new_assignee
|
|
||||||
)
|
|
||||||
if envelope is not None:
|
|
||||||
return envelope
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _delegate_static_guards(
|
async def _delegate_static_guards(
|
||||||
self,
|
self,
|
||||||
@@ -3708,6 +3835,9 @@ class Choreographer:
|
|||||||
verb="delegate",
|
verb="delegate",
|
||||||
)
|
)
|
||||||
await self._wire_ux_frontend_dependency(new_task, parent)
|
await self._wire_ux_frontend_dependency(new_task, parent)
|
||||||
|
hint = self._sizing_hint(inputs)
|
||||||
|
if hint is not None:
|
||||||
|
briefing = {**briefing, "sizing_hint": hint}
|
||||||
return Envelope.ok(
|
return Envelope.ok(
|
||||||
status="created",
|
status="created",
|
||||||
task_id=str(new_task.id),
|
task_id=str(new_task.id),
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Split-before-claim: a code leaf must not bundle too many concerns.
|
||||||
|
|
||||||
|
A `code` subtask carrying more acceptance criteria than the hard cap bundles
|
||||||
|
multiple independent concerns into one leaf — QA can't pass a partial and
|
||||||
|
criteria get dropped. The gateway rejects it at delegate time so the PM splits
|
||||||
|
the bundle before any dev can claim it. Moderate bundling is allowed but flagged
|
||||||
|
in the success envelope (the nudge). `planning` briefs are exempt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from roboco.services.gateway.choreographer._impl import Choreographer, DelegateInputs
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs(*, task_type: str, ac_count: int) -> DelegateInputs:
|
||||||
|
return DelegateInputs(
|
||||||
|
title="t",
|
||||||
|
description="d",
|
||||||
|
assigned_to="be-dev-1",
|
||||||
|
team="backend",
|
||||||
|
task_type=task_type,
|
||||||
|
nature="technical",
|
||||||
|
acceptance_criteria=[f"criterion {i}" for i in range(ac_count)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_code_leaf_is_allowed() -> None:
|
||||||
|
"""A focused code leaf (<= hard cap) passes the sizing guard."""
|
||||||
|
assert (
|
||||||
|
Choreographer._delegate_sizing_guard(_inputs(task_type="code", ac_count=4))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_leaf_at_hard_cap_is_allowed() -> None:
|
||||||
|
"""Exactly at the hard cap is still allowed; only strictly-above is blocked."""
|
||||||
|
cap = Choreographer._SIZING_HARD_AC_COUNT
|
||||||
|
assert (
|
||||||
|
Choreographer._delegate_sizing_guard(_inputs(task_type="code", ac_count=cap))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_egregiously_bundled_code_leaf_is_rejected() -> None:
|
||||||
|
"""A code leaf above the hard cap is rejected with split guidance."""
|
||||||
|
cap = Choreographer._SIZING_HARD_AC_COUNT
|
||||||
|
env = Choreographer._delegate_sizing_guard(
|
||||||
|
_inputs(task_type="code", ac_count=cap + 4)
|
||||||
|
)
|
||||||
|
assert env is not None
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "invalid_state", body
|
||||||
|
assert "Split this into smaller code subtasks" in (env.remediate or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_planning_brief_is_exempt_from_sizing() -> None:
|
||||||
|
"""planning subtasks (main_pm -> cell_pm) legitimately carry many criteria."""
|
||||||
|
assert (
|
||||||
|
Choreographer._delegate_sizing_guard(_inputs(task_type="planning", ac_count=20))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_nudge_below_threshold() -> None:
|
||||||
|
assert Choreographer._sizing_hint(_inputs(task_type="code", ac_count=5)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nudge_in_moderate_band() -> None:
|
||||||
|
"""Above the nudge count and below the hard cap: allowed but flagged."""
|
||||||
|
hint = Choreographer._sizing_hint(_inputs(task_type="code", ac_count=7))
|
||||||
|
assert hint is not None
|
||||||
|
assert "7 acceptance criteria" in hint
|
||||||
|
assert "parallel" in hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_nudge_for_planning() -> None:
|
||||||
|
assert (
|
||||||
|
Choreographer._sizing_hint(_inputs(task_type="planning", ac_count=20)) is None
|
||||||
|
)
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
"""Task #157: spine-cap allows cross-team planning fanout.
|
"""Spine-cap concurrency rules for delegated subtasks.
|
||||||
|
|
||||||
Pre-fix:
|
The per-parent cap is type-aware (`_SPINE_TYPE_CAPS`):
|
||||||
main_pm delegates a planning subtask to be-pm (backend cell). When
|
- ``code``: 2 — one per cell developer, so both build independent units in
|
||||||
it then tries to delegate a second planning subtask to fe-pm
|
parallel. A third concurrent code subtask is rejected; a second code
|
||||||
(frontend cell), the spine-cap rejects because there's already a
|
subtask to the SAME developer is rejected (same-assignee rule).
|
||||||
non-terminal task_type='planning' under the parent. Pre-gateway
|
- ``planning`` / ``documentation``: 1.
|
||||||
allowed this parallel cross-cell pattern; the gateway over-applied
|
|
||||||
the over-decomposition cap.
|
|
||||||
|
|
||||||
Fix:
|
Cross-team exemption:
|
||||||
`_sibling_dup_envelope` skips the spine-cap when:
|
- ``planning`` on a different team does NOT count toward the cap — that's
|
||||||
- new task_type == "planning"
|
main_pm's legitimate cross-cell fanout (be-pm + fe-pm + ux-pm in parallel).
|
||||||
- new team != sibling team (both non-empty)
|
- ``code`` / ``documentation`` count regardless of team (a parent's code
|
||||||
Other combinations stay capped:
|
spine is bounded by the two devs in its one cell).
|
||||||
- same-team planning: still over-decomposition (real bug)
|
|
||||||
- code / documentation regardless of team: a single repo on one
|
|
||||||
branch shouldn't have two simultaneous code subtasks
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,21 +20,27 @@ from unittest.mock import MagicMock
|
|||||||
from roboco.services.gateway.choreographer._impl import Choreographer
|
from roboco.services.gateway.choreographer._impl import Choreographer
|
||||||
|
|
||||||
|
|
||||||
def _sibling(*, task_type: str, team: str, status: str = "pending") -> MagicMock:
|
def _sibling(
|
||||||
|
*,
|
||||||
|
task_type: str,
|
||||||
|
team: str,
|
||||||
|
status: str = "pending",
|
||||||
|
assignee: str = "some-pm",
|
||||||
|
) -> MagicMock:
|
||||||
sib = MagicMock()
|
sib = MagicMock()
|
||||||
sib.id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
|
sib.id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
|
||||||
sib.status = status
|
sib.status = status
|
||||||
sib.task_type = task_type
|
sib.task_type = task_type
|
||||||
sib.team = team
|
sib.team = team
|
||||||
sib.assigned_to = "some-pm"
|
sib.assigned_to = assignee
|
||||||
return sib
|
return sib
|
||||||
|
|
||||||
|
|
||||||
def test_cross_team_planning_fanout_is_allowed() -> None:
|
def test_cross_team_planning_fanout_is_allowed() -> None:
|
||||||
"""main-pm: planning→be-pm (backend) exists; planning→fe-pm (frontend) must pass."""
|
"""main-pm: planning→be-pm (backend) exists; planning→fe-pm (frontend) must pass."""
|
||||||
sib = _sibling(task_type="planning", team="backend")
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
||||||
env = Choreographer._sibling_dup_envelope(
|
env = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib,
|
siblings=[sib],
|
||||||
new_type="planning",
|
new_type="planning",
|
||||||
new_team="frontend",
|
new_team="frontend",
|
||||||
new_assignee="fe-pm",
|
new_assignee="fe-pm",
|
||||||
@@ -52,9 +53,9 @@ def test_cross_team_planning_fanout_is_allowed() -> None:
|
|||||||
|
|
||||||
def test_cross_team_planning_third_cell_also_allowed() -> None:
|
def test_cross_team_planning_third_cell_also_allowed() -> None:
|
||||||
"""Third cell (ux_ui) is also a valid cross-team planning fanout target."""
|
"""Third cell (ux_ui) is also a valid cross-team planning fanout target."""
|
||||||
sib = _sibling(task_type="planning", team="backend")
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
||||||
env = Choreographer._sibling_dup_envelope(
|
env = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib,
|
siblings=[sib],
|
||||||
new_type="planning",
|
new_type="planning",
|
||||||
new_team="ux_ui",
|
new_team="ux_ui",
|
||||||
new_assignee="ux-pm",
|
new_assignee="ux-pm",
|
||||||
@@ -63,42 +64,70 @@ def test_cross_team_planning_third_cell_also_allowed() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_same_team_planning_still_rejected() -> None:
|
def test_same_team_planning_still_rejected() -> None:
|
||||||
"""Two planning subtasks on the SAME team is the real over-decomp pattern —
|
"""Two planning subtasks on the SAME team is over-decomposition —
|
||||||
must still be blocked by the spine-cap."""
|
must still be blocked by the spine-cap (planning cap is 1)."""
|
||||||
sib = _sibling(task_type="planning", team="backend")
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
||||||
env = Choreographer._sibling_dup_envelope(
|
env = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib,
|
siblings=[sib],
|
||||||
new_type="planning",
|
new_type="planning",
|
||||||
new_team="backend",
|
new_team="backend",
|
||||||
new_assignee="be-pm",
|
new_assignee="be-pm-2",
|
||||||
)
|
)
|
||||||
assert env is not None
|
assert env is not None
|
||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
assert body["error"] == "invalid_state", body
|
assert body["error"] == "invalid_state", body
|
||||||
|
|
||||||
|
|
||||||
def test_cross_team_code_still_rejected() -> None:
|
def test_one_code_sibling_allows_a_second_parallel_dev() -> None:
|
||||||
"""Code subtasks stay capped regardless of team — only one code task per
|
"""Code cap is 2 — one in flight to be-dev-1 must NOT block a second to
|
||||||
parent at a time. (Cross-team code under one parent is meaningless;
|
be-dev-2. This is the two-devs-per-cell parallelism the cap exists to allow."""
|
||||||
each cell's code work lives under its own cell-PM planning task.)"""
|
sib = _sibling(task_type="code", team="backend", assignee="be-dev-1")
|
||||||
sib = _sibling(task_type="code", team="backend")
|
env = Choreographer._sibling_cap_envelope(
|
||||||
env = Choreographer._sibling_dup_envelope(
|
siblings=[sib],
|
||||||
sibling=sib,
|
|
||||||
new_type="code",
|
new_type="code",
|
||||||
new_team="frontend",
|
new_team="backend",
|
||||||
new_assignee="fe-dev-1",
|
new_assignee="be-dev-2",
|
||||||
|
)
|
||||||
|
assert env is None, f"A second parallel code subtask must be allowed. Got: {env}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_code_siblings_reject_a_third() -> None:
|
||||||
|
"""Both cell devs busy (2 non-terminal code subtasks) → a third is capped."""
|
||||||
|
sibs = [
|
||||||
|
_sibling(task_type="code", team="backend", assignee="be-dev-1"),
|
||||||
|
_sibling(task_type="code", team="backend", assignee="be-dev-2"),
|
||||||
|
]
|
||||||
|
env = Choreographer._sibling_cap_envelope(
|
||||||
|
siblings=sibs,
|
||||||
|
new_type="code",
|
||||||
|
new_team="backend",
|
||||||
|
new_assignee="be-dev-1",
|
||||||
)
|
)
|
||||||
assert env is not None
|
assert env is not None
|
||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
assert body["error"] == "invalid_state", body
|
assert body["error"] == "invalid_state", body
|
||||||
|
|
||||||
|
|
||||||
def test_cross_team_documentation_still_rejected() -> None:
|
def test_second_code_to_same_dev_is_rejected() -> None:
|
||||||
"""Documentation subtasks stay capped regardless of team — single doc
|
"""The same developer never holds two code subtasks under one parent
|
||||||
pass per parent."""
|
(same-assignee rule), even though the cap is 2."""
|
||||||
sib = _sibling(task_type="documentation", team="backend")
|
sib = _sibling(task_type="code", team="backend", assignee="be-dev-1")
|
||||||
env = Choreographer._sibling_dup_envelope(
|
env = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib,
|
siblings=[sib],
|
||||||
|
new_type="code",
|
||||||
|
new_team="backend",
|
||||||
|
new_assignee="be-dev-1",
|
||||||
|
)
|
||||||
|
assert env is not None
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "invalid_state", body
|
||||||
|
|
||||||
|
|
||||||
|
def test_documentation_still_capped_at_one() -> None:
|
||||||
|
"""Documentation subtasks stay capped at 1 regardless of team."""
|
||||||
|
sib = _sibling(task_type="documentation", team="backend", assignee="be-doc")
|
||||||
|
env = Choreographer._sibling_cap_envelope(
|
||||||
|
siblings=[sib],
|
||||||
new_type="documentation",
|
new_type="documentation",
|
||||||
new_team="frontend",
|
new_team="frontend",
|
||||||
new_assignee="fe-doc",
|
new_assignee="fe-doc",
|
||||||
@@ -107,22 +136,22 @@ def test_cross_team_documentation_still_rejected() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_planning_with_missing_team_still_rejected() -> None:
|
def test_planning_with_missing_team_still_rejected() -> None:
|
||||||
"""If either side has no team attribute (defensive), fall back to the
|
"""If either side has no team (defensive), fall back to the strict cap —
|
||||||
strict cap. Don't let an empty-team escape hatch sneak past."""
|
don't let an empty-team escape hatch sneak a second planning past."""
|
||||||
sib_no_team = _sibling(task_type="planning", team="")
|
sib_no_team = _sibling(task_type="planning", team="", assignee="be-pm")
|
||||||
env = Choreographer._sibling_dup_envelope(
|
env = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib_no_team,
|
siblings=[sib_no_team],
|
||||||
new_type="planning",
|
new_type="planning",
|
||||||
new_team="backend",
|
new_team="backend",
|
||||||
new_assignee="be-pm",
|
new_assignee="be-pm-2",
|
||||||
)
|
)
|
||||||
assert env is not None, "Empty team on sibling must NOT bypass the cap"
|
assert env is not None, "Empty team on sibling must NOT bypass the cap"
|
||||||
|
|
||||||
sib = _sibling(task_type="planning", team="backend")
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
||||||
env2 = Choreographer._sibling_dup_envelope(
|
env2 = Choreographer._sibling_cap_envelope(
|
||||||
sibling=sib,
|
siblings=[sib],
|
||||||
new_type="planning",
|
new_type="planning",
|
||||||
new_team="",
|
new_team="",
|
||||||
new_assignee="be-pm",
|
new_assignee="be-pm-2",
|
||||||
)
|
)
|
||||||
assert env2 is not None, "Empty team on new task must NOT bypass the cap"
|
assert env2 is not None, "Empty team on new task must NOT bypass the cap"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def test_spine_cap_remediate_forbids_task_type_workaround() -> None:
|
|||||||
new_type="code",
|
new_type="code",
|
||||||
sibling=sibling,
|
sibling=sibling,
|
||||||
sib_assignee="be-dev-1",
|
sib_assignee="be-dev-1",
|
||||||
|
cap=2,
|
||||||
)
|
)
|
||||||
remediate = env.remediate or ""
|
remediate = env.remediate or ""
|
||||||
assert "DO NOT work around" in remediate, (
|
assert "DO NOT work around" in remediate, (
|
||||||
@@ -43,6 +44,7 @@ def test_spine_cap_remediate_warns_about_verification_subtasks() -> None:
|
|||||||
new_type="code",
|
new_type="code",
|
||||||
sibling=sibling,
|
sibling=sibling,
|
||||||
sib_assignee="be-dev-1",
|
sib_assignee="be-dev-1",
|
||||||
|
cap=2,
|
||||||
)
|
)
|
||||||
remediate = env.remediate or ""
|
remediate = env.remediate or ""
|
||||||
# Anti-pattern names — must appear so the model pattern-matches its own behavior.
|
# Anti-pattern names — must appear so the model pattern-matches its own behavior.
|
||||||
@@ -57,5 +59,6 @@ def test_spine_cap_envelope_is_invalid_state() -> None:
|
|||||||
new_type="code",
|
new_type="code",
|
||||||
sibling=sibling,
|
sibling=sibling,
|
||||||
sib_assignee="be-dev-1",
|
sib_assignee="be-dev-1",
|
||||||
|
cap=2,
|
||||||
)
|
)
|
||||||
assert env.error == "invalid_state"
|
assert env.error == "invalid_state"
|
||||||
|
|||||||
Reference in New Issue
Block a user