返回 DeepSeek-Reasonix
model.go
根目录 / internal / taskmonitor / model.go
1 // Package taskmonitor defines the unified Task Monitor domain model and
2 // read-only query interfaces for observing background tasks. It provides
3 // TaskSnapshot, TaskEvent, TaskState, RuntimeState and a Store abstraction.
4 //
5 // The package does not read private session files, does not parse internal
6 // Reasonix state files, and does not implement a second state machine — it
7 // is a pure observation layer that reuses the existing jobs.Manager as its
8 // source of truth.
9 package taskmonitor
10
11 import (
12 "encoding/json"
13 "fmt"
14 "time"
15 )
16
17 // TaskState enumerates the observable lifecycle states of a background task.
18 type TaskState string
19
20 // RuntimeState reports whether a task still has live execution behind its
21 // persisted lifecycle state. It is intentionally independent from TaskState:
22 // for example, a requeued task is queued but its previous runtime has exited.
23 // The empty value is accepted for snapshots written before this field existed
24 // and is interpreted as unknown.
25 type RuntimeState string
26
27 const (
28 TaskStateQueued TaskState = "queued"
29 TaskStateRunning TaskState = "running"
30 TaskStateWaiting TaskState = "waiting"
31 TaskStateSucceeded TaskState = "succeeded"
32 TaskStateFailed TaskState = "failed"
33 TaskStateCancelled TaskState = "cancelled"
34 TaskStateStale TaskState = "stale"
35
36 RuntimeStateUnknown RuntimeState = "unknown"
37 RuntimeStateAlive RuntimeState = "alive"
38 RuntimeStateExited RuntimeState = "exited"
39
40 // maxFieldLen is the maximum byte length for free-form string fields
41 // (TaskID, SessionID, ErrorCode, EventType). It prevents memory-
42 // exhaustion attacks from unbounded JSON input.
43 maxFieldLen = 256
44
45 // maxErrorSummaryLen is the maximum byte length for ErrorSummary.
46 maxErrorSummaryLen = 1024
47 )
48
49 // Effective returns unknown for legacy snapshots and events that predate the
50 // runtime_state field.
51 func (s RuntimeState) Effective() RuntimeState {
52 if s == "" {
53 return RuntimeStateUnknown
54 }
55 return s
56 }
57
58 // IsKnown reports whether s is one of the well-known runtime states. The empty
59 // legacy value is treated as the known unknown state.
60 func (s RuntimeState) IsKnown() bool {
61 switch s.Effective() {
62 case RuntimeStateUnknown, RuntimeStateAlive, RuntimeStateExited:
63 return true
64 default:
65 return false
66 }
67 }
68
69 // reconcileRuntime marks an alive snapshot stale when its owner lease has
70 // expired. It is deliberately pure; callers decide whether to persist the
71 // reconciled value.
72 func reconcileRuntime(snap *TaskSnapshot, now time.Time) {
73 if snap == nil || snap.RuntimeState.Effective() != RuntimeStateAlive || snap.RuntimeLeaseUntil.IsZero() {
74 return
75 }
76 if now.Before(snap.RuntimeLeaseUntil) {
77 return
78 }
79 snap.RuntimeState = RuntimeStateExited
80 if !snap.State.Terminal() {
81 snap.State = TaskStateStale
82 }
83 }
84
85 // ValidTaskStates is the set of well-known states.
86 var ValidTaskStates = map[TaskState]bool{
87 TaskStateQueued: true,
88 TaskStateRunning: true,
89 TaskStateWaiting: true,
90 TaskStateSucceeded: true,
91 TaskStateFailed: true,
92 TaskStateCancelled: true,
93 TaskStateStale: true,
94 }
95
96 // IsKnown reports whether s is one of the well-known states.
97 func (s TaskState) IsKnown() bool { return ValidTaskStates[s] }
98
99 // Terminal reports whether s is a terminal state.
100 func (s TaskState) Terminal() bool {
101 switch s {
102 case TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale:
103 return true
104 default:
105 return false
106 }
107 }
108
109 // ValidTransition reports whether moving from current to next is legitimate.
110 func (s TaskState) ValidTransition(next TaskState) bool {
111 if s == next {
112 return false
113 }
114 // Terminal states cannot transition to anything, not even unknown states.
115 if s.Terminal() {
116 return false
117 }
118 // Unknown next states are allowed (forward-compat) provided current is
119 // not terminal (guarded above).
120 if !next.IsKnown() {
121 return true
122 }
123 switch s {
124 case TaskStateQueued:
125 return next == TaskStateRunning || next == TaskStateCancelled ||
126 next == TaskStateStale
127 case TaskStateRunning:
128 return next == TaskStateWaiting || next == TaskStateSucceeded ||
129 next == TaskStateFailed || next == TaskStateCancelled ||
130 next == TaskStateStale
131 case TaskStateWaiting:
132 return next == TaskStateRunning || next == TaskStateSucceeded ||
133 next == TaskStateFailed || next == TaskStateCancelled ||
134 next == TaskStateStale
135 case TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale:
136 return false
137 default:
138 return true // forward-compat
139 }
140 }
141
142 // UnmarshalJSON preserves unknown state values as-is.
143 func (s *TaskState) UnmarshalJSON(data []byte) error {
144 var v string
145 if err := json.Unmarshal(data, &v); err != nil {
146 return err
147 }
148 *s = TaskState(v)
149 return nil
150 }
151
152 // TaskSnapshot is a sanitised snapshot of a single task. It intentionally
153 // omits prompt text, tool arguments, tool results, and reasoning traces.
154 type TaskSnapshot struct {
155 SchemaVersion int `json:"schema_version"`
156 TaskID string `json:"task_id"`
157 // JobID is the jobs.Manager-local runtime identifier. TaskID is the
158 // project-wide monitor identity and may be namespaced by session, so runtime
159 // control must not pass TaskID directly to jobs.Manager.
160 JobID string `json:"job_id,omitempty"`
161 // SessionID is the session the task was created in; it may be empty when
162 // the recorder attached before a session path was resolved.
163 SessionID string `json:"session_id"`
164 State TaskState `json:"state"`
165 RuntimeState RuntimeState `json:"runtime_state,omitempty"`
166 RuntimeLeaseUntil time.Time `json:"runtime_lease_until,omitempty"`
167 // RuntimeOwnerID identifies the recorder generation that owns the live
168 // runtime lease. It prevents a delayed heartbeat from an older controller
169 // from renewing a newer lifecycle that reused the same session/job IDs.
170 RuntimeOwnerID string `json:"runtime_owner_id,omitempty"`
171 Version uint64 `json:"version"`
172 CreatedAt time.Time `json:"created_at"`
173 UpdatedAt time.Time `json:"updated_at"`
174 ErrorCode string `json:"error_code,omitempty"`
175 ErrorSummary string `json:"error_summary,omitempty"`
176 }
177
178 // Validate returns a non-nil error if required fields are missing or
179 // inconsistent, or if any free-form field exceeds its length limit.
180 func (ts TaskSnapshot) Validate() error {
181 if ts.TaskID == "" {
182 return fmt.Errorf("TaskSnapshot.TaskID is required")
183 }
184 if ts.State == "" {
185 return fmt.Errorf("TaskSnapshot.State is required")
186 }
187 if ts.CreatedAt.IsZero() {
188 return fmt.Errorf("TaskSnapshot.CreatedAt is required")
189 }
190 if ts.UpdatedAt.IsZero() {
191 return fmt.Errorf("TaskSnapshot.UpdatedAt is required")
192 }
193 if ts.UpdatedAt.Before(ts.CreatedAt) {
194 return fmt.Errorf("TaskSnapshot.UpdatedAt (%v) is before CreatedAt (%v)",
195 ts.UpdatedAt, ts.CreatedAt)
196 }
197 if ts.SchemaVersion <= 0 {
198 return fmt.Errorf("TaskSnapshot.SchemaVersion must be positive, got %d",
199 ts.SchemaVersion)
200 }
201 if len(ts.TaskID) > maxFieldLen {
202 return fmt.Errorf("TaskSnapshot.TaskID exceeds max length %d", maxFieldLen)
203 }
204 if len(ts.JobID) > maxFieldLen {
205 return fmt.Errorf("TaskSnapshot.JobID exceeds max length %d", maxFieldLen)
206 }
207 if len(ts.SessionID) > maxFieldLen {
208 return fmt.Errorf("TaskSnapshot.SessionID exceeds max length %d", maxFieldLen)
209 }
210 if len(ts.ErrorCode) > maxFieldLen {
211 return fmt.Errorf("TaskSnapshot.ErrorCode exceeds max length %d", maxFieldLen)
212 }
213 if len(ts.RuntimeState) > maxFieldLen {
214 return fmt.Errorf("TaskSnapshot.RuntimeState exceeds max length %d", maxFieldLen)
215 }
216 if len(ts.RuntimeOwnerID) > maxFieldLen {
217 return fmt.Errorf("TaskSnapshot.RuntimeOwnerID exceeds max length %d", maxFieldLen)
218 }
219 if !ts.RuntimeLeaseUntil.IsZero() && ts.RuntimeLeaseUntil.Before(ts.CreatedAt) {
220 return fmt.Errorf("TaskSnapshot.RuntimeLeaseUntil is before CreatedAt")
221 }
222 if len(ts.ErrorSummary) > maxErrorSummaryLen {
223 return fmt.Errorf("TaskSnapshot.ErrorSummary exceeds max length %d",
224 maxErrorSummaryLen)
225 }
226 return nil
227 }
228
229 // TaskEvent is a single sanitised event in a task's lifecycle.
230 type TaskEvent struct {
231 Sequence int `json:"sequence"`
232 Timestamp time.Time `json:"timestamp"`
233 EventType string `json:"event_type"`
234 TaskID string `json:"task_id"`
235 SessionID string `json:"session_id"`
236 State TaskState `json:"state"`
237 RuntimeState RuntimeState `json:"runtime_state,omitempty"`
238 ErrorCode string `json:"error_code,omitempty"`
239 ErrorSummary string `json:"error_summary,omitempty"`
240 }
241
242 // Validate returns a non-nil error on required-field violations.
243 func (te TaskEvent) Validate() error {
244 if te.Sequence <= 0 {
245 return fmt.Errorf("TaskEvent.Sequence must be positive, got %d", te.Sequence)
246 }
247 if te.TaskID == "" {
248 return fmt.Errorf("TaskEvent.TaskID is required")
249 }
250 if te.State == "" {
251 return fmt.Errorf("TaskEvent.State is required")
252 }
253 if te.EventType == "" {
254 return fmt.Errorf("TaskEvent.EventType is required")
255 }
256 if te.Timestamp.IsZero() {
257 return fmt.Errorf("TaskEvent.Timestamp is required")
258 }
259 if len(te.TaskID) > maxFieldLen {
260 return fmt.Errorf("TaskEvent.TaskID exceeds max length %d", maxFieldLen)
261 }
262 if len(te.SessionID) > maxFieldLen {
263 return fmt.Errorf("TaskEvent.SessionID exceeds max length %d", maxFieldLen)
264 }
265 if len(te.EventType) > maxFieldLen {
266 return fmt.Errorf("TaskEvent.EventType exceeds max length %d", maxFieldLen)
267 }
268 if len(te.ErrorCode) > maxFieldLen {
269 return fmt.Errorf("TaskEvent.ErrorCode exceeds max length %d", maxFieldLen)
270 }
271 if len(te.RuntimeState) > maxFieldLen {
272 return fmt.Errorf("TaskEvent.RuntimeState exceeds max length %d", maxFieldLen)
273 }
274 if len(te.ErrorSummary) > maxErrorSummaryLen {
275 return fmt.Errorf("TaskEvent.ErrorSummary exceeds max length %d",
276 maxErrorSummaryLen)
277 }
278 return nil
279 }
280
280 lines GO