| 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 |