detached 69d07a5

fix(subagents): prevent completion delivery deadlock (#210)

commit
69d07a579e10d3ea8f148b9cae7f380422db173a
author
bnema · · 2026-09-16 17:12 UTC
committer
GitHub · · 2026-09-16 17:12 UTC
parent
969e52a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +504 -10
M packages/agent/modes/interactive.go +6 -0
@@ -871,6 +871,12 @@ type Interactive struct {
871871 completionDeliveryRunning bool
872872 completionDeliveryRequest bool
873873 completionDeliveryHolds int
874+ // completionDeliveryGeneration identifies the current hold wave. It is
875+ // bumped whenever a new wave starts (a first hold, an implicit one-worker
876+ // wave, or a coordinator replacement after cancellation), so a release can
877+ // tell whether a newer wave replaced the one it drained while it sampled
878+ // goal state.
879+ completionDeliveryGeneration uint64
874880
875881 // pendingFork is true when the user ran /session fork: the next
876882 // jump-picker selection should branch off that message instead
@@ -871,6 +871,12 @@ type Interactive struct {
871 completionDeliveryRunning bool871 completionDeliveryRunning bool
872 completionDeliveryRequest bool872 completionDeliveryRequest bool
873 completionDeliveryHolds int873 completionDeliveryHolds int
874+ // completionDeliveryGeneration identifies the current hold wave. It is
875+ // bumped whenever a new wave starts (a first hold, an implicit one-worker
876+ // wave, or a coordinator replacement after cancellation), so a release can
877+ // tell whether a newer wave replaced the one it drained while it sampled
878+ // goal state.
879+ completionDeliveryGeneration uint64
874 880
875 // pendingFork is true when the user ran /session fork: the next881 // pendingFork is true when the user ran /session fork: the next
876 // jump-picker selection should branch off that message instead882 // jump-picker selection should branch off that message instead
M packages/agent/modes/interactive_resident_completion_test.go +436 -0
@@ -4,9 +4,12 @@ import (
44 "context"
55 "errors"
66 "strings"
7+ "sync"
8+ "sync/atomic"
79 "testing"
810 "time"
911
12+ "github.com/bnema/zut/packages/agent/internal/orchestration"
1013 "github.com/bnema/zut/packages/agent/subagents"
1114 "github.com/bnema/zut/packages/core"
1215 )
@@ -46,6 +49,439 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing
4649 }
4750 }
4851
52+// TestReleaseCompletionHoldDoesNotInvertCompletionLock pins the lock order
53+// between the completion delivery hold and the coordinator state read. The
54+// release path used to hold completionDeliveryMu while it called
55+// goalContinuationMessage and interruptedPromptPending, which acquire i.mu,
56+// while coordinatorHasPendingWorkers is legitimately called by a caller that
57+// already holds i.mu. The two orders deadlocked.
58+//
59+// goalSampleGate makes the probe deterministic: the test waits until the
60+// release is provably inside goal sampling before it reads coordinator state.
61+// Under the inverted order that sample still owns completionDeliveryMu, so the
62+// probe queues behind it and the bounded wait fails the test instead of
63+// hanging the package.
64+func TestReleaseCompletionHoldDoesNotInvertCompletionLock(t *testing.T) {
65+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
66+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
67+ defer gate.unblockNow()
68+
69+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
70+ i.cfg.CurrentGoal = gate.current
71+ // A manager wake is not the subject of this test; stop it before it starts
72+ // a turn.
73+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error { return errors.New("stop after wake") }
74+ i.completionDeliveryHolds = 1
75+
76+ released := startGateRelease(i.releaseCompletionDeliveryHold)
77+ enteredGate(t, gate, "releaseCompletionDeliveryHold")
78+
79+ // A caller holding i.mu that reads coordinator state takes
80+ // completionDeliveryMu: that is the i.mu -> completionDeliveryMu order this
81+ // test protects. Probing from that side must not queue behind the release.
82+ i.mu.Lock()
83+ pending := make(chan bool, 1)
84+ go func() {
85+ pending <- i.coordinatorHasPendingWorkers()
86+ }()
87+
88+ select {
89+ case <-pending:
90+ case <-time.After(completionGateTimeout):
91+ // Break the cycle before failing so the release can sample and relock.
92+ i.mu.Unlock()
93+ gate.unblockNow()
94+ awaitGate(t, released, "releaseCompletionDeliveryHold")
95+ t.Fatal("coordinatorHasPendingWorkers blocked behind releaseCompletionDeliveryHold")
96+ }
97+ i.mu.Unlock()
98+ gate.unblockNow()
99+ awaitGate(t, released, "releaseCompletionDeliveryHold")
100+
101+ i.completionDeliveryMu.Lock()
102+ holds := i.completionDeliveryHolds
103+ i.completionDeliveryMu.Unlock()
104+ if holds != 0 {
105+ t.Fatalf("completionDeliveryHolds = %d, want 0", holds)
106+ }
107+}
108+
109+// TestNonSealingReleaseCompletionHoldSkipsGoalSampling pins that only the
110+// release which drains the last hold reads goal and interruption state. A
111+// nested release (2 -> 1) or a duplicate release (already 0) must return
112+// without acquiring i.mu, which those reads take.
113+func TestNonSealingReleaseCompletionHoldSkipsGoalSampling(t *testing.T) {
114+ for _, tc := range []struct {
115+ name string
116+ holds int
117+ wantHolds int
118+ }{
119+ {name: "nested", holds: 2, wantHolds: 1},
120+ {name: "duplicate", holds: 0, wantHolds: 0},
121+ } {
122+ t.Run(tc.name, func(t *testing.T) {
123+ i := &Interactive{agent: core.NewAgent(nil, "model", "", nil)}
124+ i.completionDeliveryHolds = tc.holds
125+
126+ i.mu.Lock()
127+ done := startGateRelease(i.releaseCompletionDeliveryHold)
128+ select {
129+ case <-done:
130+ case <-time.After(completionGateTimeout):
131+ // Break the cycle before failing so the release goroutine exits.
132+ i.mu.Unlock()
133+ awaitGate(t, done, "non-sealing release")
134+ t.Fatal("non-sealing release sampled goal state while i.mu was held")
135+ }
136+ i.mu.Unlock()
137+
138+ i.completionDeliveryMu.Lock()
139+ holds := i.completionDeliveryHolds
140+ i.completionDeliveryMu.Unlock()
141+ if holds != tc.wantHolds {
142+ t.Fatalf("completionDeliveryHolds = %d, want %d", holds, tc.wantHolds)
143+ }
144+ })
145+ }
146+}
147+
148+// TestStaleReleaseCompletionHoldDoesNotSealNewerWave covers a duplicate release
149+// that arrives after the earlier wave already drained, while a newer manager
150+// wave is active. The stale call carries no hold and must not seal that wave:
151+// pre-fix it re-applied GoalChanged and ManagerFinished, which left the newer
152+// wave looking finished.
153+func TestStaleReleaseCompletionHoldDoesNotSealNewerWave(t *testing.T) {
154+ i := &Interactive{agent: core.NewAgent(nil, "model", "", nil)}
155+ // A newer manager wave is already active with no completion hold
156+ // outstanding, as happens when completion delivery wakes the manager after
157+ // the holds drained.
158+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
159+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "w1", AgentID: "agent-1"})
160+ if i.coordinatorAcceptsUserInput() {
161+ t.Fatal("setup: newer manager wave is already sealed")
162+ }
163+
164+ i.releaseCompletionDeliveryHold()
165+
166+ if i.coordinatorAcceptsUserInput() {
167+ t.Fatal("duplicate release sealed the newer manager wave")
168+ }
169+ if !i.coordinatorHasPendingWorkers() {
170+ t.Fatal("duplicate release lost the newer wave's pending worker")
171+ }
172+}
173+
174+// goalSampleGate pins the goal-sampling window of a completion delivery
175+// release without sleeps. The first CurrentGoal call signals entered and then
176+// waits for unblock, so a test can deterministically act while that release is
177+// between its hold transition and its relock.
178+type goalSampleGate struct {
179+ entered chan struct{}
180+ unblock chan struct{}
181+ // once lets tests close unblock more than once, including from a deferred
182+ // cleanup that runs after the test already closed it.
183+ once sync.Once
184+ calls atomic.Int64
185+ next func(call int64) *core.SessionGoal
186+}
187+
188+func newGoalSampleGate(next func(call int64) *core.SessionGoal) *goalSampleGate {
189+ return &goalSampleGate{
190+ entered: make(chan struct{}),
191+ unblock: make(chan struct{}),
192+ next: next,
193+ }
194+}
195+
196+// unblockNow releases the sampling window exactly once, so a failure cleanup
197+// can let a parked release finish without a double close.
198+func (g *goalSampleGate) unblockNow() {
199+ g.once.Do(func() { close(g.unblock) })
200+}
201+
202+// current fulfils InteractiveConfig.CurrentGoal.
203+func (g *goalSampleGate) current() *core.SessionGoal {
204+ call := g.calls.Add(1)
205+ if call == 1 {
206+ close(g.entered)
207+ <-g.unblock
208+ }
209+ return g.next(call)
210+}
211+
212+// completionGateTimeout bounds every wait in the completion-hold gate tests so
213+// an inverted lock order fails the test instead of hanging the package.
214+const completionGateTimeout = 5 * time.Second
215+
216+// startGateRelease runs a potentially blocking hold, release, or coordinator
217+// transition in its own goroutine and returns a channel closed when it
218+// returns, so a test can bound the wait instead of hanging.
219+func startGateRelease(release func()) <-chan struct{} {
220+ done := make(chan struct{})
221+ go func() {
222+ defer close(done)
223+ release()
224+ }()
225+ return done
226+}
227+
228+// awaitGate waits for done to close, failing the test after
229+// completionGateTimeout rather than blocking forever.
230+func awaitGate(t *testing.T, done <-chan struct{}, what string) {
231+ t.Helper()
232+ select {
233+ case <-done:
234+ case <-time.After(completionGateTimeout):
235+ t.Fatalf("%s did not finish within %s", what, completionGateTimeout)
236+ }
237+}
238+
239+// enteredGate waits until a parked release has provably reached goal sampling.
240+func enteredGate(t *testing.T, gate *goalSampleGate, what string) {
241+ t.Helper()
242+ select {
243+ case <-gate.entered:
244+ case <-time.After(completionGateTimeout):
245+ gate.unblockNow()
246+ t.Fatalf("%s never sampled goal state", what)
247+ }
248+}
249+
250+// TestCancelledCoordinatorIsNotSealedByStaleRelease covers cancellation
251+// invalidating an in-flight release. The release drained an older hold and is
252+// sampling goal state when the coordinator is replaced, so its stale
253+// GoalChanged/ManagerFinished snapshot must not seal or wake the replacement.
254+func TestCancelledCoordinatorIsNotSealedByStaleRelease(t *testing.T) {
255+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
256+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
257+ defer gate.unblockNow()
258+
259+ // A manager wake reaches goal sampling only through PersistGoalRuntime, so
260+ // recording that callback detects an unintended seal. Returning an error
261+ // stops the continuation before it touches the provider.
262+ woke := make(chan struct{}, 1)
263+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
264+ i.cfg.CurrentGoal = gate.current
265+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
266+ select {
267+ case woke <- struct{}{}:
268+ default:
269+ }
270+ return errors.New("stop after wake")
271+ }
272+
273+ // The snapshot belongs to a wave that still owns a worker, so sealing it
274+ // would wake the manager.
275+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
276+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "w1", AgentID: "agent-1"})
277+ if !i.coordinatorHasPendingWorkers() {
278+ t.Fatal("setup: outstanding wave has no pending worker")
279+ }
280+
281+ releaseOld := i.beginCompletionDeliveryHold()
282+ released := startGateRelease(releaseOld)
283+ enteredGate(t, gate, "older release")
284+
285+ awaitGate(t, startGateRelease(i.cancelCoordinator), "cancelCoordinator")
286+
287+ gate.unblockNow()
288+ awaitGate(t, released, "older release")
289+
290+ // The replacement coordinator never owned the older wave, so the stale
291+ // release must leave it compact and must not wake the manager.
292+ if i.coordinatorHasPendingWorkers() {
293+ t.Fatal("stale release resurrected the cancelled wave's worker")
294+ }
295+ if i.coordinatorAcceptsUserInput() {
296+ t.Fatal("stale release sealed the replacement coordinator")
297+ }
298+ select {
299+ case <-woke:
300+ t.Fatal("stale release woke the manager after cancellation")
301+ default:
302+ }
303+
304+ // Positive control: the replacement coordinator still seals and wakes for a
305+ // wave that starts after cancellation, so the check above is meaningful.
306+ awaitGate(t, startGateRelease(func() { i.beginCompletionDeliveryHold() }), "post-cancel hold")
307+ awaitGate(t, startGateRelease(i.releaseCompletionDeliveryHold), "post-cancel release")
308+ select {
309+ case <-woke:
310+ default:
311+ t.Fatal("post-cancel wave did not seal and wake the manager")
312+ }
313+}
314+
315+// TestImplicitCoordinatorWaveIsNotSealedByStaleRelease covers a worker
316+// registered outside a completion-delivery wave. That implicit wave replaces
317+// whatever wave an older release drained, so once the worker finishes and the
318+// wave is sealed with no pending work, the stale release must not fall through
319+// to a goal wake when it relocks.
320+func TestImplicitCoordinatorWaveIsNotSealedByStaleRelease(t *testing.T) {
321+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
322+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
323+ defer gate.unblockNow()
324+
325+ woke := make(chan struct{}, 1)
326+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
327+ i.cfg.CurrentGoal = gate.current
328+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
329+ select {
330+ case woke <- struct{}{}:
331+ default:
332+ }
333+ return errors.New("stop after wake")
334+ }
335+
336+ releaseOld := i.beginCompletionDeliveryHold()
337+ released := startGateRelease(releaseOld)
338+ enteredGate(t, gate, "older release")
339+
340+ // A worker registered with no hold outstanding opens the implicit
341+ // one-worker sealed wave.
342+ awaitGate(t, startGateRelease(func() { i.registerCoordinatorWorker("w1") }), "registerCoordinatorWorker")
343+ workerID := i.takeCoordinatorWorkerID("w1")
344+ if workerID == "" {
345+ t.Fatal("setup: implicit wave registered no worker")
346+ }
347+
348+ // The worker finishes inside the older release's sampling window, leaving
349+ // the implicit wave sealed with no pending work. Its own worker wake is not
350+ // under test, so only the coordinator state is advanced here.
351+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerFinished, WorkerID: workerID})
352+ if i.coordinatorHasPendingWorkers() {
353+ t.Fatal("setup: implicit wave still has pending work")
354+ }
355+
356+ gate.unblockNow()
357+ awaitGate(t, released, "older release")
358+
359+ select {
360+ case <-woke:
361+ t.Fatal("stale release sealed the newer implicit coordinator wave")
362+ default:
363+ }
364+}
365+
366+// TestReleaseCompletionHoldLeavesActiveNewerWaveUnsealed covers a newer hold
367+// that begins while an older release samples goal state and is still
368+// outstanding when that release relocks. The older release must leave the
369+// newer wave alone, and the newer release must still seal it.
370+func TestReleaseCompletionHoldLeavesActiveNewerWaveUnsealed(t *testing.T) {
371+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
372+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
373+ defer gate.unblockNow()
374+
375+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
376+ i.cfg.CurrentGoal = gate.current
377+ // A manager wake is not the subject of this test; stop it before it starts
378+ // a turn.
379+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error { return errors.New("stop after wake") }
380+
381+ releaseOld := i.beginCompletionDeliveryHold()
382+ i.registerCoordinatorWorker("w1")
383+
384+ released := startGateRelease(releaseOld)
385+ enteredGate(t, gate, "older release")
386+
387+ var releaseNew func()
388+ awaitGate(t, startGateRelease(func() { releaseNew = i.beginCompletionDeliveryHold() }), "newer hold")
389+ if i.coordinatorAcceptsUserInput() {
390+ t.Fatal("setup: newer hold wave is already sealed")
391+ }
392+ gate.unblockNow()
393+ awaitGate(t, released, "older release")
394+
395+ i.completionDeliveryMu.Lock()
396+ holds := i.completionDeliveryHolds
397+ i.completionDeliveryMu.Unlock()
398+ if holds != 1 {
399+ t.Fatalf("completionDeliveryHolds = %d, want 1 (newer hold still active)", holds)
400+ }
401+ if i.coordinatorAcceptsUserInput() {
402+ t.Fatal("older release sealed the newer wave it did not drain")
403+ }
404+
405+ awaitGate(t, startGateRelease(releaseNew), "newer release")
406+ if !i.coordinatorAcceptsUserInput() {
407+ t.Fatal("newer release did not seal its own wave")
408+ }
409+ if !i.coordinatorHasPendingWorkers() {
410+ t.Fatal("newer wave lost its pending worker")
411+ }
412+}
413+
414+// TestDrainedNewerWaveIsNotResealedByOlderRelease pins the wave generation
415+// guard. While an older release samples goal state, a newer wave both begins
416+// and fully drains, leaving the hold count at zero when the older release
417+// relocks. The count alone would let that older release re-apply its stale
418+// snapshot (including GoalChanged) over the wave that already sealed.
419+func TestDrainedNewerWaveIsNotResealedByOlderRelease(t *testing.T) {
420+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
421+ gate := newGoalSampleGate(func(call int64) *core.SessionGoal {
422+ if call == 2 {
423+ // The newer wave seals with no active goal, so its own seal must not
424+ // wake the manager.
425+ return nil
426+ }
427+ return goal
428+ })
429+ defer gate.unblockNow()
430+
431+ // A manager wake reaches goal sampling only through PersistGoalRuntime, so
432+ // recording that callback detects an unintended seal. Returning an error
433+ // stops the continuation before it touches the provider.
434+ woke := make(chan struct{}, 1)
435+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
436+ i.cfg.CurrentGoal = gate.current
437+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
438+ select {
439+ case woke <- struct{}{}:
440+ default:
441+ }
442+ return errors.New("stop after wake")
443+ }
444+
445+ releaseOld := i.beginCompletionDeliveryHold()
446+ released := startGateRelease(releaseOld)
447+ enteredGate(t, gate, "older release")
448+
449+ // A newer wave begins and fully drains inside the older release's sampling
450+ // window.
451+ awaitGate(t, startGateRelease(func() {
452+ releaseNew := i.beginCompletionDeliveryHold()
453+ releaseNew()
454+ }), "newer hold wave")
455+
456+ i.completionDeliveryMu.Lock()
457+ holds := i.completionDeliveryHolds
458+ i.completionDeliveryMu.Unlock()
459+ if holds != 0 {
460+ t.Fatalf("completionDeliveryHolds = %d, want 0 after the newer wave drained", holds)
461+ }
462+ gate.unblockNow()
463+ awaitGate(t, released, "older release")
464+
465+ // The older release relocked with zero holds but belongs to the previous
466+ // generation, so it must not seal, and must not wake the manager for, the
467+ // wave that the newer release already sealed.
468+ select {
469+ case <-woke:
470+ t.Fatal("stale release sealed a drained newer wave")
471+ default:
472+ }
473+
474+ // Positive control: the same coordinator state does wake the manager when
475+ // the goal really is active, so the check above is meaningful.
476+ i.executeCoordinatorActions(i.applyCoordinator(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: true}))
477+ i.executeCoordinatorActions(i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}))
478+ select {
479+ case <-woke:
480+ default:
481+ t.Fatal("goal-active manager wake no longer reaches goal sampling")
482+ }
483+}
484+
49485 func TestResidentCompletionSlidesIntoBusyParent(t *testing.T) {
50486 for _, tc := range []struct {
51487 name string
@@ -4,9 +4,12 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "strings"6 "strings"
7+ "sync"
8+ "sync/atomic"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
12+ "github.com/bnema/zut/packages/agent/internal/orchestration"
10 "github.com/bnema/zut/packages/agent/subagents"13 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/core"14 "github.com/bnema/zut/packages/core"
12 )15 )
@@ -46,6 +49,439 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing
46 }49 }
47 }50 }
48 51
52+// TestReleaseCompletionHoldDoesNotInvertCompletionLock pins the lock order
53+// between the completion delivery hold and the coordinator state read. The
54+// release path used to hold completionDeliveryMu while it called
55+// goalContinuationMessage and interruptedPromptPending, which acquire i.mu,
56+// while coordinatorHasPendingWorkers is legitimately called by a caller that
57+// already holds i.mu. The two orders deadlocked.
58+//
59+// goalSampleGate makes the probe deterministic: the test waits until the
60+// release is provably inside goal sampling before it reads coordinator state.
61+// Under the inverted order that sample still owns completionDeliveryMu, so the
62+// probe queues behind it and the bounded wait fails the test instead of
63+// hanging the package.
64+func TestReleaseCompletionHoldDoesNotInvertCompletionLock(t *testing.T) {
65+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
66+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
67+ defer gate.unblockNow()
68+
69+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
70+ i.cfg.CurrentGoal = gate.current
71+ // A manager wake is not the subject of this test; stop it before it starts
72+ // a turn.
73+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error { return errors.New("stop after wake") }
74+ i.completionDeliveryHolds = 1
75+
76+ released := startGateRelease(i.releaseCompletionDeliveryHold)
77+ enteredGate(t, gate, "releaseCompletionDeliveryHold")
78+
79+ // A caller holding i.mu that reads coordinator state takes
80+ // completionDeliveryMu: that is the i.mu -> completionDeliveryMu order this
81+ // test protects. Probing from that side must not queue behind the release.
82+ i.mu.Lock()
83+ pending := make(chan bool, 1)
84+ go func() {
85+ pending <- i.coordinatorHasPendingWorkers()
86+ }()
87+
88+ select {
89+ case <-pending:
90+ case <-time.After(completionGateTimeout):
91+ // Break the cycle before failing so the release can sample and relock.
92+ i.mu.Unlock()
93+ gate.unblockNow()
94+ awaitGate(t, released, "releaseCompletionDeliveryHold")
95+ t.Fatal("coordinatorHasPendingWorkers blocked behind releaseCompletionDeliveryHold")
96+ }
97+ i.mu.Unlock()
98+ gate.unblockNow()
99+ awaitGate(t, released, "releaseCompletionDeliveryHold")
100+
101+ i.completionDeliveryMu.Lock()
102+ holds := i.completionDeliveryHolds
103+ i.completionDeliveryMu.Unlock()
104+ if holds != 0 {
105+ t.Fatalf("completionDeliveryHolds = %d, want 0", holds)
106+ }
107+}
108+
109+// TestNonSealingReleaseCompletionHoldSkipsGoalSampling pins that only the
110+// release which drains the last hold reads goal and interruption state. A
111+// nested release (2 -> 1) or a duplicate release (already 0) must return
112+// without acquiring i.mu, which those reads take.
113+func TestNonSealingReleaseCompletionHoldSkipsGoalSampling(t *testing.T) {
114+ for _, tc := range []struct {
115+ name string
116+ holds int
117+ wantHolds int
118+ }{
119+ {name: "nested", holds: 2, wantHolds: 1},
120+ {name: "duplicate", holds: 0, wantHolds: 0},
121+ } {
122+ t.Run(tc.name, func(t *testing.T) {
123+ i := &Interactive{agent: core.NewAgent(nil, "model", "", nil)}
124+ i.completionDeliveryHolds = tc.holds
125+
126+ i.mu.Lock()
127+ done := startGateRelease(i.releaseCompletionDeliveryHold)
128+ select {
129+ case <-done:
130+ case <-time.After(completionGateTimeout):
131+ // Break the cycle before failing so the release goroutine exits.
132+ i.mu.Unlock()
133+ awaitGate(t, done, "non-sealing release")
134+ t.Fatal("non-sealing release sampled goal state while i.mu was held")
135+ }
136+ i.mu.Unlock()
137+
138+ i.completionDeliveryMu.Lock()
139+ holds := i.completionDeliveryHolds
140+ i.completionDeliveryMu.Unlock()
141+ if holds != tc.wantHolds {
142+ t.Fatalf("completionDeliveryHolds = %d, want %d", holds, tc.wantHolds)
143+ }
144+ })
145+ }
146+}
147+
148+// TestStaleReleaseCompletionHoldDoesNotSealNewerWave covers a duplicate release
149+// that arrives after the earlier wave already drained, while a newer manager
150+// wave is active. The stale call carries no hold and must not seal that wave:
151+// pre-fix it re-applied GoalChanged and ManagerFinished, which left the newer
152+// wave looking finished.
153+func TestStaleReleaseCompletionHoldDoesNotSealNewerWave(t *testing.T) {
154+ i := &Interactive{agent: core.NewAgent(nil, "model", "", nil)}
155+ // A newer manager wave is already active with no completion hold
156+ // outstanding, as happens when completion delivery wakes the manager after
157+ // the holds drained.
158+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
159+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "w1", AgentID: "agent-1"})
160+ if i.coordinatorAcceptsUserInput() {
161+ t.Fatal("setup: newer manager wave is already sealed")
162+ }
163+
164+ i.releaseCompletionDeliveryHold()
165+
166+ if i.coordinatorAcceptsUserInput() {
167+ t.Fatal("duplicate release sealed the newer manager wave")
168+ }
169+ if !i.coordinatorHasPendingWorkers() {
170+ t.Fatal("duplicate release lost the newer wave's pending worker")
171+ }
172+}
173+
174+// goalSampleGate pins the goal-sampling window of a completion delivery
175+// release without sleeps. The first CurrentGoal call signals entered and then
176+// waits for unblock, so a test can deterministically act while that release is
177+// between its hold transition and its relock.
178+type goalSampleGate struct {
179+ entered chan struct{}
180+ unblock chan struct{}
181+ // once lets tests close unblock more than once, including from a deferred
182+ // cleanup that runs after the test already closed it.
183+ once sync.Once
184+ calls atomic.Int64
185+ next func(call int64) *core.SessionGoal
186+}
187+
188+func newGoalSampleGate(next func(call int64) *core.SessionGoal) *goalSampleGate {
189+ return &goalSampleGate{
190+ entered: make(chan struct{}),
191+ unblock: make(chan struct{}),
192+ next: next,
193+ }
194+}
195+
196+// unblockNow releases the sampling window exactly once, so a failure cleanup
197+// can let a parked release finish without a double close.
198+func (g *goalSampleGate) unblockNow() {
199+ g.once.Do(func() { close(g.unblock) })
200+}
201+
202+// current fulfils InteractiveConfig.CurrentGoal.
203+func (g *goalSampleGate) current() *core.SessionGoal {
204+ call := g.calls.Add(1)
205+ if call == 1 {
206+ close(g.entered)
207+ <-g.unblock
208+ }
209+ return g.next(call)
210+}
211+
212+// completionGateTimeout bounds every wait in the completion-hold gate tests so
213+// an inverted lock order fails the test instead of hanging the package.
214+const completionGateTimeout = 5 * time.Second
215+
216+// startGateRelease runs a potentially blocking hold, release, or coordinator
217+// transition in its own goroutine and returns a channel closed when it
218+// returns, so a test can bound the wait instead of hanging.
219+func startGateRelease(release func()) <-chan struct{} {
220+ done := make(chan struct{})
221+ go func() {
222+ defer close(done)
223+ release()
224+ }()
225+ return done
226+}
227+
228+// awaitGate waits for done to close, failing the test after
229+// completionGateTimeout rather than blocking forever.
230+func awaitGate(t *testing.T, done <-chan struct{}, what string) {
231+ t.Helper()
232+ select {
233+ case <-done:
234+ case <-time.After(completionGateTimeout):
235+ t.Fatalf("%s did not finish within %s", what, completionGateTimeout)
236+ }
237+}
238+
239+// enteredGate waits until a parked release has provably reached goal sampling.
240+func enteredGate(t *testing.T, gate *goalSampleGate, what string) {
241+ t.Helper()
242+ select {
243+ case <-gate.entered:
244+ case <-time.After(completionGateTimeout):
245+ gate.unblockNow()
246+ t.Fatalf("%s never sampled goal state", what)
247+ }
248+}
249+
250+// TestCancelledCoordinatorIsNotSealedByStaleRelease covers cancellation
251+// invalidating an in-flight release. The release drained an older hold and is
252+// sampling goal state when the coordinator is replaced, so its stale
253+// GoalChanged/ManagerFinished snapshot must not seal or wake the replacement.
254+func TestCancelledCoordinatorIsNotSealedByStaleRelease(t *testing.T) {
255+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
256+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
257+ defer gate.unblockNow()
258+
259+ // A manager wake reaches goal sampling only through PersistGoalRuntime, so
260+ // recording that callback detects an unintended seal. Returning an error
261+ // stops the continuation before it touches the provider.
262+ woke := make(chan struct{}, 1)
263+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
264+ i.cfg.CurrentGoal = gate.current
265+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
266+ select {
267+ case woke <- struct{}{}:
268+ default:
269+ }
270+ return errors.New("stop after wake")
271+ }
272+
273+ // The snapshot belongs to a wave that still owns a worker, so sealing it
274+ // would wake the manager.
275+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
276+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "w1", AgentID: "agent-1"})
277+ if !i.coordinatorHasPendingWorkers() {
278+ t.Fatal("setup: outstanding wave has no pending worker")
279+ }
280+
281+ releaseOld := i.beginCompletionDeliveryHold()
282+ released := startGateRelease(releaseOld)
283+ enteredGate(t, gate, "older release")
284+
285+ awaitGate(t, startGateRelease(i.cancelCoordinator), "cancelCoordinator")
286+
287+ gate.unblockNow()
288+ awaitGate(t, released, "older release")
289+
290+ // The replacement coordinator never owned the older wave, so the stale
291+ // release must leave it compact and must not wake the manager.
292+ if i.coordinatorHasPendingWorkers() {
293+ t.Fatal("stale release resurrected the cancelled wave's worker")
294+ }
295+ if i.coordinatorAcceptsUserInput() {
296+ t.Fatal("stale release sealed the replacement coordinator")
297+ }
298+ select {
299+ case <-woke:
300+ t.Fatal("stale release woke the manager after cancellation")
301+ default:
302+ }
303+
304+ // Positive control: the replacement coordinator still seals and wakes for a
305+ // wave that starts after cancellation, so the check above is meaningful.
306+ awaitGate(t, startGateRelease(func() { i.beginCompletionDeliveryHold() }), "post-cancel hold")
307+ awaitGate(t, startGateRelease(i.releaseCompletionDeliveryHold), "post-cancel release")
308+ select {
309+ case <-woke:
310+ default:
311+ t.Fatal("post-cancel wave did not seal and wake the manager")
312+ }
313+}
314+
315+// TestImplicitCoordinatorWaveIsNotSealedByStaleRelease covers a worker
316+// registered outside a completion-delivery wave. That implicit wave replaces
317+// whatever wave an older release drained, so once the worker finishes and the
318+// wave is sealed with no pending work, the stale release must not fall through
319+// to a goal wake when it relocks.
320+func TestImplicitCoordinatorWaveIsNotSealedByStaleRelease(t *testing.T) {
321+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
322+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
323+ defer gate.unblockNow()
324+
325+ woke := make(chan struct{}, 1)
326+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
327+ i.cfg.CurrentGoal = gate.current
328+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
329+ select {
330+ case woke <- struct{}{}:
331+ default:
332+ }
333+ return errors.New("stop after wake")
334+ }
335+
336+ releaseOld := i.beginCompletionDeliveryHold()
337+ released := startGateRelease(releaseOld)
338+ enteredGate(t, gate, "older release")
339+
340+ // A worker registered with no hold outstanding opens the implicit
341+ // one-worker sealed wave.
342+ awaitGate(t, startGateRelease(func() { i.registerCoordinatorWorker("w1") }), "registerCoordinatorWorker")
343+ workerID := i.takeCoordinatorWorkerID("w1")
344+ if workerID == "" {
345+ t.Fatal("setup: implicit wave registered no worker")
346+ }
347+
348+ // The worker finishes inside the older release's sampling window, leaving
349+ // the implicit wave sealed with no pending work. Its own worker wake is not
350+ // under test, so only the coordinator state is advanced here.
351+ i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerFinished, WorkerID: workerID})
352+ if i.coordinatorHasPendingWorkers() {
353+ t.Fatal("setup: implicit wave still has pending work")
354+ }
355+
356+ gate.unblockNow()
357+ awaitGate(t, released, "older release")
358+
359+ select {
360+ case <-woke:
361+ t.Fatal("stale release sealed the newer implicit coordinator wave")
362+ default:
363+ }
364+}
365+
366+// TestReleaseCompletionHoldLeavesActiveNewerWaveUnsealed covers a newer hold
367+// that begins while an older release samples goal state and is still
368+// outstanding when that release relocks. The older release must leave the
369+// newer wave alone, and the newer release must still seal it.
370+func TestReleaseCompletionHoldLeavesActiveNewerWaveUnsealed(t *testing.T) {
371+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
372+ gate := newGoalSampleGate(func(int64) *core.SessionGoal { return goal })
373+ defer gate.unblockNow()
374+
375+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
376+ i.cfg.CurrentGoal = gate.current
377+ // A manager wake is not the subject of this test; stop it before it starts
378+ // a turn.
379+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error { return errors.New("stop after wake") }
380+
381+ releaseOld := i.beginCompletionDeliveryHold()
382+ i.registerCoordinatorWorker("w1")
383+
384+ released := startGateRelease(releaseOld)
385+ enteredGate(t, gate, "older release")
386+
387+ var releaseNew func()
388+ awaitGate(t, startGateRelease(func() { releaseNew = i.beginCompletionDeliveryHold() }), "newer hold")
389+ if i.coordinatorAcceptsUserInput() {
390+ t.Fatal("setup: newer hold wave is already sealed")
391+ }
392+ gate.unblockNow()
393+ awaitGate(t, released, "older release")
394+
395+ i.completionDeliveryMu.Lock()
396+ holds := i.completionDeliveryHolds
397+ i.completionDeliveryMu.Unlock()
398+ if holds != 1 {
399+ t.Fatalf("completionDeliveryHolds = %d, want 1 (newer hold still active)", holds)
400+ }
401+ if i.coordinatorAcceptsUserInput() {
402+ t.Fatal("older release sealed the newer wave it did not drain")
403+ }
404+
405+ awaitGate(t, startGateRelease(releaseNew), "newer release")
406+ if !i.coordinatorAcceptsUserInput() {
407+ t.Fatal("newer release did not seal its own wave")
408+ }
409+ if !i.coordinatorHasPendingWorkers() {
410+ t.Fatal("newer wave lost its pending worker")
411+ }
412+}
413+
414+// TestDrainedNewerWaveIsNotResealedByOlderRelease pins the wave generation
415+// guard. While an older release samples goal state, a newer wave both begins
416+// and fully drains, leaving the hold count at zero when the older release
417+// relocks. The count alone would let that older release re-apply its stale
418+// snapshot (including GoalChanged) over the wave that already sealed.
419+func TestDrainedNewerWaveIsNotResealedByOlderRelease(t *testing.T) {
420+ goal := &core.SessionGoal{ID: "goal-1", Status: core.GoalActive, Objective: "keep building"}
421+ gate := newGoalSampleGate(func(call int64) *core.SessionGoal {
422+ if call == 2 {
423+ // The newer wave seals with no active goal, so its own seal must not
424+ // wake the manager.
425+ return nil
426+ }
427+ return goal
428+ })
429+ defer gate.unblockNow()
430+
431+ // A manager wake reaches goal sampling only through PersistGoalRuntime, so
432+ // recording that callback detects an unintended seal. Returning an error
433+ // stops the continuation before it touches the provider.
434+ woke := make(chan struct{}, 1)
435+ i := &Interactive{agent: core.NewAgent(nil, "model", "", goalToolRegistry())}
436+ i.cfg.CurrentGoal = gate.current
437+ i.cfg.PersistGoalRuntime = func(*core.SessionGoal) error {
438+ select {
439+ case woke <- struct{}{}:
440+ default:
441+ }
442+ return errors.New("stop after wake")
443+ }
444+
445+ releaseOld := i.beginCompletionDeliveryHold()
446+ released := startGateRelease(releaseOld)
447+ enteredGate(t, gate, "older release")
448+
449+ // A newer wave begins and fully drains inside the older release's sampling
450+ // window.
451+ awaitGate(t, startGateRelease(func() {
452+ releaseNew := i.beginCompletionDeliveryHold()
453+ releaseNew()
454+ }), "newer hold wave")
455+
456+ i.completionDeliveryMu.Lock()
457+ holds := i.completionDeliveryHolds
458+ i.completionDeliveryMu.Unlock()
459+ if holds != 0 {
460+ t.Fatalf("completionDeliveryHolds = %d, want 0 after the newer wave drained", holds)
461+ }
462+ gate.unblockNow()
463+ awaitGate(t, released, "older release")
464+
465+ // The older release relocked with zero holds but belongs to the previous
466+ // generation, so it must not seal, and must not wake the manager for, the
467+ // wave that the newer release already sealed.
468+ select {
469+ case <-woke:
470+ t.Fatal("stale release sealed a drained newer wave")
471+ default:
472+ }
473+
474+ // Positive control: the same coordinator state does wake the manager when
475+ // the goal really is active, so the check above is meaningful.
476+ i.executeCoordinatorActions(i.applyCoordinator(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: true}))
477+ i.executeCoordinatorActions(i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}))
478+ select {
479+ case <-woke:
480+ default:
481+ t.Fatal("goal-active manager wake no longer reaches goal sampling")
482+ }
483+}
484+
49 func TestResidentCompletionSlidesIntoBusyParent(t *testing.T) {485 func TestResidentCompletionSlidesIntoBusyParent(t *testing.T) {
50 for _, tc := range []struct {486 for _, tc := range []struct {
51 name string487 name string
M packages/agent/modes/interactive_resident_subagents.go +62 -10
@@ -116,6 +116,10 @@ func (i *Interactive) registerCoordinatorWorker(workerID string) {
116116 coordinator := i.ensureCoordinatorLocked()
117117 implicitWave := i.completionDeliveryHolds == 0
118118 if implicitWave {
119+ // A one-worker sealed wave replaces whatever wave an older release may
120+ // still be holding a sampled snapshot for. Bump the generation so that
121+ // release cannot seal this newer wave when it relocks.
122+ i.completionDeliveryGeneration++
119123 coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
120124 }
121125 i.coordinatorWorkerSeq++
@@ -144,6 +148,11 @@ func (i *Interactive) cancelCoordinator() {
144148 if i.completionTracker != nil {
145149 i.completionTracker.Reset()
146150 }
151+ // Cancellation also invalidates any snapshot a release sampled before it.
152+ // Bump the wave generation rather than zeroing the hold count: outstanding
153+ // release closures must still balance that count, and the generation is
154+ // what stops a stale snapshot from sealing the replacement coordinator.
155+ i.completionDeliveryGeneration++
147156 i.turnCoordinator = orchestration.New()
148157 i.coordinatorWorkerIDs = nil
149158 i.completionDeliveryMu.Unlock()
@@ -221,6 +230,12 @@ func (i *Interactive) beginCompletionDeliveryHold() func() {
221230 return func() {}
222231 }
223232 i.completionDeliveryMu.Lock()
233+ if i.completionDeliveryHolds == 0 {
234+ // A wave starts only from zero holds. Bumping the generation here lets a
235+ // release that drained an older wave detect that a newer wave replaced
236+ // it while it sampled goal state.
237+ i.completionDeliveryGeneration++
238+ }
224239 i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
225240 i.completionDeliveryHolds++
226241 i.completionDeliveryMu.Unlock()
@@ -230,21 +245,58 @@ func (i *Interactive) releaseCompletionDeliveryHold() {
230245 if i == nil {
231246 return
232247 }
233- start := false
234- var actions []orchestration.Action
235248 i.completionDeliveryMu.Lock()
249+ sealing := false
250+ var sealingGeneration uint64
236251 if i.completionDeliveryHolds > 0 {
237252 i.completionDeliveryHolds--
253+ if i.completionDeliveryHolds == 0 {
254+ sealing = true
255+ sealingGeneration = i.completionDeliveryGeneration
256+ }
238257 }
239- if i.completionDeliveryHolds == 0 {
240- // The coordinator must observe the goal before sealing the wave, so a
241- // pending worker wins the wake decision instead of a direct continuation.
242- // An Esc-armed reassessment prompt suppresses the wake: the next
243- // ordinary user message owns the turn, not autonomous work.
244- _, goalActive := i.goalContinuationMessage()
245- if i.interruptedPromptPending() {
246- goalActive = false
258+ if !sealing {
259+ // A nested or duplicate release only returns a hold; it must not sample
260+ // goal state or seal a wave it does not own. Starting pending delivery
261+ // stays available because that is the existing behaviour whenever the
262+ // wave is already idle.
263+ start := i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning
264+ if start {
265+ i.completionDeliveryRunning = true
266+ }
267+ i.completionDeliveryMu.Unlock()
268+ if start {
269+ go i.deliverCompletionUpdates()
247270 }
271+ return
272+ }
273+ i.completionDeliveryMu.Unlock()
274+
275+ // Only the release that drained the last hold seals the wave, so this
276+ // snapshot belongs to exactly that wave. Read the goal and interruption
277+ // state before taking completionDeliveryMu: both helpers acquire i.mu, and
278+ // callers legitimately hold i.mu while they read coordinator state, so
279+ // acquiring i.mu while holding completionDeliveryMu would invert the lock
280+ // order and deadlock.
281+ //
282+ // The coordinator must observe the goal before sealing the wave, so a
283+ // pending worker wins the wake decision instead of a direct continuation.
284+ // An Esc-armed reassessment prompt suppresses the wake: the next ordinary
285+ // user message owns the turn, not autonomous work.
286+ _, goalActive := i.goalContinuationMessage()
287+ if i.interruptedPromptPending() {
288+ goalActive = false
289+ }
290+
291+ start := false
292+ var actions []orchestration.Action
293+ i.completionDeliveryMu.Lock()
294+ // A newer wave may have replaced this one while goal state was sampled, so
295+ // the hold count alone is not enough: a newer hold can begin and fully
296+ // drain, leaving the count at zero, and sealing then would re-apply this
297+ // stale snapshot over the wave that already sealed. The captured generation
298+ // identifies exactly the wave this release drained.
299+ if i.completionDeliveryHolds == 0 && i.completionDeliveryGeneration == sealingGeneration {
248300 coordinator := i.ensureCoordinatorLocked()
249301 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
250302 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
@@ -116,6 +116,10 @@ func (i *Interactive) registerCoordinatorWorker(workerID string) {
116 coordinator := i.ensureCoordinatorLocked()116 coordinator := i.ensureCoordinatorLocked()
117 implicitWave := i.completionDeliveryHolds == 0117 implicitWave := i.completionDeliveryHolds == 0
118 if implicitWave {118 if implicitWave {
119+ // A one-worker sealed wave replaces whatever wave an older release may
120+ // still be holding a sampled snapshot for. Bump the generation so that
121+ // release cannot seal this newer wave when it relocks.
122+ i.completionDeliveryGeneration++
119 coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})123 coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
120 }124 }
121 i.coordinatorWorkerSeq++125 i.coordinatorWorkerSeq++
@@ -144,6 +148,11 @@ func (i *Interactive) cancelCoordinator() {
144 if i.completionTracker != nil {148 if i.completionTracker != nil {
145 i.completionTracker.Reset()149 i.completionTracker.Reset()
146 }150 }
151+ // Cancellation also invalidates any snapshot a release sampled before it.
152+ // Bump the wave generation rather than zeroing the hold count: outstanding
153+ // release closures must still balance that count, and the generation is
154+ // what stops a stale snapshot from sealing the replacement coordinator.
155+ i.completionDeliveryGeneration++
147 i.turnCoordinator = orchestration.New()156 i.turnCoordinator = orchestration.New()
148 i.coordinatorWorkerIDs = nil157 i.coordinatorWorkerIDs = nil
149 i.completionDeliveryMu.Unlock()158 i.completionDeliveryMu.Unlock()
@@ -221,6 +230,12 @@ func (i *Interactive) beginCompletionDeliveryHold() func() {
221 return func() {}230 return func() {}
222 }231 }
223 i.completionDeliveryMu.Lock()232 i.completionDeliveryMu.Lock()
233+ if i.completionDeliveryHolds == 0 {
234+ // A wave starts only from zero holds. Bumping the generation here lets a
235+ // release that drained an older wave detect that a newer wave replaced
236+ // it while it sampled goal state.
237+ i.completionDeliveryGeneration++
238+ }
224 i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})239 i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
225 i.completionDeliveryHolds++240 i.completionDeliveryHolds++
226 i.completionDeliveryMu.Unlock()241 i.completionDeliveryMu.Unlock()
@@ -230,21 +245,58 @@ func (i *Interactive) releaseCompletionDeliveryHold() {
230 if i == nil {245 if i == nil {
231 return246 return
232 }247 }
233- start := false
234- var actions []orchestration.Action
235 i.completionDeliveryMu.Lock()248 i.completionDeliveryMu.Lock()
249+ sealing := false
250+ var sealingGeneration uint64
236 if i.completionDeliveryHolds > 0 {251 if i.completionDeliveryHolds > 0 {
237 i.completionDeliveryHolds--252 i.completionDeliveryHolds--
253+ if i.completionDeliveryHolds == 0 {
254+ sealing = true
255+ sealingGeneration = i.completionDeliveryGeneration
256+ }
238 }257 }
239- if i.completionDeliveryHolds == 0 {258+ if !sealing {
240- // The coordinator must observe the goal before sealing the wave, so a259+ // A nested or duplicate release only returns a hold; it must not sample
241- // pending worker wins the wake decision instead of a direct continuation.260+ // goal state or seal a wave it does not own. Starting pending delivery
242- // An Esc-armed reassessment prompt suppresses the wake: the next261+ // stays available because that is the existing behaviour whenever the
243- // ordinary user message owns the turn, not autonomous work.262+ // wave is already idle.
244- _, goalActive := i.goalContinuationMessage()263+ start := i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning
245- if i.interruptedPromptPending() {264+ if start {
246- goalActive = false265+ i.completionDeliveryRunning = true
266+ }
267+ i.completionDeliveryMu.Unlock()
268+ if start {
269+ go i.deliverCompletionUpdates()
247 }270 }
271+ return
272+ }
273+ i.completionDeliveryMu.Unlock()
274+
275+ // Only the release that drained the last hold seals the wave, so this
276+ // snapshot belongs to exactly that wave. Read the goal and interruption
277+ // state before taking completionDeliveryMu: both helpers acquire i.mu, and
278+ // callers legitimately hold i.mu while they read coordinator state, so
279+ // acquiring i.mu while holding completionDeliveryMu would invert the lock
280+ // order and deadlock.
281+ //
282+ // The coordinator must observe the goal before sealing the wave, so a
283+ // pending worker wins the wake decision instead of a direct continuation.
284+ // An Esc-armed reassessment prompt suppresses the wake: the next ordinary
285+ // user message owns the turn, not autonomous work.
286+ _, goalActive := i.goalContinuationMessage()
287+ if i.interruptedPromptPending() {
288+ goalActive = false
289+ }
290+
291+ start := false
292+ var actions []orchestration.Action
293+ i.completionDeliveryMu.Lock()
294+ // A newer wave may have replaced this one while goal state was sampled, so
295+ // the hold count alone is not enough: a newer hold can begin and fully
296+ // drain, leaving the count at zero, and sealing then would re-apply this
297+ // stale snapshot over the wave that already sealed. The captured generation
298+ // identifies exactly the wave this release drained.
299+ if i.completionDeliveryHolds == 0 && i.completionDeliveryGeneration == sealingGeneration {
248 coordinator := i.ensureCoordinatorLocked()300 coordinator := i.ensureCoordinatorLocked()
249 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})301 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
250 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions302 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close