返回 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 // ReconcileRuntime applies the read-time lease view without mutating the
86 // authoritative snapshot file.
87 func (ts *TaskSnapshot) ReconcileRuntime(now time.Time) { reconcileRuntime(ts, now) }
88
89 // ValidTaskStates is the set of well-known states.
90 var ValidTaskStates = map[TaskState]bool{
91 TaskStateQueued: true,
92 TaskStateRunning: true,
93 TaskStateWaiting: true,
94 TaskStateSucceeded: true,
95 TaskStateFailed: true,
96 TaskStateCancelled: true,
97 TaskStateStale: true,
98 }
99
100 // IsKnown reports whether s is one of the well-known states.
101 func (s TaskState) IsKnown() bool { return ValidTaskStates[s] }
102
103 // Terminal reports whether s is a terminal state.
104 func (s TaskState) Terminal() bool {
105 switch s {
106 case TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale:
107 return true
108 default:
109 return false
110 }
111 }
112
113 // ValidTransition reports whether moving from current to next is legitimate.
114 func (s TaskState) ValidTransition(next TaskState) bool {
115 if s == next {
116 return false
117 }
118 // Terminal states cannot transition to anything, not even unknown states.
119 if s.Terminal() {
120 return false
121 }
122 // Unknown next states are allowed (forward-compat) provided current is
123 // not terminal (guarded above).
124 if !next.IsKnown() {
125 return true
126 }
127 switch s {
128 case TaskStateQueued:
129 return next == TaskStateRunning || next == TaskStateCancelled ||
130 next == TaskStateStale
131 case TaskStateRunning:
132 return next == TaskStateWaiting || next == TaskStateSucceeded ||
133 next == TaskStateFailed || next == TaskStateCancelled ||
134 next == TaskStateStale
135 case TaskStateWaiting:
136 return next == TaskStateRunning || next == TaskStateSucceeded ||
137 next == TaskStateFailed || next == TaskStateCancelled ||
138 next == TaskStateStale
139 case TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale:
140 return false
141 default:
142 return true // forward-compat
143 }
144 }
145
146 // UnmarshalJSON preserves unknown state values as-is.
147 func (s *TaskState) UnmarshalJSON(data []byte) error {
148 var v string
149 if err := json.Unmarshal(data, &v); err != nil {
150 return err
151 }
152 *s = TaskState(v)
153 return nil
154 }
155
156 // TaskSnapshot is a sanitised snapshot of a single task. It intentionally
157 // omits prompt text, tool arguments, tool results, and reasoning traces.
158 type TaskSnapshot struct {
159 SchemaVersion int `json:"schema_version"`
160 TaskID string `json:"task_id"`
161 // JobID is the jobs.Manager-local runtime identifier. TaskID is the
162 // project-wide monitor identity and may be namespaced by session, so runtime
163 // control must not pass TaskID directly to jobs.Manager.
164 JobID string `json:"job_id,omitempty"`
165 // SessionID is the session the task was created in; it may be empty when
166 // the recorder attached before a session path was resolved.
167 SessionID string `json:"session_id"`
168 State TaskState `json:"state"`
169 RuntimeState RuntimeState `json:"runtime_state,omitempty"`
170 RuntimeLeaseUntil time.Time `json:"runtime_lease_until,omitempty"`
171 // RuntimeOwnerID identifies the recorder generation that owns the live
172 // runtime lease. It prevents a delayed heartbeat from an older controller
173 // from renewing a newer lifecycle that reused the same session/job IDs.
174 RuntimeOwnerID string `json:"runtime_owner_id,omitempty"`
175 Version uint64 `json:"version"`
176 CreatedAt time.Time `json:"created_at"`
177 UpdatedAt time.Time `json:"updated_at"`
178 ErrorCode string `json:"error_code,omitempty"`
179 ErrorSummary string `json:"error_summary,omitempty"`
180 }
181
182 // Validate returns a non-nil error if required fields are missing or
183 // inconsistent, or if any free-form field exceeds its length limit.
184 func (ts TaskSnapshot) Validate() error {
185 if ts.TaskID == "" {
186 return fmt.Errorf("TaskSnapshot.TaskID is required")
187 }
188 if ts.State == "" {
189 return fmt.Errorf("TaskSnapshot.State is required")
190 }
191 if ts.CreatedAt.IsZero() {
192 return fmt.Errorf("TaskSnapshot.CreatedAt is required")
193 }
194 if ts.UpdatedAt.IsZero() {
195 return fmt.Errorf("TaskSnapshot.UpdatedAt is required")
196 }
197 if ts.UpdatedAt.Before(ts.CreatedAt) {
198 return fmt.Errorf("TaskSnapshot.UpdatedAt (%v) is before CreatedAt (%v)",
199 ts.UpdatedAt, ts.CreatedAt)
200 }
201 if ts.SchemaVersion <= 0 {
202 return fmt.Errorf("TaskSnapshot.SchemaVersion must be positive, got %d",
203 ts.SchemaVersion)
204 }
205 if len(ts.TaskID) > maxFieldLen {
206 return fmt.Errorf("TaskSnapshot.TaskID exceeds max length %d", maxFieldLen)
207 }
208 if len(ts.JobID) > maxFieldLen {
209 return fmt.Errorf("TaskSnapshot.JobID exceeds max length %d", maxFieldLen)
210 }
211 if len(ts.SessionID) > maxFieldLen {
212 return fmt.Errorf("TaskSnapshot.SessionID exceeds max length %d", maxFieldLen)
213 }
214 if len(ts.ErrorCode) > maxFieldLen {
215 return fmt.Errorf("TaskSnapshot.ErrorCode exceeds max length %d", maxFieldLen)
216 }
217 if len(ts.RuntimeState) > maxFieldLen {
218 return fmt.Errorf("TaskSnapshot.RuntimeState exceeds max length %d", maxFieldLen)
219 }
220 if len(ts.RuntimeOwnerID) > maxFieldLen {
221 return fmt.Errorf("TaskSnapshot.RuntimeOwnerID exceeds max length %d", maxFieldLen)
222 }
223 if !ts.RuntimeLeaseUntil.IsZero() && ts.RuntimeLeaseUntil.Before(ts.CreatedAt) {
224 return fmt.Errorf("TaskSnapshot.RuntimeLeaseUntil is before CreatedAt")
225 }
226 if len(ts.ErrorSummary) > maxErrorSummaryLen {
227 return fmt.Errorf("TaskSnapshot.ErrorSummary exceeds max length %d",
228 maxErrorSummaryLen)
229 }
230 return nil
231 }
232
233 // TaskEvent is a single sanitised event in a task's lifecycle.
234 type TaskEvent struct {
235 Sequence int `json:"sequence"`
236 Timestamp time.Time `json:"timestamp"`
237 EventType string `json:"event_type"`
238 TaskID string `json:"task_id"`
239 SessionID string `json:"session_id"`
240 State TaskState `json:"state"`
241 RuntimeState RuntimeState `json:"runtime_state,omitempty"`
242 ErrorCode string `json:"error_code,omitempty"`
243 ErrorSummary string `json:"error_summary,omitempty"`
244 }
245
246 // Validate returns a non-nil error on required-field violations.
247 func (te TaskEvent) Validate() error {
248 if te.Sequence <= 0 {
249 return fmt.Errorf("TaskEvent.Sequence must be positive, got %d", te.Sequence)
250 }
251 if te.TaskID == "" {
252 return fmt.Errorf("TaskEvent.TaskID is required")
253 }
254 if te.State == "" {
255 return fmt.Errorf("TaskEvent.State is required")
256 }
257 if te.EventType == "" {
258 return fmt.Errorf("TaskEvent.EventType is required")
259 }
260 if te.Timestamp.IsZero() {
261 return fmt.Errorf("TaskEvent.Timestamp is required")
262 }
263 if len(te.TaskID) > maxFieldLen {
264 return fmt.Errorf("TaskEvent.TaskID exceeds max length %d", maxFieldLen)
265 }
266 if len(te.SessionID) > maxFieldLen {
267 return fmt.Errorf("TaskEvent.SessionID exceeds max length %d", maxFieldLen)
268 }
269 if len(te.EventType) > maxFieldLen {
270 return fmt.Errorf("TaskEvent.EventType exceeds max length %d", maxFieldLen)
271 }
272 if len(te.ErrorCode) > maxFieldLen {
273 return fmt.Errorf("TaskEvent.ErrorCode exceeds max length %d", maxFieldLen)
274 }
275 if len(te.RuntimeState) > maxFieldLen {
276 return fmt.Errorf("TaskEvent.RuntimeState exceeds max length %d", maxFieldLen)
277 }
278 if len(te.ErrorSummary) > maxErrorSummaryLen {
279 return fmt.Errorf("TaskEvent.ErrorSummary exceeds max length %d",
280 maxErrorSummaryLen)
281 }
282 return nil
283 }
284
284 lines GO