"Unblock a stuck orchestrator task — whether blocked on review issues, merge failures, or agent escalations. Use this skill when the user mentions a blocked or stuck task, wants to resolve review issues in a worktree, handle an escalation for a specific task, or says things like 'unblock task 64', 'task 107 is stuck', 'fix the review issues', or '/unblock <number>'. Also trigger when the user references a specific task number alongside words like 'blocked', 'stuck', 'failed', 'escalated', or 'review issues' — even if they don't say 'unblock' explicitly."
Install
npx skillscat add leodearden/dark-factory/unblock Install via the SkillsCat registry.
Unblock Task
You're unblocking an orchestrator task that's stuck. The task is either blocked (failed reviews, merge conflicts, or verification exhaustion — task status is blocked, worktree preserved) or escalated (agent found an issue outside its scope and paused via the escalation MCP — task status may still be in-progress, but the agent is waiting for a resolution).
Your job: triage the issues, discuss them with the user, fix what needs fixing now, defer what can wait, and get the task either cleanly merged or its escalation resolved.
Two competing goals
A: Get unblocked without ugly hacks or undue scope creep.
B: Keep quality high — the codebase has stringent standards.
The critical decisions are how much to do and what to do now (this session) vs later (via new orchestrator tasks). Blockers get fixed this session. Everything else gets queued.
Step 0: Locate the task
Extract the task number from the user's message. Set these for the rest of the workflow:
TASK_ID— the numberPROJECT_ROOT— the current working directory (where.taskmaster/and.worktrees/live)WORKTREE—<PROJECT_ROOT>/.worktrees/<TASK_ID>/
Check the worktree exists. If .worktrees/<TASK_ID>/ is not found, tell the user:
I can't find a worktree for task
<TASK_ID>at<WORKTREE>/
and stop. There's nothing to unblock without a worktree.
Claim the unblock lease. Before gathering context (Step 1), claim theunblock-<project>#<TASK_ID> lease (Attention Rail T7,orchestrator/src/orchestrator/session_registry.py) with warn-and-proceed policy — unlike the
escalation-watcher's stand-down lease, a second /unblock on the same task is allowed to proceed,
just made visible:
python3 $DARK_FACTORY_ROOT/orchestrator/src/orchestrator/session_registry.py lease-claim \
--name "unblock-<project>#<TASK_ID>" \
--policy warn-and-proceedDo not assemble the slug (or the pid) in shell — the CLI owns both. --slug is optional and
defaults to <--name>-$CLAUDE_PID; --pid is optional and resolves from $CLAUDE_PID the same way.
Pass either only as a deliberate operator override. Both are derived rather than documented because
the token decides whether the release below acts or is refused, and it must not depend on this
document getting one shell token right (tasks 3994, 4248). It also has to be re-derivable, not
carried: each Bash tool call is a fresh /bin/bash -c, so a SLUG=$(...) captured here would be gone
by the time you release — hence the CLI re-derives it on every verb.
Never $$, and never $PPID. Inside a Claude Code Bash tool call $$ is the transient/bin/bash -c wrapper, dead the instant the call returns, which makes the lease's liveness guard
inert (every holder reads as dead). $PPID is not stable across tool calls either (measured:
1430433, then 1471645 on the next call, the first already dead), so a slug built on it would not
match what your own release presents. The long-lived claude process is $CLAUDE_PID — verify withps -o comm= -p "$CLAUDE_PID", which prints claude.
Under claude agents, $CLAUDE_PID is not yours alone. ps -o comm= prints claude for aclaude agents host too, so it cannot tell you: every session one claude agents process hosts
shares that host's pid, which outlives each of them. The CLI detects a host (its argv isclaude agents ...), records pid 0 (heartbeat-only staleness, logged as DEGRADED) and derives
the slug as <--name>-$CLAUDE_PID-$CLAUDE_CODE_SESSION_ID; without a usable$CLAUDE_CODE_SESSION_ID the lease verbs exit 2 (pass --slug). So a hosted holder readsholder_liveness=orphaned / holder_pid=0 even while alive: its fresh heartbeat still counts as
held, and once its heartbeat is past the TTL the reaper reclaims it. Its heartbeat (the lease file's
mtime) is the only liveness signal a hosted holder has
(orchestrator/src/orchestrator/session_registry.py::resolve_session_identity).
One-time rollout note (task 6916). A lease a hosted /unblock session claimed before this
change carries the bare <--name>-$CLAUDE_PID slug, so its re-derived release getsresult=refused — nothing is damaged. Pass the original --slug on release (lease-show prints it
as holder_slug), or let the lease become reclaimable once its heartbeat is past the 2h TTL.
If $CLAUDE_PID is unresolvable and --slug is omitted, the lease verbs exit 2 naming both,
rather than silently drifting to a slug your own later release would fail to match; the CLI will not
invent one, because a synthesized token would be identical for every degraded session and let each
act on the others' leases. Pass --slug <stable-token> to proceed, re-using the same token on the
release — --pid does not substitute (it is the lease body's liveness pid, not your identity,
and lease-release has no --pid; only --slug is honoured by both verbs).
On that path the CLI records pid 0, a never-alive sentinel that degrades the lease to
heartbeat-only staleness (loudly logged) instead of recording an unrelated durable pid that would
leave the lease unreapable forever.
(<project> is the same short project token used elsewhere for this task, e.g. the basename ofPROJECT_ROOT.) Parse the printed lines (decision=<acquired|proceed>, message,holder_liveness=<none|held|orphaned>, then slug=<the slug this claim used> — your own derived
identity, a diagnostic to compare against lease-show's holder_slug, never a value to carry into
the release — then holder_record=<unlinked|absent|unreadable|active|exited>):
decision=proceedwith a holder reported in the message: surface that line verbatim to the
user — this is exactly the near-duplicate second-/unblock-on-the-same-task case (reify 06-28) —
then continue normally into Step 1. Never stand down or exit;warn-and-proceednever blocks this
session. The message names the two axes separately, e.g.lease held by unblock-df-2085-1348600 (pid 1348600 alive, heartbeat 42s ago) — proceeding anyway lease held by unblock-df-2085-1348600 (pid 1348600 is not running, but its heartbeat is FRESH — 42s ago; the lease is still held and is NOT reclaimable for another 7158s) — proceeding anywayA fresh heartbeat means the holder is still held even when its pid reads as not running; a lease is
only stale with BOTH a dead pid and a heartbeat past the TTL.holder_liveness=orphanedrestates
the pid half on its own — the pid in the lease body is not running, and that is the whole signal —
worth mentioning to the user, but it changes nothing here:warn-and-proceedcontinues either
way, and you never force-release someone else's lease to "clean up".decision=acquired: no prior holder; continue normally. It printsholder_liveness=none—
there is no contending holder to report, the lease is yours.
The lease body also carries record_slug: the claimant's session-registry record key, which the CLI
resolves from the claiming pid. lease-claim and lease-show print holder_record= from it: the
state of the holder's record (yours, on an acquired claim), a second axis independent ofholder_liveness=. unlinked means nothing was looked up (a lease claimed before the field existed,
or an unresolvable record), not that the holder is alive. Like orphaned, it changes nothing here.
To inspect a lease, use lease-show --name "unblock-<project>#<TASK_ID>" — never cat, which shows
the holder's immutable start_ts but cannot show freshness (the heartbeat is the file's mtime).
Fail-soft. A lease-substrate fault also reports decision=proceed (fail-open), just with no
holder to report — note it in passing and continue; a lease fault must never block an /unblock
session.
Release on exit. When this /unblock session ends (Step 4.5 reflect, or an early stop), release
the lease so it doesn't linger and falsely report a holder to the next /unblock on this task. The
release acts only for the holder — a mismatched slug is refused, so one /unblock session can
never release another's lease. You do not pass the slug: the CLI derives the same one it derived at
claim time, which is what makes the two match:
python3 $DARK_FACTORY_ROOT/orchestrator/src/orchestrator/session_registry.py lease-release \
--name "unblock-<project>#<TASK_ID>"It prints result=<applied|forced|absent|refused|faulted> first. applied = released; absent =
nothing to release (idempotent, not an error); refused = you are not the holder, nothing was
touched — inspect with lease-show rather than reflexively re-running with --force (--force is
operator recovery and is logged loudly naming both parties); faulted = a substrate error, logged
and swallowed so it cannot break your exit path.
One-time rollout note (task 4248): the unblock slug SHAPE changed. Unlike the two watcher
leases — whose old prescription was already <--name>-$CLAUDE_PID, so their derived slug is
byte-identical and an in-flight watcher keeps its lease — /unblock previously builtunblock-<project>-<TASK_ID>-<pid> while its lease name is unblock-<project>#<TASK_ID>. The
derived slug is therefore unblock-<project>#<TASK_ID>-<pid>, a different token. A session that
CLAIMED under the old prescription and releases slug-less gets result=refused — nothing is
damaged, but its lease lingers and falsely reports a holder to the next /unblock on this task
until the 2h TTL ages it out. Such a session must pass its original --slug on release
(lease-show prints it as holder_slug), or accept the TTL wait. Sessions that claimed after 4248
are unaffected: both ends derive the same token.
Step 1: Gather context
Collect all available information in parallel:
1a. Worktree artifacts
Read from <WORKTREE>/.task/:
metadata.json— task identity, base commitplan.json— TDD plan: which steps completed, which are pending, design decisionsiterations.jsonl— execution log: what each agent iteration attempted and accomplishedreviews/*.json— all reviewer verdicts and issues (there may be up to 5: architect, test analyst, robustness, performance, reuse auditor)
1b. Escalations
Query the escalation MCP:
get_pending_escalations(task_id="<TASK_ID>")1c. Task status
get_task(id="<TASK_ID>", project_root="<PROJECT_ROOT>")1d. Git state
In the worktree:
git log --oneline -10— recent commits on the task branchgit diff $(git merge-base main HEAD)..HEAD --stat(equivalentlygit diff main...HEAD --stat) — scope of changes; use the merge-base/three-dot form, not two-dotmain..HEAD, which charges everything that landed on main since the branch base to the task branchWhether the branch can cleanly rebase on current main
Whether the worktree is safe to abort out of. From the primary dark-factory checkout (where
.venv/lives — not the worktree), run:.venv/bin/python -m orchestrator.rebase_recovery preflight --worktree <worktree> --report-onlyParse JSON stdout;
verdictis one ofclean | repaired | blocked.--report-onlychanges
nothing, so this is safe to run during analysis. Ablockedverdict, or a non-emptydangling
list, means a plaingit rebase --aborthere is not safe — see
Recovering a wedged rebase or merge before you run one.
1e. Event history
python3 $DARK_FACTORY_ROOT/scripts/task_event_timeline.py --project-root <PROJECT_ROOT> <TASK_ID>Step 2's agent team and Step 3's findings count the task's failure events from this listing before
joining any of them into one story — see
`skills/_shared/counting-failure-events.md`.
Step 2: Deep analysis
This is where you determine the nature and severity of each issue. The goal is to understand each issue well enough to classify it and recommend a course of action.
Use an agent team. Spawn parallel Explore agents — one per issue (or per cluster of related issues). Each agent should:
- Read the specific code at the locations referenced in the issue
- Understand the surrounding architecture — what module is this in, what are its responsibilities, what depends on it, what patterns does the codebase use in similar situations
- Consult fused-memory for prior decisions about this area:
search(query="<relevant architectural topic>", project_id="<project_id>") - Check for related issues in other reviewers' feedback or other tasks
For each issue, determine:
Architecture, code quality, or both?
- Architecture: structural problems, wrong abstractions, missing layers, coupling
- Code quality: naming, error handling, edge cases, test coverage, style
- Both: a surface-level code quality issue that reveals a deeper architectural tension
If it looks like pure code quality, probe deeper. A missing error handler might mean the error path isn't designed. A naming issue might reveal confused responsibilities. A test gap might mean the interface is untestable by design. Don't take code quality issues at face value — ask whether they're symptoms of something structural.
Clear-cut or complex?
- Clear-cut: one right fix, no real alternatives
- Complex: genuine uncertainty about what's best — either now, or in general, or in the long run
Blocker or nice-for-later?
- Blocker: can't safely merge or continue without resolving this
- Nice-for-later: real issue, but safe to defer to the backlog
Is the premise contested? A check in this session, or in an Explore agent's session, that does not reproduce an issue's premise is a non-reproduction, not a refutation, unless it ran in the premise's execution context. Say which context each check ran in. Single normative statement of the rule: orchestrator/src/orchestrator/agents/premise_refutation_guidance.py::PREMISE_REFUTATION_GUIDANCE.
Step 3: Present findings
Present the user with a structured summary. This is a decision point — they need enough context to make good calls quickly.
Blockers (must fix before merge)
For each blocker, labeled B1, B2, ...:
B1: [Short title] [architecture | code quality | both]
[If clear-cut]: One or two sentences describing the fix.
[If complex]:
- Option A: [description] — [trade-off]
- Option B: [description] — [trade-off]
- Recommended: [A or B], because [reason]
Non-blockers (recommend deferring)
For each non-blocker, labeled S1, S2, ...:
S1: [Short title] — [one-sentence description]. Modules: [which modules it touches].
Recommend queuing all non-blockers. Group them by module overlap so the user can see which ones would naturally become tasks together.
If there are pending escalations
Present each escalation with its severity, category, summary, and the agent's suggested action. Recommend whether to resolve-and-resume, terminate-and-reschedule, or fix-manually-and-merge.
Open questions
Finish with a numbered list of every decision the user needs to make:
- [Question] — Recommended: [answer]. Reason: [why].
- [Question] — Recommended: [answer]. Reason: [why].
Wait for the user. They may approve your recommendations, override some, ask for more detail, or reclassify issues. Iterate until they say "do it" or similar.
Step 3.5: Hard-abort on an explicit stop instruction
Before executing anything in Step 4, check every source gathered so far — the task
description (1a/1c), the escalation text (1b), prior turns in this session, the runbook
itself, or any instruction the user has given — for an explicit stop instruction: "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", or any phrase in
the shared orchestrator.stop_instruction.STOP_INSTRUCTION_PHRASES family
(orchestrator/src/orchestrator/stop_instruction.py — the single source of truth for this
phrase set, also used by b3_gate and DeterministicRunner).
If a stop instruction is present anywhere: HARD ABORT. Leave the task and any pending
escalation exactly as they are (still blocked / still pending) for a human to act on. Do
NOT fix it yourself, do NOT resolve the escalation, do NOT merge, and do NOT narrate the
result as human-authorized — a stop instruction overrides an otherwise-safe, obviously-
correct fix. Tell the user plainly that a stop instruction was found and that you stopped
because of it.
This precondition closes reconciliation finding 0aac21b4 (task 2509): an autonomous/unblock session on task 2407 self-authorized an irreversible Mem0 mutation despite an
explicit "do not apply" instruction, then narrated the result as human-authorized. Its
sibling session on task 2273, audited in the same episode, is what right looks like — it
honored its runbook's human-rehearsal mandate and was SIGTERM-killed before it could
auto-apply. This step turns that external kill into a self-halt: don't wait to be killed —
stop yourself.
A mechanical backstop exists on the autonomous path (unblock-low-risk's b3_gate check
now hard-aborts on the same phrase family read from the task description and proposal
text — see skills/unblock-low-risk/SKILL.md), but the mechanical gate can only see text
already persisted to the task. This prose check is the only guard for a stop instruction
that lives solely in this session's context (a prior turn, a runbook mandate, something
the user just said) — never rely on the mechanical gate alone to catch it.
Step 4: Execute
When the user approves, proceed in this order:
4.1: Triage non-blockers
Before queuing, check each non-blocker against this filter: Is it in code you're already modifying for a blocker fix, AND is it trivial to resolve reliably? Both conditions must hold. A rename in a function you're rewriting qualifies. A "missing test coverage" issue in an adjacent module does not — even if it's trivial, you're not already in that code. And a design concern in code you're touching doesn't qualify either — it's not trivial.
Non-blockers that pass this filter: fold them into the blocker fix. Note in the plan what you're picking up and why (so the user can see you're not scope-creeping).
Everything else: group into logically coherent tasks and queue them.
- Things that hit the same modules go together
- Not too large (one agent should complete it in a TDD cycle)
- Not too small (don't create a task for a single rename)
- Include specific file locations and what the reviewers flagged
# Phase 1: submit — returns immediately with a ticket id
submit_result = submit_task(
title="<descriptive title>",
description="<specific issues, file locations, reviewer references>",
project_root="<PROJECT_ROOT>",
priority="<medium|high>",
metadata={
"source": "unblock-triage",
"spawn_context": "unblock",
# sparse is fine — the architect widens scope at plan time. File paths only (a directory is rejected); use [] to defer entirely.
"files": ["<path/to/affected/file.py>"],
},
)
ticket = submit_result["ticket"]
# Phase 2: block until the curator decides
resolve = resolve_ticket(ticket=ticket, project_root="<PROJECT_ROOT>", timeout_seconds=<see _shared/ticket-failure-handling.md>)
if resolve["status"] == "created":
task_id = resolve["task_id"] # new task queued successfully
elif resolve["status"] == "combined":
task_id = resolve["task_id"] # folded into existing task — still counts as queued
elif resolve["status"] == "refused":
# A deterministic guard rejected the candidate: no task was created and there
# is no task_id. Intended outcome — record resolve["reason"] and move on.
# Do NOT retry and do NOT record a task id.
note_refused(resolve["reason"])
elif resolve["status"] == "awaiting_curation":
# The curator could not judge it yet: the ticket is durably parked and
# retried until it gets a verdict. No task_id yet. Do NOT re-submit; see
# skills/_shared/ticket-failure-handling.md.
note_parked(ticket)
elif resolve["status"] == "failed":
# On `failed`: escalate the reason to the user and skip queuing this non-blocker.
# See skills/_shared/ticket-failure-handling.md for the retryable/terminal reason
# matrix and R4 idempotency guidance. Unblock-triage tasks don't natively set
# escalation_id/suggestion_hash; to opt into R4 de-duplication on retry, synthesize
# a stable pair per that doc's guidance.
handle_failure(resolve["reason"])4.2: Reflect on analysis
Use the reflect skill (/reflect) to capture:
- What issues were found and how they were classified
- Any architectural insights discovered during analysis
- The blocker/non-blocker split and rationale
4.3: Plan the fix
Enter plan mode. The plan covers two parts:
Part 1: Fix all blocking issues in the worktree, on the task branch.
Part 2: Merge or resolve. The procedure depends on the entry path:
If this is a blocked task (task status is blocked, fixing in worktree):
The merge procedure is iterative — don't assume one pass will be enough:
Release the orchestrator's grip on the task before merging — call
release_workflow(task_id="<TASK_ID>", timeout_secs=30)on the escalation MCP. This soft-cancels any active workflow so the orchestrator stops processing the task while you finish it manually. Once no slot is live — whether one was registered or not — if the task is stillin-progressthe tool parks it asblocked(returned asparked: "blocked") — this both stops the orchestrator from re-dispatching it AND protects the worktree from the stranded-in-progress reconciliation sweep (the reaper) while you work. Always make this call — never skip it. Awas_active: falseresult does not mean there is nothing to do: that is the orphaned shape (the lane was already reaped while the task row stayedin-progress), and it is the case that most needs the hold, because nothing else is stopping the scheduler from re-dispatching the task out from under you mid-merge. The tool parks that case too. Confirm the park by reading the returnedparkedfield rather than assuming it:parked: nullmeans a slot was still active at the deadline (see below), or the task was not atin-progress, or the scheduler dispatched a fresh workflow while the tool was reading the status. In that last caseslot_clearedcomes backfalsetoo — the tool deliberately refuses to park under a live workflow rather than writeblockedout from under a running agent. Treat anyslot_cleared: falseas "the orchestrator still owns this task" regardless of whatwas_activesaid, and re-run the call rather than proceeding into the merge.A
pendingtask needs a hold of its own. The tool parks only anin-progresstask, so apendingone (for example, a lock-starved task whose earlier workflow already finished) comes backparked: nullwith nothing holding it. The scheduler dispatches onlypendingtasks, so it can start a fresh workflow in the worktree you are rebasing. Hold the task withset_task_status(id="<TASK_ID>", status="deferred", deferral={"kind": "held_by_session", "holder_pid": <your $CLAUDE_PID>}, project_root="<PROJECT_ROOT>")and confirm the response echoesdeferral; no echo means the fused-memory server predates the deferral gate, so the hold is unrecorded: re-stamp it the same way after fused-memory restarts. The hold lasts while this session lives. Once the deferral sweep lands (task 6526), a dead session's hold is released topendingafter the grace window, so a/resumed session re-stamps it with its new pid; until then nothing releases it, so release it yourself before this session ends if you do not land the merge. If the merge moves to a separate carrier task, re-stamp itdeferral={"kind": "carried_by", "carrier_task_id": <carrier>}. Recipe:docs/task-authoring.md§9 "Deferring a task". Never setblockedby hand as the hold. Ablockedtask with no live claimant and no pinning escalation is flipped back topendingwithin minutes by the stranded-blocked sweep (scheduler.py::_phase_redispatch_stranded_blocked). That happened twice to solar_challenge_platform task 585 on 2026-10-05/06. Step 8'sset_task_status(done)is then a deferred→done transition. If you abandon the merge instead, set the task back topending.Rebase on main. Resolve any conflicts.
Run the project's full verification suite (tests, lint, type-check).
Fix any failures.
On green: rebase on main again — other tasks may have merged while you were fixing.
Decide whether you must loop back to step 3, or may proceed to step 7. "Repeat steps 3-5 until rebase is clean AND verify passes" has no termination condition and does not converge on a busy fleet — main can land a commit every few minutes while a full branch verify takes much longer, so "rebase is clean" is rarely durably true. Steps 3-6 actually fuse two separate decisions with two separate tests; keep them separate, because step 7's
verified_greensits downstream of only one of them:D1 — must I rebase and re-verify again? A file-set intersection is a legitimate terminator:
git diff <old-main-tip>..<new-main-tip> --name-only # what landed on main since your last verify git diff main...HEAD --name-only # what your branch touchesEmpty intersection → you may proceed to step 7 without another loop. Non-empty → rebase on the new tip and loop back to step 3.
Treating an empty intersection as a terminator requires ALL three preconditions to hold:
- You are re-landing a branch that already carries a complete, end-to-end-verified implementation. Mid-fix (still iterating on step 4's failures), re-verification is the whole point — this precondition does not hold and D1 is not in play yet.
- The verify you're relying on ran against the exact tip you're about to submit, unchanged since.
- The intersection is taken over the effective gate surface, not literal path names — a name-only diff is blind to cross-file gate coupling (shared lockfiles, test-selection config, ratcheting baseline manifests). If this project ships a "does this changed-file set require the full gate" oracle, consult it instead of raw name matching (e.g. reify's
scripts/verify-pipeline-guard.sh requires-full-gate).
D2 — may I pass
verified_green=Truein step 7? A completely different question, and the D1 intersection is irrelevant to it — never cite an empty intersection to justifyverified_green=True. The governing rule is unchanged from below:Trueonly if the full-scope verify ran against the exact commit being submitted, this iteration. Satisfying D1 does not satisfy D2.Why it's safe to shortcut D1 at all: for merges submitted through the merge queue (step 7), the merge worker always re-runs its own authoritative full gate against the merged result, regardless of what
verified_greensays. Skipping the local re-verify loop therefore cannot land a redmainon that path — the cost of being wrong is a wasted queue cycle, not a brokenmain.verified_greenfeeds failure attribution (INTEGRATION_SKEWvsBRANCH_BUG) downstream, never merge admission.That safety net has exactly one hole: the orchestrator-down direct-merge fallback (Immediate-response failures below —
{"error": "Merge queue not available — orchestrator not running"}→git merge --no-ff+git push origin main) runs no worker gate at all; nothing downstream re-checks it. If you took D1's empty-intersection shortcut and then land on that fallback, you MUST rebase onto the current main tip and re-run the full verify beforegit merge --no-ff— on that path, skipping both the loop and a fresh check is how a redmainactually happens.Do not adopt this D1 shortcut as a blanket replacement for looping 3-5: it applies only when D1's three preconditions hold, and D1 holding never implies D2 holds.
Invariant: Every
merge_requestcall passes an explicit boundedwait_secs; completion is awaited only viamerge_statuspolling.queuedorattachedresponses are successful submissions (durable intent), never failures.Submit to the merge queue with an explicit bounded wait:
result = mcp__escalation__merge_request( task_id="<TASK_ID>", branch="task/<TASK_ID>", worktree="<WORKTREE>", description="<brief description of what landed>", wait_secs=100, verified_green=<decide per step 6's D2 — see the note below; do not default this to True>, )wait_secs=100equals the server's_MAX_WAIT_SECSclamp ceiling. A fast merge can resolve terminally inside this single bounded call; a backlogged queue returnsqueuedorattachedwithin ≤100 s.verified_greenis the D2 vouch from step 6 — decide it, don't assume it. PassTrueonly if the full-scope verification suite ran and passed against the exact commit you are submitting, this iteration; otherwise passFalse(or omit it). In particular: if you reached step 7 via step 6's D1 empty-intersection terminator, passverified_green=False— D1 lets you skip the re-verify loop, which is precisely the case where no fresh verify backs this tip. Same for a resubmission after a conflict fix-up you didn't re-verify. WhenTrueis warranted it emits aworkflow_verifyevent so a later merge failure caused by an unrelated main landing can be attributed asINTEGRATION_SKEWinstead of degrading toINDETERMINATE; passing it unearned buys nothing and corrupts that attribution.Caution — not retractable: the classifier's green fact is any-prior-green, keyed by task ID, not scoped to the specific commit that was verified. Once
verified_green=Truehas been emitted once for this task ID, a later resubmission for the same task (e.g. another/unblockpass after a conflict fix-up you re-verified via step 6's D1 path instead of a fresh full verify) can still inherit that earlier green even though this round wasn't re-verified — a genuineBRANCH_BUGcould then be misattributed toINTEGRATION_SKEW. Only passTruewhen the full verify just passed, in this iteration, on the branch you're submitting now — D1's empty-intersection terminator never satisfies D2; they are checked independently every time you reach this step.Classify the immediate response (
merge_requestdiscriminates onstatus):status: "done"orstatus: "already_merged"→ terminal success. Thread the merge commit SHA:- Normal
done: SHA is inresult["commit"]. already_merged: SHA is inresult["commit"]for the fast-path case. The worker-pathalready_mergedmay carrycommit=None; whenresult["commit"]is falsy, re-derive with the same selecting marker search the canonical check uses — step 1'sgit log main --fixed-strings --grep="$S" --format='%H%x09%s' | awk -F'\t' -v s="$S" '$2==s && !seen {print $1; seen=1}'withS="Merge task/<TASK_ID> into main", which yields only a commit whose subject is exactly the marker. Not the bare--grep ... --max-count=1: it stops at the newest message match, so a body match can shadow the real merge (`skills/_shared/deriving-landed-sha.md` states why, with the measured population) — or, if that comes back empty, do not record a note asserting the merge is present: an empty search means nothing on main cites this task, which is exactly the signal a branch that never advanced past its creation point produces (it satisfies the worker's ancestry test while carrying none of the work). Run the canonical ancestry check — including `skills/_shared/deriving-landed-sha.md`'s marker search on the rc=128 arm — and treat "nothing on main cites the task" as not done, rather than stamping adone_provenancenote. The canonical check's rc=0 arm agrees with this and does not override it: its citation gate — step 4's rc=0 sub-ladder in `skills/_shared/deriving-landed-sha.md` — will not stamp anything until a positive task citation on main proves real work landed (the shell form ofGitOps.find_task_citation_commit), and what it stamps then is that citing commit; so a branch that never advanced fails there too and is likewise reported not-landed/phantom-branch. Neither rule licenses stamping the other's way out. Do not eyeballgit log main --oneline | head -5and pick a SHA: it is not scoped to this task and you would record an unrelated task's merge as this one's provenance.- Whatever the source, stamp the SHA exactly as the tool returned it. This applies with full force to a
found_on_mainmerge_shafrom the poll loop below: it is already a verified commit on main, so never substitute the branch tip or agit merge-baseresult for it. (There are two exceptions: a project that setsgit.commit_citation_pattern: "", where the tier runs un-gated andmerge_shais the branch tip; and a task whosemetadata.delivered_checksrescued a landing whose effect is absent at main HEAD, wheremerge_shais on main but may name a reverted landing — see the polled-done note below.)
Go directly to step 8.
- Normal
status: "superseded"→ this submission was absorbed into a coalesced train, or replaced
by a generation-advance resubmission, before the bounded wait returned. Absorption
resolves the waiting future directly (MergeOutcome('superseded', superseded_by=train_id),orchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._maybe_coalesce_waiting_singles) andmerge_requestreturns that status
verbatim (escalation/server.py:1733), so withwait_secs=100this is the ordinary
absorption outcome, not an exotic one. It is always submission-scoped here — it is your own
call's response, so none of the unscoped-handle staleness guard applies — so go straight to
the Polled terminal failuressupersededbullet below and followresult["superseded_by"]
exactly as therequest_idarm does. Never resubmit and never direct-merge: the
successor is already in flight and either would race it. In particular do not reach for
the orchestrator-down direct-merge rule below — the orchestrator is up, it just coalesced you.status: "queued"orstatus: "attached"→ durable intent confirmed — the request is enqueued; proceed to poll:if result["status"] == "queued": poll_by = "request_id" poll_kwargs = {"request_id": result["request_id"]} else: # "attached" — pick the handle the response discloses (task 3148); a # missing poll_by (pre-3148 server) degrades to request_id, today's behaviour poll_by = result.get("poll_by", "request_id") if poll_by == "request_id": poll_kwargs = {"request_id": result["request_id"]} elif poll_by == "task_id": poll_kwargs = {"task_id": result["inflight_task_id"]} else: # "branch" (pollable == False): neither handle known, another merger # owns the worktree; the returned request_id was never enqueued, so a # first-tick unknown here is NOT the "server lost its record" case below — # keep polling by branch and confirm via git merge-base --is-ancestor # (see *Polled terminal failures*) before concluding anything poll_kwargs = {"branch": "task/<TASK_ID>"} # unknown is terminal only when the polled id was actually enqueued — on the branch arm # nothing was, so unknown is that arm's live state until the git-authority tier resolves it. # superseded means this request was superseded by another one — either absorbed into a # coalesced train, or replaced by a generation-advance resubmission (see *Polled terminal # failures* below; the two need different remediation). It stays terminal on every arm # (dropping it here would resurrect the spin-forever bug this tuple exists to fix), but it # is submission-scoped ONLY on the request_id arm — on branch/task_id it is subject to the # same UNSCOPED-HANDLE STALENESS GUARD as every other non-done terminal below. # merge-state-vocab:begin partition=TERMINAL_STATES # Mirrors shared/src/shared/merge_state.py::TERMINAL_STATES. Pinned by # scripts/tests/test_merge_state_vocabulary_consistency.py — extend the enum # and this tuple goes red until it matches. if poll_by == "branch": terminal = ("done", "conflict", "blocked", "abandoned", "superseded") # merge-state-vocab:end # merge-state-vocab:begin partition=POLL_STOP_STATES # Mirrors shared/src/shared/merge_state.py::POLL_STOP_STATES (TERMINAL_STATES # plus every epistemic state). Pinned by the same guard. else: terminal = ("done", "conflict", "blocked", "abandoned", "unknown", "superseded", "no_record", "stale_record", "journaled") # merge-state-vocab:end # 20-min hard ceiling on BOTH unscoped arms (branch and task_id): each can reject a # terminal `done` (see accept_terminal), and a durable tier re-serves the same stale # record every tick, so without a floor the loop would spin forever. request_id is # submission-scoped, never rejects, and keeps its unbounded wait. deadline = None if poll_by == "request_id" else now() + 1200 timed_out = False poll_interval = 15 # seconds; ramp up to 60 s # UNSCOPED-HANDLE STALENESS GUARD (applies to the `branch` AND `task_id` arms). # Neither key is tied to *this* submission once the live-snapshot tier stops serving # it (e.g. a mid-flight orchestrator restart): the durable tiers resolve both to the # most-recent *finalized* record for that key — retention ring `get_by_branch` / # `get_by_task` → event store `latest_merge_finalized(branch=...)` / # `latest_merge_finalized(task_id=...)` (escalation/server.py:2244-2287) — and the # event store survives restarts. Branches AND task_ids are both reused verbatim # across resubmissions (this skill's own retry loop is "fix in worktree, resubmit" # on the same `task/<TASK_ID>`), so a previous round's done/conflict/blocked/ # abandoned record can satisfy the terminal test on the very FIRST tick, before this # attempt has done anything. Only git ancestry proves that *this* attempt landed. def accept_terminal(poll): if poll_by == "request_id": return True # request_id names exactly this in-flight entry — always submission-scoped # branch and task_id arms: both unscoped, both gated identically. if poll["state"] == "done": if poll.get("kind") == "found_on_main": # Tier-3.5 git-authority response — a LIVE probe of main, reached only # because the durable tiers MISSED. Structurally cannot be a stale # prior-round record, and cannot be a branch that only looks landed: # the tier answers done only when the branch advanced past its # recorded creation point, AND a commit on main positively cites the # task, AND that commit's effect is still present at main HEAD # (the last two are skipped on a project that opts out with # git.commit_citation_pattern: ""). # Accept it directly: re-gating it on ancestry is what deadlocks a # merged-and-cleaned-up branch (see the rc=128 case below). return True # Durable-tier `done` (retention ring / event store: no `kind`, no # `merge_sha`) — this is the stale-record case. Confirm with git. return branch_on_main() # canonical check below; not-landed → keep polling return True # remaining non-done terminals (conflict/blocked/abandoned/ # unknown/superseded): exit the loop, but treat as UNCONFIRMED. # For superseded specifically, do not spin here re-polling this # same key hoping for done — for a coalesce-absorbed member it # structurally never arrives (see *Polled terminal failures* # below for the real resolution: ancestry + landing signals, # not more polling). loop: sleep(poll_interval) # realise per skills/merge-queue/SKILL.md#waiting-between-polls -- a bare sleep 60 is blocked by the harness poll = mcp__escalation__merge_status(**poll_kwargs) if poll["state"] in terminal and accept_terminal(poll): break if deadline is not None and now() >= deadline: timed_out = True break eta = poll.get("eta_seconds") or poll_interval * 2 # eta_seconds may be None poll_interval = min(max(eta, 15), 60)The canonical ancestry check (
branch_on_main) — three outcomes, not two. Every "is it on main?" confirmation in this skill means this check. Never use the two-way idiomgit merge-base --is-ancestor ... && echo "on main" || echo "not on main": a deleted branch ref exits 128, which that idiom silently reports as "not on main" — inverting the truth for the single most common post-merge state, since the merge lane deletes task branches on cleanup (_delete_branch_if_on_main,orchestrator/src/orchestrator/git_ops.py:7538-7574), and on thebrancharm a foreign merger's cleanup deletes it out from under you.git merge-base --is-ancestor task/<TASK_ID> main; rc=$?; echo "ancestry rc=$rc" # The trailing `echo` is REQUIRED, not decoration. `--is-ancestor` prints # nothing on rc=0 OR rc=1, and the `rc=$?` assignment itself exits 0, so # without it the tool reports exit 0 and identical empty output for "on # main" and "NOT on main" -- silence you would have to guess at. Echoing # the numeric rc is NOT the two-outcome `&& echo` idiom banned above: it # prints on every path and keeps all three outcomes distinguishable. Do # not "tidy" it away.The three outcomes, and the sha derivation behind rc=0 and rc=128, are
`skills/_shared/deriving-landed-sha.md` — the
single normative copy of the ladder (marker search, ref-existence gate, containment, the
group-merge candidate, the phantom-branch citation gate, and theDoneProvenancecontract).
Run it in full; do not improvise a shorter version. This skill enters it ancestry-first,
so you already hold the ref-existence answer and do not need itsrev-parseprobe — map your
rc onto the ladder per Two entry points:
rc=0/rc=1 mean the ref resolves, rc=128 means it does not.branch_on_main()above returns True for rc=0 only when the ladder yields a verified sha — marker +containment rc=0, a group merge withcontained-before rc=1, or a positive subject-level task citation — for a non-empty rc=128 marker search, and for rc=1 only on the rebased-solo-landing arm's stampable outcome. It returns False for rc=1's genuine not-landed outcome, for an empty rc=128 search, and for rc=0's phantom-branch exit (no subject-matching citation on main); False means "not confirmed landed", which is exactly whataccept_terminalneeds in order to keep polling rather than accept a stale durable-tierdone. rc=0 with the citation gate un-evaluable (git.commit_citation_pattern: "") is neither True nor False — it proves neither verdict, so do not accept the terminal state on it: stop and report the gate as un-evaluable. The ladder's landing-unattributable verdict is neither True nor False in the same way: do not accept the terminal state on it, stop and report it, and never resubmit. The unscoped arms hold no request-scopeddone, so there rc=1 with a non-empty, all--git cherryis that verdict, not a stale record to keep polling.On rc=1 this skill keeps polling / resubmits, per the arm — it does not abort (contrast
skills/unblock-low-risk/SKILL.md, which aborts and cancels on a genuine not-landed outcome). And remember the ladder's carve-outs: on thecoalesce-*arm rc=1 is the normal, permanent post-landing state for a non-tip train member, resolved byskills/merge-queue/SKILL.mdrules 2–3, not by waiting it out here; outside it, a rebased-solo-landing pass is a landing (stamp it at step 8), and landing-unattributable means stop and report — never resubmit, never direct-merge.After the loop exits:
timed_out(either unscoped arm's 20-minute deadline reached without an accepted terminal state — i.e. the onlydoneon offer never became an ancestor of main) → do NOT resubmit and do NOT direct-merge; run the canonical ancestry check one final time and follow it into `skills/_shared/deriving-landed-sha.md` — including step 1's exact-subject marker search on the rc=128 arm, since a branch deleted by a successful merge is the likeliest reason you got here — and stop-and-report to the human only if that too comes back not-landed, per Polled terminal failures'sunknownbullet below.poll["state"] == "done"→ if the response carriesmerge_sha(the git-authority tier'skind: "found_on_main"shape), thread it asdone_provenance={"kind": "found_on_main", "commit": "<merge_sha>", "note": "<explanation>"}— not a barecommit.merge_shais always a commit ON main on both of the tier's resolution paths — the citing commit discovered on main on the live-branch path, the merge commit itself on the deleted-branch path — and on both it is checked to still be present at main HEAD before being returned (escalation/src/escalation/git_authority.py::found_on_main_response), so stamp it exactly as returned; never substitute the branch tip or agit merge-baseresult for it. First exception: on a project that setsgit.commit_citation_pattern: ""(an explicit per-project opt-out) the live-branch path skips the citation gate andmerge_shais the raw branch tip — neither a commit on main nor effect-present-checked — so confirm it with the exact-subject re-derivation below instead of stamping it. Second exception: if the task declares a non-emptymetadata.delivered_checksand the delivered-checks differential confirmed the capability, the tier accepts even though the commit's effect is ABSENT at main HEAD —merge_shais still a commit on main, but it may name a landing that was later reverted, and themerge_statusresponse does not say which case you are in — so for such a task confirm it the same way before stamping it. Otherwisemerge_statusgives you no top-level commit hash. That covers either unscoped arm (poll_by"branch"or"task_id") and therequest_idarm's event-store answer (source: "event_store"): a durable record resolvesdoneasstate/request_id/outcome/finished_atplussourceandlast_outcome, with no top-levelmerge_sha(escalation/src/escalation/server.py::merge_status), andpoll["outcome"]is the raw state string"done". So re-derive the true merge commit from git with `skills/_shared/deriving-landed-sha.md` — start at step 1's exact-subject marker search, and if it comes back empty continue into the rest of the ladder rather than concluding anything. On therequest_idarm only,poll["last_outcome"]["merge_sha"]is the request's recorded landing sha whenlast_outcome.stateisdoneandlast_outcome.branchnamestask/<TASK_ID>. Do not stamp it bare: a marker still wins, and on an empty marker plus ancestry rc=1 that sha is the input to the ladder's rebased-solo-landing arm, whose pass stamps{"kind": "merged", "commit": "<sha>", "note": "<the note that arm specifies>"}. The unscoped arms never supply it: their record can be a prior round's. Take the command from there, not from memory: that copy carries the--fixed-stringssubstring-safety rationale and thegit.branch_prefix/git.main_branchoverride caveat, and on a project that overrides either, a hardcoded subject builds the wrong search. Never substitute main's HEAD or an eyeballedgit log main --oneline | head -5— Never derive the sha from main's HEAD has the reason the server's only backstop would not catch it. This call site's dispositions: thread the sha intodone_provenance={"kind": "merged", "commit": "<sha>"}—kindis required (the server rejects a kind-less blob withdone_provenance.kind is required), and this branch supplied the merge, somergedis the right kind here; an empty marker search is not a not-landed verdict and is never an excuse for a
note-only{"note": "<explanation>"}payload, which the server rejects too. Then proceed to step 8.poll["state"] in ("conflict", "blocked", "abandoned", "unknown")→ see Polled terminal failures below. On the unscoped arms (poll_by"branch"or"task_id") these are UNCONFIRMED — per the staleness guard above they may be a prior round's record for this same reused branch/task_id rather than this submission's outcome. Before acting on one, re-checkmcp__escalation__get_merge_queue()and who owns the worktree; if this branch is still in flight, keep polling to the 20-minute ceiling instead of resubmitting on a stale failure.poll["state"] == "superseded"→ on therequest_idarm (always submission-scoped) follow the train/successor directly. On the unscoped arms (poll_by"branch"or"task_id") this is UNCONFIRMED per the staleness guard above — with a further wrinkle for a coalesce-absorbed member, where that arm'ssupersededcan be permanent rather than merely stale. See Polled terminal failures below for the full follow-the-train procedure and why ancestry plus the two landing signals there, not re-polling this same handle, is the real resolution.
(Immediate-response failure edges —
conflict,blocked,unknown_branch,failed, orchestrator-down — plus thesupersededabsorption edge above, and cancellation, are covered below.)set_task_status(id="<TASK_ID>", status="done", project_root="<PROJECT_ROOT>", done_provenance={"kind": "merged", "commit": "<sha>"})kindis required on every payload. The server rejects a kind-less blob withdone_provenance.kind is required, and there is no note-only payload —found_on_mainrequires bothcommitandnote. So "no single commit applies" is never an escape; derive one.- Pass
{"kind": "merged", "commit": "<sha>"}when this branch supplied the merge — thread the SHA fromresult["commit"]for an immediate terminal response, or re-derive fromgit log mainfor a polled terminal response (see polled-done note above). - Pass
{"kind": "found_on_main", "commit": "<sha>", "note": "<one-sentence explanation>"}when the work was already on main — including the fast-forward and covered-by-sibling cases, where the sha is the commit on main that cites this task (the same sha for both: the citation gate cannot tell them apart, and does not need to). Afound_on_mainmerge_shareturned by the tool is safe to stamp only as returned — do not substitute the branch tip orgit merge-baseoutput for it. - If you have no sha in hand, do not guess: run `skills/_shared/deriving-landed-sha.md` in full — entered from the canonical ancestry check above — which yields the correct sha on every landed arm.
- One arm skips this step entirely, and it is a success path rather than an abort: a
coalesce-*member that resolves landed AND credited — the resumed poll's landed-and-credited break, where this task's scheduler status already readsdonebecausemark_member_doneflipped it. The credit is already recorded, so there is nothing to write and writing anyway is the self-stamp that arm forbids. Go straight to step 9. This is the only exemption: every other landed arm reaches this step and stamps.
Clean up:
git worktree remove .worktrees/<TASK_ID>andgit branch -d task/<TASK_ID>- After a rebased solo landing
git branch -drefuses — "not fully merged" — 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-, and leaving the ref in place is also fine: the same rule asskills/merge-queue/SKILL.mdrule 2b's cleanup.
- After a rebased solo landing
Merge-step failure and abandonment edges:
Immediate-response failures (from merge_request):
status: "conflict"orstatus: "blocked"→ readresult["reason"], fix the conflict in the worktree, rebase on main, then loop back to step 7 (resubmit). If backing out of that rebase is the right call, do it via Recovering a wedged rebase or merge — not a baregit rebase --abort.status: "unknown_branch"→ the branch was not found by the merge queue. Verify the branch exists in this repo (git branch) and you are targeting the correct escalation MCP endpoint. Push the branch if needed, then loop back to step 7.status: "failed"→ readresult["reason"]and address accordingly, then loop back to step 7.{"error": "Merge queue not available — orchestrator not running"}→ orchestrator is down; fall back to a direct merge (this is the ONLY situation where a direct merge is appropriate — NEVER use it in response tostate: "unknown"):
If thatgit merge --no-ff task/<TASK_ID> # run from the main branch checkout git push origin main # advance the remote ref so downstream dispatch sees itgit merge --no-ffconflicts, you are mid-merge in the main checkout, which is
machine-operated — the merge worker and the startup reconciler act on it directly. Back out via
Recovering a wedged rebase or merge rather than a baregit merge --abort, and do not leave it mid-merge while you decide.
No downstream gate checks this path — unlike the merge-queue path in step 7, nothing re-verifies aftergit merge --no-fflands. Before running it, confirm you are on the current main tip and that a full verify passed against exactly that rebased tip, this iteration. If your last verify predates any main landing since — including because step 6's D1 empty-intersection terminator let you skip a re-verify loop — rebase onto the current tip and re-run the full suite first (see step 6's D1 carve-out above). Then proceed to step 8 with the resulting commit SHA.
Polled terminal failures (from merge_status):
poll["state"] == "conflict",poll["state"] == "blocked", orpoll["state"] == "abandoned"→ same fix-and-resubmit loop: fix in worktree, rebase on main, loop back to step 7 (backing out of that rebase goes through Recovering a wedged rebase or merge). (Forabandoned, also verify the cancellation was not intentional before resubmitting.)poll["state"] == "unknown"(orchestrator restarted or retention ring expired) →merge_statusnow self-resolves a landed merge via its git-authority tier and returnsstate: "done"withkind: "found_on_main"andmerge_shawhen the branch is provably on main.unknowndoes not mean "not landed" — the tier is deliberately silent whenever it cannot attribute a landing, which now includes a branch that never advanced past its creation point and a landing that no commit on main cites. Ifmerge_statusstill returnsunknown, confirm deterministically:git merge-base --is-ancestor task/<TASK_ID> main; rc=$?; echo "ancestry rc=$rc" # The trailing `echo` is REQUIRED -- see the [canonical ancestry # check](#branch-on-main) above for why: without it, "on main" and "NOT on # main" print identical empty output and exit 0, indistinguishable.Read that rc against `skills/_shared/deriving-landed-sha.md` — the single normative ladder, entered ancestry-first (see Two entry points; you already hold the ref-existence answer, so its
rev-parseprobe is not needed here). Run it in full; do not improvise a shorter version. The canonical ancestry check above states this skill'sbranch_on_main()contract over the ladder's verdicts.This site's action. On a landed verdict — the ladder yields a verified sha — proceed to step 8, stamping
done_provenance={"kind": "found_on_main", "commit": "<that sha>", "note": "<the note the ladder specifies for that arm>"}; both fields are required. On rc=1 with the queue healthy, loop back to step 7 (resubmit) — except on thecoalesce-*arm, where rc=1 is the normal permanent post-landing state andskills/merge-queue/SKILL.mdrules 2–3 resolve it instead, and except where the ladder's rebased-solo-landing arm decides otherwise: its pass proceeds to step 8 with that arm's stamp, and landing-unattributable means stop and report — never resubmit, never direct-merge. On the ladder's genuine not-landed outcomes, and where the citation gate is un-evaluable, stamp nothing and report rather than proceeding to step 8.Never fall back to direct merge in response to
unknown—unknownmeans the server lost its record, not that the merge failed. This block'sresubmitline does not apply to thepoll_by == "branch"arm — there nothing was ever enqueued, sounknownis that arm's expected live state, not a lost record; that arm never reaches this bullet as a terminal state (it's excluded from step 7's terminal set) — it arrives here only via the branch arm's 20-minute deadline, and the action there is to run the same canonical ancestry check once more (including step 1's marker search on the rc=128 arm) and STOP and report to the human only if it still comes back not-landed, rather than resubmitting.poll["state"] == "superseded"→ this request was superseded by another one. Two distinct
mechanisms produce that state, and they need different remediation — checksuperseded_by's
shape below to tell them apart. On therequest_idarm (always submission-scoped) follow
the successor directly. On the unscoped arms (poll_by"branch"or"task_id") this is
UNCONFIRMED per the staleness guard above — but for a coalesce-train absorption, that arm'ssupersededis permanent by construction, not merely stale: it will never itself turndone. Nothing overwrites it — the absorbed member's ownmerge_finalizedrecord is
written under its own branch/task keys at absorption time
(orchestrator/src/orchestrator/merge_lane/worker.py::enqueue_merge_request, its_on_finalizedcallback), the train instead lands
under a brand-newGroupMergeRequestthat bypassesenqueue_merge_requestvia direct queue
surgery (orchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._maybe_coalesce_waiting_singles), andorchestrator/src/orchestrator/harness.py::mark_member_doneflips scheduler status without
writing a merge record. Because the durable tiers keep serving that stale hit, Tier 3.5's git-authority
probe — gated behind a durable-tier miss (escalation/server.py:2407-2420) — never runs to
correct it. So for a coalesce absorption, treat the canonical ancestry check plus the two
landing signals below as the primary confirmation, not a post-timeout fallback; reserve
resuming branch-handle polling for the derail/re-drive case below, where the orchestrator
itself re-lands or re-dispatches the member. (A generation-advancemr-*successor is
simpler: it is enqueued the normal way,orchestrator/src/orchestrator/merge_lane/worker.py::_maybe_auto_chain_generation, so branch/task_id polling does eventually
reflect its outcome there — see its dispatch below.) Once you are following a successor,
never resubmit and never direct-merge, on any arm, while it is still unresolved — it may
already be in flight and either would race it.superseded_bynames one of two shapes:mr-*id (generation-advance path — a plain resubmission of this same task at a newer
generation; not a train, nothing absorbed, nothing to re-drive). A real request id. Poll it:
with the same 15 s→60 s backoff. This successor is not your own submission, so bound themcp__escalation__merge_status(request_id="<superseded_by value>")
poll with its own 20-minute wall-clock ceiling rather than waiting unbounded. While it is
still unresolved the never-resubmit rule above holds — do not act on a non-terminal poll.
Once it reaches a terminal state, dispatch on that outcome:done→ landed. This successor merges the same single branch (no tip/train distinction),
so the standard polled-done procedure above applies directly, including itslast_outcome.merge_sharule: this is arequest_id-arm poll of a solo request, which
can itself land rebased.conflictorblocked→ the successor has now failed on its own terms, and nothing
auto-retries it —_redrive_coalesce_membersis gated onisinstance(req, GroupMergeRequest)and the train id starting withcoalesce-
(orchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._merger_loop), neither of which holds for
a generation-advance successor. Fix in the worktree, rebase on main, and resubmit — the
standard Polled terminal failures remediation, loop back to step 7. (Both states are
reachable here: this successor is an ordinary solo merge throughclassify_and_merge,
which returnsconflict(merge_lane/worker.py::classify_and_merge) and which_map_terminal_statepasses
through unchanged (escalation/server.py:2194-2195). The conflict→blockedcollapse
(merge_lane/worker.py::_do_train_merge) is inside_do_train_merge— train path only.)abandoned→ stop and report to the human. Do not resubmit; the resubmission may have
been cancelled deliberately.superseded→ the successor was itself superseded (a further generation advance, or
absorption into a train). Re-read itssuperseded_byand re-enter this bullet from the
top against that value; the 20-minute ceiling is shared across the whole chain.unknown→ the successor's record is gone (orchestrator restarted, or the retention ring
expired). This is terminal on therequest_idarm from the very first tick — it does
not wait for the 20-minute ceiling. Do not fall through to Polled terminal failures'
plainunknownrule: that ends in "loop back to step 7 (resubmit)", and the successor may
still be in flight, which is the double-merge race this bullet exists to forbid. Go
straight to the branch-handle fallback below.
coalesce-*id (coalesce-train path) — this names the train, not a request. It
resolves through none ofmerge_status's tiers (no retention-ring alias is ever recorded
for a train id, no event-store finalized row is keyed on one, and Tier 3.5's git-authority
probe is skipped when onlyrequest_idis passed) — polling it byrequest_idreturns an
honeststate: "unknown"that will never resolve to anything else. Do not poll it byrequest_id.
For the
coalesce-*case, or anmr-*poll that returnsunknownon any tick (including
the first) or is still unresolved at its 20-minute ceiling: do not fall through to step 7's
plainunknownrule — that rule resubmits, which is exactly the race this bullet exists to
forbid. Instead stop polling byrequest_idand fall back to thebranchhandle plus the
canonical ancestry check — including
`skills/_shared/deriving-landed-sha.md`'s
exact-subject merge-marker search on the rc=128 arm:mcp__escalation__merge_status(branch="task/<TASK_ID>")If still in flight, keep polling the branch handle with the same backoff, under the
resumed-poll terminal set below. Never resubmit and never direct-merge.Resumed-poll terminal set. Wherever this section sends you to
branch-handle polling with asupersededhit already in hand — after an rc=1 ancestry read
(the case below), or because itssuperseded_bywas unpollable or never resolved — dropsupersededfrom the terminal set for that resumed loop. The branch handle will otherwise
re-serve the identical record on tick 1,accept_terminalaccepts it unconditionally, and you
bounce straight back into the bullet you came from — a ping-pong that burns the entire
20-minute budget without ever observing amerge_statusstate change. Use:terminal_resumed = ("done", "conflict", "blocked", "abandoned") # plus: a `superseded` whose `superseded_by` DIFFERS from the one you just disregarded — # that is a genuinely new absorption; re-enter the superseded bullet against it. # An identical `superseded`/`superseded_by` pair is the stale record: keep polling.merge_statuswill never itself change for a coalesce-absorbed member — nothing overwrites
itssupersededrecord (see above) — soterminal_resumedalone can starve forever even after
the real merge lands. On every tick, alongside themerge_statuscheck, also re-run the
canonical ancestry check. Break on ancestry rc=0. On thecoalesce-*arm
— under either rc=1 or rc=128-with-empty-marker — the landing signals below may also end
the loop, but only when they resolve to landed AND credited: signal (a) shows the tip landed
and signal (b), re-read fresh, saysdone. That is a cleanup exit, not a stamping one —
the automatic flip already happened, so there is nothing for step 8 to write: skip step 8
entirely and go straight to step 9 (cleanup). This is a success path, not an abort.Signal (a) alone must never break the loop. Signal (a)-landed next to a non-
donesignal (b)
is exactly rule 2v's veto (both arms): keep polling toterminal_resumed's 20-minute ceiling, and only then
take the landed-but-not-credited exit stated there — citing the tip merge sha, thegit cherryoutput and the current status; never "not landed", never resubmit, never self-stamp.
Breaking out on signal (a) alone would fall through to step 8'sset_task_status(..., done_provenance=...), which is the self-stamp this arm forbids.Stop-and-report as not landed only once ancestry (and, where reached, both signals) is still
not-landed when that ceiling arrives; that final check is what "if it never lands" means below. This does
not contradictaccept_terminal's "do not spin here re-polling this same key": that rule
governs the first loop, where exiting onsupersededis exactly what gets you to the
ancestry check. This governs the resumed loop, which is driven by that check, not bymerge_status's frozen state.Neither an empty rc=128 marker search nor rc=1 means "not landed" here. A coalesce train
stacks its members linearly and merges only the tip branch into main (theGroupMergeRequest
carriestip_branch=tip_req.branch, set inorchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._maybe_coalesce_waiting_singles),
so a non-tip absorbed member gets its commits onto main with noMerge task/<TASK_ID> into main
marker of its own. And that tip is rebased onto current main before the merge, rewriting
every stacked commit's sha, while this member's owntask/<TASK_ID>ref is never advanced to the
rewritten commits — so its ref keeps the pre-rebase shas, can never become an ancestor of main,
andorchestrator/src/orchestrator/git_ops.py::GitOps._delete_branch_if_on_mainretains it
rather than deleting it: that cleanup deletes only a branch carrying no commits beyond main, andGitOps._branch_has_commits_beyond_maincountsmain..<branch>by sha (rev-list's count
mode), not by patch id, so a stale-by-rebase ref counts non-zero. rc=1 is therefore the
common outcome for a non-tip member and rc=128 the rare one. Taking either as "not landed"
would report a successful merge to the human as a failure and leave the task un-flipped.On the
coalesce-*arm, `skills/merge-queue/SKILL.md`'s "Follow the
superseded successor" rules 1–4 are the authority for what follows; this bullet mirrors them so
the two files agree, and defers to them for anything it does not restate. So:- Ancestry
rc=0is authoritative — landed — while the ref still exists. - Ancestry
rc=1(the branch ref exists and its commits are not ancestors of main) is
not a not-landed verdict on this arm. For a non-tip absorbed member it is the normal and
permanent post-landing state, per the mechanism above — not a "not landed yet", and never a
reason to resubmit. Disregard the rawsuperseded/superseded_byvalue as an action signal
(do not try to poll or follow it), resume branch-handle polling under the
resumed-poll terminal set, and on every tick consult the two landing signals
below, which are consultable under rc=1 on this arm exactly as they are under
rc=128-with-empty-marker. (Outside thecoalesce-*arm — anmr-*successor, or no train
absorption anywhere in this task's history — rc=1 keeps its ordinary reading of "not landed
yet", the signals below do not apply, and stop-and-report only once rc=1 still holds at that
loop's 20-minute ceiling; the exception is the ladder's
rebased-solo-landing arm, since a solomr-*successor can itself land rebased. Signal (a) is not even derivable there: there is nocoalesce-<TIP_ID>-<hex>id to parse a TIP_ID from.) - There are exactly two affirmative landing signals, and only these two:
(a) the tip's merge marker on main — step 1's
selecting search, run against the tip's subject:S="Merge task/<TIP_ID> into main" git log main --fixed-strings --grep="$S" --format='%H%x09%s' \ | awk -F'\t' -v s="$S" '$2==s && !seen {print $1; seen=1}'<TIP_ID>is parsed off thecoalesce-<TIP_ID>-<hex>id by stripping thecoalesce-prefix
and the trailing-plus 8 hex chars (uuid.uuid4().hex[:8]) — not a naive split on-,
which breaks for any hyphen-bearing tip id. Theawkhalf is the subject selection and is
not optional:--grepmatches commit bodies too, and on this arm a body match would be
stamped as the tip merge sha. Do not add--max-count=1— it stops at the newest message
match, so a body match shadowing the real tip merge would make this signal read not-landed on a
train that landed, and this arm has no step-3 ladder to fall through to
(`skills/_shared/deriving-landed-sha.md`,
with the measured population). And
(b) this task's own scheduler status, read fresh withget_task(id="<TASK_ID>", project_root="<PROJECT_ROOT>"). The orchestrator flips it todone
for every absorbed member once the train lands
(orchestrator/src/orchestrator/harness.py::mark_member_done). - Under rc=128-with-empty-marker → `skills/merge-queue/SKILL.md`'s
rule 2a governs (with the shared rule 2v veto); follow it there rather than from here. - Under rc=1 on the
coalesce-*arm → `skills/merge-queue/SKILL.md`'s
rule 2b governs (with the shared rule 2v veto); follow it there rather than from here. The verdict, so you know which way
the arm points before you go: rc=1 is not a not-landed outcome here, signal (b) is a veto,
not a corroborator, and there is no self-stamp on this arm. Read rule 2v there for the
argument — which exit each scheduler status yields, its landed-but-not-credited report, and
why a content proof cannot discharge the veto — and rule 2b for thegit cherry main task/<TASK_ID>content proof itself, rather than restating it. get_merge_queue()no longer showing the train is NOT a landing signal. It means only
"stop waiting on the train," and is equally consistent with a derail: on any non-done
train outcome the orchestrator re-pends the still-unlanded members for solo re-merge
(orchestrator/src/orchestrator/merge_lane/worker.py::SpeculativeMergeWorker._redrive_coalesce_members),
which also removes the train from the queue with nothing of yours on main. On queue-absence with
neither (a) nor (b), the correct action is to resume polling thebranchhandle to the
20-minute ceiling under the resumed-poll terminal set — the
orchestrator's re-drive lands it — never to flip the task.
Stop-and-report to the human, never resubmitting and never direct-merging, in these cases:
under rc=128-with-empty-marker once both signal (a) and signal (b) come back not-landed, or under
rc=1 once the branch-handle polling above has reached its 20-minute ceiling with the signals still
unresolved. Under the veto the report is different: if signal (a) shows the tip landed but
signal (b) still reads non-doneat that ceiling, keep polling until then (the flip is
asynchronous, so the normal landed case reachesdonewithin a tick or two and exits clean) and
then report it as landed-but-not-credited — citing the tip merge sha, thegit cherryoutput
and the current status — never as "not landed", and never self-stamp.
Abandonment (merge_cancel):
To abandon a submitted merge (e.g. the task needs redesign after submission):
mcp__escalation__merge_cancel(request_id=result["request_id"])Whether the request_id you received cancels the in-flight entry depends on how you got it: for queued, and for attached with poll_by == "request_id", it already is the in-flight entry's id (attached responses with a known inflight_request_id set req_id_override=dispatch.inflight_request_id) and cancel works as described above. For attached with poll_by "task_id" or "branch", the id you received names your own coalesced submission, not the in-flight entry — treat the cancel as best-effort (cancelled: false / state: "unknown" is the expected outcome, not evidence of a lost record). Re-check the real state by the handle poll_by names (merge_status(task_id=...) or merge_status(branch="task/<TASK_ID>")) before deciding whether the merge still needs abandoning.
If merge_cancel returns {state: "unknown"} on the request_id handle (or after the re-check above still leaves it unresolved), the entry has no live waiter in this server instance (restarted or finalized) — poll mcp__escalation__merge_status(request_id) first (it now self-resolves via the git-authority tier and returns state: "done" / kind: "found_on_main" / merge_sha when the branch is provably on main). If merge_status still returns unknown, confirm deterministically:
git merge-base --is-ancestor task/<TASK_ID> main; rc=$?; echo "ancestry rc=$rc"
# The trailing `echo` is REQUIRED -- see the [canonical ancestry
# check](#branch-on-main) above for why: without it, "on main" and "NOT on
# main" print identical empty output and exit 0, indistinguishable.Read that rc against `skills/_shared/deriving-landed-sha.md` — the single normative ladder, entered ancestry-first (see Two entry points). Run it in full; do not shortcut it. The canonical ancestry check above states this skill's branch_on_main() contract over its verdicts.
This site's action — you are deciding whether the entry still needs abandoning. On a landed verdict (the ladder yields a verified sha) the entry does not need abandoning: treat it as done and proceed to step 8 with done_provenance={"kind": "found_on_main", "commit": "<that sha>", "note": "<the note the ladder specifies for that arm>"}; both fields are required. On rc=1 outside the coalesce-* arm, take the ladder's rebased-solo-landing verdict first: a pass is a landing (no abandoning; step 8 with that arm's stamp), and landing-unattributable means stop and report — never resubmit, never direct-merge. Only on its genuine not-landed outcome did the merge not land — decide whether to resubmit or discard. On the ladder's other genuine not-landed outcomes, and where the citation gate is un-evaluable, stamp nothing and report; do not read either as a confirmed landing.
Never fall back to direct merge in response to unknown — unknown means the server lost its record, not that the merge failed.
If this is an escalated task (pending escalation, agent is paused):
Choose one of these based on the analysis:
Resolve and resume — if your blocker fixes address the escalation concern, resolve with actionable instructions for the resumed agent:
resolve_issue(escalation_id="<id>", resolution="<specific instructions>", action='resume', resolved_by="interactive", resolution_turns=<N>)The agent resumes with your resolution injected into its briefing. Task stays
in-progress.Restart and reschedule — if the task needs fundamental redesign:
resolve_issue(escalation_id="<id>", resolution="<reason for restart>", action='restart', resolved_by="interactive", resolution_turns=<N>)Then create or update tasks as needed. Task goes to
pending.
Turn counting: <N> is the number of user messages since this skill was invoked (count each time the user sent a message, starting from the /unblock invocation). This tracks how much human attention the resolution required. If you lose count, estimate conservatively.
- Fix manually and merge — if you fix the issue yourself in the worktree, follow the blocked-task merge procedure above.
If this session RULES an escalated question, write the ruling to the record at ruling time — not
at session end. Trigger (already computed, costs nothing): the block cause was a pending
escalation — any category — that you read in Step 1b, and the session chose an option (with or
without human ratification) rather than deferring the question. Immediately append the ruling to
the escalation record with amend_escalation(escalation_id=..., summary=..., detail=...): the
chosen option, the ruling commit sha, and what remains (e.g. "closure deferred pending merge
gate"). The amendment bumps updated_at, which is
exactly what re-arms the watcher's re-verify on a parked record. This is an annotation, not a
closure — L2 close rules are unchanged. Do NOT defer the record-write behind a merge gate or any
tail plan: a deferred write is precisely what dies when a session ends early (esc-6107-7 sat
answered-but-unrecorded for 183h because its close was sequenced behind a merge gate and the
session stopped first). While you're there, check get_pending_escalations(task_id="<TASK_ID>")
for OTHER pending records on the same task and disposition them in the same sitting — a ruling
recorded on one twin while another record survives is the same failure class (esc-3875-12 kept a
Leo-released task pinned 6.8 further days). When you later resolve the record, the resolve_issue
response's related_pending lists the twins still pending after that resolve; dispose of those too.
4.4: Execute the plan
Exit plan mode and execute. Keep the task in its current status during the work — don't manually change it until you've successfully merged or resolved. This prevents the orchestrator from trying to start new agents on it. One deliberate exception: in the blocked-task merge procedure, release_workflow intentionally moves an escalated/in-progress task to blocked (the reaper-immune holding state) when it releases the slot — that's the safe status to work from, and the final set_task_status(done) after merge is then the normal blocked→done transition. The second exception is a task you found pending, which you hold as deferred with a held_by_session record (step 1 of the blocked-task merge procedure), never as a hand-set blocked.
4.5: Final reflect
The task is not done until this step completes. After the merge succeeds (or escalation is resolved) and the task status is updated, invoke /reflect to capture:
- What was fixed and how
- Any decisions made during the fix (e.g., chose approach A over B because...)
- Architectural insights that surfaced during the work
- Brief summary: what was accomplished, what was deferred (with task numbers)
This is the last step. Do not consider the unblock workflow complete until reflect has run.
Recovering a wedged rebase or merge
Anywhere this skill says "fix the conflict in the worktree, rebase on main" — and anywhere you
decide to back out of a rebase or merge instead — do not reach for a bare git rebase --abort
or git merge --abort. Both have measured failure modes that leave the worktree wedged: an abort can
die outright, or fail with git's "Another git process seems to be running" advice, which names no
remedy you can act on.
Run the preflight first, from the primary dark-factory checkout:
.venv/bin/python -m orchestrator.rebase_recovery preflight --worktree <worktree>Parse JSON stdout; verdict is one of clean | repaired | blocked.
cleanorrepaired→ abort with the guard, and use this spelling every time:git -C <worktree> -c rerere.enabled=false rebase --abort(or... merge --abort). With rerere
disabled git never opensMERGE_RR, which is what makes the abort survive both failure modes.blocked→ do not abort. Report the payload'sunrepairedentries to the human verbatim.
It means either something the preflight declined to touch — typically a lock a live process
still holds open, and deciding what that process is, is a human's call — or a worktree it could
not resolve at all (resolved: false), where it inspected nothing and the other fields are
empty for want of a look rather than for want of damage. Check the path you passed before
reading anything else in the payload.
The preflight moves a suspect MERGE_RR aside to<git-common-dir>/rerere-specimens/MERGE_RR.<worktree>-<stamp> — moved, never deleted. A
successful abort deletes the original file, and it is the only record of which conflict ids the
worktree was carrying, so leave any quarantined copy where it is.
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 — report only what was observed, never
an explanation of the cause.
End states
After this skill completes, the task should be in one of these states:
| Starting state | Outcome | Final state |
|---|---|---|
| Blocked | Successfully merged to main, verification green | Done |
| Blocked | Needs redesign, can't fix this session | Pending (update task description first) |
| In Progress (escalated) | Escalation resolved, agent resumes | In Progress |
| In Progress (escalated) | Escalation terminated, work rescheduled | Pending |
| In Progress (escalated) | Fixed manually, merged to main | Done |
In every end state, any escalation this session ruled on must already carry the ruling as an
amendment on the record itself (Step 4's ruling-time rule). An end state reached with the ruling
living only in chat, a local todo, or a tail plan is not a valid exit.
Project-specific verification
The verification commands depend on the project. Check orchestrator.yaml in the project root for test_command, lint_command, and type_check_command overrides. If there's no override, use whatever the project's standard tooling is (check for Cargo.toml, package.json, pyproject.toml, etc.).