agent-orchestrator/backend/internal/session/manager.go

489 lines
18 KiB
Go

// Package session implements ports.SessionManager: the explicit-mutation half
// of the lane. The SM is impure plumbing — it drives the Runtime/Agent/Workspace
// plugins to create and tear down sessions, and routes mutation commands and
// outcomes to the LCM (OnSpawnInitiated / OnSpawnCompleted / OnKillRequested).
//
// It NEVER writes sessions directly: observed transitions and explicit
// canonical mutations are the LCM's job under the Writer contract. The SM is the
// single producer of the derived display status, attached on read in List/Get
// and never persisted.
package session
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// ErrNotFound is returned by Get/Restore when no record exists for the id.
var ErrNotFound = errors.New("session: not found")
// ErrNotRestorable is returned by Restore when the session is not torn down.
// Restoring a live session would spin up a second runtime/workspace for the same
// id, duplicating the agent and risking data loss.
var ErrNotRestorable = errors.New("session: not restorable (not terminal)")
// ErrIncompleteTeardownMetadata is returned when a record's teardown handles are
// missing (empty workspace path or runtime handle), so calling a real adapter's
// Destroy could act on empty args — an unsafe delete. The teardown is skipped.
var ErrIncompleteTeardownMetadata = errors.New("session: incomplete teardown metadata")
// Env vars a spawned process reads to learn who it is (distillation §5.4).
const (
EnvSessionID = "AO_SESSION_ID"
EnvProjectID = "AO_PROJECT_ID"
EnvIssueID = "AO_ISSUE_ID"
)
// Manager implements ports.SessionManager against the outbound ports. Every
// dependency is an interface so the SM runs entirely against fakes in tests.
type Manager struct {
runtime ports.Runtime
agent ports.Agent
workspace ports.Workspace
store ports.LifecycleStore
messenger ports.AgentMessenger
lcm ports.LifecycleManager
clock func() time.Time
newID func(ports.SpawnConfig) domain.SessionID
}
var _ ports.SessionManager = (*Manager)(nil)
// Deps groups the SM's collaborators. Clock and NewID are optional (defaulted)
// so production wiring only supplies the ports.
type Deps struct {
Runtime ports.Runtime
Agent ports.Agent
Workspace ports.Workspace
Store ports.LifecycleStore
Messenger ports.AgentMessenger
Lifecycle ports.LifecycleManager
Clock func() time.Time
NewID func(ports.SpawnConfig) domain.SessionID
}
func New(d Deps) *Manager {
m := &Manager{
runtime: d.Runtime,
agent: d.Agent,
workspace: d.Workspace,
store: d.Store,
messenger: d.Messenger,
lcm: d.Lifecycle,
clock: d.Clock,
newID: d.NewID,
}
if m.clock == nil {
m.clock = time.Now
}
if m.newID == nil {
m.newID = defaultNewID
}
return m
}
// ---- Spawn ----
// Spawn runs the create pipeline in spec order: workspace -> runtime -> route
// seed command to the LCM -> report completion to the LCM. The record is seeded LATE (after the runtime is up), so a
// failure before the seed leaves no record for Cleanup to reclaim — hence each
// step eagerly rolls back the steps that already succeeded.
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
id := m.newID(cfg)
if _, ok, err := m.store.Get(ctx, id); err != nil {
return domain.Session{}, fmt.Errorf("spawn %s: check existing: %w", id, err)
} else if ok {
return domain.Session{}, fmt.Errorf("spawn %s: already exists", id)
}
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{
ProjectID: cfg.ProjectID,
SessionID: id,
Branch: cfg.Branch,
})
if err != nil {
return domain.Session{}, fmt.Errorf("spawn %s: workspace create: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
SessionID: id,
WorkspacePath: ws.Path,
LaunchCommand: m.agent.GetLaunchCommand(agentCfg),
Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, cfg.ProjectID, cfg.IssueID),
})
if err != nil {
m.rollbackWorkspace(ctx, ws) // nothing seeded yet
return domain.Session{}, fmt.Errorf("spawn %s: runtime create: %w", id, err)
}
if err := m.lcm.OnSpawnInitiated(ctx, seedRecord(id, cfg, m.clock())); err != nil {
m.rollbackRuntime(ctx, handle)
m.rollbackWorkspace(ctx, ws)
return domain.Session{}, fmt.Errorf("spawn %s: on spawn initiated: %w", id, err)
}
// Prompt is persisted via OnSpawnCompleted -> spawnMetadata so a later Restore
// can fall back to a fresh launch if the agent's native session id was never
// captured (the capture path is a separate hook that may never have run).
outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, Prompt: agentCfg.Prompt}
if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil {
// The record is seeded but the runtime/workspace are about to be torn
// down. The store has no delete, so route the orphan to a terminal
// errored state (best effort) rather than strand a phantom "spawning".
_ = m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: ports.KillError, Detail: "spawn completion failed"})
m.rollbackRuntime(ctx, handle)
m.rollbackWorkspace(ctx, ws)
return domain.Session{}, fmt.Errorf("spawn %s: on spawn completed: %w", id, err)
}
return m.Get(ctx, id)
}
// rollback* are best-effort: the caller already has the originating failure, and
// there is no logger at this layer, so a secondary teardown error is dropped
// rather than masking the real cause.
func (m *Manager) rollbackWorkspace(ctx context.Context, ws ports.WorkspaceInfo) {
_ = m.workspace.Destroy(ctx, ws)
}
func (m *Manager) rollbackRuntime(ctx context.Context, h ports.RuntimeHandle) {
_ = m.runtime.Destroy(ctx, h)
}
// ---- Kill ----
// Kill records terminal intent with the LCM FIRST, then tears down the runtime
// and workspace. There is no separate Agent stop: the agent runs inside the
// runtime, so Runtime.Destroy stops it. The workspace teardown honors the
// worktree-remove safety — a refusal (path still registered after prune, so it
// may hold uncommitted work) surfaces as an error with WorkspaceFreed=false and
// is never forced.
func (m *Manager) Kill(ctx context.Context, id domain.SessionID, opts ports.KillOptions) (ports.KillResult, error) {
rec, ok, err := m.store.Get(ctx, id)
if err != nil {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w", id, err)
}
if !ok {
// Already gone: benign race, mirrors LCM.OnKillRequested's no-op.
return ports.KillResult{ID: id}, nil
}
meta, err := m.store.GetMetadata(ctx, id)
if err != nil {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: metadata: %w", id, err)
}
// Validate the teardown handles BEFORE recording intent or touching an
// adapter: a corrupted/partially-seeded record with empty handles must never
// reach Destroy (empty path / handle could be an unsafe delete).
rtHandle := runtimeHandle(meta)
wsInfo := workspaceInfo(rec, meta)
if !validRuntimeHandle(rtHandle) {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: runtime handle", id, ErrIncompleteTeardownMetadata)
}
if !validWorkspaceInfo(wsInfo) {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: workspace path", id, ErrIncompleteTeardownMetadata)
}
if err := m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: opts.Reason, Detail: opts.Detail}); err != nil {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: on kill requested: %w", id, err)
}
if err := m.runtime.Destroy(ctx, rtHandle); err != nil {
return ports.KillResult{ID: id}, fmt.Errorf("kill %s: runtime destroy: %w", id, err)
}
if err := m.workspace.Destroy(ctx, wsInfo); err != nil {
return ports.KillResult{ID: id, WorkspaceFreed: false}, fmt.Errorf("kill %s: workspace destroy: %w", id, err)
}
return ports.KillResult{ID: id, WorkspaceFreed: true}, nil
}
// ---- read-model ----
// List builds the read-model for a project: stored records with the display
// status derived on read. The SM is the single producer of that status.
func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) {
recs, err := m.store.List(ctx, project)
if err != nil {
return nil, fmt.Errorf("list %s: %w", project, err)
}
out := make([]domain.Session, 0, len(recs))
for _, rec := range recs {
out = append(out, toSession(rec))
}
return out, nil
}
func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
rec, ok, err := m.store.Get(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("get %s: %w", id, err)
}
if !ok {
return domain.Session{}, fmt.Errorf("get %s: %w", id, ErrNotFound)
}
return toSession(rec), nil
}
// ---- Send ----
// Send routes a message to the running agent through the AgentMessenger, which
// busy-detects and verifies delivery.
func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error {
if err := m.messenger.Send(ctx, id, message); err != nil {
return fmt.Errorf("send %s: %w", id, err)
}
return nil
}
// ---- Restore ----
// Restore relaunches a previously torn-down session in its workspace. The
// fallible I/O (workspace restore + runtime create) runs first so a failure
// touches no canonical state and never destroys the worktree (it may hold the
// agent's prior work). Only once the runtime is up do we reopen the lifecycle:
// resetting a terminal session is an explicit mutation routed to the LCM (the
// LCM's observe path would never resurrect a terminal session), and the PR axis
// is cleared. OnSpawnCompleted then flips the runtime to alive.
func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
rec, ok, err := m.store.Get(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, err)
}
if !ok {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound)
}
// Only a torn-down session may be restored. Reopening a live one would spawn a
// duplicate runtime/workspace for the same id and reset its lifecycle.
if !isTerminalSession(rec.Lifecycle.Session.State) {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable)
}
meta, err := m.store.GetMetadata(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err)
}
// Resume is only possible with the agent's captured session id; without it we
// fall back to a fresh launch using the seeded prompt persisted at spawn time
// (the agent's id-capture path is a separate hook that may never have run, so
// "no id" is the common case rather than an error). If neither is available
// there is nothing to relaunch from — fail early, before any I/O.
agentSessionID := meta.AgentSessionID
seededPrompt := meta.Prompt
if agentSessionID == "" && seededPrompt == "" {
return domain.Session{}, fmt.Errorf("restore %s: no agent session id or seeded prompt (cannot resume or relaunch)", id)
}
ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{
ProjectID: rec.ProjectID,
SessionID: id,
Branch: meta.Branch,
})
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: workspace restore: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: seededPrompt}
launchCommand := m.agent.GetRestoreCommand(agentSessionID)
if agentSessionID == "" {
launchCommand = m.agent.GetLaunchCommand(agentCfg)
}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
SessionID: id,
WorkspacePath: ws.Path,
LaunchCommand: launchCommand,
Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID),
})
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: runtime create: %w", id, err)
}
// Past this point the runtime is live: a failure must tear it back down (but
// never the workspace, which holds the agent's prior work) so we don't strand
// a process while parking the session in a terminal lifecycle.
reopen := rec
reopen.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}
reopen.Lifecycle.PR = domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore}
reopen.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete}
reopen.Lifecycle.Detecting = nil
if err := m.lcm.OnSpawnInitiated(ctx, reopen); err != nil {
m.rollbackRuntime(ctx, handle)
return domain.Session{}, fmt.Errorf("restore %s: on spawn initiated: %w", id, err)
}
outcome := ports.SpawnOutcome{
Branch: ws.Branch,
WorkspacePath: ws.Path,
RuntimeHandle: handle,
AgentSessionID: agentSessionID,
Prompt: seededPrompt,
}
if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil {
m.rollbackRuntime(ctx, handle)
// Re-upsert the original record to undo the reopen; the store will
// assign the next revision.
if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil {
return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err)
}
if !rec.Metadata.IsZero() {
if revertErr := m.store.PatchMetadata(ctx, id, rec.Metadata); revertErr != nil {
return domain.Session{}, fmt.Errorf("restore %s: revert metadata after spawn completed failure: %w (original error: %v)", id, revertErr, err)
}
}
return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err)
}
return m.Get(ctx, id)
}
// ---- Cleanup ----
// Cleanup reclaims the workspaces of terminal sessions in a project. A workspace
// whose teardown is refused by the worktree-remove safety (uncommitted work) is
// skipped, never forced. Runtime teardown is best-effort (a terminal session's
// runtime is usually already gone); the workspace result decides cleaned/skipped.
func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (ports.CleanupResult, error) {
recs, err := m.store.List(ctx, project)
if err != nil {
return ports.CleanupResult{}, fmt.Errorf("cleanup %s: %w", project, err)
}
var res ports.CleanupResult
for _, rec := range recs {
if !isTerminalSession(rec.Lifecycle.Session.State) {
continue
}
meta, err := m.store.GetMetadata(ctx, rec.ID)
if err != nil {
return res, fmt.Errorf("cleanup %s: metadata %s: %w", project, rec.ID, err)
}
wsInfo := workspaceInfo(rec, meta)
if !validWorkspaceInfo(wsInfo) {
// No workspace path to reclaim — skip rather than hand empty args to a
// real adapter's Destroy (an unsafe delete).
res.Skipped = append(res.Skipped, rec.ID)
continue
}
if rtHandle := runtimeHandle(meta); validRuntimeHandle(rtHandle) {
_ = m.runtime.Destroy(ctx, rtHandle) // best effort; usually already gone
}
if err := m.workspace.Destroy(ctx, wsInfo); err != nil {
res.Skipped = append(res.Skipped, rec.ID)
continue
}
res.Cleaned = append(res.Cleaned, rec.ID)
}
return res, nil
}
// ---- helpers ----
func toSession(rec domain.SessionRecord) domain.Session {
return domain.Session{SessionRecord: rec, Status: domain.DeriveLegacyStatus(rec.Lifecycle)}
}
func isTerminalSession(s domain.SessionState) bool {
return s == domain.SessionDone || s == domain.SessionTerminated
}
// buildPrompt assembles the spawn prompt from the explicit config only; the full
// 3-layer assembly (base protocol + config-derived + user rules) lands later.
func buildPrompt(cfg ports.SpawnConfig) string {
switch {
case cfg.AgentRules == "":
return cfg.Prompt
case cfg.Prompt == "":
return cfg.AgentRules
default:
return cfg.Prompt + "\n\n" + cfg.AgentRules
}
}
// spawnEnv overlays the AO_* identity vars onto the agent's environment without
// mutating the map the agent returned.
func spawnEnv(base map[string]string, id domain.SessionID, project domain.ProjectID, issue domain.IssueID) map[string]string {
env := make(map[string]string, len(base)+3)
for k, v := range base {
env[k] = v
}
env[EnvSessionID] = string(id)
env[EnvProjectID] = string(project)
env[EnvIssueID] = string(issue)
return env
}
func seedRecord(id domain.SessionID, cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
return domain.SessionRecord{
ID: id,
ProjectID: cfg.ProjectID,
IssueID: cfg.IssueID,
Kind: cfg.Kind,
CreatedAt: now,
UpdatedAt: now,
Lifecycle: domain.CanonicalSessionLifecycle{
Version: domain.LifecycleVersion,
Session: domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested},
Runtime: domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete},
PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated},
},
}
}
// runtimeHandle / workspaceInfo reconstruct teardown handles from the metadata
// the LCM persisted in OnSpawnCompleted (the metadata-key contract is shared
// with the lifecycle package).
func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle {
return ports.RuntimeHandle{
ID: meta.RuntimeHandleID,
RuntimeName: meta.RuntimeName,
}
}
func workspaceInfo(rec domain.SessionRecord, meta domain.SessionMetadata) ports.WorkspaceInfo {
return ports.WorkspaceInfo{
Path: meta.WorkspacePath,
Branch: meta.Branch,
SessionID: rec.ID,
ProjectID: rec.ProjectID,
}
}
// validRuntimeHandle reports whether the handle identifies a runtime to destroy.
// An adapter needs the handle id to target the right process; an empty handle
// would be ambiguous, so we refuse to call Destroy with one.
func validRuntimeHandle(h ports.RuntimeHandle) bool {
return h.ID != ""
}
// validWorkspaceInfo reports whether there is a concrete path to reclaim. An
// empty path handed to a worktree-remove could resolve to an unsafe target.
func validWorkspaceInfo(w ports.WorkspaceInfo) bool {
return w.Path != ""
}
func defaultNewID(cfg ports.SpawnConfig) domain.SessionID {
base := string(cfg.IssueID)
if base == "" {
base = string(cfg.Kind)
}
if base == "" {
base = "session"
}
return domain.SessionID(base + "-" + randHex(4))
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}