7d69a1f320
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>
188 lines
4.1 KiB
Go
188 lines
4.1 KiB
Go
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
|
|
}
|