返回 DeepSeek-Reasonix
turn_lifecycle.go
根目录 / internal / cli / turn_lifecycle.go
1 package cli
2
3 import (
4 "time"
5
6 "reasonix/internal/control"
7 "reasonix/internal/event"
8
9 tea "charm.land/bubbletea/v2"
10 )
11
12 // startTurn commits the user bubble to scrollback, resets the turn accumulator,
13 // and kicks off the controller turn. `sent` goes to the model uncomposed (the
14 // controller frames it with any plan marker); `displayed` is what the transcript
15 // shows, and `restore` is what Esc puts back while the bubble is still deferred.
16 func (m *chatTUI) startTurn(sent, displayed, restore string) tea.Cmd {
17 return m.startTurnWithRaw(sent, displayed, restore, sent)
18 }
19
20 // startTurnWithRaw is startTurn plus an explicit unresolved user prompt. This
21 // keeps reference-expanded model input separate from the text shown/restored by
22 // the frontend.
23 func (m *chatTUI) startTurnWithRaw(sent, displayed, restore, raw string) tea.Cmd {
24 return m.startControllerTurnWithQueue(displayed, restore, raw, func(ctrl control.SessionAPI) { ctrl.SendWithRaw(sent, raw) })
25 }
26
27 // startControllerTurn owns the TUI-side turn setup for controller entry points.
28 // Most prompts use SendWithRaw; slash-invoked skills use SubmitDisplay so the
29 // controller can choose inline vs isolated subagent execution from the live
30 // skill's RunAs metadata without the TUI reimplementing that policy.
31 func (m *chatTUI) startControllerTurn(displayed, restore string, start func(control.SessionAPI)) tea.Cmd {
32 return m.startControllerTurnWithQueue(displayed, restore, displayed, start)
33 }
34
35 func (m *chatTUI) startControllerTurnWithQueue(displayed, restore, queued string, start func(control.SessionAPI)) tea.Cmd {
36 return m.prepareControllerTurn(controllerTurnIntent{displayed, restore, queued, start}, false)
37 }
38
39 func (m *chatTUI) prepareControllerTurn(intent controllerTurnIntent, settingsChecked bool) tea.Cmd {
40 displayed, restore, queued, start := intent.displayed, intent.restore, intent.queued, intent.start
41 if m.sessionReclaimed || m.takeover != nil && m.takeover.Returned() {
42 m.notice(sessionReclaimedNotice)
43 return nil
44 }
45 if m.takeover != nil && m.takeover.Reclaiming() {
46 m.notice("the remote side is taking this session back; new input is disabled")
47 return nil
48 }
49 if !settingsChecked && m.ctrl != nil && !m.ctrl.Running() {
50 if cmd, checking := m.checkTurnModelSettings(intent); checking {
51 return cmd
52 }
53 }
54 if auth, ok := m.ctrl.(interface {
55 AuthenticationState() control.AuthenticationState
56 }); ok {
57 state := auth.AuthenticationState()
58 if !state.Ready() {
59 err := &control.AuthenticationError{State: state}
60 m.notice(err.Error())
61 if m.input.Value() == "" {
62 m.input.SetValue(restore)
63 m.growInputToFit()
64 }
65 return nil
66 }
67 }
68 // The composer can read idle while the controller already runs a
69 // dispatched queued follow-up (TurnStarted not yet ingested): queue rather
70 // than race the admission guard's silent drop (#9575).
71 if m.ctrl != nil && m.ctrl.Running() {
72 receipt, err := m.enqueueFollowup(displayed, queued)
73 if err != nil {
74 m.notice("queue: " + err.Error())
75 if m.input.Value() == "" {
76 m.input.SetValue(restore)
77 m.growInputToFit()
78 }
79 return nil
80 }
81 m.notice("durable follow-up queued #" + shortID(receipt.ItemID) + " — will run when idle")
82 m.clearQueuedPastes(restore)
83 return nil
84 }
85 // Flush any half-streamed leftover before the new turn (defensive).
86 m.commitReasoning()
87 m.commitPending()
88
89 // Echo the user bubble to scrollback now so it appears the instant Enter is
90 // pressed, not when the first packet lands: Esc before the reply pops it
91 // back off and restores the text, leaving nothing stranded.
92 m.pendingRestore = restore
93 m.pendingPastes = m.pasteLabelsIn(restore)
94 m.bubbleStartIdx = len(m.transcript)
95 m.commitLine("") // blank line separating turns
96 m.commitTranscriptSource(transcriptSource{
97 kind: transcriptSourceUser, raw: displayed, planMode: m.planMode,
98 })
99 m.bubblePending = true
100 m.turnDiscarded = false
101
102 m.state = tuiRunning
103 m.runStart = time.Now()
104 m.elapsed = 0
105 m.turnTokens = 0
106 // The controller owns the run goroutine, its context, and cancellation; it
107 // streams events to eventCh and emits TurnDone when the turn settles.
108 m.noteWatchdogRunning()
109 start(m.ctrl)
110 return m.startRunningTicks()
111 }
112
113 // confirmBubbleSent marks the already-echoed user bubble as really sent once a
114 // turn's first response packet arrives, so Esc no longer un-sends it (it cancels
115 // the stream instead). Also called defensively at turn end. A no-op once confirmed.
116 func (m *chatTUI) confirmBubbleSent() {
117 if !m.bubblePending {
118 return
119 }
120 m.bubblePending = false
121 m.pendingRestore = ""
122 }
123
124 // drainAgentEvents ingests the events already buffered behind the first one:
125 // the producing goroutine has exited (a Cmd reads the channel once), so one
126 // re-wrap covers the whole batch instead of one per event.
127 type agentEventDrain struct {
128 turnDone, gitMaybeChanged bool
129 cmds []tea.Cmd
130 }
131
132 func (m *chatTUI) consumeAgentEvent(e event.Event, drained *agentEventDrain) {
133 // Record before ingest so TurnDone still counts as an active heartbeat.
134 m.noteWatchdogHeartbeat(watchdogAgentSource(e.Kind))
135 if e.Kind == event.TurnStarted {
136 m.todos = nil
137 m.todosDismissed = false
138 if cmd := m.noteControllerTurnStarted(); cmd != nil {
139 drained.cmds = append(drained.cmds, cmd)
140 }
141 }
142 m.ingestEvent(e)
143 drained.turnDone = drained.turnDone || e.Kind == event.TurnDone
144 drained.gitMaybeChanged = drained.gitMaybeChanged || e.Kind == event.ToolResult && !e.Tool.ReadOnly
145 }
146
147 func (m *chatTUI) drainAgentEvents(first event.Event) agentEventDrain {
148 var drained agentEventDrain
149 m.consumeAgentEvent(first, &drained)
150 for range maxEventDrain {
151 select {
152 case e2 := <-m.eventCh:
153 m.consumeAgentEvent(e2, &drained)
154 default:
155 return drained
156 }
157 }
158 return drained
159 }
160
161 // noteControllerTurnStarted enters running state for a turn the TUI did not
162 // submit itself — the controller auto-dispatching a queued follow-up. Without
163 // it the composer reads as ready while the dispatched turn streams, so an
164 // Enter races the dispatch (silently dropped, or preempting the queue) and the
165 // elapsed-tick heartbeat chain stays dead (#9575).
166 func (m *chatTUI) noteControllerTurnStarted() tea.Cmd {
167 if m.state == tuiRunning {
168 return nil
169 }
170 m.state = tuiRunning
171 m.runStart = time.Now()
172 m.elapsed = 0
173 m.turnTokens = 0
174 m.noteWatchdogRunning()
175 return m.startRunningTicks()
176 }
177
178 func (m *chatTUI) startRunningTicks() tea.Cmd {
179 m.elapsedTickGeneration++
180 return tea.Batch(m.spinner.Tick, elapsedTick(m.elapsedTickGeneration))
181 }
182
183 func (m *chatTUI) clearQueuedPastes(restore string) {
184 labels := m.pasteLabelsIn(restore)
185 if len(labels) == 0 {
186 return
187 }
188 queued := make(map[string]struct{}, len(labels))
189 for _, label := range labels {
190 queued[label] = struct{}{}
191 }
192 kept := m.pastedBlocks[:0]
193 for _, block := range m.pastedBlocks {
194 if _, ok := queued[block.label]; !ok {
195 kept = append(kept, block)
196 }
197 }
198 m.pastedBlocks = kept
199 }
200
200 lines GO