[F016,F017] choreographer: surface invalid_state instead of None.status 500 on submit_root / i_am_blocked

Both verbs compose a single atomic action whose None return (the verb's
own result) flowed out of run_intent and was dereferenced as t.status,
HTTP 500-ing with no actionable rejection:

- F016 submit_root: submit_for_review returns None when the root->master
  PR was already opened / the task raced out of in_progress. Post-runner
  None-guard extracted into _submit_root_finalize -> invalid_state
  (re-fetch; if awaiting_pr_review the PR is open, wait for reviewer;
  else re-delegate fixes and retry) instead of None.status.

- F017 i_am_blocked: escalate returns None in four cases (no task, no
  agent, no resolvable escalation-target slug, no target agent row) e.g.
  a developer whose role has no PM above it. _run_i_am_blocked_intent
  now guards updated is None -> (t, invalid_state rejection) with
  remediation (re-fetch + escalate to CEO directly / retry) instead of
  the caller deref'ing None.status -> 500 + respawn-loop.

TDD red->green; ruff + mypy clean; gateway suite green (58 passed).
This commit is contained in:
Renn F
2026-06-28 10:20:39 +02:00
parent 51990d7cad
commit 742e0ebfaf
3 changed files with 207 additions and 0 deletions
@@ -404,3 +404,29 @@ async def test_pr_pass_does_not_capture_head_sha() -> None:
# pr_pass path never calls _capture_pr_head_sha, so head_sha is absent
# from the kwargs (the default None is not passed).
assert "head_sha" not in record_spy.call_args.kwargs
# ---------------------------------------------------------------------------
# F016 — submit_root must not 500 when submit_for_review returns None
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_root_invalid_state_when_submit_for_review_returns_none() -> None:
"""F016: submit_for_review returns None when the root->master PR was already
opened (the task raced out of in_progress, or a prior call already
transitioned it). create_root_pr already ran as the pre-side-effect, so the
PR exists, but the transition did not happen. submit_root must surface an
invalid_state envelope, not dereference None.status and 500."""
c, main_pm_id, root_task_id = _resubmit_root(notes_structured=None)
# The transition did not happen (PR already opened / task raced).
c.task.submit_for_review.return_value = None
env = await c.submit_root(
main_pm_id, root_task_id, notes="re-submit; transition returned nothing"
)
assert env.error is not None, env.as_dict()
assert env.error == "invalid_state"
remediate = env.remediate or ""
assert "evidence" in remediate.lower() or "re-fetch" in remediate.lower()