Split drift/nudge/enforcement into drift.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"antidrift/internal/ai"
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/enforce"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/store"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
enforceTimeout = 5 * time.Second
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const recentTitlesMax = 10
|
||||
|
||||
const (
|
||||
driftIdle = "idle"
|
||||
driftPending = "pending"
|
||||
driftOnTask = "ontask"
|
||||
driftDrifting = "drifting"
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||
// enforcement; everything else behaves identically.
|
||||
func (c *Controller) SetGuard(g enforce.Guard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.guard = g
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
enforceAct := c.enforceActionLocked()
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
if enforceAct != nil {
|
||||
go enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
}
|
||||
|
||||
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||
func (c *Controller) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("session: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
var enforceAct func()
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
enforceAct = c.enforceActionLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if enforceAct != nil {
|
||||
enforceAct()
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -75,23 +75,6 @@ const (
|
||||
reflectionAbsent = "absent"
|
||||
)
|
||||
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
enforceTimeout = 5 * 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")
|
||||
@@ -520,100 +503,6 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
||||
return c.enforcementLevel
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||
// enforcement; everything else behaves identically.
|
||||
func (c *Controller) SetGuard(g enforce.Guard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.guard = g
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// composedGroundingLocked combines the standing profile (knowledge port) with
|
||||
// the latest carry-forward takeaway into the single free-form grounding string
|
||||
// the coach already accepts. Caller holds mu.
|
||||
@@ -814,205 +703,3 @@ func (c *Controller) buildSummaryLocked() store.SessionSummary {
|
||||
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)
|
||||
enforceAct := c.enforceActionLocked()
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
if enforceAct != nil {
|
||||
go enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
}
|
||||
|
||||
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||
func (c *Controller) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("session: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
var enforceAct func()
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
enforceAct = c.enforceActionLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if enforceAct != nil {
|
||||
enforceAct()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user