"Autonomous, NON-INTERACTIVE unblock of a single blocked task — but ONLY when the orchestrator's at-block-time dry-run investigation labelled the fix `risk_label == 'low'`. This is the unattended counterpart to /unblock, built for AFK windows: the L2 escalation-watcher launches it as a sub-agent (no terminal) for `task_failure`/`review_issues` escalations whose latest `metadata.dry_run_proposals[-1]` is low-risk and fresh. It re-derives the fix from the proposal prose, applies it scoped to `files_referenced`, runs the project verify suite, and merges via the orchestrator merge queue — aborting cleanly (leaving the escalation pending for a human) on ANY doubt: non-low risk, stale proposal, scope creep, rebase conflict, verify failure, or a merge-queue result other than done. A merge the worker has already picked up and is still live at the polling ceiling is handed back to the watcher (outcome handed_off), never cancelled. It NEVER touches main directly, never --no-verify, never retries. NOT for: medium / human-review-required proposals, interactive unblocking (use /unblock), any task without a low-risk dry-run proposal, or post-merge-red-main fix-forward tasks (block reason starts with 'Post-merge unscoped type-check failed' — b3_gate.check_proposal hard-ABORTs this entire class regardless of risk_label; see task 1680)."
Instal·la
npx skillscat add leodearden/dark-factory/unblock-low-risk Instal·la des del registre de SkillsCat.
Unblock — Low-Risk (autonomous, non-interactive)
You are an unattended sub-agent resolving ONE blocked task whose at-block-time dry-run
investigation produced a low-risk fix proposal. No human is watching. Your job is to apply the
fix, verify it, and merge it through the orchestrator merge queue — or abort cleanly and leave the
work for a human. There is no middle ground and no retry: you get one careful attempt.
Caution is the whole point. You exist because a human is AFK and the alternative is the task
sitting blocked for days. But a wrong unattended merge is far worse than a delayed one. When any
precondition or step below is not unambiguously satisfied, ABORT (see "Aborting"). Aborting is a
success, not a failure — it routes the item back to the human exactly as if you had never run.
Inputs (provided by the caller)
The escalation-watcher passes you: task_id, escalation_id, project_root, the worktree path,
and the latest dry-run proposal object (proposal_text, files_referenced, risk_label,timestamp). If any is missing, ABORT immediately.
Hard preconditions — re-check ALL of them yourself
The watcher pre-gated, but you re-assert defensively (state can change between the gate and now):
Hard-abort on an explicit stop instruction. Before checking anything else, scan
latest['proposal_text'], the task description (get_task(task_id)), and any prior
context or instruction you've been given for an explicit stop instruction — the phrase
family inorchestrator.stop_instruction.STOP_INSTRUCTION_PHRASES
(orchestrator/src/orchestrator/stop_instruction.py, the single source of truth also used
byb3_gateandDeterministicRunner):'do not apply','do not merge','do not proceed','do not run','do not execute','do not self-authorize','human rehearsal','hold for human','awaiting human', etc. If any is present →
ABORT immediately, per "Aborting" below (leave the escalation pending; do not touch
task status beyond what's already there) — this is the highest-authority halt and
overrides an otherwise-low-risk, fresh proposal. Do not self-authorize around it and do
not narrate the result as human-authorized; aborting here is a success, exactly like every
other abort in this skill.This closes reconciliation finding 0aac21b4 (task 2509): an autonomous
/unblocksession
self-authorized an irreversible mutation despite an explicit "do not apply" instruction on
task 2407, then narrated it as human-authorized. Its sibling session on task 2273 is what
right looks like — it was SIGTERM-killed before it could auto-apply its own
human-rehearsal mandate. This precondition turns that external kill into a self-halt.Mechanical backstop: precondition 6's
b3_gate checkbelow now also scans the task
description, the proposal text and the block reason's prose for this same phrase family
(captured tool output — the fenced excerpt under each failure-report section whose heading
is listed inorchestrator/src/orchestrator/b3_gate.py::_FAILURE_REPORT_CAPTURED_HEADINGS
— is excluded, so
pytest/lint output that merely contains a phrase like
'do not run' no longer aborts; task 6933) and returnsverdict == 'abort'with an'explicit stop instruction present'reason when found — so
even if this precondition is somehow skipped, the existing "ONLYverdict == 'fresh'
proceeds" rule at precondition 6 already routes this class to ABORT, with the gate'sreasoncopied verbatim. This precondition still matters on its own: it can save the
preconditions-1-5 and step-1/2 work by catching the instruction immediately, and it covers
a stop instruction that lives only in context the mechanical gate can't see (it reads only
text already persisted to the task).get_task(task_id)→metadata.dry_run_proposalsis non-empty; takelatest = proposals[-1].latest['risk_label'] == 'low'. Anything else (medium,human-review-required) → ABORT.latesthas nostatuskey (investigation_failed/budget_exhaustedentries forcehuman-review-requiredanyway, but check explicitly) → else ABORT.The escalation
categoryistask_failureorreview_issues→ else ABORT.The
worktreedirectory exists andgit -C <worktree> statusis clean-or-sane (a task branch,
not detached, not mid-rebase/merge) → else ABORT.Freshness gate (mechanical). From the primary dark-factory checkout (where
.venv/andorchestrator/live — NOT the worktree, which has no.venv), run:.venv/bin/python -m orchestrator.b3_gate check \ --task-id <task_id> \ --worktree <worktree> \ --project-root <project_root> \ --category <category> \ --config <project_root>/dark-factory-orchestrator.yaml<category>is the escalation category already asserted in precondition 4. The gate readsmetadata.dry_run_proposals[-1]itself — do not hardcode any sha.Parse JSON stdout;
verdictis one offresh | drift | abort. ONLYverdict == "fresh"
proceeds. Any other verdict (bothdriftandabort) → ABORT, copying the gate'sreason
field verbatim into the return value'sreason:"freshness gate (b3_gate check): <gate reason>".This mechanically enforces what was previously heuristic: the recorded
head_shahard-stop (P1:
HEAD moved →abort) and file-scoped main drift (P2 →drift).Post-merge-red-main class (task 1680): if the proposal's
block_reasonstarts with'Post-merge unscoped type-check failed',b3_gate.check_proposalreturnsabortwith a'post-merge red-main fix-forward class'reason before consulting risk_label or git. This
hard-abort fires regardless ofrisk_label— defense-in-depth against a mislabeled-low
proposal (the 1643 incident: an investigator can label a post-merge unscoped break as mechanically
low-risk). The post-merge-red-main class is the highest-blast-radius unattended-edit scenario in
B3 (an autonomous agent editing a red main); it is intentionally outside B3's autonomous scope and
must always be routed to a human. This skill will therefore never reach this class's proposals;
the gate aborts them upstream.
Procedure
Run these strictly in order. Stop and ABORT at the first step that is not cleanly satisfied.
Release the orchestrator's grip.
mcp__escalation__release_workflow(task_id, timeout_secs=30).
This soft-cancels any active workflow; then, once no slot is live, if the task is stillin-progressthe tool parks it asblocked(the reaper-immune holding state). That park
applies whether or not a slot was registered — an orphaned task whose lane was already reaped
(was_activefalse) is parked too. Do not assume the park happened — confirm it by
reading the returned fields, and apply this decision rule exactly (you are unattended; do not
improvise a fourth case):Result Do slot_cleared: false(whateverwas_activesays)ABORT. A workflow slot is live at the end of the call — either the orchestrator never let go, or the scheduler dispatched a fresh one mid-call. Either way it owns the task; do not race it. parked: "blocked"Proceed. The hold landed. parked: nulland the task is already atblockedProceed. No park was needed — it was already in the safe state. parked: nulland any other statusABORT. The task was not at in-progressand is not atblocked(e.g. it flipped topending/review, went terminal, or the status read failed). You do not hold the task, so you have no reaper-immune hold to work under.For the two
parked: nullrows, read the status fromget_task(task_id)(the same call
step 0's preconditions already make) — do not infer it from the release result alone.Understand the issue. In the worktree, read
latest['proposal_text'], the review findings
(.task/reviews/*.json), and the failing iteration (.task/iterations.jsonl). Treat the proposal
as a starting hypothesis, not gospel — the investigating agent was read-only and may be wrong.Apply the fix — scoped. Edit/Write only files in
latest['files_referenced'](and their
direct test files). The moment the correct fix demands touching a file outside that set — or any
ofmain-only paths, CI config, infra, ordark-factory-orchestrator.yaml/.mcp.json/systemd units —
the change is no longer low-risk: ABORT. Do not commit.task/.Rebase onto main.
git -C <worktree> rebase main(main may have moved). On any conflict →
run Rebase recovery preflight to leave the worktree clean, then
ABORT (a conflict means the change is no longer self-contained).The cleanup is not optional politeness. Aborting the skill while the worktree is still
mid-rebase leaves precondition 5 refusing the next run for that exact reason, so the task
deadlocks: nothing can retry it and nothing cleans it up.Verify. Run the project's full verify suite from
<project_root>/dark-factory-orchestrator.yamlin the worktree —test_command, thenlint_command, thentype_check_command(read them from the file; do not
hardcode). Any non-zero exit → ABORT. (Pipe to a file + check exit status; never trust a
tail.)Commit.
git -C <worktree> add <only the files you changed>then commit ontask/<task_id>
with a clear message. Nevergit add -A(avoids.task/contamination); never--no-verify.Charge the merge slot. From the primary dark-factory checkout, run:
.venv/bin/python -m orchestrator.b3_gate charge \ --task-id <task_id> \ --project-root <project_root> \ --config <project_root>/dark-factory-orchestrator.yamlNote:
chargetakes no--worktreeargument — cap state is keyed onproject_rootonly.Parse JSON stdout; if
chargedisfalse(rolling-24h cap exceeded), ABORT, carrying the
gate'sreasonverbatim into the return value'sreason. Onlycharged: trueproceeds.Rationale (PRD §4.2): the charge sits at the merge-submit choke point — the actual unattended-merge
risk axis — so every ABORT before this step (preconditions, scope check, rebase conflict, verify
failure) spends no slot and is free by design.Note: once
charged: trueis returned the slot is consumed even if the merge does not succeed.
Completion is observed viamerge_statuspolling (not a blocking return); outcomes that cost a
slot:{'error': ...}(orchestrator down), or a polled/resolved state ofconflict,blocked,abandoned,failed,unknown_branch, orunknown(unconfirmed on main). These
post-charge aborts cost a slot. This is the accepted §4.2 tradeoff. A hand-off at step 8's
deadline consumes no further slot, because resume mode never charges.Merge via the queue — never directly. Submit:
mcp__escalation__merge_request(task_id=task_id, branch=task_id, worktree=<abs path>, description="unblock-low-risk: <one-line summary>", wait_secs=100)branchis the bare task id — the worker prependstask/. The server clamps the wait to
≤100 s (its internal_MAX_WAIT_SECS);wait_secs=100is the PRD-prescribed bounded value.If it returns
{'error': ...}(orchestrator not running) → ABORT. Never fall back to a
directgit merge— an unattended direct merge to main is exactly the risk this skill refuses
to take.The response shape determines the next action:
- TERMINAL (resolved within the bounded window):
status∈done|conflict
|blocked|already_merged|done_wip_recovery|unknown_branch
|unmerged_state|stash_failed|wip_halted|wip_recovery_no_advance
|error|superseded. The six worker-internal outcomes (wip_halted,done_wip_recovery,wip_recovery_no_advance,unmerged_state,stash_failed,error) are rare;merge_statuscollapses all of them exceptdone_wip_recoverytoblockedwhen
observed by polling (escalation/src/escalation/server.py::_map_terminal_state) —
handle them asblocked. (failed, which this list named until task 4829, is not a
value the server ever returns; the real one iserror.)
The call returns arequest_idin all
cases except thealready_mergedancestor fast-path, which short-circuits before entry
construction (norequest_id,commitis the ancestor sha). Thealready_merged
worker-path (entry was constructed, merge found already done) does carry arequest_id
but may havecommit: null. Proceed to step 9.
NON-TERMINAL —
status∈queued|attached:This is a successful submission, not a failure.
queuedmeans the entry is
waiting in the merge queue;attachedmeans it was
coalesced with an existing in-flight request. Pollmerge_statusuntil the entry reaches a
terminal state:deadline = now() + 1200 s # 20-minute polling ceiling (resume mode: 3 h — see "Resuming a handed-off merge") # merge-state-vocab:begin partition=LIVE_STATES # Mirrors shared/src/shared/merge_state.py::LIVE_STATES. Pinned by # scripts/tests/test_merge_state_vocabulary_consistency.py — extend the enum # and this set goes red until it matches. while state ∈ {queued, verifying, gate, finalizing} and now() < deadline: # merge-state-vocab:end wait = clamp(eta_seconds if eta_seconds else 30, min=15, max=60) # resume mode: min=60, max=300 sleep(wait) # unattended: realise per skills/merge-queue/SKILL.md#waiting-between-polls, its FOREGROUND form only -- a bare sleep 60 is blocked by the harness result = mcp__escalation__merge_status(request_id) # merge-state-vocab:begin partition=LIVE_STATES # Mirrors shared/src/shared/merge_state.py::LIVE_STATES (second copy). if state ∈ {queued, verifying, gate, finalizing}: # deadline exceeded, entry still live # merge-state-vocab:end if state == queued: # never picked up by the merge worker — cancel-before-pickup mcp__escalation__merge_cancel(request_id) ABORT else: # picked up by the merge worker (merging or verifying) — NEVER cancel HAND OFF # return outcome "handed_off" with request_id (see Return value)Use
eta_secondsfrom eachmerge_statusresponse as the cadence hint; clamp to [15 s,
60 s]. The 20-minute ceiling bounds this sub-agent's lifetime, not the merge. At the
deadline the entry's state decides:- Still
queued— no worker has started it, so cancelling costs nothing: callmerge_cancel(request_id)and ABORT, leaving the escalation pending for a human. - Any other live state (
verifying,gate,finalizing, or any state later added toLIVE_STATES) — the entry was picked up by the merge worker (merging or verifying).verifyingalso covers an entry waiting for a verify slot, so it proves pickup, not a
running verify. Nevermerge_cancelit: on a pending waiterescalation/src/escalation/server.py::merge_cancelretires the entry and reaps its
in-flight worktree, discarding the merge's progress and any verify that may be green. A merge
verify runs about 50–60 min (plans/verify-admission-task-slots-gate.md, M2), so a
picked-up entry at 20 minutes is the normal case, not a doubt signal. Instead HAND
OFF: leave the task status and the escalation untouched, call nomerge_cancel, and
returnoutcome: "handed_off"with therequest_idand the observed state inreason.
The escalation-watcher resumes it through resume mode.
Handing off is not an ABORT, so the cancel rule in "Aborting" does not apply to it.
Proceed to step 9 with the final
result.stateif the deadline is not reached.- Still
- TERMINAL (resolved within the bounded window):
Handle the outcome. The
merge_request(in-window) andmerge_status(polled) vocabularies
overlap but differ:failed/unknown_branchappear only asmerge_requeststatuses (the server
collapses them toblockedwhen observed via polling);abandonedis a polled-only state thatmerge_requestnever returns;unknownis a polled-only fallback state.done(polled state) oralready_merged(submit-time): success.already_mergedcomes in two sub-cases:- Fast-path (ancestor short-circuit): no
request_id;commitis the ancestor sha —
usedone_provenance={"kind": "merged", "commit": "<commit>"}. - Worker-path (entry constructed, merge found already done): carries a
request_id
butcommitmay benull. Ifcommitis null, recover the landed sha with the
task-scoped merge-marker search (see Deriving the landed sha below) and usedone_provenance={"kind": "found_on_main", "commit": "<sha from the merge-marker search>", "note": "merge found already done by worker; sha confirmed via merge-marker search"}.
If the search comes back empty, do not abort on that alone — you are holding a
server-issuedalready_mergedverdict, and a fast-forward or coalesce-absorbed
landing carries no marker. Run the ancestry disposition in Deriving the landed sha and follow its arms; every arm that yields no sha takes
this skill's disposition for it. Those arms must never be
stamped: report them rather than proceeding to sub-steps a–d.merge_cancel(request_id)is a safe no-op (entry is terminal) and may be skipped.
a.set_task_status(id=task_id, status="done", project_root=project_root, done_provenance=<shape>). Choose by HOWdonewas observed:- In-window (
merge_requestreturned terminaldone): the response contains acommitfield (the merge sha) — usedone_provenance={"kind": "merged", "commit": "<commit field from merge_request response>"}. - Polled (
merge_statusreturneddone): themerge_statusterminal response
carries nocommitfield — never assume one is present. It may carrylast_outcome.merge_sha, the request's recorded landing sha, valid whenlast_outcome.stateisdoneandlast_outcome.branchnamestask/<task_id>.
Never stamp it bare. Run the ladder marker-first (see Deriving the landed sha below): the task-scoped merge-marker search stays
the first rung, and a marker sha it yields is the one you use, asdone_provenance={"kind": "merged", "commit": "<recovered sha>"}(the server
ancestor-checks it) ordone_provenance={"kind": "found_on_main", "commit": "<recovered sha>", "note": "merge completed per polled merge_status; sha recovered via merge-marker search"}. On an empty marker plus ancestry rc=1,last_outcome.merge_shais the input to the ladder's rebased-solo-landing arm. An empty search is
not by itself a not-landed verdict — you are holding a server-issued polleddone. Run the ancestry disposition in Deriving the landed sha and follow its arms; every arm that yields no sha takes
this skill's disposition for it, which says when to callmerge_cancel. Those arms must never be stamped. already_mergedfast-path and worker-path: handled by the sub-case bullets
above; use thedone_provenanceshape specified there.
b. Restore metadata —set_task_statusoverwrites the metadata blob, nuking
dry_run_proposals/memory_hints/files. Immediately
update_task(..., append=true)to restore them.
c. Clean up:git worktree remove .worktrees/<task_id>andgit branch -d task/<task_id>.
After a rebased solo landing
git branch -drefuses, because the ref keeps its pre-rebase shas.git branch -D task/<task_id>is safe oncegit cherry main task/<task_id>is non-empty and every line
starts with-; leaving the ref in place is also fine.
d.mcp__escalation__resolve_issue(escalation_id, resolution="Auto-merged low-risk fix (unblock-low-risk): <what changed + merge sha>", resolved_by="unblock-low-risk").
- In-window (
- Fast-path (ancestor short-circuit): no
conflict|blocked|abandoned|failed|unknown_branch:
callmcp__escalation__merge_cancel(request_id)then ABORT. Do not resolve, do not
retry, do not direct-merge. (failed/unknown_branchappear only on the in-windowmerge_requestpath;abandonedappears only on the polledmerge_statuspath.)superseded: not a failure, and never handled as one.supersededis
submit-terminal and is not a live state, so it reaches this step either straight from step 8's
submission or on the first poll tick that returns it — step 8's loop exits immediately
becausesuperseded ∉ {queued, verifying, gate, finalizing}. It means your request was
replaced by a successor that may still be in flight and whose landing will carry this
branch's work. Reporting it as a failure would report a false failure and leave the
escalation pending on a branch that is about to land.Do not call
merge_cancel(request_id). On an absorbed id that call is a no-op which
resolves tounknown(escalation/src/escalation/server.py— "callers holding a coalesced
id will resolve to 'unknown' here"), so it buys nothing and muddies the record. There is no
cancel path for an in-flight train at all.Read
superseded_by's shape. It names one of two mechanisms, and only one of them is a
request id:coalesce-*→ a train id, not a request id: this submission was absorbed into a
coalesce train (MergeOutcome('superseded', superseded_by=train_id),orchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._maybe_coalesce_waiting_singles). Do not poll it byrequest_id—
that returns an honestunknownwhich never resolves to anything else.mr-*→ a generation advance: this same branch re-enqueued at a newer generation
(MergeOutcome('superseded', superseded_by=gen_next.request_id, ...), same module).
Nothing was absorbed, and that id is pollable byrequest_id.
Resolve it by ancestry, not by re-polling the id you already hold. Run the ancestry
disposition in Deriving the landed sha — it already carries thecoalesce-*carve-outs, so do not restate or improvise any ladder text here. It has three
dispositions here, and two of them are success:- A stampable sha — proceed with sub-steps a–d above.
- Resolved, landed and already credited — the
coalesce-*arm'sdone-status
outcome: signal (a) shows the tip landed, and this task's scheduler status, re-read
fresh, already readsdone. There is nothing to stamp, and that is success, not
an abort: skip sub-step (a), then run sub-steps (c) cleanup and (d)resolve_issue, recording the tip merge sha in the resolution text. Without this
disposition a landed, correctly-credited task falls through to the polling exit below
and gets abandoned as in-flight — see Deriving the landed sha'scoalesce-*bullet for the full two-outcome split. - Not resolved yet — the two exits in the next bullet.
On the
coalesce-*arm remember both carve-outs: neither rc=1 nor rc=128-with-an-empty-marker is
not-landed, and neither arm carries a self-stamp — the scheduler status decides only
which exit applies, never whether a write is permitted.If it has not resolved yet, both available exits are non-failures. Either keep polling
the branch handle (mcp__escalation__merge_status(branch="task/<task_id>"), same
clamped cadence) for whatever remains of step 8's polling deadline, re-running the
ancestry disposition on each tick; or, once that deadline is spent, ABORT with an
explicitly non-failure disposition — report the branch as in flight on a successor,
naming thesuperseded_byvalue, and leave the escalation pending for a human. In neither
case callmerge_cancel, and in neither case claim a failure: this is not the plain
abort-as-failure theconflict/blocked/abandonedarm above prescribes. Never
resubmit and never direct-merge while the successor is unresolved.
unknown(e.g., after an orchestrator restart;merge_statuscarrieshint="check git log main"): fall back to the task-scoped merge-marker search (see
Deriving the landed sha below) to check whether this task's
merge landed on main. Do not eyeballgit log main --oneline -20: it is not
scoped to this task, so any sha picked from it is likely an unrelated task's merge.- Confirmed on main (search non-empty) → success; use
done_provenance={"kind": "found_on_main", "commit": "<sha from the merge-marker search>", "note": "confirmed on main after unknown merge_status; sha from merge-marker search"}
and proceed with sub-steps a–d above. - Search empty → not yet a verdict: run the ancestry disposition in Deriving the
landed sha and follow its arms. This skill's
dispositions say what each verdict does: a stampable sha proceeds
with sub-steps a–d, and only a genuine not-landed outcome callsmcp__escalation__merge_cancel(request_id)before the ABORT.
- Confirmed on main (search non-empty) → success; use
Resuming a handed-off merge (resume mode)
The escalation-watcher launches this skill in resume mode after a prior run returnedhanded_off (step 8's deadline disposition). Resume mode finishes that run's merge; it is not a
second attempt.
- Inputs:
task_id,escalation_id,project_root,worktree,request_id. If any is
missing, ABORT withoutmerge_cancel. - Skip preconditions 0–6 and Procedure steps 1–7. Never call
merge_request,b3_gate check,record-launchorcharge: the fix is already committed, gated and charged,
and the entry is already submitted. Re-charging would spend a second cap slot, and resubmitting
would race a live entry (the watcher's "Tracking in-flight merges"). - The one re-check:
mcp__escalation__get_escalation(escalation_id)must still bepending.
If it is not, someone has taken the item: returnabortedwithoutmerge_canceland without
touching the task, with areasonthat says the escalation is no longer pending and names its
status. The merge carries on, and whoever resolved the escalation owns it. - Poll: enter step 8's poll loop at its first
merge_status(request_id)call (no submit), with
the resume parameters:deadline = now() + 3 h, cadence clamp [60 s, 300 s]. Past pickup there
is nothing left to cancel, and the remaining wait is a verify of about 50–60 min
(plans/verify-admission-task-slots-gate.md, M2); 3 h covers a verify plus a gate re-verify
with margin, at a cadence that keeps this sub-agent's context small. A wait over 120 s still
uses the foreground form of
Waiting between polls, with an explicit Bashtimeoutabove the wait. - At the resume deadline step 8's deadline disposition applies unchanged: still
queued→merge_canceland ABORT; picked up →handed_offagain. The watcher does not launch a second
resume (its "Completion handling"). - Otherwise run step 9 exactly as written, including this skill's
dispositions on the ladder's verdicts. A polled
donefollows sub-step
a's Polled bullet: recover the sha with the task-scoped merge-marker ladder
(Deriving the landed sha), stampkind"merged"(or"found_on_main", as that bullet allows), then run sub-steps b–d. The superseded andcoalesce-*arms keep polling to this run's polling deadline. - Return per "Return value".
Rebase recovery preflight
git rebase --abort is the way out of a conflicted rebase, and it has two measured failure modes
that leave the worktree wedged with no obvious way forward. Run the preflight first — from the
primary dark-factory checkout (where .venv/ and orchestrator/ live — NOT the worktree,
which has no .venv), exactly as precondition 6 invokes b3_gate:
.venv/bin/python -m orchestrator.rebase_recovery preflight --worktree <worktree>Parse JSON stdout; verdict is one of clean | repaired | blocked.
cleanorrepaired→ run the abort itself, with the guard:git -C <worktree> -c rerere.enabled=false rebase --abort. Use that spelling every time, not
the baregit rebase --abort: with rerere disabled git never opensMERGE_RR, which is what
makes the abort survive both failure modes.blocked→ ABORT the skill without running the abort, copying the payload'sunrepaired
entries verbatim into yourreason.blockedmeans either something the preflight declined to
touch — typically a lock file a live process still holds open, and deciding what that process
is, is a human's call, not this skill's — or a worktree it could not resolve at all
(resolved: false), where it inspected nothing, so the emptydangling/locks_*lists report
an absence of looking rather than an absence of damage. Check the path you passed.
Add --report-only to inspect without changing anything; it detects and reports, moves nothing,
and returns blocked rather than clean when it finds damage it deliberately left in place.
What it does, so you can read its output. The preflight moves a suspect MERGE_RR aside to<git-common-dir>/rerere-specimens/MERGE_RR.<worktree>-<stamp> — moved, never deleted, because a successful
abort deletes that file and it is the only record of which conflict ids the worktree was carrying.
It also removes *.lock files that are both older than an hour and held open by no running
process; a lock with a live holder is retained at any age.
The state it detects is an id present in MERGE_RR with no backing rr-cache/<id> directory. How
those directories come to be missing is not established — do not repeat any explanation of the
cause, including in a report; describe only what was observed.
Deriving the landed sha
The derivation ladder itself — exact-subject marker search, the ref-existence gate, containment,
the ancestry check, the phantom-branch citation gate, and the DoneProvenance contract — is
`skills/_shared/deriving-landed-sha.md`, the
single normative copy. Run it in full; do not improvise a shorter version.
This skill enters it marker-first: run
step 1's task-scoped marker search, then
step 2's git rev-parse --verify --quiet task/<TASK_ID> ref-existence probe. (Where the call sites above say "run the ancestry
disposition", that is step 4, reached from
step 3 when the marker search comes back empty.)
This skill's dispositions on the ladder's verdicts. Every sha recorded in done_provenance
MUST come from that task-scoped ladder — never from main's current HEAD and never from an
eyeballed listing.
A stampable sha — the ladder yields one on exactly five outcomes: a non-empty marker with
the branch ref gone; a marker that passes containment rc=0; a group/train merge
confirmed by contained-before rc=1; a landing — fast-forward or sibling-covered —
confirmed by a subject-matching task citation on main; and a rebased solo landing whose
recordedmerge_shapasses the rebased-solo-landing arm's three checks, stamped with that
arm's{"kind": "merged", "commit": ..., "note": ...}. Use the sha and thenotethe ladder
specifies for that outcome, and proceed with the calling arm's sub-steps a–d above.A genuine not-landed outcome — the ladder's phantom-branch and rc=128 exits: rc=0's
phantom-branch exit (nothing on main cites this task), and rc=128 with an empty marker
search, outside thecoalesce-*arm and with no request-scopeddonein hand. On either:mcp__escalation__merge_cancel(request_id)then ABORT. Do not resolve, do not retry, do
not direct-merge.rc=1 with no request-scoped
done, outside thecoalesce-*and rebased-solo-landing arms,
with agit cherry main task/<task_id>that prints no-line — ABORT and report it as
not landed or not confirmed landed, quoting thegit cherryoutput, withoutmerge_cancel. A single-commit branch that landed rebased with context drift prints exactly
one+line and is indistinguishable from a branch that never landed, so cherry alone never
licenses the cancel. A+line beside any-line is not confirmed landed either: same exit.Citation gate un-evaluable (
git.commit_citation_pattern: "") — proves neither verdict, so
it is not a not-landed outcome: ABORT and report the gate as un-evaluable, without
callingmerge_cancel. This skill scopes that call to genuine not-landed outcomes only.Landing-unattributable — the
work may well be on main, but no sha can be attributed to this request, so it is not a
not-landed outcome: ABORT and report it as landing-unattributable, naming the failing check
and quoting thegit cherryoutput, without callingmerge_cancel. Adoneoralready_mergedcarrying this submission'srequest_idis request-scoped, so outside thecoalesce-*arm an rc=1 or rc=128-with-an-empty-marker after one either stamps on the
rebased-solo-landing arm or lands here — never on the not-landed bullet above.On the
coalesce-*arm, neither rc=1 nor rc=128-with-an-empty-marker is a not-landed
outcome — a train merges only the tip branch, so an absorbed non-tip member has neither a
marker of its own nor an ancestor relationship to prove. Follow the ladder's pointer intomerge-queue/SKILL.md: rules 2–3 govern rc=1, rule 2a governs rc=128-with-empty-marker, and
neither arm licenses a write — never self-stamp on either. Read rules 2 and 2v there for the
argument rather than restating it; the disposition here is the same on both arms, and it
turns on re-reading this task's scheduler status fresh:done—mark_member_done's automatic flip already happened, so the work is landed
and already credited. There is nothing to write: skip sub-step (a) entirely and
proceed to sub-steps (c) and (d). This is a success path — not an abort, and not
a landed-but-not-credited report. (Sub-step (c)'sgit branch -dwill refuse on this arm:
the member ref is stale-by-rebase and not an ancestor of main.git branch -D task/<task_id>is safe oncegit cherry main task/<task_id>is non-empty and every line
starts with-; leaving the ref in place is also fine.)- Any other status —
pending,merge-deferred, anything else, including a status you
cannot read — never write. Keep polling to step 8's polling deadline, then take the
landed-but-not-credited report, citing the tip merge sha and the current status.
In neither case
merge_cancel, and in neither case report not-landed. This is
the one carve-out that most matters here: this skill is fully autonomous, so a wrong
not-landed reading cancels and abandons work that actually landed.No verdict (containment rc=128) — re-derive per the ladder. Do not stamp, and do not read
it as either outcome.
Where the ladder finds no honest commit, write nothing at all rather than substituting a
convenient sha: kind='found_on_main' requires both a commit and a note, and declining to
stamp is an available option on every failing arm.
Merge-stage completion mode (block_class == 'merge_verify_red')
Some proposals originate from a post-merge-verify block, not an ordinary
agent-block: the task's solution already passed its own verify and
review, but landing on main jammed (e.g. a rebase conflict, or an
import/type/lint break introduced by whatever landed on main after this
branch was verified). Recognize this mode fromlatest['block_class'] == 'merge_verify_red' (present on the samedry_run_proposals[-1] entry precondition 1 already reads).
This mode does not add a new precondition or gate step. The (a)+(b)+(c)
eligibility gate — block_class is MERGE_VERIFY_RED, VERIFY passed, REVIEW
passed, all run-scoped — is enforced by the orchestrator at
proposal-generation time: run_dry_run_unblock clamps risk_label down
to human-review-required whenever that evidence is incomplete, so by the
time you ever see latest['risk_label'] == 'low' on a merge_verify_red
entry, the gate has already passed. Preconditions 1-6 above (freshness,
category, risk_label, the post-merge-red-main hard-abort) apply completely
unchanged — there is nothing extra for you to check.
Two refinements to the Procedure for this mode specifically:
- Step 3 (apply the fix — scoped) is, in practice, either a rebase/
conflict resolution confined to files already in the task's own diff,
or an import/type/lint repair against the newmaintip. If the fix
demands anything beyond that (a logic change, a genuinely new and
unrelated test failure, a cross-module edit), the investigator's
labelling guidance (skills/unblock-auto/SKILL.md) says that should
never have been labelledlowin the first place — treat it as a
labelling error, not license to proceed: ABORT. - Step 5 (verify) — instead of assuming a single repo-wide verify
command, run the scoped suite per package touched byfiles_referenced(this repo's<pkg>/src/<pkg>/layout means each
touched top-level package —orchestrator/,shared/,fused-memory/,
etc. — has its ownpytest+pyright+ruff check): e.g. for a fix
confined toorchestrator/, runpytest,pyright, andruff check
from withinorchestrator/. Any non-zero exit anywhere → ABORT,
exactly as step 5's existing rule.
Everything else — rebase (step 4), commit (step 6), charge (step 7), and
submitting via merge_request (step 8) — is unchanged: this mode
merges through the same queue as every other low-risk fix. The merge
queue's own full-suite post-merge verify remains the final gate — your
per-package scoped verify here is a fast local check, not a substitute for
it, and a failure there still routes the entry to conflict / blocked
per step 9, exactly like any other post-charge merge failure. Abort-to-human
on any doubt applies identically to this mode — nothing here loosens that.
Aborting
On any ABORT:
Cancel a live durable-intent entry only on the arms listed here. This is the one list of
merge_cancelcall sites in this skill: an arm not listed never calls it. After a successfulmerge_requestsubmission (arequest_idwas returned — which excludes thealready_merged
fast-path), callmcp__escalation__merge_cancel(request_id)FIRST, before anything else, on:- an entry still
queuedat step 8's polling deadline; - step 9's
conflict|blocked|abandoned|failed|unknown_brancharm; - the ladder's genuine not-landed outcome (this skill's dispositions).
Every other ABORT leaves the entry alone, and step 8's hand-off of a picked-up entry is not an
ABORT at all. Where the call is made, it prevents an orphaned durable-intent entry (PRD D2 —
submitted merges now outlive the MCP call and session) from accumulating.merge_cancelnever
raises and is a safe no-op if the entry already finalized (it returnscancelled: falsewith
the terminal state). A coalesced orattachedrequest_idmay resolve tostate: "unknown"—
treat the cancel as best-effort.- an entry still
Do NOT change the task status.
release_workflowparks anin-progresstask toblocked
(including when there was no active slot at all) — that is the safe, sweep-immune state, so
leave it there. If it returnedparked: nullthe task was not atin-progress: leave it
wherever it is rather than forcing it toblocked.Leave the escalation
pending. Do not resolve or dismiss it — the human will handle it on
return, exactly as the normaltask_failure/review_issuespath does.Leave the worktree intact (do not remove it) so the human can inspect your partial work.
That includes any<git-common-dir>/rerere-specimens/MERGE_RR.<worktree>-*file the rebase-recovery preflight left behind: it is
preserved evidence of what the worktree was carrying when it wedged, and the human inspecting
your partial work is the reader it was preserved for. Do not tidy it away.Return a structured result so the watcher can log it to the digest.
Return value (your final message IS the data)
Return ONLY this JSON object (no prose):
{
"outcome": "merged" | "aborted" | "handed_off",
"task_id": "<id>",
"escalation_id": "<id>",
"request_id": "<merge request id when this run submitted or resumed one, else null — always set for handed_off>",
"reason": "<merged: what changed + merge sha | aborted: which precondition/step failed and why | handed_off: the live merge state at the polling deadline>",
"commit": "<merge sha if merged, else null>"
}Non-negotiable safety rails (summary)
- Stop instruction overrides everything (precondition 0): an explicit stop instruction
(orchestrator.stop_instruction.STOP_INSTRUCTION_PHRASES) found in the proposal, the task
description, or prior context → ABORT immediately, before any other precondition —
mechanically backstopped byb3_gate check(precondition 6), which returnsverdict == 'abort'for the same phrase family. - Only
risk_label == 'low'; onlytask_failure/review_issues. - Never post-merge-red-main: proposals whose
block_reasonstarts with'Post-merge unscoped type-check failed'are hard-ABORTed byb3_gate.check_proposalbefore
this skill is reached (verdictabort, reason'post-merge red-main fix-forward class').
This class is intentionally outside B3's autonomous scope — highest blast radius, always human. - Edits scoped to
files_referenced; never main-only / CI / infra / config. - Freshness enforced by
b3_gate check(precondition 6): proceed only onverdict == "fresh";
anydriftorabortverdict → ABORT with the gate'sreasonverbatim. - Rolling-24h merge cap enforced by
b3_gate charge(step 7, immediately beforemerge_request):
a refused charge (charged: false) → ABORT. All aborts before this step cost no slot. - Merge ONLY through
merge_requestwith explicitwait_secs=100; completion is awaited ONLY viamerge_statuspolling (15 s→60 s backoff usingeta_seconds; resume mode: 60 s→300 s); never
a directgit merge; never--no-verify. merge_cancelonly on the arms "Aborting" lists — that section is the single list.- One attempt. No retry loops. ABORT on the first sign of doubt. Resume mode is not a retry: it
never resubmits or re-charges. - After
set_task_status, restore metadata viaupdate_task(append=true).