返回 DeepSeek-Reasonix
autoresearch_manager.go
根目录 / internal / control / autoresearch_manager.go
1 package control
2
3 // legacyResearchArchive is a read-only compatibility boundary for Goal
4 // sidecars and prompts that still reference an old .reasonix/autoresearch
5 // task. New Goal runs never create, update, list, or expose those archives.
6
7 import (
8 "log/slog"
9 "strings"
10
11 "reasonix/internal/autoresearch"
12 )
13
14 type legacyResearchSetup struct {
15 // goal is the original objective recovered from task_spec.json when the
16 // user named an explicit archive path. Empty when no archive was referenced.
17 goal string
18 taskID string
19 blockReason string
20 notice string
21 explicit bool
22 }
23
24 type legacyResearchArchive struct {
25 store *autoresearch.Store
26 }
27
28 // prepare reads an explicitly referenced legacy task. It has no create path
29 // and never mutates the archive, even when validation fails.
30 func (m legacyResearchArchive) prepare(goal string) legacyResearchSetup {
31 taskID, found, parseErr := autoresearch.ExplicitTaskID(goal)
32 if !found {
33 return legacyResearchSetup{}
34 }
35 if parseErr != nil {
36 return legacyResearchSetup{explicit: true, blockReason: parseErr.Error()}
37 }
38 if m.store == nil {
39 return legacyResearchSetup{
40 explicit: true,
41 taskID: taskID,
42 blockReason: "legacy research archive is unavailable for this workspace",
43 }
44 }
45 original, err := m.loadGoalText(taskID)
46 if err != nil {
47 slog.Warn("controller: resume legacy autoresearch task", "err", err)
48 return legacyResearchSetup{explicit: true, taskID: taskID, blockReason: err.Error()}
49 }
50 return legacyResearchSetup{
51 goal: original,
52 taskID: taskID,
53 notice: "legacy research archive loaded: " + taskID,
54 explicit: true,
55 }
56 }
57
58 // loadGoalText returns the original objective stored in a historical archive.
59 func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) {
60 if m.store == nil {
61 return "", errLegacyArchiveUnavailable
62 }
63 task, err := m.store.LoadTask(taskID)
64 if err != nil {
65 return "", err
66 }
67 goal := strings.TrimSpace(task.Spec.Goal)
68 if goal == "" {
69 return "", errLegacyArchiveMissingGoal
70 }
71 return goal, nil
72 }
73
74 var (
75 errLegacyArchiveUnavailable = errString("legacy research archive is unavailable for this workspace")
76 errLegacyArchiveMissingGoal = errString("legacy research archive is missing goal text")
77 )
78
79 type errString string
80
81 func (e errString) Error() string { return string(e) }
82
83 func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup {
84 return c.legacyResearchArchive.prepare(goal)
85 }
86
87 func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
88 if legacy.taskID == "" {
89 goal, epoch, ok := c.goals.legacyArchiveBlockedState()
90 if ok {
91 setup := c.prepareLegacyResearchTask(goal)
92 if setup.explicit {
93 legacy = legacyGoalRestore{taskID: setup.taskID, epoch: epoch, explicit: true}
94 }
95 }
96 }
97 // A malformed explicit archive path has no safe task id to load. Keep the
98 // Controller-owned retry token so ResumeGoal cannot fall through to the
99 // ordinary Goal resume path and execute the raw path text as an objective.
100 if legacy.explicit && legacy.taskID == "" {
101 c.replaceLegacyRestore(legacy)
102 return true
103 }
104 if legacy.taskID == "" || strings.TrimSpace(c.goals.goalText()) != "" {
105 c.replaceLegacyRestore(legacyGoalRestore{})
106 return false
107 }
108 c.replaceLegacyRestore(legacy)
109 // Archived research todos remain only in the preserved legacy material. A
110 // resumed Goal starts a new turn and never passes them to persistence.
111 goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID)
112 if err != nil {
113 if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, err.Error()); ok {
114 _, _ = c.persistGoalStateAtEpoch(epoch)
115 c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch)
116 c.notice("legacy research archive resume failed: " + err.Error())
117 } else {
118 c.clearLegacyRestore(legacy.taskID, legacy.epoch)
119 }
120 return true
121 }
122 if strings.TrimSpace(c.goals.goalText()) == "" {
123 if epoch, ok := c.goals.fillGoalTextIfEmpty(legacy.epoch, goal); ok {
124 _, persistErr := c.persistGoalStateAtEpoch(epoch)
125 if persistErr != nil {
126 reason := "persist migrated legacy Goal: " + persistErr.Error()
127 if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, reason); blocked {
128 c.replaceLegacyRestore(legacyGoalRestore{taskID: legacy.taskID, epoch: blockedEpoch})
129 c.notice("legacy research archive resume failed: " + reason)
130 } else {
131 c.clearLegacyRestore(legacy.taskID, legacy.epoch)
132 }
133 } else {
134 c.goals.clearLegacyTaskID(epoch)
135 c.clearLegacyRestore(legacy.taskID, legacy.epoch)
136 }
137 } else {
138 c.clearLegacyRestore(legacy.taskID, legacy.epoch)
139 }
140 }
141 return true
142 }
143
144 func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
145 goal, epoch, blocked := c.goals.legacyArchiveBlockedState()
146 if !blocked {
147 return false, false
148 }
149 legacy, hasLegacy := c.legacyRestoreSnapshot()
150 if !hasLegacy || legacy.epoch != epoch || legacy.taskID == "" {
151 // A blocked sidecar without a Controller-owned archive identity is a
152 // fail-closed migration boundary after restart. Never resume raw text.
153 return true, false
154 }
155 taskID := legacy.taskID
156 setup := c.prepareLegacyResearchTask(goal)
157 resolvedGoal, reason := setup.goal, setup.blockReason
158 if !setup.explicit {
159 var err error
160 resolvedGoal, err = c.legacyResearchArchive.loadGoalText(taskID)
161 if err != nil {
162 reason = err.Error()
163 }
164 } else if setup.taskID != taskID {
165 reason = "legacy research archive identity changed during retry"
166 }
167 if reason != "" || strings.TrimSpace(resolvedGoal) == "" {
168 if reason == "" {
169 reason = "legacy research archive could not be recovered"
170 }
171 if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, reason); applied {
172 _, _ = c.persistGoalStateAtEpoch(nextEpoch)
173 c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: nextEpoch})
174 }
175 c.notice("legacy research archive resume failed: " + reason)
176 return true, false
177 }
178 resumedEpoch, applied := c.goals.resumeLegacyArchive(epoch, resolvedGoal)
179 if !applied {
180 c.replaceLegacyRestore(legacyGoalRestore{})
181 return true, false
182 }
183 persisted, persistErr := c.persistGoalStateAtEpoch(resumedEpoch)
184 if persistErr != nil {
185 reason := "persist migrated legacy Goal: " + persistErr.Error()
186 if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, reason); blocked {
187 c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: blockedEpoch})
188 c.notice("legacy research archive resume failed: " + reason)
189 } else {
190 c.replaceLegacyRestore(legacyGoalRestore{})
191 }
192 return true, false
193 }
194 if !persisted {
195 return true, false
196 }
197 c.goals.clearLegacyTaskID(resumedEpoch)
198 c.replaceLegacyRestore(legacyGoalRestore{})
199 if setup.notice != "" {
200 c.notice(setup.notice)
201 }
202 if c.executor != nil {
203 c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
204 }
205 return true, true
206 }
207
207 lines GO