Development record
A stuck drain lane hands itself back
Press Release
A drain lane that cannot finish no longer stops the whole drain. When a lane's pull request conflicts, its step keeps refusing, or its issue turned out to be fixed already, the drain hands the lane back as stuck after a bounded number of tries, releases its reservation, says why, and moves on to the next issue. Lanes waiting on a merge are read from their pull requests each move, so one that closed, left the merge queue or started to conflict stops holding its issue and is named. Lanes merge the default branch before they land and regenerate generated files rather than conflicting on them.
Why This Matters
A drain opens one lane at a time and waits on it, so a lane that can never finish stops the whole drain. On 2026-10-16 that is the ordinary state, not an edge case. A lane whose pull request conflicts at arming, one the land fence stops, and one whose brief is refused because the issue was already fixed all read to the drain as still in progress, and every later move answers "drive" that lane. The only way out is removing the run's directory by hand (iss-2610161015368630). Lanes parked waiting on a merge are renewed from local state alone, so a pull request that was closed, dropped from the merge queue or started to conflict keeps its issue reserved, counted against the cap of ten, for as long as the drain runs (iss-2610161015381750). And lanes from several accounts conflict on the same generated files: of 259 merges to main since 2026-10-01, 54 touched the generated CLI reference, 29 the surface manifest and 18 the brief's surfaces page, while no lane merges the default branch before it opens its pull request (iss-2610161015410527).
An unattended drain on several accounts reaches each of these within hours. Each one leaves a drain that looks alive and opens nothing, which costs a person a whole run to notice.
Mechanism
Facilitator-on-delegation, 2026-10-16: the product thinker delegated the planning decisions of this bundle; this claim is the facilitator's, grounded in the evidence of the issues it closes.
We expect a drain to keep moving past lanes that cannot finish, because the drain reads each lane's progress from evidence that changes: the refusals the run records, and the pull request's state on the forge. It does not read a local stage that never moves. After a bounded number of identical refusals the lane goes to a dead-letter hand-back, as a queue redrives a message after its maximum receive count. And a lane that merges the default branch before it pushes, and again whenever its waiting pull request starts to conflict, with its generated files regenerated by its implementer, cannot stay in conflict on those files. The forge ignores the merge drivers a local merge would use, so the merge has to happen on the lane's branch. It would be shown wrong if, in the first week of drains on two or more accounts, a drain stays on one lane for more than the stuck bound plus one move, a closed or conflicting pull request keeps its issue reserved past the next move, or a drain lane's pull request conflicts on a generated file alone.
Scope Conditions
- Holds for lanes driven by
abcd implement step, whose refusals are recorded in the run's state. A lane driven by hand outside the loop is not read. - Holds where the forge answers one pull-request listing per drain move. A move that cannot read it renews nothing new and says so, and the next move reads again.
- Holds for generated files the repository declares as generated in
.gitattributes, read from origin's default branch. An undeclared generated file still conflicts, and the lane hands back. - Holds for conflicts confined to declared generated files, which the lane's implementer regenerates under the host's permissions. Any other conflict hands the lane back for a person, as a conflicting pull request does today.
Acceptance Criteria
- Given Maya's drain lane whose step has refused the same way three times, or has refused at one stage for an hour, when Maya's drain makes its next move, then it hands the lane back as stuck, naming the refusal, releases the lane's reservation, and opens the next eligible issue's lane in the same move. (iss-2610161015368630)
- Given a lane that has waited ninety minutes for its preflight receipt, or through an outage, when the drain moves, then the lane is not handed back as stuck, because a wait is not a refusal. (iss-2610161015368630)
- Given a lane whose brief is refused because its issue is already resolved at the lane's base, when the drain next moves, then it hands the lane back at once, without waiting for the stuck bound, since the refusal cannot change. (iss-2610161015368630)
- Given a lane that has been handed back as stuck, when a person runs
abcd implement step --discardon it, then the run is discarded, with no run directory removed by hand. (iss-2610161015368630) - Given Maya's drain has a lane parked waiting for its pull request to merge, and that pull request has been closed, when the drain makes its next move, then it releases the lane's reservation, hands the lane back, and names it. (iss-2610161015381750)
- Given a parked lane whose pull request has started to conflict, when the drain makes its next move, then it stops renewing that lane's reservation and names the lane in its next move. (iss-2610161015381750)
- Given several parked lanes, when the drain makes a move, then it reads all their pull requests in one call to the forge. (iss-2610161015381750)
- Given a parked lane whose pushed head is not on the default branch and whose pull request is still open and mergeable, when the drain moves, then the lane is renewed and named as waiting, never skipped silently. (iss-2610161015381750)
- Given Bob's lane merged first and changed the generated CLI reference, and Maya's lane, cut earlier, changes it too, when Maya's lane reaches its sync before the push, then it has merged origin's default branch, the conflict on the generated file has gone back to the implementer as a fix round, and the validated result's pull request is mergeable without a person resolving it. (iss-2610161015410527)
- Given Maya's pull request is open and waiting, and another merge to the default branch makes it conflict on a generated file, when the drain next moves, then the lane syncs again instead of being handed back. (iss-2610161015410527)
- Given a lane whose merge of the default branch conflicts on a file that is not declared as generated, when the lane syncs, then nothing is pushed, and the lane is handed back naming the conflicting files. (iss-2610161015410527)
Decomposition
Run by hand under decompose-before-filing on 2026-10-16, facilitator-on-delegation (dcn-2610161040352440), and graded in the decomposition-calibration note.
| Part | Type | Home | Verdict |
|---|---|---|---|
| A lane that keeps refusing is handed back as stuck after a bound (iss-2610161015368630) | capability | this intent, a new hand-back kind routed as itd-82's hand-backs are | file here |
| Parked lanes read from their pull requests each move (iss-2610161015381750) | capability | this intent | file here |
| Lanes merge the default branch, and have declared generated files regenerated, before the push and again while the pull request waits (iss-2610161015410527) | capability | this intent; the regeneration is the implementer's fix round, so abcd runs no repository-declared command and no trust rule is added (dcn-2610161040384068) | file here |
Verdict: FILE-AS-IS, as a member of the drain-many-accounts bundle. itd-2610161016401115 builds on it.
SOTA
Facilitator-on-delegation, 2026-10-16:
- Dead-letter redrive. Message queues move a message that keeps failing to a dead-letter queue after a maximum receive count, as Amazon SQS does with
maxReceiveCount. This is mature. Adopted, path 2: three identical refusals, or sixty minutes refusing, hand the lane back as stuck, and waits by design are not counted. - Keeping a branch current with its base. GitHub's "update branch" and merge queues merge the base into a pull request so that it is tested as it would land. Both are mature. Adopted, path 2: the lane's sync merges origin's default branch before its push, and again while its pull request waits.
- Generated files. GitHub's
linguist-generatedattribute marks generated files, and lockfile merge drivers regenerate them on a local merge. The forge ignores merge drivers (iss-2609240646538011), so path 2 takes the attribute and leaves the regeneration to the implementer's fix round.
Open Questions
None open; the product thinker delegated this bundle's planning decisions on 2026-10-16, and each is recorded under Decisions. The technical questions the build meets are in the bundle's spec.
Audit Notes
Fidelity review — receipt rcp-710de708faa8 (verifier abcd:intent-auditor claude-opus-5-5).
Provenance: abcd:intent-auditor@claude-opus-5-5 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:19d260c1998ec14bc2e71f6803d8a32ba3841de4a92857c11a48b0b6d3ef7ae7 Input attestations: diff:spc-2610161044461448 steps 5, 8 and 13 as merged to main: 7611c2a1f 4dcea36d3 1bc82f1f2 f59267b30 8739ad7f6 00e95902b a1c16e21f (step 5); 40b14c21b e7975ddb4 730aca57b (step 8); 41b9add2a 1b5ed80a9 (step 13); 80764362b (spec close)@-; tree:origin/main at 2e03c61e4, read in the lowcpu-audits worktree, whose later commits add audit notes only (nothing under internal/, commands/ or .gitattributes differs)@-;
Acceptance rollup: MET 8 · MET_WITH_CONCERNS 3 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET_WITH_CONCERNS: stuckReason reads three identical refusals, sixty minutes refusing at one stage, or a refusal that cannot change (stuck.go:192-213), and the drain's move then hands the lane back as stuck with the refusal as its reason and releases its reservation with a stuck tombstone (drain.go:781-809, stuck.go:252-256), pinned by TestThreeIdenticalRefusalsHandTheLaneBackAsStuck and TestSixtyMinutesRefusingAtOneStageHandTheLaneBackAsStuck; concerns: the same move reserves the next eligible issue and asks its judgement, which is what the test asserts, so that issue's lane opens in the same move only when a routed judge answers no within it or a no already stands; the sixty minutes count only refusals no more than fifteen minutes apart (dcn-2610170118506273), so a lane stepped less often is bounded by the identical-refusal count alone; and a refusal naming no lane, such as the run's config tripwire raised before the state is read, is never counted evidence: internal/core/implement/loop/stuck.go:205 — "case r.Count >= b.refusals.Value:" evidence: internal/core/implement/loop/stuck.go:208 — "case mins >= b.minutes.Value:" evidence: internal/core/implement/loop/drain.go:781 — "if stuckReason(run, *bounds) != "" {" evidence: internal/core/implement/loop/stuck.go:256 — "releaseOnHandBack(repoRoot, st, *lane.HandBack, now)" evidence: internal/core/implement/loop/drain_stuck_test.go:84 — "if kind, _ := tombstoneTrailers(t, bare, secondIssue); kind != claim.HandBackStuck {" evidence: internal/core/implement/loop/drain_stuck_test.go:93 — "t.Fatalf("the same move takes up the next eligible issue: %+v", res.Judging)" evidence: internal/core/implement/loop/drain.go:1029 — "if taken == nil {" evidence: internal/core/implement/loop/stuck.go:156 — "if now.Sub(p.Last) <= stuckGap {" evidence: internal/core/implement/loop/loop.go:719 — "if err := configTripwire(repoRoot, runID); err != nil {"
- ac-2 — MET: waitByDesign keeps out of the lane's log every refusal carrying checkWait, which the real landing sets on its preflight-receipt wait (land.go:452), and every outage wait, which netFailure marks (outage.go:334); TestAWaitByDesignIsNotStuck steps each wait seven times fifteen minutes apart, ninety minutes, finds nothing logged, and the drain's move leaves the lane in progress with nothing routed evidence: internal/core/implement/loop/stuck.go:119 — "return !r.checkWait.IsZero() || r.Outage != nil || r.outage != nil || r.Stage == StageOutage || r.originUnread || r.releaseHeld" evidence: internal/core/implement/loop/land.go:452 — "r.checkWait = since" evidence: internal/core/implement/loop/outage.go:334 — "r.outage = &outageReport{service: implement.ServiceNetwork, kind: implement.OutageTool, lane: lane, what: what}" evidence: internal/core/implement/loop/drain_stuck_test.go:289 — "t.Fatalf("a lane waiting ninety minutes on %s is still in progress: %+v", tc.name, res)"
- ac-3 — MET: the brief refuses an issue held resolved or wontfix at the lane's base with a refusal marked final (issuebrief.go:56-64), stuckReason hands back on a final refusal before either bound is read (stuck.go:203-204), and TestABriefRefusedForAnIssueFinishedAtItsBaseHandsBackAtOnce logs one refusal (count 1, final) and the very next drain move routes a stuck hand-back saying it cannot change evidence: internal/core/implement/loop/issuebrief.go:63 — "r.final = id + " is finished at the lane's base"" evidence: internal/core/implement/loop/stuck.go:203 — "case r.Final != "":" evidence: internal/core/implement/loop/drain_stuck_test.go:326 — "!strings.Contains(r.Reason, "cannot change")"
- ac-4 — MET: decidable lets --discard take a lane handed back as stuck (hold.go:130-132, stuckLane at hold.go:186-188), Discard removes its worktree and branch and records the issue run over (hold.go:173-178, 202-260), the CLI's implement step calls loop.Discard (build.go:1035), and TestDiscardAcceptsALaneHandedBackAsStuck asserts the lane discarded, the run complete, the worktree gone and the branch deleted evidence: internal/core/implement/loop/hold.go:130 — "if l := st.Lanes[i]; flag == "--discard" && stuckLane(l) {" evidence: internal/surface/cli/build.go:1035 — "res, err = loop.Discard(root, id, discard, loop.Options{})" evidence: internal/core/implement/loop/drain_stuck_test.go:357 — "if st.Lanes[0].Stage != StageDiscarded || !st.Complete() {"
- ac-5 — MET: readParked reads a pull request the listing shows CLOSED as closed (drain_finish.go:154-155); finishMerged then hands the lane back as closed and releases its reservation with the hand-back's tombstone under the run's lock (drain_finish.go:291-297, 391-413), the drain routes the hand-back home (drain.go:701-728) and names it ahead of next (drain.go:480-481); TestAClosedPullRequestReleasesAndHandsBackItsLane asserts the released tip, the closed tombstone, the route and next naming #7 as closed evidence: internal/core/implement/loop/drain_finish.go:154 — "case pr.State == "CLOSED":" evidence: internal/core/implement/loop/drain_finish.go:403 — "releaseOnHandBack(repoRoot, st, *lane.HandBack, now)" evidence: internal/core/implement/loop/drain_parked_test.go:149 — "if kind, _ := tombstoneTrailers(t, f.bare, secondIssue); kind != claim.HandBackClosed {" evidence: internal/core/implement/loop/drain_parked_test.go:162 — "if !strings.Contains(res.Next, "#7") || !strings.Contains(res.Next, "closed") {"
- ac-6 — MET: a listing showing the pull request CONFLICTING reads the lane as conflicting (drain_finish.go:158-159), which held() leaves out of the parked renewal loop (drain_finish.go:170, 215), the lane-in-progress renewal never renews a parked lane (drain_lease.go:72-75), and the reading is named in next; TestAConflictingPullRequestIsNotRenewedAndIsNamed asserts the reservation's tip unmoved at twelve and a half hours, next naming #7 and the conflict, and no hand-back evidence: internal/core/implement/loop/drain_finish.go:158 — "case pr.Mergeable == MergeableConflicting:" evidence: internal/core/implement/loop/drain_finish.go:215 — "if run.ReservedAt == nil || !read[run.RunID].held() {" evidence: internal/core/implement/loop/drain_lease.go:74 — "return run.ReservedAt != nil && outcome == DrainLaneInProgress && parkedAtLand(run) < 0" evidence: internal/core/implement/loop/drain_parked_test.go:199 — "if tip := originReservation(t, f.bare, ref); tip.commit != fenced.commit {" evidence: internal/core/implement/loop/drain_parked_test.go:205 — "if !strings.Contains(res.Next, "#7") || !strings.Contains(res.Next, "conflict") {"
- ac-7 — MET_WITH_CONCERNS: finishMerged calls listParked once in any move that finds a parked lane (drain_finish.go:196-203), a single gh pr list --state all --author @me (drain_finish.go:115-116) matched to every lane by number and head branch, and TestEveryParkedLaneIsReadInOneForgeListing counts exactly one listing for two parked lanes; concern: the one call is one page of the person's latest 100 pull requests (parkedListLimit, dcn-2610170728074867), so a parked lane whose pull request is older is read as unread and kept renewed, never read from the forge evidence: internal/core/implement/loop/drain_finish.go:199 — "listed, unread := listParked(repoRoot)" evidence: internal/core/implement/loop/drain_finish.go:115 — "out, err := landing.GH(repoRoot, "pr", "list", "--state", "all", "--author", "@me"," evidence: internal/core/implement/loop/drain_finish.go:90 — "const parkedListLimit = 100" evidence: internal/core/implement/loop/drain_finish.go:149 — "is not among the forge's listing of your latest %d pull requests, so its lane is kept held" evidence: internal/core/implement/loop/drain_parked_test.go:111 — "if got := f.parkedListings(t) - before; got != 1 {"
- ac-8 — MET: an open pull request the listing does not show conflicting reads as waiting (drain_finish.go:160-164), held() keeps it in the renewal loop, renewed at half its lease (drain_finish.go:170, 213-233), and parkedNote names every parked reading ahead of next on each move (drain_finish.go:417-426, drain.go:480-481); TestAnOpenParkedLaneIsRenewedAndNamedAsWaiting asserts a fresh renewal on origin and next naming #7 as waiting evidence: internal/core/implement/loop/drain_finish.go:163 — "n+" is open, waiting on a person's merge (merge state "+status+"), so its lane is kept held"" evidence: internal/core/implement/loop/drain.go:480 — "if note := parkedNote(res.Parked); note != "" {" evidence: internal/core/implement/loop/drain_parked_test.go:227 — "if tip := originReservation(t, f.bare, ref); tip.parent != fenced.commit || tip.released || tip.claimed.Before(at) {" evidence: internal/core/implement/loop/drain_parked_test.go:233 — "if !strings.Contains(res.Next, "#7") || !strings.Contains(res.Next, "waiting") {"
- ac-9 — MET_WITH_CONCERNS: the issue lane's sync, run at its landing before its records and push (land.go:142-148), fetches origin's default branch and merges it whenever it has moved (sync.go:110-134); a conflict confined to files declared linguist-generated, the CLI reference among them (.gitattributes:58), is aborted and sent to a fresh implementer whose brief says to regenerate them (sync.go:191-207, 402-406), whose receipt must contain the merged sha (sync.go:427) before a fresh round of validators judges it, pinned by TestAConflictOnADeclaredGeneratedFileGoesToTheImplementer; concerns: that round is a sync, which counts no fix round (sync.go:14-15, dcn-2610170755303451), not the fix round promised; no test carries such a conflict through the regeneration to a pushed, mergeable pull request, re-landing being pinned only for a conflict-free resync; and iss-2610170921271252, still open, records that this pre-push merge is unbounded, so a slow lane can re-merge and re-validate without ever pushing evidence: internal/core/implement/loop/land.go:145 — "if out, synced, err := syncLane(c, lane); err != nil || synced {" evidence: internal/core/implement/loop/sync.go:111 — "if tracking, err = fetchDefault(c, *lane, def); err != nil {" evidence: internal/core/implement/loop/sync.go:196 — "gen, unread := claim.GeneratedFiles(c.RepoRoot)" evidence: internal/core/implement/loop/sync.go:207 — "return Outcome{Goto: StageValidate, LandLeft: true, Reservation: reserved, Note: conflictNote(lane.ID, def, merged, paths, siblings, s.Brief)}, true, nil" evidence: internal/core/implement/loop/sync.go:427 — "on, err := gitutil.IsAncestor(c.RepoRoot, s.Merged, lane.HeadSHA)" evidence: internal/core/implement/loop/sync.go:14 — "A sync does not count against" evidence: .gitattributes:58 — "docs/reference/cli/commands.md linguist-generated" evidence: internal/core/implement/loop/sync_default_test.go:140 — "t.Fatalf("the conflict goes to an implementer: %+v", l)" evidence: .abcd/work/issues/open/iss-2610170921271252-before-its-push-an-issue-lane-re-fetches.md:15 — "can run round after round of validators and never reach its push"
- ac-10 — MET: for a parked lane the listing shows conflicting, finishMerged checks a driver-free merge as the forge merges (syncable, drain_finish.go:346-353) and, when it conflicts in declared generated files alone, takes the run's own landing step (resyncParked, drain_finish.go:274-289), whose resyncLane withdraws an armed merge as a step of its own and otherwise merges the default branch (sync.go:284-302); the drain reads the lane as syncing and drives it again (drain.go:693-697); the while-it-waits-armed subtest asserts --disable-auto, ParkedSyncing, the lane in progress and no hand-back, then the lane's next step re-entering the sync evidence: internal/core/implement/loop/drain_finish.go:274 — "if p.State == ParkedConflicting && syncable(repoRoot, tracking, lane.Landing.Pushed) {" evidence: internal/core/implement/loop/sync.go:292 — "if _, err := forge(c, *lane, "pr", "merge", n, "--disable-auto"); err != nil {" evidence: internal/core/implement/loop/drain.go:696 — "st.Lanes[i].Outcome, finished = DrainLaneInProgress, true" evidence: internal/core/implement/loop/sync_default_test.go:319 — "t.Fatalf("the drain reads the lane as syncing again: %+v", p)" evidence: internal/core/implement/loop/sync_default_test.go:325 — "if st, err := ReadState(f.repo.Root(), f.runID); err != nil || st.Lanes[0].HandBack != nil {"
- ac-11 — MET: a sync merge that conflicts in any path the declaration does not name is aborted with the branch unchanged and refused, before the records and the push, with a final refusal naming the files (sync.go:191-199, 236-247), which the drain's stuck rule hands back at once (stuck.go:203-204); TestAConflictOnAnUndeclaredFileHandsBackAndPushesNothing asserts the refusal names plain.txt, no branch reaches origin, and the next drain move's stuck route names plain.txt evidence: internal/core/implement/loop/sync.go:197 — "if others := undeclared(paths, gen); len(others) > 0 {" evidence: internal/core/implement/loop/sync.go:245 — "r.final = "its merge of " + Remote + "'s default branch conflicts in " + named + ", which is not declared generated"" evidence: internal/core/implement/loop/sync_default_test.go:172 — "if f.remoteBranch(t, l.Branch) != "" {" evidence: internal/core/implement/loop/sync_default_test.go:181 — "if route.Kind != claim.HandBackStuck || !strings.Contains(route.Reason, "plain.txt") {"
Gap audit:
- honoured:
- a drain lane that keeps refusing (the same way three times, for sixty minutes at one stage, or in a way that cannot change) is handed back as stuck, says why, releases its reservation, and the drain moves past it evidence: internal/core/implement/loop/drain.go:781 — "if stuckReason(run, *bounds) != "" {" evidence: internal/core/implement/loop/stuck.go:252 — "hb := HandBack{Kind: HandBackStuck, Reason: why," evidence: internal/core/implement/loop/drain_stuck_test.go:81 — "if !strings.Contains(r.Wrote, "released the reservation on "+secondIssue) {"
- a lane whose issue turned out to be fixed already, finished at its base, is handed back at once evidence: internal/core/implement/loop/issuebrief.go:63 — "r.final = id + " is finished at the lane's base"" evidence: internal/core/implement/loop/drain_stuck_test.go:326 — "!strings.Contains(r.Reason, "cannot change")"
- a stuck lane is discarded through the step verb, with no run directory removed by hand evidence: internal/core/implement/loop/hold.go:130 — "if l := st.Lanes[i]; flag == "--discard" && stuckLane(l) {" evidence: internal/surface/cli/build.go:1035 — "res, err = loop.Discard(root, id, discard, loop.Options{})"
- lanes waiting on a merge are read from their pull requests in one listing each move: a closed one is handed back and released, a conflicting one is no longer renewed and is named, an open one is renewed and named as waiting evidence: internal/core/implement/loop/drain_finish.go:199 — "listed, unread := listParked(repoRoot)" evidence: internal/core/implement/loop/drain_finish.go:154 — "case pr.State == "CLOSED":" evidence: internal/core/implement/loop/drain_finish.go:158 — "case pr.Mergeable == MergeableConflicting:" evidence: internal/core/implement/loop/drain_parked_test.go:111 — "if got := f.parkedListings(t) - before; got != 1 {"
- lanes merge the default branch before they land, and a conflict on declared generated files goes to an implementer to regenerate rather than to a person, including when a waiting pull request starts to conflict
evidence: internal/core/implement/loop/sync.go:111 — "if tracking, err = fetchDefault(c, *lane, def); err != nil {"
evidence: internal/core/implement/loop/sync_default_test.go:106 — "len(parents) != 3 || parents[1] != judged || parents[2] != tip"
evidence: internal/core/implement/loop/sync.go:403 — "Every one of them is a file the repository declares generated (
linguist-generatedin its" evidence: internal/core/implement/loop/drain_finish.go:274 — "if p.State == ParkedConflicting && syncable(repoRoot, tracking, lane.Landing.Pushed) {"
- diverged:
- a pull request that left the merge queue stops holding its issue: gh pr list names no merge-queue entry and the arming stands for it, so an open, unconflicting pull request dropped from the queue is read as waiting and kept renewed evidence: .abcd/development/intents/shipped/itd-2610161016391855-a-drain-lane-that-cannot-finish-no.md:21 — "so one that closed, left the merge queue or started to conflict stops holding its issue and is named" evidence: internal/core/implement/loop/drain_finish.go:93 — "merge-queue entry (ghcontract pins its list), so the arming" evidence: internal/core/implement/loop/drain_finish.go:163 — "n+" is open, waiting on a person's merge (merge state "+status+"), so its lane is kept held""
- a pull request that started to conflict stops holding its issue: its reservation is only no longer renewed, so it stays live, and counts against the cap, until its current lease lapses, which the mechanism names as the failure (a conflicting pull request keeping its issue reserved past the next move) evidence: .abcd/development/intents/shipped/itd-2610161016391855-a-drain-lane-that-cannot-finish-no.md:33 — "a closed or conflicting pull request keeps its issue reserved past the next move" evidence: internal/core/implement/loop/drain_finish.go:159 — "so its lane is no longer renewed: a person merges the default branch into it, or closes it" evidence: internal/core/implement/loop/drain_parked_test.go:209 — "t.Fatalf("a conflicting lane is not handed back by the drain: %+v %v", st.Lanes[0], err)"
- generated files are regenerated rather than conflicting on them: abcd declares only the CLI reference and the surface manifest, not the brief's surfaces page the spec said its declaration would name and the intent counts in 18 merges, so that page still conflicts and its lane hands back evidence: .abcd/development/specs/closed/spc-2610161044461448-drain-many-accounts.md:63 — "abcd's own declaration names the generated CLI reference, the surface manifest and the brief's surfaces page." evidence: .gitattributes:56 — "The brief's surface chapters are not named: each mixes hand-written prose with" evidence: .abcd/development/intents/shipped/itd-2610161016391855-a-drain-lane-that-cannot-finish-no.md:25 — "54 touched the generated CLI reference, 29 the surface manifest and 18 the brief's surfaces page"
- the conflict on a generated file goes back to the implementer as a fix round: it goes to a fresh implementer as a sync round that counts no fix round evidence: internal/core/implement/loop/sync.go:14 — "A sync does not count against" evidence: .abcd/work/decisions/dcn-2610170755303451-a-lane-s-merge-of-the-default-branch-is.md:6 — "like every sync counts no fix round"
- the drain moves on to the next issue in the same move: it reserves the next issue and asks its judgement there, and that issue's lane opens only once the judgement answers no, in the same move only through a routed judge evidence: internal/core/implement/loop/drain.go:1029 — "if taken == nil {" evidence: internal/core/implement/loop/drain_stuck_test.go:93 — "t.Fatalf("the same move takes up the next eligible issue: %+v", res.Judging)"
- a step that keeps refusing is handed back after a bound: the sixty minutes count only refusals no more than fifteen minutes apart, and a refusal naming no lane, such as the run's config tripwire raised before the state is read, is never counted evidence: internal/core/implement/loop/stuck.go:59 — "const stuckGap = drainWaitMinutes * time.Minute" evidence: internal/core/implement/loop/stuck.go:133 — "if !ok || st.Issue() == "" || r.Lane == "" || waitByDesign(r) {" evidence: internal/core/implement/loop/loop.go:719 — "if err := configTripwire(repoRoot, runID); err != nil {"
- lanes waiting on a merge are read from their pull requests each move: only the person's latest 100 pull requests are listed, and a lane the listing does not show, or every lane when the listing fails, keeps its reservation renewed as before evidence: internal/core/implement/loop/drain_finish.go:90 — "const parkedListLimit = 100" evidence: internal/core/implement/loop/drain_parked_test.go:282 — "t.Fatalf("the lane's reservation is renewed: fenced %+v, now %+v", fenced, tip)" evidence: .abcd/work/decisions/dcn-2610170728074867-a-drain-move-reads-its-parked-lanes-from.md:6 — "a pull request the listing does not show, or a listing that fails, keeps its lane renewed as before"
- missing:
- a bound on the pre-push merge of the default branch: an issue lane re-merges and re-validates whenever the default branch moved, with no limit, so a slow lane may never reach its push (iss-2610170921271252, still open) evidence: .abcd/work/issues/open/iss-2610170921271252-before-its-push-an-issue-lane-re-fetches.md:15 — "can run round after round of validators and never reach its push" evidence: .abcd/work/issues/open/iss-2610170921271252-before-its-push-an-issue-lane-re-fetches.md:12 — "Merge the default branch into a lane at most once per lane before its push, or at most once per N minutes"
- a test carrying a generated-file conflict through the implementer's regeneration to a pushed, mergeable pull request: the tests stop at the sync brief, and re-landing is pinned only for a conflict-free resync evidence: internal/core/implement/loop/sync_default_test.go:140 — "t.Fatalf("the conflict goes to an implementer: %+v", l)" evidence: internal/core/implement/loop/sync_default_test.go:241 — "t.Run("and lands again once a fresh round judges the merge", func(t *testing.T) {"
- evidence from drains on several accounts: the three-account lab rerun on the built binary, which the spec's proof requires before close, is not recorded in the repository, and the close commit moves only records evidence: .abcd/development/specs/closed/spc-2610161044461448-drain-many-accounts.md:75 — "Before this spec closes, the three-account lab of 2026-10-16 is run again on the built binary, every account on the same build." evidence: 80764362b — "4 files changed, 12 insertions(+), 6 deletions(-)"
Scope-condition dispositions:
- cond-2610161044463127 — survived: a refused step is logged on its lane only on the implement-step path, inside the run's state write (loop.go:766-771, noteRefusal at stuck.go:131-178), and the drain judges a lane only from that run state (drain.go:768-786); nothing reads a lane worked by hand, and only an issue run logs, which every drain lane is (stuck.go:133) evidence: internal/core/implement/loop/loop.go:769 — "if noteRefusal(st, err, now) {" evidence: internal/core/implement/loop/stuck.go:133 — "if !ok || st.Issue() == "" || r.Lane == "" || waitByDesign(r) {" evidence: internal/core/implement/loop/drain.go:768 — "run, err := ReadState(repoRoot, l.RunID)"
- cond-2610161044469748 — narrowed: a move that finds a parked lane makes one listing (drain_finish.go:196-203), names one it cannot read, and the next move lists afresh; but the listing is one page of the person's latest 100 pull requests, and a lane the listing fails on or does not show is kept renewed at half its lease as before (dcn-2610170728074867, pinned by TestAParkedLaneTheForgeDoesNotAnswerForIsKeptHeld) rather than renewing nothing narrowing: holds for parked lanes whose pull requests are among the person's latest 100 that one gh pr list --state all --author @me --limit 100 returns; a lane outside that page, or every parked lane when the listing fails or does not parse, is read as unread, named as kept held, and still has its reservation renewed at half its lease on every move until a listing shows it evidence: internal/core/implement/loop/drain_finish.go:90 — "const parkedListLimit = 100" evidence: internal/core/implement/loop/drain_finish.go:144 — "could not be read from the forge ("+unread+"), so its lane is kept held" evidence: internal/core/implement/loop/drain_finish.go:170 — "func (p DrainParked) held() bool { return p.State == ParkedWaiting || p.State == ParkedUnread }" evidence: internal/core/implement/loop/drain_parked_test.go:282 — "t.Fatalf("the lane's reservation is renewed: fenced %+v, now %+v", fenced, tip)" evidence: .abcd/work/decisions/dcn-2610170728074867-a-drain-move-reads-its-parked-lanes-from.md:6 — "a pull request the listing does not show, or a listing that fails, keeps its lane renewed as before"
- cond-2610161044462911 — survived: claim.GeneratedFiles reads only the root .gitattributes on origin's default branch as last fetched (generated.go:13-21, 58-59), and each sync and resync fetches that branch first (sync.go:111, land.go:751); abcd declares the CLI reference and the surface manifest and leaves the brief's surface chapters undeclared, and an undeclared conflict at the lane's sync is a final refusal the drain hands back at once with nothing pushed (sync.go:197-198, TestAConflictOnAnUndeclaredFileHandsBackAndPushesNothing); a waiting pull request with such a conflict is named and no longer renewed, as ac-6 has it evidence: internal/core/claim/generated.go:17 — "shares: the root .gitattributes on origin's default branch, as this checkout" evidence: internal/core/claim/generated.go:59 — "return generatedFilesAt(root, originDefault(root))" evidence: .gitattributes:56 — "The brief's surface chapters are not named: each mixes hand-written prose with" evidence: internal/core/implement/loop/sync_default_test.go:181 — "if route.Kind != claim.HandBackStuck || !strings.Contains(route.Reason, "plain.txt") {"
- cond-2610161044466579 — survived: a conflict confined to declared generated files goes to a fresh implementer whose brief says to regenerate them with the repository's own generator, and abcd itself runs no command the repository declares (sync.go:22-25, 191-207, 402-406), pinned by TestAConflictOnADeclaredGeneratedFileGoesToTheImplementer; any other conflict is a final refusal handed back at once at the sync, and at the arming or the merge wait is refused for a person by conflictRefusal, as a conflicting pull request is today (sync.go:236-247, 267-271); the regeneration itself is the agent's and no test exercises it
evidence: internal/core/implement/loop/sync.go:25 — "permissions: abcd itself runs no command the repository declares."
evidence: internal/core/implement/loop/sync.go:403 — "Every one of them is a file the repository declares generated (
linguist-generatedin its" evidence: internal/core/implement/loop/sync.go:270 — "return Outcome{}, conflictRefusal(c, *lane, def, tracking, m)" evidence: internal/core/implement/loop/sync_default_test.go:140 — "t.Fatalf("the conflict goes to an implementer: %+v", l)"
Decisions
- 2026-10-16, facilitator-on-delegation (dcn-2610161040352440): the three drafts are one bundle, drain-many-accounts; this one is major.
- 2026-10-16, facilitator-on-delegation (dcn-2610161040382067): three identical refusals, or sixty minutes refusing at one stage, hand a lane back as stuck; waits by design never count; both bounds configurable; a refusal that cannot change hands back at once; --discard accepts a stuck lane.
- 2026-10-16, facilitator-on-delegation (dcn-2610161040380948): parked lanes are read in one forge listing per move; closed releases and hands back, conflicting stops renewing and is named.
- 2026-10-16, facilitator-on-delegation (dcn-2610161040384068): the lane's sync step merges origin's default branch whenever it has moved, and runs again when a waiting pull request starts to conflict; a conflict on declared generated files goes back to the implementer as a fix round, so abcd runs no repository-declared command; any other conflict hands back.
- 2026-10-16, facilitator-on-delegation (dcn-2610161044187673): the pre-pass's questions are answered. Brief invariant 7 is amended for each new drain network act in the step that adds it, invariant 19 gains the clause on issues naming an intent, a published hand-back is a judgement kept on the reservation and not a classification written onto the issue, and each sibling overlap is kept both.
Grounds
- pursued: facilitator-on-delegation, 2026-10-16: an unattended drain on several accounts reaches a lane that cannot finish within hours and then opens nothing; we expect a bounded hand-back, pull requests read from the forge, and lanes synced with the default branch to keep each drain moving. Shown wrong if a drain stays on one lane past the stuck bound plus one move, or a drain lane's pull request conflicts on a generated file alone.
Frontmatter
| id | itd-2610161016391855 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | bundle-member |
| severity | major |
.abcd/development/intents/shipped/itd-2610161016391855-a-drain-lane-that-cannot-finish-no.md
open on GitHub ↗ · commit history ↗
Referenced by6
- iss-2610161015368630A lane that refuses at a step without handing back (a conflicting pull request at arming, the land fence, or a brief refused because the issue is already resolved at the base) is read by laneOutcome as in progress, so every later drain move answers "drive" that lane and the drain opens nothing more; implement step --discard and --release act only on held lanes, so only removing the run directory by hand frees it.related
- iss-2610161015381750Lanes parked at land are renewed on every drain move from local state alone, whatever their pull request's state, and a lane whose head is not on main is skipped silently, so a pull request closed, dropped from the merge queue or conflicting after arming keeps its issue reserved and counted against the cap indefinitely and next never names it.related
- iss-2610161015410527Drain lanes never merge the default branch before their pull request, and the footprint taken from found_at never names generated files, so lanes from several accounts conflict on generated files: of 259 merges to main since 2026-10-01, 54 touched docs/reference/cli/commands.md, 29 surface.json and 18 the brief's surfaces README.related
- iss-2610262037525523The stuck-lane intent sends a conflict on generated files back to the implementer as a fix round; the build chapter says a sync counts no fix round.related
- itd-2610161016401115Drains run unattended and visiblybuilt on by
- spc-2610161044461448drain-many-accountsimplemented by
mentions14
- dcn-26101610403524402026-10-16 — The three drain drafts are one bundle (facilitator-on-delegation, the product thinker having delegated the planning of the drain-many-accounts bundle on 2026-10-16): itd-2610161016394967, itd-2610161016391855 and itd-2610161016401115 are planned as one bundle, drain-many-accounts, with severity critical, major and minor; itd-2610161016401115 builds on the other two, which are independent of each other; the decomposition files each draft whole as an intent and routes two parts to clauses in brief invariants 7 and 19, and none to a new ADR, principle or brief page (grounds: the three are user moments of one run on several accounts, which the product thinker asked to have delivered together, and every part is drain behaviour inside records that already exist).
- dcn-26101610403809482026-10-16 — Parked lanes are read from their pull requests (facilitator-on-delegation, drain-many-accounts): each drain move reads every parked lane's pull request in one forge listing; a closed pull request releases its lane's reservation and hands the lane back, a conflicting one stops being renewed and is named in next, and a parked lane whose head is not yet on the default branch is named as waiting rather than skipped (grounds: the pull request's state is the evidence of the lane's progress, and one listing costs one request a move; iss-2610161015381750).
- dcn-26101610403820672026-10-16 — A stuck drain lane hands itself back (facilitator-on-delegation, drain-many-accounts): a drain lane whose step has refused identically three times, or has refused at one stage for sixty minutes, is handed back as stuck and its reservation released; refusals that are waits by design, the preflight-receipt wait and an outage wait, never count; both bounds are configurable in the pace's layers; a refusal that cannot change (the issue finished at the base or on the default branch, or the hold now another's) hands back at once; and implement step --discard accepts a lane handed back as stuck (grounds: dead-letter redrive after a maximum receive count, as SQS maxReceiveCount; iss-2610161015368630).
- dcn-26101610403840682026-10-16 — A drain lane merges the default branch first (facilitator-on-delegation, drain-many-accounts): the lane's sync step, which already merges landed sibling lanes and has the merge head validated, also merges origin's default branch whenever it has moved, before the records and the push, and is entered again from arming and from the merge wait when the open pull request starts to conflict; a conflict confined to declared generated files goes back to the implementer as a fix round under the host's permissions, with the validators judging the result, so abcd itself runs no command the repository declares; any other conflict hands the lane back naming the files (grounds: the forge ignores the merge drivers a local merge uses, iss-2609240646538011; iss-2610161015410527).
- dcn-26101610441876732026-10-16 — The drain bundle's pre-pass answers (facilitator-on-delegation, drain-many-accounts): brief invariant 7's sentences naming what drain and implement step fetch and push are amended, in each building step that adds an act, to name the per-move fetch, the reservation before a judgement, the hand-back tombstone, the renewal of the lane in progress, the parked lanes' pull-request listing, the fork question and the sync's fetch of the default branch; brief invariant 19 gains a clause that an issue whose related_intents names an intent is not taken; a hand-back published on origin is the record of a judgement kept on the reservation, not a field classification written onto the issue, so invariant 19's re-derivation stands; and each member keeps both against its siblings itd-82, itd-2609091034175565 and itd-2609151838312703, building on the first two (grounds: the invariants are the record of what may touch the network and what a machine may take, so they move with the surface, as the principle spec-moves-with-the-surface requires).
- dcn-26101701185062732026-10-17 — A drain lane's sixty-minute stuck bound counts only the time across which its refusals kept coming (facilitator-on-delegation, drain-many-accounts; refines dcn-2610170005357588): the minutes run from the first refusal at the stage to the last one logged, never to the move that reads them, so a lane that refused once and then sat idle is not handed back by a move that came only to renew it; a refusal more than fifteen minutes after the lane's last, the drain's look-again time, starts the time afresh at that stage, as a quiet that long is not refusing; the count of identical refusals in a row is unchanged (grounds: the dead-letter bound counts receives, not wall time, so time without a receive is not evidence the lane cannot finish; the drain's fifteen-minute look-again as the cadence a host stepping as told keeps; review of spc-2610161044461448 step 5)
- dcn-26101707280748672026-10-17 — A drain move reads its parked lanes from one listing of the person's own latest pull requests (facilitator-on-delegation, drain-many-accounts step 8): gh pr list --state all --author @me --limit 100, one page of the forge's API, matched to each parked lane by number and head branch; gh's pr list names no merge-queue field (ghcontract pins gh 2.100.0's list), so the arming (autoMergeRequest) stands for the queue entry; a pull request the listing does not show, or a listing that fails, keeps its lane renewed as before, so only an answer the forge gave changes a lane; one merged by a squash or rebase, its head not on the default branch, is named and no longer renewed; a lane handed back as closed keeps its worktree and branch for implement step --discard, which then closes nothing (grounds: one request a move, as dcn-2610161040380948 sets; fail-safe reading, where absence is never evidence of closure; iss-2610161015381750)
- dcn-26101707553034512026-10-17 — A lane's merge of the default branch is an issue run's, and re-enters its sync only over generated-file conflicts (facilitator-on-delegation, drain-many-accounts step 13): only an issue run's sync (a drain's lane, or a person's build of an issue) fetches origin's default branch and merges it whenever it moved, since an intent's run already syncs its own landed siblings and its other conflicts stay the sibling brief's; the generated-file conflict goes to the sync's fresh implementer, whose brief says to regenerate, and like every sync counts no fix round, a regeneration being no failed attempt; any other conflict is a final refusal naming the files, so the drain's stuck rule hands the lane back at once with nothing pushed; a pull request the forge reports conflicting at the arming or the merge wait re-enters the sync when a driver-free merge of the default branch and the pushed head, as the forge merges, conflicts in declared generated files alone or in none, withdrawing an armed merge first and keeping the records commit and the pull request, the push and arming made again after a fresh round; the drain's move takes that step itself for a parked lane, reads it as syncing and drives the lane again; the generated set is claim.GeneratedFiles, the matcher step 11 exports, with no second reader (grounds: GitHub's update branch and merge queues test a pull request merged with its base; the forge ignores local merge drivers, iss-2609240646538011; a stale forge verdict with nothing to merge stays a wait; iss-2610161015410527)
- iss-2609240646538011The `merge=union` driver that .gitattributes gives `.abcd/work/DECISIONS.md` and `CHANGELOG.md` (the remedy iss-118 adopted) holds for a local merge only; the forge does not apply it. A pull request's mergeability and the merge queue's merge are computed on the forge, so two open pull requests that each append a DECISIONS.md entry conflict there as soon as one merges, while a local `git merge` of the same two is clean. In autonomous run A every records pull request went DIRTY whenever another records pull request merged first (#678, #679 and #681 on 2026-09-23), and each was recovered by a local merge in the lane's worktree, a push to the same branch behind a ten-to-twenty-minute pre-push preflight, and a re-armed auto-merge. Nothing tells an author that the union driver stops at the local clone. Wanted: the .gitattributes comment and the conventions that cite the driver say it holds locally only, and the remedy that needs no driver (one file per decision, as iss-2609100507439414 and iss-2608220150157511 propose) is weighed with this cost counted, since it removes the conflict on the forge as well.
- iss-2610170921271252Before its push, an issue lane re-fetches origin's default branch every time it arrives at its landing with no landing state, and whenever that branch has moved it merges it and sends the lane through a fresh validation round, with no limit. With the default branch moving every 30 to 60 minutes, a slow lane, whose validators take longer than that, can run round after round of validators and never reach its push.
- iss-2610252224311175No test carries a generated-file conflict through the implementer's regeneration to a pushed, mergeable pull request: the sync tests stop at the sync brief (internal/core/implement/loop/sync_default_test.go:140), and re-landing is pinned only for a conflict-free resync (:241). This is the half of itd-2610161016391855 its fidelity review found unproven.
- iss-2610252224317335spc-2610161044461448 required the 2026-10-16 three-account lab to be rerun on the built binary, every account on one build, before it closed. No rerun is recorded in the close commit (80764362b), the history or the lab store, so three shipped intents rest on per-step tests with two or three clones of one local bare origin: itd-2610161016391855 (evidence from drains on several accounts), itd-2610161016394967 (drains share the work, end to end) and itd-2610161016401115 (a drain runs unattended for a day).
- itd-82`abcd drain` fixes the issues that need no decision, and hands the rest back by kind
- itd-2610161016394967Drains on several accounts share the work