mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Token optimization levers — claim-scoped briefing, payload caps, role-scoped optimal, notification-spawn cooldown (#292)
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs
* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star
* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only
* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations
* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks
* feat(orchestrator): cross-tick cooldown for notification-triggered spawns
* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env
* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown
* test(mcp): type the mixed-item cap fixture explicitly
* fix(orchestrator): lazy-init the notification-spawn cooldown store
* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)
B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.
B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.
* fix(panel): stop scorecard fetches for fallback-roster placeholder ids
useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).
* Upgraded uv.lock
* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)
B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.
B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.
Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).
B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.
B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.
* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)
Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.
Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.
* style: ruff format for the orchestration sweep
* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate
_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -15,10 +15,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Per-role compute policy — model tier + reasoning effort.** Each role can carry both a model tier (`ROLE_MODEL_MAP`) and a per-role reasoning-effort level (`ROLE_EFFORT_MAP` → `CLAUDE_CODE_EFFORT_LEVEL` injected into the agent container). The effort map ships **empty (inert)** — populating a role is opt-in, only after verifying on one spawn that the level actually moves token usage (the CLI config key is version-dependent, and a wrong value is a silent no-op). Roles routed to Haiku ignore effort (Haiku has no effort control).
|
||||
- **Spawn preflight (default-off, `ROBOCO_SPAWN_PREFLIGHT_ENABLED`).** A flag-gated guard that refuses to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES`: such a role gets no gateway manifest and can never claim its work, so the dispatcher would respawn it on the same task forever. Instead of burning the full system prompt on each futile retry, the spawn is refused (`AgentReadinessError`) and the overseer is alerted once. Inert in practice — every real delivery role is gateway-enabled — so it's a misconfiguration guardrail. Armed on the NAS composes, off in the published registry compose.
|
||||
|
||||
- **Notification-spawn cooldown — the missing loop-breaker on task-less dispatches.** The escalation / approval / audit / a2a dispatchers spawn agents from unacknowledged notifications with no task attached, so neither the readiness gate nor the PM respawn circuit breaker (both task-keyed) ever saw them: an unacked notification respawned its recipient on **every dispatcher tick**, unbounded. A cross-tick damper (`ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS`, default 600) now allows one spawn per (agent, notification) per window — the notification stays pending, so the next window retries if it is still unacknowledged; `0` restores the legacy every-tick behavior.
|
||||
- **`request_changes` — the PM's merge-level reject at `awaiting_pm_review`.** A PM that caught a genuine AC/scope violation at merge review previously had no in-band way to send the work back (its only verbs there were `complete` and escalate), so it looped `i_am_blocked` → escalate → unblock → re-block — the live fe-pm loop from the S6 MegaTask run. The new PM verb (cell_pm + main_pm) transitions `awaiting_pm_review` → `needs_revision` with at least one concrete issue, appends the issues to the dev's notes, routes the revision like a QA fail (original developer for a leaf, the revision PM for an assembled task), and a2a-delivers the reject reason to the new owner so it is never stranded.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Claim-scoped context briefing — the heavy sections ride only on context-acquisition verbs.** Production transcripts showed every flow-verb response (success *and* rejection, `i_am_idle` included) carrying `company_goals` + `recent_team_activity` (+ task handoff) — a ~500–600-token constant floor re-sent on every verb call and re-read at cache-read price on every later turn (a failed `i_will_plan` was observed shipping a 24.8KB handoff on an *error* envelope). The full briefing now attaches only to context-acquisition verbs (`give_me_work`, claims, `i_will_plan`, `resume`, `triage`); every other verb gets a slim signals-only briefing (unread A2As/mentions/notifications + metadata gaps — the parts `i_am_idle`'s unread check actually consumes). Slim responses also skip the heavy DB queries entirely.
|
||||
- **LLM-facing payloads are capped, with explicit truncation markers.** Embedded diffs (QA `claim_review` evidence, the `evidence` verb, `roboco_git_diff`) cap at 20K chars with a pointer to the full diff (the PR / file-scoped diff — the panel's HTTP diff route stays uncapped); notification bodies, handoff journal content, and the company-charter north star clip to briefing-sized excerpts (full texts stay readable via `notify_get` and the panel); kb/error/learning search results, mentor sources, and RAG citations cap per-item content. Median payloads are untouched — these bound the tail (observed p90s of 34–37KB per single tool result).
|
||||
- **The optimal MCP server is role-scoped, like flow/do already were.** Agents no longer carry all 20 optimal tool schemas (~3.7K tokens of every turn's context): error tools go to developer/QA, standards/review to the delivery+review roles, decisions to PMs/Board, doc indexing to the documenter; search/mentor/learnings stay universal. Destructive index management (`clear_index`, `reindex_all`) no longer registers for any agent — dev/test only via `ROBOCO_ALLOW_FULL_TOOLSET`. Unknown roles fail open to the full set.
|
||||
- **Agent Bash output is explicitly capped** (`BASH_MAX_OUTPUT_LENGTH=20000` in the generated agent settings) so a giant gate/test dump can't flood the session context, and **the spawn-waste metric counts Anthropic sessions only** (`basis: anthropic_sessions`) — non-Anthropic transcripts don't reliably populate output tokens and were reporting phantom waste.
|
||||
- **Model-tier routing rebalanced for cost.** **QA** now routes to Haiku (mechanical gate work whose cost is cache-dominated) and **Main PM** moves off Opus to **Sonnet 5** — coordination cost is dominated by cache read/write, and Sonnet 5's cache-write is ~12× cheaper than Opus. The Main PM move is a watched experiment, revertible via `ROLE_MODEL_MAP` or a per-slug model override without a code change. (Builds on the earlier `sonnet`→Sonnet 5 alias adoption and the PR-reviewer→Opus tiering.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Declared dependencies become real edges (MegaTask + delegate).** The live S6 out-of-order break, both halves. Batch intake: each draft's `depends_on` (the CEO's declared "Depends on" list, batch indices) is now wired verbatim into the sequencing DAG — `SequencingService.analyze` unions declared edges with the derived collision rules (self/out-of-range references rejected, cycles caught by the existing toposort); previously only analyzer-derived file-overlap edges were wired and a declared wave could be silently dropped. Delegate: a `code` subtask now REQUIRES a non-empty `intends_to_touch` collision surface (new `TASK_AT_DELEGATE` completeness spec) — a no-surface code sibling is "parallel to everything" by analyzer design, which is how two devs ran explicitly-sequenced work out of order on divergent branches. Non-code delegations and REST/manual creation are unchanged.
|
||||
- **Respawn circuit breaker now guards every task-keyed spawn path.** The progress-aware breaker (strike counting with status-advance reset, tracing-gap budget, DB durability, one-shot CEO notification) was consulted by only 3 dispatch paths; doc/QA/dev/PR-review/PR-gate/revision/board spawns ran unguarded at fixed cadence — a documenter with no valid verb respawned 26× in ~100 min on one task. The gate is now consulted at all 14 task-keyed spawn sites.
|
||||
- **Assembled-PR freshness + integrity at submit_up / submit_root.** Freshness: the assembled cell/root branch is auto-rebased onto its base when behind (children are terminal at submit time; master is never written); a rebase conflict is a clean rejection naming the files — ends the needs_revision ↔ awaiting_pr_review ping-pong of re-reviewing a stale head. Integrity: every completed child's commits must be patch-present (`git cherry`, rebase-safe) in the assembled branch before review — a completed revert whose merge was lost re-spawned the exact violation it fixed.
|
||||
- **Documenter revision-pass dead end.** A task re-entering `awaiting_documentation` with docs already written left the documenter no move it recognized: `i_am_blocked`/`unclaim` are invalid there, and the generic rejection never named the exit. Both rejections now point at the actual exit (`i_documented` re-affirming the existing docs) and the documenter prompt gains an explicit revision-pass rule.
|
||||
- **Dispatcher heartbeat.** The dispatch loop can die silently (a 4h25m fleet-wide outage left no log line or audit row — its stdout died with the container). A `dispatcher.alive` audit row every 5 minutes makes a dead loop detectable from the DB and distinguishable from "no work".
|
||||
|
||||
|
||||
- **Admin status override now reconciles claim ownership.** Forcing a `blocked` task into a review/queue state (`needs_revision`, `awaiting_qa`, `awaiting_documentation`, `awaiting_pr_review`, `awaiting_pm_review`) previously left the stale escalation claim in place, so the next claimant was handed the task by `give_me_work`/`triage` while its `note()` writes bounced `not_authorized "you do not hold the claim"` — it re-blocked immediately. The override now clears the claim (`claimed_by`/`claimed_at`/`active_claimant_id`) and consumes the pre-block snapshot for review-state targets; the pending/in_progress owner-restore path additionally syncs `active_claimant_id` so the restored owner's content writes don't bounce either. A REST PATCH that unassigns a task (`assigned_to: null`) now releases the claim with it.
|
||||
|
||||
## [0.15.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -243,6 +243,7 @@ All status transitions are validated through the enforcement layer. Key restrict
|
||||
| `awaiting_pr_review` → `awaiting_pm_review` (pr_pass) | PR reviewer only |
|
||||
| `awaiting_pr_review` → `needs_revision` (pr_fail) | PR reviewer only |
|
||||
| `awaiting_pm_review` → `completed` | PM roles only |
|
||||
| `awaiting_pm_review` → `needs_revision` (request_changes) | PM roles only — the merge-level reject with concrete issues |
|
||||
| `awaiting_pm_review` → `awaiting_ceo_approval` | PM roles only |
|
||||
| `awaiting_ceo_approval` → `completed/needs_revision/cancelled` | CEO only |
|
||||
| Any → `cancelled` | PM roles only |
|
||||
@@ -344,8 +345,8 @@ Each agent gets a **spawn manifest** at `/app/tool-manifest.json` listing the ve
|
||||
| developer | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `resume`, `sync_branch`, `unclaim` |
|
||||
| qa | `give_me_work`, `claim_review`, `pass_review`, `fail_review`, `i_am_blocked`, `resume`, `unclaim` |
|
||||
| documenter | `give_me_work`, `claim_doc_task`, `i_documented`, `i_am_blocked`, `resume`, `unclaim` |
|
||||
| cell_pm | `give_me_work`, `i_will_plan`, `delegate`, `complete`, `submit_up`, `triage`, `unblock`, `escalate_up`, `reassign`, `resume`, `unclaim` |
|
||||
| main_pm | `give_me_work`, `i_will_plan`, `delegate`, `complete`, `submit_root`, `triage`, `triage_all`, `unblock`, `escalate_up`, `escalate_to_ceo`, `resume`, `unclaim` |
|
||||
| cell_pm | `give_me_work`, `i_will_plan`, `delegate`, `complete`, `request_changes`, `submit_up`, `triage`, `unblock`, `escalate_up`, `reassign`, `resume`, `unclaim` |
|
||||
| main_pm | `give_me_work`, `i_will_plan`, `delegate`, `complete`, `request_changes`, `submit_root`, `triage`, `triage_all`, `unblock`, `escalate_up`, `escalate_to_ceo`, `resume`, `unclaim` |
|
||||
| pr_reviewer | `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim` |
|
||||
| product_owner | `triage`, `escalate_to_ceo` |
|
||||
| head_marketing| `triage`, `escalate_to_ceo` |
|
||||
|
||||
@@ -10,6 +10,7 @@ other verb will be rejected with a Decision telling you the right one.
|
||||
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
|
||||
- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.
|
||||
- **reassign**: Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved — the new developer continues the work-in-progress. No status change.
|
||||
- **request_changes**: Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review — never i_am_blocked/escalate, which have no revision routing.
|
||||
- **resume**: Resume a paused task you own. paused -> in_progress.
|
||||
- **submit_up**: Cell PM opens the cell→root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.
|
||||
- **triage**: List actionable tasks in your scope.
|
||||
|
||||
@@ -10,6 +10,7 @@ other verb will be rejected with a Decision telling you the right one.
|
||||
- **give_me_work**: Return your most-actionable task or signal idle.
|
||||
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
|
||||
- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.
|
||||
- **request_changes**: Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review — never i_am_blocked/escalate, which have no revision routing.
|
||||
- **resume**: Resume a paused task you own. paused -> in_progress.
|
||||
- **submit_root**: Main PM opens the root→master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed — a Main-PM root is planning-typed, never code.
|
||||
- **triage**: List actionable tasks in your scope.
|
||||
|
||||
@@ -25,10 +25,11 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
|
||||
|---|---|---|
|
||||
| `give_me_work()` | Returns your highest-priority task (your own pending PM task, or a subtask in `awaiting_pm_review` for you to merge). | None. |
|
||||
| `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR cell-PM task, record your plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** explaining HOW you decompose + route + sequence (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars saying what that step actually does** — each sub_task is both a `delegate` target AND a progress-checklist item, so it must be a real step. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Example sub_task: `{"title": "Add timestamp comment to README", "description": "be-dev-1 edits README.md, prepends an HTML comment <!-- smoke-test: <date> --> above the H1, leaving the rest of the file untouched"}`. Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. |
|
||||
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?, intends_to_touch?, adds_migration?, touches_shared?, depends_on?)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature` ∈ `technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. `covers_parent_criteria` is the list of YOUR criterion ids (from the briefing's `parent_ac_coverage`) this subtask satisfies — see "Coverage" below. `intends_to_touch`/`adds_migration`/`touches_shared` are the **collision surface** — see "Collision surface" below; fill them on every `code` subtask so the system can sequence sibling dev tasks that touch the same files into a conflict-free order. `depends_on` is a list of sibling subtask IDs this one must wait on (cross-reroute gate). Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. |
|
||||
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?, intends_to_touch?, adds_migration?, touches_shared?, depends_on?)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature` ∈ `technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. `covers_parent_criteria` is the list of YOUR criterion ids (from the briefing's `parent_ac_coverage`) this subtask satisfies — see "Coverage" below. `intends_to_touch`/`adds_migration`/`touches_shared` are the **collision surface** — `intends_to_touch` is **REQUIRED (non-empty) on every `code` subtask**; the gateway rejects a code delegation without it, because a no-surface sibling runs parallel to everything and ordered work executes out of order — see "Collision surface" below; fill them on every `code` subtask so the system can sequence sibling dev tasks that touch the same files into a conflict-free order. `depends_on` is a list of sibling subtask IDs this one must wait on (cross-reroute gate). Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. |
|
||||
| `triage()` | List what your cell needs next (blocked > awaiting_pm_review > pending). | None. |
|
||||
| `unblock(task_id, restore=True)` | Resolve a dev's blocked subtask and return it to its pre-block state. | Subtask is in your cell. |
|
||||
| `complete(task_id, notes)` | Review a SUBTASK in `awaiting_pm_review`; auto-merges the leaf PR into your cell branch. | All descendants of the subtask terminal; PR open and mergeable. |
|
||||
| `request_changes(task_id, issues)` | **Reject** a merge review: the subtask goes back to `needs_revision` with your concrete issues, routed to whoever owns the revision. Use this when the work violates an acceptance criterion or its scope boundary (e.g. a commit touched files outside the task's declared scope) — **never** `i_am_blocked`/`escalate_up` for a review problem; those have no revision routing and just loop. | Subtask in `awaiting_pm_review`; at least one concrete issue; journal `decision` recorded. |
|
||||
| `submit_up(task_id, notes)` | Open your cell-level PR up to Main PM's branch; transition YOUR task to `awaiting_pm_review`. | All your subtasks terminal; `notes` >= 20 chars; journal `decision` recorded. |
|
||||
| `escalate_up(task_id, reason)` | Escalate to Main PM. | Task is yours or assigned to your cell. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
@@ -67,7 +68,7 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
|
||||
| `blocked` (waiting on a cross-cell dependency) | leave it — it auto-clears when the upstream completes. Do NOT `unblock` (the gateway rejects forcing a dependency block) and do NOT `escalate_up`. `i_am_idle()` and let the orchestrator revive it. |
|
||||
| `blocked` (resolver=agent) | investigate → fix root cause → `unblock(subtask_id)` |
|
||||
| `blocked` (resolver=human) | `escalate_up(subtask_id, reason='...')` |
|
||||
| `awaiting_pm_review` (a dev's leaf came back) | `evidence(subtask_id)` to review diff → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges into your branch) |
|
||||
| `awaiting_pm_review` (a dev's leaf came back) | `evidence(subtask_id)` to review diff → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges into your branch). **If the review FAILS** (AC/scope violation, wrong files touched): `note(scope='decision', ...)` → `request_changes(subtask_id, issues=[...])` — do NOT block or escalate a review problem. |
|
||||
| `needs_revision` | dev re-claims; you stay out |
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -22,6 +22,8 @@ You do NOT re-implement the developer's work. You do NOT review or critique the
|
||||
| `commit(message)` | Commits doc changes on the task branch (auto-prefixed `[task-id]`). | Task in `in_progress`; on the task branch. |
|
||||
| `pr_update(task_id, title?, body?, reviewers?)` | Update the PR's title, body, or reviewer list (e.g. to add a doc-relevant summary). At least one field must be set. **Do NOT bash-shim `gh pr edit`** — use this verb. | Task has `pr_number`; you are claimant on the doc task. |
|
||||
| `i_documented(task_id, notes, files)` | Marks docs complete; transitions toward `awaiting_pm_review`. | At least one doc file in `files`; `notes` >= 20 chars. |
|
||||
|
||||
**Revision passes:** a task can re-enter `awaiting_documentation` after a code revision. If the docs you (or a prior run) already wrote are still complete and accurate for the revised diff, **call `i_documented` re-affirming them** (`files` = the existing doc paths, `notes` = what you verified). Do NOT try `i_am_blocked` or `unclaim` because "there is nothing to write" — neither is valid at this stage, the session dies, and the dispatcher respawns you into the same dead end forever (a live 26-respawn loop). `i_documented` IS the exit.
|
||||
| `i_am_blocked(task_id, reason, blocker_type?, what_needed?)` | Record a blocker, escalate to your PM, idle. `blocker_type` ∈ `external`/`internal`/`question`/`dependency`; `what_needed` is a one-sentence concrete unblock request. Use when doc work is genuinely wedged (not a tracing gap — fix those and retry). | Task is yours and active. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
|
||||
|
||||
@@ -57,6 +57,7 @@ This is the single most common mental-model mistake at your seat. Get it right:
|
||||
| `triage_all()` | List blockers and reviews across all cells. | None. |
|
||||
| `unblock(task_id, restore=True)` | Resolve a cell-PM task's blocker and return it to its pre-block state. | None. |
|
||||
| `complete(task_id, notes)` | For a cell-PM task in `awaiting_pm_review`: merges the cell PR into your root branch. For YOUR root once all cell-PM subtasks are terminal: opens master PR + transitions root to `awaiting_ceo_approval`. | All descendants terminal; journal `decision` recorded. |
|
||||
| `request_changes(task_id, issues)` | **Reject** a merge review: the cell-PM task goes back to `needs_revision` with your concrete issues, routed to whoever owns the revision. Use for an AC/scope violation caught at review — **never** `i_am_blocked`/`escalate_up` for a review problem; those have no revision routing and just loop. | Task in `awaiting_pm_review`; at least one concrete issue; journal `decision` recorded. |
|
||||
| `escalate_up(task_id, reason)` | Escalate a stuck task up your chain to CEO. | Task is yours or assigned to a cell under your scope. |
|
||||
| `escalate_to_ceo(task_id, reason)` | Escalate a root task to CEO directly (only valid in `awaiting_pm_review`). | Root task in `awaiting_pm_review`; `pr_number` set. |
|
||||
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
|
||||
@@ -95,7 +96,7 @@ This is the single most common mental-model mistake at your seat. Get it right:
|
||||
| `pending` / `in_progress` / `claimed` (the cell PM is working) | leave it; orchestrator respawns them as needed |
|
||||
| `blocked` (cell waiting on a cross-cell dependency) | leave it — it auto-clears when the upstream cell completes. Do NOT `unblock` (rejected) or escalate. `i_am_idle()`. |
|
||||
| `blocked` (a real delegation issue) | investigate → fix delegation issue → `unblock(subtask_id)` |
|
||||
| `awaiting_pm_review` (a cell PM submitted up) | `evidence(subtask_id)` → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges cell PR into your root branch) |
|
||||
| `awaiting_pm_review` (a cell PM submitted up) | `evidence(subtask_id)` → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges cell PR into your root branch). **If the review FAILS** (AC/scope violation): `note(scope='decision', ...)` → `request_changes(subtask_id, issues=[...])` — do NOT block or escalate a review problem. |
|
||||
| `needs_revision` | cell PM re-claims; you stay out |
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -86,8 +86,9 @@ When you are scoped to a **MegaTask**, the CEO wants several distinct tasks work
|
||||
- `intends_to_touch` — the files/dirs this task will modify (globs are fine), from what you read in its repo.
|
||||
- `adds_migration` — `true` if it adds a DB migration / new column.
|
||||
- `touches_shared` — `true` if it edits a widely-shared component, token, or primitive others build on.
|
||||
- `depends_on` — the batch **indices** (0-based) of drafts this one must wait for. **When the CEO declares an ordering ("Wave #2", "Depends on: S1, R2, R3"), copy it here VERBATIM** — declared dependencies are authoritative and become real edges; they are NEVER inferred from the surfaces alone. Dropping a declared dependency is how ordered work has executed out of order in the past.
|
||||
|
||||
Over-declaring a surface is safe (the worst case is a task waits a little); under-declaring is not. You do **not** compute the order yourself — declare each surface honestly and the analyzer derives the waves. Present all the tasks in prose first (a short paragraph each), then call `propose_batch` once. If the conversation changes the set, call it again with the full updated batch.
|
||||
Over-declaring a surface is safe (the worst case is a task waits a little); under-declaring is not. You do **not** compute the order yourself — declare each surface honestly, copy the CEO's declared `depends_on` verbatim, and the analyzer derives the waves (declared edges unioned with derived ones). Present all the tasks in prose first (a short paragraph each), then call `propose_batch` once. If the conversation changes the set, call it again with the full updated batch.
|
||||
|
||||
**Each draft in a MegaTask becomes a Main-PM coordination root-subtask** — the Main PM coordinates it and delegates the actual code to the cells; the Main PM never writes the code itself. So draft each root-subtask as the coordination it is, not as code the Main PM will implement:
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ The orchestrator's dispatcher uses these to pace spawns, detect loops, and recla
|
||||
| `ROBOCO_CLAIM_STALE_SECONDS` | `180` | Claim-heartbeat staleness used by the spawn trigger filter. |
|
||||
| `ROBOCO_STALE_CLAIM_REAP_SECONDS` | `600` | Reaper-only stale-claim threshold before releasing a claim back to pending. |
|
||||
| `ROBOCO_PM_CLOSURE_RECENTLY_PAUSED_SECONDS` | `45` | Debounce before respawning a PM to close a recently paused parent. |
|
||||
| `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a): one spawn per (agent, notification) per window. The notification stays pending, so the next window retries if still unacknowledged; `0` restores the legacy every-tick respawn. |
|
||||
| `ROBOCO_GROK_IDLE_KILL_SECONDS` | `900` | Idle-container kill threshold for Grok agents (they emit no SDK heartbeat). |
|
||||
| `ROBOCO_GROK_MAX_COST_USD` | `0.0` | Per-agent Grok cost ceiling (USD) before kill; `0` disables. |
|
||||
| `ROBOCO_INTERACTIVE_IDLE_REAP_SECONDS` | `1800` | Idle-reap threshold for live intake/secretary chats; `0` disables. |
|
||||
|
||||
@@ -19,7 +19,7 @@ The Choreographer is the server-side composition layer that turns agent intent-v
|
||||
| `ChoreographerDeps` | dataclass | `_impl.py:207` | Dependency injection container (task, work_session, git, a2a, journal, audit, evidence_repo, messaging, product, orchestrator, stream_bus). |
|
||||
| `_COORDINATOR_ROLES` | ClassVar | `_impl.py:914` | `{main_pm, cell_pm}` — exempt from `already_active`/`paused` claim guards + advisory lock. |
|
||||
| `give_me_work` | async verb | `_impl.py:766` | Picks next task for agent + builds briefing (institutional memory injected here). |
|
||||
| `_briefing_for` | async helper | `_impl.py:814` | Builds `context_briefing` (handoff, evidence, memory, AC coverage). |
|
||||
| `_briefing_for` | async helper | `_impl.py:820` | Builds `context_briefing`. Claim-scoped: `full=True` (context-acquisition verbs only — give_me_work/claims/plan/resume/triage) carries the heavy sections via `_heavy_briefing_sections` (company_goals, recent_team_activity, blockers, task_handoff, institutional_memory); every other verb gets slim signals-only (unread a2a/mentions/notifications + metadata gaps). AC coverage stays independent of `full`. |
|
||||
| `_run_claim_guards` | async helper | `_impl.py:916` | already_active / paused / unmet_dependency (with re-check race narrowing) + `_lane_claim_guard`; `skip_dev_guards=False` param skips dev-only guards for pr_reviewer gate claims (claim_gate_review). |
|
||||
| `_lane_claim_guard` | async helper | `_impl.py:977` | Out-of-order-start barrier: refuse code leaf behind an earlier open same-assignee sibling. Fail-closed on lookup error. |
|
||||
| `_claim_plan_start_gate` | async helper | `_impl.py:1179` | spec gate → advisory claim lock (non-PM) → behavioral guards. |
|
||||
|
||||
@@ -84,6 +84,7 @@ Env-gated subsystems, default-off except the overload break. Each takes effect o
|
||||
| `ROBOCO_TOOLCHAIN_MATCH_ENABLED` | `false` | Provision each agent workspace with the target project's Python (resolved from its `requires-python` / `.python-version`) and block delivery gates when the suite cannot be executed under it. Off = today's behavior. |
|
||||
| `ROBOCO_OVERLOAD_BREAK_ENABLED` | `true` | Park a provider on a persistent model-API overload (HTTP 529 / 500 / 503) the same way a 429 is parked — queue its spawns and probe until it recovers — instead of crash-retrying into the overload. Off = crash-retry behavior. |
|
||||
| `ROBOCO_SPAWN_PREFLIGHT_ENABLED` | `false` | Refuse to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES` (no manifest → can never claim → would respawn on the same task forever); refuse + alert the overseer once instead. Inert in practice (all delivery roles are gateway-enabled). Armed on the NAS composes. |
|
||||
| `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a — task-less, so the readiness gate and respawn breaker never see them): one spawn per (agent, notification) per window; the notification stays pending so the next window retries. `0` = legacy every-tick respawn. |
|
||||
|
||||
The company-in-a-box subsystems toggle the same way and are all default-off: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), and pitch provisioning (`ROBOCO_PROVISIONING_ENABLED`).
|
||||
|
||||
|
||||
@@ -212,6 +212,15 @@ Hand a claimed/in_progress task to another developer in your own cell. The branc
|
||||
**Composes:** (no atomic actions)
|
||||
|
||||
|
||||
## request_changes
|
||||
|
||||
Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review — never i_am_blocked/escalate, which have no revision routing.
|
||||
|
||||
**Allowed roles:** cell_pm, main_pm
|
||||
|
||||
**Composes:** request_changes
|
||||
|
||||
|
||||
## resume
|
||||
|
||||
Resume a paused task you own. paused -> in_progress.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| awaiting_pm_review | awaiting_ceo_approval | escalate_to_ceo | head_marketing, main_pm, product_owner |
|
||||
| awaiting_pm_review | cancelled | cancel | cell_pm, ceo, main_pm |
|
||||
| awaiting_pm_review | completed | complete | cell_pm, main_pm |
|
||||
| awaiting_pm_review | needs_revision | request_changes | cell_pm, main_pm |
|
||||
| awaiting_pr_review | awaiting_pm_review | pr_pass | pr_reviewer |
|
||||
| awaiting_pr_review | cancelled | cancel | cell_pm, ceo, main_pm |
|
||||
| awaiting_pr_review | claimed | claim | pr_reviewer |
|
||||
|
||||
@@ -309,6 +309,19 @@
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"request_changes"
|
||||
],
|
||||
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
|
||||
"name": "request_changes",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
@@ -504,6 +517,15 @@
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "completed"
|
||||
},
|
||||
{
|
||||
"action": "request_changes",
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "pr_pass",
|
||||
"roles": [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isScorecardMemberId } from "../use-observability";
|
||||
|
||||
// The static fallback roster (use-agents AGENT_ROSTER) carries placeholder
|
||||
// ids "1".."22" while agent definitions load. Fetching scorecards for those
|
||||
// fired 22 guaranteed-422 requests per refetch cycle, clogging the browser's
|
||||
// connection pool and delaying every metrics tab by ~10s.
|
||||
describe("isScorecardMemberId", () => {
|
||||
it("rejects the static fallback roster placeholder ids", () => {
|
||||
for (let i = 1; i <= 22; i++) {
|
||||
expect(isScorecardMemberId(String(i))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts real agent UUIDs", () => {
|
||||
expect(isScorecardMemberId("00000000-0000-0000-0002-000000000001")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isScorecardMemberId("A1B2C3D4-E5F6-7890-ABCD-EF0123456789")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the ceo alias (the CEO card's member endpoint)", () => {
|
||||
expect(isScorecardMemberId("ceo")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty and junk ids", () => {
|
||||
expect(isScorecardMemberId("")).toBe(false);
|
||||
expect(isScorecardMemberId("main-pm")).toBe(false);
|
||||
expect(isScorecardMemberId("not-a-uuid-at-all")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -86,12 +86,23 @@ export function useCeoScorecard(days = 30) {
|
||||
});
|
||||
}
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Only real member ids may fetch a scorecard: an agent UUID or the "ceo"
|
||||
* alias. The static fallback roster carries placeholder ids ("1".."22")
|
||||
* while agent definitions load — fetching those fired 22 guaranteed-422
|
||||
* requests per cycle and queued the whole metrics page behind them. */
|
||||
export function isScorecardMemberId(agentId: string): boolean {
|
||||
return agentId === "ceo" || UUID_RE.test(agentId);
|
||||
}
|
||||
|
||||
/** Per-member rollup scorecard (+ live in-flight overlay). */
|
||||
export function useMemberScorecard(agentId: string, days = 30) {
|
||||
return useQuery<MemberScorecard>({
|
||||
queryKey: observabilityKeys.memberScorecard(agentId, days),
|
||||
queryFn: () => observabilityApi.getMemberScorecard(agentId, days),
|
||||
enabled: Boolean(agentId),
|
||||
enabled: isScorecardMemberId(agentId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1398,6 +1398,8 @@ export interface SpawnWasteResponse {
|
||||
unproductive_pct: number;
|
||||
by_role: RoleWasteRow[];
|
||||
respawn_strikes: RespawnStrikeRow[];
|
||||
/** Counting scope — non-Anthropic transcripts don't populate output tokens. */
|
||||
basis?: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -252,9 +252,18 @@ def _pop_null_clears(updates: dict[str, Any]) -> dict[str, None]:
|
||||
|
||||
|
||||
def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
|
||||
"""Set *null_clears* fields to None on the ORM task object."""
|
||||
"""Set *null_clears* fields to None on the ORM task object.
|
||||
|
||||
Unassigning implies releasing the claim: a cleared assigned_to with a
|
||||
surviving claimed_by/active_claimant_id keeps routing the task to the
|
||||
stale claimant while the next agent's content writes bounce.
|
||||
"""
|
||||
for field in null_clears:
|
||||
setattr(task, field, None)
|
||||
if "assigned_to" in null_clears:
|
||||
task.claimed_by = None
|
||||
task.claimed_at = None
|
||||
task.active_claimant_id = None
|
||||
|
||||
|
||||
def _reassert_batch_shape(task: Any) -> None:
|
||||
|
||||
@@ -16,6 +16,7 @@ from roboco.api.schemas.v1.flow import (
|
||||
IAmIdleRequest,
|
||||
IWillPlanRequest,
|
||||
ReassignRequest,
|
||||
RequestChangesRequest,
|
||||
ResumeRequest,
|
||||
SubmitUpRequest,
|
||||
TriageRequest,
|
||||
@@ -167,6 +168,20 @@ async def complete(
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/request_changes")
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
|
||||
async def request_changes(
|
||||
request: Request,
|
||||
body: RequestChangesRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
choreographer: _ChoreographerDep,
|
||||
) -> dict:
|
||||
env = await choreographer.request_changes(x_agent_id, body.task_id, body.issues)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/escalate_up")
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
|
||||
@@ -16,6 +16,7 @@ from roboco.api.schemas.v1.flow import (
|
||||
GiveMeWorkRequest,
|
||||
IAmIdleRequest,
|
||||
IWillPlanRequest,
|
||||
RequestChangesRequest,
|
||||
ResumeRequest,
|
||||
SubmitRootRequest,
|
||||
TriageRequest,
|
||||
@@ -174,6 +175,20 @@ async def complete(
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/request_changes")
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
|
||||
async def request_changes(
|
||||
request: Request,
|
||||
body: RequestChangesRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
choreographer: _ChoreographerDep,
|
||||
) -> dict:
|
||||
env = await choreographer.request_changes(x_agent_id, body.task_id, body.issues)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/escalate_up")
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
|
||||
@@ -222,6 +222,11 @@ class CompleteRequest(BaseModel):
|
||||
notes: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RequestChangesRequest(BaseModel):
|
||||
task_id: UUID
|
||||
issues: StrList = Field(..., min_length=1)
|
||||
|
||||
|
||||
class EscalateUpRequest(BaseModel):
|
||||
task_id: UUID
|
||||
reason: str = Field(..., min_length=1)
|
||||
|
||||
@@ -212,6 +212,15 @@ class Settings(BaseSettings):
|
||||
"of crash-retrying into the overload. Off => crash-retry behavior."
|
||||
),
|
||||
)
|
||||
notification_spawn_cooldown_seconds: int = Field(
|
||||
default=600,
|
||||
description=(
|
||||
"Cross-tick cooldown for notification-triggered spawns (escalation/"
|
||||
"approval/audit/a2a): one spawn per (agent, notification) per window. "
|
||||
"The notification stays pending, so the next window retries if still "
|
||||
"unacknowledged. 0 disables the damper (legacy every-tick respawn)."
|
||||
),
|
||||
)
|
||||
spawn_preflight_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
|
||||
@@ -324,6 +324,15 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = (
|
||||
"complete",
|
||||
frozenset({Role.CELL_PM, Role.MAIN_PM}),
|
||||
),
|
||||
# PM merge-level reject: a PM that catches an AC/scope violation at merge
|
||||
# review sends the work back with concrete issues — previously its only
|
||||
# verbs here were complete/escalate, so it looped block→escalate instead.
|
||||
StatusTransition(
|
||||
Status.AWAITING_PM_REVIEW,
|
||||
Status.NEEDS_REVISION,
|
||||
"request_changes",
|
||||
frozenset({Role.CELL_PM, Role.MAIN_PM}),
|
||||
),
|
||||
StatusTransition(
|
||||
Status.AWAITING_PM_REVIEW,
|
||||
Status.AWAITING_CEO_APPROVAL,
|
||||
@@ -609,6 +618,16 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
self_review_block=False,
|
||||
needs_team_match=True,
|
||||
),
|
||||
"request_changes": ActionSpec(
|
||||
name="request_changes",
|
||||
allowed_roles=_PM_ROLES,
|
||||
source_statuses=frozenset({Status.AWAITING_PM_REVIEW}),
|
||||
target_status=Status.NEEDS_REVISION,
|
||||
allowed_task_types=None,
|
||||
preconditions=(),
|
||||
self_review_block=False,
|
||||
needs_team_match=True,
|
||||
),
|
||||
"submit_pm_review": ActionSpec(
|
||||
name="submit_pm_review",
|
||||
allowed_roles=frozenset(_PM_ROLES | _QA_ROLES | _DOC_ROLES | _DEV_ROLES),
|
||||
@@ -1436,6 +1455,21 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
|
||||
side_effects=(),
|
||||
next_hint=_next_hint_pm_complete,
|
||||
),
|
||||
"request_changes": IntentSpec(
|
||||
name="request_changes",
|
||||
allowed_roles=_PM_ROLES,
|
||||
description=(
|
||||
"Reject the merge review with concrete issues. Transitions"
|
||||
" awaiting_pm_review -> needs_revision, routed back like a QA fail"
|
||||
" (original developer for a leaf, revision PM for an assembled"
|
||||
" task). Use this for an AC/scope violation caught at merge review"
|
||||
" — never i_am_blocked/escalate, which have no revision routing."
|
||||
),
|
||||
composes=("request_changes",),
|
||||
extra_preconditions=(),
|
||||
side_effects=(),
|
||||
next_hint=_next_hint_idle,
|
||||
),
|
||||
"escalate_up": IntentSpec(
|
||||
name="escalate_up",
|
||||
allowed_roles=_PM_ROLES,
|
||||
@@ -1561,6 +1595,30 @@ def can_claim(role: Role, task: Any) -> Decision:
|
||||
return can_invoke_action(role, "claim", task)
|
||||
|
||||
|
||||
def _invalid_source_remediate(
|
||||
status: Status, action: str, spec_action: ActionSpec
|
||||
) -> str:
|
||||
"""Directed recovery hint for a wrong-source-status rejection.
|
||||
|
||||
Doc-stage bail special case: awaiting_documentation has exactly one exit
|
||||
— i_documented. A documenter on a revision pass whose docs already exist
|
||||
must re-affirm them, not bail; the generic hint fed the live 26-respawn
|
||||
fe-doc loop (2026-07-02).
|
||||
"""
|
||||
if status is Status.AWAITING_DOCUMENTATION and action == "block":
|
||||
return (
|
||||
"awaiting_documentation has one exit: i_documented. If the "
|
||||
"docs for this task already exist and are accurate (a "
|
||||
"revision pass), call i_documented(files=[...], "
|
||||
"notes='verified existing docs are complete and accurate') "
|
||||
"to re-affirm them — do NOT retry i_am_blocked/unclaim."
|
||||
)
|
||||
return (
|
||||
f"call give_me_work() to find a task in"
|
||||
f" {sorted(s.value for s in spec_action.source_statuses)}"
|
||||
)
|
||||
|
||||
|
||||
def _check_role_status_type(
|
||||
role: Role, action: str, spec_action: ActionSpec, task: Any
|
||||
) -> Decision | None:
|
||||
@@ -1582,10 +1640,7 @@ def _check_role_status_type(
|
||||
f"task is in '{status.value}', '{action}' requires:"
|
||||
f" {sorted(s.value for s in spec_action.source_statuses)}"
|
||||
),
|
||||
remediate=(
|
||||
f"call give_me_work() to find a task in"
|
||||
f" {sorted(s.value for s in spec_action.source_statuses)}"
|
||||
),
|
||||
remediate=_invalid_source_remediate(status, action, spec_action),
|
||||
)
|
||||
if (
|
||||
spec_action.allowed_task_types is not None
|
||||
|
||||
@@ -34,6 +34,11 @@ class DraftSurface:
|
||||
adds_migration: bool
|
||||
touches_shared: bool
|
||||
project_id: str | None = None
|
||||
# Explicitly declared prerequisites (batch indices) — the CEO's / PM's
|
||||
# "Depends on" list. Authoritative: analyze() unions these with the
|
||||
# derived collision edges, so a declared ordering can never be dropped
|
||||
# just because the surfaces happen not to overlap.
|
||||
declared_depends_on: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -37,6 +37,9 @@ class FieldRequirement:
|
||||
rule: FieldRule
|
||||
value: int | None = None
|
||||
hint: str = ""
|
||||
# Conditional requirement: enforced only when the payload's `when[0]`
|
||||
# field normalizes (enum .value, lowercase) to `when[1]`. None = always.
|
||||
when: tuple[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -140,6 +143,32 @@ TASK_AT_CREATE: CompletenessSpec = CompletenessSpec(
|
||||
)
|
||||
|
||||
|
||||
_HINT_INTENDS_TO_TOUCH = (
|
||||
"non-empty list[str] of path globs this code subtask will modify (e.g. "
|
||||
"['frontend/src/components/behavioral-*.tsx']). The sibling collision "
|
||||
"analyzer turns overlapping surfaces into real dependency edges — a code "
|
||||
"subtask with NO surface is treated as parallel to every sibling, which "
|
||||
"is how ordered work ends up running out of order on divergent branches."
|
||||
)
|
||||
|
||||
|
||||
# Delegation adds the collision surface requirement for CODE subtasks: the
|
||||
# sibling collision DAG can only order what is declared. REST/manual creation
|
||||
# (TASK_AT_CREATE) is unchanged — roots have no siblings at creation.
|
||||
TASK_AT_DELEGATE: CompletenessSpec = CompletenessSpec(
|
||||
name="task_at_delegate",
|
||||
requires=(
|
||||
*TASK_AT_CREATE.requires,
|
||||
FieldRequirement(
|
||||
"intends_to_touch",
|
||||
FieldRule.NON_EMPTY_LIST,
|
||||
hint=_HINT_INTENDS_TO_TOUCH,
|
||||
when=("task_type", "code"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _check_explicitly_declared(value: Any) -> tuple[bool, str | None]:
|
||||
if value is None:
|
||||
return False, "field is None / missing"
|
||||
@@ -209,6 +238,11 @@ def check(spec: CompletenessSpec, task: Any) -> CompletenessResult:
|
||||
field_hints: dict[str, str] = {}
|
||||
|
||||
for req in spec.requires:
|
||||
if req.when is not None:
|
||||
gate_raw = getattr(task, req.when[0], None)
|
||||
gate_val = str(getattr(gate_raw, "value", gate_raw)).strip().lower()
|
||||
if gate_val != req.when[1]:
|
||||
continue
|
||||
value = getattr(task, req.field, None)
|
||||
|
||||
# Field-level rule check.
|
||||
|
||||
@@ -358,6 +358,9 @@ VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = {
|
||||
Requirement.NOTES_MIN_CHARS,
|
||||
}
|
||||
),
|
||||
# PM merge-review reject — a decision like complete; issues are enforced
|
||||
# verb-side (non-empty + soup check), so no notes requirement here.
|
||||
"request_changes": frozenset({Requirement.JOURNAL_DECISION}),
|
||||
# PM unblock — was inline at _impl.py:2192-2200; now declared.
|
||||
"unblock": frozenset({Requirement.JOURNAL_DECISION}),
|
||||
# PM escalate up — was inline.
|
||||
|
||||
@@ -736,6 +736,15 @@ def complete(task_id: str, notes: str) -> dict[str, Any]:
|
||||
return _post(_role_path("complete"), {"task_id": task_id, "notes": notes})
|
||||
|
||||
|
||||
def request_changes(task_id: str, issues: StrList) -> dict[str, Any]:
|
||||
"""PM: reject the merge review with concrete issues → needs_revision.
|
||||
|
||||
Use for an AC/scope violation caught at awaiting_pm_review — never
|
||||
i_am_blocked/escalate, which have no revision routing.
|
||||
"""
|
||||
return _post(_role_path("request_changes"), {"task_id": task_id, "issues": issues})
|
||||
|
||||
|
||||
def escalate_up(task_id: str, reason: str) -> dict[str, Any]:
|
||||
"""PM/Doc/Dev: escalate to your role's escalation target."""
|
||||
return _post(_role_path("escalate_up"), {"task_id": task_id, "reason": reason})
|
||||
@@ -911,6 +920,7 @@ _TOOLS: dict[str, Any] = {
|
||||
"triage_all": triage_all,
|
||||
"unblock": unblock,
|
||||
"complete": complete,
|
||||
"request_changes": request_changes,
|
||||
"escalate_up": escalate_up,
|
||||
"i_will_plan": i_will_plan,
|
||||
"delegate": delegate,
|
||||
|
||||
@@ -30,6 +30,12 @@ _TIMEOUT = 15
|
||||
|
||||
mcp = FastMCP("roboco-git-readonly")
|
||||
|
||||
# Char cap for diff text returned into the agent's context (~5K tokens). Kept
|
||||
# local (not imported from the gateway) so this MCP stays dependency-light in
|
||||
# the agent container. The HTTP route itself stays uncapped — the panel's diff
|
||||
# viewer reads it whole.
|
||||
_DIFF_CAP_CHARS = 20_000
|
||||
|
||||
|
||||
def _get(path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""GET against the orchestrator with the agent's identity headers."""
|
||||
@@ -42,6 +48,20 @@ def _get(path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _cap_diff(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Truncate an oversized diff for context embedding; annotate the cut."""
|
||||
diff = result.get("diff")
|
||||
if isinstance(diff, str) and len(diff) > _DIFF_CAP_CHARS:
|
||||
omitted = len(diff) - _DIFF_CAP_CHARS
|
||||
result["diff"] = (
|
||||
diff[:_DIFF_CAP_CHARS]
|
||||
+ f"\n… [diff truncated: {omitted} chars omitted — scope with"
|
||||
" file_path to read a single file's diff in full]"
|
||||
)
|
||||
result["diff_truncated"] = True
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def roboco_git_status(project_slug: str) -> dict[str, Any]:
|
||||
"""Read-only: current git status of your workspace.
|
||||
@@ -96,7 +116,7 @@ def roboco_git_diff(
|
||||
params: dict[str, Any] = {"project_slug": project_slug, "staged": staged}
|
||||
if file_path is not None:
|
||||
params["file_path"] = file_path
|
||||
return _get("/api/git/diff", params)
|
||||
return _cap_diff(_get("/api/git/diff", params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
@@ -173,7 +173,10 @@ async def propose_batch(drafts: list[dict[str, Any]], title: str = "") -> str:
|
||||
across the scoped repos. ``drafts`` is a list where each item has the same
|
||||
fields as a single draft PLUS a collision surface — ``intends_to_touch[]``
|
||||
(files/dirs it will modify), ``adds_migration`` (adds a DB migration?),
|
||||
``touches_shared`` (edits a widely shared component?). The system sequences
|
||||
``touches_shared`` (edits a widely shared component?), and ``depends_on[]``
|
||||
(batch indices of drafts this one must wait for — copy the CEO's declared
|
||||
sequencing VERBATIM; declared edges are authoritative and never inferred
|
||||
away). The system sequences
|
||||
them into conflict-free waves; over-declaring a surface is safer than
|
||||
under-declaring. ``title`` names the MegaTask. Each draft targets its repos
|
||||
via the per-cell ``project_id`` on its ``the_work[]`` entries (a multi-cell
|
||||
|
||||
@@ -21,6 +21,7 @@ Optimal Brain Tools:
|
||||
- roboco_validate_action: Validate action against standards
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import status as http_status
|
||||
@@ -63,6 +64,27 @@ def normalize_index_types(index_types: list[str] | None) -> list[str] | None:
|
||||
return [_INDEX_TYPE_ALIASES.get(t, t) for t in index_types]
|
||||
|
||||
|
||||
# Per-item free-text cap for search/mentor results embedded in agent context
|
||||
# (observed p90 payloads of ~37KB; results are for finding, not full reading).
|
||||
_RESULT_CONTENT_CAP = 800
|
||||
|
||||
|
||||
def _cap_result_content(
|
||||
items: list[Any], *, cap: int = _RESULT_CONTENT_CAP, limit: int | None = None
|
||||
) -> list[Any]:
|
||||
"""Cap each dict item's `content` text (and optionally the item count)."""
|
||||
capped = items[:limit] if limit is not None else list(items)
|
||||
out: list[Any] = []
|
||||
for item in capped:
|
||||
trimmed = item
|
||||
if isinstance(item, dict):
|
||||
content = item.get("content")
|
||||
if isinstance(content, str) and len(content) > cap:
|
||||
trimmed = {**item, "content": content[:cap] + "…"}
|
||||
out.append(trimmed)
|
||||
return out
|
||||
|
||||
|
||||
def _register_search_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
"""Register search tools available to all agents."""
|
||||
|
||||
@@ -118,7 +140,7 @@ def _register_search_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"total": total,
|
||||
"results": result.get("results", []),
|
||||
"results": _cap_result_content(result.get("results", [])),
|
||||
}
|
||||
if total == 0:
|
||||
response["hint"] = (
|
||||
@@ -178,7 +200,7 @@ def _register_search_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"answer": answer,
|
||||
"citations": result.get("citations", []),
|
||||
"citations": _cap_result_content(result.get("citations", []), limit=8),
|
||||
"context_used": context_used,
|
||||
}
|
||||
# Guide to mentor for better results
|
||||
@@ -426,7 +448,7 @@ def _register_mentor_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
return {
|
||||
"status": "success",
|
||||
"answer": result.get("answer", ""),
|
||||
"sources": result.get("sources", []),
|
||||
"sources": _cap_result_content(result.get("sources", []), limit=8),
|
||||
"conversation_id": result.get("conversation_id", ""),
|
||||
"suggested_followups": result.get("suggested_followups", []),
|
||||
}
|
||||
@@ -471,7 +493,7 @@ def _register_error_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
"status": "success",
|
||||
"error_message": error_message,
|
||||
"solutions_found": solutions_found,
|
||||
"results": result.get("results", []),
|
||||
"results": _cap_result_content(result.get("results", [])),
|
||||
}
|
||||
if solutions_found == 0:
|
||||
response["hint"] = (
|
||||
@@ -864,7 +886,7 @@ def _register_learning_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"total": total,
|
||||
"results": result.get("results", []),
|
||||
"results": _cap_result_content(result.get("results", [])),
|
||||
}
|
||||
if total == 0:
|
||||
response["hint"] = (
|
||||
@@ -1065,26 +1087,59 @@ def _register_proactive_tools(mcp: FastMCP, client: ApiClient) -> None:
|
||||
}
|
||||
|
||||
|
||||
# Role-scoped tool groups, mirroring the manifest scoping flow/do already do.
|
||||
# Every registered schema rides in each turn's context, so a role only carries
|
||||
# the groups its duties use. A group absent from this map registers for every
|
||||
# role; an unknown/unset role registers everything (fail-open to the previous
|
||||
# behaviour) — except index management, which is destructive operator tooling
|
||||
# (clear/reindex) and registers only under ROBOCO_ALLOW_FULL_TOOLSET.
|
||||
_GROUP_ROLES: dict[str, frozenset[str]] = {
|
||||
"error": frozenset({"developer", "qa"}),
|
||||
"standards": frozenset({"developer", "qa", "documenter", "pr_reviewer"}),
|
||||
"decision": frozenset(
|
||||
{"cell_pm", "main_pm", "product_owner", "head_marketing", "auditor"}
|
||||
),
|
||||
"indexing": frozenset({"documenter"}),
|
||||
}
|
||||
|
||||
|
||||
def _role_wants(group: str, role: str) -> bool:
|
||||
"""True when `role` should carry `group`'s tool schemas."""
|
||||
allowed = _GROUP_ROLES.get(group)
|
||||
if allowed is None:
|
||||
return True
|
||||
if not role:
|
||||
return True
|
||||
return role in allowed
|
||||
|
||||
|
||||
def create_optimal_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""Create an Optimal MCP server for a specific agent."""
|
||||
"""Create an Optimal MCP server for a specific agent, scoped to its role."""
|
||||
mcp = FastMCP(f"roboco-optimal-{agent_id}", json_response=True)
|
||||
client = ApiClient(agent_id)
|
||||
role = os.environ.get("ROBOCO_AGENT_ROLE", "")
|
||||
full_toolset = bool(os.environ.get("ROBOCO_ALLOW_FULL_TOOLSET"))
|
||||
|
||||
# Register core tool groups
|
||||
# Universal groups — every role reasons with search/mentor/learnings.
|
||||
_register_search_tools(mcp, client)
|
||||
_register_indexing_tools(mcp, client)
|
||||
_register_utility_tools(mcp, client)
|
||||
|
||||
# Register Optimal Brain tools
|
||||
_register_mentor_tools(mcp, client)
|
||||
_register_error_tools(mcp, client)
|
||||
_register_decision_tools(mcp, client)
|
||||
_register_standards_tools(mcp, client)
|
||||
_register_learning_tools(mcp, client)
|
||||
_register_proactive_tools(mcp, client)
|
||||
|
||||
# Register index management tools
|
||||
_register_index_management_tools(mcp, client)
|
||||
# Duty-scoped groups.
|
||||
if full_toolset or _role_wants("indexing", role):
|
||||
_register_indexing_tools(mcp, client)
|
||||
if full_toolset or _role_wants("error", role):
|
||||
_register_error_tools(mcp, client)
|
||||
if full_toolset or _role_wants("decision", role):
|
||||
_register_decision_tools(mcp, client)
|
||||
if full_toolset or _role_wants("standards", role):
|
||||
_register_standards_tools(mcp, client)
|
||||
|
||||
# Destructive index management (clear/reindex) — dev/test escape hatch only.
|
||||
if full_toolset:
|
||||
_register_index_management_tools(mcp, client)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -955,6 +956,8 @@ class AgentOrchestrator:
|
||||
# is in a loop — without this gate the orchestrator re-spawns every
|
||||
# tick forever (seen in production on 2026-04-22).
|
||||
self._pm_respawn_tracker: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
# Dispatcher heartbeat throttle (see _emit_dispatcher_heartbeat).
|
||||
self._last_dispatch_heartbeat: datetime | None = None
|
||||
# Serializes the fire-and-forget respawn-tracker upserts so same-key
|
||||
# persists COMMIT in schedule (logical) order — not whatever order their
|
||||
# DB transactions resolve in. A respawn loop fires count 1->2->3->4 in
|
||||
@@ -973,6 +976,15 @@ class AgentOrchestrator:
|
||||
# delegate, or complete, so a respawn cannot advance the task and would
|
||||
# just loop. Tracks (agent_slug, task_id) already dispatched.
|
||||
self._board_dispatched: set[tuple[str, str]] = set()
|
||||
# Cross-tick damper for notification-triggered spawns (escalation /
|
||||
# approval / audit / a2a). Those dispatchers carry no task_id, so the
|
||||
# readiness gate and the PM respawn breaker never see them — without
|
||||
# this, an unacknowledged notification respawns its recipient every
|
||||
# dispatch tick, unbounded. One spawn per (agent, notification) per
|
||||
# cooldown window; the notification stays pending, so the next window
|
||||
# retries if it is still unacked. In-memory by design (a restart just
|
||||
# allows one immediate retry — a tick damper, not durable state).
|
||||
self._notification_spawn_at: dict[tuple[str, str], float] = {}
|
||||
# Cluster C5: a board review is a two-reviewer gate — BOTH the Product
|
||||
# Owner and the Head of Marketing must review a board/coordination task
|
||||
# before it is handed to the CEO for Approve & Start. Once both have
|
||||
@@ -1612,6 +1624,13 @@ class AgentOrchestrator:
|
||||
"allow": base_allow + role_config["allow"],
|
||||
"deny": base_deny + role_config["deny"],
|
||||
},
|
||||
# Explicit Bash-output cap: a gate/test dump enters the session
|
||||
# context once and is re-read at cache-read price on every later
|
||||
# turn. 20K chars (~5K tokens) keeps failures diagnosable without
|
||||
# relying on the CLI's default ceiling.
|
||||
"env": {
|
||||
"BASH_MAX_OUTPUT_LENGTH": "20000",
|
||||
},
|
||||
"hooks": {
|
||||
# Start SDK server on session start (for A2A communication)
|
||||
"SessionStart": [
|
||||
@@ -3676,6 +3695,38 @@ class AgentOrchestrator:
|
||||
"""True if a prior dispatcher already handled this task this tick."""
|
||||
return bool(task_id and task_id in self._tick_handled_tasks)
|
||||
|
||||
_NOTIFICATION_COOLDOWN_PRUNE_AT = 512
|
||||
|
||||
def _notification_spawn_cooled(
|
||||
self, agent_slug: str, notification_id: str | None
|
||||
) -> bool:
|
||||
"""True when this (agent, notification) spawned within the cooldown.
|
||||
|
||||
Returns False — and stamps the pair — when a spawn is allowed. A
|
||||
notification with no id is never damped (fail-open: better one extra
|
||||
spawn than a silently dropped escalation).
|
||||
"""
|
||||
if not notification_id:
|
||||
return False
|
||||
# Lazy init keeps the damper working on partially-constructed
|
||||
# instances (tests build the orchestrator via __new__).
|
||||
store: dict[tuple[str, str], float] = self.__dict__.setdefault(
|
||||
"_notification_spawn_at", {}
|
||||
)
|
||||
key = (agent_slug, str(notification_id))
|
||||
now = time.monotonic()
|
||||
cooldown = settings.notification_spawn_cooldown_seconds
|
||||
last = store.get(key)
|
||||
if last is not None and (now - last) < cooldown:
|
||||
return True
|
||||
store[key] = now
|
||||
if len(store) > self._NOTIFICATION_COOLDOWN_PRUNE_AT:
|
||||
cutoff = now - cooldown
|
||||
self._notification_spawn_at = {
|
||||
k: v for k, v in store.items() if v >= cutoff
|
||||
}
|
||||
return False
|
||||
|
||||
def _is_parallel_phase_claim(
|
||||
self, task: dict[str, Any], dev_uuid: str | None
|
||||
) -> bool:
|
||||
@@ -3709,6 +3760,10 @@ class AgentOrchestrator:
|
||||
if not dev_uuid or task.get("pr_created") or task.get("pr_number"):
|
||||
return
|
||||
dev_slug = self._resolve_agent_slug(dev_uuid)
|
||||
if dev_slug and await self._pm_respawn_should_gate(dev_slug, task):
|
||||
# Respawn circuit breaker — the PR-half respawn loops exactly like
|
||||
# the doc half when the dev can never finish (progress resets it).
|
||||
return
|
||||
if not dev_slug or self._is_agent_active(dev_slug):
|
||||
return
|
||||
await self.spawn_agent(
|
||||
@@ -9035,6 +9090,31 @@ Start now: evidence(task_id="{task_id}")
|
||||
"""
|
||||
self._dispatch_wake.set()
|
||||
|
||||
# Heartbeat cadence: one dispatcher.alive audit row per window. 300s keeps
|
||||
# audit_log growth trivial (~288 rows/day) while making a dead loop visible
|
||||
# within minutes (the 2026-07-01 outage was 4h25m of undetectable silence).
|
||||
_DISPATCH_HEARTBEAT_SECONDS = 300
|
||||
|
||||
async def _emit_dispatcher_heartbeat(self) -> None:
|
||||
"""Periodic dispatcher.alive audit row — a dead loop becomes detectable.
|
||||
|
||||
The dispatch loop can die silently (task cancelled, unhandled exit) and
|
||||
its stdout dies with the container; audit_log survives both. Absence of
|
||||
a fresh heartbeat row = loop dead, distinguishable from "no work".
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
last = getattr(self, "_last_dispatch_heartbeat", None)
|
||||
if last is not None and (now - last).total_seconds() < (
|
||||
self._DISPATCH_HEARTBEAT_SECONDS
|
||||
):
|
||||
return
|
||||
self._last_dispatch_heartbeat = now
|
||||
self._fire_audit(
|
||||
event_type="dispatcher.alive",
|
||||
agent_slug="orchestrator",
|
||||
details={"interval_seconds": self._DISPATCH_HEARTBEAT_SECONDS},
|
||||
)
|
||||
|
||||
async def _dispatcher_loop(self) -> None:
|
||||
"""
|
||||
Main dispatcher loop - periodically checks for work and spawns agents.
|
||||
@@ -9064,6 +9144,7 @@ Start now: evidence(task_id="{task_id}")
|
||||
self._dispatch_wake.clear()
|
||||
await self._refresh_grok_auth()
|
||||
await self._dispatch_all_work()
|
||||
await self._emit_dispatcher_heartbeat()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -9952,6 +10033,9 @@ Start now: evidence(task_id="{task_id}")
|
||||
key = (board_slug, task_id)
|
||||
if key in self._board_dispatched:
|
||||
return
|
||||
# Respawn circuit breaker — parity with every other task-keyed path.
|
||||
if await self._pm_respawn_should_gate(board_slug, task):
|
||||
return
|
||||
self._board_dispatched.add(key)
|
||||
logger.info(
|
||||
"Spawning board agent for review",
|
||||
@@ -10214,6 +10298,10 @@ Start now: evidence(task_id="{task_id}")
|
||||
continue
|
||||
if get_agent_role(agent_slug) not in ("cell_pm", "main_pm"):
|
||||
continue
|
||||
# Respawn circuit breaker — a revision the PM can never land must
|
||||
# stop respawning the coordinator (progress resets the strikes).
|
||||
if await self._pm_respawn_should_gate(agent_slug, task):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id=agent_slug,
|
||||
task_id=task["id"],
|
||||
@@ -10611,6 +10699,10 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
# just not dispatched this tick.
|
||||
if await self._blocked_by_earlier_lane_sibling(task):
|
||||
return
|
||||
# Respawn circuit breaker — a dev leaf that respawns without the task
|
||||
# advancing (wedged workspace, unclaimable state) stops after strikes.
|
||||
if await self._pm_respawn_should_gate(agent_slug, task):
|
||||
return
|
||||
validation_issue = await self._validate_task_for_spawn(client, task, agent_slug)
|
||||
if validation_issue:
|
||||
logger.warning(
|
||||
@@ -10751,6 +10843,10 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
return False
|
||||
if self._is_agent_active(assigned_slug):
|
||||
return True
|
||||
# Respawn circuit breaker — same progress-aware gate as every other
|
||||
# task-keyed spawn path; notifies the CEO once it trips.
|
||||
if await self._pm_respawn_should_gate(assigned_slug, task):
|
||||
return True
|
||||
await self.spawn_agent(
|
||||
agent_id=assigned_slug,
|
||||
task_id=task["id"],
|
||||
@@ -10788,6 +10884,10 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
# QA already running, they'll pick up on scan
|
||||
continue
|
||||
|
||||
# Respawn circuit breaker — before claiming, so a wedged QA task
|
||||
# doesn't churn claims while the gate is open.
|
||||
if await self._pm_respawn_should_gate(agent_id, task):
|
||||
continue
|
||||
# Claim the task for QA agent BEFORE spawning
|
||||
if not await self._claim_task_for_agent(client, task["id"], agent_id):
|
||||
logger.warning(
|
||||
@@ -10827,6 +10927,9 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
continue
|
||||
if task.get("assigned_to"):
|
||||
continue
|
||||
# Respawn circuit breaker — parity with the in-path gate dispatcher.
|
||||
if await self._pm_respawn_should_gate(reviewer, task):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id=reviewer,
|
||||
task_id=task["id"],
|
||||
@@ -10857,6 +10960,10 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
reviewer = "pr-reviewer-1"
|
||||
if not reviewer or reviewer in spawned or self._is_agent_active(reviewer):
|
||||
continue
|
||||
# Respawn circuit breaker — a gate task that keeps re-surfacing
|
||||
# without advancing must stop respawning the reviewer.
|
||||
if await self._pm_respawn_should_gate(reviewer, task):
|
||||
continue
|
||||
spawned.add(reviewer)
|
||||
await self.spawn_agent(
|
||||
agent_id=reviewer,
|
||||
@@ -10907,6 +11014,11 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
if not agent_id or self._is_agent_active(agent_id):
|
||||
return
|
||||
|
||||
# Respawn circuit breaker — before claiming, so a wedged doc task
|
||||
# doesn't churn claims while the gate is open.
|
||||
if await self._pm_respawn_should_gate(agent_id, task):
|
||||
return
|
||||
|
||||
if not await self._claim_task_for_agent(client, task["id"], agent_id):
|
||||
logger.warning(
|
||||
"Failed to claim awaiting_documentation task for doc",
|
||||
@@ -10971,6 +11083,10 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
if self._is_agent_active(assigned_slug):
|
||||
return True
|
||||
if assigned_slug and "doc" in assigned_slug:
|
||||
# Respawn circuit breaker — the fe-doc 26-respawn loop ran on this
|
||||
# exact path unguarded; the gate notifies the CEO once it trips.
|
||||
if await self._pm_respawn_should_gate(assigned_slug, task):
|
||||
return True
|
||||
await self.spawn_agent(
|
||||
agent_id=assigned_slug,
|
||||
task_id=task["id"],
|
||||
@@ -11416,6 +11532,8 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
if self._is_agent_active(agent_slug):
|
||||
continue
|
||||
|
||||
if self._notification_spawn_cooled(agent_slug, notif.get("id")):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id=agent_slug,
|
||||
initial_prompt=self._build_escalation_prompt(notif),
|
||||
@@ -11444,6 +11562,8 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
if self._is_agent_active(agent_slug):
|
||||
continue
|
||||
|
||||
if self._notification_spawn_cooled(agent_slug, notif.get("id")):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id=agent_slug,
|
||||
initial_prompt=self._build_approval_prompt(notif),
|
||||
@@ -11466,6 +11586,8 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
# Resolve UUIDs to slugs and check if auditor is a target
|
||||
target_slugs = [self._resolve_agent_slug(str(t)) for t in targets]
|
||||
if "auditor" in target_slugs and not self._is_agent_active("auditor"):
|
||||
if self._notification_spawn_cooled("auditor", alert.get("id")):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id="auditor",
|
||||
initial_prompt=self._build_audit_prompt(alert),
|
||||
@@ -11744,6 +11866,8 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
continue
|
||||
|
||||
# Agent is offline - spawn them with A2A context
|
||||
if self._notification_spawn_cooled(agent_slug, notif.get("id")):
|
||||
continue
|
||||
await self.spawn_agent(
|
||||
agent_id=agent_slug,
|
||||
initial_prompt=self._build_a2a_prompt(notif),
|
||||
|
||||
@@ -782,7 +782,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._claim_verb_hint(role, t),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
agent_id, t.id, task=t, full=True
|
||||
),
|
||||
).with_introspection(task=t, role=role)
|
||||
assigned = await self._drop_dependency_held(
|
||||
await self._deps.task.list_assigned_for_agent(agent_id)
|
||||
@@ -793,7 +795,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._claim_verb_hint(role, t),
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
agent_id, t.id, task=t, full=True
|
||||
),
|
||||
).with_introspection(task=t, role=role)
|
||||
paused = await self._deps.task.list_paused_for_agent(agent_id)
|
||||
if paused:
|
||||
@@ -802,7 +806,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"call resume(task_id='{t.id}') to continue paused work",
|
||||
context_briefing=await self._briefing_for(agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
agent_id, t.id, task=t, full=True
|
||||
),
|
||||
).with_introspection(task=t, role=role)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -818,9 +824,19 @@ class Choreographer:
|
||||
*,
|
||||
task: Any | None = None,
|
||||
include_ac_coverage: bool = False,
|
||||
full: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble context_briefing for agent_id, optionally scoped to task_id.
|
||||
|
||||
``full`` gates the heavy, verb-invariant sections (company_goals,
|
||||
recent_team_activity, blockers_in_my_lane, task_handoff,
|
||||
institutional_memory). Only context-acquisition verbs (give_me_work /
|
||||
claim / plan / resume / triage) pass ``full=True``; every other verb —
|
||||
progress, transition and rejection envelopes included — gets the slim
|
||||
signals-only briefing (unread a2a/mentions/notifications + metadata
|
||||
gaps). The agent already holds the heavy context from its claim, and
|
||||
every extra copy is re-read at cache-read price on all later turns.
|
||||
|
||||
``task`` is the already-loaded row (every claim / give_me_work / done
|
||||
path holds it). The prior-work handoff is built only when it is passed —
|
||||
no extra fetch — so task-scoped error paths that carry only an id simply
|
||||
@@ -831,15 +847,12 @@ class Choreographer:
|
||||
parent criterion, what is still unclaimed and can pass
|
||||
``covers_parent_criteria`` on delegate. Off everywhere else so a leaf's
|
||||
own criteria never surface as bogus "unclaimed" noise to a developer.
|
||||
It is functional (not bulk), so it stays independent of ``full``.
|
||||
"""
|
||||
repo = self._deps.evidence_repo
|
||||
task_handoff: dict[str, Any] | None = None
|
||||
if task_id is not None and task is not None:
|
||||
# Push the prior-work digest so a freshly spawned / respawned agent
|
||||
# resumes from the previous worker's PR + commits + journal rather
|
||||
# than re-exploring the codebase cold on every lifecycle hand-off.
|
||||
handoff_highlights = await repo.journal_highlights_for_task(task_id)
|
||||
task_handoff = build_task_handoff(task, handoff_highlights)
|
||||
heavy = (
|
||||
await self._heavy_briefing_sections(agent_id, task_id, task) if full else {}
|
||||
)
|
||||
inputs = BriefingInputs(
|
||||
unread_a2a=await repo.list_unread_a2a(agent_id),
|
||||
unread_mentions=await repo.list_unread_mentions(agent_id),
|
||||
@@ -847,13 +860,13 @@ class Choreographer:
|
||||
task_metadata_gaps=(
|
||||
await repo.task_metadata_gaps(task_id) if task_id else []
|
||||
),
|
||||
recent_team_activity=await repo.recent_team_activity(agent_id),
|
||||
blockers_in_my_lane=await repo.blockers_in_lane(agent_id),
|
||||
task_handoff=task_handoff,
|
||||
company_goals=await repo.company_goals(),
|
||||
recent_team_activity=heavy.get("recent_team_activity", []),
|
||||
blockers_in_my_lane=heavy.get("blockers_in_my_lane", []),
|
||||
task_handoff=heavy.get("task_handoff"),
|
||||
company_goals=heavy.get("company_goals"),
|
||||
)
|
||||
briefing = build_context_briefing(inputs)
|
||||
memory = await self._institutional_memory(agent_id, task)
|
||||
memory = heavy.get("institutional_memory", [])
|
||||
if memory:
|
||||
# "What the company already knows about work like this" — distilled
|
||||
# lessons + approved playbooks, pushed so the agent never has to ask.
|
||||
@@ -874,6 +887,31 @@ class Choreographer:
|
||||
}
|
||||
return briefing
|
||||
|
||||
async def _heavy_briefing_sections(
|
||||
self, agent_id: UUID, task_id: UUID | None, task: Any | None
|
||||
) -> dict[str, Any]:
|
||||
"""The full-briefing-only sections (see ``_briefing_for``'s docstring).
|
||||
|
||||
The prior-work handoff is built only when the loaded ``task`` row is
|
||||
passed — no extra fetch — so task-scoped error paths that carry only an
|
||||
id simply omit the digest rather than pay a redundant read for it.
|
||||
"""
|
||||
repo = self._deps.evidence_repo
|
||||
task_handoff: dict[str, Any] | None = None
|
||||
if task_id is not None and task is not None:
|
||||
# Push the prior-work digest so a freshly spawned / respawned agent
|
||||
# resumes from the previous worker's PR + commits + journal rather
|
||||
# than re-exploring the codebase cold on every lifecycle hand-off.
|
||||
handoff_highlights = await repo.journal_highlights_for_task(task_id)
|
||||
task_handoff = build_task_handoff(task, handoff_highlights)
|
||||
return {
|
||||
"recent_team_activity": await repo.recent_team_activity(agent_id),
|
||||
"blockers_in_my_lane": await repo.blockers_in_lane(agent_id),
|
||||
"task_handoff": task_handoff,
|
||||
"company_goals": await repo.company_goals(),
|
||||
"institutional_memory": await self._institutional_memory(agent_id, task),
|
||||
}
|
||||
|
||||
async def _institutional_memory(
|
||||
self, agent_id: UUID, task: Any | None
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -1370,7 +1408,7 @@ class Choreographer:
|
||||
)
|
||||
agent = await self.task.agent_for(agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "developer"
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t, full=True)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
@@ -2161,6 +2199,119 @@ class Choreographer:
|
||||
)
|
||||
return None
|
||||
|
||||
async def _assembled_submit_guards(
|
||||
self, t: Any, task_id: UUID, verb: str
|
||||
) -> Envelope | None:
|
||||
"""Assembly integrity (#11) then behind-base auto-sync (B2) for the
|
||||
assembled PM submits. Base resolution fails open (a malformed parent
|
||||
ref must not strand the submit — the merge layer keeps its checks)."""
|
||||
guard = await self._assembly_integrity_guard(t, verb=verb)
|
||||
if guard is not None:
|
||||
return guard
|
||||
try:
|
||||
base_branch = await resolve_parent_branch(t, self.task)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"assembled_freshen_base_skip",
|
||||
task_id=str(task_id),
|
||||
error=str(exc),
|
||||
)
|
||||
base_branch = ""
|
||||
return await self._freshen_assembled_branch(
|
||||
t, base_branch=base_branch, verb=verb
|
||||
)
|
||||
|
||||
async def _assembly_integrity_guard(self, t: Any, *, verb: str) -> Envelope | None:
|
||||
"""Refuse the assembled submit when a completed child's work is missing.
|
||||
|
||||
Live incident #11: a completed revert subtask's commit never landed on
|
||||
the assembled cell branch, so the review gate re-flagged the exact
|
||||
violation the revert fixed. Patch-equivalence check (rebase-safe);
|
||||
fail-open on git errors — the review gate remains the backstop.
|
||||
"""
|
||||
if not getattr(t, "branch_name", None):
|
||||
return None
|
||||
try:
|
||||
missing = list(await self.git.unmerged_child_commits(t) or [])
|
||||
except Exception as exc:
|
||||
logger.warning("assembly_integrity_skip", task_id=str(t.id), error=str(exc))
|
||||
return None
|
||||
if not missing:
|
||||
return None
|
||||
listing = "; ".join(
|
||||
f"{m['title']} ({m['task_id']}, {m['unmerged']} commit(s))" for m in missing
|
||||
)
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"{verb} refused: completed subtask work is MISSING from the "
|
||||
f"assembled branch — {listing}"
|
||||
),
|
||||
remediate=(
|
||||
"merge each listed child's branch into the assembled branch "
|
||||
"(their completion recorded a merge that is not reflected on "
|
||||
"origin), then re-submit. Submitting now re-reviews a diff "
|
||||
"that silently drops finished work."
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def _freshen_assembled_branch(
|
||||
self, t: Any, *, base_branch: str, verb: str
|
||||
) -> Envelope | None:
|
||||
"""Behind-base auto-sync for the assembled PM submits (B2).
|
||||
|
||||
Re-submitting a cell/root head whose base moved re-reviews stale work
|
||||
and ping-pongs needs_revision ↔ awaiting_pr_review (live 2026-07-02).
|
||||
Every child is terminal at submit time, so rebasing the assembled
|
||||
branch onto its base is safe; ``sync_task_branch`` pushes only the
|
||||
HEAD branch (master/main are never written). Fail-open on probe/sync
|
||||
errors — the PR/merge layer keeps its own behind checks — but a rebase
|
||||
CONFLICT is a hard reject naming the files, so the PM routes a
|
||||
conflict-resolution revision instead of re-submitting blind.
|
||||
"""
|
||||
if not getattr(t, "branch_name", None) or not base_branch:
|
||||
return None
|
||||
try:
|
||||
behind, _ahead = await self.git.is_behind_base(t, base_branch=base_branch)
|
||||
except Exception as exc:
|
||||
logger.warning("assembled_freshen_skip", task_id=str(t.id), error=str(exc))
|
||||
return None
|
||||
if behind <= 0:
|
||||
return None
|
||||
try:
|
||||
result = await self.git.sync_task_branch(t, base_branch=base_branch)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"assembled_freshen_sync_failed", task_id=str(t.id), error=str(exc)
|
||||
)
|
||||
return None
|
||||
if result.get("status") == "conflicts":
|
||||
files = ", ".join(result.get("files") or []) or "unknown files"
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"{verb} refused: the assembled branch was {behind} "
|
||||
f"commit(s) behind its base '{base_branch}' and the "
|
||||
f"auto-rebase hit conflicts in: {files}"
|
||||
),
|
||||
remediate=(
|
||||
"the base moved under this branch and the conflict needs a "
|
||||
"human-quality merge: delegate a conflict-resolution "
|
||||
"revision for the listed files, then re-submit. Do NOT "
|
||||
"re-submit unchanged — the review gate will fail the same "
|
||||
"stale diff again."
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
logger.info(
|
||||
"assembled_branch_freshened",
|
||||
task_id=str(t.id),
|
||||
verb=verb,
|
||||
base_branch=base_branch,
|
||||
behind=behind,
|
||||
status=result.get("status"),
|
||||
)
|
||||
return None
|
||||
|
||||
async def _behind_base_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
|
||||
"""Refuse i_am_done when the task branch has fallen behind its base.
|
||||
|
||||
@@ -3275,8 +3426,12 @@ class Choreographer:
|
||||
Envelope.invalid_state(
|
||||
message=f"cannot unclaim from status {t.status}",
|
||||
remediate=(
|
||||
"only a task assigned to you in pending / claimed / "
|
||||
"in_progress can be unclaimed"
|
||||
"docs already complete? call i_documented(files=[...], "
|
||||
"notes='verified existing docs') — that is the exit "
|
||||
"from awaiting_documentation"
|
||||
if str(t.status) == "awaiting_documentation"
|
||||
else "only a task assigned to you in pending / claimed"
|
||||
" / in_progress can be unclaimed"
|
||||
),
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str),
|
||||
@@ -3449,7 +3604,7 @@ class Choreographer:
|
||||
atomic chain wrapped in a savepoint.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t)
|
||||
briefing = await self._briefing_for(agent_id, task_id, task=t, full=True)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
@@ -4114,7 +4269,7 @@ class Choreographer:
|
||||
agent = await self.task.agent_for(pm_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "cell_pm"
|
||||
briefing = await self._briefing_for(
|
||||
pm_agent_id, task_id, task=t, include_ac_coverage=True
|
||||
pm_agent_id, task_id, task=t, include_ac_coverage=True, full=True
|
||||
)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
@@ -4941,6 +5096,10 @@ class Choreographer:
|
||||
"nature": inputs.nature,
|
||||
"estimated_complexity": inputs.estimated_complexity,
|
||||
"acceptance_criteria": inputs.acceptance_criteria,
|
||||
# Collision surface — required for code subtasks (TASK_AT_DELEGATE):
|
||||
# the sibling collision DAG can only order what is declared, and a
|
||||
# no-surface code sibling silently runs parallel to everything.
|
||||
"intends_to_touch": inputs.intends_to_touch,
|
||||
}
|
||||
# Auto-fill (spec §5.2.1 (a)) — never overwrites explicit values.
|
||||
# team-from-slug is harmless when the caller already supplied team;
|
||||
@@ -4951,7 +5110,7 @@ class Choreographer:
|
||||
completeness_input = SimpleNamespace(
|
||||
**{k: v for k, v in payload.items() if not k.startswith("__")}
|
||||
)
|
||||
result = tc.check(tc.TASK_AT_CREATE, completeness_input)
|
||||
result = tc.check(tc.TASK_AT_DELEGATE, completeness_input)
|
||||
if result.passed:
|
||||
return None
|
||||
return Envelope.incomplete_input(
|
||||
@@ -5450,6 +5609,9 @@ class Choreographer:
|
||||
# head-sha comparison runs (mirroring submit_root).
|
||||
if guard is None:
|
||||
guard = await self._submit_up_unchanged_pr_guard(t, briefing)
|
||||
if guard is None:
|
||||
# Assembly integrity (#11) + behind-base auto-sync (B2).
|
||||
guard = await self._assembled_submit_guards(t, task_id, "submit_up")
|
||||
if guard is not None:
|
||||
guard.with_introspection(task=t, role=role_str)
|
||||
return await self._emit_rejection(
|
||||
@@ -5640,7 +5802,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._pm_next_hint(str(t.status), t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
assigned = await self.task.list_assigned_for_agent(pm_agent_id)
|
||||
if assigned:
|
||||
@@ -5650,7 +5814,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=self._pm_next_hint(str(t.status), t.id),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -5685,7 +5851,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"investigate the block, then unblock(task_id='{t.id}')",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
awaiting = await self.task.list_awaiting_pm_review_for_team(pm.team)
|
||||
if awaiting:
|
||||
@@ -5694,7 +5862,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"review and complete(task_id='{t.id}')",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -5715,7 +5885,9 @@ class Choreographer:
|
||||
f"escalation/cross-cell help required: investigate, then "
|
||||
f"unblock(task_id='{t.id}') or escalate_up()"
|
||||
),
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
awaiting = await self.task.list_awaiting_main_pm_all()
|
||||
if awaiting:
|
||||
@@ -5724,7 +5896,9 @@ class Choreographer:
|
||||
status=str(t.status),
|
||||
task_id=str(t.id),
|
||||
next=f"complete(task_id='{t.id}') opens master PR + escalates to CEO",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, t.id, task=t),
|
||||
context_briefing=await self._briefing_for(
|
||||
pm_agent_id, t.id, task=t, full=True
|
||||
),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -6378,6 +6552,9 @@ class Choreographer:
|
||||
# relies on the reviewer to re-fail if the diff is still bad.
|
||||
if guard is None:
|
||||
guard = await self._submit_root_unchanged_pr_guard(t, briefing)
|
||||
if guard is None:
|
||||
# Assembly integrity (#11) + behind-base auto-sync (B2).
|
||||
guard = await self._assembled_submit_guards(t, task_id, "submit_root")
|
||||
if guard is not None:
|
||||
guard.with_introspection(task=t, role=role_str)
|
||||
return await self._emit_rejection(
|
||||
@@ -6729,6 +6906,139 @@ class Choreographer:
|
||||
# role_str == "main_pm" — spec._PM_ROLES has only these two members.
|
||||
return await self.main_pm_complete(agent_id, task_id, notes)
|
||||
|
||||
async def request_changes(
|
||||
self, pm_agent_id: UUID, task_id: UUID, issues: list[str]
|
||||
) -> Envelope:
|
||||
"""PM rejects the merge review with concrete issues; → needs_revision.
|
||||
|
||||
The merge-level reject at awaiting_pm_review (the PM's only other
|
||||
verbs there are complete/escalate — an AC violation caught at merge
|
||||
review used to loop block→escalate). Spec gate enforces role + source
|
||||
status; the composed ``request_changes`` atomic routes the revision
|
||||
like a QA fail, then the verb body a2a-delivers the issues to the new
|
||||
owner so the reject reason is never stranded.
|
||||
"""
|
||||
t = await self.task.get(task_id)
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
|
||||
if t is None:
|
||||
return await self._emit_rejection(
|
||||
Envelope.not_found(message=f"task {task_id} not found"),
|
||||
agent_id=pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="request_changes",
|
||||
)
|
||||
agent = await self.task.agent_for(pm_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "cell_pm"
|
||||
if not issues:
|
||||
return await self._emit_rejection(
|
||||
Envelope.invalid_state(
|
||||
message="request_changes requires at least one issue",
|
||||
remediate="pass issues=['<concrete actionable issue>', ...]",
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str),
|
||||
agent_id=pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="request_changes",
|
||||
)
|
||||
notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues)
|
||||
rejection, spec_gate = await self._request_changes_spec_gate(
|
||||
pm_agent_id, task_id, t, agent, role_str, notes, issues
|
||||
)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
_role, spec_ctx = spec_gate
|
||||
runner = self._verb_runner()
|
||||
try:
|
||||
t = await runner.run_intent("request_changes", t, agent, spec_ctx)
|
||||
except Exception as exc:
|
||||
return await self._emit_rejection(
|
||||
Envelope.invalid_state(
|
||||
message=f"verb runner failed: {exc}",
|
||||
remediate="re-fetch the task and retry; if persistent, escalate",
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str),
|
||||
agent_id=pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="request_changes",
|
||||
)
|
||||
# Close the signal loop — the reject reason must reach whoever owns
|
||||
# the revision (mirrors fail_review / the pr_fail loop-closer).
|
||||
if t.assigned_to is not None and t.assigned_to != pm_agent_id:
|
||||
await self.a2a.send(
|
||||
from_agent=pm_agent_id,
|
||||
to_agent=t.assigned_to,
|
||||
skill="code_review",
|
||||
task_id=task_id,
|
||||
body=f"PM merge review needs changes. {notes}",
|
||||
)
|
||||
return Envelope.ok(
|
||||
status=str(t.status),
|
||||
task_id=str(task_id),
|
||||
next=spec_module._INTENT_VERBS["request_changes"].next_hint(t),
|
||||
context_briefing=briefing,
|
||||
).with_introspection(task=t, role=role_str)
|
||||
|
||||
async def _request_changes_spec_gate(
|
||||
self,
|
||||
pm_agent_id: UUID,
|
||||
task_id: UUID,
|
||||
t: Any,
|
||||
agent: Any,
|
||||
role_str: str,
|
||||
notes: str,
|
||||
issues: list[str],
|
||||
) -> tuple[Envelope | None, Any]:
|
||||
"""Role + spec + free-text gates for request_changes.
|
||||
|
||||
Returns ``(rejection, None)`` or ``(None, (role, spec_ctx))``.
|
||||
"""
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
return (
|
||||
await self._emit_rejection(
|
||||
Envelope.not_authorized(
|
||||
message=f"unknown role '{role_str}'",
|
||||
remediate="role is not declared in the lifecycle spec",
|
||||
context_briefing=await self._briefing_for(pm_agent_id, task_id),
|
||||
).with_introspection(task=t, role=role_str),
|
||||
agent_id=pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="request_changes",
|
||||
),
|
||||
None,
|
||||
)
|
||||
spec_ctx = spec_module.Context(
|
||||
actor_id=pm_agent_id,
|
||||
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
|
||||
original_developer_slug=_extract_original_developer(t),
|
||||
notes=notes,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
decision = spec_module.can_invoke_intent(role, "request_changes", t, spec_ctx)
|
||||
if not decision.allowed:
|
||||
briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
|
||||
return (
|
||||
await self._emit_rejection(
|
||||
Envelope.from_decision(
|
||||
decision, briefing=briefing
|
||||
).with_introspection(task=t, role=role_str),
|
||||
agent_id=pm_agent_id,
|
||||
task_id=task_id,
|
||||
verb="request_changes",
|
||||
),
|
||||
None,
|
||||
)
|
||||
if soup := await self._guard_free_text(
|
||||
checks=(("issues", issues, 8),),
|
||||
task=t,
|
||||
agent_id=pm_agent_id,
|
||||
role_str=role_str,
|
||||
verb="request_changes",
|
||||
):
|
||||
return (soup, None)
|
||||
return (None, (role, spec_ctx))
|
||||
|
||||
async def escalate_up(
|
||||
self, pm_agent_id: UUID, task_id: UUID, reason: str
|
||||
) -> Envelope:
|
||||
|
||||
@@ -93,6 +93,7 @@ class ChoreographerHelpers:
|
||||
*,
|
||||
task: Any | None = None,
|
||||
include_ac_coverage: bool = False,
|
||||
full: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -186,6 +186,20 @@ class VerbRunner:
|
||||
agent.id, task.id, ctx.notes or "", list(ctx.issues)
|
||||
)
|
||||
|
||||
async def _do_request_changes(
|
||||
self, task: Any, agent: Any, ctx: spec.Context
|
||||
) -> Any:
|
||||
# Forward the actor's real role — cell_pm and main_pm both own this
|
||||
# verb and the audit row must attribute the reject to the reviewer.
|
||||
agent_role = str(agent.role) if agent is not None else "cell_pm"
|
||||
return await self.task_service.request_changes(
|
||||
agent.id,
|
||||
task.id,
|
||||
ctx.notes or "",
|
||||
list(ctx.issues),
|
||||
agent_role=agent_role,
|
||||
)
|
||||
|
||||
async def _do_escalate_to_ceo(
|
||||
self, task: Any, agent: Any, ctx: spec.Context
|
||||
) -> Any:
|
||||
@@ -243,6 +257,7 @@ class VerbRunner:
|
||||
"submit_for_review": cls._do_submit_for_review,
|
||||
"pr_pass": cls._do_pr_pass,
|
||||
"pr_fail": cls._do_pr_fail,
|
||||
"request_changes": cls._do_request_changes,
|
||||
"escalate_to_ceo": cls._do_escalate_to_ceo,
|
||||
"block": cls._do_block,
|
||||
"unblock": cls._do_unblock,
|
||||
|
||||
@@ -43,7 +43,9 @@ class BoardMixin(_Base):
|
||||
f"review and call escalate_to_ceo(task_id='{t.id}', reason=...)"
|
||||
" or i_am_idle"
|
||||
),
|
||||
context_briefing=await self._briefing_for(board_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(
|
||||
board_agent_id, t.id, full=True
|
||||
),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
@@ -64,7 +66,9 @@ class BoardMixin(_Base):
|
||||
"log a reflect-note observing the anomaly via "
|
||||
f"note(scope='reflect', task_id='{t.id}', text='...')"
|
||||
),
|
||||
context_briefing=await self._briefing_for(auditor_agent_id, t.id),
|
||||
context_briefing=await self._briefing_for(
|
||||
auditor_agent_id, t.id, full=True
|
||||
),
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="idle",
|
||||
|
||||
@@ -107,7 +107,7 @@ class DocMixin(_Base):
|
||||
"""
|
||||
agent = await self.task.agent_for(doc_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "documenter"
|
||||
briefing = await self._briefing_for(doc_agent_id, task_id)
|
||||
briefing = await self._briefing_for(doc_agent_id, task_id, full=True)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
|
||||
@@ -62,7 +62,7 @@ class PRGateMixin(_Base):
|
||||
)
|
||||
agent = await self.task.agent_for(reviewer_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "pr_reviewer"
|
||||
briefing = await self._briefing_for(reviewer_agent_id, task_id)
|
||||
briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True)
|
||||
role = await self._gate_role_or_rejection(
|
||||
t, role_str, briefing, reviewer_agent_id, task_id, "claim_gate_review"
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ class PRReviewerMixin(_Base):
|
||||
)
|
||||
agent = await self.task.agent_for(reviewer_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "pr_reviewer"
|
||||
briefing = await self._briefing_for(reviewer_agent_id, task_id)
|
||||
briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True)
|
||||
role_or_rejection = await self._resolve_role(
|
||||
t, role_str, briefing, reviewer_agent_id, task_id, "claim_pr_review"
|
||||
)
|
||||
|
||||
@@ -114,7 +114,7 @@ class QAMixin(_Base):
|
||||
)
|
||||
agent = await self.task.agent_for(qa_agent_id)
|
||||
role_str = str(agent.role) if agent is not None else "qa"
|
||||
briefing = await self._briefing_for(qa_agent_id, task_id)
|
||||
briefing = await self._briefing_for(qa_agent_id, task_id, full=True)
|
||||
try:
|
||||
role = spec_module.Role(role_str)
|
||||
except ValueError:
|
||||
|
||||
@@ -55,6 +55,24 @@ class BriefingInputs:
|
||||
company_goals: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# Char cap for a unified diff embedded in an LLM-facing payload (~5K tokens).
|
||||
# The head is kept (file headers + earliest hunks carry the most signal); the
|
||||
# marker points at the full diff so a reviewer is never silently blinded.
|
||||
EVIDENCE_DIFF_CAP_CHARS = 20_000
|
||||
|
||||
|
||||
def truncate_diff(diff: str | None, limit: int = EVIDENCE_DIFF_CAP_CHARS) -> str | None:
|
||||
"""Cap a diff for envelope embedding; annotate what was omitted."""
|
||||
if not diff or len(diff) <= limit:
|
||||
return diff
|
||||
omitted = len(diff) - limit
|
||||
return (
|
||||
diff[:limit]
|
||||
+ f"\n… [diff truncated: {omitted} chars omitted — read the full diff on"
|
||||
" the PR, or scope roboco_git_diff to a single file_path]"
|
||||
)
|
||||
|
||||
|
||||
def build_evidence_for_task(
|
||||
task: Any,
|
||||
*,
|
||||
@@ -67,7 +85,7 @@ def build_evidence_for_task(
|
||||
return EvidencePayload(
|
||||
pr_number=task.pr_number,
|
||||
pr_url=task.pr_url,
|
||||
pr_diff_summary=pr_diff_summary,
|
||||
pr_diff_summary=truncate_diff(pr_diff_summary),
|
||||
commits=list(task.commits or []),
|
||||
files_changed=list(files_changed),
|
||||
dev_summary=task.dev_notes,
|
||||
|
||||
@@ -14,6 +14,18 @@ if TYPE_CHECKING:
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Free-text caps for LLM-facing briefing/handoff payloads. Full texts stay
|
||||
# readable through their dedicated verbs (notify_get) and the panel/API.
|
||||
_MENTION_EXCERPT_CAP = 280
|
||||
_NOTIFICATION_BODY_CAP = 500
|
||||
_HANDOFF_CONTENT_CAP = 800
|
||||
_NORTH_STAR_CAP = 600
|
||||
|
||||
|
||||
def _clip(text: str | None, cap: int) -> str:
|
||||
"""None-safe prefix clip for nullable text columns."""
|
||||
return (text or "")[:cap]
|
||||
|
||||
|
||||
class EvidenceRepo:
|
||||
def __init__(self, db_session: AsyncSession) -> None:
|
||||
@@ -44,7 +56,9 @@ class EvidenceRepo:
|
||||
if not any((north_star, objectives, constraints, operating_policy)):
|
||||
return None
|
||||
return {
|
||||
"north_star": north_star,
|
||||
# north_star is free Text — cap it for the briefing (the full
|
||||
# charter stays available via the company-goals API/panel).
|
||||
"north_star": north_star[:_NORTH_STAR_CAP],
|
||||
"objectives": objectives[:BRIEFING_LIST_CAP],
|
||||
"constraints": constraints[:BRIEFING_LIST_CAP],
|
||||
"operating_policy": operating_policy,
|
||||
@@ -133,7 +147,7 @@ class EvidenceRepo:
|
||||
"notification_id": str(row.id),
|
||||
"from_agent": str(row.from_agent) if row.from_agent else None,
|
||||
"subject": row.subject,
|
||||
"excerpt": (row.body or "")[:280],
|
||||
"excerpt": _clip(row.body, _MENTION_EXCERPT_CAP),
|
||||
"task_id": (str(row.related_task_id) if row.related_task_id else None),
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
}
|
||||
@@ -177,7 +191,9 @@ class EvidenceRepo:
|
||||
"type": str(row.type),
|
||||
"priority": str(row.priority),
|
||||
"subject": row.subject,
|
||||
"body": row.body,
|
||||
# Briefing carries an excerpt; the full body stays readable
|
||||
# via notify_get / notify_list.
|
||||
"body": _clip(row.body, _NOTIFICATION_BODY_CAP),
|
||||
"from_agent": str(row.from_agent) if row.from_agent else None,
|
||||
"task_id": str(row.related_task_id) if row.related_task_id else None,
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
@@ -314,7 +330,10 @@ class EvidenceRepo:
|
||||
"author_role": str(row.role),
|
||||
"type": str(row.type),
|
||||
"title": row.title,
|
||||
"content": row.content,
|
||||
# Cap per-entry content: distilled handoff lessons (≤120 words)
|
||||
# stay whole; a raw multi-page journal entry can't flood the
|
||||
# handoff/evidence payload it is embedded in.
|
||||
"content": _clip(row.content, _HANDOFF_CONTENT_CAP),
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
}
|
||||
for row in result.all()
|
||||
|
||||
@@ -97,6 +97,18 @@ def _commit_git_timeout() -> int:
|
||||
return settings.git_commit_timeout_seconds
|
||||
|
||||
|
||||
def _completed_branchful_children(children: list[Any]) -> list[Any]:
|
||||
"""Children whose completed work can be integrity-checked: completed,
|
||||
branch-bearing, with recorded commits."""
|
||||
return [
|
||||
c
|
||||
for c in children
|
||||
if str(getattr(c, "status", "")) in ("completed", "TaskStatus.COMPLETED")
|
||||
and getattr(c, "branch_name", None)
|
||||
and getattr(c, "commits", None)
|
||||
]
|
||||
|
||||
|
||||
def _network_git_timeout() -> int:
|
||||
"""Budget for git ops that talk to origin (fetch / pull / push).
|
||||
|
||||
@@ -3951,6 +3963,84 @@ class GitService(BaseService):
|
||||
git_token=git_token,
|
||||
)
|
||||
|
||||
async def unmerged_child_commits(
|
||||
self,
|
||||
task: Any,
|
||||
*,
|
||||
actor_agent_id: UUID | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Completed children whose commits are NOT in the assembled branch.
|
||||
|
||||
Patch-equivalence via ``git cherry`` (rebase-safe — the assembled
|
||||
branch may have been rebased, rewriting SHAs). Best-effort per child:
|
||||
a child with no branch / no commits / a branch pruned from origin
|
||||
after merge contributes nothing. Returns
|
||||
``[{"task_id", "title", "unmerged"}, ...]`` for children with at
|
||||
least one patch missing from the parent branch (live incident #11:
|
||||
a completed revert absent from the assembled cell PR).
|
||||
"""
|
||||
if not getattr(task, "branch_name", None):
|
||||
return []
|
||||
task_service = get_task_service(self.session)
|
||||
children = await task_service.get_subtasks(require_uuid(task.id))
|
||||
candidates = _completed_branchful_children(children)
|
||||
if not candidates:
|
||||
return []
|
||||
project = await self._project_for_task(task)
|
||||
if project is None:
|
||||
return []
|
||||
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
|
||||
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
|
||||
git_token = await self._get_project_token_or_raise(project.slug)
|
||||
refs = [str(task.branch_name)] + [str(c.branch_name) for c in candidates]
|
||||
await self._run_git(
|
||||
workspace,
|
||||
["fetch", "origin", *dict.fromkeys(refs)],
|
||||
token=git_token,
|
||||
check=False,
|
||||
timeout=_network_git_timeout(),
|
||||
)
|
||||
missing: list[dict[str, Any]] = []
|
||||
for child in candidates:
|
||||
entry = await self._cherry_unmerged_entry(
|
||||
workspace, str(task.branch_name), child
|
||||
)
|
||||
if entry is not None:
|
||||
missing.append(entry)
|
||||
return missing
|
||||
|
||||
async def _cherry_unmerged_entry(
|
||||
self, workspace: Path, parent_branch: str, child: Any
|
||||
) -> dict[str, Any] | None:
|
||||
"""One child's unmerged-commit entry, or None (merged / unprobeable).
|
||||
|
||||
A child branch pruned from origin after merge, or any git error,
|
||||
contributes nothing — best-effort per child.
|
||||
"""
|
||||
child_ref = f"origin/{child.branch_name}"
|
||||
ref_ok = await self._run_git(
|
||||
workspace,
|
||||
["rev-parse", "--verify", "--quiet", child_ref],
|
||||
check=False,
|
||||
)
|
||||
if ref_ok.returncode != 0:
|
||||
return None # branch pruned after merge — nothing to compare
|
||||
cherry = await self._run_git(
|
||||
workspace,
|
||||
["cherry", f"origin/{parent_branch}", child_ref],
|
||||
check=False,
|
||||
)
|
||||
if cherry.returncode != 0:
|
||||
return None
|
||||
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
|
||||
if not unmerged:
|
||||
return None
|
||||
return {
|
||||
"task_id": str(child.id)[:8],
|
||||
"title": str(getattr(child, "title", ""))[:80],
|
||||
"unmerged": len(unmerged),
|
||||
}
|
||||
|
||||
async def is_behind_base(
|
||||
self,
|
||||
task: Any,
|
||||
|
||||
@@ -469,6 +469,29 @@ class PrompterService:
|
||||
)
|
||||
return UUID(str(task.id))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_declared_deps(raw: Any, idx: int) -> tuple[int, ...]:
|
||||
"""Coerce a draft's ``depends_on`` into batch indices.
|
||||
|
||||
The LLM may emit ints or numeric strings; anything non-numeric is a
|
||||
malformed batch the CEO can fix (surfaced as a ValidationError, same
|
||||
rejection path as a cyclic graph).
|
||||
"""
|
||||
if not raw:
|
||||
return ()
|
||||
deps: list[int] = []
|
||||
for item in raw if isinstance(raw, (list, tuple)) else [raw]:
|
||||
try:
|
||||
deps.append(int(str(item).strip()))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"draft {idx}: depends_on entry {item!r} is not a batch index"
|
||||
),
|
||||
field="drafts",
|
||||
) from exc
|
||||
return tuple(deps)
|
||||
|
||||
def _sequence_drafts(self, drafts: list[dict[str, Any]]) -> SequencePlan:
|
||||
"""Build each draft's collision surface and sequence them into waves.
|
||||
|
||||
@@ -487,6 +510,11 @@ class PrompterService:
|
||||
adds_migration=bool(d.get("adds_migration")),
|
||||
touches_shared=bool(d.get("touches_shared")),
|
||||
project_id=str(d["project_id"]) if d.get("project_id") else None,
|
||||
# The CEO's declared "Depends on" list — authoritative; the
|
||||
# analyzer unions it with the derived collision edges (B1b).
|
||||
declared_depends_on=self._coerce_declared_deps(
|
||||
d.get("depends_on"), idx
|
||||
),
|
||||
)
|
||||
for idx, d in enumerate(drafts)
|
||||
]
|
||||
|
||||
@@ -49,7 +49,8 @@ class SequencingService:
|
||||
) -> SequencePlan:
|
||||
"""Compute the dependency edges + execution waves for a batch."""
|
||||
edges = self._dedupe(
|
||||
self._file_overlap_edges(surfaces)
|
||||
self._declared_edges(surfaces)
|
||||
+ self._file_overlap_edges(surfaces)
|
||||
+ self._migration_chain_edges(surfaces)
|
||||
+ self._shared_last_edges(surfaces)
|
||||
)
|
||||
@@ -57,6 +58,31 @@ class SequencingService:
|
||||
warnings = self._contention_warnings(waves, cell_of, cell_capacity)
|
||||
return SequencePlan(edges=edges, waves=waves, warnings=warnings)
|
||||
|
||||
# --- rule 0: declared dependencies ---------------------------------------
|
||||
def _declared_edges(self, surfaces: list[DraftSurface]) -> list[tuple[int, int]]:
|
||||
"""The CEO's / PM's explicit "Depends on" lists, verbatim.
|
||||
|
||||
Authoritative — unioned with the derived rules so a declared ordering
|
||||
can never be dropped because the surfaces don't overlap (the live S6
|
||||
break). A self- or out-of-range reference is a malformed batch, not a
|
||||
silently-droppable edge.
|
||||
"""
|
||||
edges: list[tuple[int, int]] = []
|
||||
n = len(surfaces)
|
||||
for s in surfaces:
|
||||
for dep in s.declared_depends_on:
|
||||
if dep == s.idx:
|
||||
raise SequencingError(
|
||||
f"draft {s.idx} declares a dependency on itself"
|
||||
)
|
||||
if dep < 0 or dep >= n:
|
||||
raise SequencingError(
|
||||
f"draft {s.idx} declares dependency on index {dep}, "
|
||||
f"but the batch has only {n} drafts (0..{n - 1})"
|
||||
)
|
||||
edges.append((dep, s.idx))
|
||||
return edges
|
||||
|
||||
# --- rule 1: file overlap ------------------------------------------------
|
||||
def _file_overlap_edges(
|
||||
self, surfaces: list[DraftSurface]
|
||||
|
||||
+119
-15
@@ -137,6 +137,21 @@ _ESCALATABLE_TO_BLOCKED: frozenset[TaskStatus] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Review/queue states are entered unowned and re-claimed via the claim verbs
|
||||
# (claim_review, claim_doc_task, i_will_plan, ...). An admin override landing a
|
||||
# blocked task in one of these must clear the stale claim or the next claimant
|
||||
# is handed a task it cannot write to.
|
||||
_REVIEW_QUEUE_STATES: frozenset[TaskStatus] = frozenset(
|
||||
{
|
||||
TaskStatus.NEEDS_REVISION,
|
||||
TaskStatus.AWAITING_QA,
|
||||
TaskStatus.AWAITING_DOCUMENTATION,
|
||||
TaskStatus.AWAITING_PR_REVIEW,
|
||||
TaskStatus.AWAITING_PM_REVIEW,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Board / advisory roles review and advise; they never own or execute a
|
||||
# descendant code task. Handing one to them (e.g. via the main_pm→product_owner
|
||||
# escalation rung) strands the work: the board has no verb to claim, build, or
|
||||
@@ -2219,21 +2234,11 @@ class TaskService(BaseService):
|
||||
if isinstance(task.status, TaskStatus)
|
||||
else str(task.status)
|
||||
)
|
||||
if (
|
||||
from_status == TaskStatus.BLOCKED.value
|
||||
and new_status in (TaskStatus.PENDING, TaskStatus.IN_PROGRESS)
|
||||
and task.pre_block_assignee is not None
|
||||
):
|
||||
# Thread the admin actor into the restore so the audit attributes
|
||||
# the re-owning to the admin (not the restored owner) and an
|
||||
# admin_override row is emitted (#2176).
|
||||
return await self._apply_pre_block_restore(
|
||||
task,
|
||||
new_status,
|
||||
actor_id=actor_id,
|
||||
actor_role=actor_role,
|
||||
admin_override=True,
|
||||
)
|
||||
restored = await self._admin_out_of_blocked(
|
||||
task, from_status, new_status, actor_id=actor_id, actor_role=actor_role
|
||||
)
|
||||
if restored is not None:
|
||||
return restored
|
||||
task.status = new_status
|
||||
await self.session.flush()
|
||||
self._emit_status_transition_audit(
|
||||
@@ -8535,6 +8540,51 @@ class TaskService(BaseService):
|
||||
self.log.info("Assembled PR failed review", task_id=str(task_id))
|
||||
return task
|
||||
|
||||
async def request_changes(
|
||||
self,
|
||||
pm_agent_id: UUID,
|
||||
task_id: UUID,
|
||||
notes: str,
|
||||
issues: list[str],
|
||||
agent_role: str = "cell_pm",
|
||||
) -> TaskTable | None:
|
||||
"""PM rejects the merge review: awaiting_pm_review -> needs_revision.
|
||||
|
||||
The merge-level reject the PM otherwise lacks at awaiting_pm_review
|
||||
(its only verbs were complete/escalate, so an AC violation caught at
|
||||
merge review looped block→escalate). Issues are appended for the
|
||||
dev's revision; routing mirrors a QA fail — original developer for a
|
||||
leaf, the revision PM for an assembled task.
|
||||
"""
|
||||
task = await self.get(task_id)
|
||||
if task is None or task.status != TaskStatus.AWAITING_PM_REVIEW:
|
||||
return None
|
||||
if issues:
|
||||
issue_block = "[PM REVIEW ISSUES]\n" + "\n".join(f"- {i}" for i in issues)
|
||||
task.dev_notes = _append_capped(task.dev_notes, issue_block)
|
||||
original_dev = extract_original_developer(task)
|
||||
if original_dev:
|
||||
task.assigned_to = cast("Any", UUID(original_dev))
|
||||
task.claimed_by = cast("Any", UUID(original_dev))
|
||||
else:
|
||||
pm = await self._revision_pm_for_task(task)
|
||||
task.assigned_to = cast("Any", pm.id) if pm is not None else None
|
||||
task.claimed_by = cast("Any", pm.id) if pm is not None else None
|
||||
task.active_claimant_id = cast("Any", None)
|
||||
self._validate_and_set_status(
|
||||
task,
|
||||
TaskStatus.NEEDS_REVISION,
|
||||
agent_role,
|
||||
audit_agent_id=pm_agent_id,
|
||||
)
|
||||
await self.session.flush()
|
||||
self.log.info(
|
||||
"PM requested changes at merge review",
|
||||
task_id=str(task_id),
|
||||
notes=notes[:200],
|
||||
)
|
||||
return task
|
||||
|
||||
async def unblock_with_restore(
|
||||
self,
|
||||
pm_agent_id: UUID,
|
||||
@@ -8645,6 +8695,9 @@ class TaskService(BaseService):
|
||||
if task.pre_block_assignee:
|
||||
task.assigned_to = cast("Any", task.pre_block_assignee)
|
||||
task.claimed_by = cast("Any", task.pre_block_assignee)
|
||||
# The claimant lock must follow the re-owning — a stale claimant
|
||||
# bounces every content write from the restored owner.
|
||||
task.active_claimant_id = cast("Any", task.pre_block_assignee)
|
||||
task.pre_block_state = None
|
||||
task.pre_block_assignee = None
|
||||
task.pre_block_metadata = None
|
||||
@@ -8652,6 +8705,57 @@ class TaskService(BaseService):
|
||||
task.blocker_raised_by = None
|
||||
return pre_status, restored_status, restored_owner
|
||||
|
||||
async def _admin_out_of_blocked(
|
||||
self,
|
||||
task: TaskTable,
|
||||
from_status: str,
|
||||
new_status: TaskStatus,
|
||||
*,
|
||||
actor_id: str | UUID | None,
|
||||
actor_role: str | None,
|
||||
) -> TaskTable | None:
|
||||
"""Ownership reconciliation when an admin override leaves BLOCKED.
|
||||
|
||||
pending/in_progress with a snapshot → full pre-block restore (returns
|
||||
the restored task). Review/queue targets → clear the stale claim so
|
||||
the next claimant starts clean (returns None; caller sets status).
|
||||
Any other target → no-op (returns None).
|
||||
"""
|
||||
if from_status != TaskStatus.BLOCKED.value:
|
||||
return None
|
||||
if (
|
||||
new_status in (TaskStatus.PENDING, TaskStatus.IN_PROGRESS)
|
||||
and task.pre_block_assignee is not None
|
||||
):
|
||||
# Thread the admin actor into the restore so the audit attributes
|
||||
# the re-owning to the admin (not the restored owner) and an
|
||||
# admin_override row is emitted (#2176).
|
||||
return await self._apply_pre_block_restore(
|
||||
task,
|
||||
new_status,
|
||||
actor_id=actor_id,
|
||||
actor_role=actor_role,
|
||||
admin_override=True,
|
||||
)
|
||||
if new_status in _REVIEW_QUEUE_STATES:
|
||||
# Review/queue targets are re-claimed via the claim verbs — a stale
|
||||
# escalation claim surviving the override strands the next claimant
|
||||
# (give_me_work hands out the task while its note() writes bounce).
|
||||
self._clear_claim_and_block_snapshot(task)
|
||||
return None
|
||||
|
||||
def _clear_claim_and_block_snapshot(self, task: TaskTable) -> None:
|
||||
"""Release the claim + consume the pre-block snapshot on an admin
|
||||
override into a review/queue state — the next claimant starts clean."""
|
||||
task.claimed_by = cast("Any", None)
|
||||
task.claimed_at = None
|
||||
task.active_claimant_id = cast("Any", None)
|
||||
task.pre_block_state = None
|
||||
task.pre_block_assignee = None
|
||||
task.pre_block_metadata = None
|
||||
task.blocker_resolver_type = None
|
||||
task.blocker_raised_by = None
|
||||
|
||||
def _emit_admin_override_audit(
|
||||
self,
|
||||
task: TaskTable,
|
||||
|
||||
@@ -342,9 +342,12 @@ class UsageService(BaseService):
|
||||
"""Return spawn-churn signals for the period.
|
||||
|
||||
A spawn is "unproductive" when it produced no output tokens — the system
|
||||
prompt + briefing were loaded for zero delivered work. Also surfaces the
|
||||
current respawn-tracker strikes (wedged agent/task pairs the circuit
|
||||
breaker is counting) and whether the overseer was already alerted.
|
||||
prompt + briefing were loaded for zero delivered work. Scoped to
|
||||
Anthropic (claude) sessions only: non-Anthropic transcripts (Ollama,
|
||||
Grok relays) don't reliably populate ``tokens_output``, so counting
|
||||
them reports phantom waste. Also surfaces the current respawn-tracker
|
||||
strikes (wedged agent/task pairs the circuit breaker is counting) and
|
||||
whether the overseer was already alerted.
|
||||
"""
|
||||
start_dt, _ = _parse_period(period)
|
||||
|
||||
@@ -365,6 +368,7 @@ class UsageService(BaseService):
|
||||
.where(
|
||||
AgentSpawnSessionTable.started_at >= start_dt,
|
||||
AgentSpawnSessionTable.ended_at.isnot(None),
|
||||
AgentSpawnSessionTable.model.ilike("%claude%"),
|
||||
)
|
||||
.group_by(AgentSpawnSessionTable.role)
|
||||
.order_by(func.count().desc())
|
||||
@@ -413,6 +417,7 @@ class UsageService(BaseService):
|
||||
else 0.0,
|
||||
"by_role": roles,
|
||||
"respawn_strikes": strikes,
|
||||
"basis": "anthropic_sessions",
|
||||
"period": period,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ handoff logic run unmodified.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
@@ -141,6 +141,9 @@ def _make_orch() -> AgentOrchestrator:
|
||||
orch._instances = {}
|
||||
orch._board_dispatched = set()
|
||||
orch._board_review_ceo_notified = set()
|
||||
# The board dispatch path consults the respawn circuit breaker now.
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
return orch
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""PATCH null-clear semantics — unassigning a task implies releasing its claim.
|
||||
|
||||
Live wedge (2026-07-01): a CEO PATCH set assigned_to=null on a wedged task but
|
||||
claimed_by/active_claimant_id survived, so the task kept routing to the stale
|
||||
claimant while the next agent's content writes bounced not_authorized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.api.routes.tasks import _apply_null_clears
|
||||
|
||||
|
||||
def _task(**overrides: object) -> SimpleNamespace:
|
||||
owner = uuid4()
|
||||
base: dict[str, object] = {
|
||||
"assigned_to": owner,
|
||||
"claimed_by": owner,
|
||||
"claimed_at": datetime.now(UTC),
|
||||
"active_claimant_id": owner,
|
||||
"parent_task_id": uuid4(),
|
||||
"project_id": uuid4(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_unassign_clears_claim_fields() -> None:
|
||||
"""assigned_to=null releases the claim triplet with it."""
|
||||
task = _task()
|
||||
_apply_null_clears(task, {"assigned_to": None})
|
||||
assert task.assigned_to is None
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
def test_other_null_clears_leave_claim_untouched() -> None:
|
||||
"""Clearing parent_task_id/project_id is structural — not a claim release."""
|
||||
owner = uuid4()
|
||||
task = _task(assigned_to=owner, claimed_by=owner, active_claimant_id=owner)
|
||||
_apply_null_clears(task, {"parent_task_id": None})
|
||||
assert task.parent_task_id is None
|
||||
assert task.assigned_to == owner
|
||||
assert task.claimed_by == owner
|
||||
assert task.active_claimant_id == owner
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Doc-stage bail rejections must name the real exit: i_documented.
|
||||
|
||||
Live loop (2026-07-02, b8fe0494): fe-doc respawned 26 times on a revision
|
||||
pass — the docs were already written, the agent wouldn't call i_documented
|
||||
for work it didn't author, and its bail attempts (i_am_blocked/unclaim) were
|
||||
rejected with a generic remediate that never mentioned the one verb that IS
|
||||
the exit from awaiting_documentation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import Context, can_invoke_intent
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
status: object = "awaiting_documentation"
|
||||
assigned_to: object = None
|
||||
task_type: object = "code"
|
||||
team: object = "frontend"
|
||||
created_by: object = field(default_factory=uuid4)
|
||||
|
||||
|
||||
def test_doc_block_rejection_points_at_i_documented() -> None:
|
||||
doc = uuid4()
|
||||
task = _Task(assigned_to=doc)
|
||||
decision = can_invoke_intent(
|
||||
Role.DOCUMENTER, "i_am_blocked", task, Context(actor_id=doc)
|
||||
)
|
||||
assert not decision.allowed
|
||||
assert "i_documented" in (decision.remediate or "")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""IntentSpec for the PM `request_changes` verb (S6 postmortem, gap B4).
|
||||
|
||||
At awaiting_pm_review the PM previously had NO reject verb — only `complete`
|
||||
or escalate — so a PM that caught a genuine AC/scope violation at merge review
|
||||
could only loop `i_am_blocked` + escalate (the live fe-pm block/escalate loop,
|
||||
2026-07-01). `request_changes` is the merge-level reject: awaiting_pm_review ->
|
||||
needs_revision with concrete issues, routed like a QA fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import (
|
||||
Context,
|
||||
Status,
|
||||
can_invoke_intent,
|
||||
intents_for_role,
|
||||
status_after,
|
||||
)
|
||||
from roboco.services.gateway.role_config import _CELL_PM_FLOW, _MAIN_PM_FLOW
|
||||
|
||||
|
||||
def test_request_changes_is_a_pm_flow_verb() -> None:
|
||||
# Declared for the PM roles, so intents_for_role propagates it into both
|
||||
# PM flows automatically — the spec is canon, no role_config edit.
|
||||
assert "request_changes" in intents_for_role(Role.CELL_PM)
|
||||
assert "request_changes" in intents_for_role(Role.MAIN_PM)
|
||||
assert "request_changes" in _CELL_PM_FLOW
|
||||
assert "request_changes" in _MAIN_PM_FLOW
|
||||
|
||||
|
||||
def test_request_changes_is_pm_only() -> None:
|
||||
for role in (
|
||||
Role.DEVELOPER,
|
||||
Role.QA,
|
||||
Role.DOCUMENTER,
|
||||
Role.PR_REVIEWER,
|
||||
Role.PRODUCT_OWNER,
|
||||
Role.HEAD_MARKETING,
|
||||
Role.AUDITOR,
|
||||
):
|
||||
assert "request_changes" not in intents_for_role(role), (
|
||||
f"{role} must not get request_changes"
|
||||
)
|
||||
|
||||
|
||||
def test_request_changes_transitions_pm_review_to_needs_revision() -> None:
|
||||
assert status_after("request_changes", Status.AWAITING_PM_REVIEW) == (
|
||||
Status.NEEDS_REVISION
|
||||
)
|
||||
|
||||
|
||||
def test_request_changes_only_from_awaiting_pm_review() -> None:
|
||||
for status in (
|
||||
Status.IN_PROGRESS,
|
||||
Status.AWAITING_QA,
|
||||
Status.AWAITING_PR_REVIEW,
|
||||
Status.BLOCKED,
|
||||
Status.COMPLETED,
|
||||
):
|
||||
assert status_after("request_changes", status) is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
status: object = Status.AWAITING_PM_REVIEW
|
||||
assigned_to: object = None
|
||||
task_type: object = "code"
|
||||
team: object = "frontend"
|
||||
created_by: object = field(default_factory=uuid4)
|
||||
|
||||
|
||||
def test_request_changes_allowed_for_cell_pm_at_pm_review() -> None:
|
||||
pm = uuid4()
|
||||
task = _Task(assigned_to=pm)
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM, "request_changes", task, Context(actor_id=pm)
|
||||
)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
def test_request_changes_rejected_outside_pm_review() -> None:
|
||||
pm = uuid4()
|
||||
task = _Task(status=Status.IN_PROGRESS, assigned_to=pm)
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM, "request_changes", task, Context(actor_id=pm)
|
||||
)
|
||||
assert not decision.allowed
|
||||
@@ -0,0 +1,76 @@
|
||||
"""TASK_AT_DELEGATE — code delegations must declare a collision surface.
|
||||
|
||||
Live break (2026-07-02, f3e1afc5): a PM delegated two code subtasks to two
|
||||
devs with NO intends_to_touch; the sibling collision analyzer treats a
|
||||
no-surface sibling as parallel to everything, so both dispatched at once and
|
||||
seq#1 started before seq#0 against a base missing its prerequisite. The
|
||||
surface is what turns sibling ordering into real dependency edges — an empty
|
||||
one silently disables sequencing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from roboco.foundation.policy.task_completeness import (
|
||||
TASK_AT_CREATE,
|
||||
TASK_AT_DELEGATE,
|
||||
check,
|
||||
)
|
||||
from roboco.models.base import TaskType
|
||||
|
||||
|
||||
def _payload(**overrides: Any) -> SimpleNamespace:
|
||||
base: dict[str, Any] = {
|
||||
"title": "Implement endpoint",
|
||||
"description": "Add /v1/foo endpoint with passing tests please",
|
||||
"assigned_to": "be-dev-1",
|
||||
"team": "backend",
|
||||
"task_type": "code",
|
||||
"nature": "technical",
|
||||
"estimated_complexity": "medium",
|
||||
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
|
||||
"intends_to_touch": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_code_delegation_without_surface_is_incomplete() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload())
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
assert "intends_to_touch" in result.field_hints
|
||||
|
||||
|
||||
def test_code_delegation_with_empty_surface_is_incomplete() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload(intends_to_touch=[]))
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
|
||||
|
||||
def test_code_delegation_with_surface_passes() -> None:
|
||||
result = check(
|
||||
TASK_AT_DELEGATE,
|
||||
_payload(intends_to_touch=["backend/api/routers/foo.py"]),
|
||||
)
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_non_code_delegation_needs_no_surface() -> None:
|
||||
for task_type in ("research", "documentation", "design", "planning"):
|
||||
result = check(TASK_AT_DELEGATE, _payload(task_type=task_type))
|
||||
assert result.passed, f"{task_type} must not require a surface"
|
||||
|
||||
|
||||
def test_enum_task_type_is_normalized() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload(task_type=TaskType.CODE))
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
|
||||
|
||||
def test_task_at_create_is_unchanged() -> None:
|
||||
# REST/manual creation keeps the old contract — no surface requirement.
|
||||
result = check(TASK_AT_CREATE, _payload())
|
||||
assert result.passed
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Behind-base auto-sync for the assembled PM submits (B2).
|
||||
|
||||
The needs_revision ↔ awaiting_pr_review ping-pong (live, 2026-07-02): a cell /
|
||||
root revision re-submitted a head whose BASE had moved (sibling cells merged),
|
||||
so the gate re-failed the same missing-work finding every cycle. Leaf devs
|
||||
have the ``_behind_base_gate`` + ``sync_branch``; the assembled submits had no
|
||||
freshness check at all. ``_freshen_assembled_branch`` closes that: at
|
||||
submit_up / submit_root time every child is terminal, so rebasing the
|
||||
assembled branch onto its base is safe — conflicts become a clean rejection
|
||||
naming the files instead of a blind re-review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _cell_task() -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(),
|
||||
branch_name="feature/frontend/root--cell",
|
||||
team="frontend",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_noop_when_up_to_date() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (0, 3)
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
git.sync_task_branch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_rebases_when_behind_and_proceeds() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (2, 3)
|
||||
git.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 3}
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
git.sync_task_branch.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_conflicts_reject_with_files() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (2, 3)
|
||||
git.sync_task_branch.return_value = {
|
||||
"status": "conflicts",
|
||||
"files": ["frontend/src/lib/stats.json"],
|
||||
}
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "stats.json" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_fails_open_on_probe_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.side_effect = RuntimeError("network sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_fails_open_on_sync_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (1, 1)
|
||||
git.sync_task_branch.side_effect = RuntimeError("rebase runner sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_skips_branchless_and_missing_base() -> None:
|
||||
git = AsyncMock()
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
branchless = MagicMock(id=uuid4(), branch_name=None, team="frontend")
|
||||
assert (
|
||||
await c._freshen_assembled_branch(branchless, base_branch="x", verb="submit_up")
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="", verb="submit_up"
|
||||
)
|
||||
is None
|
||||
)
|
||||
git.is_behind_base.assert_not_awaited()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Assembly integrity at submit_up — every completed child's work must be in
|
||||
the assembled branch (incident #11).
|
||||
|
||||
Live break (2026-07-02, S6 cell PR #183): revert subtask 3b9cc162 COMPLETED,
|
||||
but its commit never landed on the assembled cell branch — the reviewer
|
||||
re-flagged the exact violation the revert fixed, spawning another revision
|
||||
cycle. The gate verifies patch-equivalence (rebase-safe) per completed child
|
||||
and refuses the submit naming the children whose work is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _cell_task() -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(), branch_name="feature/frontend/root--cell", team="frontend"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_passes_when_all_children_merged() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.return_value = []
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_rejects_naming_missing_children() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.return_value = [
|
||||
{
|
||||
"task_id": "3b9cc162",
|
||||
"title": "Revert stats.json artifact",
|
||||
"unmerged": 1,
|
||||
}
|
||||
]
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "Revert stats.json artifact" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_fails_open_on_git_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.side_effect = RuntimeError("git sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_skips_branchless_task() -> None:
|
||||
git = AsyncMock()
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
t = MagicMock(id=uuid4(), branch_name=None)
|
||||
env = await c._assembly_integrity_guard(t, verb="submit_up")
|
||||
assert env is None
|
||||
git.unmerged_child_commits.assert_not_awaited()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Claim-scoped context briefing — `_briefing_for(full=...)`.
|
||||
|
||||
Only context-acquisition verbs (give_me_work / claim / plan / resume / triage)
|
||||
carry the heavy, verb-invariant sections (company_goals, recent_team_activity,
|
||||
blockers_in_my_lane, task_handoff, institutional_memory). Every other verb gets
|
||||
the slim signals-only briefing, and the heavy repo queries are not even issued.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer
|
||||
from roboco.services.gateway.evidence_builder import (
|
||||
EVIDENCE_DIFF_CAP_CHARS,
|
||||
build_evidence_for_task,
|
||||
truncate_diff,
|
||||
)
|
||||
|
||||
_PR_NUMBER = 8
|
||||
|
||||
|
||||
def _choreographer_with_repo() -> tuple[Choreographer, AsyncMock]:
|
||||
repo = AsyncMock()
|
||||
repo.list_unread_a2a.return_value = [{"conversation_id": "c1", "unread": 2}]
|
||||
repo.list_unread_mentions.return_value = []
|
||||
repo.list_pending_notifications.return_value = [{"notification_id": "n1"}]
|
||||
repo.task_metadata_gaps.return_value = []
|
||||
repo.recent_team_activity.return_value = [{"task_id": "t1", "status": "pending"}]
|
||||
repo.blockers_in_lane.return_value = [{"task_id": "b1"}]
|
||||
repo.company_goals.return_value = {"north_star": "win"}
|
||||
repo.journal_highlights_for_task.return_value = []
|
||||
choreo = object.__new__(Choreographer)
|
||||
choreo._deps = MagicMock(evidence_repo=repo)
|
||||
return choreo, repo
|
||||
|
||||
|
||||
class TestBriefingScope:
|
||||
@pytest.mark.asyncio
|
||||
async def test_slim_default_carries_signals_only(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
briefing = await choreo._briefing_for(uuid4(), None)
|
||||
assert briefing["unread_a2a"] == [{"conversation_id": "c1", "unread": 2}]
|
||||
assert briefing["pending_notifications"] == [{"notification_id": "n1"}]
|
||||
for heavy in (
|
||||
"company_goals",
|
||||
"recent_team_activity",
|
||||
"blockers_in_my_lane",
|
||||
"task_handoff",
|
||||
"institutional_memory",
|
||||
):
|
||||
assert heavy not in briefing
|
||||
# The heavy queries are not even issued on the slim path.
|
||||
repo.recent_team_activity.assert_not_awaited()
|
||||
repo.blockers_in_lane.assert_not_awaited()
|
||||
repo.company_goals.assert_not_awaited()
|
||||
repo.journal_highlights_for_task.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_carries_heavy_sections(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
briefing = await choreo._briefing_for(uuid4(), None, full=True)
|
||||
assert briefing["company_goals"] == {"north_star": "win"}
|
||||
assert briefing["recent_team_activity"] == [
|
||||
{"task_id": "t1", "status": "pending"}
|
||||
]
|
||||
assert briefing["blockers_in_my_lane"] == [{"task_id": "b1"}]
|
||||
repo.company_goals.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_with_task_builds_handoff(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
task_id = uuid4()
|
||||
task = MagicMock(
|
||||
pr_number=_PR_NUMBER,
|
||||
pr_url="https://github.com/x/y/pull/8",
|
||||
branch_name="feature/backend/abc",
|
||||
commits=[{"sha": "abc123", "message": "feat: x"}],
|
||||
quick_context=None,
|
||||
)
|
||||
briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True)
|
||||
assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER
|
||||
repo.journal_highlights_for_task.assert_awaited_once_with(task_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slim_with_task_omits_handoff(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
task = MagicMock(pr_number=8, commits=[{"sha": "abc123"}])
|
||||
briefing = await choreo._briefing_for(uuid4(), uuid4(), task=task)
|
||||
assert "task_handoff" not in briefing
|
||||
repo.journal_highlights_for_task.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPayloadCaps:
|
||||
def test_truncate_diff_caps_and_annotates(self) -> None:
|
||||
big = "x" * (EVIDENCE_DIFF_CAP_CHARS + 5_000)
|
||||
capped = truncate_diff(big)
|
||||
assert capped is not None
|
||||
assert len(capped) < len(big)
|
||||
assert capped.startswith("x" * 100) # head preserved
|
||||
assert "diff truncated" in capped
|
||||
|
||||
def test_truncate_diff_passes_small_and_none(self) -> None:
|
||||
assert truncate_diff("small diff") == "small diff"
|
||||
assert truncate_diff(None) is None
|
||||
|
||||
def test_build_evidence_caps_the_diff(self) -> None:
|
||||
task = MagicMock(
|
||||
pr_number=None,
|
||||
pr_url=None,
|
||||
commits=[],
|
||||
dev_notes=None,
|
||||
acceptance_criteria_status=[],
|
||||
)
|
||||
ev = build_evidence_for_task(
|
||||
task,
|
||||
journal_highlights=[],
|
||||
files_changed=[],
|
||||
pr_diff_summary="y" * (EVIDENCE_DIFF_CAP_CHARS * 2),
|
||||
)
|
||||
assert ev.pr_diff_summary is not None
|
||||
assert "diff truncated" in ev.pr_diff_summary
|
||||
@@ -65,6 +65,7 @@ def _delegate_inputs() -> DelegateInputs:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ async def test_delegate_parent_not_found() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -607,6 +608,7 @@ async def test_delegate_unknown_role_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -653,6 +655,7 @@ async def test_delegate_parent_no_project_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
@@ -694,6 +694,7 @@ async def test_delegate_main_pm_to_cell_pm_creates_subtask() -> None:
|
||||
task_type="planning",
|
||||
nature="technical",
|
||||
acceptance_criteria=["all backend subtasks defined with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
@@ -736,6 +737,7 @@ async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
@@ -771,6 +773,7 @@ async def test_delegate_main_pm_to_dev_is_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -807,6 +810,7 @@ async def test_delegate_cell_pm_to_other_pm_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -842,6 +846,7 @@ async def test_delegate_unknown_assignee_returns_invalid_state() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -877,6 +882,7 @@ async def test_delegate_invalid_team_enum_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.as_dict()["error"] == "invalid_state"
|
||||
@@ -1154,6 +1160,7 @@ async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None:
|
||||
task_type="code", # WRONG — Cell PM should get planning
|
||||
nature="technical",
|
||||
acceptance_criteria=["all subtasks created with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -1195,6 +1202,7 @@ async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None:
|
||||
task_type="planning",
|
||||
nature="technical",
|
||||
acceptance_criteria=["all subtasks created with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Choreographer.request_changes — the PM merge-level reject (S6 gap B4).
|
||||
|
||||
At awaiting_pm_review the PM previously had only complete/escalate, so an AC
|
||||
violation caught at merge review looped i_am_blocked→escalate. request_changes
|
||||
routes it to needs_revision with concrete issues and a2a-delivers the reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
"list_unread_mentions",
|
||||
"list_pending_notifications",
|
||||
"task_metadata_gaps",
|
||||
"recent_team_activity",
|
||||
"blockers_in_lane",
|
||||
"journal_highlights_for_task",
|
||||
):
|
||||
getattr(repo, method).return_value = []
|
||||
_ldef = base["journal"].latest_decision_at.return_value
|
||||
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
||||
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _pm_review_task(task_id: Any, assigned_to: Any) -> MagicMock:
|
||||
return MagicMock(
|
||||
id=task_id,
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=assigned_to,
|
||||
team="frontend",
|
||||
task_type="code",
|
||||
pr_number=176,
|
||||
branch_name="feature/frontend/abc--def--ghi",
|
||||
orchestration_markers=None,
|
||||
)
|
||||
|
||||
|
||||
def _pm_agent_mock(pm_id: Any, role: str = "cell_pm") -> MagicMock:
|
||||
agent = MagicMock(id=pm_id, team="frontend", slug="fe-pm")
|
||||
agent.role = role
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_succeeds_and_notifies_new_owner() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dev_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
after = MagicMock(
|
||||
id=task_id,
|
||||
status="needs_revision",
|
||||
assigned_to=dev_id,
|
||||
team="frontend",
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
task_svc.request_changes.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.begin_nested = MagicMock(
|
||||
return_value=MagicMock(
|
||||
__aenter__=AsyncMock(return_value=None),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
a2a_svc = AsyncMock()
|
||||
deps = _make_deps(task=task_svc, a2a=a2a_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
issues = [
|
||||
"frontend/CLAUDE.md modified out of scope — revert the doc commit hunk",
|
||||
]
|
||||
env = await c.request_changes(pm_id, task_id, issues)
|
||||
assert env.error is None
|
||||
assert env.status == "needs_revision"
|
||||
task_svc.request_changes.assert_awaited_once()
|
||||
a2a_svc.send.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_requires_at_least_one_issue() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(pm_id, task_id, issues=[])
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "issue" in body["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejected_outside_pm_review() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
t.status = "in_progress"
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(pm_id, task_id, issues=["real issue here"])
|
||||
body = env.as_dict()
|
||||
assert body["error"] is not None
|
||||
task_svc.request_changes.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejected_for_non_pm_role() -> None:
|
||||
dev_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, dev_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(dev_id, role="developer")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(dev_id, task_id, issues=["real issue here"])
|
||||
body = env.as_dict()
|
||||
assert body["error"] is not None
|
||||
task_svc.request_changes.assert_not_awaited()
|
||||
@@ -225,6 +225,7 @@ async def test_delegate_passes_when_payload_complete() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None, env.as_dict()
|
||||
@@ -232,3 +233,71 @@ async def test_delegate_passes_when_payload_complete() -> None:
|
||||
# Verify nature threaded through to TaskCreateRequest.
|
||||
req = task_svc.create_subtask.call_args.args[0]
|
||||
assert str(req.nature) == "technical" or req.nature.value == "technical"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_code_without_collision_surface_is_incomplete() -> None:
|
||||
"""A code delegation with NO intends_to_touch is rejected at the boundary.
|
||||
|
||||
Live break (f3e1afc5, 2026-07-02): two code siblings delegated to two devs
|
||||
with no surface — the collision analyzer treats a no-surface sibling as
|
||||
parallel to everything, so the declared sequence was decorative and seq#1
|
||||
started before seq#0 on a base missing its prerequisite.
|
||||
"""
|
||||
pm_id = uuid4()
|
||||
parent = _parent_in_progress(pm_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = parent
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||
task_svc.get_subtasks.return_value = []
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.delegate(
|
||||
pm_id,
|
||||
parent.id,
|
||||
DelegateInputs(
|
||||
title="Implement endpoint",
|
||||
description="Add /v1/foo endpoint with passing tests please",
|
||||
assigned_to="be-dev-1",
|
||||
team="backend",
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "incomplete_input"
|
||||
assert "intends_to_touch" in body.get("missing", [])
|
||||
task_svc.create_subtask.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_non_code_needs_no_collision_surface() -> None:
|
||||
"""Research/design/documentation delegations stay surface-free."""
|
||||
pm_id = uuid4()
|
||||
parent = _parent_in_progress(pm_id)
|
||||
new_task = MagicMock(id=uuid4())
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = parent
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||
task_svc.get_subtasks.return_value = []
|
||||
task_svc.create_subtask.return_value = new_task
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.delegate(
|
||||
pm_id,
|
||||
parent.id,
|
||||
DelegateInputs(
|
||||
title="Research retry semantics",
|
||||
description="Survey retry/backoff libraries and summarize tradeoffs",
|
||||
assigned_to="be-dev-1",
|
||||
team="backend",
|
||||
task_type="research",
|
||||
nature="technical",
|
||||
acceptance_criteria=["Summary doc lists at least 3 options"],
|
||||
),
|
||||
)
|
||||
assert env.error is None, env.as_dict()
|
||||
task_svc.create_subtask.assert_awaited_once()
|
||||
|
||||
@@ -74,6 +74,7 @@ def _inputs() -> DelegateInputs:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def _inputs(**kw: Any) -> DelegateInputs:
|
||||
"task_type": "code",
|
||||
"nature": "technical",
|
||||
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
|
||||
"intends_to_touch": ["backend/api/routers/foo.py"],
|
||||
}
|
||||
base.update(kw)
|
||||
return DelegateInputs(**base)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""roboco_git_diff caps oversized diff text at the MCP boundary.
|
||||
|
||||
The HTTP route stays uncapped (the panel diff viewer reads it whole); the
|
||||
truncation happens only on the agent-facing tool result so a huge diff can't
|
||||
flood the session context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import types
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000042")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
|
||||
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
|
||||
import roboco.mcp.git_readonly as srv
|
||||
|
||||
importlib.reload(srv)
|
||||
return srv
|
||||
|
||||
|
||||
def test_cap_diff_truncates_and_annotates(git_module: types.ModuleType) -> None:
|
||||
big = git_module._cap_diff({"diff": "z" * (git_module._DIFF_CAP_CHARS + 100)})
|
||||
assert big["diff_truncated"] is True
|
||||
assert "diff truncated" in big["diff"]
|
||||
assert len(big["diff"]) < git_module._DIFF_CAP_CHARS + 300
|
||||
|
||||
|
||||
def test_cap_diff_passes_small_untouched(git_module: types.ModuleType) -> None:
|
||||
small = git_module._cap_diff({"diff": "tiny"})
|
||||
assert small["diff"] == "tiny"
|
||||
assert "diff_truncated" not in small
|
||||
missing = git_module._cap_diff({"files_changed": 0})
|
||||
assert "diff_truncated" not in missing
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Optimal MCP server registers role-scoped tool groups.
|
||||
|
||||
Every registered schema rides in each turn's context, so a role carries only
|
||||
the groups its duties use; unknown roles fail open to the full set (minus the
|
||||
destructive index-management group, which is dev/test-only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from roboco.mcp.optimal_server import (
|
||||
_RESULT_CONTENT_CAP,
|
||||
_cap_result_content,
|
||||
create_optimal_mcp_server,
|
||||
)
|
||||
|
||||
|
||||
async def _tool_names(role: str, monkeypatch: pytest.MonkeyPatch) -> set[str]:
|
||||
monkeypatch.delenv("ROBOCO_ALLOW_FULL_TOOLSET", raising=False)
|
||||
if role:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", role)
|
||||
else:
|
||||
monkeypatch.delenv("ROBOCO_AGENT_ROLE", raising=False)
|
||||
server = create_optimal_mcp_server("00000000-0000-0000-0000-000000000042")
|
||||
return {t.name for t in await server.list_tools()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_developer_scope(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
names = await _tool_names("developer", monkeypatch)
|
||||
# Universal + dev-duty groups present.
|
||||
assert "roboco_kb_search" in names
|
||||
assert "roboco_ask_mentor" in names
|
||||
assert "roboco_search_error" in names
|
||||
assert "roboco_review_code" in names
|
||||
# PM/board decision tools, indexing and destructive admin absent.
|
||||
assert "roboco_record_decision" not in names
|
||||
assert "roboco_kb_index_code" not in names
|
||||
assert "roboco_reindex_all" not in names
|
||||
assert "roboco_clear_index" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pm_scope_carries_decisions_not_error_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
names = await _tool_names("cell_pm", monkeypatch)
|
||||
assert "roboco_record_decision" in names
|
||||
assert "roboco_search_error" not in names
|
||||
assert "roboco_review_code" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_documenter_carries_indexing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
names = await _tool_names("documenter", monkeypatch)
|
||||
assert "roboco_kb_index_docs" in names
|
||||
assert "roboco_get_standards" in names
|
||||
assert "roboco_record_decision" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_role_fails_open_except_admin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
names = await _tool_names("", monkeypatch)
|
||||
assert "roboco_search_error" in names
|
||||
assert "roboco_record_decision" in names
|
||||
# Destructive index management never registers without the escape hatch.
|
||||
assert "roboco_reindex_all" not in names
|
||||
|
||||
|
||||
_ITEM_LIMIT = 2
|
||||
|
||||
|
||||
def test_cap_result_content_caps_text_and_count() -> None:
|
||||
items: list[Any] = [
|
||||
{"content": "x" * (_RESULT_CONTENT_CAP + 200), "source": "a"},
|
||||
{"content": "short", "source": "b"},
|
||||
"bare-string-item",
|
||||
]
|
||||
capped = _cap_result_content(items, limit=_ITEM_LIMIT)
|
||||
assert len(capped) == _ITEM_LIMIT
|
||||
assert len(capped[0]["content"]) == _RESULT_CONTENT_CAP + 1 # + ellipsis
|
||||
assert capped[0]["content"].endswith("…")
|
||||
assert capped[1]["content"] == "short"
|
||||
# Original items are not mutated.
|
||||
assert len(items[0]["content"]) == _RESULT_CONTENT_CAP + 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_toolset_escape_hatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
|
||||
monkeypatch.setenv("ROBOCO_ALLOW_FULL_TOOLSET", "1")
|
||||
server = create_optimal_mcp_server("00000000-0000-0000-0000-000000000042")
|
||||
names = {t.name for t in await server.list_tools()}
|
||||
assert "roboco_reindex_all" in names
|
||||
assert "roboco_record_decision" in names
|
||||
@@ -42,6 +42,20 @@ _WRITER_ROLES = ("developer", "documenter", "product_owner", "head_marketing")
|
||||
_NON_WRITER_ROLES = ("qa", "cell_pm", "main_pm", "auditor")
|
||||
|
||||
|
||||
def test_generated_settings_cap_bash_output() -> None:
|
||||
"""Agent settings carry an explicit Bash-output cap — a gate/test dump
|
||||
enters context once and is re-read at cache-read price every later turn."""
|
||||
orch = _orch()
|
||||
path = orch._generate_agent_settings(
|
||||
agent_id="be-dev-1",
|
||||
role="developer",
|
||||
workspace_path=_WS,
|
||||
cell_workspace_path=_CELL,
|
||||
)
|
||||
settings = json.loads(Path(path).read_text())
|
||||
assert settings["env"]["BASH_MAX_OUTPUT_LENGTH"] == "20000"
|
||||
|
||||
|
||||
def test_generated_settings_base_deny_has_no_global_write_edit() -> None:
|
||||
"""The settings file a developer is spawned with must NOT globally
|
||||
deny Write/Edit (that shadowed the workspace allow → unusable)."""
|
||||
|
||||
@@ -28,6 +28,8 @@ from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
def _make_orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
orch._instances = {}
|
||||
orch._board_dispatched = set()
|
||||
orch._board_review_ceo_notified = set()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dispatcher heartbeat — a silently-dead dispatch loop must be detectable.
|
||||
|
||||
Live outage (2026-07-01): zero spawns fleet-wide for 4h25m; the old
|
||||
orchestrator's dispatch loop died with no log line, no audit row, nothing —
|
||||
the deploy's restart is what fixed it, and the cause is unrecoverable. A
|
||||
periodic ``dispatcher.alive`` audit row makes "loop dead" distinguishable
|
||||
from "no work" straight from the DB (and gives the panel a staleness signal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4 # noqa: F401 - parity with sibling harnesses
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
_TWO_HEARTBEATS = 2
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
o = cast("Any", orch)
|
||||
o._last_dispatch_heartbeat = None
|
||||
o._fire_audit = MagicMock()
|
||||
return orch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_tick_emits_heartbeat() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
cast("Any", orch)._fire_audit.assert_called_once()
|
||||
kwargs = cast("Any", orch)._fire_audit.call_args.kwargs
|
||||
assert kwargs["event_type"] == "dispatcher.alive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_throttled_within_window() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
assert cast("Any", orch)._fire_audit.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_re_emits_after_window() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
cast("Any", orch)._last_dispatch_heartbeat = datetime.now(UTC) - timedelta(
|
||||
seconds=400
|
||||
)
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
assert cast("Any", orch)._fire_audit.call_count == _TWO_HEARTBEATS
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Cross-tick cooldown for notification-triggered spawns.
|
||||
|
||||
Escalation/approval/audit/a2a dispatchers carry no task_id, so neither the
|
||||
readiness gate nor the PM respawn breaker sees them — the cooldown is the
|
||||
loop-breaker that stops an unacknowledged notification from respawning its
|
||||
recipient every dispatch tick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._notification_spawn_at = {}
|
||||
return orch
|
||||
|
||||
|
||||
def test_first_spawn_allowed_then_damped() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 600):
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is True
|
||||
# A different notification or agent is independent.
|
||||
assert orch._notification_spawn_cooled("be-pm", "n2") is False
|
||||
assert orch._notification_spawn_cooled("fe-pm", "n1") is False
|
||||
|
||||
|
||||
def test_cooldown_expires() -> None:
|
||||
orch = _orch()
|
||||
with (
|
||||
patch.object(settings, "notification_spawn_cooldown_seconds", 600),
|
||||
patch("roboco.runtime.orchestrator.time.monotonic") as clock,
|
||||
):
|
||||
clock.return_value = 1_000.0
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
clock.return_value = 1_300.0 # inside the window
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is True
|
||||
clock.return_value = 1_700.0 # window elapsed → retry allowed
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
|
||||
|
||||
def test_zero_cooldown_disables_damper() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 0):
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
|
||||
|
||||
def test_missing_notification_id_never_damped() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 600):
|
||||
assert orch._notification_spawn_cooled("be-pm", None) is False
|
||||
assert orch._notification_spawn_cooled("be-pm", None) is False
|
||||
assert orch._notification_spawn_at == {}
|
||||
|
||||
|
||||
def test_map_prunes_expired_entries() -> None:
|
||||
orch = _orch()
|
||||
prune_at = AgentOrchestrator._NOTIFICATION_COOLDOWN_PRUNE_AT
|
||||
with (
|
||||
patch.object(settings, "notification_spawn_cooldown_seconds", 600),
|
||||
patch("roboco.runtime.orchestrator.time.monotonic") as clock,
|
||||
):
|
||||
clock.return_value = 1_000.0
|
||||
for i in range(prune_at + 1):
|
||||
orch._notification_spawn_cooled("be-pm", f"n{i}")
|
||||
assert len(orch._notification_spawn_at) > prune_at
|
||||
# All entries expired → the next insert prunes them down to ~the
|
||||
# fresh entry (plus at most the just-stamped one).
|
||||
_max_after_prune = 2
|
||||
clock.return_value = 2_000.0
|
||||
orch._notification_spawn_cooled("be-pm", "fresh")
|
||||
assert len(orch._notification_spawn_at) <= _max_after_prune
|
||||
@@ -20,7 +20,10 @@ from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _new_orchestrator() -> AgentOrchestrator:
|
||||
return AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
return orch
|
||||
|
||||
|
||||
def _sibling(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""The respawn circuit breaker guards EVERY task-keyed spawn path.
|
||||
|
||||
Live break (2026-07-02, b8fe0494): fe-doc respawned 26 times in ~100 min
|
||||
(~$7.20) on an awaiting_documentation task with no valid verb — because
|
||||
``_pm_respawn_should_gate`` (progress-aware strikes, DB-durable, one-shot CEO
|
||||
notification) was consulted by only 3 of the ~10 task-keyed dispatch paths.
|
||||
The doc/QA/PR-gate/dev paths spawned unguarded at fixed cadence.
|
||||
|
||||
These tests pin the gate consultation on the previously-unguarded helpers:
|
||||
gate says skip → no spawn; gate says go → spawn proceeds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _orch(gate_result: bool) -> AgentOrchestrator:
|
||||
"""Orchestrator via __new__ with the gate + spawn stubbed."""
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
o = cast("Any", orch)
|
||||
o._pm_respawn_should_gate = AsyncMock(return_value=gate_result)
|
||||
o.spawn_agent = AsyncMock()
|
||||
o._resolve_agent_slug = lambda x: x
|
||||
o._is_agent_active = lambda _slug: False
|
||||
o._task_git_context = lambda _t: None
|
||||
o._build_doc_prompt = lambda _t: "doc prompt"
|
||||
o._build_qa_prompt = lambda _t: "qa prompt"
|
||||
o._build_pr_gate_prompt = lambda _t: "gate prompt"
|
||||
o._select_agent_for_cell = lambda _team, _role: "fe-pr-reviewer"
|
||||
o._is_task_handled_this_tick = lambda _tid: False
|
||||
return orch
|
||||
|
||||
|
||||
def _task(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": str(uuid4()),
|
||||
"status": "awaiting_documentation",
|
||||
"team": "frontend",
|
||||
"assigned_to": "fe-doc",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_respawn_consults_gate_and_skips_when_tripped() -> None:
|
||||
# The exact fe-doc loop path: assigned documenter, inactive, respawned
|
||||
# every tick. With the gate tripped, the spawn must be skipped.
|
||||
orch = _orch(gate_result=True)
|
||||
handled = await orch._respawn_doc_if_assigned(_task())
|
||||
assert handled is True # task stays handled (no auto-assign fallthrough)
|
||||
cast("Any", orch).spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_respawn_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
handled = await orch._respawn_doc_if_assigned(_task())
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigned_qa_consults_gate_and_skips_when_tripped() -> None:
|
||||
orch = _orch(gate_result=True)
|
||||
handled = await orch._spawn_assigned_qa(
|
||||
_task(status="awaiting_qa"), assigned_to="fe-qa"
|
||||
)
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigned_qa_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
handled = await orch._spawn_assigned_qa(
|
||||
_task(status="awaiting_qa"), assigned_to="fe-qa"
|
||||
)
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_gate_dispatch_consults_gate_and_skips_when_tripped() -> None:
|
||||
orch = _orch(gate_result=True)
|
||||
o = cast("Any", orch)
|
||||
o._fetch_tasks = AsyncMock(
|
||||
return_value=[_task(status="awaiting_pr_review", team="frontend")]
|
||||
)
|
||||
await orch._dispatch_pr_gate_work(MagicMock())
|
||||
o.spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_gate_dispatch_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
o = cast("Any", orch)
|
||||
o._fetch_tasks = AsyncMock(
|
||||
return_value=[_task(status="awaiting_pr_review", team="frontend")]
|
||||
)
|
||||
await orch._dispatch_pr_gate_work(MagicMock())
|
||||
o.spawn_agent.assert_awaited_once()
|
||||
@@ -8,7 +8,7 @@ forever. This re-spawns its owning PM so it re-coordinates the revision.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -21,6 +21,8 @@ def _orch(
|
||||
) -> tuple[AgentOrchestrator, AsyncMock]:
|
||||
"""A bare orchestrator with its dispatch helpers mocked; returns (orch, spawn)."""
|
||||
orch = object.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
spawn = AsyncMock()
|
||||
object.__setattr__(orch, "_fetch_tasks", AsyncMock(return_value=tasks))
|
||||
object.__setattr__(
|
||||
|
||||
@@ -687,6 +687,39 @@ def test_preview_batch_computes_waves_without_creating() -> None:
|
||||
assert isinstance(result["warnings"], list)
|
||||
|
||||
|
||||
def test_preview_batch_honours_declared_depends_on() -> None:
|
||||
"""B1b: a draft's declared depends_on becomes a real edge even when the
|
||||
collision surfaces are disjoint (the live S6 break: declared waves were
|
||||
dropped because the intends_to_touch globs didn't overlap)."""
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"]},
|
||||
{"title": "B", "intends_to_touch": ["b.py"], "depends_on": [0]},
|
||||
]
|
||||
result = service.preview_batch(drafts)
|
||||
assert result["waves"] == [[0], [1]]
|
||||
|
||||
|
||||
def test_preview_batch_coerces_string_declared_indices() -> None:
|
||||
"""The LLM sometimes emits depends_on indices as strings ("0")."""
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"]},
|
||||
{"title": "B", "intends_to_touch": ["b.py"], "depends_on": ["0"]},
|
||||
]
|
||||
result = service.preview_batch(drafts)
|
||||
assert result["waves"] == [[0], [1]]
|
||||
|
||||
|
||||
def test_preview_batch_rejects_out_of_range_declared_dep() -> None:
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"], "depends_on": [9]},
|
||||
]
|
||||
with pytest.raises(ValidationError):
|
||||
service.preview_batch(drafts)
|
||||
|
||||
|
||||
def test_preview_batch_rejects_empty() -> None:
|
||||
service = get_prompter_service()
|
||||
with pytest.raises(ValidationError):
|
||||
|
||||
@@ -429,3 +429,58 @@ def test_by_osmosis_skips_empty_predecessor_group() -> None:
|
||||
|
||||
def test_by_osmosis_no_edges_when_no_predecessor_groups() -> None:
|
||||
assert by_osmosis_tail_dev_tasks(True, []) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declared dependencies (B1b — the CEO's "Depends on" lists become real edges)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live break (S6, 2026-07-01): the draft declared depends-on S1+R2+R3 but only
|
||||
# the analyzer's file-overlap edges were wired, so S6 started 90s after
|
||||
# still-running R3. Declared edges are authoritative; derived edges remain the
|
||||
# safety net — analyze() takes the union.
|
||||
|
||||
|
||||
def test_declared_dependency_creates_edge_between_disjoint_surfaces() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False),
|
||||
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert (0, 1) in plan.edges
|
||||
assert _wave_of(plan.waves, 0) < _wave_of(plan.waves, 1)
|
||||
|
||||
|
||||
def test_declared_union_with_derived_dedupes() -> None:
|
||||
# Overlap already derives (0, 1) (idx 0 more important); declaring it too
|
||||
# must not duplicate the edge.
|
||||
s = [
|
||||
DraftSurface(0, 1, ["svc/threats.py"], False, False),
|
||||
DraftSurface(1, 2, ["svc/threats.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert plan.edges.count((0, 1)) == 1
|
||||
|
||||
|
||||
def test_declared_out_of_range_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(7,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
|
||||
def test_declared_self_dependency_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
|
||||
def test_declared_cycle_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(1,)),
|
||||
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
@@ -7,7 +7,7 @@ session boundary and checks the method's contract.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -700,6 +700,176 @@ async def test_admin_set_status_blocked_restore_attributes_admin_actor() -> None
|
||||
assert not any(r.agent_id == dev for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_blocked_to_review_state_clears_claim() -> None:
|
||||
"""Forcing a BLOCKED task into a review/queue state must clear the claim.
|
||||
|
||||
Live wedge (2026-07-01 22:13Z): the CEO forced blocked ->
|
||||
awaiting_pm_review, the stale escalation claim (main-pm) survived, and the
|
||||
respawned cell PM was handed the task by give_me_work while every
|
||||
note(task_id=...) bounced not_authorized "you do not hold the claim" — so
|
||||
it re-blocked. Review-state targets are re-claimed via the claim verbs, so
|
||||
the override must leave no stale claimant behind.
|
||||
"""
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
claimed_at=datetime.now(UTC),
|
||||
active_claimant_id=pm,
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_PM_REVIEW)
|
||||
assert out is task
|
||||
assert task.status == TaskStatus.AWAITING_PM_REVIEW
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
# The consumed snapshot must not survive to confuse a later unblock.
|
||||
assert task.pre_block_state is None
|
||||
assert task.pre_block_assignee is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_blocked_to_needs_revision_clears_claim() -> None:
|
||||
"""blocked -> needs_revision (the other live recovery target) also clears
|
||||
the claim so the revision coordinator / re-claiming dev starts clean."""
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
claimed_at=datetime.now(UTC),
|
||||
active_claimant_id=pm,
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.NEEDS_REVISION)
|
||||
assert out is task
|
||||
assert task.status == TaskStatus.NEEDS_REVISION
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_non_blocked_source_keeps_claim() -> None:
|
||||
"""The claim-clear fires only when leaving BLOCKED — a plain override on a
|
||||
non-blocked task (e.g. completing a reviewed task) must not strip the
|
||||
owner's claim."""
|
||||
owner = uuid4()
|
||||
claimed_at = datetime.now(UTC)
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=owner,
|
||||
claimed_by=owner,
|
||||
claimed_at=claimed_at,
|
||||
active_claimant_id=owner,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
await svc.admin_set_status(task.id, TaskStatus.COMPLETED)
|
||||
assert task.claimed_by == owner
|
||||
assert task.claimed_at == claimed_at
|
||||
assert task.active_claimant_id == owner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_routes_leaf_back_to_original_dev() -> None:
|
||||
"""PM merge-review reject: awaiting_pm_review -> needs_revision, issues
|
||||
appended for the dev, task re-owned by the original developer (the QA-fail
|
||||
routing), stale claimant cleared."""
|
||||
dev = uuid4()
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
active_claimant_id=pm,
|
||||
dev_notes=None,
|
||||
orchestration_markers={"original_developer": str(dev)},
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_validate_and_set_status", MagicMock())
|
||||
out = await svc.request_changes(
|
||||
pm, task.id, "scope violation", ["frontend/CLAUDE.md modified out of scope"]
|
||||
)
|
||||
assert out is task
|
||||
assert task.assigned_to == dev
|
||||
assert task.claimed_by == dev
|
||||
assert task.active_claimant_id is None
|
||||
assert "[PM REVIEW ISSUES]" in (task.dev_notes or "")
|
||||
assert "frontend/CLAUDE.md modified out of scope" in (task.dev_notes or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_without_dev_marker_routes_to_revision_pm() -> None:
|
||||
"""An assembled task (no original-developer marker) lands on the PM who
|
||||
owns its revision — same fallback pr_fail uses."""
|
||||
cell_pm = SimpleNamespace(id=uuid4())
|
||||
actor = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=actor,
|
||||
claimed_by=actor,
|
||||
active_claimant_id=actor,
|
||||
dev_notes=None,
|
||||
orchestration_markers=None,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_validate_and_set_status", MagicMock())
|
||||
_bind(svc, "_revision_pm_for_task", AsyncMock(return_value=cell_pm))
|
||||
out = await svc.request_changes(actor, task.id, "assembly issue", ["bad merge"])
|
||||
assert out is task
|
||||
assert task.assigned_to == cell_pm.id
|
||||
assert task.claimed_by == cell_pm.id
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejects_wrong_status() -> None:
|
||||
"""Only awaiting_pm_review is a valid source — anything else returns None
|
||||
(the gateway spec gate rejects earlier; this is the service backstop)."""
|
||||
task = _build_task(status=TaskStatus.IN_PROGRESS)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.request_changes(uuid4(), task.id, "notes", ["issue"])
|
||||
assert out is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_pre_block_restore_syncs_active_claimant() -> None:
|
||||
"""The pending/in_progress restore path re-owns the task to the pre-block
|
||||
dev — active_claimant_id must follow, or the restored dev's content writes
|
||||
bounce off the stale claimant exactly like the review-state wedge."""
|
||||
dev = uuid4()
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
active_claimant_id=pm,
|
||||
branch_name="feature/backend/abc--def",
|
||||
pre_block_state="in_progress",
|
||||
pre_block_assignee=dev,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
|
||||
assert out is task
|
||||
assert task.assigned_to == dev
|
||||
assert task.claimed_by == dev
|
||||
assert task.active_claimant_id == dev
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_block_restore_skips_revision_count_bump() -> None:
|
||||
"""#101 Gap B: restoring a blocked task to its snapshotted needs_revision
|
||||
|
||||
@@ -13,11 +13,11 @@ constraints = [{ name = "pyjwt", specifier = ">=2.13.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.6.2"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -147,7 +147,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.115.0"
|
||||
version = "0.115.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -159,9 +159,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/52/b9022707a25fd263b8ff5e11f480dcc9cd6da0a380c486e5be053c01d995/anthropic-0.115.0.tar.gz", hash = "sha256:6bb25184441d51544f8bc9877a7efbddd9bc4a6207cce898536d84b233310f4a", size = 949136, upload-time = "2026-06-30T19:47:33.848Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/e2/e27b1e70b1ddcda72362d6a26c9c699a46173d48a6c7ba966e8cc97a6c4d/anthropic-0.115.1.tar.gz", hash = "sha256:040287319abb909acf1cc49d83c0405dc0b1121ef257034b02c2b54151cf2446", size = 949185, upload-time = "2026-07-01T21:54:19.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/89/cd08c2fb41f10fc902593579cb81717ce0ce5489fdf9dcb8e04ea9825fb2/anthropic-0.115.0-py3-none-any.whl", hash = "sha256:aa4bbea9272fa1cced6749728f18d0af9d7934b7268779e84d6f5a246f4b998d", size = 960664, upload-time = "2026-06-30T19:47:32.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/3c/501c58a8f8c68811079e218a25d8672fe4fb9a650a4655e499e24d9375e9/anthropic-0.115.1-py3-none-any.whl", hash = "sha256:685fa94964c1b9428f6a76e42d0dbae49aa2016f5ad386a96becac04c5e80ef9", size = 957006, upload-time = "2026-07-01T21:54:17.392Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -819,19 +819,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.27.0"
|
||||
version = "40.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/7b/c62c98764137c949be240ad83f763b6f96cf76055952a3e2835359acc3af/faker-40.27.0.tar.gz", hash = "sha256:f697cf07f461474ad7d511164c21f45317e69f1d531d25f3e0f872b639e346a1", size = 2018361, upload-time = "2026-06-30T18:05:17.775Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/2d/3ead106cef42eab305e15f9c089dcc68a40387d5ce04af18585af4ebbb44/faker-40.28.1.tar.gz", hash = "sha256:2a63fb51abab8790636d4030a094cf942404cbccc9c288d1cad70e2e3e9ecd58", size = 2022717, upload-time = "2026-07-01T22:23:43.763Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/b2/788aae329da3d7e4f08f8e1a82e82243c3376c0f3f49b75ae29eea40b371/faker-40.27.0-py3-none-any.whl", hash = "sha256:6099bd6d7bc79041b46c28e100815e2558952bcf384b76ce6c71c8bdca744256", size = 2057897, upload-time = "2026-06-30T18:05:15.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a6/6111b9f13c1564b0e2f5dbeeb611fd00dc731e6add2336f62b798598c73d/faker-40.28.1-py3-none-any.whl", hash = "sha256:e8d3f5c469100a553d246dce7937c291308068a5ec6c9c3a228d7878b50720be", size = 2061052, upload-time = "2026-07-01T22:23:41.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.138.2"
|
||||
version = "0.139.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -840,14 +840,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-guard"
|
||||
version = "7.2.1"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
@@ -855,9 +855,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/6e/9f8b062c06c41cd38c12bc5b713bb26a4b6bb0482b681d72d147b99864a9/fastapi_guard-7.2.1.tar.gz", hash = "sha256:14773d799930452579270baa9026a86b8ee9e032a5815c7396a562186c573111", size = 34230, upload-time = "2026-07-01T08:12:10.388Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/93/0314334d4b74b86964e7ecd47257ec197ec4fc7f5db15eda2cad18af3580/fastapi_guard-7.2.2.tar.gz", hash = "sha256:b4819194570d9fe87beea92bbd47b1fbb6e20fbcedf5b3bce15df5ef61198a53", size = 34742, upload-time = "2026-07-01T11:01:18.653Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/90/cc88a2eac2b7ae04eb72ccf447c59004685ee1db409f7af0b407db099059/fastapi_guard-7.2.1-py3-none-any.whl", hash = "sha256:d45e8de2959da1031c18ac5e0f9937f3fc6a102c07b522aada02b880fcac2804", size = 14046, upload-time = "2026-07-01T08:12:09.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ea/8a13c80117a11ddc02f150e8dda23ff6b31621b9f4c0f2a204db8b3da1ab/fastapi_guard-7.2.2-py3-none-any.whl", hash = "sha256:5cb3f48aab6d269d88a34bd2a90554f243d77838ce8790afa62de403aa7289dc", size = 14137, upload-time = "2026-07-01T11:01:17.456Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1057,7 +1057,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "guard-core"
|
||||
version = "3.3.0"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1068,9 +1068,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/88/f9796b0af9c0b4f99c51a7d1ccd9c71c3f795da9fa3dbda020d162697122/guard_core-3.3.0.tar.gz", hash = "sha256:ad68023f81abf36b0e64847d4c34d241f81475bc44537b26ed6d2a38e7fff98a", size = 182752, upload-time = "2026-07-01T03:40:04.716Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/bb/97ce44ba282bcb60bdca1950b0499708477af81c700b3cb44a082590d662/guard_core-3.4.0.tar.gz", hash = "sha256:11fda317276881f1f9ff9753bb9dcd78a47633f493a465c0fec4e1dfd5be29a6", size = 185595, upload-time = "2026-07-02T02:11:28.446Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4c/0b313865c38a284ac50c4207e9d9451c947141d6c6f27363fe48d6b588e5/guard_core-3.3.0-py3-none-any.whl", hash = "sha256:c0884c68debf64c82ac216a46eaa5a8ff0a72132e714a43b3b4c7145e9ae8b65", size = 233150, upload-time = "2026-07-01T03:40:03.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/21/c9642468c439e4bab9f7e85ace789009347b1afea594d33457dd9f93cbf5/guard_core-3.4.0-py3-none-any.whl", hash = "sha256:f791e4a6f3db51bb1a21776ca7eff1494223e0507412e8ae95a0862891505884", size = 234930, upload-time = "2026-07-02T02:11:27.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user