mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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()` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user