返回 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 "path/filepath"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/evidence"
16 "reasonix/internal/fileutil"
17 fileencoding "reasonix/internal/fileutil/encoding"
18 "reasonix/internal/goaleval"
19 "reasonix/internal/store"
20 "reasonix/internal/tool"
21 )
22
23 const (
24 goalContinueTurn = "Continue pursuing the active goal under its task contract. Do the next useful work, then call update_goal with your disposition: continue (include the next concrete step in next_action), complete (only when fully done and verified), or blocked (when only the user can unblock)."
25 goalCompleteNotice = "goal complete"
26
27 // Default no-progress stall limit: four consecutive goal turns without any
28 // host-verifiable progress pause the goal.
29 defaultNoProgressLimit = 4
30 )
31
32 // Budget classes select turn quotas only. They never gate permissions, writes,
33 // or provider request admission. Token usage is still accumulated for display.
34 const (
35 budgetClassSimple = "simple"
36 budgetClassWrite = "write"
37 budgetClassResearch = "research"
38 )
39
40 // Stop causes distinguish a safe pause (blocked + stopCause) from a genuine
41 // task block (blocked with empty stopCause). Old clients see blocked either
42 // way and never fail open.
43 //
44 // stopCauseBudgetTokens is retained only so old sidecars that paused on the
45 // removed token hard-limit can be recognized and auto-resumed on load.
46 const (
47 stopCauseBudgetTurns = "budget_turns"
48 stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
49 stopCauseNoProgress = "no_progress"
50 stopCauseEvaluator = "evaluator_unavailable"
51 stopCauseManual = "manual"
52 )
53
54 // budgetQuota returns the default turn quota for a budget class. Token hard
55 // limits were removed; callers no longer receive a token ceiling.
56 func budgetQuota(class string) (turns int) {
57 switch class {
58 case budgetClassResearch:
59 return 40
60 case budgetClassWrite:
61 return 20
62 default:
63 return 10
64 }
65 }
66
67 // budgetClassFor derives a goal's budget class: AutoResearch always means
68 // research; otherwise Goal-specific write classification decides whether the
69 // objective is a write turn budget (including bare fault statements) or simple.
70 // Ordinary Delivery consultation/diagnosis classification is unchanged.
71 func budgetClassFor(goal string, researchMode GoalResearchMode) string {
72 if shouldUseAutoResearch(goal, researchMode) {
73 return budgetClassResearch
74 }
75 if agent.GoalTaskNeedsWriteBudget(goal) {
76 return budgetClassWrite
77 }
78 return budgetClassSimple
79 }
80
81 // goalMachine owns the active goal's finite-state machine and its persistence.
82 // It is a strict leaf: its methods take only the machine's own locks and never
83 // call back into the Controller, so the controller may hold c.mu while invoking
84 // a getter without risking lock inversion. The FSM is pure — advance() takes
85 // already-gathered inputs (the update_goal report, readiness, evaluator verdict,
86 // budget/progress state) and returns what to persist plus a notice, so no disk
87 // or executor work happens under mu.
88 type goalMachine struct {
89 // mu guards the FSM fields below; every critical section under it is short
90 // and non-blocking (no disk I/O, no executor calls).
91 mu sync.Mutex
92 goal string
93 status string
94 researchMode GoalResearchMode
95 autoResearchTaskID string
96 scopeID string
97 deliveryCheckpoint evidence.DeliveryCheckpoint
98 block string
99 strict bool
100 continuationEpoch uint64
101
102 // Runtime budget state, persisted across turns and restarts.
103 // tokensUsed is observational only (no hard limit). tokensLimit is kept at
104 // 0 for wire/sidecar compatibility and is never enforced.
105 budgetClass string
106 turnsUsed int
107 turnsLimit int
108 tokensUsed int
109 tokensLimit int // always 0 at runtime; deprecated hard limit
110 noProgressTurns int
111 noProgressLimit int
112 lastContinuationReason string
113 lastEvaluatorReason string
114 stopCause string
115 budgetExtensions int // turn extensions from resume (compat field name)
116
117 // statePath is the persisted goal-state sidecar; empty disables persistence.
118 statePath string
119 // writeMu serializes goal-state disk writes so concurrent saves don't
120 // interleave or land out of order. Taken OFF mu by writeState.
121 writeMu sync.Mutex
122 }
123
124 // goalState is the serializable form of a running goal. New fields are
125 // safe-to-omit JSON: old readers ignore them, and restoreFromState re-derives
126 // defaults when they are missing.
127 type goalState struct {
128 Goal string `json:"goal,omitempty"`
129 Status string `json:"status,omitempty"`
130 ResearchMode GoalResearchMode `json:"researchMode,omitempty"`
131 AutoResearchTaskID string `json:"autoResearchTaskID,omitempty"`
132 ScopeID string `json:"scopeID,omitempty"`
133 DeliveryCheckpoint evidence.DeliveryCheckpoint `json:"deliveryCheckpoint,omitempty"`
134 Turns int `json:"turns,omitempty"`
135 Blocks int `json:"blocks,omitempty"`
136 Block string `json:"block,omitempty"`
137 Strict bool `json:"strict,omitempty"`
138 Todos []evidence.TodoItem `json:"todos,omitempty"`
139
140 BudgetClass string `json:"budgetClass,omitempty"`
141 TurnsUsed int `json:"turnsUsed,omitempty"`
142 TurnsLimit int `json:"turnsLimit,omitempty"`
143 TokensUsed int `json:"tokensUsed,omitempty"`
144 TokensLimit int `json:"tokensLimit,omitempty"`
145 NoProgressTurns int `json:"noProgressTurns,omitempty"`
146 NoProgressLimit int `json:"noProgressLimit,omitempty"`
147 LastContinuationReason string `json:"lastContinuationReason,omitempty"`
148 LastEvaluatorReason string `json:"lastEvaluatorReason,omitempty"`
149 StopCause string `json:"stopCause,omitempty"`
150 BudgetExtensions int `json:"budgetExtensions,omitempty"`
151 }
152
153 // goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
154 // Persistence paths and mutexes are deliberately excluded.
155 type goalMachineSnapshot struct {
156 goal string
157 status string
158 researchMode GoalResearchMode
159 autoResearchTaskID string
160 scopeID string
161 deliveryCheckpoint evidence.DeliveryCheckpoint
162 block string
163 strict bool
164 }
165
166 // goalAdvanceInput carries everything the FSM needs for one continuation step,
167 // gathered by the caller off the machine's lock. The FSM is the exclusive
168 // decision point: it applies readiness, budget, and no-progress gates and
169 // decides complete / continue / blocked / pause.
170 type goalAdvanceInput struct {
171 report *goalTurnReport // validated update_goal report; nil when none
172 readiness agent.ReadinessResult
173 evaluator *goalEvaluatorVerdict // evaluator verdict; nil when not run
174 evaluatorFailed string // evaluator error/timeout text; pause fail-closed
175 todos []evidence.TodoItem
176 progressBefore string // host progress signature captured before the turn
177 progressAfter string // host progress signature captured after the turn
178 expectedEpoch *uint64
179 }
180
181 // goalEvaluatorVerdict is the bounded evaluator's structured outcome.
182 type goalEvaluatorVerdict struct {
183 outcome goaleval.Outcome
184 reason string
185 }
186
187 // goalAdvanceResult reports the FSM step's outcome. data/path/ok describe the
188 // state to persist (built under mu when something changed); notice is surfaced
189 // to the user; cont reports whether the goal loop should continue; intercept
190 // (with interceptNotice) is the next synthetic turn's prompt.
191 type goalAdvanceResult struct {
192 notice string
193 intercept string
194 interceptNotice string
195 cont bool
196 continuationEpoch uint64
197 path string
198 data []byte
199 ok bool
200 }
201
202 // goalContinuationSnapshot binds a continuation to the exact Goal lifecycle
203 // state admitted for its synthetic turn. The orchestrator uses these captured
204 // fields throughout the turn instead of re-reading a possibly replaced Goal.
205 type goalContinuationSnapshot struct {
206 goal string
207 researchMode GoalResearchMode
208 autoResearchTaskID string
209 scopeID string
210 }
211
212 // goalStatePath derives a session's persisted goal-state sidecar.
213 func goalStatePath(sessionPath string) string {
214 return store.SessionGoalState(sessionPath)
215 }
216
217 func (g *goalMachine) setStatePath(path string) {
218 g.mu.Lock()
219 g.statePath = path
220 g.mu.Unlock()
221 }
222
223 func (g *goalMachine) capture() goalMachineSnapshot {
224 g.mu.Lock()
225 defer g.mu.Unlock()
226 return goalMachineSnapshot{
227 goal: g.goal, status: g.status, researchMode: g.researchMode,
228 autoResearchTaskID: g.autoResearchTaskID, scopeID: g.scopeID,
229 deliveryCheckpoint: g.deliveryCheckpoint, block: g.block,
230 strict: g.strict,
231 }
232 }
233
234 func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
235 g.mu.Lock()
236 g.goal, g.status, g.researchMode = snapshot.goal, snapshot.status, snapshot.researchMode
237 g.autoResearchTaskID, g.scopeID = snapshot.autoResearchTaskID, snapshot.scopeID
238 g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block
239 g.strict = snapshot.strict
240 g.continuationEpoch++
241 g.mu.Unlock()
242 }
243
244 // snapshot returns the fields Compose injects into outgoing turns.
245 func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode, autoResearchTaskID string) {
246 g.mu.Lock()
247 defer g.mu.Unlock()
248 return g.goal, g.status, g.researchMode, g.autoResearchTaskID
249 }
250
251 func (g *goalMachine) goalText() string {
252 g.mu.Lock()
253 defer g.mu.Unlock()
254 return g.goal
255 }
256
257 func (g *goalMachine) currentAutoResearchTaskID() string {
258 g.mu.Lock()
259 defer g.mu.Unlock()
260 if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
261 return ""
262 }
263 return g.autoResearchTaskID
264 }
265
266 // continuationToken captures the Goal lifecycle that owns an outgoing turn.
267 // The matching assistant output may advance the FSM only while this epoch is
268 // still current.
269 func (g *goalMachine) continuationToken() uint64 {
270 g.mu.Lock()
271 defer g.mu.Unlock()
272 return g.continuationEpoch
273 }
274
275 func (g *goalMachine) deliveryScope() (id, task string, ok bool) {
276 g.mu.Lock()
277 defer g.mu.Unlock()
278 if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
279 return "", "", false
280 }
281 if g.scopeID == "" {
282 g.scopeID = newGoalScopeID()
283 }
284 return g.scopeID, g.goal, true
285 }
286
287 // goalScopeIDForTurn resolves the active goal scope for an outgoing turn: the
288 // continuation snapshot's scope, or the running goal's (assigning one when
289 // needed). ok=false means no active goal.
290 func (g *goalMachine) goalScopeIDForTurn(continuation *goalContinuationSnapshot) (string, bool) {
291 if continuation != nil {
292 return continuation.scopeID, true
293 }
294 id, _, ok := g.deliveryScope()
295 return id, ok
296 }
297
298 func newGoalScopeID() string {
299 var raw [16]byte
300 if _, err := rand.Read(raw[:]); err == nil {
301 return fmt.Sprintf("goal-%x", raw[:])
302 }
303 return fmt.Sprintf("goal-fallback-%d-%d", os.Getpid(), time.Now().UnixNano())
304 }
305
306 // active reports whether a goal is currently running.
307 func (g *goalMachine) active() bool {
308 g.mu.Lock()
309 defer g.mu.Unlock()
310 return strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning
311 }
312
313 // statusForDisplay maps the empty zero status to "stopped" for frontends.
314 func (g *goalMachine) statusForDisplay() string {
315 g.mu.Lock()
316 defer g.mu.Unlock()
317 if g.status == "" {
318 return GoalStatusStopped
319 }
320 return g.status
321 }
322
323 // budgetExhausted reports whether the goal's turn budget is spent. Token usage
324 // never exhausts the goal by itself.
325 func (g *goalMachine) budgetExhausted() bool {
326 g.mu.Lock()
327 defer g.mu.Unlock()
328 return g.turnsLimit > 0 && g.turnsUsed >= g.turnsLimit
329 }
330
331 // set installs a session-scoped goal (or clears it when goal is empty), resets
332 // the per-goal budget/runtime counters, and returns the state to persist. ok is
333 // false (no persistence) when the goal is unchanged or no state path is
334 // configured.
335 func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID string, todos []evidence.TodoItem) (string, []byte, bool) {
336 goal = strings.TrimSpace(goal)
337 g.mu.Lock()
338 defer g.mu.Unlock()
339 if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID {
340 return "", nil, false
341 }
342 g.continuationEpoch++
343 g.turnsUsed, g.tokensUsed, g.noProgressTurns = 0, 0, 0
344 g.block = ""
345 g.lastContinuationReason, g.lastEvaluatorReason = "", ""
346 g.stopCause = ""
347 g.budgetExtensions = 0
348 if goal == "" {
349 g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, ""
350 g.scopeID = ""
351 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
352 } else {
353 g.goal, g.status, g.researchMode, g.autoResearchTaskID = goal, GoalStatusRunning, mode, autoResearchTaskID
354 g.scopeID = newGoalScopeID()
355 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
356 g.budgetClass = budgetClassFor(goal, mode)
357 g.turnsLimit = budgetQuota(g.budgetClass)
358 g.tokensLimit = 0 // no token hard limit
359 g.noProgressLimit = defaultNoProgressLimit
360 }
361 return g.buildStateLocked(todos)
362 }
363
364 func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) {
365 g.mu.Lock()
366 defer g.mu.Unlock()
367 g.strict = strict
368 return g.buildStateLocked(todos)
369 }
370
371 // stop transitions a running goal to the given terminal status and clears the
372 // transient runtime bookkeeping. stopCause is cleared: a host stop is not a
373 // safe pause.
374 func (g *goalMachine) stop(status string, todos []evidence.TodoItem) (string, []byte, bool) {
375 g.mu.Lock()
376 defer g.mu.Unlock()
377 g.continuationEpoch++
378 if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
379 g.status = status
380 }
381 g.stopCause = ""
382 g.noProgressTurns = 0
383 return g.buildStateLocked(todos)
384 }
385
386 // pauseFor transitions a running goal to a safe pause: status blocked plus a
387 // stop cause, keeping every budget/runtime counter for a later resume.
388 func (g *goalMachine) pauseFor(stopCause, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
389 g.mu.Lock()
390 defer g.mu.Unlock()
391 g.continuationEpoch++
392 if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
393 g.status = GoalStatusBlocked
394 }
395 g.stopCause = stopCause
396 if reason != "" {
397 g.block = reason
398 }
399 return g.buildStateLocked(todos)
400 }
401
402 // resume re-enters a recoverable blocked/stopped goal without resetting its
403 // delivery evidence scope, AutoResearch identity, or runtime history. Turn
404 // budget pauses (and any pause whose original turn quota is already spent)
405 // append one turn slice of the current budget class; no-progress counting
406 // resets but accumulated token usage and budget_extensions are preserved.
407 // Token hard limits no longer exist, so resume never extends a token ceiling.
408 func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byte, persist, resumed, extended bool) {
409 g.mu.Lock()
410 defer g.mu.Unlock()
411 if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete {
412 return "", nil, false, false, false
413 }
414 // Legacy budget_tokens pauses are treated like turn-budget pauses so users
415 // can resume without understanding the removed hard limit.
416 extend := g.stopCause == stopCauseBudgetTurns ||
417 g.stopCause == stopCauseBudgetTokens ||
418 g.stopCause == stopCauseNoProgress ||
419 (g.turnsLimit > 0 && g.turnsUsed >= g.turnsLimit)
420 g.continuationEpoch++
421 g.status = GoalStatusRunning
422 g.block = ""
423 g.stopCause = ""
424 g.noProgressTurns = 0
425 g.tokensLimit = 0
426 if g.scopeID == "" {
427 g.scopeID = newGoalScopeID()
428 }
429 if extend {
430 if g.budgetClass == "" {
431 g.budgetClass = budgetClassFor(g.goal, g.researchMode)
432 }
433 g.turnsLimit += budgetQuota(g.budgetClass)
434 g.budgetExtensions++
435 }
436 path, data, persist = g.buildStateLocked(todos)
437 return path, data, persist, true, extend
438 }
439
440 func (g *goalMachine) setDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint, todos []evidence.TodoItem) (string, []byte, bool) {
441 g.mu.Lock()
442 defer g.mu.Unlock()
443 if g.scopeID == "" || checkpoint.ScopeID != g.scopeID {
444 return "", nil, false
445 }
446 g.deliveryCheckpoint = checkpoint
447 return g.buildStateLocked(todos)
448 }
449
450 func (g *goalMachine) deliveryState() evidence.DeliveryCheckpoint {
451 g.mu.Lock()
452 defer g.mu.Unlock()
453 return g.deliveryCheckpoint
454 }
455
456 // acceptContinuation checks an advance result before the orchestrator surfaces
457 // its notice. admitContinuation revalidates after synchronous notice callbacks
458 // and captures the Goal state at the synthetic-turn admission boundary.
459 func (g *goalMachine) acceptContinuation(res goalAdvanceResult) (string, bool) {
460 g.mu.Lock()
461 defer g.mu.Unlock()
462 if !res.cont ||
463 res.continuationEpoch != g.continuationEpoch ||
464 strings.TrimSpace(g.goal) == "" ||
465 g.status != GoalStatusRunning {
466 return "", false
467 }
468 return res.intercept, true
469 }
470
471 // admitContinuation atomically validates an advance result and captures the
472 // Goal state used to compose and scope its synthetic turn. Keeping validation
473 // and capture in one critical section prevents a stale intercept from being
474 // paired with a replacement Goal between those operations.
475 func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuationSnapshot, bool) {
476 g.mu.Lock()
477 defer g.mu.Unlock()
478 if !res.cont ||
479 res.continuationEpoch != g.continuationEpoch ||
480 strings.TrimSpace(g.goal) == "" ||
481 g.status != GoalStatusRunning {
482 return goalContinuationSnapshot{}, false
483 }
484 if g.scopeID == "" {
485 g.scopeID = newGoalScopeID()
486 }
487 return goalContinuationSnapshot{
488 goal: g.goal,
489 researchMode: g.researchMode,
490 autoResearchTaskID: g.autoResearchTaskID,
491 scopeID: g.scopeID,
492 }, true
493 }
494
495 // advance runs one continuation step of the goal FSM from already-gathered
496 // inputs. It mutates the machine, decides whether to keep looping, and builds
497 // the state to persist when the goal reached a terminal/notice point.
498 //
499 // Decision priority (the FSM is the exclusive decision point):
500 // 1. complete + readiness ready (report or evaluator) → complete
501 // 2. blocked (report or evaluator) → blocked immediately (no triple confirm)
502 // 3. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
503 // 4. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
504 // 5. budget exhausted → safe pause (also vetoes complete claims rejected by
505 // readiness: those would continue, and continuation past the budget is a
506 // pause)
507 // 6. no-progress limit reached → safe pause
508 // 7. otherwise continue, carrying the missing requirements (complete rejected
509 // by readiness, or no report with an explicit missing list) or the report's
510 // next_action as the next turn's prompt.
511 func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
512 g.mu.Lock()
513 defer g.mu.Unlock()
514 if in.expectedEpoch != nil && *in.expectedEpoch != g.continuationEpoch {
515 return goalAdvanceResult{cont: false}
516 }
517 if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
518 return goalAdvanceResult{cont: false}
519 }
520 g.continuationEpoch++
521 // A top-level goal turn (the first turn or a synthetic continuation) counts
522 // against the turn budget; the in-Run model/tool loop is never re-counted.
523 g.turnsUsed++
524 // No-progress bookkeeping: only host-verifiable changes reset the stall
525 // counter. A terminal report is itself host-verifiable progress.
526 progressed := in.progressAfter != in.progressBefore || in.progressBefore == ""
527 if in.report != nil && in.report.status != GoalStatusRunning {
528 progressed = true
529 }
530 if progressed {
531 g.noProgressTurns = 0
532 } else {
533 g.noProgressTurns++
534 }
535 var notice string
536 var intercept string
537 var interceptNotice string
538 evaluatorComplete := in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeComplete
539 evaluatorBlocked := in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeBlocked
540 // Terminal dispositions first (completing or blocking ends the goal, so the
541 // budget gates never veto them); then evaluator fail-closed, then the
542 // budget gates, then the no-progress gate; only then continue.
543 reportBlocked := in.report != nil && in.report.status == GoalStatusBlocked
544 reportComplete := in.report != nil && in.report.status == GoalStatusComplete
545 completeOK := (reportComplete || evaluatorComplete) && formatIncompleteTodos(in.todos, in.readiness.Reason) == ""
546 switch {
547 case reportBlocked:
548 // A single blocked report ends the goal immediately; the host no longer
549 // repeats a three-turn confirmation ritual.
550 reason := cleanGoalBlockReason(in.report.reason)
551 if reason == "" {
552 reason = "blocked"
553 }
554 g.status = GoalStatusBlocked
555 g.block = reason
556 g.stopCause = ""
557 g.lastContinuationReason = clipGoalReason(in.report.reason)
558 notice = "goal blocked: " + reason
559 case evaluatorBlocked:
560 reason := cleanGoalBlockReason(in.evaluator.reason)
561 if reason == "" {
562 reason = "blocked"
563 }
564 g.status = GoalStatusBlocked
565 g.block = reason
566 g.stopCause = ""
567 g.lastEvaluatorReason = clipGoalReason(in.evaluator.reason)
568 notice = "goal blocked: " + reason
569 case completeOK:
570 g.goal = ""
571 g.status = GoalStatusComplete
572 g.block = ""
573 g.stopCause = ""
574 g.lastContinuationReason, g.lastEvaluatorReason = "", ""
575 notice = goalCompleteNotice
576 case in.evaluatorFailed != "" || (in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeUncertain):
577 // Fail closed: an unavailable, erroring, or uncertain evaluator pauses
578 // the goal instead of defaulting to continue.
579 reason := "the completion evaluator is unavailable or could not judge the turn"
580 if in.evaluatorFailed != "" {
581 reason = "the completion evaluator failed: " + in.evaluatorFailed
582 }
583 g.status = GoalStatusBlocked
584 g.stopCause = stopCauseEvaluator
585 g.block = clipGoalReason(reason)
586 g.lastEvaluatorReason = clipGoalReason(reason)
587 notice = "goal paused: " + reason
588 case g.turnsLimit > 0 && g.turnsUsed >= g.turnsLimit:
589 reason := fmt.Sprintf("turn budget exhausted (%d/%d turns used)", g.turnsUsed, g.turnsLimit)
590 g.status = GoalStatusBlocked
591 g.stopCause = stopCauseBudgetTurns
592 g.block = clipGoalReason(reason)
593 notice = "goal paused: " + reason
594 case g.noProgressLimit > 0 && g.noProgressTurns >= g.noProgressLimit:
595 reason := fmt.Sprintf("no host-verifiable progress in the last %d turns", g.noProgressTurns)
596 g.status = GoalStatusBlocked
597 g.stopCause = stopCauseNoProgress
598 g.block = clipGoalReason(reason)
599 notice = "goal paused: " + reason
600 default:
601 // Continue. A complete claim rejected by readiness, or a turn with no
602 // report but an explicit missing list, carries the missing requirements
603 // into the next turn; a continue report carries its next_action.
604 switch {
605 case reportComplete:
606 intercept = formatIncompleteTodos(in.todos, in.readiness.Reason)
607 interceptNotice = "Goal is not ready to complete yet; continuing the remaining work."
608 g.lastContinuationReason = clipGoalReason("readiness missing: " + in.readiness.Reason)
609 case in.report != nil && in.report.status == GoalStatusRunning:
610 g.lastContinuationReason = clipGoalReason(in.report.reason)
611 if in.report.nextAction != "" {
612 intercept = in.report.nextAction
613 }
614 case len(in.readiness.Missing) > 0:
615 intercept = formatIncompleteTodos(in.todos, in.readiness.Reason)
616 interceptNotice = "Goal is not ready to complete yet; continuing the remaining work."
617 g.lastContinuationReason = clipGoalReason("readiness missing: " + in.readiness.Reason)
618 case evaluatorComplete:
619 intercept = formatIncompleteTodos(in.todos, in.readiness.Reason)
620 interceptNotice = "Goal is not ready to complete yet; continuing the remaining work."
621 g.lastEvaluatorReason = clipGoalReason(in.evaluator.reason)
622 g.lastContinuationReason = clipGoalReason("readiness missing: " + in.readiness.Reason)
623 case in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeContinue:
624 g.lastEvaluatorReason = clipGoalReason(in.evaluator.reason)
625 }
626 }
627 res := goalAdvanceResult{
628 notice: notice,
629 intercept: intercept,
630 interceptNotice: interceptNotice,
631 cont: notice == "",
632 continuationEpoch: g.continuationEpoch,
633 }
634 if notice != "" {
635 res.path, res.data, res.ok = g.buildStateLocked(in.todos)
636 }
637 return res
638 }
639
640 // foldUsage attributes a turn's billable tokens to the goal, but only while the
641 // goal lifecycle still matches the recorder's scope+epoch; stale or replaced
642 // goals reject late usage.
643 func (g *goalMachine) foldUsage(scopeID string, epoch uint64, tokens int) bool {
644 g.mu.Lock()
645 defer g.mu.Unlock()
646 if tokens <= 0 || g.scopeID != scopeID || g.continuationEpoch != epoch {
647 return false
648 }
649 g.tokensUsed += tokens
650 return true
651 }
652
653 // buildStateLocked marshals the current goal state for persistence. The caller
654 // holds mu; this only reads in-memory state, never touching disk. Returns ok=false
655 // when persistence is disabled (no state path). The matching writeState does the
656 // disk write OFF mu so the per-turn save can't stall a status poll.
657 func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, data []byte, ok bool) {
658 if g.statePath == "" {
659 return "", nil, false
660 }
661 state := goalState{
662 Goal: g.goal,
663 Status: g.status,
664 ResearchMode: g.researchMode,
665 AutoResearchTaskID: g.autoResearchTaskID,
666 ScopeID: g.scopeID,
667 DeliveryCheckpoint: g.deliveryCheckpoint,
668 Turns: g.turnsUsed,
669 Block: g.block,
670 Strict: g.strict,
671 Todos: todos,
672 BudgetClass: g.budgetClass,
673 TurnsUsed: g.turnsUsed,
674 TurnsLimit: g.turnsLimit,
675 TokensUsed: g.tokensUsed,
676 TokensLimit: g.tokensLimit,
677 NoProgressTurns: g.noProgressTurns,
678 NoProgressLimit: g.noProgressLimit,
679 LastContinuationReason: g.lastContinuationReason,
680 LastEvaluatorReason: g.lastEvaluatorReason,
681 StopCause: g.stopCause,
682 BudgetExtensions: g.budgetExtensions,
683 }
684 b, err := json.Marshal(state)
685 if err != nil {
686 slog.Warn("controller: marshal goal state", "err", err)
687 return "", nil, false
688 }
689 return g.statePath, b, true
690 }
691
692 // writeStateErr persists pre-marshaled goal-state bytes to disk, OFF mu and
693 // serialized by writeMu so concurrent saves don't interleave or land out of
694 // order. Atomic replacement keeps the prior state intact when a write fails.
695 func (g *goalMachine) writeStateErr(path string, data []byte) error {
696 if path == "" || data == nil {
697 return nil
698 }
699 g.writeMu.Lock()
700 defer g.writeMu.Unlock()
701 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
702 return err
703 }
704 return fileutil.AtomicWriteFile(path, data, 0o644)
705 }
706
707 // writeState preserves the existing best-effort behavior for background Goal
708 // progress. Callers that need transactional persistence use writeStateErr.
709 func (g *goalMachine) writeState(path string, data []byte) {
710 if err := g.writeStateErr(path, data); err != nil {
711 slog.Warn("controller: write goal state", "err", err)
712 }
713 }
714
715 // persistWithTodos re-persists goal state with the given todos, without
716 // changing any in-memory goal fields. Used after force-completing todos on
717 // goal completion so a session reload does not revert to the old incomplete
718 // todo state.
719 func (g *goalMachine) persistWithTodos(todos []evidence.TodoItem) {
720 g.mu.Lock()
721 path, data, ok := g.buildStateLocked(todos)
722 g.mu.Unlock()
723 if ok {
724 g.writeState(path, data)
725 }
726 }
727
728 // terminalTodosFromState reads the persisted goal-state sidecar and returns its
729 // todo snapshot only after the goal has reached a terminal state. Running goal
730 // state is not refreshed on every todo_write, so its todos may be older than the
731 // transcript rebuilt by Agent.SetSession.
732 func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.TodoItem, bool) {
733 if strings.TrimSpace(sessionPath) == "" {
734 return nil, false
735 }
736 data, err := fileencoding.ReadFileUTF8(goalStatePath(sessionPath))
737 if err != nil {
738 if !os.IsNotExist(err) {
739 slog.Warn("controller: read goal state", "err", err)
740 }
741 return nil, false
742 }
743 var state goalState
744 if err := json.Unmarshal(data, &state); err != nil {
745 slog.Warn("controller: parse goal state", "err", err)
746 return nil, false
747 }
748 switch state.Status {
749 case GoalStatusComplete, GoalStatusBlocked, GoalStatusStopped:
750 default:
751 return nil, false
752 }
753 if len(state.Todos) == 0 {
754 return nil, false
755 }
756 return append([]evidence.TodoItem(nil), state.Todos...), true
757 }
758
759 // restoreFromState reloads Goal state from the persisted sidecar during resume.
760 // The sidecar is authoritative when present: a stale tab profile must not turn
761 // a blocked or stopped Goal back into a running one during a controller rebuild.
762 // Recoverable terminal states retain their scope for an explicit ResumeGoal.
763 // Old sidecars missing the budget fields get their defaults re-derived: the
764 // existing Turns count carries into the new turn budget, tokens start from 0,
765 // and the budget class is recomputed from the goal text.
766 //
767 // When a legacy budget_tokens pause is cleared, migrated is true and path/data
768 // carry the rewritten state for immediate atomic persistence (no provider call).
769 func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool) {
770 if strings.TrimSpace(sessionPath) == "" {
771 return "", nil, false
772 }
773 // Ensure write path is bound even when the controller rebuilds.
774 if g.statePath == "" {
775 g.setStatePath(goalStatePath(sessionPath))
776 }
777 raw, err := fileencoding.ReadFileUTF8(goalStatePath(sessionPath))
778 if err != nil {
779 if !os.IsNotExist(err) {
780 slog.Warn("controller: read goal state", "err", err)
781 }
782 return "", nil, false
783 }
784 var state goalState
785 if err := json.Unmarshal(raw, &state); err != nil {
786 slog.Warn("controller: parse goal state", "err", err)
787 return "", nil, false
788 }
789 g.mu.Lock()
790 defer g.mu.Unlock()
791 g.goal = strings.TrimSpace(state.Goal)
792 g.status = state.Status
793 if g.status == "" {
794 g.status = GoalStatusStopped
795 }
796 g.researchMode = state.ResearchMode
797 g.autoResearchTaskID = strings.TrimSpace(state.AutoResearchTaskID)
798 g.scopeID = strings.TrimSpace(state.ScopeID)
799 if g.goal != "" && g.scopeID == "" {
800 g.scopeID = newGoalScopeID()
801 }
802 g.deliveryCheckpoint = state.DeliveryCheckpoint
803 if g.scopeID == "" {
804 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
805 } else if g.deliveryCheckpoint.ScopeID == "" {
806 g.deliveryCheckpoint.ScopeID = g.scopeID
807 } else if g.deliveryCheckpoint.ScopeID != g.scopeID {
808 g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
809 }
810 g.block = state.Block
811 g.strict = state.Strict
812 g.stopCause = state.StopCause
813 g.budgetExtensions = state.BudgetExtensions
814 g.lastContinuationReason = state.LastContinuationReason
815 g.lastEvaluatorReason = state.LastEvaluatorReason
816 // Budget defaults: old sidecars carry Turns (pre-budget counting); treat it
817 // as the new turn usage and re-derive the class/limits from the goal text.
818 g.turnsUsed = state.TurnsUsed
819 if g.turnsUsed == 0 && state.Turns > 0 {
820 g.turnsUsed = state.Turns
821 }
822 g.tokensUsed = state.TokensUsed
823 // Token hard limits are gone: keep the field at 0. Old non-zero sidecar
824 // values are read and ignored so downgrade/upgrade never loses other state.
825 g.tokensLimit = 0
826 migrated = false
827 if g.goal != "" {
828 g.budgetClass = state.BudgetClass
829 if g.budgetClass == "" {
830 g.budgetClass = budgetClassFor(g.goal, g.researchMode)
831 }
832 if state.TurnsLimit > 0 {
833 g.turnsLimit = state.TurnsLimit
834 } else {
835 g.turnsLimit = budgetQuota(g.budgetClass)
836 }
837 if state.NoProgressLimit > 0 {
838 g.noProgressLimit = state.NoProgressLimit
839 } else {
840 g.noProgressLimit = defaultNoProgressLimit
841 }
842 g.noProgressTurns = state.NoProgressTurns
843 // Auto-clear legacy token-budget pauses so the next user turn can
844 // continue without a manual resume. Loading itself never calls a
845 // provider.
846 if g.status == GoalStatusBlocked && g.stopCause == stopCauseBudgetTokens {
847 g.status = GoalStatusRunning
848 g.stopCause = ""
849 // The stop cause proves the block belongs to the removed token gate;
850 // do not leave a stale blocked reason attached to a running goal.
851 g.block = ""
852 migrated = true
853 }
854 // Also rewrite sidecars that still store a non-zero tokensLimit so the
855 // next load does not re-surface the deprecated hard ceiling in status.
856 if state.TokensLimit != 0 {
857 migrated = true
858 }
859 }
860 g.continuationEpoch++
861 if migrated {
862 // Migration rewrites only the removed budget state. Preserve the todo
863 // snapshot carried by the authoritative sidecar instead of clearing it.
864 path, data, ok := g.buildStateLocked(state.Todos)
865 if ok {
866 return path, data, true
867 }
868 }
869 return "", nil, false
870 }
871
872 // formatIncompleteTodos renders the reminder shown when a complete claim
873 // arrives while the executor's canonical todos or project-readiness checks
874 // aren't done. Returns empty when nothing is blocking. Pure: the caller gathers
875 // todos and the readiness reason from the executor off the goal lock.
876 func formatIncompleteTodos(todos []evidence.TodoItem, readiness string) string {
877 var parts []string
878 if len(todos) > 0 {
879 if incomplete := evidence.IncompleteTodos(todos); len(incomplete) > 0 {
880 var b strings.Builder
881 b.WriteString("the following tasks are still incomplete:")
882 for _, t := range incomplete {
883 fmt.Fprintf(&b, "\n - %s (%s)", t.Content, t.Status)
884 }
885 parts = append(parts, b.String())
886 }
887 }
888 if readiness != "" {
889 parts = append(parts, readiness)
890 }
891 if len(parts) == 0 {
892 return ""
893 }
894 var b strings.Builder
895 b.WriteString("Goal signaled complete but issues remain:\n")
896 for _, p := range parts {
897 b.WriteString("- ")
898 b.WriteString(p)
899 b.WriteString("\n")
900 }
901 b.WriteString("Fix or use todo_write/complete_step to mark done, then report complete again via update_goal.")
902 return b.String()
903 }
904
905 // clipGoalReason bounds a recorded reason for storage and display.
906 func clipGoalReason(reason string) string {
907 reason = strings.TrimSpace(reason)
908 const max = 400
909 if r := []rune(reason); len(r) > max {
910 return string(r[:max]) + "..."
911 }
912 return reason
913 }
914
915 func cleanGoalBlockReason(reason string) string {
916 return strings.Trim(strings.TrimSpace(reason), " \t\r\n::,,.。;;!!??-—_[]()()")
917 }
918
919 // ShortGoalForNotice collapses whitespace and truncates a goal for one-line UI.
920 func ShortGoalForNotice(goal string) string {
921 goal = strings.Join(strings.Fields(goal), " ")
922 runes := []rune(goal)
923 const max = 160
924 if len(runes) <= max {
925 return goal
926 }
927 return string(runes[:max]) + "..."
928 }
929
930 // goalTodos snapshots the executor's canonical todos for goal-state persistence.
931 func (c *Controller) goalTodos() []evidence.TodoItem {
932 if c.executor == nil {
933 return nil
934 }
935 return c.executor.CanonicalTodoState()
936 }
937
938 // persistGoalState writes a freshly built goal state to disk, off c.mu. The
939 // executor guard preserves the original behavior of skipping persistence when
940 // no executor is attached.
941 func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
942 if !ok || c.executor == nil {
943 return
944 }
945 c.goals.writeState(path, data)
946 }
947
948 func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
949 if c.executor == nil {
950 return
951 }
952 todos, ok := c.goals.terminalTodosFromState(sessionPath)
953 if !ok {
954 return
955 }
956 c.executor.ReplaceTodoState(todos)
957 }
958
959 // GoalRuntimeView is the host-side runtime summary exposed to frontends.
960 type GoalRuntimeView struct {
961 TurnsUsed int
962 TurnsLimit int
963 TokensUsed int
964 TokensLimit int
965 NoProgressTurns int
966 NoProgressLimit int
967 LastReason string
968 StopCause string
969 BudgetExtensions int
970 }
971
972 // runtimeView returns the goal's budget/runtime summary.
973 func (g *goalMachine) runtimeView() GoalRuntimeView {
974 g.mu.Lock()
975 defer g.mu.Unlock()
976 last := g.lastEvaluatorReason
977 if last == "" {
978 last = g.lastContinuationReason
979 }
980 return GoalRuntimeView{
981 TurnsUsed: g.turnsUsed,
982 TurnsLimit: g.turnsLimit,
983 TokensUsed: g.tokensUsed,
984 TokensLimit: 0, // deprecated: no hard token limit
985 NoProgressTurns: g.noProgressTurns,
986 NoProgressLimit: g.noProgressLimit,
987 LastReason: last,
988 StopCause: g.stopCause,
989 BudgetExtensions: g.budgetExtensions,
990 }
991 }
992
993 func (g *goalMachine) lastContinuationReasonText() string {
994 g.mu.Lock()
995 defer g.mu.Unlock()
996 if g.lastEvaluatorReason != "" {
997 return g.lastEvaluatorReason
998 }
999 return g.lastContinuationReason
1000 }
1001
1002 func (g *goalMachine) budgetStatusText() string {
1003 g.mu.Lock()
1004 defer g.mu.Unlock()
1005 return fmt.Sprintf("turns: %d/%d used, tokens: %d, no-progress turns: %d/%d",
1006 g.turnsUsed, g.turnsLimit, g.tokensUsed, g.noProgressTurns, g.noProgressLimit)
1007 }
1008
1009 // goalTurnRecorder is the per-turn recorder bound to one goal turn's scope and
1010 // epoch. update_goal calls land here as candidate state; the FSM commits them
1011 // only when the goal lifecycle still matches (scope + epoch), so late calls
1012 // from a replaced or cleared goal are rejected. Usage events emitted during the
1013 // turn are folded through the recorder into the goal's observational token total.
1014 type goalTurnRecorder struct {
1015 mu sync.Mutex
1016 machine *goalMachine
1017 scopeID string
1018 epoch uint64
1019 recorded bool
1020 terminal bool
1021 status string
1022 reason string
1023 nextAction string
1024 tokensUsed int
1025 progressBefore string
1026 }
1027
1028 func (g *goalMachine) newTurnRecorder(scopeID string, epoch uint64) *goalTurnRecorder {
1029 return &goalTurnRecorder{machine: g, scopeID: scopeID, epoch: epoch}
1030 }
1031
1032 // RecordGoalReport validates the report against the turn's goal lifecycle and
1033 // records it as the turn's candidate disposition. Same-value repeats are
1034 // idempotent; continue may upgrade to complete/blocked; complete and blocked
1035 // are terminal and reject conflicting later calls.
1036 func (r *goalTurnRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
1037 r.mu.Lock()
1038 defer r.mu.Unlock()
1039 if !r.machine.turnActive(r.scopeID, r.epoch) {
1040 return "", fmt.Errorf("update_goal: the active goal changed during this turn — report ignored; no goal state was changed")
1041 }
1042 switch {
1043 case r.terminal:
1044 return "", fmt.Errorf("update_goal: this turn's disposition is already final (%s); conflicting %q report ignored", r.status, report.Status)
1045 case !r.recorded:
1046 // first record
1047 case r.status == report.Status && r.reason == report.Reason && r.nextAction == report.NextAction:
1048 return fmt.Sprintf("update_goal: %s already recorded for this turn (identical report).", report.Status), nil
1049 case r.status == GoalStatusRunning && (report.Status == GoalStatusComplete || report.Status == GoalStatusBlocked):
1050 // continue → terminal upgrade allowed.
1051 default:
1052 return "", fmt.Errorf("update_goal: conflicting reports this turn (%s then %s) — the later report was ignored", r.status, report.Status)
1053 }
1054 r.recorded = true
1055 r.status = report.Status
1056 r.reason = report.Reason
1057 r.nextAction = report.NextAction
1058 if report.Status != GoalStatusRunning {
1059 r.terminal = true
1060 }
1061 return fmt.Sprintf("update_goal: %s recorded for this turn.", report.Status), nil
1062 }
1063
1064 func (r *goalTurnRecorder) setProgressBefore(sig string) {
1065 r.mu.Lock()
1066 r.progressBefore = sig
1067 r.mu.Unlock()
1068 }
1069
1070 func (r *goalTurnRecorder) progressBeforeText() string {
1071 r.mu.Lock()
1072 defer r.mu.Unlock()
1073 return r.progressBefore
1074 }
1075
1076 func (r *goalTurnRecorder) addUsage(tokens int) {
1077 if tokens <= 0 {
1078 return
1079 }
1080 r.mu.Lock()
1081 if r.machine.foldUsage(r.scopeID, r.epoch, tokens) {
1082 r.tokensUsed += tokens
1083 }
1084 r.mu.Unlock()
1085 }
1086
1087 func (r *goalTurnRecorder) usageTokens() int {
1088 r.mu.Lock()
1089 defer r.mu.Unlock()
1090 return r.tokensUsed
1091 }
1092
1093 // validReport returns the recorded report only when the goal lifecycle still
1094 // matches the recorder's binding; stale (replaced/cleared) turns report nothing.
1095 func (r *goalTurnRecorder) validReport(expectedEpoch uint64) *goalTurnReport {
1096 r.mu.Lock()
1097 defer r.mu.Unlock()
1098 if !r.recorded || r.epoch != expectedEpoch || !r.machine.turnActive(r.scopeID, r.epoch) {
1099 return nil
1100 }
1101 return &goalTurnReport{status: r.status, reason: r.reason, nextAction: r.nextAction}
1102 }
1103
1104 // goalTurnReport is the validated update_goal report for one goal turn.
1105 type goalTurnReport struct {
1106 status string
1107 reason string
1108 nextAction string
1109 }
1110
1111 // turnActive reports whether the machine's goal lifecycle matches the binding.
1112 func (g *goalMachine) turnActive(scopeID string, epoch uint64) bool {
1113 g.mu.Lock()
1114 defer g.mu.Unlock()
1115 return strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning &&
1116 g.scopeID == scopeID && g.continuationEpoch == epoch
1117 }
1118
1118 lines GO