7eb2b5dc58
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
10 KiB
Go
344 lines
10 KiB
Go
// Package session owns the daemon's in-memory state of truth and persists a
|
|
// snapshot on every change. Transitions go through the pure statemachine. It
|
|
// also owns per-session evidence stats: it accumulates active-window time while
|
|
// Active, logs raw focus events, and seals each session into the audit chain.
|
|
package session
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"antidrift/internal/ai"
|
|
"antidrift/internal/domain"
|
|
"antidrift/internal/enforce"
|
|
"antidrift/internal/evidence"
|
|
"antidrift/internal/knowledge"
|
|
"antidrift/internal/statemachine"
|
|
"antidrift/internal/store"
|
|
"antidrift/internal/tasks"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
unavailableTitle = "(evidence unavailable)"
|
|
sessionRetention = 30 * 24 * time.Hour
|
|
)
|
|
|
|
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
|
|
|
var ErrNotActive = errors.New("session: only available while a commitment is active")
|
|
|
|
// Controller holds runtime state and the active commitment behind a mutex.
|
|
type Controller struct {
|
|
mu sync.Mutex
|
|
runtimeState domain.RuntimeState
|
|
commitment *domain.Commitment
|
|
deadline time.Time
|
|
snapshotPath string
|
|
auditPath string
|
|
sessionsDir string
|
|
clock func() time.Time
|
|
onChange func()
|
|
latestWindow evidence.WindowSnapshot
|
|
stats *EvidenceStats
|
|
outcomePending string
|
|
coach ai.Coach
|
|
coachStatus string
|
|
coachProposal *ai.Proposal
|
|
coachErr string
|
|
coachGen int
|
|
|
|
tasksProvider tasks.Provider
|
|
tasksStatus string
|
|
tasksList []tasks.Task
|
|
tasksGen int
|
|
|
|
knowledgeSrc knowledge.Source
|
|
knowledgeStatus string
|
|
knowledgeText string // cached grounding the coach reads
|
|
knowledgePath string // selected path; "" = adapter default
|
|
knowledgeChars int
|
|
knowledgeGen int
|
|
|
|
reviewer ai.Reviewer
|
|
reflectionStatus string
|
|
reflectionRecap string
|
|
carryForward string // latest-wins takeaway; grounds the next coach
|
|
reflectionGen int
|
|
|
|
allowedClasses []string // durable: the active session's allowed window classes
|
|
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
|
guard enforce.Guard
|
|
judge ai.DriftJudge
|
|
driftStatus string
|
|
driftReason string
|
|
driftGen int
|
|
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
|
|
lastJudgedAt time.Time
|
|
judgedClasses map[string]ai.Verdict
|
|
|
|
nudge ai.Nudger
|
|
recentTitles []string // in-memory ring of recent distinct titles this session
|
|
nudgeMessage string // current soft advisory ("" = none)
|
|
lastNudgedAt time.Time
|
|
}
|
|
|
|
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
|
// in-memory stats from the raw log if a live session was interrupted.
|
|
func New(snapshotPath string) (*Controller, error) {
|
|
s, err := store.Load(snapshotPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir := filepath.Dir(snapshotPath)
|
|
c := &Controller{
|
|
runtimeState: s.RuntimeState,
|
|
commitment: s.Commitment,
|
|
snapshotPath: snapshotPath,
|
|
auditPath: filepath.Join(dir, "audit.jsonl"),
|
|
sessionsDir: filepath.Join(dir, "sessions"),
|
|
clock: time.Now,
|
|
outcomePending: s.OutcomePending,
|
|
reflectionStatus: s.ReflectionStatus,
|
|
reflectionRecap: s.ReflectionRecap,
|
|
carryForward: s.CarryForward,
|
|
}
|
|
if s.DeadlineUnixSecs > 0 {
|
|
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
|
}
|
|
if c.runtimeState == "" {
|
|
c.runtimeState = domain.RuntimeLocked
|
|
}
|
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
|
c.allowedClasses = s.AllowedWindowClasses
|
|
c.enforcementLevel = s.EnforcementLevel
|
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
|
// stale interrupts. This also initializes judgedClasses (the pipeline
|
|
// writes to it) so a restored Active session never panics on a nil map.
|
|
c.resetDriftLocked()
|
|
c.replayStats(s.SessionID)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// SetClock overrides the time source (tests only). Call before starting a
|
|
// session.
|
|
func (c *Controller) SetClock(f func() time.Time) {
|
|
c.mu.Lock()
|
|
c.clock = f
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// SetOnChange registers a callback fired after an evidence-driven state change
|
|
// (focus updates). It is invoked with the mutex released.
|
|
func (c *Controller) SetOnChange(f func()) {
|
|
c.mu.Lock()
|
|
c.onChange = f
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Controller) notify() {
|
|
c.mu.Lock()
|
|
f := c.onChange
|
|
c.mu.Unlock()
|
|
if f != nil {
|
|
f()
|
|
}
|
|
}
|
|
|
|
// State returns the current broadcastable state. Safe for concurrent use.
|
|
func (c *Controller) State() State {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.stateLocked()
|
|
}
|
|
|
|
// Deadline returns the active commitment deadline, or the zero time.
|
|
func (c *Controller) Deadline() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.deadline
|
|
}
|
|
|
|
func (c *Controller) persistLocked() error {
|
|
snap := store.Snapshot{
|
|
RuntimeState: c.runtimeState,
|
|
Commitment: c.commitment,
|
|
OutcomePending: c.outcomePending,
|
|
}
|
|
if !c.deadline.IsZero() {
|
|
snap.DeadlineUnixSecs = c.deadline.Unix()
|
|
}
|
|
if c.stats != nil {
|
|
snap.SessionID = c.stats.SessionID
|
|
}
|
|
snap.AllowedWindowClasses = c.allowedClasses
|
|
snap.EnforcementLevel = c.enforcementLevel
|
|
snap.ReflectionStatus = c.reflectionStatus
|
|
snap.ReflectionRecap = c.reflectionRecap
|
|
snap.CarryForward = c.carryForward
|
|
return store.Save(c.snapshotPath, snap)
|
|
}
|
|
|
|
// EnterPlanning moves Locked -> Planning.
|
|
func (c *Controller) EnterPlanning() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.runtimeState = next
|
|
c.resetCoachLocked()
|
|
c.startTasksFetchLocked()
|
|
c.startKnowledgeFetchLocked()
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
|
func (c *Controller) AllowedClassesForTest() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.allowedClasses...)
|
|
}
|
|
|
|
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
|
// only.
|
|
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.enforcementLevel
|
|
}
|
|
|
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
|
// Active.
|
|
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := c.clock()
|
|
c.runtimeState = next
|
|
c.commitment = &commitment
|
|
c.deadline = now.Add(timebox)
|
|
c.outcomePending = ""
|
|
c.resetCoachLocked()
|
|
c.allowedClasses = append([]string(nil), allowedClasses...)
|
|
c.enforcementLevel = level
|
|
c.resetDriftLocked()
|
|
|
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
|
c.stats = &EvidenceStats{
|
|
SessionID: sessionID,
|
|
StartedUnix: now.Unix(),
|
|
Buckets: map[bucketKey]time.Duration{},
|
|
OnTask: map[bucketKey]time.Duration{},
|
|
OffTask: map[bucketKey]time.Duration{},
|
|
}
|
|
seed := c.latestWindow
|
|
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
|
c.applyEvent(now, seed)
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// Complete moves Active -> Review with a "completed" outcome.
|
|
func (c *Controller) Complete() error { return c.enterReview("completed") }
|
|
|
|
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
|
|
func (c *Controller) Expire() error { return c.enterReview("expired") }
|
|
|
|
func (c *Controller) enterReview(outcome string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.commitment != nil {
|
|
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.commitment.State = completed
|
|
}
|
|
// Flush the final open segment, then freeze accounting.
|
|
if c.stats != nil && c.stats.hasLast {
|
|
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
|
|
c.stats.hasLast = false
|
|
}
|
|
c.runtimeState = next
|
|
c.outcomePending = outcome
|
|
c.startReflectionFetchLocked()
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// End moves Review -> Locked, writes the session summary to the audit chain,
|
|
// and clears the commitment and stats.
|
|
func (c *Controller) End() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.stats != nil {
|
|
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
|
// State integrity over audit completeness: the transition still
|
|
// completes. Surfaced for the operator; no auto-retry in M1.
|
|
log.Printf("session: audit append failed: %v", err)
|
|
}
|
|
}
|
|
c.runtimeState = next
|
|
c.commitment = nil
|
|
c.deadline = time.Time{}
|
|
c.stats = nil
|
|
c.outcomePending = ""
|
|
c.allowedClasses = nil
|
|
c.enforcementLevel = ""
|
|
c.resetDriftLocked()
|
|
return c.persistLocked()
|
|
}
|
|
|
|
func (c *Controller) buildSummaryLocked() store.SessionSummary {
|
|
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
|
for k, d := range c.stats.Buckets {
|
|
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
|
}
|
|
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
|
|
outcome := c.outcomePending
|
|
if outcome == "" {
|
|
outcome = "completed"
|
|
}
|
|
var na, sc string
|
|
if c.commitment != nil {
|
|
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
|
}
|
|
return store.SessionSummary{
|
|
SessionID: c.stats.SessionID,
|
|
NextAction: na,
|
|
SuccessCond: sc,
|
|
Outcome: outcome,
|
|
StartedUnix: c.stats.StartedUnix,
|
|
EndedUnix: c.clock().Unix(),
|
|
SwitchCount: c.stats.SwitchCount,
|
|
Buckets: buckets,
|
|
}
|
|
}
|