detached dca6cce

merge(main): update Ponytail branch with latest main

commit
dca6cce373033608923c1171f3e0bc66dd9e76d4
author
brice · · 2026-08-05 07:55 UTC
merge of
dc29cb4 + b6de308 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +163 -37
M examples/extensions/tasked-phases/README.md +5 -3
@@ -57,6 +57,8 @@ available, the plan is also stored as extension-owned state on each session
5757 branch and restored when sessions are opened, switched, or forked.
5858
5959 Before each model turn, the extension supplies only the active phase, its goal,
60-and a bounded set of incomplete tasks as hidden context. Completed phases and
61-future phases are not repeated in every provider request; use `get_status` or
62-`/phases` when the full checklist is needed.
60+and a bounded set of incomplete tasks as hidden context. The same context
61+reminds the model to update the checklist continuously, mark each task as soon
62+as it is completed, and advance the current phase when work moves forward.
63+Completed phases and future phases are not repeated in every provider request;
64+use `get_status` or `/phases` when the full checklist is needed.
@@ -57,6 +57,8 @@ available, the plan is also stored as extension-owned state on each session
57 branch and restored when sessions are opened, switched, or forked.57 branch and restored when sessions are opened, switched, or forked.
58 58
59 Before each model turn, the extension supplies only the active phase, its goal,59 Before each model turn, the extension supplies only the active phase, its goal,
60-and a bounded set of incomplete tasks as hidden context. Completed phases and60+and a bounded set of incomplete tasks as hidden context. The same context
61-future phases are not repeated in every provider request; use `get_status` or61+reminds the model to update the checklist continuously, mark each task as soon
62-`/phases` when the full checklist is needed.62+as it is completed, and advance the current phase when work moves forward.
63+Completed phases and future phases are not repeated in every provider request;
64+use `get_status` or `/phases` when the full checklist is needed.
M examples/extensions/tasked-phases/main.go +1 -1
@@ -118,7 +118,7 @@ func main() {
118118 })
119119
120120 e.Tool(toolName,
121- "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",
121+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.",
122122 json.RawMessage(toolSchema), a.handleTool)
123123 e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
124124 e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
@@ -118,7 +118,7 @@ func main() {
118 })118 })
119 119
120 e.Tool(toolName,120 e.Tool(toolName,
121- "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",121+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.",
122 json.RawMessage(toolSchema), a.handleTool)122 json.RawMessage(toolSchema), a.handleTool)
123 e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)123 e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
124 e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)124 e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
M examples/extensions/tasked-phases/state.go +7 -0
@@ -915,7 +915,14 @@ func buildTurnContext(state PlanState) string {
915915 return ""
916916 }
917917 lines := []string{
918+ "[TASKED PHASES STATE - SOURCE OF TRUTH]",
918919 "Tasked phases focus:",
920+ "Call tasked_phases get_status if the exact full checklist is needed.",
921+ "Update tasked_phases continuously while implementing, not only at the end.",
922+ "After completing each checklist task, immediately call tasked_phases set_task_checked.",
923+ "After moving to another phase, immediately call tasked_phases set_current_phase.",
924+ "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
925+ "Do not rely on prose alone for completion state.",
919926 fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
920927 }
921928 if phase.Goal != "" {
@@ -915,7 +915,14 @@ func buildTurnContext(state PlanState) string {
915 return ""915 return ""
916 }916 }
917 lines := []string{917 lines := []string{
918+ "[TASKED PHASES STATE - SOURCE OF TRUTH]",
918 "Tasked phases focus:",919 "Tasked phases focus:",
920+ "Call tasked_phases get_status if the exact full checklist is needed.",
921+ "Update tasked_phases continuously while implementing, not only at the end.",
922+ "After completing each checklist task, immediately call tasked_phases set_task_checked.",
923+ "After moving to another phase, immediately call tasked_phases set_current_phase.",
924+ "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
925+ "Do not rely on prose alone for completion state.",
919 fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),926 fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
920 }927 }
921 if phase.Goal != "" {928 if phase.Goal != "" {
M examples/extensions/tasked-phases/state_test.go +16 -0
@@ -238,6 +238,22 @@ func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
238238 }
239239 }
240240
241+func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) {
242+ context := buildTurnContext(sampleState())
243+ for _, want := range []string{
244+ "[TASKED PHASES STATE - SOURCE OF TRUTH]",
245+ "Update tasked_phases continuously while implementing, not only at the end.",
246+ "After completing each checklist task, immediately call tasked_phases set_task_checked.",
247+ "After moving to another phase, immediately call tasked_phases set_current_phase.",
248+ "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
249+ "Do not rely on prose alone for completion state.",
250+ } {
251+ if !strings.Contains(context, want) {
252+ t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context)
253+ }
254+ }
255+}
256+
241257 func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
242258 state := sampleState()
243259 state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
@@ -238,6 +238,22 @@ func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
238 }238 }
239 }239 }
240 240
241+func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) {
242+ context := buildTurnContext(sampleState())
243+ for _, want := range []string{
244+ "[TASKED PHASES STATE - SOURCE OF TRUTH]",
245+ "Update tasked_phases continuously while implementing, not only at the end.",
246+ "After completing each checklist task, immediately call tasked_phases set_task_checked.",
247+ "After moving to another phase, immediately call tasked_phases set_current_phase.",
248+ "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
249+ "Do not rely on prose alone for completion state.",
250+ } {
251+ if !strings.Contains(context, want) {
252+ t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context)
253+ }
254+ }
255+}
256+
241 func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {257 func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
242 state := sampleState()258 state := sampleState()
243 state.Phases[1].ID = strings.Repeat("phase-id-", 1000)259 state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
M packages/agent/modes/auto_subagents_test.go +85 -0
@@ -4,11 +4,13 @@ import (
44 "context"
55 "errors"
66 "strings"
7+ "sync"
78 "testing"
89 "time"
910
1011 "github.com/patriceckhart/zot/packages/agent/subagents"
1112 "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
1214 )
1315
1416 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
@@ -151,6 +153,89 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
151153 }
152154 }
153155
156+type autoSubagentsQueueClient struct {
157+ requests chan provider.Request
158+}
159+
160+func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" }
161+
162+func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
163+ c.requests <- req
164+ out := make(chan provider.Event, 1)
165+ go func() {
166+ defer close(out)
167+ out <- provider.EventDone{
168+ Stop: provider.StopEnd,
169+ Message: provider.Message{
170+ Role: provider.RoleAssistant,
171+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
172+ },
173+ }
174+ }()
175+ return out, nil
176+}
177+
178+func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
179+ client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)}
180+ agent := core.NewAgent(client, "test-model", "", nil)
181+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
182+ interactive.runCtx = context.Background()
183+
184+ var once sync.Once
185+ agent.OnEvent = func(ev core.AgentEvent) {
186+ if _, ok := ev.(core.EvDone); !ok {
187+ return
188+ }
189+ once.Do(func() {
190+ interactive.flushSupervisorSummary([]*subagentWatchEntry{{
191+ agent: &subagents.Agent{},
192+ task: "smoke test",
193+ }})
194+ })
195+ }
196+
197+ interactive.startTurn(context.Background(), "initial")
198+ select {
199+ case <-client.requests:
200+ case <-time.After(2 * time.Second):
201+ t.Fatal("initial request did not start")
202+ }
203+
204+ var followUp provider.Request
205+ select {
206+ case followUp = <-client.requests:
207+ case <-time.After(2 * time.Second):
208+ t.Fatal("queued auto-subagents update did not start a follow-up request")
209+ }
210+ foundUpdate := false
211+ for _, message := range followUp.Messages {
212+ for _, content := range message.Content {
213+ if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") {
214+ foundUpdate = true
215+ }
216+ }
217+ }
218+ if !foundUpdate {
219+ t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages)
220+ }
221+
222+ deadline := time.NewTimer(2 * time.Second)
223+ defer deadline.Stop()
224+ for {
225+ interactive.mu.Lock()
226+ busy := interactive.busy
227+ interactive.mu.Unlock()
228+ if !busy && agent.QueuedMessageCount() == 0 {
229+ return
230+ }
231+ select {
232+ case <-deadline.C:
233+ t.Fatal("interactive turn remained busy or retained a queued update")
234+ case <-time.After(time.Millisecond):
235+ }
236+ }
237+}
238+
154239 func newQueuedAutoSubagentsInteractive() *Interactive {
155240 return &Interactive{
156241 agent: &core.Agent{},
@@ -4,11 +4,13 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "strings"6 "strings"
7+ "sync"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
10 "github.com/patriceckhart/zot/packages/agent/subagents"11 "github.com/patriceckhart/zot/packages/agent/subagents"
11 "github.com/patriceckhart/zot/packages/core"12 "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
12 )14 )
13 15
14 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {16 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
@@ -151,6 +153,89 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
151 }153 }
152 }154 }
153 155
156+type autoSubagentsQueueClient struct {
157+ requests chan provider.Request
158+}
159+
160+func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" }
161+
162+func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
163+ c.requests <- req
164+ out := make(chan provider.Event, 1)
165+ go func() {
166+ defer close(out)
167+ out <- provider.EventDone{
168+ Stop: provider.StopEnd,
169+ Message: provider.Message{
170+ Role: provider.RoleAssistant,
171+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
172+ },
173+ }
174+ }()
175+ return out, nil
176+}
177+
178+func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
179+ client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)}
180+ agent := core.NewAgent(client, "test-model", "", nil)
181+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
182+ interactive.runCtx = context.Background()
183+
184+ var once sync.Once
185+ agent.OnEvent = func(ev core.AgentEvent) {
186+ if _, ok := ev.(core.EvDone); !ok {
187+ return
188+ }
189+ once.Do(func() {
190+ interactive.flushSupervisorSummary([]*subagentWatchEntry{{
191+ agent: &subagents.Agent{},
192+ task: "smoke test",
193+ }})
194+ })
195+ }
196+
197+ interactive.startTurn(context.Background(), "initial")
198+ select {
199+ case <-client.requests:
200+ case <-time.After(2 * time.Second):
201+ t.Fatal("initial request did not start")
202+ }
203+
204+ var followUp provider.Request
205+ select {
206+ case followUp = <-client.requests:
207+ case <-time.After(2 * time.Second):
208+ t.Fatal("queued auto-subagents update did not start a follow-up request")
209+ }
210+ foundUpdate := false
211+ for _, message := range followUp.Messages {
212+ for _, content := range message.Content {
213+ if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") {
214+ foundUpdate = true
215+ }
216+ }
217+ }
218+ if !foundUpdate {
219+ t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages)
220+ }
221+
222+ deadline := time.NewTimer(2 * time.Second)
223+ defer deadline.Stop()
224+ for {
225+ interactive.mu.Lock()
226+ busy := interactive.busy
227+ interactive.mu.Unlock()
228+ if !busy && agent.QueuedMessageCount() == 0 {
229+ return
230+ }
231+ select {
232+ case <-deadline.C:
233+ t.Fatal("interactive turn remained busy or retained a queued update")
234+ case <-time.After(time.Millisecond):
235+ }
236+ }
237+}
238+
154 func newQueuedAutoSubagentsInteractive() *Interactive {239 func newQueuedAutoSubagentsInteractive() *Interactive {
155 return &Interactive{240 return &Interactive{
156 agent: &core.Agent{},241 agent: &core.Agent{},
M packages/agent/modes/interactive.go +49 -33
@@ -3608,19 +3608,17 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
36083608 i.maybeStartSessionTitle(i.runCtx, text)
36093609 i.mu.Lock()
36103610 if i.busy {
3611- // Queue text only; images are dropped for queued prompts. Compaction
3612- // uses the host queue because it has no active agent loop to drain
3613- // Agent.QueueMessage entries.
3614- ag := i.agent
3615- compacting := i.compacting
3616- i.mu.Unlock()
3617- if ag != nil && !compacting {
3618- ag.QueueMessage(text)
3611+ // Queue text only; images are dropped for queued prompts. Keep the
3612+ // interactive mutex held through Agent.QueueMessage so turn teardown
3613+ // cannot inspect the agent queue between the busy check and enqueue.
3614+ // Compaction uses the host queue because it has no active agent loop
3615+ // to drain Agent.QueueMessage entries.
3616+ if i.agent != nil && !i.compacting {
3617+ i.agent.QueueMessage(text)
36193618 } else {
3620- i.mu.Lock()
36213619 i.queued = append(i.queued, text)
3622- i.mu.Unlock()
36233620 }
3621+ i.mu.Unlock()
36243622 i.invalidate()
36253623 return
36263624 }
@@ -5802,22 +5800,20 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
58025800 i.invalidate()
58035801 return
58045802 }
5805- busy := i.busy
5806- compacting := i.compacting
5807- ag := i.agent
5808- i.mu.Unlock()
5809-
5810- if busy {
5811- if !compacting {
5812- ag.QueueMessage(prompt)
5813- } else {
5814- i.mu.Lock()
5803+ if i.busy {
5804+ // Keep the mutex held while enqueueing so the turn-completion
5805+ // goroutine cannot publish idle and inspect an empty queue between
5806+ // this check and the append.
5807+ if i.compacting {
58155808 i.queued = append(i.queued, prompt)
5816- i.mu.Unlock()
5809+ } else {
5810+ i.agent.QueueMessage(prompt)
58175811 }
5812+ i.mu.Unlock()
58185813 i.invalidate()
58195814 return
58205815 }
5816+ i.mu.Unlock()
58215817 i.startTurn(ctx, prompt)
58225818 }
58235819
@@ -6250,8 +6246,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
62506246 summary, err := i.agent.Compact(ctx, keepTail, sink)
62516247 _ = summary
62526248 i.mu.Lock()
6253- i.busy = false
6254- i.compacting = false
6249+ // Keep busy/compacting asserted while cleanup and queue selection run.
6250+ // Completion updates can arrive in this window; clearing busy before
6251+ // inspecting the queues lets them race a new turn or be stranded.
62556252 i.resetStreamingStateLocked()
62566253 i.cancelTurn = nil
62576254 i.autoCompacting = false
@@ -6333,12 +6330,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
63336330 continueAutomatically = true
63346331 }
63356332 }
6336- if hasNext || continueExisting || continueAutomatically {
6337- // Keep the host busy while handing the next turn back to the
6338- // agent. Prompts submitted in this hand-off window then enter
6339- // the agent queue instead of racing a second startTurn call.
6340- i.busy = true
6341- }
6333+ // Keep the host busy until the hand-off decision is committed under
6334+ // the mutex. A completion update arriving after the compaction result
6335+ // but before this assignment is then queued for the selected follow-up
6336+ // instead of starting a competing turn.
6337+ i.busy = hasNext || continueExisting || continueAutomatically
6338+ i.compacting = false
6339+ i.autoCompacting = false
63426340 i.mu.Unlock()
63436341 runPendingIdleWork(pendingIdleWork)
63446342 i.invalidate()
@@ -6520,9 +6518,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
65206518 i.mu.Lock()
65216519 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
65226520 if needsPreCompact {
6523- i.pendingCompactPrompt = prompt
6524- i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6525- i.hasPendingCompactPrompt = true
6521+ // Reserve the compaction hand-off before releasing i.mu. A completion
6522+ // update arriving in the small gap before runCompact acquires the
6523+ // mutex must join the host queue rather than starting a competing turn.
6524+ i.busy = true
6525+ i.compacting = true
6526+ i.autoCompacting = true
6527+ if continueExisting {
6528+ i.continueAfterCompact = true
6529+ } else {
6530+ i.pendingCompactPrompt = prompt
6531+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6532+ i.hasPendingCompactPrompt = true
6533+ }
65266534 i.statusErr = ""
65276535 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
65286536 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -6596,7 +6604,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
65966604 err = i.agent.Prompt(ctx, prompt, images, sink)
65976605 }
65986606 i.mu.Lock()
6599- i.busy = false
6607+ // Keep busy asserted through final cleanup and queue selection. A
6608+ // watcher may enqueue a completion summary concurrently with the
6609+ // provider's final event; publishing idle before inspecting queues can
6610+ // strand that summary in the core agent queue or start an overlapping
6611+ // turn.
66006612 // Don't touch streamPending / streamFlushPending here — the
66016613 // pacer may still be draining the final deltas and needs to
66026614 // paint them even though Prompt has returned. It will reset
@@ -6686,8 +6698,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
66866698 if i.agent != nil {
66876699 agentQueued = i.agent.QueuedMessageCount()
66886700 }
6701+ continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
66896702 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
66906703 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
6704+ i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
66916705 i.mu.Unlock()
66926706 if alertReason != "" {
66936707 i.scheduleMainAlert(alertReason)
@@ -6704,6 +6718,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
67046718 switch {
67056719 case hasNext:
67066720 i.startTurn(parent, next)
6721+ case continueQueued:
6722+ i.startTurnRequest(parent, "", nil, true, false)
67076723 case offer:
67086724 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
67096725 case recoverContextOverflow:
@@ -3608,19 +3608,17 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3608 i.maybeStartSessionTitle(i.runCtx, text)3608 i.maybeStartSessionTitle(i.runCtx, text)
3609 i.mu.Lock()3609 i.mu.Lock()
3610 if i.busy {3610 if i.busy {
3611- // Queue text only; images are dropped for queued prompts. Compaction3611+ // Queue text only; images are dropped for queued prompts. Keep the
3612- // uses the host queue because it has no active agent loop to drain3612+ // interactive mutex held through Agent.QueueMessage so turn teardown
3613- // Agent.QueueMessage entries.3613+ // cannot inspect the agent queue between the busy check and enqueue.
3614- ag := i.agent3614+ // Compaction uses the host queue because it has no active agent loop
3615- compacting := i.compacting3615+ // to drain Agent.QueueMessage entries.
3616- i.mu.Unlock()3616+ if i.agent != nil && !i.compacting {
3617- if ag != nil && !compacting {3617+ i.agent.QueueMessage(text)
3618- ag.QueueMessage(text)
3619 } else {3618 } else {
3620- i.mu.Lock()
3621 i.queued = append(i.queued, text)3619 i.queued = append(i.queued, text)
3622- i.mu.Unlock()
3623 }3620 }
3621+ i.mu.Unlock()
3624 i.invalidate()3622 i.invalidate()
3625 return3623 return
3626 }3624 }
@@ -5802,22 +5800,20 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
5802 i.invalidate()5800 i.invalidate()
5803 return5801 return
5804 }5802 }
5805- busy := i.busy5803+ if i.busy {
5806- compacting := i.compacting5804+ // Keep the mutex held while enqueueing so the turn-completion
5807- ag := i.agent5805+ // goroutine cannot publish idle and inspect an empty queue between
5808- i.mu.Unlock()5806+ // this check and the append.
5809-5807+ if i.compacting {
5810- if busy {
5811- if !compacting {
5812- ag.QueueMessage(prompt)
5813- } else {
5814- i.mu.Lock()
5815 i.queued = append(i.queued, prompt)5808 i.queued = append(i.queued, prompt)
5816- i.mu.Unlock()5809+ } else {
5810+ i.agent.QueueMessage(prompt)
5817 }5811 }
5812+ i.mu.Unlock()
5818 i.invalidate()5813 i.invalidate()
5819 return5814 return
5820 }5815 }
5816+ i.mu.Unlock()
5821 i.startTurn(ctx, prompt)5817 i.startTurn(ctx, prompt)
5822 }5818 }
5823 5819
@@ -6250,8 +6246,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6250 summary, err := i.agent.Compact(ctx, keepTail, sink)6246 summary, err := i.agent.Compact(ctx, keepTail, sink)
6251 _ = summary6247 _ = summary
6252 i.mu.Lock()6248 i.mu.Lock()
6253- i.busy = false6249+ // Keep busy/compacting asserted while cleanup and queue selection run.
6254- i.compacting = false6250+ // Completion updates can arrive in this window; clearing busy before
6251+ // inspecting the queues lets them race a new turn or be stranded.
6255 i.resetStreamingStateLocked()6252 i.resetStreamingStateLocked()
6256 i.cancelTurn = nil6253 i.cancelTurn = nil
6257 i.autoCompacting = false6254 i.autoCompacting = false
@@ -6333,12 +6330,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6333 continueAutomatically = true6330 continueAutomatically = true
6334 }6331 }
6335 }6332 }
6336- if hasNext || continueExisting || continueAutomatically {6333+ // Keep the host busy until the hand-off decision is committed under
6337- // Keep the host busy while handing the next turn back to the6334+ // the mutex. A completion update arriving after the compaction result
6338- // agent. Prompts submitted in this hand-off window then enter6335+ // but before this assignment is then queued for the selected follow-up
6339- // the agent queue instead of racing a second startTurn call.6336+ // instead of starting a competing turn.
6340- i.busy = true6337+ i.busy = hasNext || continueExisting || continueAutomatically
6341- }6338+ i.compacting = false
6339+ i.autoCompacting = false
6342 i.mu.Unlock()6340 i.mu.Unlock()
6343 runPendingIdleWork(pendingIdleWork)6341 runPendingIdleWork(pendingIdleWork)
6344 i.invalidate()6342 i.invalidate()
@@ -6520,9 +6518,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6520 i.mu.Lock()6518 i.mu.Lock()
6521 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()6519 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
6522 if needsPreCompact {6520 if needsPreCompact {
6523- i.pendingCompactPrompt = prompt6521+ // Reserve the compaction hand-off before releasing i.mu. A completion
6524- i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)6522+ // update arriving in the small gap before runCompact acquires the
6525- i.hasPendingCompactPrompt = true6523+ // mutex must join the host queue rather than starting a competing turn.
6524+ i.busy = true
6525+ i.compacting = true
6526+ i.autoCompacting = true
6527+ if continueExisting {
6528+ i.continueAfterCompact = true
6529+ } else {
6530+ i.pendingCompactPrompt = prompt
6531+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6532+ i.hasPendingCompactPrompt = true
6533+ }
6526 i.statusErr = ""6534 i.statusErr = ""
6527 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))6535 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
6528 i.pendingPostCompactNote = "context auto-compacted; sending your last message"6536 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -6596,7 +6604,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6596 err = i.agent.Prompt(ctx, prompt, images, sink)6604 err = i.agent.Prompt(ctx, prompt, images, sink)
6597 }6605 }
6598 i.mu.Lock()6606 i.mu.Lock()
6599- i.busy = false6607+ // Keep busy asserted through final cleanup and queue selection. A
6608+ // watcher may enqueue a completion summary concurrently with the
6609+ // provider's final event; publishing idle before inspecting queues can
6610+ // strand that summary in the core agent queue or start an overlapping
6611+ // turn.
6600 // Don't touch streamPending / streamFlushPending here — the6612 // Don't touch streamPending / streamFlushPending here — the
6601 // pacer may still be draining the final deltas and needs to6613 // pacer may still be draining the final deltas and needs to
6602 // paint them even though Prompt has returned. It will reset6614 // paint them even though Prompt has returned. It will reset
@@ -6686,8 +6698,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6686 if i.agent != nil {6698 if i.agent != nil {
6687 agentQueued = i.agent.QueuedMessageCount()6699 agentQueued = i.agent.QueuedMessageCount()
6688 }6700 }
6701+ continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
6689 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()6702 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
6690 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)6703 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
6704+ i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
6691 i.mu.Unlock()6705 i.mu.Unlock()
6692 if alertReason != "" {6706 if alertReason != "" {
6693 i.scheduleMainAlert(alertReason)6707 i.scheduleMainAlert(alertReason)
@@ -6704,6 +6718,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6704 switch {6718 switch {
6705 case hasNext:6719 case hasNext:
6706 i.startTurn(parent, next)6720 i.startTurn(parent, next)
6721+ case continueQueued:
6722+ i.startTurnRequest(parent, "", nil, true, false)
6707 case offer:6723 case offer:
6708 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)6724 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
6709 case recoverContextOverflow:6725 case recoverContextOverflow:

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 476

↑↓ moveenter openesc close