Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// Package harness is the generic host: it owns shared services and exactly one
|
||||
// active mode, and routes evidence, expiry, and commands to it.
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Async runs generation-guarded background work for a mode. It preserves the
|
||||
// lock discipline of the original session.runFetchAsync: fetch runs with no
|
||||
// lock held; stale and apply run under the mode's mutex; notify fires once,
|
||||
// after the lock is released.
|
||||
type Async struct {
|
||||
mu *sync.Mutex
|
||||
notify func()
|
||||
}
|
||||
|
||||
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
|
||||
func NewAsync(mu *sync.Mutex, notify func()) Async {
|
||||
return Async{mu: mu, notify: notify}
|
||||
}
|
||||
|
||||
// Run launches a background fetch. stale returns true to DISCARD the result
|
||||
// (a newer generation superseded it). apply records a non-stale result under
|
||||
// the mutex and must NOT call notify — Run owns the post-unlock notify.
|
||||
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
fetch(ctx)
|
||||
a.mu.Lock()
|
||||
if stale() {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
apply()
|
||||
a.mu.Unlock()
|
||||
if a.notify != nil {
|
||||
a.notify()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
notified := make(chan struct{}, 1)
|
||||
a := NewAsync(&mu, func() { notified <- struct{}{} })
|
||||
|
||||
var applied bool
|
||||
a.Run(time.Second,
|
||||
func(ctx context.Context) {},
|
||||
func() bool { return false },
|
||||
func() { applied = true },
|
||||
)
|
||||
select {
|
||||
case <-notified:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("notify never fired")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !applied {
|
||||
t.Fatal("apply did not run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
|
||||
done := make(chan struct{})
|
||||
a.Run(time.Second,
|
||||
func(ctx context.Context) {},
|
||||
func() bool { close(done); return true },
|
||||
func() { t.Fatal("apply ran on stale result") },
|
||||
)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stale guard never evaluated")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/mode"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBusy = errors.New("harness: another mode is active")
|
||||
ErrUnknownMode = errors.New("harness: unknown mode")
|
||||
ErrIdle = errors.New("harness: no active mode")
|
||||
)
|
||||
|
||||
// Factory builds a mode from the shared services, in its initial active state.
|
||||
type Factory func(Services) mode.Mode
|
||||
|
||||
type Harness struct {
|
||||
mu sync.Mutex
|
||||
active mode.Mode
|
||||
factories map[string]Factory
|
||||
services Services
|
||||
onChange []func()
|
||||
}
|
||||
|
||||
// New wires Services.Notify to the harness so every mode's async completion
|
||||
// reaches all listeners, then stores the services for the factories to receive.
|
||||
func New(services Services, factories map[string]Factory) *Harness {
|
||||
h := &Harness{factories: factories}
|
||||
services.Notify = h.notify
|
||||
h.services = services
|
||||
return h
|
||||
}
|
||||
|
||||
// AddOnChange registers a change listener (web broadcaster, status writer).
|
||||
func (h *Harness) AddOnChange(f func()) {
|
||||
h.mu.Lock()
|
||||
h.onChange = append(h.onChange, f)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetServices swaps the services future Start calls hand to factories. In v1 it
|
||||
// affects the NEXT mode start only; an already-active mode keeps the services it
|
||||
// was built with (settings changes take effect on the next session).
|
||||
func (h *Harness) SetServices(s Services) {
|
||||
s.Notify = h.notify
|
||||
h.mu.Lock()
|
||||
h.services = s
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Services returns the shared services the harness hands to factories, with
|
||||
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
|
||||
// (e.g. startup restoration) so the restored mode's async work reaches the
|
||||
// harness's onChange listeners.
|
||||
func (h *Harness) Services() Services {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.services
|
||||
}
|
||||
|
||||
func (h *Harness) notify() {
|
||||
h.mu.Lock()
|
||||
fs := append([]func(){}, h.onChange...)
|
||||
h.mu.Unlock()
|
||||
for _, f := range fs {
|
||||
if f != nil {
|
||||
f()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adopt installs an already-constructed mode as active (startup restoration).
|
||||
// It fails if a mode is already active.
|
||||
func (h *Harness) Adopt(m mode.Mode) error {
|
||||
h.mu.Lock()
|
||||
if h.active != nil {
|
||||
h.mu.Unlock()
|
||||
return ErrBusy
|
||||
}
|
||||
h.active = m
|
||||
h.mu.Unlock()
|
||||
h.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start activates a mode by kind. Idempotent if that kind is already active;
|
||||
// ErrBusy if a different kind is active.
|
||||
func (h *Harness) Start(kind string) error {
|
||||
h.mu.Lock()
|
||||
if h.active != nil {
|
||||
busy := h.active.Kind() != kind
|
||||
h.mu.Unlock()
|
||||
if busy {
|
||||
return ErrBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f, ok := h.factories[kind]
|
||||
if !ok {
|
||||
h.mu.Unlock()
|
||||
return ErrUnknownMode
|
||||
}
|
||||
h.active = f(h.services)
|
||||
h.mu.Unlock()
|
||||
h.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Command routes to the active mode, then releases it if it went inactive.
|
||||
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
|
||||
h.mu.Lock()
|
||||
m := h.active
|
||||
h.mu.Unlock()
|
||||
if m == nil {
|
||||
return ErrIdle
|
||||
}
|
||||
if err := m.Command(ctx, name, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
h.releaseIfDone()
|
||||
h.notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Harness) releaseIfDone() {
|
||||
h.mu.Lock()
|
||||
if h.active != nil && !h.active.Active() {
|
||||
h.active = nil
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// State returns the surfacing envelope.
|
||||
func (h *Harness) State() mode.Envelope {
|
||||
h.mu.Lock()
|
||||
m := h.active
|
||||
h.mu.Unlock()
|
||||
if m == nil {
|
||||
return mode.Envelope{}
|
||||
}
|
||||
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
|
||||
}
|
||||
|
||||
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
|
||||
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
|
||||
h.mu.Lock()
|
||||
m := h.active
|
||||
h.mu.Unlock()
|
||||
if c, ok := m.(mode.EvidenceConsumer); ok {
|
||||
c.OnWindow(w)
|
||||
}
|
||||
}
|
||||
|
||||
// Deadline returns the active mode's deadline iff it is an Expirer.
|
||||
func (h *Harness) Deadline() time.Time {
|
||||
h.mu.Lock()
|
||||
m := h.active
|
||||
h.mu.Unlock()
|
||||
if e, ok := m.(mode.Expirer); ok {
|
||||
return e.Deadline()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
|
||||
// if it went inactive.
|
||||
func (h *Harness) Expire() error {
|
||||
h.mu.Lock()
|
||||
m := h.active
|
||||
h.mu.Unlock()
|
||||
e, ok := m.(mode.Expirer)
|
||||
if !ok {
|
||||
return ErrIdle
|
||||
}
|
||||
if err := e.Expire(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.releaseIfDone()
|
||||
h.notify()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/mode"
|
||||
)
|
||||
|
||||
// fakeMode implements Mode and (optionally) the capability interfaces.
|
||||
type fakeMode struct {
|
||||
mu sync.Mutex
|
||||
kind string
|
||||
active bool
|
||||
cmds []string
|
||||
windows int
|
||||
deadline time.Time
|
||||
expired bool
|
||||
evidence bool // implements EvidenceConsumer when true
|
||||
expirer bool // implements Expirer when true
|
||||
}
|
||||
|
||||
func (f *fakeMode) Kind() string { return f.kind }
|
||||
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
|
||||
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
|
||||
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.cmds = append(f.cmds, name)
|
||||
if name == "finish" {
|
||||
f.active = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type evidenceMode struct{ *fakeMode }
|
||||
|
||||
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
|
||||
|
||||
type expirerMode struct{ *fakeMode }
|
||||
|
||||
func (e expirerMode) Deadline() time.Time { return e.deadline }
|
||||
func (e expirerMode) Expire() error {
|
||||
e.mu.Lock()
|
||||
e.expired = true
|
||||
e.active = false
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHarness(m mode.Mode) *Harness {
|
||||
return New(Services{Clock: time.Now}, map[string]Factory{
|
||||
m.Kind(): func(Services) mode.Mode { return m },
|
||||
})
|
||||
}
|
||||
|
||||
func TestStartSingleActiveInvariant(t *testing.T) {
|
||||
h := New(Services{}, map[string]Factory{
|
||||
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
|
||||
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
|
||||
})
|
||||
if err := h.Start("a"); err != nil {
|
||||
t.Fatalf("Start a: %v", err)
|
||||
}
|
||||
if err := h.Start("a"); err != nil {
|
||||
t.Fatalf("re-Start a should be idempotent: %v", err)
|
||||
}
|
||||
if err := h.Start("b"); err != ErrBusy {
|
||||
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
|
||||
}
|
||||
if got := h.State().ActiveMode; got != "a" {
|
||||
t.Fatalf("ActiveMode = %q, want a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandReleasesWhenInactive(t *testing.T) {
|
||||
m := &fakeMode{kind: "a", active: true}
|
||||
h := newHarness(m)
|
||||
_ = h.Start("a")
|
||||
if err := h.Command(context.Background(), "finish", nil); err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
if got := h.State().ActiveMode; got != "" {
|
||||
t.Fatalf("after finish ActiveMode = %q, want idle", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
|
||||
plain := &fakeMode{kind: "plain", active: true}
|
||||
h := newHarness(plain)
|
||||
_ = h.Start("plain")
|
||||
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
|
||||
if plain.windows != 0 {
|
||||
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
|
||||
}
|
||||
|
||||
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
|
||||
h2 := newHarness(ev)
|
||||
_ = h2.Start("ev")
|
||||
h2.RecordWindow(evidence.WindowSnapshot{})
|
||||
if ev.windows != 1 {
|
||||
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryOnlyForExpirers(t *testing.T) {
|
||||
plain := &fakeMode{kind: "plain", active: true}
|
||||
h := newHarness(plain)
|
||||
_ = h.Start("plain")
|
||||
if err := h.Expire(); err != ErrIdle {
|
||||
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
|
||||
}
|
||||
if !h.Deadline().IsZero() {
|
||||
t.Fatal("non-expirer Deadline should be zero")
|
||||
}
|
||||
|
||||
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
|
||||
h2 := newHarness(ex)
|
||||
_ = h2.Start("ex")
|
||||
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
|
||||
t.Fatalf("Deadline = %v, want unix 100", got)
|
||||
}
|
||||
if err := h2.Expire(); err != nil {
|
||||
t.Fatalf("Expire: %v", err)
|
||||
}
|
||||
if got := h2.State().ActiveMode; got != "" {
|
||||
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandWhileIdle(t *testing.T) {
|
||||
h := New(Services{}, map[string]Factory{})
|
||||
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
|
||||
t.Fatalf("Command while idle = %v, want ErrIdle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
|
||||
h := New(Services{}, map[string]Factory{})
|
||||
fired := make(chan struct{}, 1)
|
||||
h.AddOnChange(func() { fired <- struct{}{} })
|
||||
svc := h.Services()
|
||||
if svc.Notify == nil {
|
||||
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
|
||||
}
|
||||
svc.Notify() // simulate a restored mode's async completion firing notify
|
||||
select {
|
||||
case <-fired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Services().Notify did not reach the registered onChange listener")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/enforce"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// Services is the shared infrastructure every mode receives from the harness.
|
||||
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
|
||||
// modes build their own file paths under it. Notify fires the harness change
|
||||
// listeners (web SSE + status bar).
|
||||
type Services struct {
|
||||
AI *ai.Service
|
||||
Tasks tasks.Provider
|
||||
Knowledge knowledge.Source
|
||||
Enforce enforce.Guard
|
||||
Clock func() time.Time
|
||||
Dir string
|
||||
Notify func()
|
||||
}
|
||||
Reference in New Issue
Block a user