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