返回 DeepSeek-Reasonix
goal.go
根目录 / internal / control / goal.go
1 package control
2
3 import (
4 "crypto/rand"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "os"
9 "strings"
10 "sync"
11 "time"
12
13 "reasonix/internal/evidence"
14 fileencoding "reasonix/internal/fileutil/encoding"
15 "reasonix/internal/store"
16 )
17
18 const (
19 unlimitedGoalTurns = -1
20
21 // Bound the persisted novelty window. Signatures are compact hashes, and
22 // retaining the most recent window is enough to stop short repeat cycles
23 // without allowing an unbounded Goal sidecar.
24 maxGoalProgressEvidence = 512
25 )
26
27 // Budget class aliases remain as sidecar/CLI compatibility metadata only.
28 const (
29 budgetClassSimple = BudgetClassSimple
30 budgetClassWrite = BudgetClassWrite
31 budgetClassResearch = BudgetClassResearch
32 )
33
34 // Stop causes distinguish a safe pause from a genuine block. Removed numeric
35 // causes remain migration-only constants so old sidecars can be normalized.
36 const (
37 stopCauseBudgetTurns = "budget_turns" // legacy; the class-derived turn quota is gone
38 stopCauseBudgetSpend = "budget_spend"
39 stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
40 stopCauseNoProgress = "no_progress" // legacy; never written by current runtime
41 stopCauseGoalRunBudget = "goal_run_budget" // legacy; the per-Run round ceiling is gone
42 stopCauseGoalStuck = "goal_stuck"
43 stopCauseEvaluator = "evaluator_unavailable"
44 stopCauseLegacyArchive = "legacy_archive"
45 stopCauseManual = "manual"
46 )
47
48 // budgetClassForLegacyMode translates old sidecars and deprecated CLI flags at
49 // the compatibility boundary. The active Goal runtime stores only budgetClass.
50 func budgetClassForLegacyMode(goal string, researchMode GoalResearchMode) string {
51 switch researchMode {
52 case GoalResearchOn:
53 return budgetClassResearch
54 case GoalResearchOff:
55 if GoalNeedsWriteBudget(goal) {
56 return budgetClassWrite
57 }
58 return budgetClassSimple
59 default:
60 return ClassifyGoalBudget(goal)
61 }
62 }
63
64 // goalMachine owns the active goal FSM and its persistence. It is a strict
65 // leaf: methods take only machine locks and never call back into Controller.
66 // advance() takes already-gathered inputs so no disk/executor work holds mu.
67 type goalMachine struct {
68 // mu guards the FSM fields below; every critical section under it is short
69 // and non-blocking (no disk I/O, no executor calls).
70 mu sync.Mutex
71 goal string
72 status string
73 scopeID string
74 deliveryCheckpoint evidence.DeliveryCheckpoint
75 block string
76 strict bool
77 continuationEpoch uint64
78
79 tokenBudget int // configured ceiling for an unattended loop; 0 = unbounded
80
81 // Runtime statistics and optional user-selected spend state, persisted
82 // across turns and restarts. turnsUsed and noProgressTurns are observational;
83 // tokensLimit is non-zero only when the user configured a Goal token budget.
84 budgetClass string
85 turnsUsed int
86 turnsLimit int
87 tokensUsed int
88 requestsUsed int
89 workDurationMs int64
90 tokensLimit int // always 0 at runtime; deprecated hard limit
91 noProgressTurns int
92 noProgressLimit int
93 lastContinuationReason string
94 launch goalLaunchState
95 goalActivationState
96 lastEvaluatorReason string
97 stopCause string
98 budgetExtensions int // deprecated historical sidecar field
99 progressEvidence []string
100 // stateExtra preserves fields written by a newer peer during read/modify/
101 // write cycles. Known current fields always win on serialization.
102 stateExtra map[string]json.RawMessage
103 // legacyTaskID is retained only while a historical AutoResearch archive is
104 // awaiting migration. It is serialized on fail-closed blocked sidecars so a
105 // restart can retry the migration without treating the raw archive path as a
106 // new Goal.
107 legacyTaskID string
108
109 // statePath is the persisted goal-state sidecar; empty disables persistence.
110 statePath string
111 // writeMu serializes goal-state disk writes so concurrent saves don't
112 // interleave or land out of order. Taken OFF mu by writeState.
113 writeMu sync.Mutex
114 }
115
116 // goalState is the serializable form of a running goal. New fields are
117 // safe-to-omit JSON: old readers ignore them, and restoreFromState re-derives
118 // defaults when they are missing.
119 type goalState struct {
120 Goal string `json:"goal,omitempty"`
121 Status string `json:"status,omitempty"`
122 ResearchMode GoalResearchMode `json:"researchMode,omitempty"`
123 AutoResearchTaskID string `json:"autoResearchTaskID,omitempty"`
124 ScopeID string `json:"scopeID,omitempty"`
125 DeliveryCheckpoint evidence.DeliveryCheckpoint `json:"deliveryCheckpoint,omitempty"`
126 Turns int `json:"turns,omitempty"`
127 Blocks int `json:"blocks,omitempty"`
128 Block string `json:"block,omitempty"`
129 Strict bool `json:"strict,omitempty"`
130 Todos []evidence.TodoItem `json:"todos,omitempty"`
131
132 BudgetClass string `json:"budgetClass,omitempty"`
133 TurnsUsed int `json:"turnsUsed,omitempty"`
134 TurnsLimit int `json:"turnsLimit,omitempty"`
135 TokensUsed int `json:"tokensUsed,omitempty"`
136 RequestsUsed int `json:"requestsUsed,omitempty"`
137 WorkDurationMs int64 `json:"workDurationMs,omitempty"`
138 TokensLimit int `json:"tokensLimit,omitempty"`
139 NoProgressTurns int `json:"noProgressTurns,omitempty"`
140 NoProgressLimit int `json:"noProgressLimit,omitempty"`
141 LastContinuationReason string `json:"lastContinuationReason,omitempty"`
142 LastEvaluatorReason string `json:"lastEvaluatorReason,omitempty"`
143 StopCause string `json:"stopCause,omitempty"`
144 BudgetExtensions int `json:"budgetExtensions,omitempty"`
145 ProgressEvidence []string `json:"progressEvidence,omitempty"`
146 }
147
148 // goalStatePath derives a session's persisted goal-state sidecar.
149 func goalStatePath(sessionPath string) string {
150 return store.SessionGoalState(sessionPath)
151 }
152
153 func (g *goalMachine) setStatePath(path string) {
154 g.mu.Lock()
155 g.statePath = path
156 g.mu.Unlock()
157 }
158
159 // snapshot returns the fields Compose injects into outgoing turns.
160 func (g *goalMachine) snapshot() (goal, status string) {
161 g.mu.Lock()
162 defer g.mu.Unlock()
163 if g.disarmed && g.status == GoalStatusRunning {
164 return g.goal, GoalStatusStopped
165 }
166 return g.goal, g.status
167 }
168
169 func (g *goalMachine) goalText() string {
170 g.mu.Lock()
171 defer g.mu.Unlock()
172 return g.goal
173 }
174
175 // continuationToken captures the Goal lifecycle that owns an outgoing turn.
176 // The matching assistant output may advance the FSM only while this epoch is
177 // still current.
178 func (g *goalMachine) continuationToken() uint64 {
179 g.mu.Lock()
180 defer g.mu.Unlock()
181 return g.continuationEpoch
182 }
183
184 func (g *goalMachine) deliveryScope() (id, task string, ok bool) {
185 g.mu.Lock()
186 defer g.mu.Unlock()
187 if g.disarmed || strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
188 return "", "", false
189 }
190 if g.scopeID == "" {
191 g.scopeID = newGoalScopeID()
192 }
193 return g.scopeID, g.goal, true
194 }
195
196 func newGoalScopeID() string {
197 var raw [16]byte
198 if _, err := rand.Read(raw[:]); err == nil {
199 return fmt.Sprintf("goal-%x", raw[:])
200 }
201 return fmt.Sprintf("goal-fallback-%d-%d", os.Getpid(), time.Now().UnixNano())
202 }
203
204 // active reports whether a goal is currently running.
205 func (g *goalMachine) active() bool {
206 g.mu.Lock()
207 defer g.mu.Unlock()
208 return !g.disarmed && strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning
209 }
210
211 // statusForDisplay maps the empty zero status to "stopped" for frontends.
212 func (g *goalMachine) statusForDisplay() string {
213 g.mu.Lock()
214 defer g.mu.Unlock()
215 if g.status == "" || g.disarmed && g.status == GoalStatusRunning {
216 return GoalStatusStopped
217 }
218 return g.status
219 }
220
221 // set installs a session-scoped goal (or clears it when goal is empty), resets
222 // the per-goal runtime counters, and returns the state to persist. ok is
223 // false (no persistence) when the goal is unchanged or no state path is
224 // configured.
225 func (g *goalMachine) set(goal, preferredBudgetClass string) (string, []byte, bool) {
226 goal = strings.TrimSpace(goal)
227 if goal != "" && preferredBudgetClass == "" {
228 preferredBudgetClass = ClassifyGoalBudget(goal)
229 }
230 g.mu.Lock()
231 defer g.mu.Unlock()
232 if goal != "" && g.goal == goal && g.status == GoalStatusRunning {
233 if !g.disarmed {
234 if g.budgetClass != preferredBudgetClass {
235 g.budgetClass = preferredBudgetClass
236 return g.buildStateLocked()
237 }
238 return "", nil, false
239 }
240 // Explicitly starting the restored objective preserves its identity and
241 // actual accumulated usage, rather than creating a fresh budget.
242 g.disarmed = false
243 g.continuationEpoch++
244 return g.buildStateLocked()
245 }
246 g.installGoalLocked(goal, preferredBudgetClass)
247 return g.buildStateLocked()
248 }
249
250 // setLegacyArchiveBlocked atomically installs and blocks an explicit legacy
251 // archive goal. A concurrent Goal replacement cannot be blocked between two
252 // separate FSM mutations.
253 func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason string) (string, []byte, bool) {
254 return g.setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetClass, reason, "")
255 }
256
257 func (g *goalMachine) setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetClass, reason, taskID string) (string, []byte, bool) {
258 goal = strings.TrimSpace(goal)
259 taskID = strings.TrimSpace(taskID)
260 if goal != "" && preferredBudgetClass == "" {
261 preferredBudgetClass = ClassifyGoalBudget(goal)
262 }
263 g.mu.Lock()
264 defer g.mu.Unlock()
265 g.installGoalLocked(goal, preferredBudgetClass)
266 if goal != "" {
267 g.status = GoalStatusBlocked
268 }
269 g.stopCause = stopCauseLegacyArchive
270 g.block = clipGoalReason(reason)
271 g.legacyTaskID = taskID
272 return g.buildStateLocked()
273 }
274
275 func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
276 g.disarmed = false
277 g.continuationEpoch++
278 g.turnsUsed, g.tokensUsed, g.requestsUsed, g.noProgressTurns = 0, 0, 0, 0
279 g.workDurationMs = 0
280 g.block = ""
281 g.lastContinuationReason, g.lastEvaluatorReason = "", ""
282 g.stopCause = ""
283 g.budgetExtensions = 0
284 g.progressEvidence = nil
285 if goal == "" {
286 g.goal, g.status = "", GoalStatusStopped
287 g.budgetClass = ""
288 g.turnsLimit = 0
289 g.noProgressLimit = 0
290 g.scopeID = ""
291 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
292 } else {
293 g.goal, g.status = goal, GoalStatusRunning
294 g.scopeID = newGoalScopeID()
295 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
296 g.budgetClass = preferredBudgetClass
297 g.turnsLimit = unlimitedGoalTurns
298 g.tokensLimit = g.tokenBudget
299 g.noProgressLimit = 0
300 }
301 // Installing a normal Goal always abandons any pending legacy migration.
302 g.legacyTaskID = ""
303 }
304
305 func (g *goalMachine) setStrict(strict bool) (string, []byte, bool) {
306 g.mu.Lock()
307 defer g.mu.Unlock()
308 g.strict = strict
309 return g.buildStateLocked()
310 }
311
312 // stop transitions a running goal to the given terminal status and clears the
313 // transient runtime bookkeeping. stopCause is cleared: a host stop is not a
314 // safe pause.
315 func (g *goalMachine) stop(status string) (string, []byte, bool) {
316 g.mu.Lock()
317 defer g.mu.Unlock()
318 g.continuationEpoch++
319 if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
320 g.status = status
321 }
322 g.stopCause = ""
323 g.noProgressTurns = 0
324 return g.buildStateLocked()
325 }
326
327 // pauseFor transitions a running goal to a safe pause: status blocked plus a
328 // stop cause, keeping every runtime counter for a later resume.
329 func (g *goalMachine) pauseFor(stopCause, reason string) (string, []byte, bool) {
330 g.mu.Lock()
331 defer g.mu.Unlock()
332 g.continuationEpoch++
333 if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
334 g.status = GoalStatusBlocked
335 }
336 g.stopCause = stopCause
337 if reason != "" {
338 g.block = reason
339 }
340 return g.buildStateLocked()
341 }
342
343 // resume re-enters a recoverable blocked/stopped goal without resetting scope
344 // or runtime history. Continuous Goals never extend a numeric quota.
345 func (g *goalMachine) resume() (path string, data []byte, persist, resumed bool) {
346 g.mu.Lock()
347 defer g.mu.Unlock()
348 if g.stopCause == stopCauseLegacyArchive {
349 // A legacy archive block is recoverable only through the read-only
350 // archive boundary; never reinterpret it as an ordinary Goal resume.
351 return "", nil, false, false
352 }
353 if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete {
354 return "", nil, false, false
355 }
356 // A user-selected spend pause grants one fresh configured slice. Usage
357 // remains cumulative; only the absolute threshold moves forward.
358 spentBudget := g.stopCause == stopCauseBudgetSpend
359 g.continuationEpoch++
360 g.status = GoalStatusRunning
361 g.disarmed = false
362 g.block = ""
363 g.stopCause = ""
364 g.noProgressTurns = 0
365 g.turnsLimit = unlimitedGoalTurns
366 g.noProgressLimit = 0
367 g.budgetExtensions = 0
368 g.grantSpendSliceLocked(spentBudget)
369 if g.scopeID == "" {
370 g.scopeID = newGoalScopeID()
371 }
372 path, data, persist = g.buildStateLocked()
373 return path, data, persist, true
374 }
375
376 func (g *goalMachine) setDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint) (string, []byte, bool) {
377 g.mu.Lock()
378 defer g.mu.Unlock()
379 if g.scopeID == "" || checkpoint.ScopeID != g.scopeID {
380 return "", nil, false
381 }
382 g.deliveryCheckpoint = checkpoint
383 return g.buildStateLocked()
384 }
385
386 func (g *goalMachine) deliveryState() evidence.DeliveryCheckpoint {
387 g.mu.Lock()
388 defer g.mu.Unlock()
389 return g.deliveryCheckpoint
390 }
391
392 // buildStateLocked marshals the current goal state for persistence. The caller
393 // holds mu; this only reads in-memory state, never touching disk. Returns ok=false
394 // when persistence is disabled (no state path). The matching writeState does the
395 // disk write OFF mu so the per-turn save can't stall a status poll.
396 func (g *goalMachine) buildStateLocked() (path string, data []byte, ok bool) {
397 if g.statePath == "" {
398 return "", nil, false
399 }
400 b, ok := g.marshalStateLocked()
401 return g.statePath, b, ok
402 }
403
404 func (g *goalMachine) eventState() ([]byte, bool) {
405 g.mu.Lock()
406 defer g.mu.Unlock()
407 return g.marshalStateLocked()
408 }
409
410 func (g *goalMachine) marshalStateLocked() ([]byte, bool) {
411 state := goalState{
412 Goal: g.goal,
413 Status: g.status,
414 ScopeID: g.scopeID,
415 DeliveryCheckpoint: g.deliveryCheckpoint,
416 Turns: g.turnsUsed,
417 Block: g.block,
418 Strict: g.strict,
419 // Todos is intentionally omitted. Legacy sidecars remain readable, but
420 // turn-local progress is never persisted with a Goal.
421 BudgetClass: g.budgetClass,
422 TurnsUsed: g.turnsUsed,
423 TurnsLimit: g.turnsLimit,
424 TokensUsed: g.tokensUsed,
425 RequestsUsed: g.requestsUsed,
426 WorkDurationMs: g.workDurationMs,
427 TokensLimit: g.tokensLimit,
428 NoProgressTurns: g.noProgressTurns,
429 NoProgressLimit: g.noProgressLimit,
430 LastContinuationReason: g.lastContinuationReason,
431 LastEvaluatorReason: g.lastEvaluatorReason,
432 StopCause: g.stopCause,
433 BudgetExtensions: g.budgetExtensions,
434 ProgressEvidence: append([]string(nil), g.progressEvidence...),
435 }
436 // GoalResearchOff is a downgrade fence for ordinary Goal sidecars. A
437 // fail-closed legacy migration keeps its task identity and compatibility mode
438 // until the archive has been validated and the Goal-only state is committed.
439 if g.legacyTaskID != "" && g.status == GoalStatusBlocked && g.stopCause == stopCauseLegacyArchive {
440 state.AutoResearchTaskID = g.legacyTaskID
441 state.ResearchMode = GoalResearchOn
442 } else {
443 state.ResearchMode = GoalResearchOff
444 }
445 b, err := marshalGoalState(state, g.stateExtra)
446 if err != nil {
447 slog.Warn("controller: marshal goal state", "err", err)
448 return nil, false
449 }
450 return b, true
451 }
452
453 // writeState preserves the existing best-effort behavior for background Goal
454 // progress. Callers that need transactional persistence use writeStateErr.
455 func (g *goalMachine) writeState(path string, data []byte) {
456 if err := g.writeStateErr(path, data); err != nil {
457 slog.Warn("controller: write goal state", "err", err)
458 }
459 }
460
461 // restoreFromState reloads Goal state from the sidecar. The sidecar is
462 // authoritative; active Goals are normalized to continuous-runtime sentinels.
463 // migrated means path/data were atomically rewritten (without a provider call).
464 // legacyTaskID is returned only
465 // so Controller can fill missing goal text from a historical archive.
466 func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacy legacyGoalRestore) {
467 if strings.TrimSpace(sessionPath) == "" {
468 return "", nil, false, legacyGoalRestore{}
469 }
470 // Ensure write path is bound even when the controller rebuilds.
471 if g.statePath == "" {
472 g.setStatePath(goalStatePath(sessionPath))
473 }
474 raw, err := fileencoding.ReadFileUTF8(goalStatePath(sessionPath))
475 if err != nil {
476 if !os.IsNotExist(err) {
477 slog.Warn("controller: read goal state", "err", err)
478 }
479 return "", nil, false, legacyGoalRestore{}
480 }
481 var state goalState
482 if err := json.Unmarshal(raw, &state); err != nil {
483 slog.Warn("controller: parse goal state", "err", err)
484 return "", nil, false, legacyGoalRestore{}
485 }
486 legacy = g.restoreDecodedState(raw, state)
487 return "", nil, false, legacy
488 }
489
490 // restoreGoalEvent installs a persisted v3 goal projection without reviving
491 // an execution loop. Goal events retain objective, status and budgets, while
492 // Todo remains a separate current-turn projection.
493 func (g *goalMachine) restoreGoalEvent(raw []byte) error {
494 if len(raw) == 0 {
495 raw = []byte(`{}`)
496 }
497 var state goalState
498 if err := json.Unmarshal(raw, &state); err != nil {
499 return err
500 }
501 state.Todos = nil
502 g.restoreDecodedState(raw, state)
503 return nil
504 }
505
506 func (g *goalMachine) restoreDecodedState(raw []byte, state goalState) legacyGoalRestore {
507 g.mu.Lock()
508 defer g.mu.Unlock()
509 g.stateExtra = goalStateUnknownFields(raw)
510 delete(g.stateExtra, "todos")
511 delete(g.stateExtra, "todo")
512 g.goal = strings.TrimSpace(state.Goal)
513 g.disarmed = true
514 g.status = state.Status
515 if g.status == "" {
516 g.status = GoalStatusStopped
517 }
518 // Legacy task identity is migration-only compatibility data. It is returned to
519 // the Controller's archive boundary and retained in the machine only while a
520 // fail-closed migration remains pending.
521 legacy := legacyGoalRestore{
522 taskID: strings.TrimSpace(state.AutoResearchTaskID),
523 }
524 // A task id is pending only when the sidecar has no Goal text. A legacy
525 // sidecar that already contains an objective can be migrated directly and
526 // must serialize as ordinary Goal state on the first write.
527 if g.goal == "" {
528 g.legacyTaskID = legacy.taskID
529 } else {
530 g.legacyTaskID = ""
531 }
532 g.scopeID = strings.TrimSpace(state.ScopeID)
533 if g.scopeID == "" {
534 g.scopeID = strings.TrimSpace(state.DeliveryCheckpoint.ScopeID)
535 }
536 if g.goal != "" && g.scopeID == "" {
537 g.scopeID = newGoalScopeID()
538 }
539 g.deliveryCheckpoint = state.DeliveryCheckpoint
540 if g.scopeID == "" {
541 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
542 } else if g.deliveryCheckpoint.ScopeID == "" {
543 g.deliveryCheckpoint.ScopeID = g.scopeID
544 } else if g.deliveryCheckpoint.ScopeID != g.scopeID {
545 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
546 }
547 g.block = state.Block
548 g.strict = state.Strict
549 g.stopCause = state.StopCause
550 g.budgetExtensions = state.BudgetExtensions
551 g.progressEvidence, _ = mergeGoalProgressEvidence(nil, state.ProgressEvidence)
552 g.lastContinuationReason = state.LastContinuationReason
553 g.lastEvaluatorReason = state.LastEvaluatorReason
554 // Old sidecars carry Turns (pre-budget counting); treat it as turn usage.
555 g.turnsUsed = state.TurnsUsed
556 if g.turnsUsed == 0 && state.Turns > 0 {
557 g.turnsUsed = state.Turns
558 }
559 g.tokensUsed = state.TokensUsed
560 g.requestsUsed = state.RequestsUsed
561 g.workDurationMs = state.WorkDurationMs
562 g.budgetClass = normalizeBudgetClass(g.goal, state.BudgetClass, state.ResearchMode)
563 g.turnsLimit = state.TurnsLimit
564 g.noProgressTurns = state.NoProgressTurns
565 g.noProgressLimit = state.NoProgressLimit
566 g.tokensLimit = state.TokensLimit
567 // Normalize in memory only. Reading history must not write a sidecar;
568 // the next ordinary save persists compatibility values under its lease.
569 g.normalizeContinuousState(state.ResearchMode, legacy.taskID)
570 g.continuationEpoch++
571 legacy.epoch = g.continuationEpoch
572 return legacy
573 }
574
575 // clipGoalReason bounds a recorded reason for storage and display.
576 func clipGoalReason(reason string) string {
577 reason = strings.TrimSpace(reason)
578 const max = 400
579 if r := []rune(reason); len(r) > max {
580 return string(r[:max]) + "..."
581 }
582 return reason
583 }
584
585 // ShortGoalForNotice collapses whitespace and truncates a goal for one-line UI.
586 func ShortGoalForNotice(goal string) string {
587 goal = strings.Join(strings.Fields(goal), " ")
588 runes := []rune(goal)
589 const max = 160
590 if len(runes) <= max {
591 return goal
592 }
593 return string(runes[:max]) + "..."
594 }
595
596 // persistGoalState writes a freshly built goal state to disk, off c.mu. The
597 // executor guard preserves the original behavior of skipping persistence when
598 // no executor is attached.
599 func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
600 if !ok && c.sessionEngineEnabled() {
601 data, ok = c.goals.eventState()
602 }
603 if !ok || c.executor == nil {
604 return
605 }
606 eventData := goalEventPayload(data)
607 if err := c.appendDomainState("goal/state", eventData, "goal-update"); err != nil {
608 slog.Warn("controller: append goal state event", "err", err)
609 c.failTurnEventLedger(err)
610 return
611 }
612 c.goals.writeState(path, data)
613 }
614
615 func goalEventPayload(data []byte) []byte {
616 var state map[string]json.RawMessage
617 if json.Unmarshal(data, &state) != nil {
618 return data
619 }
620 delete(state, "todos")
621 delete(state, "todo")
622 delete(state, "activeForm")
623 delete(state, "step_id")
624 delete(state, "auto_continue")
625 delete(state, "autoContinue")
626 clean, err := json.Marshal(state)
627 if err != nil {
628 return data
629 }
630 return clean
631 }
632
633 func (c *Controller) persistGoalStateAtEpoch(epoch uint64) (bool, error) {
634 applied, err := c.goals.writeStateAtEpoch(epoch)
635 if err != nil {
636 slog.Warn("controller: write goal state", "err", err)
637 }
638 return applied, err
639 }
640
640 lines GO