[F059] self-heal: hold fix tasks for CEO Approve-&-Start (restore dispatch gate)

The module docstring promised self-heal fix tasks 'wait for the CEO's
Approve-&-Start', but _originate created them confirmed_by_human=True and the
orchestrator dispatched them at once — a self-heal fix that re-broke CI would
trigger another cycle, open another auto-dispatched fix, and loop with no CEO
gate on dispatch.

Restore the documented gate:
* _originate opens the task confirmed_by_human=False (held for the CEO).
* The orchestrator holds a self-heal task out of both the PM and dev dispatch
  paths until confirmed_by_human flips True.
* approve_and_start (the CEO's start gate) sets confirmed_by_human=True so the
  held task finally dispatches (idempotent for board/intake tasks already True).
* list_pending_for_agent scopes the give_me_work hold to self-heal
  (source != self_heal OR confirmed_by_human) so an already-alive PM can't grab
  it pre-approval — while ordinary delegated subtasks (confirmed_by_human=False
  by default, where the delegation IS the authorization) still dispatch.

The 'never self-deploys' guarantee (no merge) is unchanged.
This commit is contained in:
Renn F
2026-06-28 16:18:56 +02:00
parent 23d6573f7f
commit b515022778
6 changed files with 418 additions and 42 deletions
+22 -6
View File
@@ -60,7 +60,11 @@ from roboco.models.runtime import (
WaitingRecord,
)
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.task import PR_REVIEW_SOURCES, RELEASE_MANAGER_SOURCE
from roboco.services.task import (
PR_REVIEW_SOURCES,
RELEASE_MANAGER_SOURCE,
SELF_HEAL_SOURCE,
)
logger = structlog.get_logger()
@@ -8713,11 +8717,16 @@ Start now: evidence(task_id="{task_id}")
# are acted on by the release routes + executor, never dispatched.
if task.get("source") == RELEASE_MANAGER_SOURCE:
continue
# Self-heal fix tasks dispatch autonomously — the loop opens them
# confirmed + assigned to the Main PM, so they flow through the
# assigned-PM path below like any other PM task (no CEO Approve-&-
# Start; that gate is the Intake/board flow). The fix still ships
# through dev -> QA -> PR review -> the CEO's merge.
# F059: a self-heal fix task is HELD for the CEO's Approve-&-Start
# (confirmed_by_human=False at origination). It must NOT dispatch
# autonomously — the loop only OPENS it; the CEO's approve_and_start
# flips confirmed_by_human True, after which it flows through the
# assigned-PM path below like any other PM task. (The fix still ships
# through dev -> QA -> PR review -> the CEO's merge.)
if task.get("source") == SELF_HEAL_SOURCE and not task.get(
"confirmed_by_human"
):
continue
assigned_to = task.get("assigned_to")
if assigned_to:
if self._resolve_agent_slug(assigned_to) in self._BOARD_AGENTS:
@@ -9099,6 +9108,13 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# Release proposals are CEO-gated artifacts, never dev work.
if task.get("source") == RELEASE_MANAGER_SOURCE:
continue
# F059: a self-heal fix task held for the CEO's Approve-&-Start is
# not dev work yet — it must not route to its assigned_to as a dev
# before the CEO approves it.
if task.get("source") == SELF_HEAL_SOURCE and not task.get(
"confirmed_by_human"
):
continue
await self._dev_dispatch_one(client, task)
@staticmethod
+17 -13
View File
@@ -129,13 +129,17 @@ class SelfHealEngine(BaseService):
Bounded + deduped: skips a regression that already has an open self-heal
task (by fingerprint), honors the per-cycle and rolling open-task caps,
and resolves the repo to RoboCo's own project. Each task is created
PENDING + assigned to the Main PM agent (not merely team=main_pm) so the
orchestrator dispatches it straight to that agent via the assigned-PM
path. RoboCo self-heals autonomously: the fix task dispatches WITHOUT a
CEO Approve-&-Start (``confirmed_by_human=True`` up front) — that is the
Intake/board flow, not this one. It is safe because the loop only OPENS
the task; the fix itself still ships through the normal gates
(dev -> QA -> PR review -> the CEO's merge), and the loop NEVER calls
PENDING + assigned to the Main PM agent (not merely team=main_pm) so
that, once the CEO approves it, the orchestrator dispatches it straight
to that agent via the assigned-PM path.
The fix task is HELD for the CEO's Approve-&-Start
(``confirmed_by_human=False``) — the documented safety invariant: the
loop only OPENS the task; it never starts, approves, merges, or deploys.
The orchestrator + ``give_me_work`` hold a ``confirmed_by_human=False``
self-heal task out of dispatch until the CEO's ``approve_and_start``
flips the flag True. The fix then ships through the normal gates
(dev -> QA -> PR review -> the CEO's merge). The loop NEVER calls
start / approve / merge / deploy. Flushes; the caller commits.
"""
task_svc = get_task_service(self.session)
@@ -174,10 +178,10 @@ class SelfHealEngine(BaseService):
f"Evidence: {obs.raw_ref}\n\n"
"Investigate and fix the regression at its root so CI "
"returns to green. This task was opened automatically by "
"the self-heal loop and is READY TO START NOW — no "
"approval needed; pick it up and coordinate the fix. It "
"still ships through the normal gates (QA, PR review, and "
"the CEO's merge)."
"the self-heal loop and is HELD for the CEO's "
"Approve-&-Start — it will not dispatch until the CEO "
"approves it. Once approved, it ships through the normal "
"gates (QA, PR review, and the CEO's merge)."
),
acceptance_criteria=[
f"CI on {obs.repo_hint}'s default branch is green again",
@@ -193,7 +197,7 @@ class SelfHealEngine(BaseService):
project_id=cast("UUID", project.id),
status=TaskStatus.PENDING,
source=SELF_HEAL_SOURCE,
confirmed_by_human=True,
confirmed_by_human=False,
)
)
# Carry the fingerprint so a later cycle sees this regression already
@@ -204,7 +208,7 @@ class SelfHealEngine(BaseService):
open_count += 1
created += 1
self.log.info(
"self-heal fix task opened (PENDING; awaiting CEO)",
"self-heal fix task opened (PENDING; held for CEO Approve-&-Start)",
task_id=str(task.id),
repo=obs.repo_hint,
fingerprint=obs.fingerprint,
+18
View File
@@ -5134,6 +5134,13 @@ class TaskService(BaseService):
already = task.assigned_to == main_pm.id
task.assigned_to = cast("Any", main_pm.id)
# F059: this is the CEO's start gate — approving the task confirms it for
# dispatch. A self-heal fix task is opened held (confirmed_by_human=False)
# so the orchestrator + give_me_work keep it out of dispatch until now;
# flipping it True lifts that hold. Idempotent for board/intake tasks,
# which are already confirmed at creation. (The release-manager proposal
# is not routed through approve_and_start — it has its own CEO routes.)
task.confirmed_by_human = cast("Any", True)
# The board-reviewed coordination task now belongs to Main PM, who will
# delegate it to the cells. Leaving team="board" is misleading once it's
# off the board — reflect the new owner. Team.MAIN_PM is a valid non-cell
@@ -7083,6 +7090,13 @@ class TaskService(BaseService):
`list_pending(filter_by_dependencies=True)`, so the dependency gate
must be applied here too.
F059: a self-heal fix task held for the CEO's Approve-&-Start
(``source=self_heal`` + ``confirmed_by_human=False``) is NOT offered
here an already-alive PM must not grab it via give_me_work before the
CEO opens the gate. The hold is scoped to self-heal: ordinary delegated
subtasks default to ``confirmed_by_human=False`` (the PM delegated them,
which is itself the authorization to start) and MUST still be offered.
Ordered by sequence asc, then priority asc, then created_at asc so
earlier-sequence tasks win.
"""
@@ -7091,6 +7105,10 @@ class TaskService(BaseService):
.where(
TaskTable.assigned_to == agent_id,
TaskTable.status == TaskStatus.PENDING,
or_(
TaskTable.source != SELF_HEAL_SOURCE,
TaskTable.confirmed_by_human.is_(True),
),
)
.order_by(
TaskTable.sequence,