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 |
|------|-------------|
| `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)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `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)` |
| `open_pr` | `open_pr(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)` |
### 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.
- **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.
- **escalate_up**: Escalate to your role's escalation_target.
- **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.
- **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.
- **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.
@@ -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.
- **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.
- **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval.
- **escalate_up**: Escalate to your role's escalation_target.
+1
View File
@@ -8,6 +8,7 @@
| Verb | Body schema |
|------|-------------|
| `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)` |
| `escalate_to_ceo` | `escalate_to_ceo(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)` |
| `open_pr` | `open_pr(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)` |
### Content (do) tools
@@ -113,6 +113,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema |
|------|-------------|
| `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)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` |
@@ -149,6 +150,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema |
|------|-------------|
| `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)` |
| `escalate_to_ceo` | `escalate_to_ceo(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
## 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
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
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
+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`.
## 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)
**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.
**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
+12 -1
View File
@@ -85,6 +85,17 @@
"pre_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": [
"cell_pm",
@@ -371,7 +382,7 @@
"developer"
],
"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",
"pre_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=(
f"verb {verb!r} has been rejected {count} times in "
f"{_VERB_ATTEMPT_WINDOW_S}s. Stop retrying. Call "
"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."
"i_am_blocked(reason='unable to satisfy gate after N attempts'), "
"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()
@@ -618,8 +619,9 @@ def _check_verb_absolute_circuit(
remediate=(
f"verb {verb!r} has been rejected {count} times this session "
f"(absolute cap {cap}, regardless of pacing). Stop retrying. Call "
"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."
"i_am_blocked(reason='unable to satisfy gate after N attempts'), "
"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()
+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.schemas.v1.flow import (
CompleteRequest,
DeclareCoverageRequest,
DelegateRequest,
EscalateUpRequest,
GiveMeWorkRequest,
@@ -238,6 +239,20 @@ async def resume(
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")
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.content_type_filter(["application/json"])
+1 -1
View File
@@ -159,7 +159,7 @@ async def sync_branch(
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> 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)
+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.schemas.v1.flow import (
CompleteRequest,
DeclareCoverageRequest,
DelegateRequest,
EscalateToCeoRequest,
EscalateUpRequest,
@@ -277,3 +278,17 @@ async def i_am_idle(
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
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)
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):
task_id: UUID
@@ -156,6 +169,10 @@ class SyncBranchRequest(BaseModel):
"""
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):
+7
View File
@@ -180,6 +180,13 @@ class TaskTable(Base):
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: Mapped[TaskStatus] = mapped_column(
_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
# -> awaiting_qa -> (qa_pass) -> awaiting_documentation; a direct
# 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.
Status.AWAITING_QA: frozenset({Status.BLOCKED}),
# PM claim + PM reject path on review queue.
Status.AWAITING_PM_REVIEW: frozenset({Status.CLAIMED, Status.NEEDS_REVISION}),
# Re-entry from revision back into active dev work (without re-claim).
Status.NEEDS_REVISION: frozenset({Status.IN_PROGRESS}),
# Re-entry from revision back into active dev work (without re-claim), or
# 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
+31 -1
View File
@@ -1236,7 +1236,9 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
" 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."
" 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
extra_preconditions=(
@@ -1290,6 +1292,22 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
"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(
name="resume",
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
must re-affirm them, not bail; the generic hint fed the live 26-respawn
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":
return (
@@ -1613,6 +1637,12 @@ def _invalid_source_remediate(
"notes='verified existing docs are complete and accurate') "
"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 (
f"call give_me_work() to find a task in"
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
"unclaim", # voluntary release; no rationale required
"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
# claim_review's tracing applies on pass_review / fail_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})
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.
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
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).
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]:
@@ -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]:
"""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})
@@ -986,6 +1011,7 @@ _TOOLS: dict[str, Any] = {
"delegate": delegate,
"submit_up": submit_up,
"submit_root": submit_root,
"declare_coverage": declare_coverage,
# board / main pm
"escalate_to_ceo": escalate_to_ceo,
}
+51 -2
View File
@@ -465,6 +465,23 @@ class _SecretaryRunSpec:
# spawn taskless, so they are NOT flagged (#11).
_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:
"""True when a delivery-role spawn carries no ``task_id`` (#11).
@@ -9291,7 +9308,36 @@ Start by:
async def _auto_block_task(
self, client: httpx.AsyncClient, task_id: str, reason: str
) -> 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:
await client.patch(
f"{self._api_url}/tasks/{task_id}",
@@ -9306,10 +9352,13 @@ Start by:
reason=reason,
)
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(
"Failed to auto-block task",
task_id=task_id,
error=str(e),
error=str(e) or repr(e),
)
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"completed subtask before {context_phrase}: {listing}. Delegate "
"(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),
)
@@ -2317,7 +2319,12 @@ class Choreographer:
"your latest commits are local-only and QA reviews the "
"pushed PR branch. resolve the push error (often a "
"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,
)
@@ -3559,7 +3566,8 @@ class Choreographer:
"from awaiting_documentation"
if str(t.status) == "awaiting_documentation"
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,
).with_introspection(task=t, role=role_str),
@@ -3714,6 +3722,137 @@ class Choreographer:
context_briefing=briefing,
).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:
"""Resume a paused task this agent owns; transitions paused → in_progress.
@@ -3822,7 +3961,9 @@ class Choreographer:
context_briefing=briefing,
).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.
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
(no force-push) and return the conflicted files resolve by hand,
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)
briefing = await self._briefing_for(agent_id, task_id, task=t)
@@ -3854,16 +4000,23 @@ class Choreographer:
)
try:
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:
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(
Envelope.invalid_state(
message=f"sync_branch failed: {exc}",
remediate=(
"the git rebase could not complete; escalate via"
" i_am_blocked(reason='...') with the error"
),
remediate=remediate,
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
@@ -3872,28 +4025,47 @@ class Choreographer:
)
# Heartbeat — the agent is actively working the task.
await self._touch(task_id)
status = str(result.get("status", "unknown"))
evidence = {
"rebase": result,
"base_branch": base_branch,
"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(
status=str(t.status),
task_id=str(task_id),
next=next_hint,
next=self._sync_branch_next_hint(t, result),
evidence=evidence,
context_briefing=briefing,
).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(
self,
agent_id: UUID,
+3 -2
View File
@@ -189,8 +189,9 @@ class Envelope:
Distinct from `tracing_gap` and `incomplete_input`. The agent receives
a structured "stop hammering this verb" signal with a remediate hint
pointing to i_am_blocked() / i_am_idle() as graceful exits. Wired by
the agent_sdk runtime tracker the gateway itself does not raise this.
pointing to i_am_blocked() / unclaim() / i_am_idle() as graceful exits.
Wired by the agent_sdk runtime tracker the gateway itself does not
raise this.
`message` overrides the default windowed wording used by the
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/`
only, worktree-aware via `_resolve_clone_root`), or "full" (repair the
whole workspace unchanged behavior, and the safe default for
checkout/reset/rebase/pull or any verb this classifier doesn't
recognize, so an unclassified op is never under-repaired).
checkout/reset/rebase/pull/stash or any verb this classifier doesn't
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:
return "full"
@@ -3986,6 +3990,7 @@ class GitService(BaseService):
head_branch: str,
base_branch: str,
git_token: str,
stash: bool = False,
) -> dict[str, Any]:
"""Rebase ``head_branch`` onto the latest ``base_branch`` from origin.
@@ -4003,6 +4008,8 @@ class GitService(BaseService):
can now merge cleanly.
- ``{"status": "conflicts", "files": [...]}`` the rebase hit
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
``head_branch`` (with ``--force-with-lease``). The caller must ensure
@@ -4010,16 +4017,17 @@ class GitService(BaseService):
rebase-merge into master.
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(
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."
)
stashed = await self._stash_if_dirty(workspace, stash=stash)
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
await self._run_git(workspace, ["checkout", head_branch])
@@ -4028,27 +4036,72 @@ class GitService(BaseService):
workspace, ["rebase", f"origin/{base_branch}"], check=False
)
if rebase.returncode != 0:
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)
return {"status": "conflicts", "files": files}
return await self._abort_rebase_conflict(workspace, stashed=stashed)
count = await self._run_git(
workspace,
["rev-list", "--count", f"origin/{base_branch}..HEAD"],
)
unique = int(count.stdout.strip() or "0")
if unique == 0:
return {"status": "superseded"}
await self._run_git(
workspace,
["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"],
token=git_token,
result: dict[str, Any] = {"status": "superseded"}
else:
await self._run_git(
workspace,
["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(
self,
@@ -4111,6 +4164,7 @@ class GitService(BaseService):
*,
base_branch: str,
actor_agent_id: UUID | None = None,
stash: bool = False,
) -> dict[str, Any]:
"""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
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
agents never rebase into master/main; the choreographer guards this.
"""
@@ -4145,6 +4202,7 @@ class GitService(BaseService):
head_branch=task.branch_name,
base_branch=base_branch,
git_token=git_token,
stash=stash,
)
async def unmerged_child_commits(
+139 -1
View File
@@ -3909,7 +3909,16 @@ class TaskService(BaseService):
return await self._unclaim_pending_assignment(task)
if task.status == TaskStatus.BLOCKED:
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
# 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.
descendants = await self.get_all_descendants(task_id)
cancelled_count = 0
cancelled_now: list[TaskTable] = []
for descendant in descendants:
if descendant.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
continue
@@ -6213,6 +6223,7 @@ class TaskService(BaseService):
),
) from e
cancelled_count += 1
cancelled_now.append(descendant)
await self._abandon_work_session_for_task(
descendant, reason="parent task cancelled"
)
@@ -6227,10 +6238,30 @@ class TaskService(BaseService):
# Validate transition with PM role requirement
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._delete_task_branch_best_effort(task)
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)
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
@@ -6241,6 +6272,7 @@ class TaskService(BaseService):
details={
"cancelled_by_role": agent_role,
"descendants_cancelled": cancelled_count,
"orphaned_parent_acs": orphaned,
},
)
)
@@ -6249,6 +6281,51 @@ class TaskService(BaseService):
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:
"""Best-effort CEO completion notification (granular effort breakdown).
@@ -8093,6 +8170,67 @@ class TaskService(BaseService):
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]:
"""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
@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
# ---------------------------------------------------------------------------
@@ -1486,6 +1524,128 @@ async def test_cancel_returns_task_with_cancellation_note_appended(
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
# ---------------------------------------------------------------------------
@@ -236,6 +236,7 @@ def test_check_returns_envelope_at_or_above_limit() -> None:
assert "i_am_done" in result["message"]
assert result["remediate"] is not None
assert "i_am_blocked" in result["remediate"]
assert "unclaim" in result["remediate"]
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 "absolute cap" in result["message"]
assert "i_am_blocked" in result["remediate"]
assert "unclaim" in result["remediate"]
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("warning") is not None
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)
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.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 "")
@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
async def test_sync_branch_rejection_writes_audit_row() -> None:
"""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:
"""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"})
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"}
args, kwargs = fake_client.post.call_args
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:
+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
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
+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) == []
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
async def test_parent_ac_coverage_normalizes_text_refs() -> None:
# A text-declared coverage ref from a COMPLETED child surfaces as