agent-orchestrator/backend/internal/daemon/lifecycle_wiring.go

152 lines
6.1 KiB
Go

package daemon
import (
"context"
"log/slog"
"path/filepath"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/notification"
"github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/session"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/wiring"
)
// lifecycleStack owns the running LCM + reaper. The LCM is the sole writer of
// canonical transitions; the reaper is the OBSERVE-layer timer that probes live
// runtimes and reports facts back through it. Adapter is exposed so the Session
// Manager construction in startSession can plug the same SessionStore + PRWriter
// instance the LCM already holds.
type lifecycleStack struct {
LCM *lifecycle.Manager
Adapter wiring.Adapter
reaperDone <-chan struct{}
}
// startLifecycle constructs the LCM over the store adapter and starts the reaper.
// The goroutine stops when ctx is cancelled; Stop waits for it to drain.
//
// TEMPORARY STUBS (replace as the daemon lane lands the collaborators):
// - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger.
// - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks
// escalations but probes nothing until the runtime plugins exist.
func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) {
a := wiring.Adapter{Store: store}
renderer := notification.NewRenderer(store)
notifier := notification.NewEnqueuer(store, renderer, logger)
lcm := lifecycle.New(a, a, notifier, noopMessenger{})
rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger})
return &lifecycleStack{LCM: lcm, Adapter: a, reaperDone: rp.Start(ctx)}, nil
}
// Stop waits for the reaper goroutine to exit (the caller must have cancelled the
// ctx passed to startLifecycle).
func (l *lifecycleStack) Stop() { <-l.reaperDone }
// sessionStack holds the daemon's live Session Manager. It mirrors
// lifecycleStack's shape so a future teardown hook (worktree drain, runtime
// shutdown) has a place to attach.
type sessionStack struct {
SM *session.Manager
}
// startSession constructs the Session Manager over the real tmux Runtime and
// gitworktree Workspace, the LCM and adapter created by startLifecycle, and the
// loud-stub Agent / Messenger / Notifier ports that have no production
// implementations yet. It does NOT mount any HTTP routes — those come with the
// daemon lane (#10). Returning the SM here lets main hold the wired-but-quiet
// instance so future route wiring is a one-line plumb-through.
func startSession(ctx context.Context, cfg config.Config, ls *lifecycleStack, log *slog.Logger) (*sessionStack, error) {
_ = ctx // reserved for future ctx-aware plugin construction; today's tmux/gitworktree constructors are synchronous.
runtime := tmux.New(tmux.Options{})
ws, err := gitworktree.New(gitworktree.Options{
// ManagedRoot is the directory under which per-session worktrees are
// materialised. Co-located with the SQLite DB so a single AO_DATA_DIR
// override moves all durable per-user state together.
ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"),
// An empty resolver fails every project lookup with a clear
// `no repo configured for project %q` error. That's the right loud
// failure until the projects table feeds repo paths into the resolver
// — hard-coding a single repo here would silently misroute spawns.
RepoResolver: gitworktree.StaticRepoResolver{},
})
if err != nil {
return nil, err
}
agent := newNoopAgent(log)
sm := session.New(session.Deps{
Runtime: runtime,
Agent: agent,
Workspace: ws,
Store: ls.Adapter,
Messenger: noopMessenger{},
Lifecycle: ls.LCM,
})
return &sessionStack{SM: sm}, nil
}
// noopMessenger is a TEMPORARY stub (see startLifecycle): the canonical write
// path and durable notifications work without it; only live agent nudges are
// absent until the real runtime/agent plugin is wired.
type noopMessenger struct{}
func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil }
// agentNotWiredSentinel is the launch / restore command (and env-var key)
// noopAgent returns. tmux will try to exec a binary named exactly this and fail
// fast, so a Spawn against the loud stub surfaces a clear runtime error rather
// than starting a quiet, broken session.
const agentNotWiredSentinel = "AO_AGENT_HARNESS_NOT_WIRED"
// noopAgent is a loud stub for ports.Agent. There is no production Agent
// adapter on main yet; rather than panic at construction, this struct lets the
// daemon stand up the Session Manager, then logs a single warning the first
// time any SM call route through it and returns sentinel commands that make
// the runtime layer fail loudly.
type noopAgent struct {
log *slog.Logger
once *sync.Once
}
var _ ports.Agent = (*noopAgent)(nil)
func newNoopAgent(log *slog.Logger) *noopAgent {
return &noopAgent{log: log, once: &sync.Once{}}
}
func (n *noopAgent) warn() {
n.once.Do(func() {
n.log.Warn(
"agent harness not wired: Spawn/Restore will fail at the runtime layer until a ports.Agent adapter is built",
"sentinel", agentNotWiredSentinel,
"next_step", "implement a per-harness ports.Agent adapter and plug it into startSession",
)
})
}
func (n *noopAgent) GetLaunchCommand(ports.AgentConfig) string {
n.warn()
return agentNotWiredSentinel
}
func (n *noopAgent) GetEnvironment(ports.AgentConfig) map[string]string {
n.warn()
return map[string]string{agentNotWiredSentinel: "1"}
}
func (n *noopAgent) GetRestoreCommand(string) string {
n.warn()
return agentNotWiredSentinel
}