fix(gateway): working exits for wedged agents + declare_coverage roll-up unblock (#341)

A live task burned 5+ hours because every exit was locked. unclaim now
works from verifying and needs_revision (service guard + lifecycle edge);
the circuit breaker and the i_am_done push-failure remediate name the
working chain ending in unclaim(); sync_branch(stash=true) clears the
DIRTY_WORKSPACE dead-end (pop-conflict preserves the stash); blocking a
task QA already owns now says to idle instead of listing states; the
orchestrator auto-block logs real errors and skips states where blocking
is meaningless instead of force-blocking them.

declare_coverage (cell/main PM) retroactively stamps parent-AC refs on a
child that implements them -- closing the roll-up deadlock where the
declaring child was cancelled and its re-delegated replacement completed
the work uncredited. Cancelling a ref-declaring child now warns and
surfaces the orphaned criteria.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 21:40:22 +02:00
committed by GitHub
co-authored by Renn F
parent 889d48b99b
commit f48d088c08
34 changed files with 1514 additions and 69 deletions
+1
View File
@@ -8,6 +8,7 @@
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
+1 -1
View File
@@ -14,7 +14,7 @@
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` | | `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
| `open_pr` | `open_pr(task_id: UUID)` | | `open_pr` | `open_pr(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` | | `resume` | `resume(task_id: UUID)` |
| `sync_branch` | `sync_branch(task_id: UUID)` | | `sync_branch` | `sync_branch(task_id: UUID, stash: bool = False)` |
| `unclaim` | `unclaim(task_id: UUID)` | | `unclaim` | `unclaim(task_id: UUID)` |
### Content (do) tools ### Content (do) tools
@@ -4,6 +4,7 @@ These are the only verbs the gateway will accept from you. Calling any
other verb will be rejected with a Decision telling you the right one. other verb will be rejected with a Decision telling you the right one.
- **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here. - **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here.
- **declare_coverage**: Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact — for a replacement child whose delegate omitted covers_parent_criteria. No status change; the verb body owns ownership + criterion validation.
- **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA. - **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA.
- **escalate_up**: Escalate to your role's escalation_target. - **escalate_up**: Escalate to your role's escalation_target.
- **give_me_work**: Return your most-actionable task or signal idle. - **give_me_work**: Return your most-actionable task or signal idle.
@@ -10,5 +10,5 @@ other verb will be rejected with a Decision telling you the right one.
- **i_will_work_on**: Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation. - **i_will_work_on**: Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.
- **open_pr**: Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done. - **open_pr**: Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.
- **resume**: Resume a paused task you own. paused -> in_progress. - **resume**: Resume a paused task you own. paused -> in_progress.
- **sync_branch**: Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again. - **sync_branch**: Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.
- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. A PR reviewer who claimed an external review (in_progress) or a gate review (awaiting_pr_review) and cannot finish releases the claim here rather than wedging the lane until the stale-claim reaper. - **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. A PR reviewer who claimed an external review (in_progress) or a gate review (awaiting_pr_review) and cannot finish releases the claim here rather than wedging the lane until the stale-claim reaper.
@@ -4,6 +4,7 @@ These are the only verbs the gateway will accept from you. Calling any
other verb will be rejected with a Decision telling you the right one. other verb will be rejected with a Decision telling you the right one.
- **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here. - **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here.
- **declare_coverage**: Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact — for a replacement child whose delegate omitted covers_parent_criteria. No status change; the verb body owns ownership + criterion validation.
- **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA. - **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA.
- **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval. - **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval.
- **escalate_up**: Escalate to your role's escalation_target. - **escalate_up**: Escalate to your role's escalation_target.
+1
View File
@@ -8,6 +8,7 @@
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` | | `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
+3 -1
View File
@@ -23,7 +23,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` | | `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
| `open_pr` | `open_pr(task_id: UUID)` | | `open_pr` | `open_pr(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` | | `resume` | `resume(task_id: UUID)` |
| `sync_branch` | `sync_branch(task_id: UUID)` | | `sync_branch` | `sync_branch(task_id: UUID, stash: bool = False)` |
| `unclaim` | `unclaim(task_id: UUID)` | | `unclaim` | `unclaim(task_id: UUID)` |
### Content (do) tools ### Content (do) tools
@@ -113,6 +113,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
@@ -149,6 +150,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` | | `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
+10 -1
View File
@@ -47,6 +47,15 @@ Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR in
**Composes:** complete **Composes:** complete
## declare_coverage
Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact — for a replacement child whose delegate omitted covers_parent_criteria. No status change; the verb body owns ownership + criterion validation.
**Allowed roles:** cell_pm, main_pm
**Composes:** (no atomic actions)
## delegate ## delegate
Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA. Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA.
@@ -256,7 +265,7 @@ Cell PM opens the cell→root PR and moves the cell task into the PR-review gate
## sync_branch ## sync_branch
Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again. Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.
**Allowed roles:** developer **Allowed roles:** developer
+9 -1
View File
@@ -60,6 +60,14 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool.
**Fix (PMs, for a cell/root integration branch — NOT a dev leaf):** `escalate_up(task_id, reason='branch behind base — needs rebase')` — bringing an integration branch current is a platform action. A dev's own leaf branch is the dev's to `sync_branch`. **Fix (PMs, for a cell/root integration branch — NOT a dev leaf):** `escalate_up(task_id, reason='branch behind base — needs rebase')` — bringing an integration branch current is a platform action. A dev's own leaf branch is the dev's to `sync_branch`.
## DIRTY_WORKSPACE on sync_branch
**Error:** `sync_branch failed: DIRTY_WORKSPACE: Cannot rebase with uncommitted changes.`
**Cause:** `sync_branch` refuses to rebase over uncommitted edits by default (a `git reset --hard` mid-rebase would discard them).
**Fix:** Either `commit(message=...)` (omit `files` to stage everything) then `sync_branch(task_id)` again, OR call `sync_branch(task_id, stash=True)` to auto-stash (tracked + untracked), rebase, and restore your changes in one call. If the stash pop conflicts, the envelope's `next` tells you so — your stash is preserved (never dropped); resolve by hand and `commit(...)`. Still stuck after that? `unclaim(task_id)` releases the claim back to the pool rather than looping.
## src refspec does not match any (during open_pr) ## src refspec does not match any (during open_pr)
**Error:** `src refspec '<branch>' does not match any` **Error:** `src refspec '<branch>' does not match any`
@@ -72,7 +80,7 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool.
**Cause:** Your branch is behind its remote, so the push can't fast-forward. **Cause:** Your branch is behind its remote, so the push can't fast-forward.
**Fix (devs):** Call `sync_branch(task_id)` to rebase onto your base through the gate, then retry the push-bearing verb (`open_pr` / `i_am_done`). **PMs** escalate: `escalate_up(...)`. There is no agent-layer pull — `sync_branch` is the dev's rebase path. **Fix (devs):** Call `sync_branch(task_id)` to rebase onto your base through the gate, then retry the push-bearing verb (`open_pr` / `i_am_done`). If `sync_branch` itself refuses with DIRTY_WORKSPACE, see that section below (`commit(...)` then retry, or `sync_branch(task_id, stash=True)`). **PMs** escalate: `escalate_up(...)`. There is no agent-layer pull — `sync_branch` is the dev's rebase path. Still stuck after trying both? `unclaim(task_id)` releases the claim back to the pool rather than looping on `i_am_done`.
## NO_PR on pass / fail ## NO_PR on pass / fail
+12 -1
View File
@@ -85,6 +85,17 @@
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [],
"description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. No status change; the verb body owns ownership + criterion validation.",
"name": "declare_coverage",
"pre_side_effects": [],
"side_effects": []
},
{ {
"allowed_roles": [ "allowed_roles": [
"cell_pm", "cell_pm",
@@ -371,7 +382,7 @@
"developer" "developer"
], ],
"composes": [], "composes": [],
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again.", "description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
"name": "sync_branch", "name": "sync_branch",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
+6 -4
View File
@@ -570,8 +570,9 @@ def _check_verb_circuit(verb: str, task_id: str | None) -> dict[str, Any] | None
remediate=( remediate=(
f"verb {verb!r} has been rejected {count} times in " f"verb {verb!r} has been rejected {count} times in "
f"{_VERB_ATTEMPT_WINDOW_S}s. Stop retrying. Call " f"{_VERB_ATTEMPT_WINDOW_S}s. Stop retrying. Call "
"i_am_blocked(reason='unable to satisfy gate after N attempts') " "i_am_blocked(reason='unable to satisfy gate after N attempts'), "
"or i_am_idle() to release the claim. The PM will pick it up." "unclaim() to release the claim back to the pool, or i_am_idle() "
"if you hold no claim. The PM will pick it up."
), ),
) )
return env.as_dict() return env.as_dict()
@@ -618,8 +619,9 @@ def _check_verb_absolute_circuit(
remediate=( remediate=(
f"verb {verb!r} has been rejected {count} times this session " f"verb {verb!r} has been rejected {count} times this session "
f"(absolute cap {cap}, regardless of pacing). Stop retrying. Call " f"(absolute cap {cap}, regardless of pacing). Stop retrying. Call "
"i_am_blocked(reason='unable to satisfy gate after N attempts') " "i_am_blocked(reason='unable to satisfy gate after N attempts'), "
"or i_am_idle() to release the claim. The PM will pick it up." "unclaim() to release the claim back to the pool, or i_am_idle() "
"if you hold no claim. The PM will pick it up."
), ),
) )
return env.as_dict() return env.as_dict()
+15
View File
@@ -10,6 +10,7 @@ from roboco.api.deps import get_choreographer
from roboco.api.routes.v1._role_dep import envelope_to_response, require_cell_pm from roboco.api.routes.v1._role_dep import envelope_to_response, require_cell_pm
from roboco.api.schemas.v1.flow import ( from roboco.api.schemas.v1.flow import (
CompleteRequest, CompleteRequest,
DeclareCoverageRequest,
DelegateRequest, DelegateRequest,
EscalateUpRequest, EscalateUpRequest,
GiveMeWorkRequest, GiveMeWorkRequest,
@@ -238,6 +239,20 @@ async def resume(
return envelope_to_response(env, request) return envelope_to_response(env, request)
@router.post("/declare_coverage")
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
async def declare_coverage(
request: Request,
body: DeclareCoverageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.declare_coverage(x_agent_id, body.task_id, body.criteria)
return envelope_to_response(env, request)
@router.post("/i_am_idle") @router.post("/i_am_idle")
@guard_deco.rate_limit(requests=30, window=60) @guard_deco.rate_limit(requests=30, window=60)
@guard_deco.content_type_filter(["application/json"]) @guard_deco.content_type_filter(["application/json"])
+1 -1
View File
@@ -159,7 +159,7 @@ async def sync_branch(
x_agent_id: _AgentIdHeader, x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep, choreographer: _ChoreographerDep,
) -> dict: ) -> dict:
env = await choreographer.sync_branch(x_agent_id, body.task_id) env = await choreographer.sync_branch(x_agent_id, body.task_id, stash=body.stash)
return envelope_to_response(env, request) return envelope_to_response(env, request)
+15
View File
@@ -10,6 +10,7 @@ from roboco.api.deps import get_choreographer
from roboco.api.routes.v1._role_dep import envelope_to_response, require_main_pm from roboco.api.routes.v1._role_dep import envelope_to_response, require_main_pm
from roboco.api.schemas.v1.flow import ( from roboco.api.schemas.v1.flow import (
CompleteRequest, CompleteRequest,
DeclareCoverageRequest,
DelegateRequest, DelegateRequest,
EscalateToCeoRequest, EscalateToCeoRequest,
EscalateUpRequest, EscalateUpRequest,
@@ -277,3 +278,17 @@ async def i_am_idle(
) -> dict: ) -> dict:
env = await choreographer.i_am_idle(x_agent_id) env = await choreographer.i_am_idle(x_agent_id)
return envelope_to_response(env, request) return envelope_to_response(env, request)
@router.post("/declare_coverage")
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
async def declare_coverage(
request: Request,
body: DeclareCoverageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.declare_coverage(x_agent_id, body.task_id, body.criteria)
return envelope_to_response(env, request)
+17
View File
@@ -143,6 +143,19 @@ class ReassignRequest(BaseModel):
new_assignee: str = Field(..., min_length=1) new_assignee: str = Field(..., min_length=1)
class DeclareCoverageRequest(BaseModel):
"""HTTP body for the cell_pm/main_pm `declare_coverage` verb.
``task_id`` is the CHILD to stamp; ``criteria`` are parent acceptance
criteria (id or exact text same representation as `delegate`'s
`covers_parent_criteria`). The choreographer validates ownership +
unknown criteria.
"""
task_id: UUID
criteria: StrList = Field(..., min_length=1)
class ResumeRequest(BaseModel): class ResumeRequest(BaseModel):
task_id: UUID task_id: UUID
@@ -156,6 +169,10 @@ class SyncBranchRequest(BaseModel):
""" """
task_id: UUID task_id: UUID
# Auto-stash (tracked + untracked) instead of refusing DIRTY_WORKSPACE;
# popped back after the rebase. Default False preserves the prior refuse
# behavior for callers that don't opt in.
stash: bool = False
class IAmIdleRequest(BaseModel): class IAmIdleRequest(BaseModel):
+7
View File
@@ -180,6 +180,13 @@ class TaskTable(Base):
ARRAY(String), nullable=False, default=list ARRAY(String), nullable=False, default=list
) )
if TYPE_CHECKING:
# ponytail: transient, non-persisted — set only by TaskService.cancel()
# to surface orphaned parent-AC coverage without a response-schema
# change. Declared here only so mypy accepts the attribute; SQLAlchemy
# never sees it (TYPE_CHECKING is False at runtime).
orphaned_parent_acs: list[str] | None
# Status # Status
status: Mapped[TaskStatus] = mapped_column( status: Mapped[TaskStatus] = mapped_column(
_str_enum(TaskStatus), nullable=False, default=TaskStatus.PENDING, index=True _str_enum(TaskStatus), nullable=False, default=TaskStatus.PENDING, index=True
+7 -3
View File
@@ -87,13 +87,17 @@ _LEGACY_OPERATIONAL_EDGES: dict[Status, frozenset[Status]] = {
# in ROLE_RESTRICTED_TRANSITIONS below). The canonical exit is submit_qa # in ROLE_RESTRICTED_TRANSITIONS below). The canonical exit is submit_qa
# -> awaiting_qa -> (qa_pass) -> awaiting_documentation; a direct # -> awaiting_qa -> (qa_pass) -> awaiting_documentation; a direct
# verifying->awaiting_documentation edge would bypass the QA review hop. # verifying->awaiting_documentation edge would bypass the QA review hop.
Status.VERIFYING: frozenset({Status.NEEDS_REVISION}), # PENDING is voluntary unclaim (TaskService.unclaim_for_agent) — a dev
# mid self-verification is still a claim it can hand back.
Status.VERIFYING: frozenset({Status.NEEDS_REVISION, Status.PENDING}),
# QA can park a task as blocked while waiting on dev clarification. # QA can park a task as blocked while waiting on dev clarification.
Status.AWAITING_QA: frozenset({Status.BLOCKED}), Status.AWAITING_QA: frozenset({Status.BLOCKED}),
# PM claim + PM reject path on review queue. # PM claim + PM reject path on review queue.
Status.AWAITING_PM_REVIEW: frozenset({Status.CLAIMED, Status.NEEDS_REVISION}), Status.AWAITING_PM_REVIEW: frozenset({Status.CLAIMED, Status.NEEDS_REVISION}),
# Re-entry from revision back into active dev work (without re-claim). # Re-entry from revision back into active dev work (without re-claim), or
Status.NEEDS_REVISION: frozenset({Status.IN_PROGRESS}), # voluntary unclaim back to the pool (TaskService.unclaim_for_agent) — a
# dev sent back for revision otherwise had no legal exit but in_progress.
Status.NEEDS_REVISION: frozenset({Status.IN_PROGRESS, Status.PENDING}),
} }
# Role pins for legacy operational edges. Same shape as the spec-derived # Role pins for legacy operational edges. Same shape as the spec-derived
+31 -1
View File
@@ -1236,7 +1236,9 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
" while you worked. Fetches origin, rebases head onto base, and" " while you worked. Fetches origin, rebases head onto base, and"
" force-pushes (with-lease). No DB state change. On conflicts the" " force-pushes (with-lease). No DB state change. On conflicts the"
" rebase is aborted and the conflicted files are returned — resolve" " rebase is aborted and the conflicted files are returned — resolve"
" by hand, commit, then sync_branch again." " by hand, commit, then sync_branch again. Pass stash=True to"
" auto-stash uncommitted changes instead of refusing"
" DIRTY_WORKSPACE; they are restored after the rebase."
), ),
composes=(), # git-only verb — no DB transition; the handler runs the git op composes=(), # git-only verb — no DB transition; the handler runs the git op
extra_preconditions=( extra_preconditions=(
@@ -1290,6 +1292,22 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
"reassigned; the new developer will be respawned to continue" "reassigned; the new developer will be respawned to continue"
), ),
), ),
"declare_coverage": IntentSpec(
name="declare_coverage",
allowed_roles=_PM_ROLES,
description=(
"Stamp parent acceptance criteria onto an existing child's"
" parent_ac_refs after the fact — for a replacement child whose"
" delegate omitted covers_parent_criteria. No status change; the"
" verb body owns ownership + criterion validation."
),
composes=(), # special — no transition, just an AC-ref write + audit
extra_preconditions=(),
side_effects=(),
next_hint=lambda _t: (
"coverage declared; check evidence.remaining_uncovered_parent_acs"
),
),
"resume": IntentSpec( "resume": IntentSpec(
name="resume", name="resume",
allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES),
@@ -1604,6 +1622,12 @@ def _invalid_source_remediate(
i_documented. A documenter on a revision pass whose docs already exist 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 must re-affirm them, not bail; the generic hint fed the live 26-respawn
fe-doc loop (2026-07-02). fe-doc loop (2026-07-02).
awaiting_qa bail special case: a dev whose task moved on to QA while it
was still trying to call i_am_blocked isn't stuck — its part is DONE. The
generic "find a task in [...]" hint reads as a dead end (a live 5h+ wedge
incident: the dev kept retrying i_am_blocked against a task QA already
owned). Tell it the truth and point at the real exit.
""" """
if status is Status.AWAITING_DOCUMENTATION and action == "block": if status is Status.AWAITING_DOCUMENTATION and action == "block":
return ( return (
@@ -1613,6 +1637,12 @@ def _invalid_source_remediate(
"notes='verified existing docs are complete and accurate') " "notes='verified existing docs are complete and accurate') "
"to re-affirm them — do NOT retry i_am_blocked/unclaim." "to re-affirm them — do NOT retry i_am_blocked/unclaim."
) )
if status is Status.AWAITING_QA and action == "block":
return (
"your part is done — this task already moved to QA review; you "
"are not blocked on it anymore. Call i_am_idle() to pick up new "
"work. Do NOT retry i_am_blocked/unclaim against a task QA owns."
)
return ( return (
f"call give_me_work() to find a task in" f"call give_me_work() to find a task in"
f" {sorted(s.value for s in spec_action.source_statuses)}" f" {sorted(s.value for s in spec_action.source_statuses)}"
+3
View File
@@ -383,6 +383,9 @@ VERBS_WITHOUT_TRACING: frozenset[str] = frozenset(
"i_am_idle", # signal only "i_am_idle", # signal only
"unclaim", # voluntary release; no rationale required "unclaim", # voluntary release; no rationale required
"reassign", # mechanical intra-cell hand-off; branch/WIP preserved "reassign", # mechanical intra-cell hand-off; branch/WIP preserved
# declare_coverage is a mechanical AC-ref stamp + its own audit row
# (task.coverage_declared) — no journal rationale required.
"declare_coverage",
"resume", # pure state move paused→in_progress "resume", # pure state move paused→in_progress
# claim_review's tracing applies on pass_review / fail_review. # claim_review's tracing applies on pass_review / fail_review.
"claim_review", "claim_review",
+28 -2
View File
@@ -646,7 +646,7 @@ def resume(task_id: str) -> dict[str, Any]:
return _post(_role_path("resume"), {"task_id": task_id}) return _post(_role_path("resume"), {"task_id": task_id})
def sync_branch(task_id: str) -> dict[str, Any]: def sync_branch(task_id: str, stash: bool = False) -> dict[str, Any]:
"""Re-sync your branch onto its base through the gate. """Re-sync your branch onto its base through the gate.
Rebases the task's branch onto its resolved parent/base branch (fetch + Rebases the task's branch onto its resolved parent/base branch (fetch +
@@ -657,8 +657,16 @@ def sync_branch(task_id: str) -> dict[str, Any]:
i_am_done as normal. On ``conflicts`` status the envelope's ``next`` tells i_am_done as normal. On ``conflicts`` status the envelope's ``next`` tells
you the rebase aborted and your branch is unchanged resolve the conflict you the rebase aborted and your branch is unchanged resolve the conflict
in your working tree first (the gate does not force a conflicted rebase). in your working tree first (the gate does not force a conflicted rebase).
Args:
task_id: UUID of the task whose branch you're re-syncing.
stash: If your workspace has uncommitted changes, pass True to
auto-stash them (tracked + untracked), rebase, then restore them
instead of refusing DIRTY_WORKSPACE. A conflicted restore
leaves the stash in place (never dropped); the envelope's
``next`` tells you to resolve it by hand.
""" """
return _post(_role_path("sync_branch"), {"task_id": task_id}) return _post(_role_path("sync_branch"), {"task_id": task_id, "stash": stash})
def i_am_idle() -> dict[str, Any]: def i_am_idle() -> dict[str, Any]:
@@ -775,6 +783,23 @@ def unblock(task_id: str, reason: str, restore: bool = True) -> dict[str, Any]:
) )
def declare_coverage(task_id: str, criteria: StrList) -> dict[str, Any]:
"""PM: stamp parent acceptance criteria onto an existing child (task_id).
Use when a completed subtask already implements a parent AC but was
delegated without `covers_parent_criteria` (e.g. it's a replacement for a
cancelled sibling) the roll-up gate (submit_up/submit_root) keeps
demanding coverage otherwise. `criteria` are the parent's acceptance
criteria, by id or exact text (copy them straight out of the gate's
rejection listing). Returns evidence.remaining_uncovered_parent_acs so you
know if submit_up will now pass.
"""
return _post(
_role_path("declare_coverage"),
{"task_id": task_id, "criteria": criteria},
)
def complete(task_id: str, notes: str) -> dict[str, Any]: def complete(task_id: str, notes: str) -> dict[str, Any]:
"""PM: complete a task. Cell PM auto-merges PR; Main PM opens PR + escalates.""" """PM: complete a task. Cell PM auto-merges PR; Main PM opens PR + escalates."""
return _post(_role_path("complete"), {"task_id": task_id, "notes": notes}) return _post(_role_path("complete"), {"task_id": task_id, "notes": notes})
@@ -986,6 +1011,7 @@ _TOOLS: dict[str, Any] = {
"delegate": delegate, "delegate": delegate,
"submit_up": submit_up, "submit_up": submit_up,
"submit_root": submit_root, "submit_root": submit_root,
"declare_coverage": declare_coverage,
# board / main pm # board / main pm
"escalate_to_ceo": escalate_to_ceo, "escalate_to_ceo": escalate_to_ceo,
} }
+51 -2
View File
@@ -465,6 +465,23 @@ class _SecretaryRunSpec:
# spawn taskless, so they are NOT flagged (#11). # spawn taskless, so they are NOT flagged (#11).
_TASKLESS_SPAWN_SUSPECT_ROLES = frozenset({"developer", "qa", "documenter"}) _TASKLESS_SPAWN_SUSPECT_ROLES = frozenset({"developer", "qa", "documenter"})
# Statuses where `_auto_block_task` forcing "blocked" is meaningless — the
# task already moved past the caller's control to a reviewer/terminal state.
# Forcing it back to "blocked" from here would yank it out from under
# whoever now owns it instead of skipping a no-op.
_AUTO_BLOCK_SKIP_STATUSES = frozenset(
{
"awaiting_qa",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
"awaiting_ceo_approval",
"completed",
"cancelled",
"blocked",
}
)
def is_unattributed_delivery_spawn(role: str, task_id: str | None) -> bool: def is_unattributed_delivery_spawn(role: str, task_id: str | None) -> bool:
"""True when a delivery-role spawn carries no ``task_id`` (#11). """True when a delivery-role spawn carries no ``task_id`` (#11).
@@ -9291,7 +9308,36 @@ Start by:
async def _auto_block_task( async def _auto_block_task(
self, client: httpx.AsyncClient, task_id: str, reason: str self, client: httpx.AsyncClient, task_id: str, reason: str
) -> None: ) -> None:
"""Auto-block a task that cannot proceed due to missing prerequisites.""" """Auto-block a task that cannot proceed due to missing prerequisites.
Re-checks live status first: every caller's view of the task can be
stale by the time this runs (a spawn-readiness check that raced a
reassignment, a dead container whose task was already picked up and
submitted for QA). Blocking is meaningless once the task moved past
the caller's control — it skips with an info log instead of yanking
a task out from under whoever now owns it.
"""
try:
resp = await client.get(f"{self._api_url}/tasks/{task_id}")
if (
resp.is_success
and resp.json().get("status") in _AUTO_BLOCK_SKIP_STATUSES
):
logger.info(
"Skipped auto-block: task already past dev control",
task_id=task_id,
status=resp.json().get("status"),
reason=reason,
)
return
except Exception as e:
# A pre-check failure must not swallow the block attempt itself —
# fall through and try the PATCH as before.
logger.debug(
"Auto-block status pre-check failed, proceeding anyway",
task_id=task_id,
error=str(e) or repr(e),
)
try: try:
await client.patch( await client.patch(
f"{self._api_url}/tasks/{task_id}", f"{self._api_url}/tasks/{task_id}",
@@ -9306,10 +9352,13 @@ Start by:
reason=reason, reason=reason,
) )
except Exception as e: except Exception as e:
# str(e) is empty for some exception types (e.g. a bare
# asyncio.TimeoutError) — repr always names the class, so the
# fallback guarantees the log line is never blank.
logger.error( logger.error(
"Failed to auto-block task", "Failed to auto-block task",
task_id=task_id, task_id=task_id,
error=str(e), error=str(e) or repr(e),
) )
async def _auto_resume_paused_parent( async def _auto_resume_paused_parent(
+191 -19
View File
@@ -1240,7 +1240,9 @@ class Choreographer:
f"{len(uncovered)} parent acceptance criteria are not covered by a " f"{len(uncovered)} parent acceptance criteria are not covered by a "
f"completed subtask before {context_phrase}: {listing}. Delegate " f"completed subtask before {context_phrase}: {listing}. Delegate "
"(or reassign) subtasks covering them and let those pass QA + " "(or reassign) subtasks covering them and let those pass QA + "
"complete first." "complete first. If a completed subtask already implements a "
"criterion, stamp it: declare_coverage(task_id=<child>, "
"criteria=[...])."
), ),
context_briefing=await self._briefing_for(agent_id, task_id), context_briefing=await self._briefing_for(agent_id, task_id),
) )
@@ -2317,7 +2319,12 @@ class Choreographer:
"your latest commits are local-only and QA reviews the " "your latest commits are local-only and QA reviews the "
"pushed PR branch. resolve the push error (often a " "pushed PR branch. resolve the push error (often a "
"transient network / fetch timeout) and call i_am_done " "transient network / fetch timeout) and call i_am_done "
"again." "again. if it says your branch is behind its remote "
"counterpart, call sync_branch() to rebase + force-push. "
"if sync_branch reports a dirty workspace, commit(...) "
"(omit files to stage everything) THEN sync_branch() "
"THEN i_am_done() again. still stuck? unclaim() releases "
"the task back to the pool."
), ),
context_briefing=ctx.briefing, context_briefing=ctx.briefing,
) )
@@ -3559,7 +3566,8 @@ class Choreographer:
"from awaiting_documentation" "from awaiting_documentation"
if str(t.status) == "awaiting_documentation" if str(t.status) == "awaiting_documentation"
else "only a task assigned to you in pending / claimed" else "only a task assigned to you in pending / claimed"
" / in_progress can be unclaimed" " / in_progress / verifying / needs_revision can be"
" unclaimed"
), ),
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=t, role=role_str), ).with_introspection(task=t, role=role_str),
@@ -3714,6 +3722,137 @@ class Choreographer:
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=after, role=role_str) ).with_introspection(task=after, role=role_str)
async def _declare_coverage_guard(
self, pm_agent_id: UUID, child: Any, agent: Any, briefing: dict[str, Any]
) -> tuple[Envelope | None, Any]:
"""Role + parent-presence + ownership guard for ``declare_coverage``.
Returns ``(rejection, parent)`` ``parent`` is non-``None`` only
when ``rejection`` is ``None``. Ownership mirrors submit_up (the PM
assigned to the parent coordination task), with a fallback for any
PM on the child's own team — the minimum bar the spec asks for.
"""
if agent is None or agent.role not in ("cell_pm", "main_pm"):
return (
Envelope.not_authorized(
message="declare_coverage is reserved for PM roles",
remediate="only a cell PM or main PM may declare coverage",
context_briefing=briefing,
),
None,
)
if not child.parent_task_id:
return (
Envelope.invalid_state(
message="task has no parent; nothing to cover",
remediate=(
"declare_coverage only applies to a decomposition"
" child (a subtask with a parent coordination task)"
),
context_briefing=briefing,
),
None,
)
parent = await self.task.get(child.parent_task_id)
if parent is None:
return (
Envelope.invalid_state(
message="parent task not found",
remediate="the parent task may have been deleted; escalate",
context_briefing=briefing,
),
None,
)
child_team = getattr(child.team, "value", child.team)
agent_team = getattr(agent, "team", None)
agent_team_val = getattr(agent_team, "value", agent_team)
owns_parent = parent.assigned_to == pm_agent_id
on_child_team = child_team is not None and child_team == agent_team_val
if not owns_parent and not on_child_team:
return (
Envelope.not_authorized(
message="not the parent's PM and not on the child's team",
remediate=(
"declare_coverage requires owning the parent"
" coordination task or being a PM on the child's team"
),
context_briefing=briefing,
),
None,
)
return None, parent
async def declare_coverage(
self, pm_agent_id: UUID, task_id: UUID, criteria: list[str]
) -> Envelope:
"""PM stamps parent acceptance criteria onto an existing child.
Fixes the roll-up-gate deadlock where a replacement child
delegated without ``covers_parent_criteria`` finishes a parent
AC's real work but ``_parent_acs_covered_envelope`` still shows it
uncovered, since only ``delegate`` writes ``parent_ac_refs`` today.
``criteria`` takes the same id-or-text representation
``covers_parent_criteria`` does (``TaskService._normalize_ac_refs``
resolves it at read time), so a PM can copy straight out of the
gate's own uncovered-criteria listing. ``task_id`` is the CHILD
(any non-cancelled status the live case is a completed child).
"""
child = await self.task.get(task_id)
briefing = await self._briefing_for(pm_agent_id, task_id, task=child)
if child 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="declare_coverage",
)
agent = await self.task.agent_for(pm_agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm"
rejection, parent = await self._declare_coverage_guard(
pm_agent_id, child, agent, briefing
)
if rejection is not None:
return await self._emit_rejection(
rejection.with_introspection(task=child, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="declare_coverage",
)
unknown = self.task.unknown_ac_refs(parent, criteria)
if unknown:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"unknown criteria: {'; '.join(unknown)}",
remediate=(
"criteria must match a parent acceptance criterion"
" (id or exact text): "
f"{'; '.join(parent.acceptance_criteria or [])}"
),
context_briefing=briefing,
).with_introspection(task=child, role=role_str),
agent_id=pm_agent_id,
task_id=task_id,
verb="declare_coverage",
)
updated = await self.task.add_parent_ac_refs(
task_id, criteria, declared_by=pm_agent_id
)
uncovered = await self.task.uncovered_parent_acceptance_criteria(
child.parent_task_id
)
next_hint = (
"submit_up's roll-up gate will pass on the parent now"
if not uncovered
else f"{len(uncovered)} parent ACs still uncovered: {'; '.join(uncovered)}"
)
return Envelope.ok(
status=str((updated or child).status),
task_id=str(task_id),
next=next_hint,
evidence={"remaining_uncovered_parent_acs": uncovered},
context_briefing=briefing,
).with_introspection(task=updated or child, role=role_str)
async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope: async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Resume a paused task this agent owns; transitions paused → in_progress. """Resume a paused task this agent owns; transitions paused → in_progress.
@@ -3822,7 +3961,9 @@ class Choreographer:
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=after, role=role_str) ).with_introspection(task=after, role=role_str)
async def sync_branch(self, agent_id: UUID, task_id: UUID) -> Envelope: async def sync_branch(
self, agent_id: UUID, task_id: UUID, stash: bool = False
) -> Envelope:
"""Rebase the caller's task branch onto its current base THROUGH the gate. """Rebase the caller's task branch onto its current base THROUGH the gate.
Raw shell git is denied to agents (the ``Bash(git:*)`` base deny), so a Raw shell git is denied to agents (the ``Bash(git:*)`` base deny), so a
@@ -3837,6 +3978,11 @@ class Choreographer:
guards branch + base, then runs the git op. Conflicts abort the rebase guards branch + base, then runs the git op. Conflicts abort the rebase
(no force-push) and return the conflicted files resolve by hand, (no force-push) and return the conflicted files resolve by hand,
commit, then sync_branch again. commit, then sync_branch again.
``stash=True`` auto-stashes uncommitted changes (tracked + untracked)
before rebasing and pops them back after, instead of refusing
DIRTY_WORKSPACE the dev-facing dead end where the prescribed fix
(stage/commit) required a raw ``git`` agents are denied.
""" """
t = await self.task.get(task_id) 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)
@@ -3854,16 +4000,23 @@ class Choreographer:
) )
try: try:
result = await self.git.sync_task_branch( result = await self.git.sync_task_branch(
t, base_branch=base_branch, actor_agent_id=agent_id t, base_branch=base_branch, actor_agent_id=agent_id, stash=stash
) )
except Exception as exc: except Exception as exc:
dirty = "DIRTY_WORKSPACE" in str(exc)
remediate = (
"your workspace has uncommitted changes; call"
" sync_branch(stash=True) to auto-stash, rebase, and restore"
" them, or commit(...) (omit files to stage everything) then"
" sync_branch() again"
if dirty
else "the git rebase could not complete; escalate via"
" i_am_blocked(reason='...') with the error"
)
return await self._emit_rejection( return await self._emit_rejection(
Envelope.invalid_state( Envelope.invalid_state(
message=f"sync_branch failed: {exc}", message=f"sync_branch failed: {exc}",
remediate=( remediate=remediate,
"the git rebase could not complete; escalate via"
" i_am_blocked(reason='...') with the error"
),
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=t, role=role_str), ).with_introspection(task=t, role=role_str),
agent_id=agent_id, agent_id=agent_id,
@@ -3872,28 +4025,47 @@ class Choreographer:
) )
# Heartbeat — the agent is actively working the task. # Heartbeat — the agent is actively working the task.
await self._touch(task_id) await self._touch(task_id)
status = str(result.get("status", "unknown"))
evidence = { evidence = {
"rebase": result, "rebase": result,
"base_branch": base_branch, "base_branch": base_branch,
"head_branch": str(t.branch_name), "head_branch": str(t.branch_name),
} }
if status == "conflicts":
# The rebase was aborted (no force-push); tell the dev to resolve.
next_hint = (
f"sync_branch hit conflicts on {result.get('files', [])};"
" resolve by hand, commit(message='...'), then sync_branch again"
)
else:
next_hint = spec_module._INTENT_VERBS["sync_branch"].next_hint(t)
return Envelope.ok( return Envelope.ok(
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
next=next_hint, next=self._sync_branch_next_hint(t, result),
evidence=evidence, evidence=evidence,
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=t, role=role_str) ).with_introspection(task=t, role=role_str)
@staticmethod
def _sync_branch_next_hint(t: Any, result: dict[str, Any]) -> str:
"""Compute the ``next`` hint for a completed ``sync_branch`` run.
Three shapes: a rebase conflict (files listed, stash noted if one was
taken), a clean rebase whose stash pop then conflicted (stash
preserved, never dropped), or the plain spec default.
"""
status = str(result.get("status", "unknown"))
if status == "conflicts":
hint = (
f"sync_branch hit conflicts on {result.get('files', [])};"
" resolve by hand, commit(message='...'), then sync_branch again"
)
if result.get("stash_preserved"):
hint += (
" — your stashed changes were left untouched (git stash"
" list); pop them by hand once the conflict is resolved"
)
return hint
if result.get("stash_pop_conflict"):
return (
"sync_branch rebased cleanly but restoring your stashed "
"changes conflicted; the stash is preserved (not dropped) — "
"resolve the conflict by hand, then commit(...) and continue"
)
return spec_module._INTENT_VERBS["sync_branch"].next_hint(t)
async def _sync_branch_preflight_rejection( async def _sync_branch_preflight_rejection(
self, self,
agent_id: UUID, agent_id: UUID,
+3 -2
View File
@@ -189,8 +189,9 @@ class Envelope:
Distinct from `tracing_gap` and `incomplete_input`. The agent receives Distinct from `tracing_gap` and `incomplete_input`. The agent receives
a structured "stop hammering this verb" signal with a remediate hint a structured "stop hammering this verb" signal with a remediate hint
pointing to i_am_blocked() / i_am_idle() as graceful exits. Wired by pointing to i_am_blocked() / unclaim() / i_am_idle() as graceful exits.
the agent_sdk runtime tracker the gateway itself does not raise this. Wired by the agent_sdk runtime tracker the gateway itself does not
raise this.
`message` overrides the default windowed wording used by the `message` overrides the default windowed wording used by the
session-scoped absolute breaker, whose trip isn't "in last Ns". session-scoped absolute breaker, whose trip isn't "in last Ns".
+83 -25
View File
@@ -269,8 +269,12 @@ def _git_ownership_scope(args: list[str]) -> str:
Returns "none" (skip repair zero syscalls), "git" (repair `.git/` Returns "none" (skip repair zero syscalls), "git" (repair `.git/`
only, worktree-aware via `_resolve_clone_root`), or "full" (repair the only, worktree-aware via `_resolve_clone_root`), or "full" (repair the
whole workspace unchanged behavior, and the safe default for whole workspace unchanged behavior, and the safe default for
checkout/reset/rebase/pull or any verb this classifier doesn't checkout/reset/rebase/pull/stash or any verb this classifier doesn't
recognize, so an unclassified op is never under-repaired). recognize, so an unclassified op is never under-repaired). `stash`
(rebase_onto_base's `stash=True` path, #337 scoping) writes both the
working tree (push/pop) and `.git/` (the stash ref + objects) it is
deliberately left unclassified so it falls to the safe "full" default
rather than a `.git/`-only repair that would strand agent-owned files.
""" """
if not args: if not args:
return "full" return "full"
@@ -3986,6 +3990,7 @@ class GitService(BaseService):
head_branch: str, head_branch: str,
base_branch: str, base_branch: str,
git_token: str, git_token: str,
stash: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rebase ``head_branch`` onto the latest ``base_branch`` from origin. """Rebase ``head_branch`` onto the latest ``base_branch`` from origin.
@@ -4003,6 +4008,8 @@ class GitService(BaseService):
can now merge cleanly. can now merge cleanly.
- ``{"status": "conflicts", "files": [...]}`` the rebase hit - ``{"status": "conflicts", "files": [...]}`` the rebase hit
conflicts and was aborted; a developer must resolve by hand. conflicts and was aborted; a developer must resolve by hand.
Any of the above may carry ``"stash_pop_conflict": True`` when
``stash`` popped into a conflict (see below).
Never touches the base branch and only ever force-pushes Never touches the base branch and only ever force-pushes
``head_branch`` (with ``--force-with-lease``). The caller must ensure ``head_branch`` (with ``--force-with-lease``). The caller must ensure
@@ -4010,16 +4017,17 @@ class GitService(BaseService):
rebase-merge into master. rebase-merge into master.
Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so the Safety gate (mirrors :meth:`pull`): refuses on a dirty worktree so the
``git reset --hard`` below can't discard uncommitted agent edits. ``git reset --hard`` below can't discard uncommitted agent edits
UNLESS ``stash=True``, in which case the dirty worktree (tracked +
untracked, ``-u``) is stashed first and popped back after the rebase
instead of refusing outright (the dev-facing dead end this closes:
DIRTY_WORKSPACE had no in-gate remedy other than a raw ``git`` the
agent is denied). A pop conflict is never auto-resolved the stash
is left in place (never dropped) and the result gets
``stash_pop_conflict: True`` so the caller returns an actionable
envelope; the agent's uncommitted work is never lost.
""" """
status_result = await self._run_git( stashed = await self._stash_if_dirty(workspace, stash=stash)
workspace, ["status", "--porcelain"], check=False
)
if status_result.stdout.strip():
raise ValidationError(
"DIRTY_WORKSPACE: Cannot rebase with uncommitted changes. "
"Stage and commit (or stash) your changes before rebasing."
)
await self._run_git(workspace, ["fetch", "origin"], token=git_token) await self._run_git(workspace, ["fetch", "origin"], token=git_token)
await self._run_git(workspace, ["checkout", head_branch]) await self._run_git(workspace, ["checkout", head_branch])
@@ -4028,27 +4036,72 @@ class GitService(BaseService):
workspace, ["rebase", f"origin/{base_branch}"], check=False workspace, ["rebase", f"origin/{base_branch}"], check=False
) )
if rebase.returncode != 0: if rebase.returncode != 0:
conflict = await self._run_git( return await self._abort_rebase_conflict(workspace, stashed=stashed)
workspace,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
return {"status": "conflicts", "files": files}
count = await self._run_git( count = await self._run_git(
workspace, workspace,
["rev-list", "--count", f"origin/{base_branch}..HEAD"], ["rev-list", "--count", f"origin/{base_branch}..HEAD"],
) )
unique = int(count.stdout.strip() or "0") unique = int(count.stdout.strip() or "0")
if unique == 0: if unique == 0:
return {"status": "superseded"} result: dict[str, Any] = {"status": "superseded"}
await self._run_git( else:
workspace, await self._run_git(
["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"], workspace,
token=git_token, ["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"],
token=git_token,
)
result = {"status": "rebased", "unique_commits": unique}
if stashed:
await self._pop_stash_into(workspace, result)
return result
async def _stash_if_dirty(self, workspace: Path, *, stash: bool) -> bool:
"""Clean-tree gate for :meth:`rebase_onto_base`.
Refuses a dirty worktree (``DIRTY_WORKSPACE``) unless ``stash`` is
set, in which case it auto-stashes (tracked + untracked) and returns
``True`` so the caller knows to pop it back later.
"""
status_result = await self._run_git(
workspace, ["status", "--porcelain"], check=False
) )
return {"status": "rebased", "unique_commits": unique} if not status_result.stdout.strip():
return False
if not stash:
raise ValidationError(
"DIRTY_WORKSPACE: Cannot rebase with uncommitted changes. "
"Stage and commit (or stash) your changes before rebasing."
)
await self._run_git(
workspace, ["stash", "push", "-u", "-m", "sync_branch autostash"]
)
return True
async def _abort_rebase_conflict(
self, workspace: Path, *, stashed: bool
) -> dict[str, Any]:
"""Collect conflicted files and abort a failed rebase.
The stash (if one was taken) is left untouched here popping it onto
an aborted, still-conflicted rebase would just stack a second conflict
on top of the first. ``stash_preserved`` is only added when a stash
was actually taken, so the non-stash result shape is unchanged.
"""
conflict = await self._run_git(
workspace, ["diff", "--name-only", "--diff-filter=U"], check=False
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
result: dict[str, Any] = {"status": "conflicts", "files": files}
if stashed:
result["stash_preserved"] = True
return result
async def _pop_stash_into(self, workspace: Path, result: dict[str, Any]) -> None:
"""Pop the autostash, flagging (never auto-resolving) a pop conflict."""
pop = await self._run_git(workspace, ["stash", "pop"], check=False)
if pop.returncode != 0:
result["stash_pop_conflict"] = True
async def rebase_pr_for_task( async def rebase_pr_for_task(
self, self,
@@ -4111,6 +4164,7 @@ class GitService(BaseService):
*, *,
base_branch: str, base_branch: str,
actor_agent_id: UUID | None = None, actor_agent_id: UUID | None = None,
stash: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Rebase a task's branch onto ``base_branch`` through the gate. """Rebase a task's branch onto ``base_branch`` through the gate.
@@ -4124,6 +4178,9 @@ class GitService(BaseService):
resolution and delegates to :meth:`rebase_onto_base`, returning the same resolution and delegates to :meth:`rebase_onto_base`, returning the same
classification dict (``rebased`` / ``superseded`` / ``conflicts``). classification dict (``rebased`` / ``superseded`` / ``conflicts``).
``stash`` forwards to :meth:`rebase_onto_base` auto-stash a dirty
worktree instead of refusing DIRTY_WORKSPACE.
The caller MUST ensure ``base_branch`` is not a protected branch The caller MUST ensure ``base_branch`` is not a protected branch
agents never rebase into master/main; the choreographer guards this. agents never rebase into master/main; the choreographer guards this.
""" """
@@ -4145,6 +4202,7 @@ class GitService(BaseService):
head_branch=task.branch_name, head_branch=task.branch_name,
base_branch=base_branch, base_branch=base_branch,
git_token=git_token, git_token=git_token,
stash=stash,
) )
async def unmerged_child_commits( async def unmerged_child_commits(
+139 -1
View File
@@ -3909,7 +3909,16 @@ class TaskService(BaseService):
return await self._unclaim_pending_assignment(task) return await self._unclaim_pending_assignment(task)
if task.status == TaskStatus.BLOCKED: if task.status == TaskStatus.BLOCKED:
return await self._unclaim_from_blocked(task) return await self._unclaim_from_blocked(task)
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS): # verifying (self-verification before awaiting_qa) and needs_revision
# (QA/CEO sent it back) are both claims a dev can be sitting on when it
# decides to bail — a wedged dev retrying unclaim from either got a
# silent rejection with no legal exit (the 2026-07 5h+ wedge incident).
if task.status not in (
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
TaskStatus.VERIFYING,
TaskStatus.NEEDS_REVISION,
):
return None return None
# Look up the requesting agent's role so role-restricted-transition # Look up the requesting agent's role so role-restricted-transition
@@ -6188,6 +6197,7 @@ class TaskService(BaseService):
# gate from silently orphaning.) Non-validation errors propagate. # gate from silently orphaning.) Non-validation errors propagate.
descendants = await self.get_all_descendants(task_id) descendants = await self.get_all_descendants(task_id)
cancelled_count = 0 cancelled_count = 0
cancelled_now: list[TaskTable] = []
for descendant in descendants: for descendant in descendants:
if descendant.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED): if descendant.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
continue continue
@@ -6213,6 +6223,7 @@ class TaskService(BaseService):
), ),
) from e ) from e
cancelled_count += 1 cancelled_count += 1
cancelled_now.append(descendant)
await self._abandon_work_session_for_task( await self._abandon_work_session_for_task(
descendant, reason="parent task cancelled" descendant, reason="parent task cancelled"
) )
@@ -6227,10 +6238,30 @@ class TaskService(BaseService):
# Validate transition with PM role requirement # Validate transition with PM role requirement
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role) self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
cancelled_now.append(task)
await self._abandon_work_session_for_task(task, reason="task cancelled") await self._abandon_work_session_for_task(task, reason="task cancelled")
await self._delete_task_branch_best_effort(task) await self._delete_task_branch_best_effort(task)
await self.session.flush() await self.session.flush()
# Origin fix: a cancelled child may have declared parent_ac_refs that
# no surviving sibling covers, leaving the roll-up gate
# (_parent_acs_covered_envelope) demanding coverage for already-
# finished work once a replacement is delegated. Warn-and-surface
# only — no hard gate; the PM re-declares via declare_coverage.
orphaned = await self._detect_orphaned_parent_acs(cancelled_now)
if orphaned:
self.log.warning(
"Cancel orphaned parent AC coverage",
task_id=str(task_id),
orphaned_parent_acs=orphaned,
)
self._emit_orphaned_ac_audit(task_id, orphaned)
# ponytail: transient, non-persisted attribute — same-request
# evidence for a caller that wants to surface it without a response
# schema change; upgrade to a real field if a caller needs it
# across a request boundary.
task.orphaned_parent_acs = orphaned
# Index lifecycle event (fire-and-forget) # Index lifecycle event (fire-and-forget)
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._index_lifecycle_event_background( self._index_lifecycle_event_background(
@@ -6241,6 +6272,7 @@ class TaskService(BaseService):
details={ details={
"cancelled_by_role": agent_role, "cancelled_by_role": agent_role,
"descendants_cancelled": cancelled_count, "descendants_cancelled": cancelled_count,
"orphaned_parent_acs": orphaned,
}, },
) )
) )
@@ -6249,6 +6281,51 @@ class TaskService(BaseService):
return task return task
async def _detect_orphaned_parent_acs(
self, cancelled: list[TaskTable]
) -> list[str]:
"""Parent-AC texts a just-cancelled task declared that no surviving
sibling covers.
Runs after the cascade commits, so ``_parent_ac_ref_sets`` reads each
cancelled task's real post-cancel status. Safe-by-construction: a
task with no ``parent_ac_refs`` (the common case) contributes
nothing.
"""
texts: list[str] = []
for t in cancelled:
if not t.parent_task_id or not t.parent_ac_refs:
continue
loaded = await self._parent_ac_ref_sets(cast("UUID", t.parent_task_id))
if loaded is None:
continue
parent, claimed, _verified, _any = loaded
own = self._normalize_ac_refs(parent, t.parent_ac_refs)
orphaned_ids = own - claimed
if orphaned_ids:
texts.extend(self._ac_texts_for(parent, orphaned_ids))
return texts
def _emit_orphaned_ac_audit(self, task_id: UUID, orphaned: list[str]) -> None:
"""Persist the orphaned-coverage warning as evidence.
Mirrors ``_emit_escalation_audit`` an additive row alongside the
generic ``task.cancelled`` transition audit, best-effort for
observability and never gating the cancel itself.
"""
from roboco.db.tables import AuditLogTable
self.session.add(
AuditLogTable(
event_type="task.cancelled_ac_orphaned",
agent_id=None,
target_type="task",
target_id=task_id,
severity="warning",
details={"orphaned_parent_acs": orphaned},
)
)
async def _notify_completion(self, task: TaskTable, task_id: UUID) -> None: async def _notify_completion(self, task: TaskTable, task_id: UUID) -> None:
"""Best-effort CEO completion notification (granular effort breakdown). """Best-effort CEO completion notification (granular effort breakdown).
@@ -8093,6 +8170,67 @@ class TaskService(BaseService):
if ac_id not in covered if ac_id not in covered
] ]
@staticmethod
def _ac_texts_for(parent: TaskTable, ids: set[str]) -> list[str]:
"""Texts of specific parent-criterion ids, in the parent's own order."""
ac_ids = parent.acceptance_criteria_ids or []
ac_texts = parent.acceptance_criteria or []
return [
ac_texts[idx] if idx < len(ac_texts) else ac_id
for idx, ac_id in enumerate(ac_ids)
if ac_id in ids
]
@staticmethod
def unknown_ac_refs(parent: TaskTable, refs: list[str]) -> list[str]:
"""Refs that match neither a parent criterion's id nor its exact text.
Used by ``declare_coverage`` to reject a stamp against a criterion
that does not exist on the parent ``covers_parent_criteria`` (via
``delegate``) stores refs unvalidated, but a PM declaring coverage
after the fact gets a hard check with the parent's AC list in the
remediate.
"""
valid_ids = set(parent.acceptance_criteria_ids or [])
valid_texts = set(parent.acceptance_criteria or [])
return [r for r in refs if r not in valid_ids and r not in valid_texts]
async def add_parent_ac_refs(
self, task_id: UUID, refs: list[str], declared_by: UUID | None = None
) -> TaskTable | None:
"""UNION ``refs`` into a child's ``parent_ac_refs`` (idempotent) + audit.
Lets a PM stamp coverage onto an already-existing child after the
fact e.g. a completed replacement whose original ``delegate``
omitted ``covers_parent_criteria`` (the roll-up-gate deadlock
``declare_coverage`` fixes). Refs use the same id-or-text
representation ``covers_parent_criteria`` writes, resolved at read
time by ``_normalize_ac_refs``. Callers must validate refs against
the parent's criteria first (``unknown_ac_refs``) — this method
merges unconditionally.
"""
from roboco.db.tables import AuditLogTable
task = await self.get(task_id)
if task is None:
return None
existing = task.parent_ac_refs or []
merged = existing + [r for r in refs if r not in existing]
if merged != existing:
task.parent_ac_refs = merged
self.session.add(
AuditLogTable(
event_type="task.coverage_declared",
agent_id=declared_by,
target_type="task",
target_id=task_id,
severity="info",
details={"criteria": refs},
)
)
await self.session.flush()
return task
async def uncovered_parent_acceptance_criteria(self, task_id: UUID) -> list[str]: async def uncovered_parent_acceptance_criteria(self, task_id: UUID) -> list[str]:
"""Parent ACs not yet satisfied by a COMPLETED child — for the roll-up gate. """Parent ACs not yet satisfied by a COMPLETED child — for the roll-up gate.
@@ -354,6 +354,44 @@ async def test_unclaim_for_agent_returns_none_when_paused(
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_releases_from_verifying(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A dev mid self-verification (before awaiting_qa) must have a legal
exit the 2026-07 5h+ wedge incident found unclaim silently rejected
from verifying with no other legal move but block/i_am_blocked, both of
which require in_progress."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.VERIFYING
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is not None
assert out.status == TaskStatus.PENDING
assert out.assigned_to is None
assert out.active_claimant_id is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_releases_from_needs_revision(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A dev sent back for revision must also have unclaim as a legal exit —
same incident, same silent rejection for needs_revision."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.NEEDS_REVISION
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is not None
assert out.status == TaskStatus.PENDING
assert out.assigned_to is None
assert out.active_claimant_id is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# resume_for_agent # resume_for_agent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1486,6 +1524,128 @@ async def test_cancel_returns_task_with_cancellation_note_appended(
assert "duplicate" in (out.dev_notes or "") assert "duplicate" in (out.dev_notes or "")
@pytest.mark.asyncio
async def test_cancel_warns_and_surfaces_orphaned_parent_ac_refs(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Origin fix: a cancelled child's parent_ac_refs with no surviving-sibling
coverage surface as orphaned_parent_acs the signal a PM re-declares via
declare_coverage on the replacement child."""
svc = task_setup["svc"]
parent = await svc.create(
_req(task_setup, acceptance_criteria=["crit a", "crit b"])
)
ac_id = parent.acceptance_criteria_ids[0]
child = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=[ac_id])
)
await db_session.flush()
out = await svc.cancel(child.id, agent_role="cell_pm")
assert out is not None
assert out.orphaned_parent_acs == ["crit a"]
rows = (
(
await db_session.execute(
select(AuditLogTable).where(
AuditLogTable.event_type == "task.cancelled_ac_orphaned"
)
)
)
.scalars()
.all()
)
assert any(r.target_id == child.id for r in rows)
@pytest.mark.asyncio
async def test_cancel_reports_no_orphaned_acs_when_sibling_still_covers(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A sibling still claiming the same criterion means nothing is orphaned."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup, acceptance_criteria=["crit a"]))
ac_id = parent.acceptance_criteria_ids[0]
child_a = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=[ac_id])
)
child_b = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=[ac_id])
)
await db_session.flush()
out = await svc.cancel(child_a.id, agent_role="cell_pm")
assert out is not None
assert out.orphaned_parent_acs == []
_ = child_b
# ---------------------------------------------------------------------------
# add_parent_ac_refs / declare_coverage primitive
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_parent_ac_refs_lets_uncovered_gate_pass_after_replacement(
task_setup: dict, db_session: AsyncSession
) -> None:
"""End-to-end proof of the production fix: a replacement child delegated
without covers_parent_criteria leaves the roll-up gate
(uncovered_parent_acceptance_criteria) blocked until the PM calls
add_parent_ac_refs (declare_coverage's primitive) on it."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup, acceptance_criteria=["crit a"]))
ac_text = parent.acceptance_criteria[0]
original = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=[ac_text])
)
await svc.cancel(original.id, agent_role="cell_pm")
# Replacement delegated WITHOUT covers_parent_criteria — the live bug.
replacement = await svc.create(_req(task_setup, parent_task_id=parent.id))
replacement.status = TaskStatus.COMPLETED
await db_session.flush()
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == [ac_text]
updated = await svc.add_parent_ac_refs(
replacement.id, [ac_text], declared_by=task_setup["agent_id"]
)
assert updated is not None
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
rows = (
(
await db_session.execute(
select(AuditLogTable).where(
AuditLogTable.event_type == "task.coverage_declared"
)
)
)
.scalars()
.all()
)
assert any(r.target_id == replacement.id for r in rows)
@pytest.mark.asyncio
async def test_add_parent_ac_refs_is_idempotent(task_setup: dict) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup, acceptance_criteria=["crit a"]))
ac_id = parent.acceptance_criteria_ids[0]
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
first = await svc.add_parent_ac_refs(child.id, [ac_id])
second = await svc.add_parent_ac_refs(child.id, [ac_id])
assert first is not None and second is not None
assert second.parent_ac_refs == [ac_id]
@pytest.mark.asyncio
async def test_add_parent_ac_refs_returns_none_for_missing_task(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
assert await svc.add_parent_ac_refs(uuid4(), ["id-a"]) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _unblock_dependents # _unblock_dependents
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -236,6 +236,7 @@ def test_check_returns_envelope_at_or_above_limit() -> None:
assert "i_am_done" in result["message"] assert "i_am_done" in result["message"]
assert result["remediate"] is not None assert result["remediate"] is not None
assert "i_am_blocked" in result["remediate"] assert "i_am_blocked" in result["remediate"]
assert "unclaim" in result["remediate"]
def test_check_returns_none_for_unlimited_retry_verbs() -> None: def test_check_returns_none_for_unlimited_retry_verbs() -> None:
@@ -511,6 +512,7 @@ def test_slow_drip_never_trips_window_but_trips_absolute_cap() -> None:
assert result["error"] == "circuit_open" assert result["error"] == "circuit_open"
assert "absolute cap" in result["message"] assert "absolute cap" in result["message"]
assert "i_am_blocked" in result["remediate"] assert "i_am_blocked" in result["remediate"]
assert "unclaim" in result["remediate"]
def test_absolute_check_returns_none_below_cap() -> None: def test_absolute_check_returns_none_below_cap() -> None:
+233
View File
@@ -1087,3 +1087,236 @@ async def test_cell_pm_complete_survives_parent_advance_failure() -> None:
assert body.get("error") is None, body assert body.get("error") is None, body
assert body.get("warning") is not None assert body.get("warning") is not None
assert "advance" in body["warning"].lower() assert "advance" in body["warning"].lower()
# ---------------------------------------------------------------------------
# declare_coverage
# ---------------------------------------------------------------------------
def _declare_coverage_deps(
*,
parent_id: Any,
child_id: Any,
parent_kwargs: dict[str, Any],
child_kwargs: dict[str, Any],
agent_kwargs: dict[str, Any],
) -> tuple[AsyncMock, MagicMock, MagicMock]:
"""Wire a task_svc AsyncMock whose .get resolves parent_id/child_id, plus
the parent + child MagicMocks (declare_coverage loads both by id)."""
parent = MagicMock(id=parent_id, **parent_kwargs)
child = MagicMock(id=child_id, parent_task_id=parent_id, **child_kwargs)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else child
task_svc.agent_for.return_value = MagicMock(**agent_kwargs)
return task_svc, parent, child
@pytest.mark.asyncio
async def test_declare_coverage_task_not_found() -> None:
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(uuid4(), uuid4(), ["id-a"])
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_declare_coverage_no_parent_returns_invalid_state() -> None:
pm_id = uuid4()
child_id = uuid4()
child = MagicMock(id=child_id, parent_task_id=None, team="backend")
task_svc = AsyncMock()
task_svc.get.return_value = child
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "invalid_state"
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_non_pm_rejected() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={"assigned_to": pm_id},
child_kwargs={"team": "backend"},
agent_kwargs={"role": "developer", "team": "backend"},
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "not_authorized"
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_rejects_pm_off_team_without_parent_ownership() -> None:
pm_id, other_pm_id = uuid4(), uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={"assigned_to": other_pm_id},
child_kwargs={"team": "frontend"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_declare_coverage_allows_pm_on_child_team_without_ownership() -> None:
"""The minimum authorization bar: a PM on the child's own team may
declare coverage even without owning the parent coordination task."""
pm_id, other_pm_id = uuid4(), uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": other_pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
@pytest.mark.asyncio
async def test_declare_coverage_unknown_criterion_rejected_lists_parent_acs() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a", "crit b"],
"acceptance_criteria_ids": ["id-a", "id-b"],
},
child_kwargs={"team": "backend"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=["bogus"])
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["bogus"])
assert env.error == "invalid_state"
assert env.remediate is not None
assert "crit a" in env.remediate and "crit b" in env.remediate
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_happy_path_stamps_refs_and_returns_remaining() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a", "crit b"],
"acceptance_criteria_ids": ["id-a", "id-b"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = ["crit b"]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
task_svc.add_parent_ac_refs.assert_awaited_once_with(
child_id, ["id-a"], declared_by=pm_id
)
assert env.evidence == {"remaining_uncovered_parent_acs": ["crit b"]}
@pytest.mark.asyncio
async def test_declare_coverage_idempotent_redeclare() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
first = await c.declare_coverage(pm_id, child_id, ["id-a"])
count_after_first = task_svc.add_parent_ac_refs.await_count
second = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert first.error is None, first.as_dict()
assert second.error is None, second.as_dict()
assert task_svc.add_parent_ac_refs.await_count == count_after_first + 1
@pytest.mark.asyncio
async def test_declare_coverage_then_submit_up_gate_passes() -> None:
"""declare_coverage followed by the roll-up gate — the production
deadlock's end-to-end fix: once uncovered_parent_acceptance_criteria
empties, _parent_acs_covered_envelope no longer blocks submit_up."""
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
assert env.evidence == {"remaining_uncovered_parent_acs": []}
gate_env = await c._parent_acs_covered_envelope(
pm_id, parent_id, context_phrase="bubbling up"
)
assert gate_env is None
@@ -0,0 +1,124 @@
"""``i_am_blocked`` must never demand paperwork — a non-empty ``reason`` is
the only requirement (blocker_type / what_needed stay optional), so a wedged
agent can bail with one sentence. Also pins the awaiting_qa bail message: a
dev whose task already moved to QA review is not blocked, it's done — the
rejection must say so and point at i_am_idle(), not list allowed states like
a wall.
Mirrors the fake-dependency shape of test_i_am_blocked_no_escalation_target.py.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_evidence_repo() -> AsyncMock:
repo = AsyncMock()
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",
"similar_memory",
):
getattr(repo, method).return_value = []
return repo
def _make_task(agent_id: object, task_id: object, status: str) -> MagicMock:
return MagicMock(
id=task_id,
status=status,
assigned_to=agent_id,
pre_block_state=None,
task_type="code",
team="backend",
dependency_ids=[],
acceptance_criteria=[],
quick_context=None,
notes_structured=None,
)
def _make_task_svc(agent_id: object, task: object) -> AsyncMock:
task_svc = AsyncMock()
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
task_svc.get.return_value = task
task_svc.agent_for.return_value = MagicMock(
id=agent_id,
role="developer",
team="backend",
slug="be-dev-1",
)
return task_svc
def _make_deps(task_svc: AsyncMock) -> ChoreographerDeps:
return ChoreographerDeps(
task=task_svc,
work_session=AsyncMock(),
git=AsyncMock(),
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=_make_evidence_repo(),
)
@pytest.mark.asyncio
async def test_i_am_blocked_succeeds_with_only_reason() -> None:
"""No blocker_type/what_needed supplied — a bare reason is enough."""
agent_id = uuid4()
task_id = uuid4()
task = _make_task(agent_id, task_id, "in_progress")
task_svc = _make_task_svc(agent_id, task)
blocked_task = _make_task(agent_id, task_id, "blocked")
task_svc.escalate.return_value = blocked_task
deps = _make_deps(task_svc)
c = Choreographer(deps)
env = await c.i_am_blocked(agent_id, task_id, "wedged, cannot push, need help")
assert env.error is None, env.as_dict()
assert env.status == "blocked"
task_svc.escalate.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_blocked_from_awaiting_qa_names_i_am_idle() -> None:
"""A task that already moved to QA is not this dev's to block anymore —
the rejection must say the truth (done, QA owns it) and point at
i_am_idle(), not a generic 'find a task in [...]' dead end."""
agent_id = uuid4()
task_id = uuid4()
task = _make_task(agent_id, task_id, "awaiting_qa")
task_svc = _make_task_svc(agent_id, task)
deps = _make_deps(task_svc)
c = Choreographer(deps)
env = await c.i_am_blocked(agent_id, task_id, "stuck on this task")
assert env.error == "invalid_state", env.as_dict()
assert "awaiting_qa" in (env.message or "")
remediate = env.remediate or ""
assert "i_am_idle" in remediate
assert "QA" in remediate
task_svc.escalate.assert_not_awaited()
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+113 -1
View File
@@ -88,7 +88,7 @@ async def test_sync_branch_rebases_and_returns_evidence() -> None:
env = await c.sync_branch(aid, tid) env = await c.sync_branch(aid, tid)
git_svc.sync_task_branch.assert_awaited_once_with( git_svc.sync_task_branch.assert_awaited_once_with(
t, base_branch=_BASE, actor_agent_id=aid t, base_branch=_BASE, actor_agent_id=aid, stash=False
) )
assert env.error is None assert env.error is None
assert env.evidence is not None assert env.evidence is not None
@@ -227,6 +227,118 @@ async def test_sync_branch_git_failure_steers_to_i_am_blocked() -> None:
assert "i_am_blocked" in (env.remediate or "") assert "i_am_blocked" in (env.remediate or "")
@pytest.mark.asyncio
async def test_sync_branch_passes_stash_flag_through() -> None:
"""stash=True on the verb forwards to GitService.sync_task_branch."""
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 1}
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid, stash=True)
git_svc.sync_task_branch.assert_awaited_once_with(
t, base_branch=_BASE, actor_agent_id=aid, stash=True
)
assert env.error is None
@pytest.mark.asyncio
async def test_sync_branch_dirty_workspace_failure_steers_to_stash_or_commit() -> None:
"""A DIRTY_WORKSPACE failure gets a specific, actionable remediate — not
the generic i_am_blocked escalation."""
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.side_effect = RuntimeError(
"DIRTY_WORKSPACE: Cannot rebase with uncommitted changes."
)
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid)
assert env.error == "invalid_state"
assert "stash=True" in (env.remediate or "")
assert "commit(" in (env.remediate or "")
@pytest.mark.asyncio
async def test_sync_branch_conflicts_with_stash_preserved_notes_it_in_next() -> None:
"""A conflict with stash_preserved=True tells the dev their stash is safe."""
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.return_value = {
"status": "conflicts",
"files": ["src/a.py"],
"stash_preserved": True,
}
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid, stash=True)
assert env.error is None
assert "stash" in (env.next or "").lower()
@pytest.mark.asyncio
async def test_sync_branch_stash_pop_conflict_notes_preserved_stash() -> None:
"""A clean rebase whose stash pop conflicted must not read as a plain
success the dev still has manual work to finish."""
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.return_value = {
"status": "rebased",
"unique_commits": 2,
"stash_pop_conflict": True,
}
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid, stash=True)
assert env.error is None
assert "conflict" in (env.next or "").lower()
assert "preserved" in (env.next or "").lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_branch_rejection_writes_audit_row() -> None: async def test_sync_branch_rejection_writes_audit_row() -> None:
"""Every rejection envelope must call audit.log_event (Task 6 contract).""" """Every rejection envelope must call audit.log_event (Task 6 contract)."""
+13 -2
View File
@@ -270,7 +270,7 @@ def test_i_am_done_notes_defaults_to_empty(flow_module: types.ModuleType) -> Non
def test_sync_branch_posts_to_dev_path(flow_module: types.ModuleType) -> None: def test_sync_branch_posts_to_dev_path(flow_module: types.ModuleType) -> None:
"""sync_branch forwards task_id to /api/v1/flow/developer/sync_branch.""" """sync_branch forwards task_id + stash to /api/v1/flow/developer/sync_branch."""
fake_client = _make_fake_client({"status": "ok"}) fake_client = _make_fake_client({"status": "ok"})
with patch("httpx.Client", return_value=fake_client): with patch("httpx.Client", return_value=fake_client):
@@ -279,7 +279,18 @@ def test_sync_branch_posts_to_dev_path(flow_module: types.ModuleType) -> None:
assert result == {"status": "ok"} assert result == {"status": "ok"}
args, kwargs = fake_client.post.call_args args, kwargs = fake_client.post.call_args
assert "/api/v1/flow/developer/sync_branch" in args[0] assert "/api/v1/flow/developer/sync_branch" in args[0]
assert kwargs["json"] == {"task_id": "task-abc"} assert kwargs["json"] == {"task_id": "task-abc", "stash": False}
def test_sync_branch_forwards_stash_true(flow_module: types.ModuleType) -> None:
"""sync_branch(stash=True) forwards the flag through the body."""
fake_client = _make_fake_client({"status": "ok"})
with patch("httpx.Client", return_value=fake_client):
flow_module.sync_branch("task-abc", stash=True)
_, kwargs = fake_client.post.call_args
assert kwargs["json"] == {"task_id": "task-abc", "stash": True}
def test_i_am_blocked_sends_reason(flow_module: types.ModuleType) -> None: def test_i_am_blocked_sends_reason(flow_module: types.ModuleType) -> None:
+103
View File
@@ -0,0 +1,103 @@
"""`_auto_block_task` must be state-aware and never log an empty error.
Live incident: the orchestrator logged `{"error": "", "event": "Failed to
auto-block task"}` for a task whose owning container had died mid-
awaiting_qa an empty error string with no diagnostic value, from a PATCH
attempting to force a task QA already owns back to "blocked". This pins:
- a task already past dev control (awaiting_qa, terminal, ...) is skipped
with an info log, no PATCH attempted
- a still-blockable task (pending) proceeds to the PATCH as before
- a PATCH failure logs a non-empty error even for exception types whose
str() is empty (e.g. a bare TimeoutError)
- a failed status pre-check does not swallow the block attempt itself
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
def _make_orch() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
def _resp(status_code_ok: bool, payload: dict[str, Any]) -> MagicMock:
r = MagicMock()
r.is_success = status_code_ok
r.json.return_value = payload
return r
@pytest.mark.asyncio
async def test_auto_block_skips_task_already_in_awaiting_qa() -> None:
orch = _make_orch()
client: Any = AsyncMock()
client.get.return_value = _resp(True, {"status": "awaiting_qa"})
await orch._auto_block_task(client, "tid-1", "container died")
client.patch.assert_not_awaited()
@pytest.mark.asyncio
async def test_auto_block_skips_completed_task() -> None:
orch = _make_orch()
client: Any = AsyncMock()
client.get.return_value = _resp(True, {"status": "completed"})
await orch._auto_block_task(client, "tid-2", "stale readiness check")
client.patch.assert_not_awaited()
@pytest.mark.asyncio
async def test_auto_block_proceeds_for_pending_task() -> None:
"""The main existing use case (stuck pending tasks) must be unaffected."""
orch = _make_orch()
client: Any = AsyncMock()
client.get.return_value = _resp(True, {"status": "pending"})
await orch._auto_block_task(client, "tid-3", "needs a project_id")
client.patch.assert_awaited_once()
args, kwargs = client.patch.await_args
assert "tid-3" in args[0]
assert kwargs["json"]["status"] == "blocked"
@pytest.mark.asyncio
async def test_auto_block_proceeds_when_status_precheck_fails() -> None:
"""A GET failure must not swallow the block attempt — fall through."""
orch = _make_orch()
client: Any = AsyncMock()
client.get.side_effect = RuntimeError("network down")
await orch._auto_block_task(client, "tid-4", "some reason")
client.patch.assert_awaited_once()
@pytest.mark.asyncio
async def test_auto_block_logs_nonempty_error_for_blank_exception() -> None:
"""str(TimeoutError()) is '' — the log must still carry a real message."""
assert str(TimeoutError()) == "" # the exact gotcha this guards against
orch = _make_orch()
client: Any = AsyncMock()
client.get.return_value = _resp(True, {"status": "pending"})
client.patch.side_effect = TimeoutError()
with patch("roboco.runtime.orchestrator.logger") as mock_logger:
await orch._auto_block_task(client, "tid-5", "some reason")
mock_logger.error.assert_called_once()
_, kwargs = mock_logger.error.call_args
assert kwargs["error"], "error field must never be blank"
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+117
View File
@@ -1069,6 +1069,123 @@ async def test_rebase_onto_base_proceeds_on_clean_tree() -> None:
assert ["rebase", "origin/master"] in calls assert ["rebase", "origin/master"] in calls
# ---------------------------------------------------------------------------
# rebase_onto_base — stash=True auto-stash/pop (the dirty-workspace exit)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rebase_onto_base_stash_true_auto_stashes_and_pops() -> None:
"""stash=True: a dirty tree is stashed (not refused), rebased, popped back."""
svc = _service()
calls: list[list[str]] = []
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
calls.append(args)
res = MagicMock()
res.returncode = 0
if args[:2] == ["status", "--porcelain"]:
res.stdout = " M dirty.py\n"
elif args[:2] == ["rev-list", "--count"]:
res.stdout = "1"
else:
res.stdout = ""
return res
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
result = await svc.rebase_onto_base(
Path("/tmp/ws"),
head_branch="feature/backend/h",
base_branch="master",
git_token="t",
stash=True,
)
assert result == {"status": "rebased", "unique_commits": 1}
push_args = ["stash", "push", "-u", "-m", "sync_branch autostash"]
pop_args = ["stash", "pop"]
assert push_args in calls
assert pop_args in calls
# Stash push runs before the rebase, pop runs after.
assert calls.index(push_args) < calls.index(["rebase", "origin/master"])
assert calls.index(pop_args) > calls.index(["rebase", "origin/master"])
@pytest.mark.asyncio
async def test_rebase_onto_base_stash_pop_conflict_preserves_stash() -> None:
"""A conflicted pop is flagged, never auto-resolved — stash stays intact."""
svc = _service()
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
res = MagicMock()
res.returncode = 0
if args[:2] == ["status", "--porcelain"]:
res.stdout = " M dirty.py\n"
elif args[:2] == ["rev-list", "--count"]:
res.stdout = "1"
elif args == ["stash", "pop"]:
res.returncode = 1 # pop conflicted — stash is NOT dropped by git
else:
res.stdout = ""
return res
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
result = await svc.rebase_onto_base(
Path("/tmp/ws"),
head_branch="feature/backend/h",
base_branch="master",
git_token="t",
stash=True,
)
assert result == {
"status": "rebased",
"unique_commits": 1,
"stash_pop_conflict": True,
}
@pytest.mark.asyncio
async def test_rebase_onto_base_stash_true_rebase_conflict_skips_pop() -> None:
"""A rebase conflict aborts before ever attempting the pop — no double
conflict; the stash is reported preserved for the caller to surface."""
svc = _service()
calls: list[list[str]] = []
async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock:
calls.append(args)
res = MagicMock()
res.returncode = 0
if args[:2] == ["status", "--porcelain"]:
res.stdout = " M dirty.py\n"
elif args == ["rebase", "origin/master"]:
res.returncode = 1
elif args[:2] == ["diff", "--name-only"]:
res.stdout = "src/a.py\n"
else:
res.stdout = ""
return res
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
result = await svc.rebase_onto_base(
Path("/tmp/ws"),
head_branch="feature/backend/h",
base_branch="master",
git_token="t",
stash=True,
)
assert result == {
"status": "conflicts",
"files": ["src/a.py"],
"stash_preserved": True,
}
assert ["stash", "pop"] not in calls
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _link_commit_to_task — flush; the runner commits (no out-of-band commit) # _link_commit_to_task — flush; the runner commits (no out-of-band commit)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+12
View File
@@ -1094,6 +1094,18 @@ async def test_uncovered_parent_acs_recognizes_text_declared_coverage() -> None:
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == [] assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
def test_unknown_ac_refs_flags_refs_not_on_parent() -> None:
# declare_coverage's validation primitive: accepts a parent criterion by
# id OR exact text; anything else is unknown and must be rejected with
# the parent's real AC list in the remediate.
parent = _build_task(
acceptance_criteria=["crit a", "crit b"],
acceptance_criteria_ids=["id-a", "id-b"],
)
assert TaskService.unknown_ac_refs(parent, ["id-a", "crit b", "bogus"]) == ["bogus"]
assert TaskService.unknown_ac_refs(parent, ["id-a", "crit b"]) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_parent_ac_coverage_normalizes_text_refs() -> None: async def test_parent_ac_coverage_normalizes_text_refs() -> None:
# A text-declared coverage ref from a COMPLETED child surfaces as # A text-declared coverage ref from a COMPLETED child surfaces as