返回 DeepSeek-Reasonix
goal_lifecycle_owner.go
根目录 / internal / control / goal_lifecycle_owner.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7
8 "reasonix/internal/event"
9 goaldomain "reasonix/internal/goal"
10 "reasonix/internal/session"
11 "reasonix/internal/tool"
12 )
13
14 const defaultBlockedAfterRounds uint64 = 3
15
16 func (c *Controller) GetGoal(context.Context) (*goaldomain.View, error) {
17 return c.goalLifecycleView()
18 }
19
20 func (c *Controller) CreateGoal(ctx context.Context, request goaldomain.CreateRequest, authority tool.GoalAuthority) (goaldomain.View, error) {
21 view, err := c.applyGoalMutation(ctx, authority, tool.GoalActionCreate, func(machine *goaldomain.Machine) (goaldomain.View, error) {
22 return machine.Create(request)
23 })
24 if err == nil {
25 c.resetGoalResourceBudget()
26 }
27 return view, err
28 }
29
30 func (c *Controller) UpdateGoal(ctx context.Context, request tool.GoalUpdateRequest, authority tool.GoalAuthority) (goaldomain.View, error) {
31 return c.applyGoalMutation(ctx, authority, request.Action, func(machine *goaldomain.Machine) (goaldomain.View, error) {
32 switch request.Action {
33 case tool.GoalActionEdit:
34 if request.BlockedReason != nil {
35 return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidEdit, Message: "blocked_reason is valid only with action blocked"}
36 }
37 return machine.Edit(request.Ref, goaldomain.EditRequest{Objective: request.Objective, MaxGoalRounds: request.MaxGoalRounds})
38 case tool.GoalActionPause:
39 return machine.Pause(request.Ref)
40 case tool.GoalActionResume:
41 if current := machine.Get(); current != nil && current.Phase == goaldomain.PhasePaused {
42 return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "a user-paused goal must be resumed through an explicit UI or command action"}
43 }
44 return machine.Resume(request.Ref, authority.Source == tool.GoalSourceDirectHuman)
45 case tool.GoalActionComplete:
46 return machine.Complete(request.Ref)
47 case tool.GoalActionBlocked:
48 if request.BlockedReason == nil {
49 return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidBlockReason, Message: "blocked action requires blocked_reason"}
50 }
51 return machine.Block(request.Ref, *request.BlockedReason, authority.Source == tool.GoalSourceDirectHuman, defaultBlockedAfterRounds)
52 default:
53 return goaldomain.View{}, &goaldomain.Error{Code: goaldomain.ErrInvalidTransition, Message: fmt.Sprintf("unsupported goal action %q", request.Action)}
54 }
55 })
56 }
57
58 func (c *Controller) applyGoalMutation(
59 ctx context.Context,
60 authority tool.GoalAuthority,
61 action tool.GoalAction,
62 mutate func(*goaldomain.Machine) (goaldomain.View, error),
63 ) (goaldomain.View, error) {
64 if c == nil || mutate == nil {
65 return goaldomain.View{}, session.ErrSessionNotRunning
66 }
67 c.goalLifecycleMutationMu.Lock()
68 defer c.goalLifecycleMutationMu.Unlock()
69 runtime, err := c.validateGoalAuthority(authority, action)
70 if err != nil {
71 return goaldomain.View{}, err
72 }
73 c.goalLifecycleMu.RLock()
74 machine, loadErr := c.goalLifecycle, c.goalLifecycleLoadErr
75 c.goalLifecycleMu.RUnlock()
76 if loadErr != nil {
77 return goaldomain.View{}, fmt.Errorf("goal lifecycle is unavailable: %w", loadErr)
78 }
79 if machine == nil {
80 return goaldomain.View{}, session.ErrSessionNotRunning
81 }
82 candidate := machine.Clone()
83 view, err := mutate(candidate)
84 if err != nil {
85 return goaldomain.View{}, err
86 }
87 payload, err := candidate.Encode()
88 if err != nil {
89 return goaldomain.View{}, err
90 }
91 operationID := fmt.Sprintf("goal:%s:%s:%d:%s", runtime.Ref().SessionID, view.ID, view.Revision, action)
92 appendCtx := ctx
93 if appendCtx == nil || appendCtx.Err() != nil {
94 appendCtx = context.Background()
95 }
96 if _, err := runtime.Session().Append(appendCtx, session.Batch{
97 OperationID: operationID,
98 TurnID: runtime.Session().ExecutionSnapshot().Projection.TurnID,
99 Events: []session.Event{{Kind: "goal/state", Payload: json.RawMessage(payload)}},
100 }); err != nil {
101 return goaldomain.View{}, err
102 }
103 // Session.Append is the final authority check. Once accepted, goal/state is
104 // a session fact and must be published locally; a stale error here could make
105 // the model repeat a mutation whose durability is already certain.
106 _, currentRuntime, stillExclusive := c.v3Binding()
107 if !stillExclusive || currentRuntime != runtime {
108 // The accepted fact belongs to the runtime that authorized this tool
109 // call. A concurrent session switch installs its own projection, so do
110 // not publish the old session's candidate into the new runtime.
111 return view, nil
112 }
113 c.goalLifecycleMu.Lock()
114 if c.goalLifecycle == machine && c.goalLifecycleLoadErr == nil {
115 c.goalLifecycle = candidate
116 }
117 c.goalLifecycleMu.Unlock()
118 c.refreshRuntimeState(event.Event{})
119 return view, nil
120 }
121
122 func (c *Controller) validateGoalAuthority(authority tool.GoalAuthority, action tool.GoalAction) (*session.Runtime, error) {
123 if !authority.Allows(action) {
124 return nil, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "current execution is not allowed to perform this goal action"}
125 }
126 _, runtime, exclusive := c.v3Binding()
127 if !exclusive || runtime == nil {
128 return nil, session.ErrSessionNotRunning
129 }
130 snapshot := runtime.StateSnapshot()
131 if snapshot.Phase != session.RuntimeRunning ||
132 authority.SessionID != snapshot.Ref.SessionID ||
133 authority.RuntimeEpoch != snapshot.Epoch ||
134 authority.ActivityID != snapshot.ActivityRevision {
135 return nil, &goaldomain.Error{Code: goaldomain.ErrUserAuthorityRequired, Message: "goal authority no longer matches the active runtime"}
136 }
137 if authority.Source == tool.GoalSourceGoalRound {
138 view, err := c.goalLifecycleView()
139 if err != nil {
140 return nil, err
141 }
142 if view == nil || view.ID != authority.GoalID || view.Revision != authority.Revision || view.RoundsStarted != authority.Round {
143 return nil, &goaldomain.Error{Code: goaldomain.ErrStaleRevision, Message: "goal round authority is stale"}
144 }
145 }
146 return runtime, nil
147 }
148
148 lines GO