Board Program LEARN context, ruff 0.16, and verb-rejection observability (#700)

* fix(board): LEARN decisions name the item, not its per-cycle index

A cycle's reject reasons are rendered into the NEXT cycle's exploration
prompt, but the ref recorded alongside each reason was the item's stored
id (item-0/item-1) — a per-cycle index that means something different
every cycle and appears nowhere the explorer can resolve. The reason
survived the loop; what it was about did not.

Record the item's title instead, via a shared learn_ref() helper (falls
back to the id when title-less, and reads target_task_title for Scales,
whose items name the live task they mutate).

* chore(lint): satisfy ruff 0.16 — keyword-only signatures and markdown formatting

The dev toolchain resolved ruff 0.16.0, which stabilises PLR0917 (too many
positional arguments) and formats python code blocks inside markdown. Both
fired repo-wide and neither had anything to do with the code they flagged.

- 36 signatures gain a `*` so their tail arguments are keyword-only, and
  the 104 call sites that passed them positionally are converted. mypy was
  the safety net for the static ones; the full suite caught nine more that
  only bind at runtime (the MCP tool functions, whose real callers already
  pass named JSON arguments).
- 28 markdown files reformatted by 0.16's code-block formatter.
- One RUF036 (`None` mid-union) autofixed in the GitLab provider.

* fix(gateway): log the reason when a verb rejects

A rejected envelope rides an HTTP 200, its body is never logged, and there
is no trace table — so in the access log a verb an agent could not satisfy
looks identical to one that worked. On 2026-07-25 four Board Programs
(Periscope, Sentinel, Scales, Barfly) each POSTed their propose verb three
or four times, persisted nothing, and left their exploration tasks PENDING;
the reason was unrecoverable afterwards, from the logs or from the agents'
own transcripts.

Log error/message/remediate/missing plus the calling agent at
envelope_to_response — the one chokepoint every v1 flow and do route
returns through. Success envelopes stay silent.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-26 15:07:28 +02:00
committed by GitHub
co-authored by Renn F
parent 401f8a2cc9
commit 879afc14a4
84 changed files with 932 additions and 567 deletions
@@ -127,7 +127,7 @@ async def test_i_will_work_on_matches_spec(
# Per-role claim authority (CLAIM_RULES) is now enforced inside
# spec.can_invoke_action when action == "claim", dispatched by
# can_invoke_intent. The verb's single gate is can_invoke_intent.
env = await c.i_will_work_on(agent_id, task_id, plan="my plan")
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, plan="my plan")
body = env.as_dict()
if expected.allowed:
# Verb may still fail downstream of the gate (e.g. claim() returns
@@ -352,8 +352,8 @@ async def test_dev_can_claim_pending_task_via_gateway(
c = Choreographer(deps)
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -401,8 +401,8 @@ async def test_dev_full_chain_through_awaiting_qa(
# 1. Claim
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -480,8 +480,8 @@ async def test_full_chain_through_doc_handoff(
# Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa).
await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
+6 -6
View File
@@ -420,8 +420,8 @@ async def test_dev_full_chain_through_awaiting_qa(
c = Choreographer(deps)
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -906,8 +906,8 @@ async def test_block_then_unblock_restore(
# Drive into in_progress via the real claim+start sequence.
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -969,8 +969,8 @@ async def test_pause_then_resume(
c = _build_choreographer(db_session, task, task_service)
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -723,8 +723,8 @@ async def test_i_am_done_full_chain_blocks_then_resolves(
# --- Round 1: no findings exist yet — i_am_done must pass untouched. ---
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -784,8 +784,8 @@ async def test_i_am_done_full_chain_blocks_then_resolves(
# re-enforces the rich-plan gate for a developer — so the full plan is
# supplied again here, same as the very first claim. ---
env = await c.i_will_work_on(
dev_agent.id,
task.id,
agent_id=dev_agent.id,
task_id=task.id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -57,13 +57,7 @@ class _MockChoreographer:
next="claim it",
)
async def i_will_work_on(
self,
_agent_id: object,
_task_id: object,
_plan: object = None,
**_kwargs: object,
) -> Envelope:
async def i_will_work_on(self, **_kwargs: object) -> Envelope:
self._state["task_status"] = "in_progress"
return Envelope.ok(
status="in_progress",
+3 -3
View File
@@ -77,9 +77,9 @@ async def test_i_will_work_on_dispatches_task_id() -> None:
assert body["status"] == "in_progress"
mock_chore.i_will_work_on.assert_awaited_once()
call_args = mock_chore.i_will_work_on.call_args
# second positional arg is task_id (UUID), third is plan
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "implement the feature"
# the verb's params are keyword-only past agent_id, so read the kwargs
assert str(call_args.kwargs["task_id"]) == _TASK_ID
assert call_args.kwargs["plan"] == "implement the feature"
@pytest.mark.asyncio
@@ -0,0 +1,60 @@
"""A rejected envelope must leave a server-side trace.
An error envelope rides a 200, so the access log cannot distinguish a verb an
agent could not satisfy from one that worked. Four Board Programs died that
way on 2026-07-25 with no recoverable reason.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from roboco.api.routes.v1._role_dep import envelope_to_response
from roboco.services.gateway.envelope import Envelope
from structlog.testing import capture_logs
def _request(path: str) -> MagicMock:
request = MagicMock()
request.url.path = path
request.state.correlation_id = "cid-1"
request.headers = {"X-Agent-ID": "agent-7", "X-Agent-Role": "head_marketing"}
return request
def _captured(env: Envelope, path: str) -> list[Any]:
"""``capture_logs`` rather than a hand-rolled processor swap — it is
independent of whatever global structlog config the rest of the suite has
already installed, which made the swap pass alone and fail in-suite."""
with capture_logs() as entries:
envelope_to_response(env=env, request=_request(path))
return [e for e in entries if e.get("event") == "verb rejected"]
def test_rejection_logs_reason_and_remediation() -> None:
env = Envelope.invalid_state(
message="finding 0 is missing 'source_url' — an uncited market claim is noise",
remediate="provide the http(s) source URL finding 0's claim came from",
context_briefing={},
)
entries = _captured(env, "/api/v1/do/propose_market_brief")
assert len(entries) == 1, entries
logged = entries[0]
assert logged["verb"] == "propose_market_brief"
assert logged["error"]
assert "source_url" in str(logged["detail"])
assert "http(s) source URL" in str(logged["remediate"])
assert logged["agent_id"] == "agent-7"
assert logged["agent_role"] == "head_marketing"
def test_success_envelope_logs_nothing() -> None:
env = Envelope.ok(
status="market_brief_proposed",
task_id="t-1",
next="i_am_idle()",
context_briefing={},
)
assert _captured(env, "/api/v1/do/propose_market_brief") == []
+19 -19
View File
@@ -68,8 +68,8 @@ async def test_off_mode_ceo_header_spoof_still_works(
new=AsyncMock(return_value=(aid, "ceo")),
):
ctx = await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id=CEO_AGENT_ID,
x_agent_role="ceo",
)
@@ -89,8 +89,8 @@ async def test_off_mode_developer_header_trust_unchanged(
new=AsyncMock(return_value=(aid, "be-dev-1")),
):
ctx = await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id="be-dev-1",
x_agent_role="developer",
x_agent_team="backend",
@@ -111,8 +111,8 @@ async def test_on_mode_spoofed_ceo_header_without_token_or_session_401(
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
with pytest.raises(HTTPException) as exc:
await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id=CEO_AGENT_ID,
x_agent_role="ceo",
)
@@ -127,7 +127,7 @@ async def test_on_mode_no_headers_no_cookie_401(
401s without a valid session never silently falls back to anything."""
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
with pytest.raises(HTTPException) as exc:
await get_agent_context(MagicMock(), MagicMock())
await get_agent_context(db=MagicMock(), response=MagicMock())
assert exc.value.status_code == _HTTP_401
@@ -154,9 +154,7 @@ async def test_on_mode_valid_session_yields_ceo_context_and_slides_cookie(
response = Response()
ctx = await get_agent_context(
db_session,
response,
roboco_session=token,
db=db_session, response=response, roboco_session=token
)
assert ctx.role == AgentRole.CEO
@@ -174,7 +172,9 @@ async def test_on_mode_invalid_session_cookie_401(
) -> None:
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
with pytest.raises(HTTPException) as exc:
await get_agent_context(db_session, Response(), roboco_session="not-a-real-jwt")
await get_agent_context(
db=db_session, response=Response(), roboco_session="not-a-real-jwt"
)
assert exc.value.status_code == _HTTP_401
@@ -192,8 +192,8 @@ async def test_on_mode_agent_hmac_still_works(
new=AsyncMock(return_value=(aid, "be-dev-1")),
):
ctx = await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id=str(aid),
x_agent_role="developer",
x_agent_team="backend",
@@ -212,8 +212,8 @@ async def test_on_mode_system_self_patch_still_works(
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
token = issue_agent_token(_SYSTEM_AGENT_ID, "system", "")
ctx = await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id=_SYSTEM_AGENT_ID,
x_agent_role="system",
x_agent_token=token,
@@ -228,8 +228,8 @@ async def test_on_mode_forged_agent_token_401(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
with pytest.raises(HTTPException) as exc:
await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id="be-dev-1",
x_agent_role="developer",
x_agent_token="forged",
@@ -248,8 +248,8 @@ async def test_on_mode_spoofed_non_ceo_role_without_token_401(
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
with pytest.raises(HTTPException) as exc:
await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id="main-pm",
x_agent_role="main_pm",
)
+1 -1
View File
@@ -228,7 +228,7 @@ def test_do_server_attaches_correlation_id_header(
) -> None:
fake = _fake_client({"status": "noted"})
with patch("httpx.Client", return_value=fake):
do_module.note("hi")
do_module.note(text="hi")
_args, kwargs = fake.post.call_args
headers = kwargs["headers"]
assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
+16 -10
View File
@@ -113,7 +113,9 @@ def test_get_orchestrator_raises_503_when_unset() -> None:
@pytest.mark.asyncio
async def test_get_current_agent_id_raises_when_header_missing() -> None:
with pytest.raises(HTTPException) as exc:
await get_current_agent_id(MagicMock(), MagicMock(), x_agent_id=None)
await get_current_agent_id(
db=MagicMock(), response=MagicMock(), x_agent_id=None
)
assert exc.value.status_code == _HTTP_401
@@ -125,7 +127,7 @@ async def test_get_current_agent_id_returns_uuid() -> None:
new=AsyncMock(return_value=expected),
):
out = await get_current_agent_id(
MagicMock(), MagicMock(), x_agent_id="be-dev-1"
db=MagicMock(), response=MagicMock(), x_agent_id="be-dev-1"
)
assert out == expected
@@ -133,13 +135,17 @@ async def test_get_current_agent_id_returns_uuid() -> None:
@pytest.mark.asyncio
async def test_get_current_agent_slug_raises_when_header_missing() -> None:
with pytest.raises(HTTPException) as exc:
await get_current_agent_slug(MagicMock(), MagicMock(), x_agent_id=None)
await get_current_agent_slug(
db=MagicMock(), response=MagicMock(), x_agent_id=None
)
assert exc.value.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_get_current_agent_slug_returns_header() -> None:
out = await get_current_agent_slug(MagicMock(), MagicMock(), x_agent_id="be-dev-1")
out = await get_current_agent_slug(
db=MagicMock(), response=MagicMock(), x_agent_id="be-dev-1"
)
assert out == "be-dev-1"
@@ -494,8 +500,8 @@ def test_coerce_agent_team_empty_returns_none() -> None:
async def test_get_agent_context_missing_id_raises() -> None:
with pytest.raises(HTTPException) as exc:
await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id=None,
x_agent_role="developer",
)
@@ -506,8 +512,8 @@ async def test_get_agent_context_missing_id_raises() -> None:
async def test_get_agent_context_missing_role_raises() -> None:
with pytest.raises(HTTPException) as exc:
await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id="be-dev-1",
x_agent_role=None,
)
@@ -523,8 +529,8 @@ async def test_get_agent_context_happy_path(monkeypatch: pytest.MonkeyPatch) ->
new=AsyncMock(return_value=(aid, "be-dev-1")),
):
ctx = await get_agent_context(
MagicMock(),
MagicMock(),
db=MagicMock(),
response=MagicMock(),
x_agent_id="be-dev-1",
x_agent_role="developer",
x_agent_team="backend",
@@ -95,7 +95,7 @@ async def test_pm_cannot_execute_code_writes_audit_row() -> None:
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(aid, tid, plan="x")
env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x")
assert env.error == "not_authorized"
audit_svc.log_event.assert_awaited()
@@ -215,7 +215,7 @@ async def test_rejection_remediate_lands_in_audit_details() -> None:
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(aid, tid, plan="x")
env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x")
assert env.error == "not_authorized"
assert env.remediate
@@ -239,7 +239,9 @@ async def test_i_will_work_on_refuses_at_cap() -> None:
task_svc.project_month_spend_usd = AsyncMock(return_value=999.0)
c = Choreographer(_make_deps(task_svc))
env = await c.i_will_work_on(agent_id, task_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert "10.00" in body["message"] and "999.00" in body["message"]
@@ -131,7 +131,9 @@ async def test_i_will_work_on_blocks_when_agent_has_in_progress_task() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, target_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=target_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert str(other_id) in body["message"] or str(other_id) in body["remediate"]
@@ -165,7 +167,7 @@ async def test_i_will_work_on_resumption_does_not_self_block() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, steps=_STEPS)
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, steps=_STEPS)
assert env.error is None
task_svc.start.assert_awaited_once_with(task_id, agent_id)
@@ -195,7 +197,9 @@ async def test_i_will_work_on_blocks_when_agent_has_paused_task() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, target_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=target_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert str(paused_id) in body["remediate"]
@@ -226,7 +230,9 @@ async def test_cell_pm_cannot_claim_code_task_via_i_will_work_on() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(pm_id, task_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=pm_id, task_id=task_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "not_authorized"
# Spec produces "role 'cell_pm' may not call 'i_will_work_on'".
@@ -253,7 +259,9 @@ async def test_main_pm_cannot_claim_code_task_via_i_will_work_on() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(pm_id, task_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=pm_id, task_id=task_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "not_authorized"
@@ -403,7 +411,7 @@ async def test_developer_cannot_claim_qa_status_task() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(dev_id, task_id, steps=_STEPS)
env = await c.i_will_work_on(agent_id=dev_id, task_id=task_id, steps=_STEPS)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "developer" in body["message"]
@@ -494,7 +502,9 @@ async def test_non_developer_role_cannot_claim_via_i_will_work_on() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(doc_id, task_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=doc_id, task_id=task_id, plan="x", steps=_STEPS
)
body = env.as_dict()
# Role-typed claim refuses with not_authorized
assert body["error"] == "not_authorized"
@@ -149,8 +149,8 @@ async def test_dev_claim_acquires_lock_before_guard_read() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
+9 -9
View File
@@ -167,8 +167,8 @@ async def test_i_will_work_on_pending_with_plan() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -213,7 +213,7 @@ async def test_i_will_work_on_pending_no_plan_returns_tracing_gap() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None)
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, plan=None)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
@@ -263,8 +263,8 @@ async def test_i_will_work_on_needs_revision_re_starts() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -283,7 +283,7 @@ async def test_i_will_work_on_task_not_found_returns_not_found() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id)
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "not_found"
@@ -316,7 +316,7 @@ async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id)
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "invalid_state"
# Spec produces "task is in 'completed', 'claim' requires: ..."
@@ -376,8 +376,8 @@ async def test_i_will_work_on_blocks_when_journal_note_at_claim_missing() -> Non
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -173,8 +173,8 @@ async def test_i_will_work_on_pending_claim_raises_returns_invalid_state() -> No
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -196,8 +196,8 @@ async def test_i_will_work_on_pending_claim_returns_none_invalid_state() -> None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -224,7 +224,9 @@ async def test_i_will_work_on_pending_no_plan_tracing_gap() -> None:
task_svc.claim.return_value = claimed_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None, steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan=None, steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
@@ -249,8 +251,8 @@ async def test_i_will_work_on_start_returns_none_invalid_state() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -279,8 +281,8 @@ async def test_needs_revision_branch_claim_fails_invalid_state() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -302,8 +304,8 @@ async def test_needs_revision_branch_start_fails() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -329,7 +331,9 @@ async def test_claimed_branch_returns_start_failed() -> None:
task_svc.start.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state"
@@ -350,7 +354,9 @@ async def test_i_will_work_on_in_progress_assigned_to_self_idempotent() -> None:
task_svc.heartbeat = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS
)
body = env.as_dict()
# No error — re-entry pass.
assert "error" not in body or body.get("error") is None
@@ -1095,7 +1101,9 @@ async def test_claimed_branch_already_active_guard() -> None:
task_svc.list_in_progress_for_agent.return_value = [in_prog]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state"
@@ -1231,8 +1239,8 @@ async def test_i_will_work_on_envelope_carries_introspection_on_success() -> Non
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -1258,7 +1266,9 @@ async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> N
task_svc = _wire_dev_task_svc(task_id, status="completed", assigned_to=agent_id)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="x", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="x", steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert body["current_state"] == "completed"
@@ -1319,7 +1329,9 @@ async def test_i_will_work_on_missing_plan_does_not_claim_pending_task() -> None
task_svc = _wire_dev_task_svc(task_id, status="pending")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan=None, steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan=None, steps=_STEPS
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
@@ -1354,7 +1366,9 @@ async def test_i_will_work_on_claimed_with_no_plan_accepts_recovery_plan() -> No
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, plan="recovery plan", steps=_STEPS)
env = await c.i_will_work_on(
agent_id=agent_id, task_id=task_id, plan="recovery plan", steps=_STEPS
)
body = env.as_dict()
assert body["error"] is None, f"expected success, got {body}"
task_svc.set_plan.assert_awaited_once()
+5 -5
View File
@@ -148,8 +148,8 @@ async def test_i_will_work_on_pending_calls_claim_with_task_id_first() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -207,8 +207,8 @@ async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() ->
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -261,7 +261,7 @@ async def test_i_will_work_on_claimed_resumption_calls_start_with_task_id_first(
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(agent_id, task_id, steps=_STEPS)
env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, steps=_STEPS)
task_svc.start.assert_awaited_once_with(task_id, agent_id)
assert env.error is None
+11 -7
View File
@@ -115,7 +115,7 @@ async def test_dev_fresh_claim_without_steps_is_rejected() -> None:
task_id = uuid4()
c = Choreographer(_make_deps(task=_dev_task_svc(task_id)))
env = await c.i_will_work_on(dev_id, task_id, plan="do the thing")
env = await c.i_will_work_on(agent_id=dev_id, task_id=task_id, plan="do the thing")
body = env.as_dict()
assert body["error"] == "incomplete_input", body
assert "steps" in (body.get("missing") or []), body
@@ -128,8 +128,8 @@ async def test_dev_thin_step_description_is_rejected() -> None:
c = Choreographer(_make_deps(task=_dev_task_svc(task_id)))
env = await c.i_will_work_on(
dev_id,
task_id,
agent_id=dev_id,
task_id=task_id,
plan="do the thing",
steps=[{"title": "Edit README", "description": "edit it"}],
)
@@ -162,7 +162,9 @@ async def test_dev_with_substantive_steps_passes_gate_and_persists_checklist() -
{"title": "Edit README", "description": _GOOD_STEP_DESC},
{"title": "Commit + open PR", "description": _GOOD_STEP_DESC},
]
env = await c.i_will_work_on(dev_id, task_id, **_full_plan_kwargs(steps_in))
env = await c.i_will_work_on(
agent_id=dev_id, task_id=task_id, **_full_plan_kwargs(steps_in)
)
body = env.as_dict()
assert body.get("error") != "incomplete_input", body
# The full rich plan was layered into the panel-shaped dict and persisted,
@@ -188,8 +190,8 @@ async def test_dev_fresh_claim_missing_considerations_and_risks_rejected() -> No
c = Choreographer(_make_deps(task=_dev_task_svc(task_id)))
env = await c.i_will_work_on(
dev_id,
task_id,
agent_id=dev_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=[{"title": "Edit README", "description": _GOOD_STEP_DESC}],
)
@@ -210,7 +212,9 @@ async def test_dev_reentry_in_progress_short_circuits_before_steps_gate() -> Non
svc.get.return_value.assigned_to = dev_id
c = Choreographer(_make_deps(task=svc))
env = await c.i_will_work_on(dev_id, task_id, plan="resume: keep going")
env = await c.i_will_work_on(
agent_id=dev_id, task_id=task_id, plan="resume: keep going"
)
body = env.as_dict()
assert body.get("error") is None, body
assert body.get("status") == "in_progress", body
@@ -95,7 +95,7 @@ async def test_heartbeat_fires_on_rejection_not_authorized() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_work_on(aid, tid, plan="x")
env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x")
assert env.error == "not_authorized"
task_svc.heartbeat.assert_awaited_with(tid)
+1 -1
View File
@@ -86,7 +86,7 @@ async def test_i_will_work_on_calls_heartbeat() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_will_work_on(aid, tid, plan="go")
await c.i_will_work_on(agent_id=aid, task_id=tid, plan="go")
task_svc.heartbeat.assert_awaited_with(tid)
@@ -67,7 +67,12 @@ async def test_superseded_closes_pr_and_completes_without_merge(
)
env = await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/frontend/root--cell", "notes", _EXC
pm_agent_id=uuid4(),
task_id=uuid4(),
t=t,
target="feature/frontend/root--cell",
notes="notes",
exc=_EXC,
)
git.close_pull_request.assert_awaited_once()
@@ -100,7 +105,12 @@ async def test_rebased_retries_merge_and_completes(
)
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
pm_agent_id=uuid4(),
task_id=uuid4(),
t=t,
target="feature/backend/root--cell",
notes="notes",
exc=_EXC,
)
git.pr_merge.assert_awaited_once()
@@ -133,7 +143,12 @@ async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
)
env = await choreo._resolve_merge_conflict_on_complete(
uuid4(), tid, t, "feature/backend/root--cell", "notes", _EXC
pm_agent_id=uuid4(),
task_id=tid,
t=t,
target="feature/backend/root--cell",
notes="notes",
exc=_EXC,
)
task.admin_set_status.assert_awaited_once()
@@ -171,7 +186,12 @@ async def test_diverged_rebase_outcome_escalates_rather_than_completing(
)
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
pm_agent_id=uuid4(),
task_id=uuid4(),
t=t,
target="feature/backend/root--cell",
notes="notes",
exc=_EXC,
)
task.admin_set_status.assert_awaited_once()
@@ -198,7 +218,12 @@ async def test_unknown_rebase_outcome_escalates_rather_than_completing(
)
await choreo._resolve_merge_conflict_on_complete(
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
pm_agent_id=uuid4(),
task_id=uuid4(),
t=t,
target="feature/backend/root--cell",
notes="notes",
exc=_EXC,
)
task.admin_set_status.assert_awaited_once()
@@ -148,8 +148,8 @@ async def test_i_will_work_on_calls_ensure_work_session() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
@@ -290,8 +290,8 @@ async def test_ensure_work_session_not_called_when_start_fails() -> None:
c = Choreographer(deps)
env = await c.i_will_work_on(
agent_id,
task_id,
agent_id=agent_id,
task_id=task_id,
plan=_GOOD_PLAN,
steps=_STEPS,
technical_considerations=_GOOD_TC,
+2 -2
View File
@@ -74,7 +74,7 @@ def test_note_default_scope_note(do_module: Any) -> None:
fake_client.post.return_value = fake_response
with patch("httpx.Client", return_value=fake_client):
do_module.note("hello world")
do_module.note(text="hello world")
_args, kwargs = fake_client.post.call_args
assert kwargs["json"]["scope"] == "note"
@@ -88,7 +88,7 @@ def test_note_with_scope_reflect(do_module: Any) -> None:
fake_client.post.return_value = fake_response
with patch("httpx.Client", return_value=fake_client):
do_module.note("did x", scope="reflect")
do_module.note(text="did x", scope="reflect")
_args, kwargs = fake_client.post.call_args
assert kwargs["json"]["scope"] == "reflect"
@@ -105,7 +105,7 @@ def test_flow_post_returns_envelope_on_400(flow_module: types.ModuleType) -> Non
}
client = _fake_client_with(400, body)
with patch("httpx.Client", return_value=client):
result = flow_module.i_will_work_on("task-id", plan="x")
result = flow_module.i_will_work_on(task_id="task-id", plan="x")
assert result["error"] == "not_authorized"
+3 -3
View File
@@ -192,7 +192,7 @@ def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "in_progress"})
with patch("httpx.Client", return_value=fake_client):
flow_module.i_will_work_on("task-uuid", plan="my plan")
flow_module.i_will_work_on(task_id="task-uuid", plan="my plan")
args, kwargs = fake_client.post.call_args
assert kwargs["json"] == {
@@ -210,7 +210,7 @@ def test_i_will_work_on_plan_defaults_to_none(flow_module: types.ModuleType) ->
fake_client = _make_fake_client({"status": "in_progress"})
with patch("httpx.Client", return_value=fake_client):
flow_module.i_will_work_on("task-uuid")
flow_module.i_will_work_on(task_id="task-uuid")
_, kwargs = fake_client.post.call_args
assert kwargs["json"] == {
@@ -234,7 +234,7 @@ def test_i_will_work_on_passes_steps(flow_module: types.ModuleType) -> None:
]
with patch("httpx.Client", return_value=fake_client):
flow_module.i_will_work_on("task-uuid", plan="p", steps=steps)
flow_module.i_will_work_on(task_id="task-uuid", plan="p", steps=steps)
_, kwargs = fake_client.post.call_args
assert kwargs["json"] == {
@@ -471,6 +471,23 @@ async def test_prior_cycle_context_renders_rejections_with_reasons(
assert "item-2 — too risky" in context
def test_learn_ref_names_the_item_not_its_per_cycle_index() -> None:
"""The ref reaches the next cycle's prompt, so it must say WHAT was
decided ``item-1`` means something different in every cycle."""
assert bp_module.learn_ref({"id": "item-1", "title": "Fix the CLA gate"}) == (
"Fix the CLA gate"
)
# Scales items name the live task they mutate instead of a draft title.
assert bp_module.learn_ref(
{"id": "item-0", "target_task_title": "Panel UX wave"}
) == ("Panel UX wave")
# A title-less item degrades to the old behaviour rather than an empty ref.
assert bp_module.learn_ref({"id": "item-2"}) == "item-2"
assert bp_module.learn_ref({"id": "item-3", "title": " "}) == "item-3"
cap = 80
assert len(bp_module.learn_ref({"id": "item-4", "title": "x" * 200})) == cap
def test_originators_cover_exactly_the_registry() -> None:
assert set(bp_module._ORIGINATORS) == set(PROGRAMS)
+8 -2
View File
@@ -418,8 +418,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -442,8 +445,11 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
+8 -2
View File
@@ -421,8 +421,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -445,8 +448,11 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
@@ -415,8 +415,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -439,8 +442,11 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
+7 -2
View File
@@ -413,8 +413,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -437,8 +440,10 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# Title, not index — the reject reason is only useful to the next cycle
# if it says which proposal it was about.
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
+8 -2
View File
@@ -528,8 +528,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Stale task",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -553,8 +556,11 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Stale task",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions
+8 -2
View File
@@ -418,8 +418,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "approved",
"reason": None,
} in row.decisions
@@ -442,8 +445,11 @@ async def test_reject_records_learn_decision_with_reason(
)
).scalar_one()
assert row.items_rejected == ONE
# The ref is the item's TITLE, not its per-cycle index: this row is
# rendered into the next cycle's exploration prompt, where "item-0"
# names nothing (see BoardProgramEngine.learn_ref).
assert {
"item_ref": "item-0",
"item_ref": "Item 0",
"verdict": "rejected",
"reason": "not a priority",
} in row.decisions