返回 DeepSeek-Reasonix
turn_orchestrator.go
根目录 / internal / control / turn_orchestrator.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/autoresearch"
13 "reasonix/internal/event"
14 "reasonix/internal/evidence"
15 "reasonix/internal/jobs"
16 "reasonix/internal/provider"
17 "reasonix/internal/skill"
18 "reasonix/internal/tool"
19 )
20
21 // turnOrchestrator owns foreground turn execution while Controller keeps the
22 // public ports, run-state guard, and session-scoped dependencies.
23 type turnOrchestrator struct {
24 c *Controller
25 }
26
27 type orchestratedTurn struct {
28 input string
29 raw string
30 display string
31 editedOriginal string
32 synthetic bool
33 goalContinuation *goalContinuationSnapshot
34 }
35
36 func newTurnOrchestrator(c *Controller) *turnOrchestrator {
37 return &turnOrchestrator{c: c}
38 }
39
40 func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
41 return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display})
42 }
43
44 func (o *turnOrchestrator) runEditedTurnWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
45 return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, editedOriginal: original})
46 }
47
48 func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
49 return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, synthetic: true})
50 }
51
52 func (o *turnOrchestrator) runGoalContinuationTurnWithRawDisplay(
53 ctx context.Context,
54 input, raw, display string,
55 res goalAdvanceResult,
56 ) (bool, error) {
57 snapshot, ok := o.c.goals.admitContinuation(res)
58 if !ok {
59 return false, nil
60 }
61 err := o.runOrchestratedTurn(ctx, orchestratedTurn{
62 input: input,
63 raw: raw,
64 display: display,
65 synthetic: true,
66 goalContinuation: &snapshot,
67 })
68 return true, err
69 }
70
71 func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error {
72 c := o.c
73 ctx = agent.WithRawUserInput(ctx, text)
74 ctx = c.withPlannerTurnMetadata(ctx, text, true, c.messageCount())
75 return c.runner.Run(ctx, c.ComposeSynthetic(text))
76 }
77
78 // runSubagentSkillGoalLoop executes a slash-invoked runAs=subagent skill as a
79 // real isolated child turn, then lets an active goal continue just as an inline
80 // skill turn did before.
81 func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
82 return o.runSubagentSkillTurnsGoalLoop(ctx, []skill.Skill{sk}, task, raw, display, runner, planMode)
83 }
84
85 func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
86 expectedContinuationEpoch := o.c.goals.continuationToken()
87 // The skill turn's model requests count against the active goal's token
88 // budget, so bind a recorder for the span even though the sub-agent cannot
89 // call update_goal itself.
90 if scopeID, _, ok := o.c.goals.deliveryScope(); ok {
91 recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken())
92 o.c.goalUsageTee.setActiveRecorder(recorder)
93 }
94 if err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode); err != nil {
95 if ctx.Err() != nil {
96 o.c.goalUsageTee.setActiveRecorder(nil)
97 o.c.stopGoal(GoalStatusStopped)
98 }
99 o.c.goalUsageTee.setActiveRecorder(nil)
100 return err
101 }
102 return o.continueGoal(ctx, expectedContinuationEpoch, nil)
103 }
104
105 // runSubagentSkillTurns records the composed user task and distilled child
106 // answers only. Child reasoning and tool chatter stay out of the
107 // provider-visible parent context while their UI events nest under synthetic
108 // top-level run_skill cards.
109 func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) (err error) {
110 c := o.c
111 c.maybeSessionStart(ctx)
112 parentSession := c.parentSessionID()
113 images := c.inputImages(raw)
114 ctx = agent.WithParentSession(ctx, parentSession)
115 ctx = jobs.WithSession(ctx, parentSession)
116 ctx = agent.WithUserImages(ctx, images)
117 ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
118 ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
119
120 input := c.compose(task, raw, true)
121 startMessages := c.messageCount()
122 defer c.snapshotActivityIfChanged(startMessages)
123 defer c.recordDisplayForNewUser(startMessages, display)
124 // The checkpoint prompt labels the turn in the rewind picker (and is
125 // prefilled into the composer after a conversation rewind), so it must be
126 // the user's own text — never the composed provider input with its
127 // transient <response-language>/<reasoning-language>/memory/hook blocks.
128 c.beginCheckpoint(firstNonEmpty(raw, task))
129 if c.guardianSess != nil {
130 c.guardianSess.ResetTurn()
131 }
132 if c.hooks.Enabled() {
133 c.mu.Lock()
134 c.turn++
135 turn := c.turn
136 c.mu.Unlock()
137 if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
138 return nil
139 }
140 defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
141 }
142
143 c.markInFlightTurn(startMessages, true)
144 inFlight := true
145 defer func() {
146 if inFlight {
147 c.clearInFlightTurn()
148 }
149 }()
150 c.sink.Emit(event.Event{Kind: event.TurnStarted})
151 if c.executor == nil {
152 return fmt.Errorf("subagent slash invocation requires an active session")
153 }
154 c.executor.Session().Add(provider.Message{Role: provider.RoleUser, Content: input, Images: images, CreatedAt: time.Now().UnixMilli()})
155
156 for _, sk := range skills {
157 sk = c.skills.prepare(sk)
158 callID := fmt.Sprintf("slash-skill-%d", c.slashSkillSeq.Add(1))
159 args, _ := json.Marshal(map[string]string{"name": sk.Name, "arguments": task})
160 toolEvent := event.Tool{
161 ID: callID,
162 Name: "run_skill",
163 Args: string(args),
164 ReadOnly: sk.ReadOnly,
165 }
166 if c.skillProfile != nil {
167 toolEvent.Profile = c.skillProfile(sk)
168 }
169 c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: toolEvent})
170 runCtx := agent.WithToolCallContext(ctx, callID, c.sink, c, planMode)
171 runCtx = agent.WithSubagentDepth(runCtx, 0)
172 answer, err := runner(runCtx, sk, input, skill.SubagentRunOptions{HostInitiated: true})
173 if err != nil {
174 toolEvent.Err = err.Error()
175 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent})
176 return err
177 }
178 answer = tool.GuardSubagentHostDecisionText(answer)
179 toolEvent.Output = answer
180 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent})
181 c.executor.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: answer})
182 display := agent.DisplayAssistantText(answer)
183 c.sink.Emit(event.Event{Kind: event.Text, Text: display})
184 c.sink.Emit(event.Event{Kind: event.Message, Text: display})
185 }
186
187 c.clearInFlightTurn()
188 inFlight = false
189 return nil
190 }
191
192 func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchestratedTurn) (err error) {
193 c := o.c
194 c.maybeSessionStart(ctx)
195 parentSession := c.parentSessionID()
196 ctx = agent.WithParentSession(ctx, parentSession)
197 ctx = jobs.WithSession(ctx, parentSession)
198 userImages := c.inputImages(turn.input)
199 ctx = agent.WithUserImages(ctx, userImages)
200 ctx = agent.WithRawUserInput(ctx, turn.raw)
201 continuation := turn.goalContinuation
202 var input string
203 if continuation != nil {
204 input = c.composeWithGoal(
205 turn.input,
206 turn.raw,
207 false,
208 continuation.goal,
209 GoalStatusRunning,
210 continuation.researchMode,
211 continuation.autoResearchTaskID,
212 )
213 } else {
214 input = c.compose(turn.input, turn.raw, !turn.synthetic)
215 }
216 // input.receive: the composed text crosses the extension chain before it
217 // enters the session (checkpoint, hooks, and the model all see the final
218 // text). A block ruling aborts the turn with the redacted reason surfaced,
219 // mirroring the PromptSubmit hook's abort path; a required-class extension
220 // failure fails the turn.
221 input, blocked, interceptErr := c.interceptInputReceive(ctx, input)
222 if interceptErr != nil {
223 return interceptErr
224 }
225 if blocked {
226 return nil
227 }
228 startMessages := c.messageCount()
229 defer c.snapshotActivityIfChanged(startMessages)
230 defer c.recordDisplayForNewUser(startMessages, turn.display)
231 if turn.editedOriginal != "" {
232 defer c.markEditedForNewUser(startMessages, turn.editedOriginal)
233 }
234 // Open a checkpoint only for visible user turns before the user message is
235 // appended, so the recorded message boundary precedes it and pre-edit
236 // snapshots land here. Synthetic continuations stay attached to the visible
237 // turn that spawned them; otherwise hidden user-role messages would advance
238 // backend checkpoint turns without a matching frontend turn. The label is
239 // the user's own text (raw, falling back to the expanded input) — the
240 // composed provider input carries transient prefab blocks that must never
241 // surface in the rewind picker or be prefilled into the composer.
242 if !turn.synthetic {
243 c.beginCheckpoint(firstNonEmpty(turn.raw, turn.input))
244 }
245 if c.guardianSess != nil {
246 c.guardianSess.ResetTurn()
247 }
248 // UserPromptSubmit / Stop hooks bracket the whole turn (incl. the plan
249 // research + approved-execution sub-turns below): a gating UserPromptSubmit
250 // aborts before any model call; Stop fires once when the turn returns.
251 if c.hooks.Enabled() {
252 c.mu.Lock()
253 c.turn++
254 turn := c.turn
255 c.mu.Unlock()
256 if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
257 return nil // the hook's notify callback already surfaced the reason
258 }
259 defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
260 }
261 c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
262 var autoResearchTaskID string
263 if continuation != nil {
264 autoResearchTaskID = continuation.autoResearchTaskID
265 } else {
266 autoResearchTaskID = c.goals.currentAutoResearchTaskID()
267 }
268 autoResearchAcceptedBefore := c.autoResearchAcceptedEvidenceIDs(autoResearchTaskID)
269 c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
270 if continuation != nil {
271 ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{
272 ID: continuation.scopeID,
273 TaskText: continuation.goal,
274 })
275 } else if scopeID, task, ok := c.goals.deliveryScope(); ok {
276 ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: task})
277 }
278 // Goal turns get a per-turn recorder bound to the goal scope+epoch: the
279 // update_goal tool records its candidate report here, and billable usage
280 // events during the turn fold into the goal's observational token total. The span stays
281 // active until the FSM commits (advanceGoalAfterTurn) so evaluator usage
282 // also counts; error paths that skip the FSM clear it explicitly.
283 if goalScopeID, ok := c.goals.goalScopeIDForTurn(continuation); ok {
284 recorder := c.goals.newTurnRecorder(goalScopeID, c.goals.continuationToken())
285 if c.executor != nil {
286 recorder.setProgressBefore(c.executor.HostProgressSignature())
287 }
288 ctx = tool.WithGoalTurnRecorder(ctx, recorder)
289 c.goalUsageTee.setActiveRecorder(recorder)
290 }
291 modelInput := input
292 if !turn.synthetic {
293 modelInput = c.withCapabilityRoute(ctx, input, turn.raw)
294 }
295 ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic, startMessages)
296 // Real user turns open a fresh Recovery Episode. Goal auto-continues and
297 // other synthetic turns inherit the current Episode so budgets accumulate
298 // only within one host-owned execution round.
299 if !turn.synthetic {
300 c.beginRecoveryEpisode()
301 }
302 err = c.runner.Run(ctx, modelInput)
303 c.persistGoalDeliveryCheckpoint()
304 if err == nil {
305 c.recordAutoResearchEvidenceFromAssistant(autoResearchTaskID, lastAssistantText(c.History()))
306 c.recordAutoResearchTurnProgress(autoResearchTaskID, autoResearchAcceptedBefore)
307 c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatTurnDone, "")
308 c.clearInFlightTurn()
309 } else {
310 c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatWarning, err.Error())
311 // When the user explicitly cancels, keep the real prompt and any fully
312 // paired tool work. Partial reasoning/output remains durable for display
313 // but is marked local-only, and a bounded recovery summary is folded into
314 // the next real user turn (#5499, #6680).
315 if errors.Is(err, context.Canceled) && c.CancelRequested() {
316 if turn.synthetic || IsSyntheticUserMessage(turn.raw) {
317 c.stripInterruptedSyntheticTurnMessagesAfter(startMessages)
318 } else {
319 c.stripCancelledVisibleTurnMessagesAfterWithFallback(startMessages, provider.Message{
320 Role: provider.RoleUser,
321 Content: input,
322 Images: append([]string(nil), userImages...),
323 CreatedAt: time.Now().UnixMilli(),
324 })
325 }
326 } else if !turn.synthetic && !IsSyntheticUserMessage(turn.raw) && c.hasInterruptedDisplayAfter(startMessages, provider.Message{
327 Role: provider.RoleUser, Content: input,
328 }) {
329 // Provider/API failures use the same safe recovery path as an explicit
330 // stop once the agent has recorded a partial stream. Completed tool
331 // pairs survive; unsafe stream fragments stay local-only.
332 c.stripCancelledVisibleTurnMessagesAfterWithFallback(startMessages, provider.Message{
333 Role: provider.RoleUser,
334 Content: input,
335 Images: append([]string(nil), userImages...),
336 CreatedAt: time.Now().UnixMilli(),
337 })
338 }
339 c.clearInFlightTurn()
340 return err
341 }
342 c.mu.Lock()
343 plan := c.planMode
344 c.mu.Unlock()
345 if !plan {
346 return nil
347 }
348 proposal := lastAssistantText(c.History())
349 if proposal == "" {
350 return nil // no substantive proposal to gate
351 }
352 // The plan is already visible as the assistant's answer, so the request
353 // carries no subject — it's purely the gate.
354 allow, _, err := c.requestApproval(ctx, planApprovalTool, "", nil)
355 if err != nil {
356 return err
357 }
358 if !allow {
359 // The host decides whether denial means "revise and keep planning" or
360 // "exit without executing" by leaving plan mode on or switching it off.
361 return nil
362 }
363 c.SetPlanMode(false)
364 todoArgs := c.seedPlanTodos(proposal)
365 execStart := c.sessionMessageCount()
366 // Starting plan execution is a real Recovery Episode boundary even though
367 // the follow-up turn is synthetic.
368 c.beginRecoveryEpisode()
369 // The plan is the go-ahead: don't re-prompt for each write of the approved
370 // work. Auto-approve writers for the duration of this execution turn only; a
371 // later turn (even "continue") falls back to the normal per-tool approval.
372 c.approval.setPlanAutoApprove(true)
373 defer c.approval.setPlanAutoApprove(false)
374 err = func() error {
375 c.markInFlightTurn(execStart, false)
376 defer c.clearInFlightTurn()
377 return o.runComposedSyntheticTurn(ctx, planApprovedMessage)
378 }()
379 if err != nil {
380 if errors.Is(err, context.Canceled) && c.CancelRequested() {
381 c.stripInterruptedSyntheticTurnMessagesAfter(execStart)
382 }
383 return err
384 }
385 if todoArgs != "" && !c.hasTodoUpdateSince(execStart) {
386 c.completePlanTodos(todoArgs)
387 }
388 return nil
389 }
390
391 func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
392 expectedContinuationEpoch := o.c.goals.continuationToken()
393 err := o.runTurnWithRawDisplay(ctx, input, raw, display)
394 if err != nil {
395 if ctx.Err() != nil {
396 o.c.goalUsageTee.setActiveRecorder(nil)
397 o.c.stopGoal(GoalStatusStopped)
398 return err
399 }
400 var readinessErr *agent.FinalReadinessError
401 if !errors.As(err, &readinessErr) || !o.c.goals.active() {
402 // Terminal provider/host error (or a plain non-Goal Delivery
403 // readiness failure): stop auto-continue. With no active Goal the
404 // error surfaces the recovery card; with a Goal it stays running so
405 // the next ordinary user message keeps the same scope.
406 o.c.goalUsageTee.setActiveRecorder(nil)
407 return err
408 }
409 // FinalReadinessError is absorbed below: the Goal FSM continues with
410 // the missing requirements as the next turn's prompt.
411 }
412 return o.continueGoal(ctx, expectedContinuationEpoch, err)
413 }
414
415 func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
416 expectedContinuationEpoch := o.c.goals.continuationToken()
417 err := o.runEditedTurnWithRawDisplay(ctx, input, raw, display, original)
418 if err != nil {
419 if ctx.Err() != nil {
420 o.c.goalUsageTee.setActiveRecorder(nil)
421 o.c.stopGoal(GoalStatusStopped)
422 return err
423 }
424 var readinessErr *agent.FinalReadinessError
425 if !errors.As(err, &readinessErr) || !o.c.goals.active() {
426 o.c.goalUsageTee.setActiveRecorder(nil)
427 return err
428 }
429 }
430 return o.continueGoal(ctx, expectedContinuationEpoch, err)
431 }
432
433 // continueGoal runs the goal auto-continuation loop. A FinalReadinessError
434 // from the last turn is absorbed into the FSM decision (the Goal continues
435 // with the missing requirements); any other terminal error stops the loop and
436 // is returned to the caller.
437 func (o *turnOrchestrator) continueGoal(ctx context.Context, expectedContinuationEpoch uint64, firstTurnErr error) error {
438 c := o.c
439 turnErr := firstTurnErr
440 for {
441 res := o.advanceGoalAfterTurn(ctx, expectedContinuationEpoch, turnErr)
442 if !res.cont {
443 return nil
444 }
445 if err := ctx.Err(); err != nil {
446 c.stopGoal(GoalStatusStopped)
447 return err
448 }
449 intercept, ok := c.goals.acceptContinuation(res)
450 if !ok {
451 return nil
452 }
453 turn := goalContinueTurn
454 if intercept != "" {
455 turn = intercept
456 if res.interceptNotice != "" {
457 c.noticeDetail(res.interceptNotice, intercept)
458 }
459 }
460 admitted, err := o.runGoalContinuationTurnWithRawDisplay(ctx, turn, turn, "", res)
461 if err != nil {
462 if ctx.Err() != nil {
463 c.stopGoal(GoalStatusStopped)
464 return err
465 }
466 var readinessErr *agent.FinalReadinessError
467 if !errors.As(err, &readinessErr) {
468 // Terminal provider/host error: stop auto-continue; the Goal
469 // stays running for the next user turn.
470 c.goalUsageTee.setActiveRecorder(nil)
471 return err
472 }
473 turnErr = err
474 } else {
475 turnErr = nil
476 }
477 if !admitted {
478 return nil
479 }
480 expectedContinuationEpoch = res.continuationEpoch
481 }
482 }
483
484 // advanceGoalAfterTurn gathers every input the FSM needs off the goal lock —
485 // the turn's update_goal report, Delivery readiness, budget/usage state, and
486 // the evaluator verdict — then lets the FSM exclusively decide complete,
487 // continue, blocked, or pause. The usage span bound to this turn stays active
488 // until here so evaluator usage also counts against the goal budget.
489 func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedContinuationEpoch uint64, turnErr error) goalAdvanceResult {
490 c := o.c
491 recorder := c.goalUsageTee.activeRecorder()
492 defer c.goalUsageTee.setActiveRecorder(nil)
493 // Only active Goal turns bind a recorder. Ordinary and edited non-Goal
494 // turns still pass through the shared turn wrapper, but must not enter the
495 // Goal FSM or pay for an isolated completion evaluation.
496 if recorder == nil || recorder.epoch != expectedContinuationEpoch ||
497 !c.goals.turnActive(recorder.scopeID, recorder.epoch) {
498 return goalAdvanceResult{cont: false}
499 }
500
501 var readiness agent.ReadinessResult
502 var readinessErr *agent.FinalReadinessError
503 if errors.As(turnErr, &readinessErr) {
504 readiness = agent.ReadinessResult{
505 Ready: false,
506 Missing: append([]string(nil), readinessErr.Missing...),
507 Reason: readinessErr.Reason,
508 ProgressKey: readinessErr.Reason,
509 }
510 } else if turnErr != nil {
511 // Terminal provider/host error: stop auto-continue without an FSM
512 // transition; the goal stays running for the next user turn.
513 return goalAdvanceResult{cont: false}
514 } else if c.executor != nil {
515 readiness = c.executor.ReadinessResult()
516 }
517 if arReadiness := c.autoResearchReadinessFailure(); arReadiness != "" {
518 readiness.Ready = false
519 readiness.Missing = append(readiness.Missing, "autoresearch")
520 if readiness.Reason != "" {
521 readiness.Reason += "\n" + arReadiness
522 } else {
523 readiness.Reason = arReadiness
524 }
525 }
526 autoResearchTaskID := c.goals.currentAutoResearchTaskID()
527
528 // The validated update_goal report for this turn, if any.
529 var report *goalTurnReport
530 if recorder != nil {
531 report = recorder.validReport(expectedContinuationEpoch)
532 }
533
534 // The bounded evaluator runs once, only when the model gave no report and
535 // readiness has no definite missing list; never past an exhausted turn
536 // budget. Failures fail closed in the FSM.
537 var evaluator *goalEvaluatorVerdict
538 var evaluatorFailed string
539 if report == nil && len(readiness.Missing) == 0 && !c.goals.budgetExhausted() {
540 if c.evaluator == nil {
541 evaluatorFailed = "goal evaluator unavailable"
542 } else if verdict, err := c.evaluator.Evaluate(ctx, c.goalEvaluatorEvidence()); err != nil {
543 evaluatorFailed = err.Error()
544 } else {
545 evaluator = &goalEvaluatorVerdict{outcome: verdict.Outcome, reason: verdict.Reason}
546 }
547 }
548
549 var progressBefore, progressAfter string
550 if recorder != nil {
551 progressBefore = recorder.progressBeforeText()
552 }
553 if c.executor != nil {
554 progressAfter = c.executor.HostProgressSignature()
555 }
556
557 res := c.goals.advance(goalAdvanceInput{
558 report: report,
559 readiness: readiness,
560 evaluator: evaluator,
561 evaluatorFailed: evaluatorFailed,
562 todos: c.goalTodos(),
563 progressBefore: progressBefore,
564 progressAfter: progressAfter,
565 expectedEpoch: &expectedContinuationEpoch,
566 })
567 c.persistGoalState(res.path, res.data, res.ok)
568 if res.notice != "" {
569 c.finalizeAutoResearchTask(autoResearchTaskID, res.notice)
570 c.notice(res.notice)
571 }
572 if res.notice == goalCompleteNotice && c.executor != nil {
573 c.completeRemainingGoalTodos()
574 }
575 return res
576 }
577
578 func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
579 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
580 return
581 }
582 switch {
583 case notice == goalCompleteNotice:
584 status := autoresearch.StatusComplete
585 if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status}); err != nil {
586 c.noticeDetail("AutoResearch status update failed.", "autoresearch task completion update failed: "+err.Error())
587 return
588 }
589 c.notice("autoresearch task completed: " + taskID)
590 case strings.HasPrefix(notice, "goal blocked: ") || notice == "goal continuation limit reached":
591 status := autoresearch.StatusBlocked
592 reason := strings.TrimPrefix(notice, "goal blocked: ")
593 if reason == "" {
594 reason = notice
595 }
596 if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status, BlockedReason: &reason}); err != nil {
597 c.noticeDetail("AutoResearch status update failed.", "autoresearch task blocked update failed: "+err.Error())
598 return
599 }
600 c.noticeDetail("AutoResearch task marked blocked.", "autoresearch task blocked: "+taskID+"\nreason: "+reason)
601 }
602 }
603
604 // completeRemainingGoalTodos force-completes any remaining incomplete canonical
605 // todos when the goal FSM transitions to completed and emits a synthetic
606 // todo_write event so the frontend panel reflects the final state. Handles the
607 // second [goal:complete] override (non-strict) where the model does not mark
608 // each todo individually.
609 func (c *Controller) completeRemainingGoalTodos() {
610 todos := c.executor.CanonicalTodoState()
611 if len(evidence.IncompleteTodos(todos)) == 0 {
612 return
613 }
614 for i := range todos {
615 todos[i].Status = "completed"
616 }
617 args, err := json.Marshal(map[string]any{"todos": todos})
618 if err != nil {
619 return
620 }
621 t := event.Tool{ID: "goal-final", Name: "todo_write", Args: string(args), ReadOnly: true}
622 c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
623 t.Output = "goal completed"
624 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
625 c.executor.ReplaceTodoState(todos)
626 // Persist the completed todo state so a session reload does not revert
627 // to the old incomplete list — the synthetic todo_write events are not
628 // part of the session transcript and rebuildTodoState would otherwise
629 // reconstruct the stale pre-completion state.
630 c.goals.persistWithTodos(todos)
631 }
632
632 lines GO