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, # review pass per assigned task: they have no verb to claim, plan,
# delegate, or complete, so a respawn cannot advance the task and would # delegate, or complete, so a respawn cannot advance the task and would
# just loop. Tracks (agent_slug, task_id) already dispatched. # 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() self._board_dispatched: set[tuple[str, str]] = set()
# Cross-tick damper for notification-triggered spawns (escalation / # Cross-tick damper for notification-triggered spawns (escalation /
# approval / audit / a2a). Those dispatchers carry no task_id, so the # 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 "Approve & Start" notification fires. ``propose_roadmap`` (not this
dispatcher) marks the cycle authored (a ``roadmap_cycle`` marker); this dispatcher) marks the cycle authored (a ``roadmap_cycle`` marker); this
only ever spawns once per task while that marker is absent, reusing the 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. other board dispatch uses.
""" """
task_id = str(task.get("id")) task_id = str(task.get("id"))
@@ -12888,12 +12899,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner" po_slug = "product-owner"
if self._is_agent_active(po_slug): if self._is_agent_active(po_slug):
return return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task): if await self._pm_respawn_should_gate(po_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for roadmap exploration", task_id=task_id) logger.info("Spawning Product Owner for roadmap exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("roadmap") prior_context = await self._board_program_prior_context("roadmap")
market_brief_context = await self._periscope_brief_context() 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. """One-shot Product-Owner spawn to author a Pest Control bug hunt.
Mirrors ``_dispatch_roadmap_exploration`` exactly (PO-solo, the same 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 "already authored" marker pre-check ``propose_bug_hunt`` marks it
via the ``pest_hunt`` marker, not this dispatcher). The extra step is via the ``pest_hunt`` marker, not this dispatcher). The extra step is
the server-assembled evidence context (rework hotspots + findings- the server-assembled evidence context (rework hotspots + findings-
@@ -12944,12 +12951,8 @@ Start now: evidence(task_id="{task_id}")
po_slug = "product-owner" po_slug = "product-owner"
if self._is_agent_active(po_slug): if self._is_agent_active(po_slug):
return return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task): if await self._pm_respawn_should_gate(po_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Product Owner for pest-control exploration", task_id=task_id "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. """One-shot Product-Owner spawn to author a Scales rebalance plan.
Mirrors ``_dispatch_pest_control_exploration`` exactly (PO-solo, the 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`` same "already authored" marker pre-check ``propose_rebalance``
marks it via the ``rebalance_plan`` marker, not this dispatcher). The marks it via the ``rebalance_plan`` marker, not this dispatcher). The
extra step is the server-assembled stale-backlog snapshot the PO 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" po_slug = "product-owner"
if self._is_agent_active(po_slug): if self._is_agent_active(po_slug):
return return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task): if await self._pm_respawn_should_gate(po_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for scales exploration", task_id=task_id) logger.info("Spawning Product Owner for scales exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("scales") prior_context = await self._board_program_prior_context("scales")
evidence_context = await self._scales_evidence_context() 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 pest-control "already authored" marker shape): no pre-check is
needed ``propose_postmortem`` completes this task atomically, so a needed ``propose_postmortem`` completes this task atomically, so a
successful call stops it matching the PENDING fetch on the next successful call stops it matching the PENDING fetch on the next
tick. Reuses the same one-shot ``_board_dispatched`` tracker + tick. Reuses respawn breaker every other board dispatch uses. EVENT-triggered
respawn breaker every other board dispatch uses. EVENT-triggered
(spec §4): this task only ever exists because (spec §4): this task only ever exists because
``CoronerEngine.open_for_incident`` opened it there is no LEARN ``CoronerEngine.open_for_incident`` opened it there is no LEARN
"prior cycles" injection here (no cron cadence to have learned from). "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" auditor_slug = "auditor"
if self._is_agent_active(auditor_slug): if self._is_agent_active(auditor_slug):
return return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task): if await self._pm_respawn_should_gate(auditor_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for Coroner postmortem", task_id=task_id) logger.info("Spawning Auditor for Coroner postmortem", task_id=task_id)
incident_context = await self._coroner_incident_context(task) incident_context = await self._coroner_incident_context(task)
await self.spawn_agent( await self.spawn_agent(
@@ -13095,7 +13089,7 @@ Start now: evidence(task_id="{task_id}")
"already authored" marker shape): no pre-check is needed "already authored" marker shape): no pre-check is needed
``propose_campaign`` completes this task atomically, so a successful ``propose_campaign`` completes this task atomically, so a successful
call stops it matching the PENDING fetch on the next tick. Reuses the 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 other board dispatch uses. EVENT-triggered (spec §4): this task only
ever exists because a release just published or the CEO called "run ever exists because a release just published or the CEO called "run
now" — there is no LEARN "prior cycles" injection here (no cron 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" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for War Room campaign planning", "Spawning Head of Marketing for War Room campaign planning",
task_id=task_id, 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. """One-shot Product-Owner spawn to author a Spackle gap-fill audit.
Mirrors ``_dispatch_pest_control_exploration`` exactly (PO-solo, the 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 same "already authored" marker pre-check ``propose_gap_fill`` marks
it via the ``gap_fill`` marker, not this dispatcher). Unlike Pest it via the ``gap_fill`` marker, not this dispatcher). Unlike Pest
Control there is no server-assembled evidence context the spec 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" po_slug = "product-owner"
if self._is_agent_active(po_slug): if self._is_agent_active(po_slug):
return return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task): if await self._pm_respawn_should_gate(po_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for spackle exploration", task_id=task_id) logger.info("Spawning Product Owner for spackle exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("spackle") prior_context = await self._board_program_prior_context("spackle")
await self.spawn_agent( await self.spawn_agent(
@@ -13166,7 +13152,7 @@ Start now: evidence(task_id="{task_id}")
audit. audit.
Mirrors ``_dispatch_spackle_exploration`` exactly (HoM-solo, the same 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`` "already authored" marker pre-check ``propose_messaging_fixes``
marks it via the ``messaging_fixes`` marker, not this dispatcher). marks it via the ``messaging_fixes`` marker, not this dispatcher).
Like Spackle there is no server-assembled evidence context the 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" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for mirror exploration", task_id=task_id "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. Dogfood friction audit.
Mirrors ``_dispatch_spackle_exploration`` exactly (PO-solo, the same 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`` "already authored" marker pre-check ``propose_friction_fixes``
marks it via the ``friction_fixes`` marker, not this dispatcher). marks it via the ``friction_fixes`` marker, not this dispatcher).
There is no server-assembled evidence context walking the product 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" po_slug = "product-owner"
if self._is_agent_active(po_slug): if self._is_agent_active(po_slug):
return return
key = (po_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(po_slug, task): if await self._pm_respawn_should_gate(po_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Product Owner for dogfood exploration", task_id=task_id) logger.info("Spawning Product Owner for dogfood exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("dogfood") prior_context = await self._board_program_prior_context("dogfood")
await self.spawn_agent( 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 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 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 per-item decisions and needs the marker check to avoid re-spawning the PO
after authoring. Reuses the same one-shot _board_dispatched tracker + after authoring. Reuses the respawn breaker every other board dispatch uses.
respawn breaker every other board dispatch uses.
""" """
task_id = str(task.get("id")) task_id = str(task.get("id"))
hom_slug = "head-marketing" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for feature-spotlight exploration", "Spawning Head of Marketing for feature-spotlight exploration",
task_id=task_id, task_id=task_id,
@@ -13280,19 +13253,14 @@ Start now: evidence(task_id="{task_id}")
authored" marker pre-check is needed — ``propose_market_brief`` authored" marker pre-check is needed — ``propose_market_brief``
completes this task atomically (the x_feature complete-at-propose completes this task atomically (the x_feature complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick. asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn Reuses respawn breaker every other board dispatch uses.
breaker every other board dispatch uses.
""" """
task_id = str(task.get("id")) task_id = str(task.get("id"))
hom_slug = "head-marketing" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for periscope exploration", task_id=task_id "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- completes this task atomically (the x_feature/periscope complete-at-
propose asymmetry, multiplied across every materialized reply), so propose asymmetry, multiplied across every materialized reply), so
it stops matching the PENDING fetch on the next tick. Reuses the 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. other board dispatch uses.
""" """
task_id = str(task.get("id")) task_id = str(task.get("id"))
hom_slug = "head-marketing" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for barfly exploration", task_id=task_id "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 marker pre-check is needed ``propose_quality_report`` completes
this task atomically (the x_feature/periscope complete-at-propose this task atomically (the x_feature/periscope complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick. asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn Reuses respawn breaker every other board dispatch uses. The extra step is the
breaker every other board dispatch uses. The extra step is the
server-assembled drift evidence (waived-findings trend, open- server-assembled drift evidence (waived-findings trend, open-
findings-by-severity, conventions hotspots, budget snapshot) the findings-by-severity, conventions hotspots, budget snapshot) the
Auditor cannot gather itself mirrors Auditor cannot gather itself mirrors
@@ -13374,12 +13337,8 @@ Start now: evidence(task_id="{task_id}")
auditor_slug = "auditor" auditor_slug = "auditor"
if self._is_agent_active(auditor_slug): if self._is_agent_active(auditor_slug):
return return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task): if await self._pm_respawn_should_gate(auditor_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for sentinel exploration", task_id=task_id) logger.info("Spawning Auditor for sentinel exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("sentinel") prior_context = await self._board_program_prior_context("sentinel")
evidence_context = await self._sentinel_evidence_context() 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 marker pre-check is needed ``propose_editorial_post`` completes
this task atomically (the x_feature/periscope complete-at-propose this task atomically (the x_feature/periscope complete-at-propose
asymmetry), so it stops matching the PENDING fetch on the next tick. asymmetry), so it stops matching the PENDING fetch on the next tick.
Reuses the same one-shot ``_board_dispatched`` tracker + respawn Reuses respawn breaker every other board dispatch uses. The extra step is the
breaker every other board dispatch uses. The extra step is the
server-assembled shipped-this-week digest (completed tasks + server-assembled shipped-this-week digest (completed tasks +
CHANGELOG Unreleased bullets) the Head of Marketing cannot gather CHANGELOG Unreleased bullets) the Head of Marketing cannot gather
itself mirrors ``_dispatch_pest_control_exploration``'s itself mirrors ``_dispatch_pest_control_exploration``'s
@@ -13427,12 +13385,8 @@ Start now: evidence(task_id="{task_id}")
hom_slug = "head-marketing" hom_slug = "head-marketing"
if self._is_agent_active(hom_slug): if self._is_agent_active(hom_slug):
return return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task): if await self._pm_respawn_should_gate(hom_slug, task):
return return
self._board_dispatched.add(key)
logger.info( logger.info(
"Spawning Head of Marketing for megaphone exploration", task_id=task_id "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 marker pre-check is needed ``propose_playbook_drafts`` completes
this task atomically (the x_feature/periscope/sentinel complete-at- this task atomically (the x_feature/periscope/sentinel complete-at-
propose asymmetry), so it stops matching the PENDING fetch on the propose asymmetry), so it stops matching the PENDING fetch on the
next tick. Reuses the same one-shot ``_board_dispatched`` tracker + next tick. Reuses the respawn breaker every other board dispatch
respawn breaker every other board dispatch uses. The extra step is uses. The extra step is
the server-assembled mining context (recurring learning-journal the server-assembled mining context (recurring learning-journal
topics + existing playbook titles) the Auditor cannot gather itself topics + existing playbook titles) the Auditor cannot gather itself
mirrors ``_dispatch_sentinel_exploration``'s evidence-context mirrors ``_dispatch_sentinel_exploration``'s evidence-context
@@ -13482,12 +13436,8 @@ Start now: evidence(task_id="{task_id}")
auditor_slug = "auditor" auditor_slug = "auditor"
if self._is_agent_active(auditor_slug): if self._is_agent_active(auditor_slug):
return return
key = (auditor_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(auditor_slug, task): if await self._pm_respawn_should_gate(auditor_slug, task):
return return
self._board_dispatched.add(key)
logger.info("Spawning Auditor for librarian exploration", task_id=task_id) logger.info("Spawning Auditor for librarian exploration", task_id=task_id)
prior_context = await self._board_program_prior_context("librarian") prior_context = await self._board_program_prior_context("librarian")
mining_context = await self._librarian_mining_context() 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 @pytest.mark.asyncio
async def test_barfly_dispatch_is_one_shot() -> None: async def test_barfly_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _barfly_task() task = _barfly_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -3
View File
@@ -67,8 +67,15 @@ async def test_coroner_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_coroner_dispatch_is_one_shot() -> None: async def test_coroner_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending autopsy must NOT respawn.""" """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() orch = _make_orch()
task = _coroner_task() task = _coroner_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -3
View File
@@ -61,8 +61,15 @@ async def test_dogfood_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dogfood_dispatch_is_one_shot() -> None: async def test_dogfood_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn.""" """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() orch = _make_orch()
task = _dogfood_task() task = _dogfood_task()
with ( 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)
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 @pytest.mark.asyncio
@@ -57,9 +57,15 @@ async def test_feature_spotlight_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_feature_spotlight_dispatch_is_one_shot() -> None: async def test_feature_spotlight_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _feature_task() task = _feature_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_librarian_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_librarian_dispatch_is_one_shot() -> None: async def test_librarian_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending mining task must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _librarian_task() task = _librarian_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -4
View File
@@ -58,9 +58,15 @@ async def test_megaphone_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_megaphone_dispatch_is_one_shot() -> None: async def test_megaphone_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _megaphone_task() task = _megaphone_task()
with ( 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)
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 @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 @pytest.mark.asyncio
async def test_mirror_dispatch_is_one_shot() -> None: async def test_mirror_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn.""" """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() orch = _make_orch()
task = _mirror_task() task = _mirror_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_periscope_dispatch_spawns_only_head_marketing() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_periscope_dispatch_is_one_shot() -> None: async def test_periscope_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _periscope_task() task = _periscope_task()
with ( 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)
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 @pytest.mark.asyncio
@@ -57,8 +57,15 @@ async def test_pest_control_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pest_control_dispatch_is_one_shot() -> None: async def test_pest_control_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn.""" """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() orch = _make_orch()
task = _pest_control_task() task = _pest_control_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -4
View File
@@ -57,9 +57,15 @@ async def test_roadmap_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_roadmap_dispatch_is_one_shot() -> None: async def test_roadmap_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn — """A failed exploration must be retried, not abandoned.
board roles have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _roadmap_task() task = _roadmap_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -3
View File
@@ -57,8 +57,15 @@ async def test_scales_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scales_dispatch_is_one_shot() -> None: async def test_scales_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn.""" """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() orch = _make_orch()
task = _scales_task() task = _scales_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -4
View File
@@ -59,9 +59,15 @@ async def test_sentinel_dispatch_spawns_only_auditor() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sentinel_dispatch_is_one_shot() -> None: async def test_sentinel_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles """A failed exploration must be retried, not abandoned.
have no progression verb, so a respawn would just loop."""
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() orch = _make_orch()
task = _sentinel_task() task = _sentinel_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -3
View File
@@ -57,8 +57,15 @@ async def test_spackle_dispatch_spawns_only_product_owner() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_spackle_dispatch_is_one_shot() -> None: async def test_spackle_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn.""" """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() orch = _make_orch()
task = _spackle_task() task = _spackle_task()
with ( 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)
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 @pytest.mark.asyncio
+13 -3
View File
@@ -67,8 +67,15 @@ async def test_war_room_dispatch_spawns_only_hom() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_war_room_dispatch_is_one_shot() -> None: async def test_war_room_dispatch_retries_until_breaker() -> None:
"""Re-ticking a still-pending campaign-planning task must NOT respawn.""" """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() orch = _make_orch()
task = _war_room_task() task = _war_room_task()
with ( 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)
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 @pytest.mark.asyncio