8b7bec1173
The controller fetches the Marvin task list when entering planning, mirroring the planning coach: generation-guarded goroutine, status enum (idle/pending/ready/error), projected into State only while planning and only when a provider is set. nil provider degrades to no tasks view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
945 lines
27 KiB
Go
945 lines
27 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 (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"antidrift/internal/ai"
|
|
"antidrift/internal/domain"
|
|
"antidrift/internal/evidence"
|
|
"antidrift/internal/statemachine"
|
|
"antidrift/internal/store"
|
|
"antidrift/internal/tasks"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
unavailableTitle = "(evidence unavailable)"
|
|
sessionRetention = 30 * 24 * time.Hour
|
|
)
|
|
|
|
const coachTimeout = 60 * time.Second
|
|
|
|
const (
|
|
coachIdle = "idle"
|
|
coachPending = "pending"
|
|
coachReady = "ready"
|
|
coachError = "error"
|
|
)
|
|
|
|
const tasksTimeout = 30 * time.Second
|
|
|
|
const (
|
|
tasksIdle = "idle"
|
|
tasksPending = "pending"
|
|
tasksReady = "ready"
|
|
tasksError = "error"
|
|
)
|
|
|
|
const (
|
|
driftDebounce = 10 * time.Second
|
|
driftTimeout = 30 * time.Second
|
|
nudgeDebounce = 5 * time.Minute
|
|
nudgeTimeout = 30 * time.Second
|
|
)
|
|
|
|
const recentTitlesMax = 10
|
|
|
|
const (
|
|
driftIdle = "idle"
|
|
driftPending = "pending"
|
|
driftOnTask = "ontask"
|
|
driftDrifting = "drifting"
|
|
)
|
|
|
|
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
|
|
|
var ErrNotActive = errors.New("session: only available while a commitment is active")
|
|
|
|
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
|
type bucketKey struct{ Class, Title string }
|
|
|
|
// EvidenceStats is the in-memory accounting for the current session only.
|
|
type EvidenceStats struct {
|
|
SessionID string
|
|
StartedUnix int64
|
|
Buckets map[bucketKey]time.Duration
|
|
SwitchCount int
|
|
Current evidence.WindowSnapshot
|
|
lastFocusAt time.Time
|
|
lastKey bucketKey
|
|
hasLast bool
|
|
}
|
|
|
|
// 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
|
|
|
|
allowedClasses []string // durable: the active session's allowed window classes
|
|
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
|
|
}
|
|
|
|
// CommitmentView is the UI projection of the active commitment.
|
|
type CommitmentView struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxSecs int64 `json:"timebox_secs"`
|
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
|
|
}
|
|
|
|
// ProposalView / CoachView project the ephemeral planning-coach state.
|
|
type ProposalView struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxSecs int64 `json:"timebox_secs"`
|
|
|
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
|
}
|
|
|
|
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
|
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
|
// inside an allowed app), so it cannot be folded into the status enum.
|
|
type DriftView struct {
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Nudge string `json:"nudge,omitempty"`
|
|
}
|
|
|
|
type CoachView struct {
|
|
Status string `json:"status"`
|
|
Proposal *ProposalView `json:"proposal,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// TaskView is one to-do item in the planning tasks list.
|
|
type TaskView struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Day string `json:"day,omitempty"`
|
|
}
|
|
|
|
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
|
|
type TasksView struct {
|
|
Status string `json:"status"`
|
|
Tasks []TaskView `json:"tasks,omitempty"`
|
|
}
|
|
|
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
|
type WindowView struct {
|
|
Class string `json:"class"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type BucketView struct {
|
|
Class string `json:"class"`
|
|
Title string `json:"title"`
|
|
Seconds int64 `json:"seconds"`
|
|
}
|
|
|
|
type EvidenceView struct {
|
|
Available bool `json:"available"`
|
|
Reason string `json:"reason"`
|
|
Current WindowView `json:"current"`
|
|
SwitchCount int `json:"switch_count"`
|
|
Buckets []BucketView `json:"buckets"`
|
|
}
|
|
|
|
// State is the broadcastable view of the controller.
|
|
type State struct {
|
|
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
|
Commitment *CommitmentView `json:"commitment"`
|
|
Evidence *EvidenceView `json:"evidence"`
|
|
Coach *CoachView `json:"coach,omitempty"`
|
|
Tasks *TasksView `json:"tasks,omitempty"`
|
|
Drift *DriftView `json:"drift,omitempty"`
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
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
|
|
// 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) stateLocked() State {
|
|
st := State{RuntimeState: c.runtimeState}
|
|
if c.commitment != nil {
|
|
view := &CommitmentView{
|
|
NextAction: c.commitment.NextAction,
|
|
SuccessCondition: c.commitment.SuccessCondition,
|
|
TimeboxSecs: c.commitment.TimeboxSecs,
|
|
}
|
|
if !c.deadline.IsZero() {
|
|
view.DeadlineUnixSecs = c.deadline.Unix()
|
|
}
|
|
st.Commitment = view
|
|
}
|
|
if c.stats != nil {
|
|
st.Evidence = &EvidenceView{
|
|
Available: c.stats.Current.Health.Available,
|
|
Reason: c.stats.Current.Health.Reason,
|
|
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
|
|
SwitchCount: c.stats.SwitchCount,
|
|
Buckets: bucketViews(c.stats.Buckets),
|
|
}
|
|
}
|
|
if c.runtimeState == domain.RuntimePlanning {
|
|
status := c.coachStatus
|
|
if status == "" {
|
|
status = coachIdle
|
|
}
|
|
cv := &CoachView{Status: status, Error: c.coachErr}
|
|
if c.coachProposal != nil {
|
|
cv.Proposal = &ProposalView{
|
|
NextAction: c.coachProposal.NextAction,
|
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
|
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
|
}
|
|
}
|
|
st.Coach = cv
|
|
if c.tasksProvider != nil {
|
|
tstatus := c.tasksStatus
|
|
if tstatus == "" {
|
|
tstatus = tasksIdle
|
|
}
|
|
tv := &TasksView{Status: tstatus}
|
|
for _, t := range c.tasksList {
|
|
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
|
|
}
|
|
st.Tasks = tv
|
|
}
|
|
}
|
|
if c.runtimeState == domain.RuntimeActive {
|
|
status := c.driftStatus
|
|
if status == "" {
|
|
status = driftIdle
|
|
}
|
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
|
}
|
|
return st
|
|
}
|
|
|
|
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
|
|
out := make([]BucketView, 0, len(buckets))
|
|
for k, d := range buckets {
|
|
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
|
|
return out
|
|
}
|
|
|
|
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
|
|
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()
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
|
// RequestCoach degrade gracefully.
|
|
func (c *Controller) SetCoach(coach ai.Coach) {
|
|
c.mu.Lock()
|
|
c.coach = coach
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// resetCoachLocked returns coach state to idle and invalidates any in-flight
|
|
// request. Caller holds mu.
|
|
func (c *Controller) resetCoachLocked() {
|
|
c.coachStatus = coachIdle
|
|
c.coachProposal = nil
|
|
c.coachErr = ""
|
|
c.coachGen++
|
|
}
|
|
|
|
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
|
// the planning tasks list absent.
|
|
func (c *Controller) SetTasks(p tasks.Provider) {
|
|
c.mu.Lock()
|
|
c.tasksProvider = p
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
|
|
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
|
// post-planning results, and notifies on completion. Caller holds mu.
|
|
func (c *Controller) startTasksFetchLocked() {
|
|
c.tasksList = nil
|
|
if c.tasksProvider == nil {
|
|
c.tasksStatus = tasksIdle
|
|
return
|
|
}
|
|
c.tasksGen++
|
|
gen := c.tasksGen
|
|
c.tasksStatus = tasksPending
|
|
provider := c.tasksProvider
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
|
|
defer cancel()
|
|
list, err := provider.Today(ctx)
|
|
|
|
c.mu.Lock()
|
|
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
|
|
c.mu.Unlock()
|
|
return // stale or left planning: discard
|
|
}
|
|
if err != nil {
|
|
c.tasksStatus = tasksError
|
|
c.tasksList = nil
|
|
} else {
|
|
c.tasksStatus = tasksReady
|
|
c.tasksList = list
|
|
}
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
}()
|
|
}
|
|
|
|
// 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...)
|
|
}
|
|
|
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
|
// working; unmatched windows simply stay idle.
|
|
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
|
c.mu.Lock()
|
|
c.judge = j
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
|
// local matching and the drift judge are unaffected.
|
|
func (c *Controller) SetNudge(n ai.Nudger) {
|
|
c.mu.Lock()
|
|
c.nudge = n
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// commitmentLineLocked renders the active commitment as a single line for AI
|
|
// prompts, or "" if there is none. Caller holds mu.
|
|
func (c *Controller) commitmentLineLocked() string {
|
|
if c.commitment == nil {
|
|
return ""
|
|
}
|
|
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
|
}
|
|
|
|
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
|
func (c *Controller) recentTitlesForTest() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.recentTitles...)
|
|
}
|
|
|
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
|
func (c *Controller) resetDriftLocked() {
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
c.driftGen++
|
|
c.nudgeEpoch++
|
|
c.lastJudgedAt = time.Time{}
|
|
c.judgedClasses = map[string]ai.Verdict{}
|
|
c.recentTitles = nil
|
|
c.nudgeMessage = ""
|
|
c.lastNudgedAt = time.Time{}
|
|
}
|
|
|
|
// OnTask appends the current window class to the session allowed-context, clears
|
|
// drift, drops any cached verdict for that class, and persists. The class now
|
|
// matches locally and is never re-judged.
|
|
func (c *Controller) OnTask() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.runtimeState != domain.RuntimeActive {
|
|
return ErrNotActive
|
|
}
|
|
var class string
|
|
if c.stats != nil {
|
|
class = c.stats.Current.Class
|
|
}
|
|
if strings.TrimSpace(class) != "" {
|
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
|
if !evidence.MatchesAllowed(ac, class, "") {
|
|
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
|
}
|
|
delete(c.judgedClasses, class)
|
|
}
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// Refocus clears the current drift verdict without changing allowed-context, and
|
|
// drops the cached verdict for the current class so it may be judged again later.
|
|
func (c *Controller) Refocus() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.runtimeState != domain.RuntimeActive {
|
|
return ErrNotActive
|
|
}
|
|
if c.stats != nil {
|
|
delete(c.judgedClasses, c.stats.Current.Class)
|
|
}
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
|
// not in planning; otherwise never a hard error (failures surface as coach
|
|
// state). The proposal is ephemeral and never persisted.
|
|
func (c *Controller) RequestCoach(intent string) error {
|
|
c.mu.Lock()
|
|
if c.runtimeState != domain.RuntimePlanning {
|
|
c.mu.Unlock()
|
|
return ErrNotPlanning
|
|
}
|
|
if c.coach == nil {
|
|
c.coachStatus = coachError
|
|
c.coachErr = "coach unavailable"
|
|
c.coachProposal = nil
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
return nil
|
|
}
|
|
c.coachGen++
|
|
gen := c.coachGen
|
|
c.coachStatus = coachPending
|
|
c.coachErr = ""
|
|
c.coachProposal = nil
|
|
coach := c.coach
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
|
|
defer cancel()
|
|
prop, err := coach.Coach(ctx, intent)
|
|
|
|
c.mu.Lock()
|
|
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
|
|
c.mu.Unlock()
|
|
return // stale or left planning: discard
|
|
}
|
|
if err != nil {
|
|
c.coachStatus = coachError
|
|
c.coachErr = coachErrorMessage(err)
|
|
c.coachProposal = nil
|
|
} else {
|
|
c.coachStatus = coachReady
|
|
c.coachProposal = &prop
|
|
c.coachErr = ""
|
|
}
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func coachErrorMessage(err error) string {
|
|
switch {
|
|
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
|
|
return "coach returned an unusable response"
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
return "coach timed out"
|
|
default:
|
|
return "coach unavailable"
|
|
}
|
|
}
|
|
|
|
// 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) 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.resetDriftLocked()
|
|
|
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
|
c.stats = &EvidenceStats{
|
|
SessionID: sessionID,
|
|
StartedUnix: now.Unix(),
|
|
Buckets: 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.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
|
|
c.stats.hasLast = false
|
|
}
|
|
c.runtimeState = next
|
|
c.outcomePending = outcome
|
|
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.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,
|
|
}
|
|
}
|
|
|
|
// RecordWindow ingests one sensor observation. It accumulates time only while
|
|
// Active, always tracks the latest window for display, and fires onChange after
|
|
// releasing the mutex.
|
|
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|
c.mu.Lock()
|
|
c.latestWindow = snap
|
|
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
return
|
|
}
|
|
now := c.clock()
|
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
|
c.applyEvent(now, snap)
|
|
c.recordTitleLocked(snap.Title)
|
|
launch := c.evaluateDriftLocked(now, snap)
|
|
c.mu.Unlock()
|
|
if launch != nil {
|
|
go launch()
|
|
}
|
|
c.notify()
|
|
}
|
|
|
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
|
// and updates synchronous drift state. When an async judgment is warranted it
|
|
// returns the judging closure; the caller runs it in a goroutine after
|
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
|
// already verified the runtime is Active.
|
|
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
|
class, title := snap.Class, snap.Title
|
|
|
|
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
|
// where the semantic nudge runs — the drift judge stays silent here, so the
|
|
// two are mutually exclusive per observation.
|
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
|
if evidence.MatchesAllowed(ac, class, title) {
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return c.maybeNudgeLocked(now)
|
|
}
|
|
|
|
// Past this point the window is not a local on-task match: the on-task
|
|
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
|
|
// epoch so an in-flight nudge result is discarded rather than surfacing stale
|
|
// when the user returns.
|
|
c.nudgeMessage = ""
|
|
c.nudgeEpoch++
|
|
|
|
// 2. Per-class cache.
|
|
if v, ok := c.judgedClasses[class]; ok {
|
|
c.applyVerdictLocked(v)
|
|
return nil
|
|
}
|
|
|
|
// 3. No judge wired: never block; leave idle.
|
|
if c.judge == nil {
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
return nil
|
|
}
|
|
|
|
// 4. Debounce: at most one judgment per driftDebounce window.
|
|
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
|
return nil
|
|
}
|
|
|
|
prevStatus, prevReason := c.driftStatus, c.driftReason
|
|
c.driftGen++
|
|
gen := c.driftGen
|
|
c.lastJudgedAt = now
|
|
c.driftStatus = driftPending
|
|
c.driftReason = ""
|
|
judge := c.judge
|
|
commitment := c.commitmentLineLocked()
|
|
|
|
return func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
|
defer cancel()
|
|
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
|
|
|
c.mu.Lock()
|
|
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
|
c.mu.Unlock()
|
|
return // stale: a newer judgment started or the session ended
|
|
}
|
|
if err != nil {
|
|
// Degrade: never fabricate drift. Revert the optimistic pending.
|
|
if c.driftStatus == driftPending {
|
|
c.driftStatus = prevStatus
|
|
c.driftReason = prevReason
|
|
}
|
|
c.mu.Unlock()
|
|
log.Printf("session: drift judge failed: %v", err)
|
|
c.notify()
|
|
return
|
|
}
|
|
c.judgedClasses[class] = v
|
|
if c.stats != nil && c.stats.Current.Class == class {
|
|
c.applyVerdictLocked(v)
|
|
}
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
}
|
|
}
|
|
|
|
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
|
// local-match path. It returns the nudging closure when eligible (judge wired,
|
|
// at least two titles of history, debounce elapsed), else nil. The closure
|
|
// follows the drift-judge discipline: it runs in a goroutine after the caller
|
|
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
|
|
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
|
|
// episode or a session change — is discarded rather than surfacing stale, never
|
|
// fabricates a concern on error, and notifies only after unlocking. Caller holds
|
|
// mu and has already verified the runtime is Active.
|
|
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
|
if c.nudge == nil || len(c.recentTitles) < 2 {
|
|
return nil
|
|
}
|
|
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
|
return nil
|
|
}
|
|
c.lastNudgedAt = now
|
|
epoch := c.nudgeEpoch
|
|
nudge := c.nudge
|
|
commitment := c.commitmentLineLocked()
|
|
titles := append([]string(nil), c.recentTitles...)
|
|
|
|
return func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
|
defer cancel()
|
|
msg, err := nudge.Nudge(ctx, commitment, titles)
|
|
|
|
c.mu.Lock()
|
|
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
|
|
c.mu.Unlock()
|
|
return // stale: the on-task stretch ended (drift episode) or the session changed
|
|
}
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
log.Printf("session: nudge failed: %v", err)
|
|
return // never fabricate a concern; leave any prior message intact
|
|
}
|
|
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
}
|
|
}
|
|
|
|
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
|
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
|
func (c *Controller) recordTitleLocked(title string) {
|
|
t := strings.TrimSpace(title)
|
|
if t == "" {
|
|
return
|
|
}
|
|
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
|
return
|
|
}
|
|
c.recentTitles = append(c.recentTitles, t)
|
|
if len(c.recentTitles) > recentTitlesMax {
|
|
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
|
}
|
|
}
|
|
|
|
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
|
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
|
if v.OnTask {
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return
|
|
}
|
|
c.driftStatus = driftDrifting
|
|
c.driftReason = v.Reason
|
|
}
|
|
|
|
// applyEvent advances stats by one observation: it credits the prior segment to
|
|
// the prior bucket, counts a context switch on key change, and records the new
|
|
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
|
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
|
if c.stats.hasLast {
|
|
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
|
|
}
|
|
newKey := keyFor(snap)
|
|
if c.stats.hasLast && newKey != c.stats.lastKey {
|
|
c.stats.SwitchCount++
|
|
}
|
|
c.stats.lastKey = newKey
|
|
c.stats.lastFocusAt = now
|
|
c.stats.hasLast = true
|
|
c.stats.Current = snap
|
|
}
|
|
|
|
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
|
func (c *Controller) replayStats(sessionID string) {
|
|
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
|
if err != nil || len(events) == 0 {
|
|
c.stats = &EvidenceStats{
|
|
SessionID: sessionID,
|
|
StartedUnix: c.clock().Unix(),
|
|
Buckets: map[bucketKey]time.Duration{},
|
|
}
|
|
return
|
|
}
|
|
c.stats = &EvidenceStats{
|
|
SessionID: sessionID,
|
|
StartedUnix: events[0].AtUnixMillis / 1000,
|
|
Buckets: map[bucketKey]time.Duration{},
|
|
}
|
|
for _, e := range events {
|
|
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
|
}
|
|
}
|
|
|
|
func keyFor(snap evidence.WindowSnapshot) bucketKey {
|
|
if !snap.Health.Available {
|
|
return bucketKey{Class: "", Title: unavailableTitle}
|
|
}
|
|
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
|
|
}
|
|
|
|
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
|
|
return store.FocusEvent{
|
|
AtUnixMillis: now.UnixMilli(),
|
|
Class: snap.Class,
|
|
Title: snap.Title,
|
|
Available: snap.Health.Available,
|
|
Reason: snap.Health.Reason,
|
|
}
|
|
}
|
|
|
|
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
|
|
return evidence.WindowSnapshot{
|
|
Title: e.Title,
|
|
Class: e.Class,
|
|
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
|
|
}
|
|
}
|