返回 DeepSeek-Reasonix
background_runtime.go
根目录 / desktop / background_runtime.go
1 package main
2
3 import (
4 "fmt"
5 "strings"
6 "time"
7
8 "reasonix/internal/control"
9 "reasonix/internal/workspacelease"
10 )
11
12 // ActiveWorkView is the structured Desktop contract for work that prevents a
13 // controller rebuild or a destructive tab action. Jobs is always a JSON array.
14 type ActiveWorkView struct {
15 Running bool `json:"running"`
16 PendingPrompt bool `json:"pendingPrompt"`
17 Cancellable bool `json:"cancellable"`
18 Jobs []JobView `json:"jobs"`
19 }
20
21 // JobCancelBatchView reports which requested jobs accepted cancellation. Both
22 // slices are initialized so Wails never sends null to React.
23 type JobCancelBatchView struct {
24 Cancelled []string `json:"cancelled"`
25 NotRunning []string `json:"notRunning"`
26 }
27
28 // BackgroundRuntimeView is one visible or detached runtime with active work.
29 // TabID is an opaque process-local handle; paths and session writer ids are
30 // deliberately omitted from this user-facing contract.
31 type BackgroundRuntimeView struct {
32 TabID string `json:"tabId"`
33 Title string `json:"title"`
34 Detached bool `json:"detached"`
35 Running bool `json:"running"`
36 PendingPrompt bool `json:"pendingPrompt"`
37 Jobs []JobView `json:"jobs"`
38 }
39
40 // WorkspaceConflictView describes a currently-waiting Delivery writer without
41 // exposing the lock path, process id, or session path.
42 type WorkspaceConflictView struct {
43 State string `json:"state"`
44 OwnerTabID string `json:"ownerTabId,omitempty"`
45 OwnerTitle string `json:"ownerTitle,omitempty"`
46 OwnerWork ActiveWorkView `json:"ownerWork"`
47 CanReveal bool `json:"canReveal"`
48 CanCreateWorktree bool `json:"canCreateWorktree"`
49 }
50
51 func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
52 view := ActiveWorkView{Jobs: []JobView{}}
53 if ctrl == nil {
54 return view
55 }
56 status := ctrl.RuntimeStatus()
57 view.Running = status.Running
58 view.PendingPrompt = status.PendingPrompt
59 view.Cancellable = status.Cancellable
60 for _, job := range ctrl.Jobs() {
61 view.Jobs = append(view.Jobs, JobView{
62 ID: job.ID, Kind: job.Kind, Label: job.Label,
63 Status: job.Status, StartedAt: job.StartedAt,
64 })
65 }
66 return view
67 }
68
69 func (v ActiveWorkView) active() bool {
70 return v.Running || v.PendingPrompt || len(v.Jobs) > 0
71 }
72
73 // ActiveWorkForTab returns the precise blocker state for one tab. It is a
74 // preflight aid only; rebuild paths still re-check active work atomically.
75 func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
76 return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
77 }
78
79 // CancelJobsForTab requests cancellation for a stable tab id. The job manager
80 // keeps cancelled-but-unwinding jobs visible until their done channels close.
81 func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
82 result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
83 seen := map[string]bool{}
84 for _, raw := range jobIDs {
85 id := strings.TrimSpace(raw)
86 if id == "" || seen[id] {
87 continue
88 }
89 seen[id] = true
90 cancelled, err := a.CancelJobForTab(tabID, id)
91 if err != nil {
92 return result, err
93 }
94 if cancelled {
95 result.Cancelled = append(result.Cancelled, id)
96 } else {
97 result.NotRunning = append(result.NotRunning, id)
98 }
99 }
100 return result, nil
101 }
102
103 type backgroundRuntimeSnapshot struct {
104 id string
105 ctrl control.SessionAPI
106 detached bool
107 title string
108 }
109
110 // BackgroundRuntimes returns every process-local runtime that still needs a
111 // visible recovery path, including runtimes detached by an explicit tab close.
112 func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
113 a.mu.RLock()
114 snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
115 seen := map[*WorkspaceTab]bool{}
116 for _, tab := range a.tabs {
117 if tab == nil || seen[tab] {
118 continue
119 }
120 seen[tab] = true
121 title := strings.TrimSpace(tab.TopicTitle)
122 if title == "" {
123 title = strings.TrimSpace(tab.Label)
124 }
125 snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
126 }
127 for _, tab := range a.detachedSessions {
128 if tab == nil || seen[tab] {
129 continue
130 }
131 seen[tab] = true
132 title := strings.TrimSpace(tab.TopicTitle)
133 if title == "" {
134 title = strings.TrimSpace(tab.Label)
135 }
136 snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
137 }
138 a.mu.RUnlock()
139
140 out := make([]BackgroundRuntimeView, 0, len(snapshots))
141 for _, snapshot := range snapshots {
142 work := activeWorkForController(snapshot.ctrl)
143 if !work.active() {
144 continue
145 }
146 out = append(out, BackgroundRuntimeView{
147 TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
148 Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
149 })
150 }
151 return out
152 }
153
154 func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
155 a.mu.RLock()
156 defer a.mu.RUnlock()
157 if strings.TrimSpace(tabID) == "" {
158 return a.activeCtrlLocked()
159 }
160 tab := a.tabByEventSinkIDLocked(tabID)
161 if tab == nil {
162 return nil
163 }
164 return tab.Ctrl
165 }
166
167 // RevealBackgroundRuntime activates a visible owner or reopens the exact
168 // detached session. It never reattaches by workspace alone.
169 func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
170 a.mu.RLock()
171 if tab := a.tabs[tabID]; tab != nil {
172 a.mu.RUnlock()
173 if err := a.SetActiveTab(tabID); err != nil {
174 return TabMeta{}, err
175 }
176 a.mu.RLock()
177 current := a.tabs[tabID]
178 if current == nil {
179 a.mu.RUnlock()
180 return TabMeta{}, fmt.Errorf("background task is no longer available")
181 }
182 meta := a.tabMeta(current, true)
183 a.mu.RUnlock()
184 return enrichTabMeta(meta), nil
185 }
186 tab := a.tabByEventSinkIDLocked(tabID)
187 if tab == nil || tab.Ctrl == nil {
188 a.mu.RUnlock()
189 return TabMeta{}, fmt.Errorf("background task is no longer available")
190 }
191 scope := tab.Scope
192 workspaceRoot := tab.WorkspaceRoot
193 topicID := tab.TopicID
194 sessionPath := tab.currentSessionPath()
195 a.mu.RUnlock()
196 if strings.TrimSpace(sessionPath) == "" {
197 return TabMeta{}, fmt.Errorf("background task session is unavailable")
198 }
199 return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
200 }
201
202 type workspaceLeaseReporter interface {
203 WorkspaceLeaseState() workspacelease.State
204 }
205
206 func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
207 if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
208 return reporter.WorkspaceLeaseState()
209 }
210 return workspacelease.State{}
211 }
212
213 // WorkspaceConflictForTab classifies the owner that a Delivery writer is
214 // currently waiting for. An acquired process-local owner is actionable; when no
215 // local owner matches, the OS lock is treated as external.
216 func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
217 empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
218 a.mu.RLock()
219 var target *WorkspaceTab
220 if strings.TrimSpace(tabID) == "" {
221 target = a.activeTabLocked()
222 } else {
223 target = a.tabByEventSinkIDLocked(tabID)
224 }
225 if target == nil {
226 a.mu.RUnlock()
227 return empty
228 }
229 targetCtrl := target.Ctrl
230 targetWorkspaceRoot := target.WorkspaceRoot
231 a.mu.RUnlock()
232 if targetCtrl == nil {
233 return empty
234 }
235 targetState := controllerWorkspaceLeaseState(targetCtrl)
236 if !targetState.Waiting || targetState.Acquired {
237 return empty
238 }
239 targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
240 if err != nil {
241 return empty
242 }
243 availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
244
245 a.mu.RLock()
246 type candidate struct {
247 id string
248 ctrl control.SessionAPI
249 root string
250 title string
251 }
252 candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
253 seen := map[*WorkspaceTab]bool{}
254 for _, tab := range a.runtimeTabsLocked() {
255 if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
256 continue
257 }
258 seen[tab] = true
259 title := strings.TrimSpace(tab.TopicTitle)
260 if title == "" {
261 title = strings.TrimSpace(tab.Label)
262 }
263 candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
264 }
265 a.mu.RUnlock()
266
267 for _, candidate := range candidates {
268 root, err := workspacelease.CanonicalWorkspace(candidate.root)
269 if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
270 continue
271 }
272 return WorkspaceConflictView{
273 State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
274 OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
275 CanCreateWorktree: availability.Available,
276 }
277 }
278 empty.State = "external"
279 empty.CanCreateWorktree = availability.Available
280 return empty
281 }
282
283 // RevealWorkspaceWriterForTab opens the exact process-local runtime identified
284 // by WorkspaceConflictForTab. External writers remain non-actionable.
285 func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
286 conflict := a.WorkspaceConflictForTab(tabID)
287 if conflict.State != "local" || conflict.OwnerTabID == "" {
288 return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
289 }
290 return a.RevealBackgroundRuntime(conflict.OwnerTabID)
291 }
292
293 const stopAndCloseGrace = 15 * time.Second
294
295 // CloseTabWithPolicy makes the old implicit detach behavior an explicit user
296 // choice. stop_and_close never removes the tab until all owned work is idle.
297 func (a *App) CloseTabWithPolicy(tabID, policy string) error {
298 switch strings.TrimSpace(policy) {
299 case "keep_running":
300 return a.closeTab(tabID, true)
301 case "stop_and_close":
302 ctrl := a.ctrlForRuntimeTabID(tabID)
303 if ctrl == nil {
304 return a.closeTab(tabID, false)
305 }
306 ctrl.Cancel()
307 jobs := ctrl.Jobs()
308 ids := make([]string, 0, len(jobs))
309 for _, job := range jobs {
310 ids = append(ids, job.ID)
311 }
312 if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
313 return err
314 }
315 deadline := time.NewTimer(stopAndCloseGrace)
316 defer deadline.Stop()
317 ticker := time.NewTicker(25 * time.Millisecond)
318 defer ticker.Stop()
319 for {
320 if !activeWorkForController(ctrl).active() {
321 return a.closeTab(tabID, false)
322 }
323 select {
324 case <-deadline.C:
325 return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
326 case <-ticker.C:
327 }
328 }
329 default:
330 return fmt.Errorf("unknown close policy %q", policy)
331 }
332 }
333
333 lines GO