Skip to content

Commit 17ad1fb

Browse files
fix(loop): work queued behind a human was reported as a failed run
The other half of the idle deadlock, found by the end-to-end sweep that was meant to prove the first half fixed. Only `done` satisfies a dependency, so a task sitting in ready_to_dev whose dependency was parked in to_scope by the attempt ceiling can never become executable. AgentWorkRemaining counts ready_to_dev, so it short-circuited the human-backlog branch directly below it: the loop found nothing schedulable, found nothing to reclaim, and then spent 31 idle rounds before returning ErrIdleTimeout — reporting a board that was simply owed to a human as a run that gave up. Measured on a live greenfield Go build: T1 and T3 parked by the ceiling, T4 and T5 queued behind them, and the verdict read "gave up before the board was finished: idle while agent work still in progress (rounds=31, tasks still open=2)". The human-backlog path already existed one branch below and already returns cleanly with an announcement rather than a failure; it was simply unreachable. Agent work waiting behind a human is not agent work in progress, so the idle branch now defers to it. Reclaim still runs first — an orphan left mid-flight by a finished wave is a different condition and is still re-queued.
1 parent 5b2f43d commit 17ad1fb

2 files changed

Lines changed: 62 additions & 10 deletions

File tree

‎pkg/loop/reclaim_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,44 @@ func TestReclaimIsNilSafe(t *testing.T) {
107107
t.Fatalf("reclaimed %d from a nil board", moved)
108108
}
109109
}
110+
111+
// Agent work waiting BEHIND A HUMAN is not agent work in progress.
112+
//
113+
// Only `done` satisfies a dependency, so a task in ready_to_dev whose
114+
// dependency is parked in to_scope can never become executable. But
115+
// AgentWorkRemaining counts ready_to_dev, so it short-circuited the
116+
// human-backlog branch and the loop idled 31 rounds before reporting a
117+
// hand-back as ErrIdleTimeout — a failure.
118+
//
119+
// Measured on a live greenfield build: T1 and T3 parked by the attempt ceiling,
120+
// T4 and T5 queued behind them, and the run reported
121+
// "gave up ... idle while agent work still in progress" over a board that was
122+
// simply owed to a human.
123+
func TestWorkQueuedBehindAHumanIsNotAgentProgress(t *testing.T) {
124+
b := &plan.Board{Tasks: []plan.Task{
125+
{ID: "T1", Role: plan.RoleWorker, Column: plan.ColToScope, Title: "parked by the ceiling"},
126+
{ID: "T3", Role: plan.RoleWorker, Column: plan.ColToScope, Title: "also parked"},
127+
{ID: "T4", Role: plan.RoleWorker, Column: plan.ColReadyToDev, DependsOn: []string{"T3"}},
128+
{ID: "T5", Role: plan.RoleTester, Column: plan.ColReadyToDev, DependsOn: []string{"T1", "T3", "T4"}},
129+
}}
130+
for i := range b.Tasks {
131+
b.Tasks[i].Normalize()
132+
}
133+
134+
// The shape: nothing executable, yet the board claims work remains.
135+
if len(b.ReadyTasks()) != 0 {
136+
t.Fatal("fixture is wrong: something is executable")
137+
}
138+
if !b.AgentWorkRemaining() {
139+
t.Fatal("fixture is wrong: ready_to_dev must make this look like agent work")
140+
}
141+
// Reclaim cannot help — nothing is mid-flight.
142+
if moved := (&Runner{}).reclaimOrphaned(b); moved != 0 {
143+
t.Fatalf("reclaimed %d, want 0 — nothing is in_progress/in_review", moved)
144+
}
145+
// So the honest reading is the human backlog, which the loop now checks
146+
// before spending 31 idle rounds on a failure verdict.
147+
if !b.HumanBacklogRemaining() {
148+
t.Fatal("the parked tasks must register as human backlog")
149+
}
150+
}

‎pkg/loop/runner.go‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -401,16 +401,27 @@ func (r *Runner) RunBoard(ctx context.Context, board *plan.Board) error {
401401

402402
ready := scheduleReady(board.ReadyTasks())
403403
if len(ready) == 0 {
404-
if board.AgentWorkRemaining() {
405-
// Nothing is executable, yet the board says work is in
406-
// progress. runWave is synchronous, so nothing actually is:
407-
// whatever sits in in_progress/in_review was abandoned by a
408-
// wave that already returned, and no code path re-dispatches
409-
// those columns. Re-queue them and schedule again rather than
410-
// idling 31 rounds waiting on an agent that exited.
411-
if r.reclaimOrphaned(board) > 0 {
412-
continue
413-
}
404+
// Nothing is executable, yet the board says work is in progress.
405+
// runWave is synchronous, so nothing actually is: whatever sits in
406+
// in_progress/in_review was abandoned by a wave that already
407+
// returned, and no code path re-dispatches those columns. Re-queue
408+
// them and schedule again rather than idling 31 rounds waiting on
409+
// an agent that exited.
410+
if board.AgentWorkRemaining() && r.reclaimOrphaned(board) > 0 {
411+
continue
412+
}
413+
// Agent work that is waiting BEHIND A HUMAN is not agent work in
414+
// progress. A task in ready_to_dev whose dependency is parked in
415+
// to_scope can never become executable — only `done` satisfies a
416+
// dependency — but AgentWorkRemaining counts ready_to_dev, so it
417+
// short-circuited the human-backlog branch below and the loop idled
418+
// 31 rounds before reporting a hand-back as a FAILURE.
419+
//
420+
// Measured: T1 and T3 parked by the attempt ceiling, T4 and T5
421+
// queued behind them, the run reported as "gave up ... idle while
422+
// agent work still in progress" with the board simply owed to a
423+
// human.
424+
if board.AgentWorkRemaining() && !board.HumanBacklogRemaining() {
414425
idleRounds++
415426
if idleRounds > 30 {
416427
return r.giveUp(board, ErrIdleTimeout, idleRounds)

0 commit comments

Comments
 (0)