fix(orchestrator): let a failed Board Program exploration retry (#706)

`_board_dispatched` is an in-memory, never-expiring set of
(agent_slug, task_id). The solo exploration dispatchers consulted it, so an
exploration whose propose verb rejected was never retried for the life of the
process — Periscope, Sentinel, Scales and Barfly each spawned once on
2026-07-25, failed, and sat PENDING until the stack restarted.

The guard was written for the two-reviewer board REVIEW pass, where it is
correct: a reviewer has no verb to advance the task, so a respawn can only
loop. An explorer is the opposite — `propose_*` is exactly such a verb, so a
respawn can and should advance it.

Drop it from the 15 `_dispatch_*_exploration` functions. Bounding falls to
`_pm_respawn_should_gate`, which is what actually bounds a loop: DB-persisted,
reset by a status change, and cooled down so a deploy that fixes the cause
lets the work resume. `_dispatch_board_reviewer` keeps the set (its
`_board_review_complete` reads it as a has-run signal) as does vault
curation (same-process race guard behind its own durable marker).

The 14 `*_dispatch_is_one_shot` tests asserted the old contract on the false
rationale that board roles have no progression verb; they now assert a second
tick re-attempts.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-26 15:52:33 +02:00
committed by GitHub
co-authored by Renn F
parent 879afc14a4
commit a36a180b73
15 changed files with 208 additions and 125 deletions
+26 -76
View File
@@ -1230,6 +1230,17 @@ class AgentOrchestrator:
# review pass per assigned task: they have no verb to claim, plan,
# delegate, or complete, so a respawn cannot advance the task and would
# just loop. Tracks (agent_slug, task_id) already dispatched.
#
# Scope is the two-reviewer board REVIEW pass (plus vault curation's
# same-process race guard, which has its own durable marker behind it)
# — NOT the solo Board Program exploration cycles. Those used it too
# until 2026-07-26, and because the set never expires and lives only in
# memory, an exploration whose propose verb rejected was never retried
# for the life of the process: Periscope, Sentinel, Scales and Barfly
# each spawned once, failed, and sat PENDING until a restart. They now
# rely on ``_pm_respawn_should_gate`` alone, which is the guard that
# actually bounds a loop — DB-persisted, reset by a status change, and
# cooled down so a deploy that fixes the cause lets the work resume.
self._board_dispatched: set[tuple[str, str]] = set()
# Cross-tick damper for notification-triggered spawns (escalation /
# approval / audit / a2a). Those dispatchers carry no task_id, so the
@@ -12878,7 +12889,7 @@ Start now: evidence(task_id="{task_id}")
"Approve & Start" notification fires. ``propose_roadmap`` (not this
dispatcher) marks the cycle authored (a ``roadmap_cycle`` marker); this
only ever spawns once per task while that marker is absent, reusing the
same one-shot ``_board_dispatched`` tracker + respawn breaker every
respawn breaker every
other board dispatch uses.
"""
task_id = str(task.get("id"))
@@ -12888,12 +12899,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner"
if self._is_agent_active(po_slug):
return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for roadmap exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("roadmap")
market_brief_context = await self._periscope_brief_context()
@@ -12931,7 +12938,7 @@ Start now: evidence(task_id="{task_id}")
"""One-shot Product-Owner spawn to author a Pest Control bug hunt.
Mirrors ``_dispatch_roadmap_exploration`` exactly (PO-solo, the same
one-shot ``_board_dispatched`` tracker + respawn breaker, the same
respawn breaker
"already authored" marker pre-check ``propose_bug_hunt`` marks it
via the ``pest_hunt`` marker, not this dispatcher). The extra step is
the server-assembled evidence context (rework hotspots + findings-
@@ -12944,12 +12951,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner"
if self._is_agent_active(po_slug):
return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Product Owner for pest-control exploration", task_id=task_id
)
@@ -12984,7 +12987,7 @@ Start now: evidence(task_id="{task_id}")
"""One-shot Product-Owner spawn to author a Scales rebalance plan.
Mirrors ``_dispatch_pest_control_exploration`` exactly (PO-solo, the
same one-shot ``_board_dispatched`` tracker + respawn breaker, the
respawn breaker the
same "already authored" marker pre-check ``propose_rebalance``
marks it via the ``rebalance_plan`` marker, not this dispatcher). The
extra step is the server-assembled stale-backlog snapshot the PO
@@ -12997,12 +13000,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner"
if self._is_agent_active(po_slug):
return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for scales exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("scales")
evidence_context = await self._scales_evidence_context()
@@ -13039,8 +13038,7 @@ Start now: evidence(task_id="{task_id}")
pest-control "already authored" marker shape): no pre-check is
needed ``propose_postmortem`` completes this task atomically, so a
successful call stops it matching the PENDING fetch on the next
tick. Reuses the same one-shot ``_board_dispatched`` tracker +
respawn breaker every other board dispatch uses. EVENT-triggered
tick. Reuses respawn breaker every other board dispatch uses. EVENT-triggered
(spec §4): this task only ever exists because
``CoronerEngine.open_for_incident`` opened it there is no LEARN
"prior cycles" injection here (no cron cadence to have learned from).
@@ -13049,12 +13047,8 @@ Start now: evidence(task_id="{task_id}")
auditor_slug = "auditor"
if self._is_agent_active(auditor_slug):
return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for Coroner postmortem", task_id=task_id)
incident_context = await self._coroner_incident_context(task)
await self.spawn_agent(
@@ -13095,7 +13089,7 @@ Start now: evidence(task_id="{task_id}")
"already authored" marker shape): no pre-check is needed
``propose_campaign`` completes this task atomically, so a successful
call stops it matching the PENDING fetch on the next tick. Reuses the
same one-shot ``_board_dispatched`` tracker + respawn breaker every
respawn breaker every
other board dispatch uses. EVENT-triggered (spec §4): this task only
ever exists because a release just published or the CEO called "run
now" — there is no LEARN "prior cycles" injection here (no cron
@@ -13108,12 +13102,8 @@ Start now: evidence(task_id="{task_id}")
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for War Room campaign planning",
task_id=task_id,
@@ -13130,7 +13120,7 @@ Start now: evidence(task_id="{task_id}")
"""One-shot Product-Owner spawn to author a Spackle gap-fill audit.
Mirrors ``_dispatch_pest_control_exploration`` exactly (PO-solo, the
same one-shot ``_board_dispatched`` tracker + respawn breaker, the
respawn breaker the
same "already authored" marker pre-check ``propose_gap_fill`` marks
it via the ``gap_fill`` marker, not this dispatcher). Unlike Pest
Control there is no server-assembled evidence context the spec
@@ -13145,12 +13135,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner"
if self._is_agent_active(po_slug):
return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for spackle exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("spackle")
await self.spawn_agent(
@@ -13166,7 +13152,7 @@ Start now: evidence(task_id="{task_id}")
audit.
Mirrors ``_dispatch_spackle_exploration`` exactly (HoM-solo, the same
one-shot ``_board_dispatched`` tracker + respawn breaker, the same
respawn breaker
"already authored" marker pre-check ``propose_messaging_fixes``
marks it via the ``messaging_fixes`` marker, not this dispatcher).
Like Spackle there is no server-assembled evidence context the
@@ -13181,12 +13167,8 @@ Start now: evidence(task_id="{task_id}")
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for mirror exploration", task_id=task_id
)
@@ -13204,7 +13186,7 @@ Start now: evidence(task_id="{task_id}")
Dogfood friction audit.
Mirrors ``_dispatch_spackle_exploration`` exactly (PO-solo, the same
one-shot ``_board_dispatched`` tracker + respawn breaker, the same
respawn breaker
"already authored" marker pre-check ``propose_friction_fixes``
marks it via the ``friction_fixes`` marker, not this dispatcher).
There is no server-assembled evidence context walking the product
@@ -13220,12 +13202,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner"
if self._is_agent_active(po_slug):
return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for dogfood exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("dogfood")
await self.spawn_agent(
@@ -13246,19 +13224,14 @@ Start now: evidence(task_id="{task_id}")
completes this task atomically (it stops matching the PENDING fetch on the
next tick) unlike the roadmap cycle, which stays open across the CEO's
per-item decisions and needs the marker check to avoid re-spawning the PO
after authoring. Reuses the same one-shot _board_dispatched tracker +
respawn breaker every other board dispatch uses.
after authoring. Reuses the respawn breaker every other board dispatch uses.
"""
task_id = str(task.get("id"))
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for feature-spotlight exploration",
task_id=task_id,
@@ -13280,19 +13253,14 @@ Start now: evidence(task_id="{task_id}")
authored" marker pre-check is needed — ``propose_market_brief``
completes this task atomically (the x_feature complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn
breaker every other board dispatch uses.
Reuses respawn breaker every other board dispatch uses.
"""
task_id = str(task.get("id"))
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for periscope exploration", task_id=task_id
)
@@ -13314,19 +13282,15 @@ Start now: evidence(task_id="{task_id}")
completes this task atomically (the x_feature/periscope complete-at-
propose asymmetry, multiplied across every materialized reply), so
it stops matching the PENDING fetch on the next tick. Reuses the
same one-shot ``_board_dispatched`` tracker + respawn breaker every
respawn breaker every
other board dispatch uses.
"""
task_id = str(task.get("id"))
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for barfly exploration", task_id=task_id
)
@@ -13363,8 +13327,7 @@ Start now: evidence(task_id="{task_id}")
marker pre-check is needed ``propose_quality_report`` completes
this task atomically (the x_feature/periscope complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn
breaker every other board dispatch uses. The extra step is the
Reuses respawn breaker every other board dispatch uses. The extra step is the
server-assembled drift evidence (waived-findings trend, open-
findings-by-severity, conventions hotspots, budget snapshot) the
Auditor cannot gather itself mirrors
@@ -13374,12 +13337,8 @@ Start now: evidence(task_id="{task_id}")
auditor_slug = "auditor"
if self._is_agent_active(auditor_slug):
return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for sentinel exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("sentinel")
evidence_context = await self._sentinel_evidence_context()
@@ -13416,8 +13375,7 @@ Start now: evidence(task_id="{task_id}")
marker pre-check is needed ``propose_editorial_post`` completes
this task atomically (the x_feature/periscope complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn
breaker every other board dispatch uses. The extra step is the
Reuses respawn breaker every other board dispatch uses. The extra step is the
server-assembled shipped-this-week digest (completed tasks +
CHANGELOG Unreleased bullets) the Head of Marketing cannot gather
itself mirrors ``_dispatch_pest_control_exploration``'s
@@ -13427,12 +13385,8 @@ Start now: evidence(task_id="{task_id}")
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for megaphone exploration", task_id=task_id
)
@@ -13471,8 +13425,8 @@ Start now: evidence(task_id="{task_id}")
marker pre-check is needed ``propose_playbook_drafts`` completes
this task atomically (the x_feature/periscope/sentinel complete-at-
propose asymmetry), so it stops matching the PENDING fetch on the
next tick. Reuses the same one-shot ``_board_dispatched`` tracker +
respawn breaker every other board dispatch uses. The extra step is
next tick. Reuses the respawn breaker every other board dispatch
uses. The extra step is
the server-assembled mining context (recurring learning-journal
topics + existing playbook titles) the Auditor cannot gather itself
mirrors ``_dispatch_sentinel_exploration``'s evidence-context
@@ -13482,12 +13436,8 @@ Start now: evidence(task_id="{task_id}")
auditor_slug = "auditor"
if self._is_agent_active(auditor_slug):
return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task):
return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for librarian exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("librarian")
mining_context = await self._librarian_mining_context()
+13 -4
View File
@@ -58,9 +58,15 @@ async def test_barfly_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio
async def test_barfly_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_barfly_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _barfly_task()
with (
@@ -71,7 +77,10 @@ async def test_barfly_dispatch_is_one_shot() -> None:
await orch._dispatch_barfly_exploration(task)
await orch._dispatch_barfly_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -67,8 +67,15 @@ async def test_coroner_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio
async def test_coroner_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending autopsy must NOT respawn."""
async def test_coroner_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _coroner_task()
with (
@@ -80,7 +87,10 @@ async def test_coroner_dispatch_is_one_shot() -> None:
await orch._dispatch_coroner_exploration(task)
await orch._dispatch_coroner_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -61,8 +61,15 @@ async def test_dogfood_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio
async def test_dogfood_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
async def test_dogfood_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _dogfood_task()
with (
@@ -73,7 +80,10 @@ async def test_dogfood_dispatch_is_one_shot() -> None:
await orch._dispatch_dogfood_exploration(task)
await orch._dispatch_dogfood_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
@@ -57,9 +57,15 @@ async def test_feature_spotlight_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_feature_spotlight_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _feature_task()
with (
@@ -70,7 +76,10 @@ async def test_feature_spotlight_dispatch_is_one_shot() -> None:
await orch._dispatch_feature_spotlight_exploration(task)
await orch._dispatch_feature_spotlight_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_librarian_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio
async def test_librarian_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending mining task must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_librarian_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _librarian_task()
with (
@@ -72,7 +78,10 @@ async def test_librarian_dispatch_is_one_shot() -> None:
await orch._dispatch_librarian_exploration(task)
await orch._dispatch_librarian_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -4
View File
@@ -58,9 +58,15 @@ async def test_megaphone_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio
async def test_megaphone_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_megaphone_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _megaphone_task()
with (
@@ -71,7 +77,10 @@ async def test_megaphone_dispatch_is_one_shot() -> None:
await orch._dispatch_megaphone_exploration(task)
await orch._dispatch_megaphone_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -57,8 +57,15 @@ async def test_mirror_dispatch_spawns_only_head_of_marketing() -> None:
@pytest.mark.asyncio
async def test_mirror_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
async def test_mirror_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task — unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _mirror_task()
with (
@@ -69,7 +76,10 @@ async def test_mirror_dispatch_is_one_shot() -> None:
await orch._dispatch_mirror_exploration(task)
await orch._dispatch_mirror_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_periscope_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio
async def test_periscope_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_periscope_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _periscope_task()
with (
@@ -72,7 +78,10 @@ async def test_periscope_dispatch_is_one_shot() -> None:
await orch._dispatch_periscope_exploration(task)
await orch._dispatch_periscope_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
@@ -57,8 +57,15 @@ async def test_pest_control_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio
async def test_pest_control_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
async def test_pest_control_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _pest_control_task()
with (
@@ -69,7 +76,10 @@ async def test_pest_control_dispatch_is_one_shot() -> None:
await orch._dispatch_pest_control_exploration(task)
await orch._dispatch_pest_control_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -4
View File
@@ -57,9 +57,15 @@ async def test_roadmap_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio
async def test_roadmap_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn —
board roles have no progression verb, so a respawn would just loop."""
async def test_roadmap_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _roadmap_task()
with (
@@ -70,7 +76,10 @@ async def test_roadmap_dispatch_is_one_shot() -> None:
await orch._dispatch_roadmap_exploration(task)
await orch._dispatch_roadmap_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -57,8 +57,15 @@ async def test_scales_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio
async def test_scales_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
async def test_scales_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _scales_task()
with (
@@ -69,7 +76,10 @@ async def test_scales_dispatch_is_one_shot() -> None:
await orch._dispatch_scales_exploration(task)
await orch._dispatch_scales_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_sentinel_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio
async def test_sentinel_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
async def test_sentinel_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _sentinel_task()
with (
@@ -72,7 +78,10 @@ async def test_sentinel_dispatch_is_one_shot() -> None:
await orch._dispatch_sentinel_exploration(task)
await orch._dispatch_sentinel_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -57,8 +57,15 @@ async def test_spackle_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio
async def test_spackle_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn."""
async def test_spackle_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _spackle_task()
with (
@@ -69,7 +76,10 @@ async def test_spackle_dispatch_is_one_shot() -> None:
await orch._dispatch_spackle_exploration(task)
await orch._dispatch_spackle_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio
+13 -3
View File
@@ -67,8 +67,15 @@ async def test_war_room_dispatch_spawns_only_hom() -> None:
@pytest.mark.asyncio
async def test_war_room_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending campaign-planning task must NOT respawn."""
async def test_war_room_dispatch_retries_until_breaker() -> None:
"""A failed exploration must be retried, not abandoned.
The explorer has a progression verb (``propose_*``), so a respawn CAN
advance the task unlike the two-reviewer review pass this guard was
originally written for. Bounding belongs to
``_pm_respawn_should_gate`` (DB-persisted, reset by a status change),
not to a never-expiring in-memory set.
"""
orch = _make_orch()
task = _war_room_task()
with (
@@ -79,7 +86,10 @@ async def test_war_room_dispatch_is_one_shot() -> None:
await orch._dispatch_war_room_exploration(task)
await orch._dispatch_war_room_exploration(task)
spawn.assert_awaited_once()
ticks = 2
assert spawn.await_count == ticks, (
"a second tick must re-attempt a failed exploration"
)
@pytest.mark.asyncio