| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "sync" |
| 5 | "sync/atomic" |
| 6 | |
| 7 | "reasonix/internal/evidence" |
| 8 | ) |
| 9 | |
| 10 | // sessionRuntime is the host state one conversation owns. Its lifetime sits |
| 11 | // between the process and the task: SetSession replaces the conversation and |
| 12 | // reset restarts everything here that belongs to it. Atomics and mutexes make |
| 13 | // the whole-value assignment taskRuntime uses illegal, so the "no field is |
| 14 | // forgotten" property is enforced by sessionstate_test.go instead. |
| 15 | type sessionRuntime struct { |
| 16 | mu sync.Mutex // guards conversation for external Session()/SetSession |
| 17 | conversation *Session |
| 18 | output outputBudgetState |
| 19 | |
| 20 | // cacheHit/cacheMiss are the session aggregate, which compaction must not |
| 21 | // reset — the hit-rate would crater every time the visible prefix is folded. |
| 22 | // Atomic: the run loop accumulates while the status line reads. |
| 23 | cacheHit atomic.Int64 |
| 24 | cacheMiss atomic.Int64 |
| 25 | |
| 26 | missingReasoning missingReasoningWatch |
| 27 | |
| 28 | // reasoningReplayStrongProjection records the provider-visible history cutoff |
| 29 | // after thinking-400 repair; later messages use normal replay. Its anchor |
| 30 | // resolves the cutoff after old tool-result messages are removed. |
| 31 | reasoningReplayStrongProjection int |
| 32 | reasoningReplayStrongProjectionAnchor string |
| 33 | |
| 34 | // compactionMu guards projection snapshots/install and the in-memory sidecar |
| 35 | // generation. Network summarization never runs while this lock is held. |
| 36 | compactionMu sync.Mutex |
| 37 | // compactionRunMu singleflights the expensive summary transaction without |
| 38 | // holding the session lock during network I/O. |
| 39 | compactionRunMu sync.Mutex |
| 40 | compaction compactionProgress |
| 41 | compactionState CompactionState |
| 42 | cacheState string // legacy resume telemetry; never provider-visible |
| 43 | |
| 44 | // path is rebound by preflight when a transcript is bound. Checkpoint state |
| 45 | // and any unconfirmed commit belong to the current conversation and reset. |
| 46 | path string // bound transcript path for projection sidecars |
| 47 | checkpointState string // none|restored|pending|applied; runtime-only |
| 48 | // pendingModelContextCommit is an event-log commit that was accepted but |
| 49 | // whose durability barrier did not complete. The exact payload is retained |
| 50 | // so the next model boundary can retry idempotently before any provider work. |
| 51 | pendingModelContextCommit *SessionModelContextCommit |
| 52 | |
| 53 | // todoState is an executor-local mirror populated only after the semantic |
| 54 | // ToolResult commit succeeds. It never rebuilds from transcript text and is |
| 55 | // never used as frontend or authorization state. |
| 56 | todoMu sync.Mutex |
| 57 | todoState []evidence.TodoItem |
| 58 | todoWritten bool |
| 59 | |
| 60 | // lastPrefixShape records the previous provider request's cacheable prefix |
| 61 | // so usage events can explain prefix churn on the next request. Carried |
| 62 | // across a conversation swap; see sessionCarryOver. |
| 63 | lastPrefixShape PrefixShape |
| 64 | haveLastPrefixShape bool |
| 65 | } |
| 66 | |
| 67 | // reset rebinds the runtime to a new conversation. Every field is named here or |
| 68 | // in sessionCarryOver, and sessionstate_test.go checks both lists against the |
| 69 | // struct: an atomic-bearing type cannot be replaced by one assignment, so the |
| 70 | // guarantee has to be tested rather than compiled. |
| 71 | func (r *sessionRuntime) reset(s *Session) { |
| 72 | r.mu.Lock() |
| 73 | r.conversation = s |
| 74 | r.mu.Unlock() |
| 75 | r.cacheHit.Store(0) |
| 76 | r.cacheMiss.Store(0) |
| 77 | r.output.reset() |
| 78 | r.missingReasoning = missingReasoningWatch{} |
| 79 | r.reasoningReplayStrongProjection = 0 |
| 80 | r.reasoningReplayStrongProjectionAnchor = "" |
| 81 | r.compactionMu.Lock() |
| 82 | r.compactionState = CompactionState{} // lineage change; disk reloaded on Resume |
| 83 | r.cacheState = CacheStateUnknown |
| 84 | r.checkpointState = "none" |
| 85 | r.pendingModelContextCommit = nil |
| 86 | r.compactionMu.Unlock() |
| 87 | r.compaction.stuck = false |
| 88 | r.compaction.stuckInputHash = "" |
| 89 | r.compaction.consecutive = 0 |
| 90 | r.compaction.failedTurn.Store(0) |
| 91 | r.compaction.lastTurn.Store(0) |
| 92 | r.todoMu.Lock() |
| 93 | r.todoState = nil |
| 94 | r.todoWritten = false |
| 95 | r.todoMu.Unlock() |
| 96 | } |
| 97 | |
| 98 | // clearReasoningReplayStrongProjection drops the process-local repair overlay. |
| 99 | // The overlay is tied to one canonical history shape; any rewind, branch, or |
| 100 | // other lineage rewrite must not let an old cutoff/anchor govern the new view. |
| 101 | func (r *sessionRuntime) clearReasoningReplayStrongProjection() { |
| 102 | if r == nil { |
| 103 | return |
| 104 | } |
| 105 | r.reasoningReplayStrongProjection = 0 |
| 106 | r.reasoningReplayStrongProjectionAnchor = "" |
| 107 | } |
| 108 | |
| 109 | // session returns the bound conversation under the lock that guards the |
| 110 | // pointer against a concurrent SetSession. |
| 111 | func (r *sessionRuntime) session() *Session { |
| 112 | r.mu.Lock() |
| 113 | defer r.mu.Unlock() |
| 114 | return r.conversation |
| 115 | } |
| 116 |