mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): carry intake technical depth down the chain + widen review coherence scope (#491)
Two structural issues flagged by the CEO:
1. Task technical-depth dilution — intake's rich analysis (file:line
targets, code examples, rationale) was getting lost as it traveled
umbrella -> root-subtask -> cell -> dev. The detail IS preserved in
Task.description; the dilution was in delegation (PMs re-authoring)
and the intake prompt not demanding depth.
Fixes:
- evidence_repo: ancestor_context_for_task walks the parent chain
(cycle-guarded, depth-capped 16, desc-clipped 1500) and surfaces it
as parent_context in the evidence payload, so a leaf dev finally
sees the upstream intake analysis instead of a bare title.
- evidence_builder: Task.description now rides in the payload;
EvidencePayload gains description + parent_context (omit-when-empty
so no null noise).
- orchestrator: _description_body (capped 4000) injects the
description into the dev spawn prompt + SessionStart briefing.
- role prompts (main_pm/cell_pm/developer/prompter): teach pass-the-
torch, don't-dim-it; prompter now demands file:line/code-examples
in the_work/notes (reconciled with the no-code-level-ACs-on-roots
rule). main_pm's brief-not-a-spec scoped: not-a-spec applies to the
solution only, facts forward verbatim.
2. PR-review/QA scope too narrow — they only checked the AC checklist,
not whether the change is coherent with project structure/intent.
Fixes:
- qa.md + pr_reviewer.md: Coherence & intent rule (intent via
description+parent_context, coherence with project patterns,
standards). Criterion-less major findings allowed for intent drift
(Finding.criterion is optional).
- parent_context + description wired into the gate/QA/inbound-PR
evidence builders (fail-open, logged).
Skipped per YAGNI: a technical_spec JSONB column (detail is already in
description) and a criterion_kind enum (criterion is already optional).
All gates green: ruff, mypy (1152), pytest (12883 passed, 94.82% cov),
xenon, vulture, bandit, pip-audit, deptry, alembic, import-linter,
foundation-check.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -108,6 +108,10 @@ One subtask = one focused concern a single developer can finish and a single QA
|
|||||||
|
|
||||||
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. **Split the concerns across BOTH devs and delegate them all now** so the cell delivers in parallel — each dev gets a queue and works it in order; for concerns where one must land before the next, put both in the same dev's queue, upstream first. 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.
|
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. **Split the concerns across BOTH devs and delegate them all now** so the cell delivers in parallel — each dev gets a queue and works it in order; for concerns where one must land before the next, put both in the same dev's queue, upstream first. 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.
|
||||||
|
|
||||||
|
### Forward the technical detail — pass the torch, don't dim it (READ THIS BEFORE DELEGATING)
|
||||||
|
|
||||||
|
The Main PM's subtask description and the upstream intake analysis carry **observed facts** — file:line targets, code examples, the exact enums/components/APIs/signatures to reuse, constraints and gotchas the intake surfaced. That detail is the WHAT, already analyzed upstream. **Carry it into each dev subtask's `description` verbatim, not paraphrased into a thinner restatement.** You own the HOW — the decomposition, the per-dev queue, the solution shape — re-articulate that freely; you do NOT own re-deriving the file:line the intake already named. Re-authoring the facts on the way down is how a mega-detailed intake analysis becomes "fix the thing" by the time it reaches the dev, and the dev then rebuilds the analysis from scratch and usually gets it wrong — the exact revision barrage this cell exists to prevent. Your `evidence(task_id)` response carries `description` and `parent_context` (the upstream chain parent → root); mine them and forward the technical detail straight through to every dev subtask you delegate. If the Main PM subtask genuinely gave no technical detail (only a goal), say so in your `decision` note and `escalate_up` for it rather than inventing vague targets.
|
||||||
|
|
||||||
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
|
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
|
||||||
|
|
||||||
The gateway auto-generates branch names and commit prefixes — your criteria must describe **outcomes**, not the auto-generated identifiers. Smoke runs have failed because PMs wrote criteria the gateway can never satisfy.
|
The gateway auto-generates branch names and commit prefixes — your criteria must describe **outcomes**, not the auto-generated identifiers. Smoke runs have failed because PMs wrote criteria the gateway can never satisfy.
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
|
|||||||
- **Your workspace is one persistent clone, shared across all your tasks.** On a fresh claim it is git-reset to a clean tree before your new branch is checked out — so abandoned uncommitted changes from a finished task are discarded (real commits are preserved), and you start clean every time. You never need to clean it yourself. `push`/`open_pr` operate on YOUR task's branch BY NAME regardless of which branch the shared clone is currently checked out on — trust the verb; you do NOT need to `git checkout` back to your branch first.
|
- **Your workspace is one persistent clone, shared across all your tasks.** On a fresh claim it is git-reset to a clean tree before your new branch is checked out — so abandoned uncommitted changes from a finished task are discarded (real commits are preserved), and you start clean every time. You never need to clean it yourself. `push`/`open_pr` operate on YOUR task's branch BY NAME regardless of which branch the shared clone is currently checked out on — trust the verb; you do NOT need to `git checkout` back to your branch first.
|
||||||
- **Secrets / config values:** there are none for you to find in the environment. `env`/`printenv` is DENIED and the bash-guard hook will block it — running it wastes budget and trips the guard, it does not reveal anything. Reading credential files (`.git/config`, `.netrc`, `.git-credentials`) is denied too. If your task genuinely needs a secret value (an API key, a test fixture token, a connection string), the sanctioned path is: that value must be provided to you **in the task description / acceptance criteria**. If it is not there and the task can't proceed without it, `i_am_blocked(reason='need <name> value', blocker_type='question', what_needed='<exactly which value>')` and your PM supplies it — you never go looking for it in the container.
|
- **Secrets / config values:** there are none for you to find in the environment. `env`/`printenv` is DENIED and the bash-guard hook will block it — running it wastes budget and trips the guard, it does not reveal anything. Reading credential files (`.git/config`, `.netrc`, `.git-credentials`) is denied too. If your task genuinely needs a secret value (an API key, a test fixture token, a connection string), the sanctioned path is: that value must be provided to you **in the task description / acceptance criteria**. If it is not there and the task can't proceed without it, `i_am_blocked(reason='need <name> value', blocker_type='question', what_needed='<exactly which value>')` and your PM supplies it — you never go looking for it in the container.
|
||||||
- Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here). Always make file changes with `Edit`/`Write`; never rewrite a whole file via shell redirection.
|
- Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here). Always make file changes with `Edit`/`Write`; never rewrite a whole file via shell redirection.
|
||||||
- Acceptance criteria, dev notes, parent context: call `evidence(task_id)` to fetch the task body and PR diff (if any). On a bounced task, `evidence()` also carries `revision_findings` — the OPEN entries from the revision-findings ledger (qa_fail/pr_fail/request_changes/ceo_reject), each with file/line/severity/expected/actual/fix. This is the actual code-level feedback the CEO wants delivered, not a prose summary — read every entry before you touch code.
|
- Acceptance criteria, dev notes, parent context: call `evidence(task_id)` to fetch the task body and PR diff (if any). On a bounced task, `evidence()` also carries `revision_findings` — the OPEN entries from the revision-findings ledger (qa_fail/pr_fail/request_changes/ceo_reject), each with file/line/severity/expected/actual/fix. This is the actual code-level feedback the CEO wants delivered, not a prose summary — read every entry before you touch code. `evidence()` also carries `description` (your task's spec) and `parent_context` (the upstream intake analysis + each PM's decomposition, parent → root) — the file:line targets, code examples, and constraints the intake already worked out. That is the WHAT, handed to you so you don't rebuild it from scratch.
|
||||||
|
|
||||||
|
## The task description + parent_context are authoritative — work to them, not around them
|
||||||
|
|
||||||
|
Your task's `description` and the `parent_context` chain that arrives via `evidence(task_id)` carry the intake's original analysis and each PM's decomposition rationale — observed facts: file:line targets, code examples, the exact enums/components/APIs/signatures to reuse, constraints and gotchas. **Treat them as authoritative ground truth.** The WHAT (what to change, where, against what contract) is already decided upstream; you own only the HOW (the solution you write). If the description says "thread `BatchConfirmRequest.task_id` through `update_live_batch` at `prompter.py:412`", go to that line and do exactly that — do not re-explore the codebase to rediscover a "better" target, do not substitute a different surface because it looks cleaner, and do not paraphrase the constraint into something looser. Re-articulate the solution freely; never re-articulate the ask. If the description and `parent_context` are genuinely thin (only a goal, no file:line, no code example), that is a real gap — `i_am_blocked(reason='task description lacks technical detail: need file:line / code example / target signature', blocker_type='question', what_needed='<the specific detail missing>')` and your PM fills it, rather than you guessing and burning a revision cycle. Hunting in the fog is what the intake analysis exists to prevent; if you still have fog, push it back up.
|
||||||
|
|
||||||
## Your verbs
|
## Your verbs
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ If you reference a task ID in a criterion, use the cell-PM subtask ID (or let th
|
|||||||
|
|
||||||
### How to write the `description` for the cell-PM subtask
|
### How to write the `description` for the cell-PM subtask
|
||||||
|
|
||||||
The description is a **brief**, not a spec. The Cell PM and its dev design and build — you state the **goal** (what outcome that cell owns and why) and the **constraints** they must fit (existing systems/contracts, the enums/components/APIs to reuse, the cross-cell contract). Then stop. Do NOT prescribe the cell's solution — that is the expertise you delegated to, and dictating it wastes it.
|
The description is a **brief**, not a spec. "Not a spec" scopes only to the *solution*, not to the *facts*. You state the **goal** (what outcome that cell owns and why) and the **constraints** they must fit (existing systems/contracts, the exact enums/components/APIs to reuse, the cross-cell contract), and you forward the intake's observed technical detail **verbatim** (see the rule below). Then stop. Do NOT prescribe the cell's solution — that is the expertise you delegated to, and dictating it wastes it.
|
||||||
|
|
||||||
- ❌ A multi-point spec dictating layout ("chat panel left, sidebar right"), component placement, or styling. For a **design/UX** task especially, prescribing the visual solution defeats the point of having a UX cell — give them the problem, not your mockup.
|
- ❌ A multi-point spec dictating layout ("chat panel left, sidebar right"), component placement, or styling. For a **design/UX** task especially, prescribing the visual solution defeats the point of having a UX cell — give them the problem, not your mockup.
|
||||||
- ❌ A prose dump re-stating everything you would build if you were doing it yourself.
|
- ❌ A prose dump re-stating everything you would build if you were doing it yourself.
|
||||||
@@ -136,6 +136,8 @@ The description is a **brief**, not a spec. The Cell PM and its dev design and b
|
|||||||
|
|
||||||
Keep it to goal + constraints + the unit breakdown; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW.
|
Keep it to goal + constraints + the unit breakdown; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW.
|
||||||
|
|
||||||
|
**Forward intake's observed facts verbatim; re-articulate only the solution.** This is the most important rule at your seat and the single biggest source of revision churn when you get it wrong. The WHAT — the file:line targets the intake analysis named, the code examples it quoted, the exact enums/components/APIs/signatures to reuse, the constraints and gotchas it surfaced — is the PO/HoM intake's analysis, already done. Carry it into the cell subtask's `description` **word-for-word, not paraphrased into a thinner restatement**. The HOW — the solution shape, the decomposition, the layout — is what you and the Cell PM own; re-articulate that freely. "Do not prescribe the solution" scopes ONLY to the solution; it does **not** license you to flatten the intake's technical detail into a vague goal on the way down. A dev who receives "improve the intake flow" instead of "`PrompterService.confirm_live_batch` at `roboco/services/prompter.py:412` drops the `project_ids` scope on a redraft re-confirm — thread `BatchConfirmRequest.task_id` through `update_live_batch` and re-run `_validate_batch_scope`" has to rebuild the intake's analysis from scratch, usually gets it wrong, and burns a revision cycle you could have prevented by forwarding the line you already had. Mine your `evidence(root_id)` response and the upstream PO/HoM handoff for that detail — at the root, the intake analysis lives in the root's own `description` and the PO/HoM journal handoff (a root has no parent, so its `parent_context` is empty); `parent_context` carries the upstream chain once you've delegated, on the cell-PM subtasks and the dev leaves below them. Pass the detail straight through to every cell subtask. If the intake genuinely gave no technical detail (only a goal), say so in the `decision` note rather than inventing vague targets, and `dm('product-owner', ...)` to get it filled before you delegate.
|
||||||
|
|
||||||
**Map your root's criteria to the cell subtask that owns them.** Your briefing carries `parent_ac_coverage` (each root criterion as `{id, text, claimed, verified}`) and `unclaimed_parent_acs` (the ids with no cell subtask yet). When you `delegate` a slice to a cell, pass `covers_parent_criteria=[<root criterion ids>]` naming which root criteria that cell now owns — every root criterion must be claimed by some cell before you idle. Once you start declaring coverage, the gateway **rejects `i_am_idle()`** while `unclaimed_parent_acs` is non-empty, naming the gap; the fix is one more `delegate` to the cell that should own it. (Opt-in: if you never pass `covers_parent_criteria` the gate stays silent, but declaring it is how a dropped cross-cell criterion gets caught here instead of at the CEO.)
|
**Map your root's criteria to the cell subtask that owns them.** Your briefing carries `parent_ac_coverage` (each root criterion as `{id, text, claimed, verified}`) and `unclaimed_parent_acs` (the ids with no cell subtask yet). When you `delegate` a slice to a cell, pass `covers_parent_criteria=[<root criterion ids>]` naming which root criteria that cell now owns — every root criterion must be claimed by some cell before you idle. Once you start declaring coverage, the gateway **rejects `i_am_idle()`** while `unclaimed_parent_acs` is non-empty, naming the gap; the fix is one more `delegate` to the cell that should own it. (Opt-in: if you never pass `covers_parent_criteria` the gate stays silent, but declaring it is how a dropped cross-cell criterion gets caught here instead of at the CEO.)
|
||||||
|
|
||||||
**Some root criteria are yours alone — never delegate them.** A criterion satisfiable only by your own machinery (e.g. "a PR is opened from `feature/main_pm/...`", "contributor PR #N is closed and linked") cannot be honored by any cell — a cell can't operate in your branch namespace or close a PR it doesn't own. Do NOT push it into a cell's `acceptance_criteria` or `covers_parent_criteria`; declare it root-owned instead: `declare_coverage(task_id=<your own root>, criteria=[<ids>])`. `parent_ac_coverage` then shows `claimed_by: "root"` for it, and it counts as claimed+satisfied for `i_am_idle` and the roll-up gate — no cell involved.
|
**Some root criteria are yours alone — never delegate them.** A criterion satisfiable only by your own machinery (e.g. "a PR is opened from `feature/main_pm/...`", "contributor PR #N is closed and linked") cannot be honored by any cell — a cell can't operate in your branch namespace or close a PR it doesn't own. Do NOT push it into a cell's `acceptance_criteria` or `covers_parent_criteria`; declare it root-owned instead: `declare_coverage(task_id=<your own root>, criteria=[<ids>])`. `parent_ac_coverage` then shows `claimed_by: "root"` for it, and it counts as claimed+satisfied for `i_am_idle` and the roll-up gate — no cell involved.
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ You have a second, distinct surface: the **in-path PR-review gate**. After a Cel
|
|||||||
|
|
||||||
**The named-deliverable/silent-drop rule.** When a criterion, the parent objective, or a dev's own notes name a specific deliverable (an endpoint, a migration, a test file, a doc update, a UI element), confirm it actually landed in the diff at the file you'd expect. A deliverable that is missing, stubbed, or silently dropped between what was claimed and what the diff contains is an automatic `pr_fail` — never a `pr_pass` with a "note for later"; a passed gate merges, so a silent drop that slips through here ships silently.
|
**The named-deliverable/silent-drop rule.** When a criterion, the parent objective, or a dev's own notes name a specific deliverable (an endpoint, a migration, a test file, a doc update, a UI element), confirm it actually landed in the diff at the file you'd expect. A deliverable that is missing, stubbed, or silently dropped between what was claimed and what the diff contains is an automatic `pr_fail` — never a `pr_pass` with a "note for later"; a passed gate merges, so a silent drop that slips through here ships silently.
|
||||||
|
|
||||||
|
**Coherence & intent — your scope is bigger than the AC checklist (non-negotiable).** Ticking every acceptance criterion is the floor, not the ceiling. A diff can satisfy every criterion and still be wrong for *this* project: it can solve the right problem the wrong way, ignore a convention the codebase already follows, duplicate a helper that exists three files over, or build something the CEO did not actually ask for. Before you `pr_pass`, check the bigger scope:
|
||||||
|
|
||||||
|
1. **Intent — is this what the intake/parent objective actually asked for?** Compare the assembled diff to the parent task's objective and the intake's stated intent (the `description` + `parent_context` in your `claim_gate_review` evidence — the file:line targets and code examples the intake worked out), not only to the AC list. A diff that satisfies the ACs but drifts from the intent — solves an adjacent problem, over-builds past the named target, or quietly swaps the surface the intake specified — is a `pr_fail` with a `criterion`-less `major` finding (`expected`: the intake's intent, `actual`: what the diff does instead). "They did what the task says" is not a pass when what the task says was diluted on the way down and the diff followed the dilution.
|
||||||
|
2. **Coherence — does it fit the project it lands in?** The diff should read like it belongs in this codebase: it reuses the project's existing helpers/types/patterns rather than re-inventing them, follows the project's layering and file style, and doesn't introduce a parallel way of doing something the project already does one way. A change that is technically correct but structurally foreign is a `pr_fail`, not a "ship it, refactor later." The conventions validator catches the mechanical half (placement, modularity, suppressions); your judgment catches the rest — a hand-rolled retry when a project helper exists, a new config loader next to the existing one, a service doing what a route should.
|
||||||
|
3. **Standards — does it hold the project's bar?** No silent `except: pass` / `# type: ignore` / commented-out code / debug `print`; error handling and naming match the project's posture; tests follow the project's test style. A diff that passes its ACs while lowering the project's hygiene bar is a `pr_fail`.
|
||||||
|
|
||||||
|
These are the difference between a gate that catches a wrong-but-AC-compliant change before it merges and one that waves it through to a CEO rejection or a shipped regression. When in doubt, `pr_fail` with a concrete finding and let the owning dev/cell PM respond.
|
||||||
|
|
||||||
**On a blocked `pr_pass`:** three guards can refuse the transition, each with a reviewer-aware `remediate` pointing at `pr_fail` (never `i_am_blocked` — you have no such verb):
|
**On a blocked `pr_pass`:** three guards can refuse the transition, each with a reviewer-aware `remediate` pointing at `pr_fail` (never `i_am_blocked` — you have no such verb):
|
||||||
- **Toolchain / conventions:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), `remediate` points at `pr_fail(findings=[{severity: 'blocker', expected: '...', actual: 'toolchain: ...'}])` so the dev rebuilds the environment.
|
- **Toolchain / conventions:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), `remediate` points at `pr_fail(findings=[{severity: 'blocker', expected: '...', actual: 'toolchain: ...'}])` so the dev rebuilds the environment.
|
||||||
- **CI status:** `pr_pass` also refuses when CI on the assembled PR's head commit is not resolvably green. Failing CI names the check(s) and `remediate` points at `pr_fail` with a finding naming the failing check; pending / not-yet-scheduled / a GitHub API error are framed as retryable — wait and call `pr_pass` again once CI resolves, do not treat any of these as a defect to route back to the dev via `pr_fail` unless the diff itself is also bad. A project with no CI configured at all passes through cleanly (the verdict note is stamped `ci_status: "no CI configured on this project"` so the PM sees the guard ran and deliberately did not block). Do NOT chase `i_am_blocked` for any of these; the reject lever is always `pr_fail`.
|
- **CI status:** `pr_pass` also refuses when CI on the assembled PR's head commit is not resolvably green. Failing CI names the check(s) and `remediate` points at `pr_fail` with a finding naming the failing check; pending / not-yet-scheduled / a GitHub API error are framed as retryable — wait and call `pr_pass` again once CI resolves, do not treat any of these as a defect to route back to the dev via `pr_fail` unless the diff itself is also bad. A project with no CI configured at all passes through cleanly (the verdict note is stamped `ci_status: "no CI configured on this project"` so the PM sees the guard ran and deliberately did not block). Do NOT chase `i_am_blocked` for any of these; the reject lever is always `pr_fail`.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
## Technical depth — capture the analysis IN the draft, not just in the chat
|
||||||
|
|
||||||
|
This is the most important rule at your seat and the single biggest source of downstream revision churn when you get it wrong. You read the repo, you find the exact file:line to change, the exact signature to add or reuse, the code shape that already exists — that analysis is the whole point of having you interview instead of the CEO typing a one-liner. **It must live in the draft fields, not only in your prose chat with the CEO.** The chat is lost the moment the CEO confirms the card; only the draft travels down the chain — Main PM → Cell PM → dev. A brilliant analysis you only spoke in chat, but never wrote into `the_work` / `notes` / `what_this_builds`, is diluted to nothing by the time a dev reads the task description, and the dev rebuilds your analysis from scratch (usually wrong). That is the exact barrage of revisions this rule prevents.
|
||||||
|
|
||||||
|
So write the technical detail you discovered into the draft:
|
||||||
|
|
||||||
|
- **`the_work` items** — each independently-shippable unit should name the **file:line** it touches and the **change** at that location, not just the outcome. ❌ "improve the intake re-confirm flow". ✅ "`PrompterService.confirm_live_batch` at `roboco/services/prompter.py:412` drops the `project_ids` scope on a redraft re-confirm — thread `BatchConfirmRequest.task_id` through `update_live_batch` and re-run `_validate_batch_scope` against the original scope."
|
||||||
|
- **`notes`** — the exact enums/components/APIs/signatures to reuse, the constraint or gotcha, a short code example when the shape is non-obvious. `notes` is where a dev finds "reuse `render_findings` from `evidence_builder.py`, don't re-roll a renderer" or "the `Finding.criterion` field is optional — a coherence/intent finding is filed without a criterion id".
|
||||||
|
- **`what_this_builds`** — concrete artifacts, named with the real path/identifier.
|
||||||
|
|
||||||
|
"Use the real names you find in the repo" (above) is the floor. The bar is: **a dev reading the composed task description sees the file:line and the code example you found, and goes straight to the point instead of hunting in the fog.** If you couldn't pin a file:line because the surface is genuinely unknown, say so in `notes` ("target file not yet determined — the cell PM locates the handler during decomposition") rather than leaving a vague goal that reads as if you did the analysis.
|
||||||
|
|
||||||
|
**This does not override the coordination-level-AC rule for MegaTask roots.** That rule (below) says a root's *acceptance criteria* stay coordination-level — don't put code-level ACs on the root. It does NOT say the root's `the_work` and `notes` should be vague. The `the_work` items and `notes` on a root still name the specific files/signatures each cell will touch, because the Main PM forwards `the_work` to the cell PMs who forward it to the devs — that detail is what survives the chain. Code-level *ACs* belong on the cell/dev subtasks the Main PM delegates to; code-level *detail in the work-unit descriptions* belongs on the root and rides all the way down.
|
||||||
|
|
||||||
## MegaTasks (several tasks at once)
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
|
|||||||
| anything else (`pending`/`in_progress`/`awaiting_documentation`/etc.) | not yours to act on — `i_am_idle()` |
|
| anything else (`pending`/`in_progress`/`awaiting_documentation`/etc.) | not yours to act on — `i_am_idle()` |
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. `give_me_work()` -> task in `awaiting_qa`.
|
1. `give_me_work()` -> task in `awaiting_qa`.
|
||||||
2. `claim_review(task_id)` -> read the response in full: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. The journal tells you why; the diff tells you what.
|
2. `claim_review(task_id)` -> read the response in full: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. The journal tells you why; the diff tells you what.
|
||||||
3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response.
|
3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response.
|
||||||
@@ -71,6 +70,16 @@ You have five journal scopes. QA's job is fundamentally about evidence — spars
|
|||||||
|
|
||||||
The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carries the public verdict; the journal carries the reasoning — and the panel renders your decision's `options`/`chosen`/`rationale`/`consequences` as named sections so PMs can read them at a glance. **A decision with only `text=…` is a regression — always fill the structured fields.**
|
The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carries the public verdict; the journal carries the reasoning — and the panel renders your decision's `options`/`chosen`/`rationale`/`consequences` as named sections so PMs can read them at a glance. **A decision with only `text=…` is a regression — always fill the structured fields.**
|
||||||
|
|
||||||
|
## Coherence & intent — your scope is bigger than the AC checklist
|
||||||
|
|
||||||
|
Checking "does the diff satisfy every acceptance criterion" is the floor, not the ceiling. A diff can tick every criterion and still be wrong: it can solve the right problem the wrong way for *this* project, ignore a convention the codebase already follows, duplicate a helper that exists three files over, or build something the CEO did not actually ask for. Your job is the bigger scope — **is the change logical toward the project it modifies, coherent with that project's structure and standards, and actually what was asked — not merely what the task's AC list says.** Three things to check beyond the AC walk:
|
||||||
|
|
||||||
|
1. **Intent — is this what the CEO/intake actually asked for?** Read the `description` and `parent_context` in your `claim_review` / `evidence` response: that is the intake's original analysis (the WHAT, with file:line targets and code examples) and each PM's decomposition. Compare the diff to *that*, not only to the ACs. A diff that satisfies the ACs but drifts from the intake's stated intent — solves an adjacent problem, over-builds past the named target, or quietly swaps the surface the intake specified — is a fail with a `criterion`-less finding (`severity: major`, `expected`: the intake's intent, `actual`: what the diff does instead). "They did what the task says" is not a pass if what the task says was diluted on the way down and the diff followed the dilution.
|
||||||
|
2. **Coherence — does it fit the project it lands in?** The diff should read like it belongs in this codebase: it reuses the project's existing helpers/types/patterns rather than re-inventing them, follows the file's surrounding style and the project's layering (a route delegates to a service, a component stays presentational), and doesn't introduce a parallel way of doing something the project already does one way. A change that is technically correct but structurally foreign — a new config loader when the project already has one, a hand-rolled retry when a project helper exists, a model defined where the project puts services — is a fail, not a "ship it, refactor later." The `convention_findings` in your evidence catch the mechanical half of this (placement, modularity, suppressions); your judgment catches the rest.
|
||||||
|
3. **Standards — does it hold the project's bar?** Beyond the conventions validator: error handling matches the project's posture, naming follows the project's conventions, no silent `except: pass` / `# type: ignore` / commented-out code / debug `print`, and tests follow the project's test style. A diff that passes its ACs while lowering the project's hygiene bar is a fail.
|
||||||
|
|
||||||
|
These are not "nice to have" — they are the difference between a review that catches a wrong-but-AC-compliant change before it merges and one that waves it through to a CEO rejection (or worse, a shipped regression). When in doubt, fail with a concrete finding and let the dev respond; a fail costs one cycle, a wrong pass costs the whole chain.
|
||||||
|
|
||||||
## Mandatory checklist before `pass` / `fail`
|
## Mandatory checklist before `pass` / `fail`
|
||||||
|
|
||||||
1. ✅ You are NOT the original developer (gateway-enforced for `claim_review`; the convention also forbids self-pass even if the gate slips).
|
1. ✅ You are NOT the original developer (gateway-enforced for `claim_review`; the convention also forbids self-pass even if the gate slips).
|
||||||
|
|||||||
@@ -4174,6 +4174,26 @@ class AgentOrchestrator:
|
|||||||
self._TOOL_LOAD_CACHE[role] = block
|
self._TOOL_LOAD_CACHE[role] = block
|
||||||
return block
|
return block
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _description_body(description: str | None, *, cap: int = 4000) -> str:
|
||||||
|
"""The task description as a bounded body for a prompt/briefing block.
|
||||||
|
|
||||||
|
The description is the actual ask — it travels with the spawn prompt and
|
||||||
|
SessionStart briefing so the dev starts with the spec instead of a bare
|
||||||
|
title. Capped so a giant umbrella description can't swamp the prompt;
|
||||||
|
the full upstream chain is still available via ``evidence()``.
|
||||||
|
"""
|
||||||
|
text = (description or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "(none — ask the PM before proceeding)"
|
||||||
|
if len(text) <= cap:
|
||||||
|
return text
|
||||||
|
omitted = len(text) - cap
|
||||||
|
return (
|
||||||
|
f"{text[:cap]}\n… [{omitted} chars omitted — evidence() carries"
|
||||||
|
" the full text]"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
|
def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
|
||||||
"""Build the ``## Current task`` markdown block from a fetched task."""
|
"""Build the ``## Current task`` markdown block from a fetched task."""
|
||||||
@@ -4187,6 +4207,9 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
branch = task.get("branch_name") or "(to be created)"
|
branch = task.get("branch_name") or "(to be created)"
|
||||||
project_slug = task.get("project_slug") or "(unset — ask PM)"
|
project_slug = task.get("project_slug") or "(unset — ask PM)"
|
||||||
|
description_body = AgentOrchestrator._description_body(
|
||||||
|
task.get("description") or ""
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
"\n## Current task\n"
|
"\n## Current task\n"
|
||||||
f"- **ID:** `{task.get('id', task_id)}`\n"
|
f"- **ID:** `{task.get('id', task_id)}`\n"
|
||||||
@@ -4196,6 +4219,8 @@ class AgentOrchestrator:
|
|||||||
f"- **Project slug:** `{project_slug}` "
|
f"- **Project slug:** `{project_slug}` "
|
||||||
"(pass this as `project_slug=` on every git/task tool)\n"
|
"(pass this as `project_slug=` on every git/task tool)\n"
|
||||||
f"- **Branch:** `{branch}`\n"
|
f"- **Branch:** `{branch}`\n"
|
||||||
|
"\n### Description (the ask — treat as ground truth)\n"
|
||||||
|
f"{description_body}\n"
|
||||||
"\n### Acceptance criteria\n"
|
"\n### Acceptance criteria\n"
|
||||||
f"{criteria}\n"
|
f"{criteria}\n"
|
||||||
)
|
)
|
||||||
@@ -13931,6 +13956,7 @@ Run the project's quality checks against acceptance criteria:
|
|||||||
task_id = task.get("id", "unknown")
|
task_id = task.get("id", "unknown")
|
||||||
title = task.get("title", "Untitled")
|
title = task.get("title", "Untitled")
|
||||||
status = task.get("status", "unknown")
|
status = task.get("status", "unknown")
|
||||||
|
description = task.get("description") or ""
|
||||||
|
|
||||||
# Determine workflow state based on task attributes
|
# Determine workflow state based on task attributes
|
||||||
has_plan = bool(task.get("plan"))
|
has_plan = bool(task.get("plan"))
|
||||||
@@ -13942,6 +13968,12 @@ Run the project's quality checks against acceptance criteria:
|
|||||||
workflow_state, task_id, open_findings_block
|
workflow_state, task_id, open_findings_block
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The task spec travels with the prompt so the dev starts with the
|
||||||
|
# actual ask (file:line targets, constraints, the intake's rationale)
|
||||||
|
# instead of hunting in the fog. evidence() carries the full upstream
|
||||||
|
# ancestor chain on top; this is the leaf's own brief.
|
||||||
|
desc_block = f"DESCRIPTION:\n{self._description_body(description)}"
|
||||||
|
|
||||||
return f"""You have been assigned a development task.
|
return f"""You have been assigned a development task.
|
||||||
|
|
||||||
TASK ID: {task_id}
|
TASK ID: {task_id}
|
||||||
@@ -13949,9 +13981,17 @@ TITLE: {title}
|
|||||||
STATUS: {status}
|
STATUS: {status}
|
||||||
WORKFLOW STATE: {workflow_state}
|
WORKFLOW STATE: {workflow_state}
|
||||||
|
|
||||||
|
{desc_block}
|
||||||
|
|
||||||
|
Treat the description and any upstream technical detail you receive via
|
||||||
|
evidence() as authoritative ground truth — file:line targets, code examples,
|
||||||
|
and constraints come from the intake analysis and PM decomposition. Re-articulate
|
||||||
|
only the HOW (the solution); the WHAT is already decided upstream.
|
||||||
|
|
||||||
{instructions}
|
{instructions}
|
||||||
|
|
||||||
Start by calling evidence(task_id="{task_id}") for full details and acceptance criteria.
|
Start by calling evidence(task_id="{task_id}") for full details, acceptance
|
||||||
|
criteria, and the upstream parent/ancestor context (the original intake analysis).
|
||||||
|
|
||||||
When out of work: i_am_idle().
|
When out of work: i_am_idle().
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1024,11 +1024,16 @@ class Choreographer:
|
|||||||
# Push the prior-work digest so a freshly spawned / respawned agent
|
# Push the prior-work digest so a freshly spawned / respawned agent
|
||||||
# resumes from the previous worker's PR + commits + journal rather
|
# resumes from the previous worker's PR + commits + journal rather
|
||||||
# than re-exploring the codebase cold on every lifecycle hand-off.
|
# than re-exploring the codebase cold on every lifecycle hand-off.
|
||||||
handoff_highlights = await repo.journal_highlights_for_task(task_id)
|
handoff_highlights = await repo.journal_highlights_for_task(
|
||||||
|
task_id, include_ancestors=True
|
||||||
|
)
|
||||||
open_findings = await findings_lib.open_findings_for_task(
|
open_findings = await findings_lib.open_findings_for_task(
|
||||||
self.task.session, task_id
|
self.task.session, task_id
|
||||||
)
|
)
|
||||||
task_handoff = build_task_handoff(task, handoff_highlights, open_findings)
|
parent_context = await repo.ancestor_context_for_task(task_id)
|
||||||
|
task_handoff = build_task_handoff(
|
||||||
|
task, handoff_highlights, open_findings, parent_context
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"recent_team_activity": await repo.recent_team_activity(agent_id),
|
"recent_team_activity": await repo.recent_team_activity(agent_id),
|
||||||
"blockers_in_my_lane": await repo.blockers_in_lane(agent_id),
|
"blockers_in_my_lane": await repo.blockers_in_lane(agent_id),
|
||||||
|
|||||||
@@ -1200,7 +1200,18 @@ class PRGateMixin(_Base):
|
|||||||
prior_findings = await findings_lib.full_ledger_for_task(
|
prior_findings = await findings_lib.full_ledger_for_task(
|
||||||
self.task.session, t.id
|
self.task.session, t.id
|
||||||
)
|
)
|
||||||
return {
|
# The ask: this assembled task's own description + the upstream
|
||||||
|
# chain (parent → root) so the gate reviewer checks INTENT against
|
||||||
|
# the intake's original analysis, not only the AC list. Empty chain
|
||||||
|
# for a parentless root is fine (omitted downstream when empty).
|
||||||
|
parent_context: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
parent_context = await self.evidence_repo.ancestor_context_for_task(t.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"gate_review_parent_context_skip", task_id=str(t.id), error=str(exc)
|
||||||
|
)
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
"pr_number": t.pr_number,
|
"pr_number": t.pr_number,
|
||||||
"pr_url": t.pr_url,
|
"pr_url": t.pr_url,
|
||||||
"pr_diff": diff,
|
"pr_diff": diff,
|
||||||
@@ -1209,3 +1220,9 @@ class PRGateMixin(_Base):
|
|||||||
"revision_findings": render_findings(open_findings),
|
"revision_findings": render_findings(open_findings),
|
||||||
"prior_findings": render_findings(prior_findings),
|
"prior_findings": render_findings(prior_findings),
|
||||||
}
|
}
|
||||||
|
description = getattr(t, "description", None)
|
||||||
|
if description:
|
||||||
|
evidence["description"] = description
|
||||||
|
if parent_context:
|
||||||
|
evidence["parent_context"] = parent_context
|
||||||
|
return evidence
|
||||||
|
|||||||
@@ -495,17 +495,36 @@ class PRReviewerMixin(_Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _build_pr_review_evidence(self, t: Any) -> dict[str, Any]:
|
async def _build_pr_review_evidence(self, t: Any) -> dict[str, Any]:
|
||||||
"""Inline evidence for claim_pr_review: the PR's unified diff (read-only)."""
|
"""Inline evidence for claim_pr_review: the PR's unified diff (read-only).
|
||||||
|
|
||||||
|
Carries the review task's own ``description`` (the CEO/intake review ask)
|
||||||
|
and the upstream ``parent_context`` chain so the reviewer judges the
|
||||||
|
contributor's diff against the stated intent, not in a vacuum. Both are
|
||||||
|
empty for a bare external-PR task and omitted when empty.
|
||||||
|
"""
|
||||||
slug = await self._project_slug_for(t)
|
slug = await self._project_slug_for(t)
|
||||||
diff = ""
|
diff = ""
|
||||||
if slug and t.pr_number:
|
if slug and t.pr_number:
|
||||||
diff = await self.git.get_pr_diff(slug, t.pr_number)
|
diff = await self.git.get_pr_diff(slug, t.pr_number)
|
||||||
return {
|
evidence: dict[str, Any] = {
|
||||||
"pr_number": t.pr_number,
|
"pr_number": t.pr_number,
|
||||||
"pr_url": t.pr_url,
|
"pr_url": t.pr_url,
|
||||||
"pr_diff": diff,
|
"pr_diff": diff,
|
||||||
"is_external_pr": True,
|
"is_external_pr": True,
|
||||||
}
|
}
|
||||||
|
description = getattr(t, "description", None)
|
||||||
|
if description:
|
||||||
|
evidence["description"] = description
|
||||||
|
try:
|
||||||
|
parent_context = await self.evidence_repo.ancestor_context_for_task(t.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"pr_review_parent_context_skip", task_id=str(t.id), error=str(exc)
|
||||||
|
)
|
||||||
|
parent_context = []
|
||||||
|
if parent_context:
|
||||||
|
evidence["parent_context"] = parent_context
|
||||||
|
return evidence
|
||||||
|
|
||||||
async def _pr_review_tracing_gate(
|
async def _pr_review_tracing_gate(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ class QAMixin(_Base):
|
|||||||
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
||||||
task_id
|
task_id
|
||||||
)
|
)
|
||||||
|
# The ask-chain (parent → root descriptions) so QA judges INTENT
|
||||||
|
# against the intake's original analysis, not only the leaf's ACs.
|
||||||
|
# Leaf-only journals stay (include_ancestors defaults False above);
|
||||||
|
# ancestor *descriptions* are the ask, not work-so-far.
|
||||||
|
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
|
||||||
convention_findings = await self._qa_convention_findings(qa_agent_id, t)
|
convention_findings = await self._qa_convention_findings(qa_agent_id, t)
|
||||||
open_findings = await findings_lib.open_findings_for_task(
|
open_findings = await findings_lib.open_findings_for_task(
|
||||||
self.task.session, t.id
|
self.task.session, t.id
|
||||||
@@ -241,6 +246,7 @@ class QAMixin(_Base):
|
|||||||
convention_findings=convention_findings,
|
convention_findings=convention_findings,
|
||||||
revision_findings=open_findings,
|
revision_findings=open_findings,
|
||||||
prior_findings=prior_findings,
|
prior_findings=prior_findings,
|
||||||
|
parent_context=parent_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _verify_qa_owner(
|
async def _verify_qa_owner(
|
||||||
|
|||||||
@@ -1919,8 +1919,9 @@ class ContentActions:
|
|||||||
branch_name=t.branch_name, actor_agent_id=agent_id
|
branch_name=t.branch_name, actor_agent_id=agent_id
|
||||||
)
|
)
|
||||||
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
||||||
task_id
|
task_id, include_ancestors=True
|
||||||
)
|
)
|
||||||
|
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
|
||||||
open_findings = await findings_lib.open_findings_for_task(
|
open_findings = await findings_lib.open_findings_for_task(
|
||||||
self.task.session, task_id
|
self.task.session, task_id
|
||||||
)
|
)
|
||||||
@@ -1930,6 +1931,7 @@ class ContentActions:
|
|||||||
files_changed=files_changed,
|
files_changed=files_changed,
|
||||||
pr_diff_summary=diff,
|
pr_diff_summary=diff,
|
||||||
revision_findings=open_findings,
|
revision_findings=open_findings,
|
||||||
|
parent_context=parent_context,
|
||||||
)
|
)
|
||||||
return Envelope.ok(
|
return Envelope.ok(
|
||||||
status=str(t.status),
|
status=str(t.status),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
|
|||||||
"convention_findings",
|
"convention_findings",
|
||||||
"revision_findings",
|
"revision_findings",
|
||||||
"prior_findings",
|
"prior_findings",
|
||||||
|
"parent_context",
|
||||||
|
"description",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +38,14 @@ class EvidencePayload:
|
|||||||
dev_summary: str | None
|
dev_summary: str | None
|
||||||
journal_highlights: list[dict[str, Any]]
|
journal_highlights: list[dict[str, Any]]
|
||||||
acceptance_criteria_status: list[dict[str, Any]]
|
acceptance_criteria_status: list[dict[str, Any]]
|
||||||
|
# The task's own description (the spec / brief) — the dev's ``evidence()``
|
||||||
|
# call finally carries the ask, not only the work-so-far. Omitted when empty.
|
||||||
|
description: str | None = None
|
||||||
|
# The upstream ``description`` chain (immediate parent → root) so a
|
||||||
|
# downstream owner reads the intake's original analysis and each PM's
|
||||||
|
# decomposition rationale verbatim instead of a re-paraphrased summary.
|
||||||
|
# Empty for a parentless task.
|
||||||
|
parent_context: list[dict[str, Any]] = field(default_factory=list)
|
||||||
# Architectural-conventions validator findings on the changed files, so QA
|
# Architectural-conventions validator findings on the changed files, so QA
|
||||||
# can flag a misplaced definition / suppression. Empty when the subsystem
|
# can flag a misplaced definition / suppression. Empty when the subsystem
|
||||||
# is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly.
|
# is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly.
|
||||||
@@ -149,12 +159,15 @@ def build_evidence_for_task(
|
|||||||
convention_findings: list[dict[str, Any]] | None = None,
|
convention_findings: list[dict[str, Any]] | None = None,
|
||||||
revision_findings: list[Any] | None = None,
|
revision_findings: list[Any] | None = None,
|
||||||
prior_findings: list[Any] | None = None,
|
prior_findings: list[Any] | None = None,
|
||||||
|
parent_context: list[dict[str, Any]] | None = None,
|
||||||
) -> EvidencePayload:
|
) -> EvidencePayload:
|
||||||
"""Compose an EvidencePayload from a Task model + supplemental data.
|
"""Compose an EvidencePayload from a Task model + supplemental data.
|
||||||
|
|
||||||
``revision_findings`` / ``prior_findings`` take raw ledger rows (the
|
``revision_findings`` / ``prior_findings`` take raw ledger rows (the
|
||||||
caller fetches; this module stays DB-free) and render them via
|
caller fetches; this module stays DB-free) and render them via
|
||||||
``render_findings``.
|
``render_findings``. ``parent_context`` is the upstream ``description``
|
||||||
|
chain (parent → root) the caller fetches via EvidenceRepo so the dev
|
||||||
|
reads the intake's original analysis verbatim.
|
||||||
"""
|
"""
|
||||||
return EvidencePayload(
|
return EvidencePayload(
|
||||||
pr_number=task.pr_number,
|
pr_number=task.pr_number,
|
||||||
@@ -165,6 +178,8 @@ def build_evidence_for_task(
|
|||||||
dev_summary=task.dev_notes,
|
dev_summary=task.dev_notes,
|
||||||
journal_highlights=list(journal_highlights),
|
journal_highlights=list(journal_highlights),
|
||||||
acceptance_criteria_status=list(task.acceptance_criteria_status or []),
|
acceptance_criteria_status=list(task.acceptance_criteria_status or []),
|
||||||
|
description=_typed(task.description, str, None),
|
||||||
|
parent_context=list(parent_context or []),
|
||||||
convention_findings=list(convention_findings or []),
|
convention_findings=list(convention_findings or []),
|
||||||
revision_findings=render_findings(revision_findings),
|
revision_findings=render_findings(revision_findings),
|
||||||
prior_findings=render_findings(prior_findings),
|
prior_findings=render_findings(prior_findings),
|
||||||
@@ -191,19 +206,28 @@ def _has_prior_work(
|
|||||||
qa_review: dict[str, Any] | None,
|
qa_review: dict[str, Any] | None,
|
||||||
pm_review: dict[str, Any] | None,
|
pm_review: dict[str, Any] | None,
|
||||||
open_findings: list,
|
open_findings: list,
|
||||||
|
description: str | None = None,
|
||||||
|
parent_context: list[dict[str, Any]] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when any resumable prior-work signal is present on the task."""
|
"""True when any resumable prior-work signal — or the task spec itself —
|
||||||
return bool(
|
is present. The spec (``description``) and upstream ``parent_context`` count
|
||||||
commits
|
so a freshly-claimed leaf still gets a handoff carrying the intake's
|
||||||
or acceptance
|
analysis, not ``None``."""
|
||||||
or highlights
|
return any(
|
||||||
or pr_number is not None
|
[
|
||||||
or dev_summary
|
commits,
|
||||||
or completed_deps
|
acceptance,
|
||||||
or pr_review is not None
|
highlights,
|
||||||
or qa_review is not None
|
pr_number is not None,
|
||||||
or pm_review is not None
|
dev_summary,
|
||||||
or open_findings
|
completed_deps,
|
||||||
|
pr_review is not None,
|
||||||
|
qa_review is not None,
|
||||||
|
pm_review is not None,
|
||||||
|
open_findings,
|
||||||
|
description,
|
||||||
|
parent_context,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,15 +235,18 @@ def build_task_handoff(
|
|||||||
task: Any,
|
task: Any,
|
||||||
journal_highlights: list[dict[str, Any]],
|
journal_highlights: list[dict[str, Any]],
|
||||||
open_findings: list[Any] | None = None,
|
open_findings: list[Any] | None = None,
|
||||||
|
parent_context: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Compose a compact prior-work digest for the briefed task.
|
"""Compose a compact prior-work digest for the briefed task.
|
||||||
|
|
||||||
Returns ``None`` when there is no task or no prior work worth resuming
|
Returns ``None`` only when there is no task AND no spec to carry — the
|
||||||
from, so the briefing only carries a handoff when one genuinely exists.
|
task's own ``description`` and the upstream ``parent_context`` chain now
|
||||||
DB-only by design — no git diff — so it is cheap enough to attach to
|
count as worth carrying, so a freshly-claimed leaf finally receives the
|
||||||
every task-scoped briefing. ``open_findings`` takes raw ledger rows (the
|
intake's analysis at claim time instead of an empty handoff. DB-only by
|
||||||
caller fetches — this module stays DB-free); rendered under
|
design — no git diff — so it is cheap enough to attach to every
|
||||||
``revision_findings`` when non-empty.
|
task-scoped briefing. ``open_findings`` takes raw ledger rows (the caller
|
||||||
|
fetches — this module stays DB-free); rendered under ``revision_findings``
|
||||||
|
when non-empty.
|
||||||
"""
|
"""
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
@@ -228,6 +255,8 @@ def build_task_handoff(
|
|||||||
highlights = _typed(journal_highlights, list, [])
|
highlights = _typed(journal_highlights, list, [])
|
||||||
pr_number = _typed(task.pr_number, int, None)
|
pr_number = _typed(task.pr_number, int, None)
|
||||||
dev_summary = _typed(task.dev_notes, str, None)
|
dev_summary = _typed(task.dev_notes, str, None)
|
||||||
|
description = _typed(task.description, str, None)
|
||||||
|
parent_context = _typed(parent_context, list, [])
|
||||||
# Upstream dependencies that completed and were cleared — present only on a
|
# Upstream dependencies that completed and were cleared — present only on a
|
||||||
# just-unblocked task, so the revived dependent knows what it can build on.
|
# just-unblocked task, so the revived dependent knows what it can build on.
|
||||||
completed_deps = _typed(getattr(task, "completed_dependency_ids", None), list, [])
|
completed_deps = _typed(getattr(task, "completed_dependency_ids", None), list, [])
|
||||||
@@ -253,6 +282,8 @@ def build_task_handoff(
|
|||||||
qa_review,
|
qa_review,
|
||||||
pm_review,
|
pm_review,
|
||||||
open_findings,
|
open_findings,
|
||||||
|
description,
|
||||||
|
parent_context,
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
handoff: dict[str, Any] = {
|
handoff: dict[str, Any] = {
|
||||||
@@ -262,6 +293,8 @@ def build_task_handoff(
|
|||||||
"commit_count": len(commits),
|
"commit_count": len(commits),
|
||||||
"recent_commits": commits[-BRIEFING_LIST_CAP:],
|
"recent_commits": commits[-BRIEFING_LIST_CAP:],
|
||||||
"dev_summary": dev_summary,
|
"dev_summary": dev_summary,
|
||||||
|
"description": description,
|
||||||
|
"parent_context": parent_context[:BRIEFING_LIST_CAP],
|
||||||
"acceptance_criteria_status": acceptance[:BRIEFING_LIST_CAP],
|
"acceptance_criteria_status": acceptance[:BRIEFING_LIST_CAP],
|
||||||
"journal_highlights": highlights[:BRIEFING_LIST_CAP],
|
"journal_highlights": highlights[:BRIEFING_LIST_CAP],
|
||||||
"completed_dependency_ids": [
|
"completed_dependency_ids": [
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ _HANDOFF_CONTENT_CAP = 800
|
|||||||
_NORTH_STAR_CAP = 600
|
_NORTH_STAR_CAP = 600
|
||||||
_BRAND_VOICE_CAP = 600
|
_BRAND_VOICE_CAP = 600
|
||||||
_A2A_PREVIEW_CAP = 200
|
_A2A_PREVIEW_CAP = 200
|
||||||
|
# Per-ancestor description cap in the upstream-context chain — the root's
|
||||||
|
# intake analysis is the valuable part; a giant umbrella description can't
|
||||||
|
# flood the evidence payload it rides.
|
||||||
|
_ANCESTOR_DESC_CAP = 1500
|
||||||
|
# Backstop on the parent_task_id walk so a malformed/cyclic hierarchy can't
|
||||||
|
# loop or explode the query. Real depth is ≤4 (umbrella→root→cell→leaf).
|
||||||
|
_HIERARCHY_DEPTH_CAP = 16
|
||||||
# similar_memory's "kind" label per index type; anything absent (LEARNINGS)
|
# similar_memory's "kind" label per index type; anything absent (LEARNINGS)
|
||||||
# falls back to "learning" via .get() below.
|
# falls back to "learning" via .get() below.
|
||||||
_MEMORY_KIND_BY_INDEX = {
|
_MEMORY_KIND_BY_INDEX = {
|
||||||
@@ -317,7 +324,78 @@ class EvidenceRepo:
|
|||||||
for row in result.all()
|
for row in result.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
async def journal_highlights_for_task(self, task_id: UUID) -> list[dict[str, Any]]:
|
async def _ancestor_task_ids(self, task_id: UUID) -> list[UUID]:
|
||||||
|
"""Walk ``parent_task_id`` up to the root, returning ancestor ids
|
||||||
|
(immediate parent first). Cycle-guarded + depth-capped so a malformed
|
||||||
|
hierarchy can't loop or explode the query. Stops clean on a missing
|
||||||
|
row or a root (``parent_task_id`` is NULL).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
|
ancestors: list[UUID] = []
|
||||||
|
seen: set[str] = {str(task_id)}
|
||||||
|
current: UUID = task_id
|
||||||
|
for _ in range(_HIERARCHY_DEPTH_CAP):
|
||||||
|
parent_id = (
|
||||||
|
await self._db.execute(
|
||||||
|
select(TaskTable.parent_task_id).where(TaskTable.id == current)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if parent_id is None:
|
||||||
|
break # missing row or reached a root
|
||||||
|
key = str(parent_id)
|
||||||
|
if key in seen:
|
||||||
|
break # cycle guard
|
||||||
|
seen.add(key)
|
||||||
|
ancestors.append(parent_id)
|
||||||
|
current = parent_id
|
||||||
|
return ancestors
|
||||||
|
|
||||||
|
async def ancestor_context_for_task(self, task_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
"""The upstream ``description`` chain (immediate parent → root), so a
|
||||||
|
downstream owner — the dev or PM claiming a leaf — reads the intake's
|
||||||
|
original analysis and each PM's decomposition rationale verbatim
|
||||||
|
instead of a re-paraphrased summary. Each ancestor carries title +
|
||||||
|
description (capped) + depth (1 = immediate parent). Empty for a
|
||||||
|
parentless task. Best-effort: a missing ancestor row is skipped.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
|
||||||
|
ancestor_ids = await self._ancestor_task_ids(task_id)
|
||||||
|
if not ancestor_ids:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
await self._db.execute(
|
||||||
|
select(
|
||||||
|
TaskTable.id,
|
||||||
|
TaskTable.title,
|
||||||
|
TaskTable.description,
|
||||||
|
).where(TaskTable.id.in_(ancestor_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
by_id = {str(r.id): r for r in rows}
|
||||||
|
context: list[dict[str, Any]] = []
|
||||||
|
for depth, aid in enumerate(ancestor_ids, start=1):
|
||||||
|
row = by_id.get(str(aid))
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
context.append(
|
||||||
|
{
|
||||||
|
"task_id": str(aid),
|
||||||
|
"depth": depth,
|
||||||
|
"title": row.title,
|
||||||
|
"description": _clip(row.description, _ANCESTOR_DESC_CAP),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
async def journal_highlights_for_task(
|
||||||
|
self, task_id: UUID, *, include_ancestors: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""The task's upstream handoff: every author's decision / reflection /
|
"""The task's upstream handoff: every author's decision / reflection /
|
||||||
note journal entry tied to this task, oldest first.
|
note journal entry tied to this task, oldest first.
|
||||||
|
|
||||||
@@ -328,6 +406,15 @@ class EvidenceRepo:
|
|||||||
and struggle entries are personal and excluded. Ownership is enforced by
|
and struggle entries are personal and excluded. Ownership is enforced by
|
||||||
the caller (``evidence`` only serves the task's assignee), so private
|
the caller (``evidence`` only serves the task's assignee), so private
|
||||||
task-scoped entries are surfaced to the owner who needs the full handoff.
|
task-scoped entries are surfaced to the owner who needs the full handoff.
|
||||||
|
|
||||||
|
When ``include_ancestors`` is set, the query also pulls every ancestor
|
||||||
|
task's handoff entries (walking ``parent_task_id`` to the root), so a
|
||||||
|
dev or PM claiming a leaf reads the PO / HM analysis and each PM's
|
||||||
|
decomposition rationale instead of re-deriving it. Oldest-first
|
||||||
|
ordering puts the root's analysis at the top (the "what and why"); the
|
||||||
|
leaf's own entries follow. Opt-in so QA's leaf-journal view (the dev's
|
||||||
|
intent) stays undiluted — QA gets the parent objective via a dedicated
|
||||||
|
evidence field instead.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -339,6 +426,10 @@ class EvidenceRepo:
|
|||||||
JournalEntryType.TASK_REFLECTION,
|
JournalEntryType.TASK_REFLECTION,
|
||||||
JournalEntryType.GENERAL,
|
JournalEntryType.GENERAL,
|
||||||
)
|
)
|
||||||
|
if include_ancestors:
|
||||||
|
scope_ids: list[UUID] = [task_id, *await self._ancestor_task_ids(task_id)]
|
||||||
|
else:
|
||||||
|
scope_ids = [task_id]
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
JournalEntryTable.type,
|
JournalEntryTable.type,
|
||||||
@@ -350,7 +441,7 @@ class EvidenceRepo:
|
|||||||
)
|
)
|
||||||
.join(JournalTable, JournalEntryTable.journal_id == JournalTable.id)
|
.join(JournalTable, JournalEntryTable.journal_id == JournalTable.id)
|
||||||
.join(AgentTable, JournalTable.agent_id == AgentTable.id)
|
.join(AgentTable, JournalTable.agent_id == AgentTable.id)
|
||||||
.where(JournalEntryTable.task_id == task_id)
|
.where(JournalEntryTable.task_id.in_(scope_ids))
|
||||||
.where(JournalEntryTable.type.in_(handoff_types))
|
.where(JournalEntryTable.type.in_(handoff_types))
|
||||||
.order_by(JournalEntryTable.timestamp.asc())
|
.order_by(JournalEntryTable.timestamp.asc())
|
||||||
.limit(50)
|
.limit(50)
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class TestBriefingScope:
|
|||||||
)
|
)
|
||||||
briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True)
|
briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True)
|
||||||
assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER
|
assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER
|
||||||
repo.journal_highlights_for_task.assert_awaited_once_with(task_id)
|
repo.journal_highlights_for_task.assert_awaited_once_with(
|
||||||
|
task_id, include_ancestors=True
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_slim_with_task_omits_handoff(self) -> None:
|
async def test_slim_with_task_omits_handoff(self) -> None:
|
||||||
|
|||||||
@@ -444,3 +444,68 @@ class TestExtractPmReview:
|
|||||||
digest = build_task_handoff(t, [])
|
digest = build_task_handoff(t, [])
|
||||||
assert digest is not None
|
assert digest is not None
|
||||||
assert "pm_review" not in digest
|
assert "pm_review" not in digest
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescriptionAndParentContext:
|
||||||
|
"""The ask (description + upstream parent_context) now rides the evidence
|
||||||
|
payload and the task handoff so a freshly-claimed leaf receives the intake's
|
||||||
|
analysis instead of an empty handoff."""
|
||||||
|
|
||||||
|
def test_payload_omits_description_when_none(self) -> None:
|
||||||
|
"""A descriptionless task must not emit ``description: null`` in every
|
||||||
|
evidence payload — omitted like the other noise-when-empty fields."""
|
||||||
|
t = _task()
|
||||||
|
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
|
||||||
|
assert "description" not in ev.as_dict()
|
||||||
|
|
||||||
|
def test_payload_carries_description_when_set(self) -> None:
|
||||||
|
t = _task()
|
||||||
|
t.description = "edit prompter.py:412 to thread task_id through"
|
||||||
|
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
|
||||||
|
assert (
|
||||||
|
ev.as_dict()["description"]
|
||||||
|
== "edit prompter.py:412 to thread task_id through"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_payload_carries_parent_context(self) -> None:
|
||||||
|
t = _task()
|
||||||
|
chain = [
|
||||||
|
{
|
||||||
|
"task_id": "p",
|
||||||
|
"depth": 1,
|
||||||
|
"title": "Root",
|
||||||
|
"description": "intake analysis",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ev = build_evidence_for_task(
|
||||||
|
t, journal_highlights=[], files_changed=[], parent_context=chain
|
||||||
|
)
|
||||||
|
assert ev.as_dict()["parent_context"] == chain
|
||||||
|
|
||||||
|
def test_payload_omits_parent_context_when_empty(self) -> None:
|
||||||
|
t = _task()
|
||||||
|
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
|
||||||
|
assert "parent_context" not in ev.as_dict()
|
||||||
|
|
||||||
|
def test_handoff_carries_description_and_parent_context(self) -> None:
|
||||||
|
"""A leaf with no commits/PR yet still gets a handoff when the spec
|
||||||
|
(description) or the upstream chain is present — the intake analysis
|
||||||
|
reaches the dev at claim time."""
|
||||||
|
t = _task(pr_number=None, pr_url=None, commits=[], dev_notes="")
|
||||||
|
t.description = "the ask: edit foo.py:10"
|
||||||
|
chain = [
|
||||||
|
{"task_id": "p", "depth": 1, "title": "Root", "description": "upstream"}
|
||||||
|
]
|
||||||
|
handoff = build_task_handoff(t, [], parent_context=chain)
|
||||||
|
assert handoff is not None
|
||||||
|
assert handoff["description"] == "the ask: edit foo.py:10"
|
||||||
|
assert handoff["parent_context"] == chain
|
||||||
|
|
||||||
|
def test_handoff_none_when_no_spec_and_no_prior_work(self) -> None:
|
||||||
|
"""A bare task with no spec, no commits, no PR, no findings, no upstream
|
||||||
|
chain still collapses to None — no empty handoff payload."""
|
||||||
|
t = _task(pr_number=None, pr_url=None, commits=[], dev_notes="")
|
||||||
|
t.commits = [] # _task's `commits or [...]` default would re-seed one
|
||||||
|
t.acceptance_criteria_status = []
|
||||||
|
# MagicMock auto-attrs make description a non-str -> _typed -> None.
|
||||||
|
assert build_task_handoff(t, []) is None
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ async def test_evidence_populates_journal_highlights() -> None:
|
|||||||
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
|
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
|
||||||
body = env.as_dict()
|
body = env.as_dict()
|
||||||
assert body["evidence"]["journal_highlights"] == highlights
|
assert body["evidence"]["journal_highlights"] == highlights
|
||||||
evidence_repo.journal_highlights_for_task.assert_awaited_once_with(task_id)
|
evidence_repo.journal_highlights_for_task.assert_awaited_once_with(
|
||||||
|
task_id, include_ancestors=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -13,7 +13,25 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.services.gateway.evidence_repo import EvidenceRepo
|
from roboco.services.gateway.evidence_repo import (
|
||||||
|
_ANCESTOR_DESC_CAP,
|
||||||
|
_HIERARCHY_DEPTH_CAP,
|
||||||
|
EvidenceRepo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar_result(value: object) -> MagicMock:
|
||||||
|
"""A query result whose ``scalar_one_or_none()`` returns ``value``."""
|
||||||
|
r = MagicMock()
|
||||||
|
r.scalar_one_or_none.return_value = value
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_result(rows: list[object]) -> MagicMock:
|
||||||
|
"""A query result whose ``all()`` returns ``rows``."""
|
||||||
|
r = MagicMock()
|
||||||
|
r.all.return_value = rows
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _empty_repo() -> EvidenceRepo:
|
def _empty_repo() -> EvidenceRepo:
|
||||||
@@ -233,3 +251,130 @@ async def test_journal_highlights_for_task_maps_rows_with_author() -> None:
|
|||||||
"timestamp": ts.isoformat(),
|
"timestamp": ts.isoformat(),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parent-chain walk + ancestor context (the intake-analysis torch carrier).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_task_ids_walks_parent_to_root() -> None:
|
||||||
|
leaf, parent, grand = uuid4(), uuid4(), uuid4()
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_scalar_result(parent),
|
||||||
|
_scalar_result(grand),
|
||||||
|
_scalar_result(None), # grand is a root
|
||||||
|
]
|
||||||
|
)
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo._ancestor_task_ids(leaf) == [parent, grand]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_task_ids_cycle_guard_stops() -> None:
|
||||||
|
leaf, parent = uuid4(), uuid4()
|
||||||
|
db = MagicMock()
|
||||||
|
# leaf -> parent -> leaf (cycles back to the start, already in `seen`).
|
||||||
|
db.execute = AsyncMock(side_effect=[_scalar_result(parent), _scalar_result(leaf)])
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo._ancestor_task_ids(leaf) == [parent]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_task_ids_missing_row_returns_empty() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(side_effect=[_scalar_result(None)])
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo._ancestor_task_ids(uuid4()) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_task_ids_depth_capped() -> None:
|
||||||
|
start = uuid4()
|
||||||
|
chain = [uuid4() for _ in range(_HIERARCHY_DEPTH_CAP + 5)]
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(side_effect=[_scalar_result(p) for p in chain])
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
result = await repo._ancestor_task_ids(start)
|
||||||
|
assert len(result) == _HIERARCHY_DEPTH_CAP
|
||||||
|
assert result == chain[:_HIERARCHY_DEPTH_CAP]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_context_for_task_parentless_returns_empty() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(side_effect=[_scalar_result(None)])
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo.ancestor_context_for_task(uuid4()) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_context_for_task_maps_chain_with_depth() -> None:
|
||||||
|
leaf, parent, grand = uuid4(), uuid4(), uuid4()
|
||||||
|
row_p = SimpleNamespace(id=parent, title="Cell PM slice", description="p-desc")
|
||||||
|
row_g = SimpleNamespace(id=grand, title="Root", description="g-desc")
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_scalar_result(parent),
|
||||||
|
_scalar_result(grand),
|
||||||
|
_scalar_result(None), # end of _ancestor_task_ids
|
||||||
|
_rows_result([row_p, row_g]), # the batch fetch
|
||||||
|
]
|
||||||
|
)
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo.ancestor_context_for_task(leaf) == [
|
||||||
|
{
|
||||||
|
"task_id": str(parent),
|
||||||
|
"depth": 1,
|
||||||
|
"title": "Cell PM slice",
|
||||||
|
"description": "p-desc",
|
||||||
|
},
|
||||||
|
{"task_id": str(grand), "depth": 2, "title": "Root", "description": "g-desc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_context_for_task_skips_missing_ancestor_row() -> None:
|
||||||
|
leaf, parent, grand = uuid4(), uuid4(), uuid4()
|
||||||
|
row_p = SimpleNamespace(id=parent, title="Cell PM slice", description="p-desc")
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_scalar_result(parent),
|
||||||
|
_scalar_result(grand),
|
||||||
|
_scalar_result(None),
|
||||||
|
_rows_result([row_p]), # grand's row is missing from the batch
|
||||||
|
]
|
||||||
|
)
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
assert await repo.ancestor_context_for_task(leaf) == [
|
||||||
|
{
|
||||||
|
"task_id": str(parent),
|
||||||
|
"depth": 1,
|
||||||
|
"title": "Cell PM slice",
|
||||||
|
"description": "p-desc",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ancestor_context_for_task_clips_long_description() -> None:
|
||||||
|
leaf, parent = uuid4(), uuid4()
|
||||||
|
row_p = SimpleNamespace(
|
||||||
|
id=parent, title="P", description="x" * (_ANCESTOR_DESC_CAP + 500)
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_scalar_result(parent),
|
||||||
|
_scalar_result(None),
|
||||||
|
_rows_result([row_p]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
repo = EvidenceRepo(db)
|
||||||
|
ctx = await repo.ancestor_context_for_task(leaf)
|
||||||
|
assert len(ctx[0]["description"]) == _ANCESTOR_DESC_CAP
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""``AgentOrchestrator._description_body`` — the bounded description block for
|
||||||
|
the dev spawn prompt + SessionStart briefing. Pure static helper, no fixtures."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||||
|
|
||||||
|
_PLACEHOLDER = "(none — ask the PM before proceeding)"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescriptionBody:
|
||||||
|
def test_empty_returns_placeholder(self) -> None:
|
||||||
|
assert AgentOrchestrator._description_body("") == _PLACEHOLDER
|
||||||
|
assert AgentOrchestrator._description_body(None) == _PLACEHOLDER
|
||||||
|
assert AgentOrchestrator._description_body(" \n ") == _PLACEHOLDER
|
||||||
|
|
||||||
|
def test_short_description_returned_verbatim(self) -> None:
|
||||||
|
desc = "edit prompter.py:412 — thread task_id through update_live_batch"
|
||||||
|
assert AgentOrchestrator._description_body(desc) == desc
|
||||||
|
|
||||||
|
def test_long_description_capped_with_omitted_marker(self) -> None:
|
||||||
|
cap = 4000
|
||||||
|
desc = "x" * (cap + 1500)
|
||||||
|
body = AgentOrchestrator._description_body(desc)
|
||||||
|
assert body.startswith("x" * cap)
|
||||||
|
assert "chars omitted" in body
|
||||||
|
assert "evidence() carries" in body
|
||||||
|
# The kept prefix is exactly the cap; the marker follows.
|
||||||
|
assert len(body.split("\n… ", 1)[0]) == cap
|
||||||
|
|
||||||
|
def test_custom_cap_respected(self) -> None:
|
||||||
|
body = AgentOrchestrator._description_body("x" * 50, cap=10)
|
||||||
|
assert body.startswith("x" * 10)
|
||||||
|
assert "chars omitted" in body
|
||||||
Reference in New Issue
Block a user