refactor: simplify session lifecycle and zellij runtime (#62)

* refactor: remove canonical lifecycle state

* refactor: move sqlite stores into subpackage (#62)

* refactor: strengthen sqlite generated model types (#62)

* refactor: remove lifecycle notifications (#62)

* docs: remove notification cleanup leftovers (#62)

* refactor: narrow lifecycle manager scope (#62)

* refactor: keep PR nudges in lifecycle (#62)

* refactor: trim unused storage and lifecycle contracts (#62)

* refactor: align storage and runtime observation surfaces (#62)

* refactor: remove stale daemon and adapter bloat (#62)

* test: fix terminal ring race assertion (#62)

* refactor: trim lifecycle and http boilerplate (#62)

* refactor: expose sqlite CDC source directly (#62)

* refactor: share process liveness checks (#62)

* test: trim lifecycle store fake surface (#62)

* refactor: separate PR observations from storage rows (#62)

* refactor: trim remaining cleanup surfaces (#62)

* refactor: narrow observation and PR display APIs (#62)

* refactor: move PR write DTOs out of domain (#62)

* refactor: normalize PR domain storage types (#62)

* refactor: remove unused session port interface (#62)

* fix: reject unexpected CLI arguments (#62)

* refactor: use session metadata for spawn completion (#62)

* refactor: narrow session runtime dependency (#62)

* fix: validate zellij version in doctor (#62)

* refactor: split observation port DTOs (#62)

* chore: add sqlc generation script (#62)

* refactor: clarify terminal mux naming (#62)

* fix: tolerate nil loggers (#62)

---------

Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
This commit is contained in:
prateek 2026-06-01 08:42:49 +05:30 committed by GitHub
parent 80f46719d9
commit a34094e7d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 3102 additions and 8390 deletions

3
.gitignore vendored
View File

@ -26,6 +26,9 @@ agent-orchestrator.yaml
session-events.jsonl
session-events.jsonl.*
# Agent Orchestrator local session state
.ao/
# Environment
.env
.env.*

View File

@ -1,7 +1,7 @@
# agent-orchestrator
Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`)
paired with an Electron + TypeScript frontend (`frontend/`).
paired with a placeholder Electron + TypeScript frontend shell (`frontend/`).
See [`docs/`](docs/README.md) for architecture and status — start with the
Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md).
@ -31,8 +31,8 @@ AO_PORT=3019 go run ./cmd/ao start # override per invocation
Health check:
```bash
curl localhost:3001/healthz # {"status":"ok"}
curl localhost:3001/readyz # {"status":"ready"}
curl localhost:3001/healthz # includes status/service/pid
curl localhost:3001/readyz # includes status/service/pid
```
### Configuration (env only)
@ -47,10 +47,12 @@ is intentionally not env-configurable.
| `AO_REQUEST_TIMEOUT` | `60s` | per-request timeout (Go duration) |
| `AO_SHUTDOWN_TIMEOUT` | `10s` | graceful-shutdown hard cap |
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path |
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, and managed state |
### Test
```bash
cd backend
gofmt -l . && go build ./... && go vet ./... && go test -race ./...
npm run lint
# optional deeper backend pass:
cd backend && go test -race ./...
```

View File

@ -1,97 +0,0 @@
package tmux
import (
"fmt"
"sort"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const runtimeName = "tmux"
func newSessionArgs(id, workspacePath, shellPath, script string) []string {
return []string{"new-session", "-d", "-s", id, "-c", workspacePath, shellPath, "-lc", script}
}
func setStatusOffArgs(id string) []string {
return []string{"set-option", "-t", exactSessionTarget(id), "status", "off"}
}
func hasSessionArgs(id string) []string {
return []string{"has-session", "-t", exactSessionTarget(id)}
}
func killSessionArgs(id string) []string {
return []string{"kill-session", "-t", exactSessionTarget(id)}
}
func capturePaneArgs(id string, lines int) []string {
return []string{"capture-pane", "-p", "-t", exactPaneTarget(id), "-S", fmt.Sprintf("-%d", lines)}
}
func sendLiteralArgs(id, message string) []string {
return []string{"send-keys", "-t", exactPaneTarget(id), "-l", message}
}
func sendEnterArgs(id string) []string {
return []string{"send-keys", "-t", exactPaneTarget(id), "C-m"}
}
func loadBufferArgs(bufferName, path string) []string {
return []string{"load-buffer", "-b", bufferName, path}
}
func pasteBufferArgs(id, bufferName string) []string {
return []string{"paste-buffer", "-d", "-t", exactPaneTarget(id), "-b", bufferName}
}
func exactSessionTarget(id string) string {
return "=" + id + ":"
}
func exactPaneTarget(id string) string {
return "=" + id + ":0.0"
}
func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
}
var b strings.Builder
for _, key := range sortedKeys(cfg.Env) {
if key == "PATH" {
continue
}
b.WriteString("export ")
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(cfg.Env[key]))
b.WriteString("; ")
}
if path != "" {
b.WriteString("export PATH=")
b.WriteString(shellQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString("; exec ")
b.WriteString(shellQuote(shellPath))
b.WriteString(" -i")
return b.String()
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

View File

@ -1,296 +0,0 @@
// Package tmux implements ports.Runtime using tmux sessions.
package tmux
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const defaultTimeout = 5 * time.Second
const longMessageThreshold = 512
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var getenv = os.Getenv
// Options configures a tmux Runtime; every field has a default (see New).
type Options struct {
Binary string
Timeout time.Duration
Shell string
}
// Runtime runs agent sessions inside tmux sessions, driving them via the tmux
// CLI. It implements ports.Runtime.
type Runtime struct {
binary string
timeout time.Duration
shell string
runner runner
}
var _ ports.Runtime = (*Runtime)(nil)
type runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
// New builds a tmux Runtime, filling unset Options with defaults: binary
// "tmux", shell from $SHELL (else /bin/sh), and the default timeout.
func New(opts Options) *Runtime {
binary := opts.Binary
if binary == "" {
binary = "tmux"
}
timeout := opts.Timeout
if timeout == 0 {
timeout = defaultTimeout
}
shellPath := opts.Shell
if shellPath == "" {
shellPath = os.Getenv("SHELL")
}
if shellPath == "" {
shellPath = "/bin/sh"
}
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}}
}
// Create starts a new tmux session in the workspace, running the agent's
// launch command, and returns a handle to it.
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
id, err := tmuxSessionName(cfg.SessionID)
if err != nil {
return ports.RuntimeHandle{}, err
}
if cfg.WorkspacePath == "" {
return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required")
}
if cfg.LaunchCommand == "" {
return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required")
}
script := wrapLaunchCommand(cfg, r.shell)
if _, err := r.run(ctx, newSessionArgs(id, cfg.WorkspacePath, r.shell, script)...); err != nil {
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err)
}
if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: disable status %s: %w", id, err)
}
return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil
}
// Destroy kills the handle's tmux session. An already-gone session is treated
// as success.
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
id, err := handleID(handle)
if err != nil {
return err
}
if _, err := r.run(ctx, killSessionArgs(id)...); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil
}
return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err)
}
return nil
}
// SendMessage types a message into the session's pane and presses Enter,
// routing large messages through a tmux paste buffer.
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
id, err := handleID(handle)
if err != nil {
return err
}
if useBuffer(message) {
return r.sendViaBuffer(ctx, id, message)
}
if _, err := r.run(ctx, sendLiteralArgs(id, message)...); err != nil {
return fmt.Errorf("tmux runtime: send message %s: %w", id, err)
}
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
}
return nil
}
// GetOutput captures the last `lines` lines of the session pane.
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
id, err := handleID(handle)
if err != nil {
return "", err
}
if lines <= 0 {
return "", errors.New("tmux runtime: lines must be positive")
}
out, err := r.run(ctx, capturePaneArgs(id, lines)...)
if err != nil {
return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err)
}
return string(out), nil
}
// IsAlive reports whether the handle's tmux session still exists.
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
id, err := handleID(handle)
if err != nil {
return false, err
}
_, err = r.run(ctx, hasSessionArgs(id)...)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false, nil
}
return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err)
}
// AttachCommand returns the argv a human runs to attach their terminal to the
// session.
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
id, err := handleID(handle)
if err != nil {
return nil, err
}
return append([]string{r.binary}, "attach", "-t", exactSessionTarget(id)), nil
}
func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error {
dir := os.TempDir()
file, err := os.CreateTemp(dir, "ao-tmux-message-*")
if err != nil {
return fmt.Errorf("tmux runtime: create message temp file: %w", err)
}
path := file.Name()
defer func() { _ = os.Remove(path) }()
if _, err := file.WriteString(message); err != nil {
_ = file.Close()
return fmt.Errorf("tmux runtime: write message temp file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("tmux runtime: close message temp file: %w", err)
}
bufferName := "ao-" + filepath.Base(path)
if _, err := r.run(ctx, loadBufferArgs(bufferName, path)...); err != nil {
return fmt.Errorf("tmux runtime: load buffer %s: %w", id, err)
}
if _, err := r.run(ctx, pasteBufferArgs(id, bufferName)...); err != nil {
return fmt.Errorf("tmux runtime: paste buffer %s: %w", id, err)
}
if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: send enter %s: %w", id, err)
}
return nil
}
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
out, err := r.runner.Run(cmdCtx, r.binary, args...)
if cmdCtx.Err() != nil {
return out, cmdCtx.Err()
}
if err != nil {
return out, commandError{err: err, output: strings.TrimSpace(string(out))}
}
return out, nil
}
func tmuxSessionName(id domain.SessionID) (string, error) {
raw := string(id)
if raw == "" {
return "", errors.New("tmux runtime: session id is required")
}
if sessionIDPattern.MatchString(raw) {
return raw, nil
}
return sanitizedSessionName(raw), nil
}
func sanitizedSessionName(raw string) string {
var b strings.Builder
lastDash := false
for _, r := range raw {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
if valid {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
base := strings.Trim(b.String(), "-")
if base == "" {
base = "session"
}
if len(base) > 40 {
base = strings.TrimRight(base[:40], "-")
}
sum := sha256.Sum256([]byte(raw))
return base + "-" + hex.EncodeToString(sum[:4])
}
func validateSessionID(id string) error {
if id == "" {
return errors.New("tmux runtime: session id is required")
}
if !sessionIDPattern.MatchString(id) {
return fmt.Errorf("tmux runtime: invalid session id %q", id)
}
return nil
}
func handleID(handle ports.RuntimeHandle) (string, error) {
if handle.RuntimeName != "" && handle.RuntimeName != runtimeName {
return "", fmt.Errorf("tmux runtime: wrong runtime %q", handle.RuntimeName)
}
if err := validateSessionID(handle.ID); err != nil {
return "", err
}
return handle.ID, nil
}
func useBuffer(message string) bool {
return strings.Contains(message, "\n") || len(message) > longMessageThreshold
}
type commandError struct {
err error
output string
}
func (e commandError) Error() string {
if e.output == "" {
return e.err.Error()
}
return e.err.Error() + ": " + e.output
}
func (e commandError) Unwrap() error { return e.err }

View File

@ -1,112 +0,0 @@
package tmux
import (
"context"
"os/exec"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestRuntimeIntegration(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
r := New(Options{Timeout: 5 * time.Second})
ctx := context.Background()
id := "ao_itest_tmux"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_itest_tmux",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready\\n",
Env: map[string]string{"AO_SESSION_ID": id},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if !alive {
t.Fatal("alive = false, want true")
}
if err := r.SendMessage(ctx, h, "printf hello-from-tmux"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
var out string
for time.Now().Before(deadline) {
out, err = r.GetOutput(ctx, h, 20)
if err != nil {
t.Fatalf("GetOutput: %v", err)
}
if strings.Contains(out, "hello-from-tmux") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(out, "hello-from-tmux") {
t.Fatalf("output = %q, want sent command output", out)
}
if err := r.Destroy(ctx, h); err != nil {
t.Fatalf("Destroy: %v", err)
}
alive, err = r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive after destroy: %v", err)
}
if alive {
t.Fatal("alive after destroy = true, want false")
}
}
func TestRuntimeIntegrationUsesExactTargets(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
r := New(Options{Timeout: 5 * time.Second})
ctx := context.Background()
longID := "ao_exact_target_long"
prefixID := "ao_exact_target"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_exact_target_long",
WorkspacePath: t.TempDir(),
LaunchCommand: "cat",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive prefix: %v", err)
}
if alive {
t.Fatal("prefix handle reported alive; tmux target matching is not exact")
}
if err := r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}); err != nil {
t.Fatalf("Destroy prefix: %v", err)
}
alive, err = r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive long after prefix destroy: %v", err)
}
if !alive {
t.Fatal("destroying prefix handle killed longer session")
}
}

View File

@ -1,256 +0,0 @@
package tmux
import (
"context"
"errors"
"os/exec"
"reflect"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestNewDefaultsToPortableShell(t *testing.T) {
t.Setenv("SHELL", "")
r := New(Options{})
if got, want := r.shell, "/bin/sh"; got != want {
t.Fatalf("default shell = %q, want %q", got, want)
}
}
func TestCommandBuilders(t *testing.T) {
if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) {
t.Fatalf("newSessionArgs = %#v, want %#v", got, want)
}
if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "=sess-1:", "status", "off"}; !reflect.DeepEqual(got, want) {
t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want)
}
if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "=sess-1:0.0", "-S", "-42"}; !reflect.DeepEqual(got, want) {
t.Fatalf("capturePaneArgs = %#v, want %#v", got, want)
}
}
func TestExactTargets(t *testing.T) {
if got, want := exactSessionTarget("abc"), "=abc:"; got != want {
t.Fatalf("exactSessionTarget = %q, want %q", got, want)
}
if got, want := exactPaneTarget("abc"), "=abc:0.0"; got != want {
t.Fatalf("exactPaneTarget = %q, want %q", got, want)
}
}
func TestTmuxSessionNameSanitizesIssueRefs(t *testing.T) {
got, err := tmuxSessionName("repo/issue#42.1")
if err != nil {
t.Fatalf("tmuxSessionName: %v", err)
}
if err := validateSessionID(got); err != nil {
t.Fatalf("sanitized id %q is invalid: %v", got, err)
}
if !strings.HasPrefix(got, "repo-issue-42-1-") {
t.Fatalf("sanitized id = %q, want readable prefix", got)
}
if got == "repo/issue#42.1" {
t.Fatal("sanitized id still contains raw unsafe characters")
}
}
func TestValidateSessionID(t *testing.T) {
valid := []string{"sess-1", "S_2", "abc123"}
for _, id := range valid {
if err := validateSessionID(id); err != nil {
t.Fatalf("validateSessionID(%q): %v", id, err)
}
}
invalid := []string{"", "sess.1", "sess/1", "$(boom)", "with space"}
for _, id := range invalid {
if err := validateSessionID(id); err == nil {
t.Fatalf("validateSessionID(%q): got nil, want error", id)
}
}
}
func TestWrapLaunchCommandExportsEnvAndKeepsPaneAlive(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
if key == "PATH" {
return "/usr/bin:/bin"
}
return ""
}
defer func() { getenv = oldGetenv }()
got := wrapLaunchCommand(ports.RuntimeConfig{LaunchCommand: "ao run", Env: map[string]string{
"AO_SESSION_ID": "sess-1",
"ODD": "can't",
"PATH": "/custom/bin:/usr/bin",
}}, "/bin/zsh")
for _, want := range []string{
"export AO_SESSION_ID='sess-1';",
"export ODD='can'\\''t';",
"export PATH='/custom/bin:/usr/bin';",
"ao run; exec '/bin/zsh' -i",
} {
if !strings.Contains(got, want) {
t.Fatalf("wrapped command missing %q in %q", want, got)
}
}
}
func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/zsh"})
r.runner = fr
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if handle != (ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) {
t.Fatalf("handle = %+v, want tmux handle", handle)
}
if len(fr.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(fr.calls))
}
if got, want := fr.calls[0].args[:6], []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws"}; !reflect.DeepEqual(got, want) {
t.Fatalf("create args prefix = %#v, want %#v", got, want)
}
if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("status args = %#v, want %#v", got, want)
}
}
func TestCreateNormalizesUnsafeSessionID(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh"})
r.runner = fr
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "repo/issue#42",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := validateSessionID(handle.ID); err != nil {
t.Fatalf("handle id %q invalid: %v", handle.ID, err)
}
if handle.ID == "repo/issue#42" {
t.Fatal("handle kept unsafe raw session id")
}
if got := fr.calls[0].args[3]; got != handle.ID {
t.Fatalf("tmux session arg = %q, want handle id %q", got, handle.ID)
}
}
func TestSendMessageUsesLiteralForShortInput(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if got, want := fr.calls[0].args, sendLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) {
t.Fatalf("literal args = %#v, want %#v", got, want)
}
if got, want := fr.calls[1].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("enter args = %#v, want %#v", got, want)
}
}
func TestSendMessageUsesBufferForMultilineInput(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello\nworld"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if len(fr.calls) != 3 {
t.Fatalf("calls = %d, want 3", len(fr.calls))
}
if fr.calls[0].args[0] != "load-buffer" {
t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args)
}
if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "=sess-1:0.0"}) {
t.Fatalf("paste args = %#v", got)
}
if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("enter args = %#v, want %#v", got, want)
}
}
func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if alive {
t.Fatal("alive = true, want false")
}
}
func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil {
t.Fatalf("Destroy: %v", err)
}
if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" {
t.Fatalf("calls = %#v, want only kill-session", fr.calls)
}
}
func TestGetOutputValidatesLines(t *testing.T) {
r := New(Options{Timeout: time.Second})
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, 0)
if err == nil {
t.Fatal("GetOutput lines=0: got nil, want error")
}
}
type fakeRunner struct {
calls []runnerCall
out []byte
err error
}
type runnerCall struct {
name string
args []string
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.calls = append(f.calls, runnerCall{name: name, args: append([]string(nil), args...)})
if f.err != nil {
return f.out, f.err
}
return f.out, nil
}
func TestCommandErrorUnwraps(t *testing.T) {
base := errors.New("base")
err := commandError{err: base, output: "details"}
if !errors.Is(err, base) {
t.Fatal("commandError should unwrap base error")
}
if !strings.Contains(err.Error(), "details") {
t.Fatalf("error = %q, want output details", err.Error())
}
}

View File

@ -10,7 +10,6 @@ import (
)
const (
runtimeName = "zellij"
agentPaneName = "agent"
defaultChunkBytes = 16 * 1024
)
@ -188,6 +187,31 @@ func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string {
return b.String()
}
func validateEnvKeys(env map[string]string) error {
for key := range env {
if !validEnvKey(key) {
return fmt.Errorf("zellij runtime: invalid env key %q", key)
}
}
return nil
}
func validEnvKey(key string) bool {
if key == "" {
return false
}
for i, r := range key {
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
continue
}
if i > 0 && r >= '0' && r <= '9' {
continue
}
return false
}
return true
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {

View File

@ -115,6 +115,9 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
if cfg.LaunchCommand == "" {
return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required")
}
if err := validateEnvKeys(cfg.Env); err != nil {
return ports.RuntimeHandle{}, err
}
if err := r.ensureSupportedVersion(ctx); err != nil {
return ports.RuntimeHandle{}, err
}
@ -130,10 +133,10 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
}
paneID, err := r.findAgentPane(ctx, id)
if err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
return ports.RuntimeHandle{}, err
}
return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil
return ports.RuntimeHandle{ID: handleIDValue(id, paneID)}, nil
}
// Destroy kills the handle's zellij session. An already-gone session is treated
@ -225,13 +228,9 @@ func (r *Runtime) ensureSupportedVersion(ctx context.Context) error {
if err != nil {
return fmt.Errorf("zellij runtime: check version: %w", err)
}
version, err := parseVersion(string(out))
if err != nil {
if _, err := CheckVersionOutput(string(out)); err != nil {
return fmt.Errorf("zellij runtime: check version: %w", err)
}
if compareVersion(version, semver{minMajor, minMinor, minPatch}) < 0 {
return fmt.Errorf("zellij runtime: unsupported zellij version %s; require >= %d.%d.%d", version, minMajor, minMinor, minPatch)
}
return nil
}
@ -384,9 +383,6 @@ func validatePaneID(id string) error {
}
func handleID(handle ports.RuntimeHandle) (string, string, error) {
if handle.RuntimeName != "" && handle.RuntimeName != runtimeName {
return "", "", fmt.Errorf("zellij runtime: wrong runtime %q", handle.RuntimeName)
}
parts := strings.Split(handle.ID, "/")
if len(parts) == 1 {
if err := validateSessionID(parts[0]); err != nil {
@ -471,6 +467,25 @@ func tailLines(s string, n int) string {
return strings.Join(lines[len(lines)-n:], "")
}
// RequiredVersion returns the minimum Zellij version AO's runtime adapter
// supports.
func RequiredVersion() string { return minSupportedVersion().String() }
// CheckVersionOutput parses `zellij --version` output, returning the parsed
// version when it satisfies AO's minimum runtime requirement.
func CheckVersionOutput(out string) (string, error) {
version, err := parseVersion(out)
if err != nil {
return "", err
}
if compareVersion(version, minSupportedVersion()) < 0 {
return version.String(), fmt.Errorf("unsupported zellij version %s; require >= %s", version, RequiredVersion())
}
return version.String(), nil
}
func minSupportedVersion() semver { return semver{minMajor, minMinor, minPatch} }
type semver struct {
major int
minor int

View File

@ -25,7 +25,7 @@ func TestRuntimeIntegration(t *testing.T) {
}
configDir := t.TempDir()
r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_itest_zj",
@ -90,8 +90,8 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) {
r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()})
longID := "ao_zj_exact_long"
prefixID := "ao_zj_exact"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_zj_exact_long",
@ -103,7 +103,7 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) {
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID})
if err != nil {
t.Fatalf("IsAlive prefix: %v", err)
}

View File

@ -83,17 +83,13 @@ func TestValidateSessionAndPaneID(t *testing.T) {
}
func TestHandleID(t *testing.T) {
session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: runtimeName})
session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7"})
if err != nil {
t.Fatalf("handleID: %v", err)
}
if session != "sess-1" || pane != "terminal_7" {
t.Fatalf("handleID = %q/%q", session, pane)
}
_, _, err = handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: "tmux"})
if err == nil {
t.Fatal("wrong runtime: got nil, want error")
}
}
func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
@ -176,6 +172,20 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) {
}
}
func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"})
r.runner = &fakeRunner{}
_, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Env: map[string]string{"BAD KEY": "x"},
})
if err == nil || !strings.Contains(err.Error(), "invalid env key") {
t.Fatalf("Create err = %v, want invalid env key", err)
}
}
func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}}
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"})
@ -190,7 +200,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
if err != nil {
t.Fatalf("Create: %v", err)
}
if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3", RuntimeName: runtimeName}) {
if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) {
t.Fatalf("handle = %+v, want zellij handle", handle)
}
if len(fr.calls) != 3 {
@ -212,7 +222,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
func TestAttachCommandUsesSocketDir(t *testing.T) {
r := New(Options{SocketDir: "/tmp/zj"})
args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"})
if err != nil {
t.Fatalf("AttachCommand: %v", err)
}
@ -270,6 +280,15 @@ func TestParseVersion(t *testing.T) {
if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 {
t.Fatal("compareVersion should order 0.44.2 before 0.44.3")
}
if got := RequiredVersion(); got != "0.44.3" {
t.Fatalf("RequiredVersion = %q, want 0.44.3", got)
}
if got, err := CheckVersionOutput("zellij 0.44.3"); err != nil || got != "0.44.3" {
t.Fatalf("CheckVersionOutput supported = %q, %v", got, err)
}
if _, err := CheckVersionOutput("zellij 0.44.2"); err == nil {
t.Fatal("CheckVersionOutput unsupported: got nil error")
}
}
func TestSendMessageChunksAndSendsEnter(t *testing.T) {
@ -277,7 +296,7 @@ func TestSendMessageChunksAndSendsEnter(t *testing.T) {
r := New(Options{Timeout: time.Second, ChunkSize: 5})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, "hello世界"); err != nil {
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, "hello世界"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if len(fr.calls) != 4 {
@ -302,7 +321,7 @@ func TestGetOutputTrimsLines(t *testing.T) {
r := New(Options{Timeout: time.Second})
r.runner = fr
out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 2)
out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2)
if err != nil {
t.Fatalf("GetOutput: %v", err)
}
@ -316,7 +335,7 @@ func TestIsAliveParsesNoFormattingOutput(t *testing.T) {
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
@ -336,7 +355,7 @@ func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) {
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
@ -350,7 +369,7 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}); err != nil {
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil {
t.Fatalf("Destroy: %v", err)
}
if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" {
@ -360,7 +379,7 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
func TestGetOutputValidatesLines(t *testing.T) {
r := New(Options{Timeout: time.Second})
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 0)
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 0)
if err == nil {
t.Fatal("GetOutput lines=0: got nil, want error")
}

View File

@ -36,7 +36,7 @@
// - No List pagination beyond a single page (callers requesting more than
// 100 results need to wait for the observer/polling work in issue #35).
// - No webhook receiver, no polling goroutine, no fact projection into
// LCM (issue #35).
// the PR service (issue #35).
// - No richer per-provider metadata on Issue (milestones, project boards,
// reactions); the port only carries fields all v1 providers can fill.
package github

View File

@ -230,10 +230,10 @@ func mapStateFromGitHub(state, reason string, labels []string) domain.Normalized
}
var hasProgress, hasReview bool
for _, l := range labels {
switch l {
case labelInProgress:
switch {
case strings.EqualFold(l, labelInProgress):
hasProgress = true
case labelInReview:
case strings.EqualFold(l, labelInReview):
hasReview = true
}
}
@ -376,7 +376,10 @@ func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte
return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("github tracker: read response body: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
@ -473,14 +476,9 @@ func parseGitHubID(native string) (owner, repo string, number int, err error) {
}
repoPart := native[:hash]
numPart := native[hash+1:]
slash := strings.IndexByte(repoPart, '/')
if slash < 0 {
return "", "", 0, fmt.Errorf("%w: missing owner/repo separator", ErrBadID)
}
owner = repoPart[:slash]
repo = repoPart[slash+1:]
if owner == "" || repo == "" {
return "", "", 0, fmt.Errorf("%w: empty owner or repo", ErrBadID)
owner, repo, err = parseGitHubRepo(repoPart)
if err != nil {
return "", "", 0, err
}
n, parseErr := strconv.Atoi(numPart)
if parseErr != nil || n <= 0 {

View File

@ -115,6 +115,8 @@ func TestParseID(t *testing.T) {
{"missing slash", "octocat#42", "", "", 0, true},
{"empty owner", "/repo#1", "", "", 0, true},
{"empty repo", "owner/#1", "", "", 0, true},
{"embedded slash", "o/r/x#1", "", "", 0, true},
{"space", "o/r space#1", "", "", 0, true},
{"non-numeric", "o/r#abc", "", "", 0, true},
{"zero", "o/r#0", "", "", 0, true},
{"negative", "o/r#-1", "", "", 0, true},
@ -184,7 +186,7 @@ func TestGet_StateMappingFromGitHubFields(t *testing.T) {
wantState domain.NormalizedIssueState
}{
{"plain open", "open", "", nil, domain.IssueOpen},
{"open with in-progress label", "open", "", []string{"in-progress"}, domain.IssueInProgress},
{"open with in-progress label", "open", "", []string{"In-Progress"}, domain.IssueInProgress},
{"open with in-review label", "open", "", []string{"in-review"}, domain.IssueInReview},
{"review wins over progress when both present", "open", "", []string{"in-progress", "in-review"}, domain.IssueInReview},
{"closed completed", "closed", "completed", nil, domain.IssueDone},
@ -288,7 +290,7 @@ func TestGet_SecondaryRateLimit(t *testing.T) {
func TestGet_RejectsWrongProvider(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitLab, Native: "g/p#1"})
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProvider("gitlab"), Native: "g/p#1"})
if !errors.Is(err, ErrWrongProvider) {
t.Fatalf("err = %v, want ErrWrongProvider", err)
}
@ -518,7 +520,7 @@ func TestList_QueryEncoding(t *testing.T) {
func TestList_RejectsWrongProvider(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
_, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitLab, Native: "g/p"}, domain.ListFilter{})
_, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProvider("gitlab"), Native: "g/p"}, domain.ListFilter{})
if !errors.Is(err, ErrWrongProvider) {
t.Fatalf("err = %v, want ErrWrongProvider", err)
}

View File

@ -1,5 +1,7 @@
package gitworktree
import "strings"
func checkRefFormatBranchArgs(repo, branch string) []string {
return []string{"-C", repo, "check-ref-format", "--branch", branch}
}
@ -34,12 +36,11 @@ func worktreeListPorcelainArgs(repo string) []string {
}
func baseRefCandidates(branch, defaultBranch string) []string {
return []string{"origin/" + branch, "origin/" + defaultBranch, branch}
}
func chooseWorktreeAddArgs(repo, path, branch, baseRef string, localBranchExists bool) []string {
if localBranchExists {
return worktreeAddBranchArgs(repo, path, branch)
candidates := []string{"origin/" + branch}
if strings.Contains(defaultBranch, "/") {
candidates = append(candidates, defaultBranch)
} else {
candidates = append(candidates, "origin/"+defaultBranch)
}
return worktreeAddNewBranchArgs(repo, branch, path, baseRef)
return append(candidates, branch)
}

View File

@ -143,7 +143,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error
if err != nil {
return err
}
if worktreeRegistered(records, path) {
if _, ok := findWorktree(records, path); ok {
if removeErr != nil {
return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr)
}
@ -155,26 +155,6 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error
return nil
}
// List returns the managed worktrees that belong to a project.
func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) {
if project == "" {
return nil, errors.New("gitworktree: project id is required")
}
repo, err := w.repoPath(project)
if err != nil {
return nil, err
}
records, err := w.listRecords(ctx, repo)
if err != nil {
return nil, err
}
projectRoot, err := w.projectRoot(project)
if err != nil {
return nil, err
}
return filterProjectWorktrees(records, projectRoot, project), nil
}
// Restore re-attaches to an existing worktree for the session if one is still
// present, recreating the handle without disturbing its contents.
func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
@ -220,7 +200,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string)
return err
}
if localBranch {
if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, "", true)...); err != nil {
if _, err := w.run(ctx, w.binary, worktreeAddBranchArgs(repo, path, branch)...); err != nil {
return fmt.Errorf("gitworktree: worktree add existing branch %q: %w", branch, err)
}
return nil
@ -229,7 +209,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string)
if err != nil {
return err
}
if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, baseRef, false)...); err != nil {
if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); err != nil {
return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err)
}
return nil
@ -358,11 +338,6 @@ func (w *Workspace) managedPath(project domain.ProjectID, session domain.Session
return w.validateManagedPath(path)
}
func (w *Workspace) projectRoot(project domain.ProjectID) (string, error) {
path := filepath.Join(w.managedRoot, string(project))
return w.validateManagedPath(path)
}
func (w *Workspace) validateManagedPath(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("%w: empty path", ErrUnsafePath)
@ -397,29 +372,6 @@ func pathWithin(root, path string) (bool, error) {
return rel == "." || (rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))), nil
}
func filterProjectWorktrees(records []worktreeRecord, projectRoot string, project domain.ProjectID) []ports.WorkspaceInfo {
out := make([]ports.WorkspaceInfo, 0, len(records))
for _, rec := range records {
path := filepath.Clean(rec.Path)
inside, err := pathWithin(projectRoot, path)
if err != nil || !inside || path == projectRoot {
continue
}
out = append(out, ports.WorkspaceInfo{
Path: path,
Branch: rec.Branch,
SessionID: domain.SessionID(filepath.Base(path)),
ProjectID: project,
})
}
return out
}
func worktreeRegistered(records []worktreeRecord, path string) bool {
_, ok := findWorktree(records, path)
return ok
}
func findWorktree(records []worktreeRecord, path string) (worktreeRecord, bool) {
clean := filepath.Clean(path)
for _, rec := range records {

View File

@ -12,7 +12,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) {
func TestWorkspaceIntegrationCreateRestoreDestroy(t *testing.T) {
git := requireGit(t)
tmp := t.TempDir()
repo := setupOriginClone(t, git, tmp)
@ -35,14 +35,6 @@ func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) {
t.Fatalf("created worktree missing seed file: %v", err)
}
listed, err := ws.List(ctx, "proj")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 || listed[0].Path != info.Path || listed[0].Branch != cfg.Branch || listed[0].SessionID != cfg.SessionID {
t.Fatalf("listed = %#v", listed)
}
restored, err := ws.Restore(ctx, cfg)
if err != nil {
t.Fatalf("restore registered: %v", err)

View File

@ -9,7 +9,6 @@ import (
"strings"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -25,8 +24,8 @@ func TestCommandArgs(t *testing.T) {
}{
{"check ref", checkRefFormatBranchArgs(repo, branch), []string{"-C", repo, "check-ref-format", "--branch", branch}},
{"rev parse", revParseVerifyArgs(repo, "origin/main"), []string{"-C", repo, "rev-parse", "--verify", "--quiet", "origin/main"}},
{"add existing", chooseWorktreeAddArgs(repo, path, branch, "", true), []string{"-C", repo, "worktree", "add", path, branch}},
{"add new", chooseWorktreeAddArgs(repo, path, branch, "origin/main", false), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}},
{"add existing", worktreeAddBranchArgs(repo, path, branch), []string{"-C", repo, "worktree", "add", path, branch}},
{"add new", worktreeAddNewBranchArgs(repo, branch, path, "origin/main"), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}},
// No --force: a dirty worktree must cause `git worktree remove` to fail so
// the post-prune safety check surfaces the refusal instead of deleting
// uncommitted agent work (review item RA).
@ -49,6 +48,12 @@ func TestBaseRefCandidates(t *testing.T) {
if !reflect.DeepEqual(got, want) {
t.Fatalf("candidates = %#v, want %#v", got, want)
}
got = baseRefCandidates("feature/test", "upstream/main")
want = []string{"origin/feature/test", "upstream/main", "feature/test"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("qualified candidates = %#v, want %#v", got, want)
}
}
func TestParseWorktreePorcelain(t *testing.T) {
@ -88,26 +93,6 @@ func TestParseWorktreePorcelain(t *testing.T) {
}
}
func TestFilterProjectWorktrees(t *testing.T) {
root := filepath.Clean("/managed/proj")
recs := []worktreeRecord{
{Path: "/repo", Branch: "main"},
{Path: "/managed/proj/s1", Branch: "feature/one"},
{Path: "/managed/proj/s2", Branch: ""},
{Path: "/managed/other/s3", Branch: "feature/three"},
}
got := filterProjectWorktrees(recs, root, domain.ProjectID("proj"))
if len(got) != 2 {
t.Fatalf("len = %d, want 2: %#v", len(got), got)
}
if got[0].SessionID != "s1" || got[0].Branch != "feature/one" || got[0].ProjectID != "proj" {
t.Fatalf("first = %#v", got[0])
}
if got[1].SessionID != "s2" || got[1].Branch != "" {
t.Fatalf("second = %#v", got[1])
}
}
func TestManagedPathSafety(t *testing.T) {
root := t.TempDir()
ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}})

View File

@ -5,11 +5,11 @@ import (
"sync"
)
// Broadcaster is the in-process fan-out the poller feeds. Subscribers (the
// WS/SSE transport, wired in the frontend task) register a callback; every
// polled Event is delivered to all current subscribers. It is the single seam
// between the CDC poller and live delivery, so the transport can be built and
// swapped without touching the poller.
// Broadcaster is the in-process fan-out the poller feeds. Subscribers such as
// terminal session-state fan-out register a callback; every polled Event is
// delivered to all current subscribers. It is the single seam between the CDC
// poller and live delivery, so transports can be built and swapped without
// touching the poller.
type Broadcaster struct {
mu sync.RWMutex
nextID int

View File

@ -9,33 +9,10 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// storeSource adapts sqlite.Store to cdc.Source — the same glue the daemon wires.
type storeSource struct{ s *sqlite.Store }
func (a storeSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) {
rows, err := a.s.ReadChangeLogAfter(ctx, after, limit)
if err != nil {
return nil, err
}
out := make([]cdc.Event, len(rows))
for i, r := range rows {
out[i] = cdc.Event{
Seq: r.Seq,
ProjectID: r.ProjectID,
SessionID: r.SessionID,
Type: cdc.EventType(r.EventType),
Payload: json.RawMessage(r.Payload),
CreatedAt: r.CreatedAt,
}
}
return out, nil
}
func (a storeSource) LatestSeq(ctx context.Context) (int64, error) { return a.s.MaxChangeLogSeq(ctx) }
func newStore(t *testing.T) *sqlite.Store {
t.Helper()
s, err := sqlite.Open(t.TempDir())
@ -50,15 +27,12 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord {
t.Helper()
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
if err := s.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil {
if err := s.Upsert(ctx, project.Row{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil {
t.Fatal(err)
}
r, err := s.CreateSession(ctx, domain.SessionRecord{
ProjectID: "mer", Kind: domain.KindWorker,
Lifecycle: domain.CanonicalSessionLifecycle{
Session: domain.SessionSubstate{State: domain.SessionWorking},
Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative},
},
Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative},
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
@ -74,18 +48,18 @@ func TestE2E_StoreWriteToBroadcast(t *testing.T) {
s := newStore(t)
r := seedSession(t, s) // -> session_created (seq 1)
r.Lifecycle.Session.State = domain.SessionIdle
r.Activity.State = domain.ActivityIdle
if err := s.UpdateSession(ctx, r); err != nil { // -> session_updated (seq 2)
t.Fatal(err)
}
if err := s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3)
if err := s.WritePR(ctx, domain.PullRequest{URL: "pr1", SessionID: r.ID, UpdatedAt: r.UpdatedAt}, nil, nil); err != nil { // -> pr_created (seq 3)
t.Fatal(err)
}
var got []cdc.Event
bc := cdc.NewBroadcaster()
bc.Subscribe(func(e cdc.Event) { got = append(got, e) })
p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top
p := cdc.NewPoller(s, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top
if err := p.Poll(ctx); err != nil {
t.Fatal(err)
}
@ -109,7 +83,7 @@ func TestE2E_StoreWriteToBroadcast(t *testing.T) {
if err := json.Unmarshal(got[0].Payload, &payload); err != nil {
t.Fatalf("payload not JSON: %v", err)
}
if payload["id"] != string(r.ID) || payload["state"] != "working" {
if payload["id"] != string(r.ID) || payload["activity"] != "active" {
t.Fatalf("payload = %v", payload)
}
@ -135,17 +109,21 @@ func TestE2E_ConcurrentPollerLiveDelivery(t *testing.T) {
bc := cdc.NewBroadcaster()
bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() })
p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // from the top
p := cdc.NewPoller(s, bc, cdc.PollerConfig{}) // from the top
done := p.Start(ctx)
const n = 6
for i := 0; i < n; i++ {
r.Lifecycle.IsAlive = i%2 == 0 // toggles is_alive -> sessions_cdc_update fires
if i%2 == 0 {
r.Activity.State = domain.ActivityActive
} else {
r.Activity.State = domain.ActivityIdle
}
if err := s.UpdateSession(ctx, r); err != nil {
t.Fatal(err)
}
}
want := 1 + n // session_created + n updates
want := n // session_created + n-1 activity updates; first write is unchanged
deadline := time.Now().Add(5 * time.Second)
for {

View File

@ -1,7 +1,8 @@
// Package cdc is the change-data-capture delivery layer. Change events are
// captured durably by SQLite triggers into the change_log table (see the storage
// migrations); this package POLLS that log and fans new events out, in order, to
// in-process subscribers (the WS/SSE transport, wired in the frontend task).
// in-process subscribers such as terminal session-state fan-out. Future SSE/event
// endpoints can subscribe here too.
//
// There is no durable outbox/JSONL/janitor machinery: the change_log table IS
// the durable, ordered source of truth, and clients catch up by reading it from
@ -19,13 +20,11 @@ type EventType string
// Event types, one per row-change the DB triggers emit into change_log.
const (
EventSessionCreated EventType = "session_created"
EventSessionUpdated EventType = "session_updated"
EventPRCreated EventType = "pr_created"
EventPRUpdated EventType = "pr_updated"
EventPRCheckRecorded EventType = "pr_check_recorded"
EventNotificationCreated EventType = "notification_created"
EventNotificationUpdated EventType = "notification_updated"
EventSessionCreated EventType = "session_created"
EventSessionUpdated EventType = "session_updated"
EventPRCreated EventType = "pr_created"
EventPRUpdated EventType = "pr_updated"
EventPRCheckRecorded EventType = "pr_check_recorded"
)
// Event is one CDC change read from change_log. Seq is the monotonic ordering +

View File

@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
)
@ -38,6 +39,7 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "Run local AO health checks",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
checks := ctx.runDoctor(cmd.Context())
failures := 0
@ -97,12 +99,12 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
} else {
level := doctorPass
switch st.State {
case "stale", "not_ready":
case stateStale, stateNotReady:
level = doctorWarn
case "unhealthy":
case stateUnhealthy:
level = doctorFail
}
msg := st.State
msg := string(st.State)
if st.PID != 0 {
msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port)
}
@ -114,8 +116,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
checks = append(checks,
c.checkTool("git", true),
c.checkTool("tmux", false),
c.checkTool("zellij", false),
c.checkZellij(ctx),
)
return checks
}
@ -145,6 +146,24 @@ func checkStore(dataDir string) doctorCheck {
}
}
func (c *commandContext) checkZellij(ctx context.Context) doctorCheck {
path, err := c.deps.LookPath("zellij")
if err != nil {
return doctorCheck{Level: doctorWarn, Name: "zellij", Message: "not found in PATH"}
}
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
out, err := c.deps.CommandOutput(reqCtx, path, "--version")
if err != nil {
return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
}
version, err := zellij.CheckVersionOutput(string(out))
if err != nil {
return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)}
}
return doctorCheck{Level: doctorPass, Name: "zellij", Message: fmt.Sprintf("%s (%s)", path, version)}
}
func (c *commandContext) checkTool(name string, required bool) doctorCheck {
path, err := c.deps.LookPath(name)
if err == nil {

View File

@ -0,0 +1,71 @@
package cli
import (
"context"
"errors"
"strings"
"testing"
)
func TestDoctorChecksZellijVersion(t *testing.T) {
setConfigEnv(t)
cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}
c := &commandContext{deps: Deps{
LookPath: func(name string) (string, error) { return cmdPath[name], nil },
CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) {
if name != "/bin/zellij" || len(args) != 1 || args[0] != "--version" {
t.Fatalf("unexpected command: %s %v", name, args)
}
return []byte("zellij 0.44.3\n"), nil
},
}.withDefaults()}
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") {
t.Fatalf("zellij check = %+v, want PASS with version", check)
}
}
func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) {
setConfigEnv(t)
cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}
c := &commandContext{deps: Deps{
LookPath: func(name string) (string, error) { return cmdPath[name], nil },
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
return []byte("zellij 0.44.2\n"), nil
},
}.withDefaults()}
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") {
t.Fatalf("zellij check = %+v, want FAIL with minimum version", check)
}
}
func TestDoctorWarnsWhenZellijMissing(t *testing.T) {
setConfigEnv(t)
c := &commandContext{deps: Deps{
LookPath: func(name string) (string, error) {
if name == "git" {
return "/bin/git", nil
}
return "", errors.New("missing")
},
}.withDefaults()}
check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij")
if check.Level != doctorWarn {
t.Fatalf("zellij check = %+v, want WARN", check)
}
}
func findDoctorCheck(t *testing.T, checks []doctorCheck, name string) doctorCheck {
t.Helper()
for _, check := range checks {
if check.Name == name {
return check
}
}
t.Fatalf("doctor check %q not found in %+v", name, checks)
return doctorCheck{}
}

View File

@ -13,11 +13,7 @@ type processStartConfig struct {
Stderr *os.File
}
type processHandle struct {
PID int
}
func startProcess(cfg processStartConfig) (processHandle, error) {
func startProcess(cfg processStartConfig) error {
cmd := exec.Command(cfg.Path, cfg.Args...)
cmd.Env = cfg.Env
cmd.Stdout = cfg.Stdout
@ -27,8 +23,8 @@ func startProcess(cfg processStartConfig) (processHandle, error) {
// freshly spawned daemon (it would otherwise share the launcher's group).
cmd.SysProcAttr = detachSysProcAttr()
if err := cmd.Start(); err != nil {
return processHandle{}, err
return err
}
go func() { _ = cmd.Wait() }()
return processHandle{PID: cmd.Process.Pid}, nil
return nil
}

View File

@ -2,18 +2,7 @@
package cli
import (
"errors"
"syscall"
)
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
import "syscall"
// detachSysProcAttr puts the daemon in a new session (Setsid) so it is no
// longer in the launcher's foreground process group and won't receive the

View File

@ -3,32 +3,11 @@
package cli
import (
"errors"
"syscall"
"golang.org/x/sys/windows"
)
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return true
}
return false
}
defer windows.CloseHandle(handle)
status, err := windows.WaitForSingleObject(handle, 0)
if err != nil {
return false
}
return status == uint32(windows.WAIT_TIMEOUT)
}
// detachSysProcAttr starts the daemon in a new process group so it does not
// receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness.
func detachSysProcAttr() *syscall.SysProcAttr {

View File

@ -3,6 +3,7 @@
package cli
import (
"context"
"errors"
"io"
"net/http"
@ -13,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/daemon"
"github.com/aoagents/agent-orchestrator/backend/internal/processalive"
)
// Execute runs the ao CLI with process stdio.
@ -48,31 +50,37 @@ type Deps struct {
Out io.Writer
Err io.Writer
HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) (processHandle, error)
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
Now func() time.Time
Sleep func(time.Duration)
HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) error
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
Now func() time.Time
Sleep func(time.Duration)
}
// DefaultDeps returns production dependencies.
func DefaultDeps() Deps {
return Deps{
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
HTTPClient: &http.Client{Timeout: 2 * time.Second},
Executable: os.Executable,
StartProcess: startProcess,
ProcessAlive: processAlive,
LookPath: exec.LookPath,
Now: time.Now,
Sleep: time.Sleep,
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
HTTPClient: &http.Client{Timeout: 2 * time.Second},
Executable: os.Executable,
StartProcess: startProcess,
ProcessAlive: processalive.Alive,
LookPath: exec.LookPath,
CommandOutput: commandOutput,
Now: time.Now,
Sleep: time.Sleep,
}
}
func commandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
func (d Deps) withDefaults() Deps {
def := DefaultDeps()
if d.In == nil {
@ -99,6 +107,9 @@ func (d Deps) withDefaults() Deps {
if d.LookPath == nil {
d.LookPath = def.LookPath
}
if d.CommandOutput == nil {
d.CommandOutput = def.CommandOutput
}
if d.Now == nil {
d.Now = def.Now
}
@ -146,11 +157,19 @@ type commandContext struct {
deps Deps
}
func noArgs(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(0)(cmd, args); err != nil {
return usageError{err}
}
return nil
}
func newDaemonCommand() *cobra.Command {
return &cobra.Command{
Use: "daemon",
Short: "Run the AO backend daemon",
Hidden: true,
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return daemon.Run()
},

View File

@ -34,6 +34,27 @@ func TestRootHelpDoesNotShowDaemon(t *testing.T) {
}
}
func TestCommandsRejectUnexpectedArgs(t *testing.T) {
for _, args := range [][]string{
{"daemon", "extra"},
{"start", "extra"},
{"stop", "extra"},
{"status", "extra"},
{"doctor", "extra"},
{"version", "extra"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
_, _, err := executeCLI(t, Deps{}, args...)
if err == nil {
t.Fatal("expected usage error")
}
if got := ExitCode(err); got != 2 {
t.Fatalf("ExitCode(%v) = %d, want 2", err, got)
}
})
}
}
func TestStatusStoppedJSON(t *testing.T) {
setConfigEnv(t)
@ -71,9 +92,9 @@ func TestStartReturnsExistingReadyDaemon(t *testing.T) {
var started bool
out, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == os.Getpid() },
StartProcess: func(processStartConfig) (processHandle, error) {
StartProcess: func(processStartConfig) error {
started = true
return processHandle{}, nil
return nil
},
Now: func() time.Time { return time.Unix(110, 0).UTC() },
}, "start", "--json")
@ -115,7 +136,7 @@ func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) {
out, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() },
StartProcess: func(processStartConfig) (processHandle, error) {
StartProcess: func(processStartConfig) error {
info, err := runfile.Read(cfg.runFile)
if err != nil {
t.Fatal(err)
@ -127,7 +148,7 @@ func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) {
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil {
t.Fatal(err)
}
return processHandle{PID: os.Getpid()}, nil
return nil
},
Now: func() time.Time { return time.Unix(120, 0).UTC() },
}, "start", "--json")
@ -301,9 +322,9 @@ func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) {
var started bool
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == 4242 },
StartProcess: func(processStartConfig) (processHandle, error) {
StartProcess: func(processStartConfig) error {
started = true
return processHandle{}, nil
return nil
},
}, "start", "--timeout", "1ns", "--json")
if err == nil {

View File

@ -26,6 +26,7 @@ func newStartCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "start",
Short: "Start the AO daemon",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.startDaemon(cmd.Context(), opts)
if err != nil {
@ -34,7 +35,7 @@ func newStartCommand(ctx *commandContext) *cobra.Command {
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
if st.State == "ready" {
if st.State == stateReady {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port)
return err
}
@ -57,17 +58,17 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
if err != nil {
return daemonStatus{}, err
}
if st.State == "ready" {
if st.State == stateReady {
return st, nil
}
if st.State != "stopped" && st.State != "stale" {
if st.State != stateStopped && st.State != stateStale {
ready, waitErr := c.waitForReady(ctx, opts.timeout)
if waitErr == nil {
return ready, nil
}
return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr)
}
if st.State == "stale" {
if st.State == stateStale {
if err := runfile.Remove(cfg.RunFilePath); err != nil {
return daemonStatus{}, err
}
@ -91,7 +92,7 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
}
defer func() { _ = logFile.Close() }()
if _, err := c.deps.StartProcess(processStartConfig{
if err := c.deps.StartProcess(processStartConfig{
Path: exe,
Args: []string{"daemon"},
Env: os.Environ(),
@ -128,7 +129,7 @@ func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration
lastErr = err
} else {
last = st
if st.State == "ready" {
if st.State == stateReady {
return st, nil
}
}

View File

@ -20,17 +20,27 @@ type statusOptions struct {
json bool
}
type daemonState string
const (
stateReady daemonState = "ready"
stateStopped daemonState = "stopped"
stateStale daemonState = "stale"
stateUnhealthy daemonState = "unhealthy"
stateNotReady daemonState = "not_ready"
)
type daemonStatus struct {
State string `json:"state"`
PID int `json:"pid,omitempty"`
Port int `json:"port,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
Uptime string `json:"uptime,omitempty"`
RunFile string `json:"runFile"`
DataDir string `json:"dataDir"`
Health string `json:"health,omitempty"`
Ready string `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
State daemonState `json:"state"`
PID int `json:"pid,omitempty"`
Port int `json:"port,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
Uptime string `json:"uptime,omitempty"`
RunFile string `json:"runFile"`
DataDir string `json:"dataDir"`
Health string `json:"health,omitempty"`
Ready string `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
owned bool
}
@ -45,6 +55,7 @@ func newStatusCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "Show AO daemon status",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.inspectDaemon(cmd.Context())
if err != nil {
@ -65,7 +76,7 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error
if err != nil {
return daemonStatus{}, err
}
st := daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}
st := daemonStatus{State: stateStopped, RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}
info, err := runfile.Read(cfg.RunFilePath)
if err != nil {
@ -82,47 +93,47 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error
st.Uptime = formatUptime(c.deps.Now().Sub(info.StartedAt))
if !c.deps.ProcessAlive(info.PID) {
st.State = "stale"
st.State = stateStale
st.Error = "run-file points to a dead process"
return st, nil
}
health, err := c.readProbe(ctx, info.Port, "healthz")
if err != nil {
st.State = "unhealthy"
st.State = stateUnhealthy
st.Error = err.Error()
return st, nil
}
if err := verifyProbeOwner(health, info.PID, "healthz"); err != nil {
st.State = "stale"
st.State = stateStale
st.Error = err.Error()
return st, nil
}
st.owned = true
st.Health = health.Status
if health.Status != "ok" {
st.State = "unhealthy"
st.State = stateUnhealthy
return st, nil
}
ready, err := c.readProbe(ctx, info.Port, "readyz")
if err != nil {
st.State = "not_ready"
st.State = stateNotReady
st.Error = err.Error()
return st, nil
}
if err := verifyProbeOwner(ready, info.PID, "readyz"); err != nil {
st.State = "stale"
st.State = stateStale
st.owned = false
st.Error = err.Error()
return st, nil
}
st.Ready = ready.Status
if ready.Status == "ready" {
st.State = "ready"
if ready.Status == string(stateReady) {
st.State = stateReady
return st, nil
}
st.State = "not_ready"
st.State = stateNotReady
return st, nil
}

View File

@ -24,6 +24,7 @@ func newStopCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the AO daemon",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.stopDaemon(cmd.Context(), opts)
if err != nil {
@ -32,7 +33,7 @@ func newStopCommand(ctx *commandContext) *cobra.Command {
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
if st.State == "stopped" {
if st.State == stateStopped {
_, err = fmt.Fprintln(cmd.OutOrStdout(), "AO daemon stopped")
return err
}
@ -54,13 +55,13 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem
return daemonStatus{}, err
}
switch st.State {
case "stopped":
case stateStopped:
return st, nil
case "stale":
case stateStale:
if err := runfile.Remove(cfg.RunFilePath); err != nil {
return daemonStatus{}, err
}
return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil
return daemonStatus{State: stateStopped, RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil
}
if !st.owned {
if st.Error != "" {
@ -112,7 +113,7 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat
}
alive := c.deps.ProcessAlive(pid)
if info == nil {
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
return daemonStatus{State: stateStopped, RunFile: runFilePath, DataDir: dataDir}, nil
}
if !alive {
// Only remove the run-file if it still belongs to the process we
@ -124,7 +125,7 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat
return daemonStatus{}, err
}
}
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
return daemonStatus{State: stateStopped, RunFile: runFilePath, DataDir: dataDir}, nil
}
if !c.deps.Now().Before(deadline) {
return daemonStatus{}, fmt.Errorf("daemon pid %d did not stop within %s", pid, timeout)

View File

@ -33,7 +33,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if st.State != "stopped" {
if st.State != stateStopped {
t.Fatalf("state = %q, want stopped", st.State)
}
@ -70,7 +70,7 @@ func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if st.State != "stopped" {
if st.State != stateStopped {
t.Fatalf("state = %q, want stopped", st.State)
}
info, err := runfile.Read(runFile)

View File

@ -31,6 +31,7 @@ func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), VersionString())
return err

View File

@ -16,17 +16,14 @@ import (
const (
// LoopbackHost is the only host the daemon ever binds. There is deliberately
// no AO_HOST env var: the daemon has no auth/CORS/TLS and a stray
// AO_HOST=0.0.0.0 would turn it into a public no-auth service. The legacy
// TS server bound all-interfaces by accident and docs/CROSS_PLATFORM.md
// already calls that out as a bug; the Go rewrite fixes it by removing the
// knob entirely. If a non-default loopback (e.g. ::1, 127.0.0.2) is ever
// needed, add it back with an IsLoopback() validator — not a raw env read.
// AO_HOST=0.0.0.0 would turn it into a public no-auth service. If a
// non-default loopback (e.g. ::1, 127.0.0.2) is ever needed, add it back with
// an IsLoopback() validator — not a raw env read.
LoopbackHost = "127.0.0.1"
// DefaultPort is the single port the whole surface (REST, SSE, WS, static)
// is served from. Single-port keeps it same-origin: no CORS, one lifecycle.
// DefaultPort is the single port for REST, terminal mux, health, and control.
DefaultPort = 3001
// DefaultRequestTimeout bounds a single request. Long-lived surfaces (SSE,
// WS) are mounted outside this timeout; it guards the REST surface only.
// DefaultRequestTimeout bounds a single REST request. Long-lived terminal mux
// connections are mounted outside this timeout.
DefaultRequestTimeout = 60 * time.Second
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining.
@ -47,8 +44,8 @@ type Config struct {
// RunFilePath is where the PID + port handshake file (running.json) is
// written so the Electron supervisor can discover and reap the daemon.
RunFilePath string
// DataDir is the directory holding durable state (the SQLite database and
// the CDC JSONL log). It is created on first use by the storage layer.
// DataDir is the directory holding durable SQLite state: DB and WAL files.
// It is created on first use by the storage layer.
DataDir string
}
@ -136,7 +133,7 @@ func parsePositiveDuration(name, raw string) (time.Duration, error) {
}
// resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE
// wins; otherwise it sits under the per-user state directory so multiple repos
// wins; otherwise it sits under the per-user config directory so multiple repos
// share one supervisor handshake location.
func resolveRunFilePath() (string, error) {
if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" {
@ -150,7 +147,7 @@ func resolveRunFilePath() (string, error) {
}
// resolveDataDir picks where durable state (the SQLite DB) lives. An explicit
// AO_DATA_DIR wins; otherwise it sits under the per-user state directory
// AO_DATA_DIR wins; otherwise it sits under the per-user config directory
// alongside running.json.
func resolveDataDir() (string, error) {
if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" {

View File

@ -1,6 +1,8 @@
package config
import (
"path/filepath"
"strings"
"testing"
"time"
)
@ -8,7 +10,7 @@ import (
func TestLoadDefaults(t *testing.T) {
// Clear every recognised var so we observe pure defaults regardless of the
// surrounding environment.
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE"} {
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR"} {
t.Setenv(k, "")
}
@ -31,6 +33,15 @@ func TestLoadDefaults(t *testing.T) {
if cfg.RunFilePath == "" {
t.Error("RunFilePath is empty, want a resolved default path")
}
if !strings.HasSuffix(cfg.RunFilePath, filepath.Join("agent-orchestrator", "running.json")) {
t.Errorf("RunFilePath = %q, want agent-orchestrator/running.json suffix", cfg.RunFilePath)
}
if cfg.DataDir == "" {
t.Error("DataDir is empty, want a resolved default path")
}
if !strings.HasSuffix(cfg.DataDir, filepath.Join("agent-orchestrator", "data")) {
t.Errorf("DataDir = %q, want agent-orchestrator/data suffix", cfg.DataDir)
}
}
func TestLoadOverrides(t *testing.T) {
@ -38,6 +49,7 @@ func TestLoadOverrides(t *testing.T) {
t.Setenv("AO_REQUEST_TIMEOUT", "5s")
t.Setenv("AO_SHUTDOWN_TIMEOUT", "3s")
t.Setenv("AO_RUN_FILE", "/tmp/ao-test-running.json")
t.Setenv("AO_DATA_DIR", "/tmp/ao-test-data")
cfg, err := Load()
if err != nil {
@ -55,6 +67,9 @@ func TestLoadOverrides(t *testing.T) {
if cfg.RunFilePath != "/tmp/ao-test-running.json" {
t.Errorf("RunFilePath = %q, want /tmp/ao-test-running.json", cfg.RunFilePath)
}
if cfg.DataDir != "/tmp/ao-test-data" {
t.Errorf("DataDir = %q, want /tmp/ao-test-data", cfg.DataDir)
}
}
func TestLoadInvalid(t *testing.T) {

View File

@ -2,18 +2,17 @@ package daemon
import (
"context"
"encoding/json"
"log/slog"
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// cdcPipeline owns the running CDC poller and the broadcaster the SSE transport
// subscribes to. The DB triggers write change_log; the poller tails it and fans
// each new event out through the broadcaster. Durable catch-up is the client's
// job (it reads change_log from its own Last-Event-ID), so the poller only
// pushes live events and re-seeks to head on restart.
// cdcPipeline owns the running CDC poller and live-event broadcaster. The DB
// triggers write change_log; the poller tails it and fans each new event out to
// live transports such as terminal session-state subscriptions. Durable catch-up
// is a client concern; the poller only pushes live events and re-seeks to head
// on restart.
type cdcPipeline struct {
Broadcaster *cdc.Broadcaster
done <-chan struct{}
@ -23,7 +22,7 @@ type cdcPipeline struct {
// when ctx is cancelled; Stop waits for it to drain.
func startCDC(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*cdcPipeline, error) {
bcast := cdc.NewBroadcaster()
poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{Logger: logger})
poller := cdc.NewPoller(store, bcast, cdc.PollerConfig{Logger: logger})
if err := poller.SeekToHead(ctx); err != nil {
return nil, err
}
@ -36,29 +35,3 @@ func (p *cdcPipeline) Stop() error {
<-p.done
return nil
}
// cdcSource adapts *sqlite.Store's change_log reads to cdc.Source.
type cdcSource struct{ store *sqlite.Store }
func (s cdcSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) {
rows, err := s.store.ReadChangeLogAfter(ctx, after, limit)
if err != nil {
return nil, err
}
out := make([]cdc.Event, len(rows))
for i, r := range rows {
out[i] = cdc.Event{
Seq: r.Seq,
ProjectID: r.ProjectID,
SessionID: r.SessionID,
Type: cdc.EventType(r.EventType),
Payload: json.RawMessage(r.Payload),
CreatedAt: r.CreatedAt,
}
}
return out, nil
}
func (s cdcSource) LatestSeq(ctx context.Context) (int64, error) {
return s.store.MaxChangeLogSeq(ctx)
}

View File

@ -11,9 +11,10 @@ import (
"os/signal"
"syscall"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
@ -38,13 +39,9 @@ func Run() error {
return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port)
}
// Open the durable store and bring up the CDC substrate: the DB triggers
// capture changes into change_log, the poller tails it, and the broadcaster
// fans events out to the SSE transport. The LCM/Session Manager and the HTTP
// API routes that drive and read this store are owned by the daemon lane and
// are wired there once their collaborators (Notifier, AgentMessenger, and the
// runtime/agent/workspace plugins) have production implementations; here we
// stand up the persistence + change-delivery foundation they build on.
// Open the durable store and bring up the CDC substrate: DB triggers capture
// changes into change_log, the poller tails it, and the broadcaster fans
// events out to live transports.
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return fmt.Errorf("open store: %w", err)
@ -61,46 +58,27 @@ func Run() error {
return err
}
// Terminal streaming: the tmux runtime supplies the PTY-attach command and
// Terminal streaming: the Zellij runtime supplies the PTY-attach command and
// liveness; the CDC broadcaster feeds the session-state channel. The manager
// is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow
// through the CDC change_log — only session-state events do.
runtimeAdapter := tmux.New(tmux.Options{})
runtimeAdapter := zellij.New(zellij.Options{})
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close()
srv, err := httpd.New(cfg, log, termMgr)
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)})
if err != nil {
return err
}
// Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE
// timer). This makes the write path live end-to-end: LCM write -> store -> DB
// trigger -> change_log -> poller -> broadcaster.
lcStack := startLifecycle(ctx, store, log)
// Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree)
// are real on main; ports.Agent has no production adapter yet, so a loud
// stub returns a sentinel command that makes any Spawn fail at the runtime
// layer rather than start a broken session quietly. Notifier and
// AgentMessenger remain stubbed alongside the LCM until their multiplexers
// land. No HTTP routes wire to this yet — the daemon lane (#10) owns API
// surfacing — so we hold the SM in a local until it does.
sStack, err := startSession(ctx, cfg, lcStack, log)
if err != nil {
// startSession is the first start* call after this point that can
// realistically fail while the cdc poller and the reaper are already
// running. Mirror the bottom-of-run shutdown sequence so both have
// drained before the deferred store.Close() fires. Defers would hit
// the LIFO trap (see comment after srv.Run), hence explicit.
stop()
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
_ = sStack
// Bring up the Lifecycle Manager and the reaper. This makes the session
// lifecycle write path live end-to-end: reducer write -> store -> DB trigger
// -> change_log -> poller -> broadcaster.
lcStack := startLifecycle(ctx, store, runtimeAdapter, log)
runErr := srv.Run(ctx)

View File

@ -3,147 +3,27 @@ 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"
)
// 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. Store is exposed so the Session
// Manager construction in startSession can plug the same SessionStore + PRWriter
// instance the LCM already holds (*sqlite.Store satisfies both ports directly).
// lifecycleStack owns the runtime reaper goroutine started with the lifecycle
// reducer. The reducer itself is only used for wiring observations into storage.
type lifecycleStack struct {
LCM *lifecycle.Manager
Store *sqlite.Store
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 {
renderer := notification.NewRenderer(store)
notifier := notification.NewEnqueuer(store, renderer, logger)
lcm := lifecycle.New(store, store, notifier, noopMessenger{})
rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger})
return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)}
// startLifecycle constructs the Lifecycle Manager over the store and starts the
// reaper. The goroutine stops when ctx is cancelled; Stop waits for it to drain.
func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, logger *slog.Logger) *lifecycleStack {
lcm := lifecycle.New(store, nil)
rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger})
return &lifecycleStack{reaperDone: rp.Start(ctx)}
}
// Stop waits for the reaper goroutine to exit (the caller must have cancelled the
// ctx passed to startLifecycle).
// Stop waits for the reaper goroutine to exit. The caller must cancel the ctx
// passed to startLifecycle before calling Stop.
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.Store,
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
}

View File

@ -2,27 +2,21 @@ package daemon
import (
"context"
"io"
"log/slog"
"reflect"
"sync"
"testing"
"time"
"unsafe"
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"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/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/session"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end:
// a lifecycle write -> sqlite -> DB trigger -> change_log -> CDC poller ->
// broadcaster, through the production wiring.Adapter and cdcSource.
// broadcaster, through the same cdc.Source implementation the daemon uses.
func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
ctx := context.Background()
store, err := sqlite.Open(t.TempDir())
@ -31,13 +25,10 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
}
defer store.Close()
renderer := notification.NewRenderer(store)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
notifier := notification.NewEnqueuer(store, renderer, logger)
lcm := lifecycle.New(store, store, notifier, noopMessenger{})
lcm := lifecycle.New(store, nil)
bcast := cdc.NewBroadcaster()
poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{})
poller := cdc.NewPoller(store, bcast, cdc.PollerConfig{})
if err := poller.SeekToHead(ctx); err != nil {
t.Fatal(err)
}
@ -46,19 +37,19 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
var got []cdc.Event
bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() })
if err := store.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/repo/mer"}); err != nil {
if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer"}); err != nil {
t.Fatal(err)
}
rec, err := store.CreateSession(ctx, domain.SessionRecord{
ProjectID: "mer", Kind: domain.KindWorker,
Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion, Session: domain.SessionSubstate{State: domain.SessionNotStarted}},
Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceNone},
})
if err != nil {
t.Fatal(err)
}
// A real transition through the engine, which writes the row and fires the
// is_alive/activity_state CDC trigger.
if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now()}); err != nil {
// activity_state/is_terminated CDC trigger.
if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now(), Source: domain.SourceNative}); err != nil {
t.Fatal(err)
}
@ -78,78 +69,3 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got))
}
}
// TestWiring_SessionManagerSharesLifecycleStoreAndLCM verifies that startSession
// constructs an SM whose Store and Lifecycle dependencies are the exact same
// values the LCM holds: a single canonical-store + LCM pair, not two parallel
// stacks that would diverge under concurrent writes. The brief constraint
// forbids modifying session/manager.go to add accessors, so the assertion
// reaches into the unexported fields via reflect + unsafe — scoped to the test
// and isolated in inspectSessionDeps.
func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
// Registered first so it runs LAST (after the reaper has drained).
t.Cleanup(func() { _ = store.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{DataDir: t.TempDir()}
lcStack := startLifecycle(ctx, store, log)
// lcStack.Stop blocks on the reaper goroutine, which only exits once its
// ctx is cancelled. Production main.go calls stop() before lcStack.Stop()
// for the same reason — same ordering here.
t.Cleanup(func() {
cancel()
lcStack.Stop()
})
sStack, err := startSession(ctx, cfg, lcStack, log)
if err != nil {
t.Fatal(err)
}
if sStack == nil || sStack.SM == nil {
t.Fatal("startSession returned nil Session Manager")
}
gotStore, gotLCM := inspectSessionDeps(t, sStack.SM)
// Store should be the exact *sqlite.Store the LCM was constructed with.
gotSqlite, ok := gotStore.(*sqlite.Store)
if !ok {
t.Fatalf("SM.store is %T, want *sqlite.Store", gotStore)
}
if gotSqlite != lcStack.Store {
t.Fatalf("SM.store is a different *sqlite.Store than lcStack.Store")
}
// Lifecycle should be the exact *lifecycle.Manager pointer from startLifecycle.
gotLCMPtr, ok := gotLCM.(*lifecycle.Manager)
if !ok {
t.Fatalf("SM.lcm is %T, want *lifecycle.Manager", gotLCM)
}
if gotLCMPtr != lcStack.LCM {
t.Fatalf("SM.lcm pointer (%p) differs from lcStack.LCM (%p)", gotLCMPtr, lcStack.LCM)
}
}
// inspectSessionDeps reads session.Manager's unexported store and lcm fields.
// The brief forbids modifying session/manager.go to expose them; we settle for
// reflect + unsafe scoped to this one test helper. If the field names change
// upstream, the type assertion (and this helper) is the only place to touch.
func inspectSessionDeps(t *testing.T, sm *session.Manager) (store any, lcm any) {
t.Helper()
v := reflect.ValueOf(sm).Elem()
storeField := v.FieldByName("store")
lcmField := v.FieldByName("lcm")
if !storeField.IsValid() || !lcmField.IsValid() {
t.Fatalf("session.Manager fields renamed: store.IsValid=%v lcm.IsValid=%v — update inspectSessionDeps", storeField.IsValid(), lcmField.IsValid())
}
storeVal := reflect.NewAt(storeField.Type(), unsafe.Pointer(storeField.UnsafeAddr())).Elem()
lcmVal := reflect.NewAt(lcmField.Type(), unsafe.Pointer(lcmField.UnsafeAddr())).Elem()
return storeVal.Interface(), lcmVal.Interface()
}

View File

@ -0,0 +1,63 @@
package domain
import "time"
// ActivityState is how busy the agent is, derived from its output/JSONL.
type ActivityState string
// Activity states. WaitingInput and Blocked are sticky (see IsSticky).
const (
ActivityActive ActivityState = "active"
ActivityIdle ActivityState = "idle"
ActivityWaitingInput ActivityState = "waiting_input"
ActivityBlocked ActivityState = "blocked"
ActivityExited ActivityState = "exited"
)
// IsSticky reports whether an activity state must NOT be aged/demoted by the
// passage of time (a paused agent is still paused until a new signal says so).
func (a ActivityState) IsSticky() bool {
return a == ActivityWaitingInput || a == ActivityBlocked
}
// ActivitySource records where an activity reading came from, so a weaker
// source can't override a stronger one.
type ActivitySource string
// Activity signal sources, strongest first.
const (
SourceNative ActivitySource = "native"
SourceTerminal ActivitySource = "terminal"
SourceHook ActivitySource = "hook"
SourceRuntime ActivitySource = "runtime"
SourceNone ActivitySource = "none"
)
// CanOverride reports whether a reading from source a may replace a current
// reading from source current. Unknown sources are treated as weakest.
func (a ActivitySource) CanOverride(current ActivitySource) bool {
return activitySourceRank(a) <= activitySourceRank(current)
}
func activitySourceRank(s ActivitySource) int {
switch s {
case SourceNative:
return 0
case SourceTerminal:
return 1
case SourceHook:
return 2
case SourceRuntime:
return 3
default:
return 4
}
}
// ActivitySubstate is the persisted activity reading: the state, when it was
// last observed, and which source reported it.
type ActivitySubstate struct {
State ActivityState `json:"state"`
LastActivityAt time.Time `json:"lastActivityAt"`
Source ActivitySource `json:"source"`
}

View File

@ -1,158 +0,0 @@
// Package decide is the pure DECIDE core: total, deterministic, zero I/O. It
// collapses observed liveness facts (plus the prior detecting memory) into one
// LifecycleDecision. Every function here is side-effect free so the whole
// liveness truth-table can be tested in isolation.
//
// PR-driven behaviour is NOT here: PR display status is derived by
// domain.DeriveStatus from the pr table, and PR-driven nudges are the reaction
// engine's job. decide is only about liveness + the anti-flap quarantine.
package decide
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Anti-flap tuning. detecting escalates to stuck only after this many
// consecutive unchanged-evidence ticks OR once this much wallclock has elapsed
// since first entering detecting.
const (
DetectingMaxAttempts = 3
DetectingMaxDuration = 5 * time.Minute
)
// ResolveProbeDecision reconciles runtime/process liveness into a decision.
//
// The ordering encodes the load-bearing invariants:
// - an explicit kill short-circuits straight to terminal (the only inferred
// terminal this decider may reach without quarantine);
// - a *failed* probe (timeout/error) is never read as death — it routes to
// detecting, as does any disagreement between the two probes;
// - only runtime-down + process-dead + no-recent-activity reaches terminal.
func ResolveProbeDecision(in ProbeInput) LifecycleDecision {
if in.KillRequested {
reason := in.KillReason
if reason == "" {
reason = domain.TermManuallyKilled
}
return LifecycleDecision{
Evidence: "manual kill requested",
SessionState: domain.SessionTerminated,
TerminationReason: reason,
IsAlive: false,
}
}
if in.RuntimeFailed || in.ProcessFailed {
ev := fmt.Sprintf("probe_failed runtimeFailed=%t process=%s processFailed=%t", in.RuntimeFailed, in.Process, in.ProcessFailed)
return detecting(in, ev)
}
if in.RuntimeAlive {
if in.Process == ProcessDead {
// Runtime up but the agent process is gone: probes disagree.
ev := fmt.Sprintf("disagree runtime=alive process=%s recentActivity=%t", in.Process, in.RecentActivity)
return detecting(in, ev)
}
return LifecycleDecision{
Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process),
SessionState: domain.SessionWorking,
IsAlive: true,
}
}
// Runtime is gone. Death is only concluded when the process is *also*
// confirmed dead AND nothing has been heard from the agent recently; any
// other shape is ambiguous and quarantines.
if in.Process == ProcessAlive || in.RecentActivity {
ev := fmt.Sprintf("disagree runtime=down process=%s recentActivity=%t", in.Process, in.RecentActivity)
return detecting(in, ev)
}
if in.Process == ProcessDead {
return LifecycleDecision{
Evidence: "dead runtime=down process=dead recentActivity=false",
SessionState: domain.SessionTerminated,
TerminationReason: domain.TermRuntimeLost,
IsAlive: false,
}
}
// Process indeterminate: cannot confirm death, so quarantine.
ev := fmt.Sprintf("runtime_lost runtime=down process=%s recentActivity=false", in.Process)
return detecting(in, ev)
}
// CreateDetectingDecision advances or escalates the anti-flap quarantine.
//
// The attempt counter climbs only while the (timestamp-stripped) evidence hash
// is unchanged and resets the moment the evidence moves; StartedAt is preserved
// across the whole detecting episode so the duration cap is a real wall-clock
// safety net even when the evidence keeps flapping. Escalation to stuck fires at
// DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration
// elapsed since first entering detecting. Detecting/stuck leave IsAlive true:
// the probe was ambiguous, so the session is not confirmed dead.
func CreateDetectingDecision(in DetectingInput) LifecycleDecision {
hash := HashEvidence(in.Evidence)
attempts := 1
startedAt := in.Now
if in.Prior != nil {
startedAt = in.Prior.StartedAt
if in.Prior.EvidenceHash == hash {
attempts = in.Prior.Attempts + 1
}
}
escalate := attempts >= DetectingMaxAttempts || !in.Now.Before(startedAt.Add(DetectingMaxDuration))
if escalate {
return LifecycleDecision{
Evidence: in.Evidence,
SessionState: domain.SessionStuck,
IsAlive: true,
}
}
return LifecycleDecision{
Evidence: in.Evidence,
Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash},
SessionState: domain.SessionDetecting,
IsAlive: true,
}
}
// HashEvidence normalises an evidence string (stripping timestamps and
// collapsing whitespace) and hashes it, so unchanged-but-restamped signals
// compare equal and the detecting counter is not reset by clock movement alone.
func HashEvidence(evidence string) string {
s := evidence
for _, re := range timestampPatterns {
s = re.ReplaceAllString(s, "")
}
s = strings.Join(strings.Fields(s), " ")
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// timestampPatterns is the list of regexes HashEvidence applies (in order) to
// delete the time-varying parts of an evidence string before hashing.
var timestampPatterns = []*regexp.Regexp{
regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`),
regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`),
regexp.MustCompile(`\b\d{10,13}\b`),
}
// detecting packages a probe verdict into the shared anti-flap path, so every
// probe-driven ambiguity is counted and escalated by the identical quarantine
// logic instead of each probe branch re-implementing the counter.
func detecting(in ProbeInput, evidence string) LifecycleDecision {
return CreateDetectingDecision(DetectingInput{
Evidence: evidence,
Prior: in.Prior,
Now: in.Now,
})
}

View File

@ -1,164 +0,0 @@
package decide
import (
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
var t0 = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
func TestResolveProbeDecision(t *testing.T) {
tests := []struct {
name string
in ProbeInput
wantState domain.SessionState
wantReason domain.TerminationReason
wantAlive bool
wantDetect bool // expect a detecting verdict (first attempt -> SessionDetecting)
}{
{
name: "kill requested -> terminated with reason",
in: ProbeInput{KillRequested: true, KillReason: domain.TermManuallyKilled, Now: t0},
wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false,
},
{
name: "kill requested without reason defaults to manually_killed",
in: ProbeInput{KillRequested: true, Now: t0},
wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false,
},
{
name: "runtime probe failed -> detecting (not death)",
in: ProbeInput{RuntimeFailed: true, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
{
name: "process probe failed -> detecting",
in: ProbeInput{RuntimeAlive: true, ProcessFailed: true, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
{
name: "runtime alive + process alive -> working",
in: ProbeInput{RuntimeAlive: true, Process: ProcessAlive, Now: t0},
wantState: domain.SessionWorking, wantAlive: true,
},
{
name: "runtime alive + process indeterminate -> working",
in: ProbeInput{RuntimeAlive: true, Process: ProcessIndeterminate, Now: t0},
wantState: domain.SessionWorking, wantAlive: true,
},
{
name: "runtime alive + process dead -> detecting (disagree)",
in: ProbeInput{RuntimeAlive: true, Process: ProcessDead, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
{
name: "runtime down + process dead + no activity -> terminated runtime_lost",
in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: false, Now: t0},
wantState: domain.SessionTerminated, wantReason: domain.TermRuntimeLost, wantAlive: false,
},
{
name: "runtime down + process alive -> detecting (disagree)",
in: ProbeInput{RuntimeAlive: false, Process: ProcessAlive, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
{
name: "runtime down + process dead + recent activity -> detecting",
in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: true, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
{
name: "runtime down + process indeterminate -> detecting",
in: ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0},
wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := ResolveProbeDecision(tt.in)
if d.SessionState != tt.wantState {
t.Errorf("state = %q, want %q", d.SessionState, tt.wantState)
}
if d.TerminationReason != tt.wantReason {
t.Errorf("reason = %q, want %q", d.TerminationReason, tt.wantReason)
}
if d.IsAlive != tt.wantAlive {
t.Errorf("isAlive = %v, want %v", d.IsAlive, tt.wantAlive)
}
if tt.wantDetect && d.Detecting == nil {
t.Errorf("expected detecting memory, got nil")
}
})
}
}
func TestCreateDetectingDecision(t *testing.T) {
t.Run("first entry sets attempts 1", func(t *testing.T) {
d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Now: t0})
if d.SessionState != domain.SessionDetecting || d.Detecting == nil || d.Detecting.Attempts != 1 {
t.Fatalf("got %+v", d)
}
})
t.Run("same evidence climbs the counter", func(t *testing.T) {
prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")}
d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)})
if d.Detecting == nil || d.Detecting.Attempts != 2 {
t.Fatalf("attempts = %+v, want 2", d.Detecting)
}
})
t.Run("changed evidence resets the counter", func(t *testing.T) {
prior := &domain.DetectingState{Attempts: 2, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")}
d := CreateDetectingDecision(DetectingInput{Evidence: "process dead", Prior: prior, Now: t0.Add(time.Second)})
if d.Detecting == nil || d.Detecting.Attempts != 1 {
t.Fatalf("attempts = %+v, want 1 (evidence changed)", d.Detecting)
}
})
t.Run("escalates to stuck at the attempt cap", func(t *testing.T) {
prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")}
d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)})
if d.SessionState != domain.SessionStuck {
t.Fatalf("state = %q, want stuck", d.SessionState)
}
})
t.Run("escalates to stuck past the duration cap", func(t *testing.T) {
prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")}
d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Second)})
if d.SessionState != domain.SessionStuck {
t.Fatalf("state = %q, want stuck (duration cap)", d.SessionState)
}
})
}
func TestProbeDetectingEscalationFlow(t *testing.T) {
in := ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0}
var prior *domain.DetectingState
for i := 1; i < DetectingMaxAttempts; i++ {
in.Prior = prior
in.Now = t0.Add(time.Duration(i) * time.Second)
d := ResolveProbeDecision(in)
if d.SessionState != domain.SessionDetecting {
t.Fatalf("attempt %d: state = %q, want detecting", i, d.SessionState)
}
prior = d.Detecting
}
in.Prior = prior
in.Now = t0.Add(time.Hour)
if d := ResolveProbeDecision(in); d.SessionState != domain.SessionStuck {
t.Fatalf("final attempt: state = %q, want stuck", d.SessionState)
}
}
func TestHashEvidence(t *testing.T) {
// timestamp-only differences hash equal; a real change differs.
a := HashEvidence("runtime down at 2026-05-31T12:00:00Z")
b := HashEvidence("runtime down at 2026-05-31T13:30:45Z")
if a != b {
t.Errorf("restamped evidence should hash equal")
}
c := HashEvidence("process dead at 2026-05-31T12:00:00Z")
if a == c {
t.Errorf("different evidence should hash differently")
}
}

View File

@ -1,58 +0,0 @@
package decide
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// LifecycleDecision is the output of a decider: the canonical session sub-state
// to persist (state, the liveness bool, and — only for a terminal state — the
// termination reason), the human-readable evidence, and the (possibly updated)
// detecting memory. The display status is NOT here — it is derived on read by
// domain.DeriveStatus from the persisted lifecycle plus the pr table.
//
// PR facts are likewise not here: a liveness verdict knows nothing about the PR,
// and PR-driven display/reactions are handled off the pr table, not the session
// state machine.
type LifecycleDecision struct {
Evidence string
Detecting *domain.DetectingState
SessionState domain.SessionState
TerminationReason domain.TerminationReason // set only when SessionState is terminated
IsAlive bool
}
// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout or
// error) is distinct from a "dead" verdict and must route to detecting, never to
// a death conclusion. KillRequested short-circuits to terminal with KillReason.
type ProbeInput struct {
RuntimeAlive bool // the runtime probe reports the backing runtime is up
RuntimeFailed bool // the runtime probe itself failed (timeout/error) — not "dead"
Process ProcessLiveness
ProcessFailed bool
RecentActivity bool
KillRequested bool
KillReason domain.TerminationReason // the terminal reason when KillRequested
Prior *domain.DetectingState
Now time.Time
}
// ProcessLiveness mirrors isProcessRunning's three-valued answer.
type ProcessLiveness string
// Process liveness readings.
const (
ProcessAlive ProcessLiveness = "alive"
ProcessDead ProcessLiveness = "dead"
ProcessIndeterminate ProcessLiveness = "indeterminate"
)
// DetectingInput feeds the anti-flap quarantine counter. Evidence is hashed with
// timestamps stripped, so "same ambiguous signal" keeps the counter climbing
// while any real change resets it.
type DetectingInput struct {
Evidence string
Prior *domain.DetectingState
Now time.Time
}

View File

@ -0,0 +1,5 @@
// Package domain holds shared vocabulary for sessions, activity, and PR facts.
// Session state is deliberately small: durable session rows carry activity_state
// plus an is_terminated bit; user-facing status is derived from those fields and
// PR facts at read time.
package domain

View File

@ -0,0 +1,12 @@
package domain
// AgentHarness identifies which agent CLI/runtime a session drives.
type AgentHarness string
// Supported agent harnesses.
const (
HarnessClaudeCode AgentHarness = "claude-code"
HarnessCodex AgentHarness = "codex"
HarnessAider AgentHarness = "aider"
HarnessOpenCode AgentHarness = "opencode"
)

View File

@ -1,209 +0,0 @@
// Package domain holds the shared contract types for the LCM + Session Manager
// lane: the canonical session state model, the derived display status, and the
// session read-model. It has no behaviour beyond pure derivation (status.go)
// and imports nothing outside the standard library, so every other package can
// depend on it without creating cycles.
package domain
import "time"
// LifecycleVersion is the schema version stamped onto every persisted record.
// Greenfield: we start at 1 and carry no migration/synthesis code.
const LifecycleVersion = 1
// CanonicalSessionLifecycle is the ONLY lifecycle state persisted for a session.
// The display status is derived from it (plus the session's PR facts, which live
// in the separate pr table) on read — see DeriveStatus — and is never stored, so
// canonical truth and display cannot drift.
//
// PR facts are deliberately NOT here: a session can own several PRs over its
// life, and PR state is owned by the pr table. The runtime axis is collapsed to
// a single IsAlive boolean. Activity and Detecting are decider *inputs* that
// must survive between observations, so they live in the persisted record.
type CanonicalSessionLifecycle struct {
// Version is the Go-only schema-shape constant for this record. It is not
// persisted and is not part of the CDC payload.
Version int
Session SessionSubstate `json:"session"`
Activity ActivitySubstate `json:"activity"`
// TerminationReason is set only when Session.State is terminated; '' otherwise.
TerminationReason TerminationReason `json:"terminationReason,omitempty"`
// IsAlive is the single liveness fact: is the runtime/process backing this
// session still up? It replaces the old runtime (state, reason) axis — the
// nuance the probe decider needs (failed-probe != dead, anti-flap) lives in
// the decide core's inputs, not in a persisted enum.
IsAlive bool `json:"isAlive"`
// Harness is the agent harness the session runs (claude-code, codex, ...).
Harness AgentHarness `json:"harness,omitempty"`
// Detecting is the anti-flap quarantine memory. It is non-nil only while
// the session is in the detecting state; it carries the attempt counter,
// the first-entry time, and a hash of the (timestamp-stripped) evidence so
// the decider can tell "same ambiguous signal N times" from "signal moved".
Detecting *DetectingState `json:"detecting,omitempty"`
}
// ---- agent harness ----
// AgentHarness identifies which agent CLI/runtime a session drives.
type AgentHarness string
// Supported agent harnesses.
const (
HarnessClaudeCode AgentHarness = "claude-code"
HarnessCodex AgentHarness = "codex"
HarnessAider AgentHarness = "aider"
HarnessOpenCode AgentHarness = "opencode"
)
// ---- session sub-state ----
// SessionState is the canonical lifecycle phase of a session.
type SessionState string
// The canonical session states (see the package doc for the transition model).
const (
SessionNotStarted SessionState = "not_started"
SessionWorking SessionState = "working"
SessionIdle SessionState = "idle"
SessionNeedsInput SessionState = "needs_input"
SessionStuck SessionState = "stuck"
SessionDetecting SessionState = "detecting"
SessionDone SessionState = "done"
SessionTerminated SessionState = "terminated"
)
// TerminationReason is the typed "why" for a terminated session — the only
// state that carries a reason. Empty for every non-terminal state. It decides
// the terminal display status (killed / cleanup / errored). The PR-pipeline
// "why" (fixing CI, awaiting review, …) is NOT here; it is derived on read from
// the pr table, not persisted on the session.
type TerminationReason string
// Termination reasons; TermNone is the non-terminal zero value.
const (
TermNone TerminationReason = ""
TermManuallyKilled TerminationReason = "manually_killed"
TermRuntimeLost TerminationReason = "runtime_lost"
TermAgentProcessExited TerminationReason = "agent_process_exited"
TermProbeFailure TerminationReason = "probe_failure"
TermErrorInProcess TerminationReason = "error_in_process"
TermAutoCleanup TerminationReason = "auto_cleanup"
TermPRMerged TerminationReason = "pr_merged"
)
// SessionSubstate wraps the session phase in a struct so the persisted/CDC JSON
// shape can gain fields without a migration.
type SessionSubstate struct {
State SessionState `json:"state"`
}
// ---- PR facts (NOT persisted on the session; sourced from the pr table) ----
// PRFacts is the per-session PR snapshot the status/reaction derivation reads
// from the pr table. It is the decider input that replaces the old persisted PR
// axis. The zero value (Exists=false) means "no PR", which derivation treats as
// "session has no PR".
type PRFacts struct {
URL string
Number int
Exists bool
Draft bool
Merged bool
Closed bool
CI CIState
Review ReviewDecision
Mergeability Mergeability
ReviewComments bool // has unresolved review comments (any author) to address
}
// CIState is the aggregate CI status of a PR.
type CIState string
// CI states.
const (
CIUnknown CIState = "unknown"
CIPending CIState = "pending"
CIPassing CIState = "passing"
CIFailing CIState = "failing"
)
// ReviewDecision is the aggregate human-review verdict on a PR.
type ReviewDecision string
// Review decisions.
const (
ReviewNone ReviewDecision = "none"
ReviewApproved ReviewDecision = "approved"
ReviewChangesRequest ReviewDecision = "changes_requested"
ReviewRequired ReviewDecision = "review_required"
)
// Mergeability is whether a PR can currently be merged.
type Mergeability string
// Mergeability states.
const (
MergeUnknown Mergeability = "unknown"
MergeMergeable Mergeability = "mergeable"
MergeConflicting Mergeability = "conflicting"
MergeBlocked Mergeability = "blocked"
MergeUnstable Mergeability = "unstable"
)
// ---- activity sub-state (decider input) ----
// ActivityState is how busy the agent is, derived from its output/JSONL.
type ActivityState string
// Activity states. WaitingInput and Blocked are sticky (see IsSticky).
const (
ActivityActive ActivityState = "active"
ActivityReady ActivityState = "ready"
ActivityIdle ActivityState = "idle"
ActivityWaitingInput ActivityState = "waiting_input" // sticky: does not decay by wallclock
ActivityBlocked ActivityState = "blocked" // sticky: does not decay by wallclock
ActivityExited ActivityState = "exited"
)
// IsSticky reports whether an activity state must NOT be aged/demoted by the
// passage of time (a paused agent is still paused until a new signal says so).
func (a ActivityState) IsSticky() bool {
return a == ActivityWaitingInput || a == ActivityBlocked
}
// ActivitySource records where an activity reading came from, so a weaker
// source can't override a stronger one.
type ActivitySource string
// Activity signal sources, strongest first.
const (
SourceNative ActivitySource = "native"
SourceTerminal ActivitySource = "terminal"
SourceHook ActivitySource = "hook"
SourceRuntime ActivitySource = "runtime"
SourceNone ActivitySource = "none"
)
// ActivitySubstate is the persisted activity reading: the state, when it was
// last observed, and which source reported it.
type ActivitySubstate struct {
State ActivityState `json:"state"`
LastActivityAt time.Time `json:"lastActivityAt"`
Source ActivitySource `json:"source"`
}
// ---- detecting quarantine memory (decider input) ----
// DetectingState is the anti-flap quarantine memory carried while a session is
// detecting: how many ambiguous observations, since when, and a hash of the
// (timestamp-stripped) evidence to tell "same signal again" from "signal moved".
type DetectingState struct {
Attempts int `json:"attempts"`
StartedAt time.Time `json:"startedAt"`
EvidenceHash string `json:"evidenceHash"`
}

View File

@ -1,44 +0,0 @@
package domain
import (
"encoding/json"
"time"
)
// NotificationID is the stable public identifier for a persisted notification.
type NotificationID string
// Notification is the provider-neutral durable notification read model. It is
// sink-agnostic: desktop, dashboard, Slack, webhooks, etc. all consume the same
// semantic payload and actions later.
type Notification struct {
Seq int64
ID NotificationID
ProjectID ProjectID
SessionID SessionID
Source string
EventType string
SemanticType string
Priority string
Message string
Payload json.RawMessage
Actions []NotificationAction
DedupeKey string
CauseKey string
ReadAt time.Time
ArchivedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NotificationAction is a provider-neutral action descriptor. Renderers may use
// Route for app-local navigation, URL for external navigation, or Method for a
// future command/action endpoint.
type NotificationAction struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Route string `json:"route,omitempty"`
URL string `json:"url,omitempty"`
Method string `json:"method,omitempty"`
}

View File

@ -2,17 +2,28 @@ package domain
import "time"
// The PR rows are the canonical shapes for the pr / pr_checks / pr_comment
// tables, shared by the PRWriter port and the sqlite store (the store maps them
// to/from the sqlc gen.* models). They are flat by design — these tables carry
// no nesting or derivation, so a single definition serves every layer.
// ---- PR read model ----
// PRRow is the scalar facts of one tracked pull request (the pr table). A session
// can own several PRs; a PR belongs to one session. PRFacts is the read-model
// derived from these for display status; PRRow is what gets written.
type PRRow struct {
// PRFacts is the per-session PR snapshot the status derivation reads from the
// pr table.
type PRFacts struct {
URL string
Number int
Draft bool
Merged bool
Closed bool
CI CIState
Review ReviewDecision
Mergeability Mergeability
ReviewComments bool // has unresolved review comments (any author) to address
}
// PullRequest is the app-level representation of one tracked pull request as
// persisted by the PR store. It is intentionally separate from the sqlc
// generated sqlite row type so storage details do not leak outside sqlite.
type PullRequest struct {
URL string
SessionID string
SessionID SessionID
Number int
Draft bool
Merged bool
@ -23,20 +34,18 @@ type PRRow struct {
UpdatedAt time.Time
}
// PRCheckRow is one CI check run — one row per check name per commit.
type PRCheckRow struct {
PRURL string
// PullRequestCheck is one normalized CI check run for a pull request.
type PullRequestCheck struct {
Name string
CommitHash string
Status string
Status PRCheckStatus
URL string
LogTail string
CreatedAt time.Time
}
// PRComment is one review comment. Feedback is injected into the agent
// regardless of author, so there is no bot/human distinction.
type PRComment struct {
// PullRequestComment is one normalized review comment for a pull request.
type PullRequestComment struct {
ID string
Author string
File string
@ -45,3 +54,63 @@ type PRComment struct {
Resolved bool
CreatedAt time.Time
}
// CIState is the aggregate CI status of a PR.
type CIState string
// CI states.
const (
CIUnknown CIState = "unknown"
CIPending CIState = "pending"
CIPassing CIState = "passing"
CIFailing CIState = "failing"
)
// ReviewDecision is the aggregate human-review verdict on a PR.
type ReviewDecision string
// Review decisions.
const (
ReviewNone ReviewDecision = "none"
ReviewApproved ReviewDecision = "approved"
ReviewChangesRequest ReviewDecision = "changes_requested"
ReviewRequired ReviewDecision = "review_required"
)
// Mergeability is whether a PR can currently be merged.
type Mergeability string
// Mergeability states.
const (
MergeUnknown Mergeability = "unknown"
MergeMergeable Mergeability = "mergeable"
MergeConflicting Mergeability = "conflicting"
MergeBlocked Mergeability = "blocked"
MergeUnstable Mergeability = "unstable"
)
// PRState is the normalized lifecycle of one tracked pull request as stored in
// the pr table.
type PRState string
// PR states.
const (
PRStateDraft PRState = "draft"
PRStateOpen PRState = "open"
PRStateMerged PRState = "merged"
PRStateClosed PRState = "closed"
)
// PRCheckStatus is one CI check run's normalized status.
type PRCheckStatus string
// PR check statuses.
const (
PRCheckUnknown PRCheckStatus = "unknown"
PRCheckQueued PRCheckStatus = "queued"
PRCheckInProgress PRCheckStatus = "in_progress"
PRCheckPassed PRCheckStatus = "passed"
PRCheckFailed PRCheckStatus = "failed"
PRCheckSkipped PRCheckStatus = "skipped"
PRCheckCancelled PRCheckStatus = "cancelled"
)

View File

@ -22,50 +22,34 @@ const (
KindOrchestrator SessionKind = "orchestrator"
)
// SessionMetadata is the typed, off-canonical metadata for a session: the
// operational handles and seed inputs the Session Manager and reaper need but
// that are NOT part of the canonical lifecycle. The set of fields is fixed here
// (no free-form keys), so what a session can carry is a compile-time fact, and
// it is folded into the sessions row off the CDC path.
//
// Empty fields mean "unset": the LCM merges metadata without overwriting a
// stored value with an empty one, so a partial write (spawn setting only the
// runtime handle) does not clobber a value set earlier (the branch at creation).
// SessionMetadata is the typed, off-status metadata for a session: operational
// handles and seed inputs used by Session Manager and reaper.
type SessionMetadata struct {
Branch string `json:"branch,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
RuntimeHandleID string `json:"runtimeHandleId,omitempty"`
RuntimeName string `json:"runtimeName,omitempty"`
AgentSessionID string `json:"agentSessionId,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
// IsZero reports whether no metadata field is set.
func (m SessionMetadata) IsZero() bool { return m == SessionMetadata{} }
// SessionRecord is the PERSISTENCE shape: identity, canonical lifecycle, and
// metadata — everything the store holds, and nothing derived. The store reads
// and writes records; it never produces the derived display status.
//
// Metadata is json:"-" on purpose: it lives off the canonical path, so it must
// never ride along in the change_log / snapshot payloads. Enforcing that at the
// type level means no caller has to remember to scrub it before marshalling.
// SessionRecord is the persistence shape. It intentionally stores only durable
// facts: identity, agent harness, activity_state, is_terminated, and operational
// metadata. The user-facing Status is derived from these facts plus PR facts.
type SessionRecord struct {
ID SessionID `json:"id"`
ProjectID ProjectID `json:"projectId"`
IssueID IssueID `json:"issueId,omitempty"`
Kind SessionKind `json:"kind"`
Lifecycle CanonicalSessionLifecycle `json:"lifecycle"`
Metadata SessionMetadata `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID SessionID `json:"id"`
ProjectID ProjectID `json:"projectId"`
IssueID IssueID `json:"issueId,omitempty"`
Kind SessionKind `json:"kind"`
Harness AgentHarness `json:"harness,omitempty"`
Activity ActivitySubstate `json:"activity"`
IsTerminated bool `json:"isTerminated"`
Metadata SessionMetadata `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Session is the read-model returned across the API boundary (to controllers,
// then the frontend): a SessionRecord plus the DERIVED display Status. The
// Session Manager is the single producer of Status — it builds a Session from a
// stored SessionRecord by calling DeriveLegacyStatus, so the store and API
// never recompute (or accidentally persist) it.
// Session is the read-model returned across the API boundary: a SessionRecord
// plus the derived display Status.
type Session struct {
SessionRecord
Status SessionStatus `json:"status"`

View File

@ -1,15 +1,12 @@
package domain
// SessionStatus is the single-word DISPLAY status the dashboard renders. It is
// derived from the canonical lifecycle (plus the session's PR facts) on read and
// never persisted.
// derived from persisted session facts plus PR facts and is never stored.
type SessionStatus string
// The display statuses the dashboard renders.
const (
StatusSpawning SessionStatus = "spawning"
StatusWorking SessionStatus = "working"
StatusDetecting SessionStatus = "detecting"
StatusPROpen SessionStatus = "pr_open"
StatusDraft SessionStatus = "draft"
StatusCIFailed SessionStatus = "ci_failed"
@ -18,78 +15,45 @@ const (
StatusApproved SessionStatus = "approved"
StatusMergeable SessionStatus = "mergeable"
StatusMerged SessionStatus = "merged"
StatusCleanup SessionStatus = "cleanup"
StatusNeedsInput SessionStatus = "needs_input"
StatusStuck SessionStatus = "stuck"
StatusErrored SessionStatus = "errored"
StatusKilled SessionStatus = "killed"
StatusIdle SessionStatus = "idle"
StatusDone SessionStatus = "done"
StatusTerminated SessionStatus = "terminated"
)
// DeriveStatus is the ONLY producer of the display status. It is a pure, total
// function of the canonical record plus the session's PR facts (read from the pr
// table by the caller, since PR state is no longer persisted on the session).
//
// Order matters:
// 1. Terminal / hard session states (done, terminated, needs_input, stuck,
// detecting, not_started) map directly — these OUTRANK PR facts.
// 2. Otherwise, if the session has a PR: a merged PR wins, else the PR pipeline
// ladder (CI failure dominates, then draft/review/merge states).
// 3. Otherwise fall through to the SOFT session state (idle/working).
//
// So "PR facts dominate session facts" applies only to the soft states: an idle
// or working session with an open, CI-failing PR displays as ci_failed — but a
// session that is stuck or needs_input shows that regardless, since it needs a
// human either way.
func DeriveStatus(l CanonicalSessionLifecycle, pr PRFacts) SessionStatus {
switch l.Session.State {
case SessionDone:
return StatusDone
case SessionTerminated:
return terminatedStatus(l.TerminationReason)
case SessionNeedsInput:
return StatusNeedsInput
case SessionStuck:
return StatusStuck
case SessionDetecting:
return StatusDetecting
case SessionNotStarted:
return StatusSpawning
// DeriveStatus is the ONLY producer of display status. It is a pure function of
// persisted session facts and PR facts: is_terminated, activity_state, and the PR
// table are the durable facts that tell the UI what it needs to know.
func DeriveStatus(rec SessionRecord, pr *PRFacts) SessionStatus {
if rec.IsTerminated {
if pr != nil && pr.Merged {
return StatusMerged
}
return StatusTerminated
}
if pr.Exists {
switch rec.Activity.State {
case ActivityWaitingInput:
return StatusNeedsInput
case ActivityBlocked:
return StatusStuck
}
if pr != nil {
if pr.Merged {
return StatusMerged
}
if !pr.Closed {
return prPipelineStatus(pr)
return prPipelineStatus(*pr)
}
}
if l.Session.State == SessionIdle {
return StatusIdle
if rec.Activity.State == ActivityActive {
return StatusWorking
}
return StatusWorking
return StatusIdle
}
func terminatedStatus(r TerminationReason) SessionStatus {
switch r {
case TermManuallyKilled, TermRuntimeLost, TermAgentProcessExited:
return StatusKilled
case TermAutoCleanup, TermPRMerged:
return StatusCleanup
case TermErrorInProcess, TermProbeFailure:
return StatusErrored
default:
return StatusTerminated
}
}
// prPipelineStatus maps an open/draft PR's facts to a display status, preserving
// the old ladder: CI failure dominates everything, then draft, then the review /
// merge states.
func prPipelineStatus(pr PRFacts) SessionStatus {
switch {
case pr.CI == CIFailing:

View File

@ -2,58 +2,37 @@ package domain
import "testing"
func TestDeriveStatus(t *testing.T) {
// sess builds a non-terminal lifecycle (no reason).
sess := func(s SessionState) CanonicalSessionLifecycle {
return CanonicalSessionLifecycle{Session: SessionSubstate{State: s}}
}
// term builds a terminated lifecycle carrying a TerminationReason.
term := func(r TerminationReason) CanonicalSessionLifecycle {
return CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated}, TerminationReason: r}
}
openPR := func(mut func(*PRFacts)) PRFacts {
f := PRFacts{Exists: true, CI: CIUnknown, Review: ReviewNone, Mergeability: MergeUnknown}
if mut != nil {
mut(&f)
}
return f
}
func rec(activity ActivityState, terminated bool) SessionRecord {
return SessionRecord{Activity: ActivitySubstate{State: activity}, IsTerminated: terminated}
}
func pr(facts PRFacts) *PRFacts { return &facts }
func TestDeriveStatusFromSessionFactsAndPR(t *testing.T) {
tests := []struct {
name string
in CanonicalSessionLifecycle
pr PRFacts
rec SessionRecord
pr *PRFacts
want SessionStatus
}{
{"not_started maps to spawning", sess(SessionNotStarted), PRFacts{}, StatusSpawning},
{"terminated+manually_killed -> killed", term(TermManuallyKilled), PRFacts{}, StatusKilled},
{"terminated+runtime_lost -> killed", term(TermRuntimeLost), PRFacts{}, StatusKilled},
{"terminated+auto_cleanup -> cleanup", term(TermAutoCleanup), PRFacts{}, StatusCleanup},
{"terminated+pr_merged -> cleanup", term(TermPRMerged), PRFacts{}, StatusCleanup},
{"terminated+error -> errored", term(TermErrorInProcess), PRFacts{}, StatusErrored},
{"needs_input maps directly", sess(SessionNeedsInput), PRFacts{}, StatusNeedsInput},
{"stuck dominates any PR", sess(SessionStuck), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusStuck},
{"no PR + idle -> idle", sess(SessionIdle), PRFacts{}, StatusIdle},
{"no PR + working -> working", sess(SessionWorking), PRFacts{}, StatusWorking},
{"merged PR dominates idle session", sess(SessionIdle), PRFacts{Exists: true, Merged: true}, StatusMerged},
{"open PR failing CI -> ci_failed", sess(SessionIdle), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusCIFailed},
{"draft PR failing CI -> ci_failed (CI dominates)", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.CI = CIFailing }), StatusCIFailed},
{"draft PR ignores review state -> draft", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.Review = ReviewApproved }), StatusDraft},
{"open PR changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewChangesRequest }), StatusChangesRequested},
{"open PR review comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.ReviewComments = true }), StatusChangesRequested},
{"open PR mergeable", sess(SessionWorking), openPR(func(f *PRFacts) { f.Mergeability = MergeMergeable }), StatusMergeable},
{"open PR approved", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewApproved }), StatusApproved},
{"open PR review required -> review_pending", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewRequired }), StatusReviewPending},
{"open PR no signal -> pr_open", sess(SessionWorking), openPR(nil), StatusPROpen},
{"closed PR falls through to soft state", sess(SessionIdle), PRFacts{Exists: true, Closed: true}, StatusIdle},
{"terminated", rec(ActivityExited, true), nil, StatusTerminated},
{"merged-pr", rec(ActivityIdle, true), pr(PRFacts{Merged: true}), StatusMerged},
{"needs-input", rec(ActivityWaitingInput, false), pr(PRFacts{CI: CIFailing}), StatusNeedsInput},
{"blocked", rec(ActivityBlocked, false), pr(PRFacts{CI: CIFailing}), StatusStuck},
{"ci-failed", rec(ActivityIdle, false), pr(PRFacts{CI: CIFailing}), StatusCIFailed},
{"draft", rec(ActivityIdle, false), pr(PRFacts{Draft: true}), StatusDraft},
{"changes-requested", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewChangesRequest}), StatusChangesRequested},
{"mergeable", rec(ActivityIdle, false), pr(PRFacts{Mergeability: MergeMergeable}), StatusMergeable},
{"approved", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewApproved}), StatusApproved},
{"review-pending", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewRequired}), StatusReviewPending},
{"pr-open", rec(ActivityIdle, false), pr(PRFacts{}), StatusPROpen},
{"working", rec(ActivityActive, false), nil, StatusWorking},
{"idle", rec(ActivityIdle, false), nil, StatusIdle},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DeriveStatus(tt.in, tt.pr); got != tt.want {
t.Errorf("DeriveStatus() = %q, want %q", got, tt.want)
if got := DeriveStatus(tt.rec, tt.pr); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}

View File

@ -1,23 +1,13 @@
package domain
// TrackerProvider identifies an issue-tracker provider implementation.
// Provider differences (label-driven vs state-machine vs close-reason) are
// absorbed inside each adapter; the rest of the system only sees
// NormalizedIssueState.
type TrackerProvider string
// Supported tracker providers.
const (
TrackerProviderGitHub TrackerProvider = "github"
TrackerProviderGitLab TrackerProvider = "gitlab"
TrackerProviderLinear TrackerProvider = "linear"
)
// TrackerProviderGitHub is the only supported issue-tracker provider.
const TrackerProviderGitHub TrackerProvider = "github"
// TrackerID identifies a single issue across providers. Native is the
// provider's own canonical form ("owner/repo#123" for GitHub,
// "group/project#456" for GitLab, "ABC-789" for Linear) and is parsed by the
// adapter. Provider is the discriminator the Session Manager uses to pick an
// adapter.
// TrackerID identifies one issue. Native is the provider's own canonical form
// ("owner/repo#123" for GitHub) and is parsed by the adapter.
type TrackerID struct {
Provider TrackerProvider `json:"provider"`
Native string `json:"native"`
@ -37,9 +27,8 @@ const (
IssueCancelled NormalizedIssueState = "cancelled"
)
// Issue is the minimum projection every tracker can produce. Fields are
// added only when all v1 providers (GitHub, GitLab, Linear) can populate
// them faithfully; richer metadata stays inside provider-specific code paths.
// Issue is the minimum projection every tracker can produce. Provider-specific
// metadata stays inside provider-specific code paths.
type Issue struct {
ID TrackerID `json:"id"`
Title string `json:"title"`
@ -50,11 +39,9 @@ type Issue struct {
Assignees []string `json:"assignees,omitempty"`
}
// TrackerRepo identifies a repository (or its provider-equivalent) for
// cross-issue queries like Tracker.List. Native is the provider's canonical
// owner/project form: "owner/repo" for GitHub, "group/project" for GitLab.
// Linear has no native repo concept; adapters may use a team or workspace
// identifier in Native when this port reaches Linear.
// TrackerRepo identifies a repository for cross-issue queries like Tracker.List.
// Native is the provider's canonical owner/project form, e.g. "owner/repo" for
// GitHub.
type TrackerRepo struct {
Provider TrackerProvider `json:"provider"`
Native string `json:"native"`
@ -77,12 +64,8 @@ const (
// ListFilter is the query the Session Manager passes to Tracker.List.
// Empty / zero values mean "no filter on this dimension".
//
// Limit is the requested page size. The adapter applies its own default
// when zero and SILENTLY CAPS at the provider's per-page maximum — a
// caller asking for more than the cap gets exactly cap items back with no
// error and no indication of truncation. v1 has no auto-pagination;
// callers needing more results need to wait for the observer/polling work
// in issue #35.
// Limit is the requested page size. The adapter applies its own default when
// zero and caps at the provider's per-page maximum.
type ListFilter struct {
State ListStateFilter `json:"state,omitempty"`
Labels []string `json:"labels,omitempty"`

View File

@ -9,27 +9,20 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
// APIDeps bundles every Manager the API layer's controllers depend on. There
// is exactly one Manager per resource, defined in that resource's own package
// (project.Manager, later session.Manager, ...), and the controllers see ONLY
// that interface — they don't reach past it to the LCM, adapters, or stores.
// Whether a Manager impl talks to the registry, the LCM, or an outbound port
// is its own concern.
//
// The route-shell PR (#20) leaves every field nil — handlers answer via
// apispec.NotImplemented and don't dereference them yet. The handler-impl PR
// wires real Managers and flips stubs to real logic one route at a time.
// APIDeps bundles every Manager the API layer's controllers depend on.
// Controllers see only resource-level interfaces; they do not reach through to
// lifecycle reducers, adapters, or storage. A nil dependency keeps its routes
// registered but returns the OpenAPI-backed 501 response.
type APIDeps struct {
Projects project.Manager
}
// API owns one controller per resource and is the single Register call the
// router invokes to mount the /api/v1 surface. Splitting per-resource means
// later PRs can land a controller's real handlers without touching the
// surrounding wiring.
// router invokes to mount the /api/v1 surface.
type API struct {
cfg config.Config
projects *controllers.ProjectsController
@ -47,13 +40,8 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
}
}
// Register mounts the API surface on root. /api/v1 hosts the REST group with
// the per-request Timeout that the skeleton router (router.go) deliberately
// kept off the global stack — REST routes are bounded, but long-lived surfaces
// (/events SSE, /mux WS) live outside this group when they land.
//
// /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is
// a phase-4 placeholder and stays unregistered here until that lane starts.
// Register mounts the bounded /api/v1 REST surface. Long-lived surfaces such
// as muxed terminal streams stay outside this timeout group.
func (a *API) Register(root chi.Router) {
timeout := a.cfg.RequestTimeout
if timeout <= 0 {
@ -61,20 +49,15 @@ func (a *API) Register(root chi.Router) {
}
root.Route("/api/v1", func(r chi.Router) {
// The OpenAPI document is the source of truth for every contract on
// this surface; serve it so tooling (SDK generators, the OpenAPI
// validator in #19, the dashboard's developer tools) can fetch the
// whole spec from the same origin as the routes it describes.
apispec.RegisterServe(r, "/openapi.yaml")
// Serve the OpenAPI document from the same origin as the routes it describes.
r.Get("/openapi.yaml", apispec.ServeYAML)
r.Group(func(r chi.Router) {
r.Use(middleware.Timeout(timeout))
a.projects.Register(r)
// Sibling controllers (sessions, issues, prs, ...) plug in here in
// follow-up PRs #21 / #22 without touching the timeout group.
// Sibling REST controllers plug in here.
})
// Surfaces that intentionally bypass the REST timeout (SSE, future WS)
// register at this level — none exist in the route-shell PR.
// Surfaces that intentionally bypass the REST timeout register at this level.
})
}
@ -82,7 +65,7 @@ func (a *API) Register(root chi.Router) {
// 404 is a text/plain body; the API surface must answer JSON so consumers can
// parse it uniformly.
func notFoundJSON(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND",
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND",
r.Method+" "+r.URL.Path+" has no handler", nil)
}
@ -90,6 +73,6 @@ func notFoundJSON(w http.ResponseWriter, r *http.Request) {
// known path without a matching verb (e.g. PUT /projects/{id} after we drop
// the legacy PUT alias).
func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
envelope.WriteAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
r.Method+" not allowed on "+r.URL.Path, nil)
}

View File

@ -16,7 +16,6 @@ import (
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
yaml "gopkg.in/yaml.v3"
)
@ -65,10 +64,6 @@ func New(yamlBytes []byte) (*Spec, error) {
return &Spec{doc: doc}, nil
}
// YAML returns the raw embedded document bytes. Used by the /openapi.yaml
// handler.
func (s *Spec) YAML() []byte { return openapiYAML }
// Operation returns the spec slice for a single (method, path) pair, ready
// to be JSON-serialised. The slice is the OpenAPI Operation object (the
// inner block under e.g. paths./projects.get), with parent path-level
@ -119,9 +114,7 @@ type notImplementedResponse struct {
}
// NotImplemented writes the locked 501 envelope, embedding the OpenAPI
// Operation slice that documents what this route WILL do. Replaces the
// throwaway PlannedRoute literals that the first cut of the route shell
// duplicated in controller code.
// Operation slice for the capability that is currently unavailable.
func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) {
op := Default().Operation(method, path)
if op == nil {
@ -130,7 +123,7 @@ func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string)
body := notImplementedResponse{
Error: "not_implemented",
Code: "NOT_IMPLEMENTED",
Message: method + " " + path + " is registered but not yet implemented",
Message: method + " " + path + " is unavailable in this daemon",
RequestID: middleware.GetReqID(r.Context()),
Spec: op,
}
@ -140,18 +133,9 @@ func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string)
_ = json.NewEncoder(w).Encode(body)
}
// ServeYAML serves the embedded openapi.yaml document. Mounted at
// /api/v1/openapi.yaml so spec-consuming tooling (#19's validator,
// SDK generators, the dashboard's developer tools) can fetch the
// whole document in one request.
// ServeYAML serves the embedded OpenAPI document for SDK generators, tests, and
// developer tooling.
func ServeYAML(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
_, _ = w.Write(openapiYAML)
}
// RegisterServe mounts ServeYAML on the supplied router. Kept as a
// helper so the router code only references one symbol from apispec
// for the static serve path.
func RegisterServe(r chi.Router, path string) {
r.Get(path, ServeYAML)
}

View File

@ -1,13 +1,12 @@
openapi: 3.1.0
info:
title: Agent Orchestrator HTTP daemon
version: 0.1.0-route-shell
version: 0.1.0
description: |
Loopback-only HTTP surface served by the Go daemon. This spec is the
source of truth for every route's contract — the 501 stubs in the
route-shell phase return the matching Operation slice as a `spec`
field, so consumers discover the contract by calling the endpoint
they care about. Real handlers in later PRs satisfy this same spec.
Loopback-only HTTP surface served by the Go daemon. This document describes
the registered /api/v1 project routes and the shared error envelope used by
OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz,
/readyz, /shutdown, and /mux are intentionally outside this REST spec.
servers:
- url: http://127.0.0.1:3001
@ -135,12 +134,6 @@ paths:
schema: { $ref: "#/components/schemas/APIError" }
example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" }
"501": { $ref: "#/components/responses/NotImplemented" }
x-rest-audit-notes: |
R5: degraded projects return 200 with a `status` discriminator
instead of 200 with an `error` field (as the legacy TS surface did).
Archived projects are hidden from list responses but still resolve by
id so historical sessions can keep their project_id reference.
patch:
operationId: updateProjectConfig
tags: [projects]
@ -180,13 +173,6 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" }
x-rest-audit-notes: |
R3: legacy `PUT /projects/{id}` (a TS alias of PATCH) is NOT
registered. PUT returns 405 Method Not Allowed.
R6: when config persistence lands this route returns { project }, not
{ ok: true }. Until then, config patches return 501 instead of
pretending to persist fields the current project store cannot store.
delete:
operationId: removeProject
tags: [projects]
@ -221,10 +207,6 @@ paths:
summary: Repair a degraded project where automatic recovery is available
x-replaces:
- "POST /api/v1/projects/{id}"
x-rest-audit-notes: |
R4: this canonical path replaces the overloaded
`POST /api/v1/projects/{id}` from the legacy TS surface.
The legacy path is NOT registered; consumers must use /repair.
responses:
"200":
description: Project repaired
@ -323,12 +305,8 @@ components:
description: "\"owner/name\" or empty string when unset"
defaultBranch: { type: string, default: main }
agent: { type: string }
runtime: { type: string }
tracker: { $ref: "#/components/schemas/TrackerConfig" }
scm: { $ref: "#/components/schemas/SCMConfig" }
reactions:
type: object
additionalProperties: { $ref: "#/components/schemas/ReactionConfig" }
DegradedProject:
type: object
@ -373,12 +351,8 @@ components:
persistence exists.
properties:
agent: { type: string }
runtime: { type: string }
tracker: { $ref: "#/components/schemas/TrackerConfig" }
scm: { $ref: "#/components/schemas/SCMConfig" }
reactions:
type: object
additionalProperties: { $ref: "#/components/schemas/ReactionConfig" }
RemoveProjectResult:
type: object
@ -395,9 +369,7 @@ components:
projectCount: { type: integer }
degradedCount: { type: integer }
# ---- Behaviour config blobs (ported from the TS Zod schemas) ----
# These are the known config shapes only. The current Go handler does not
# preserve unknown passthrough keys until config persistence is implemented.
# ---- Behaviour config blobs ----
TrackerConfig:
type: object
@ -424,23 +396,3 @@ components:
eventHeader: { type: string }
deliveryHeader: { type: string }
maxBodyBytes: { type: integer }
ReactionConfig:
type: object
properties:
auto: { type: boolean }
action:
type: string
enum: [send-to-agent, notify, auto-merge]
message: { type: string }
priority:
type: string
enum: [urgent, action, warning, info]
retries: { type: integer }
escalateAfter:
oneOf:
- { type: number }
- { type: string }
description: Either ms (number) or duration string ("30m").
threshold: { type: string }
includeSummary: { type: boolean }

View File

@ -1,16 +1,7 @@
// Package controllers holds the HTTP-facing controllers for the /api/v1
// surface. Each controller groups one resource's routes, exposes a Register
// method that wires them on a chi.Router, and depends on exactly one
// *Manager interface from ports/inbound.go — never on a store, the LCM, an
// adapter, or any other port. Whether the Manager impl reaches past that
// boundary is its own concern.
//
// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented
// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and
// the 501 body returns that document's slice for the route so consumers can
// discover the contract from the endpoint itself. When real handlers land,
// the stub one-liner is replaced with the impl; no per-route planned
// metadata in code ever has to be deleted.
// method, and depends on exactly one resource-level Manager interface — never
// directly on stores, lifecycle reducers, or adapters.
package controllers
import (
@ -28,10 +19,8 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
// ProjectsController owns the 7 canonical /projects routes. The controller
// depends ONLY on project.Manager — it doesn't know whether the impl reaches
// into the registry, the LCM, an adapter, or all three. Mgr is nil while
// handlers are stubs; the handler-impl PR supplies a real project.Manager.
// ProjectsController owns the /projects routes. The controller depends only on
// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s.
type ProjectsController struct {
Mgr project.Manager
}
@ -39,12 +28,6 @@ type ProjectsController struct {
// Register mounts the project routes on the supplied router. Route order
// matters: /projects/reload must register before /projects/{id} for the POST
// verb, otherwise chi would treat "reload" as an {id} match for repair.
//
// Legacy paths that the REST audit dropped are deliberately NOT registered
// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id})
// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as
// `x-replaces` on the canonical operation so consumers discover the
// migration without leaving the spec.
func (c *ProjectsController) Register(r chi.Router) {
r.Get("/projects", c.list)
r.Post("/projects", c.add)

View File

@ -136,7 +136,7 @@ func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) {
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude","runtime":"tmux"}`)
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude"}`)
assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED")
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`)
@ -229,7 +229,6 @@ type projectBody struct {
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent"`
Runtime string `json:"runtime"`
}
type errorBody struct {

View File

@ -1,22 +0,0 @@
package httpd
import (
"net/http"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
)
// APIError is the locked wire shape for every non-2xx response. It supersedes
// the legacy TS `{error: "msg"}` bag with a machine-readable Code and a
// RequestID for log correlation (sourced from chi's RequestID middleware).
//
// Details is open so collision-style errors can carry typed sub-fields
// (e.g. existingProjectId, suggestedProjectId on POST /projects 409s).
type APIError = envelope.APIError
// writeAPIError emits the locked envelope for any non-2xx response. The
// request id falls back to empty when the chi middleware hasn't tagged the
// request (e.g. in tests that bypass NewRouter).
func writeAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) {
envelope.WriteAPIError(w, r, status, kind, code, message, details)
}

View File

@ -1,14 +0,0 @@
package httpd
import (
"net/http"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
)
// writeJSON serialises v as JSON with the given status. It is the single JSON
// writer for the skeleton; the typed error envelope (open item Q1.3) will build
// on this in a later phase.
func writeJSON(w http.ResponseWriter, status int, v any) {
envelope.WriteJSON(w, status, v)
}

View File

@ -0,0 +1,10 @@
package httpd
import "log/slog"
func loggerOrDefault(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.Default()
}

View File

@ -0,0 +1,19 @@
package httpd
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
)
func TestNewRouterAllowsNilLogger(t *testing.T) {
router := NewRouter(config.Config{}, nil, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/healthz status = %d, want 200", rec.Code)
}
}

View File

@ -1,7 +1,5 @@
// Package httpd builds and runs the daemon's HTTP surface. Phase 1a is the
// skeleton: the middleware stack, liveness/readiness probes, and a graceful
// run loop. Route registration (/api/v1, /events, /mux, /) lands in later
// phases on top of the router this package builds.
// Package httpd builds and runs the daemon's HTTP surface: middleware, health
// probes, daemon control, REST APIs, and terminal WebSocket routing.
package httpd
import (
@ -15,6 +13,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
@ -28,11 +27,8 @@ import (
// requestLogger → slog-backed access log, stderr, carries the request id
// RealIP → normalise client IP (loopback proxy from the dev server)
//
// The per-request Timeout from the decision table is deliberately NOT applied
// globally: it must wrap only the /api/v1 REST surface, never the long-lived
// SSE (/events) or WebSocket (/mux) surfaces, nor the always-must-answer health
// probes. It is therefore applied per-surface when those subrouters are mounted
// in Phase 1b; cfg.RequestTimeout carries the value through to that point.
// The per-request timeout is deliberately not global: it wraps only bounded
// REST routes, never long-lived terminal streams or health probes.
func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router {
return NewRouterWithAPI(cfg, log, termMgr, APIDeps{})
}
@ -43,9 +39,8 @@ type ControlDeps struct {
RequestShutdown func()
}
// NewRouterWithAPI is the dependency-injected variant. main.go calls it with
// real Managers when they exist; tests/dev wiring inject mocks explicitly.
// Missing Managers intentionally keep the route-shell 501 behavior.
// NewRouterWithAPI is the dependency-injected variant. Missing Managers keep
// routes registered but return OpenAPI-backed 501 responses.
func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) chi.Router {
return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{})
}
@ -53,6 +48,7 @@ func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Man
// NewRouterWithControl is NewRouterWithAPI plus daemon-control hooks: it mounts
// the same API surface and additionally wires the ControlDeps callbacks.
func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router {
log = loggerOrDefault(log)
r := chi.NewRouter()
r.Use(middleware.Recoverer)
@ -67,7 +63,7 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal
r.MethodNotAllowed(methodNotAllowedJSON)
mountHealth(r)
mountMux(r, termMgr, log)
mountTerminalMux(r, termMgr, log)
mountControl(r, control)
NewAPI(cfg, deps).Register(r)
@ -91,13 +87,13 @@ func mountControl(r chi.Router, deps ControlDeps) {
}
r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) {
if !localControlRequest(req) {
writeJSON(w, http.StatusForbidden, map[string]any{
envelope.WriteJSON(w, http.StatusForbidden, map[string]any{
"status": "forbidden",
"service": daemonmeta.ServiceName,
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
envelope.WriteJSON(w, http.StatusAccepted, map[string]any{
"status": "shutting_down",
"service": daemonmeta.ServiceName,
"pid": os.Getpid(),
@ -132,18 +128,17 @@ func localControlRequest(r *http.Request) bool {
// handleHealthz is the liveness probe: it answers 200 as long as the process is
// up and serving. It does no dependency checks by design.
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
envelope.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": daemonmeta.ServiceName,
"pid": os.Getpid(),
})
}
// handleReadyz is the readiness probe. In the 1a skeleton the daemon is ready
// as soon as it is listening; later phases will gate this on dependency
// initialisation (e.g. store/event-bus warm-up).
// handleReadyz is the readiness probe. Dependency initialization happens before
// the server is constructed, so a listening daemon is ready to answer requests.
func handleReadyz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
envelope.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ready",
"service": daemonmeta.ServiceName,
"pid": os.Getpid(),

View File

@ -34,6 +34,12 @@ type Server struct {
// the returned Server's lifecycle via Run. termMgr may be nil, in which case
// the /mux terminal surface is not mounted.
func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Server, error) {
return NewWithDeps(cfg, log, termMgr, APIDeps{})
}
// NewWithDeps constructs a Server with API dependencies supplied by the daemon.
func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) (*Server, error) {
log = loggerOrDefault(log)
ln, err := net.Listen("tcp", cfg.Addr())
if err != nil {
return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err)
@ -46,7 +52,7 @@ func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Serve
shutdownRequested: make(chan struct{}),
}
srv.http = &http.Server{
Handler: NewRouterWithControl(cfg, log, termMgr, APIDeps{}, ControlDeps{
Handler: NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{
RequestShutdown: srv.requestShutdown,
}),
// ReadHeaderTimeout guards against slow-loris even on loopback;
@ -75,7 +81,7 @@ func (s *Server) Run(ctx context.Context) error {
return fmt.Errorf("write run-file: %w", err)
}
defer func() {
if err := runfile.Remove(s.cfg.RunFilePath); err != nil {
if err := runfile.RemoveIfOwned(s.cfg.RunFilePath, info.PID); err != nil {
s.log.Warn("failed to remove run-file", "path", s.cfg.RunFilePath, "err", err)
}
}()

View File

@ -12,48 +12,50 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
// muxReadLimit caps a single inbound frame. Client→server frames are small
// terminalMuxReadLimit caps a single inbound frame. Client→server frames are small
// (keystrokes, resize, control), so a generous 1 MiB is ample headroom while
// still bounding memory per message.
const muxReadLimit = 1 << 20
const terminalMuxReadLimit = 1 << 20
// mountMux registers the long-lived terminal-multiplexing WebSocket at /mux. It
// mountTerminalMux registers the long-lived terminal-multiplexing WebSocket at /mux. It
// is intentionally outside the per-request Timeout middleware (the connection is
// long-lived). When mgr is nil the route is not mounted — the daemon simply has
// no terminal surface yet.
func mountMux(r chi.Router, mgr *terminal.Manager, log *slog.Logger) {
func mountTerminalMux(r chi.Router, mgr *terminal.Manager, log *slog.Logger) {
if mgr == nil {
return
}
r.Get("/mux", muxHandler(mgr, log))
r.Get("/mux", terminalMuxHandler(mgr, log))
}
// muxHandler upgrades the request to a WebSocket and hands the connection to the
// terminalMuxHandler upgrades the request to a WebSocket and hands the connection to the
// terminal manager. httpd owns only the upgrade and the transport adaptation;
// all stream logic lives in internal/terminal.
func muxHandler(mgr *terminal.Manager, log *slog.Logger) http.HandlerFunc {
func terminalMuxHandler(mgr *terminal.Manager, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// InsecureSkipVerify disables coder/websocket's same-origin check: the
// daemon binds loopback only and the desktop renderer's origin differs
// from the loopback host, mirroring the legacy Node mux server.
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
log.Warn("mux: websocket upgrade failed", "err", err)
log.Warn("terminal mux: websocket upgrade failed", "err", err)
return
}
c.SetReadLimit(muxReadLimit)
mgr.Serve(r.Context(), &coderConn{c: c})
c.SetReadLimit(terminalMuxReadLimit)
mgr.Serve(r.Context(), &terminalMuxConn{c: c})
}
}
// coderConn adapts a coder/websocket connection to terminal.wsConn. JSON framing
// terminalMuxConn adapts a coder/websocket connection to terminal.wsConn. JSON framing
// uses wsjson (text messages); Ping is a control frame; Close sends a normal
// closure.
type coderConn struct{ c *websocket.Conn }
type terminalMuxConn struct{ c *websocket.Conn }
func (a *coderConn) ReadJSON(ctx context.Context, v any) error { return wsjson.Read(ctx, a.c, v) }
func (a *coderConn) WriteJSON(ctx context.Context, v any) error { return wsjson.Write(ctx, a.c, v) }
func (a *coderConn) Ping(ctx context.Context) error { return a.c.Ping(ctx) }
func (a *coderConn) Close(reason string) error {
func (a *terminalMuxConn) ReadJSON(ctx context.Context, v any) error { return wsjson.Read(ctx, a.c, v) }
func (a *terminalMuxConn) WriteJSON(ctx context.Context, v any) error {
return wsjson.Write(ctx, a.c, v)
}
func (a *terminalMuxConn) Ping(ctx context.Context) error { return a.c.Ping(ctx) }
func (a *terminalMuxConn) Close(reason string) error {
return a.c.Close(websocket.StatusNormalClosure, reason)
}

View File

@ -17,9 +17,9 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
// stubSource attaches a throwaway shell command instead of a real tmux pane, so
// stubSource attaches a throwaway shell command instead of a real Zellij pane, so
// the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow
// without needing tmux. IsAlive=false means the pane is treated as gone once the
// without needing Zellij. IsAlive=false means the pane is treated as gone once the
// command exits (no re-attach).
type stubSource struct{ argv []string }
@ -28,7 +28,7 @@ func (stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) {
return false, nil
}
type muxFrame struct {
type terminalMuxFrame struct {
Ch string `json:"ch"`
ID string `json:"id"`
Type string `json:"type"`
@ -52,12 +52,12 @@ func dialMux(t *testing.T, mgr *terminal.Manager) (*websocket.Conn, func()) {
}
}
func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) muxFrame {
func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) terminalMuxFrame {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
for {
var f muxFrame
var f terminalMuxFrame
if err := wsjson.Read(ctx, c, &f); err != nil {
t.Fatalf("waiting for %s/%s: %v", ch, typ, err)
}
@ -81,7 +81,7 @@ func TestMuxUpgradeStreamsTerminal(t *testing.T) {
defer done()
ctx := context.Background()
if err := wsjson.Write(ctx, c, muxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil {
if err := wsjson.Write(ctx, c, terminalMuxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil {
t.Fatalf("write open: %v", err)
}

View File

@ -1,731 +1,163 @@
// Package integration exercises the lifecycle + session lane against the real
// SQLite store and the real CDC trigger pipeline. Unit tests stay on the
// in-memory fakes in lifecycle/ and session/; these live-fire tests prove the
// wiring across packages actually flows: SM -> store row -> LCM mutate -> store
// update -> DB trigger -> change_log read.
package integration
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"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/ports"
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
"github.com/aoagents/agent-orchestrator/backend/internal/session"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// ---- plugin fakes (minimal: only enough to drive SM through real LCM) ----
type stubRuntime struct{ created, destroyed int }
type stubRuntime struct {
id, name string
}
func (s *stubRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
return ports.RuntimeHandle{ID: s.id, RuntimeName: s.name}, nil
}
func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil }
func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) {
return true, nil
func (s *stubRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) {
s.created++
return ports.RuntimeHandle{ID: "h1"}, nil
}
func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { s.destroyed++; return nil }
func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil }
type stubAgent struct{}
func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" }
func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { return map[string]string{} }
func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id }
type stubWorkspace struct {
root string
func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" }
func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string {
return map[string]string{"X": "1"}
}
func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id }
func (w *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
return ports.WorkspaceInfo{
Path: filepath.Join(w.root, string(cfg.SessionID)),
Branch: cfg.Branch,
SessionID: cfg.SessionID,
ProjectID: cfg.ProjectID,
}, nil
}
func (w *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { return nil }
func (w *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
return w.Create(ctx, cfg)
}
type stubWorkspace struct{ destroyed int }
type captureMessenger struct {
mu sync.Mutex
msgs []string
func (s *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
}
func (m *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.msgs = append(m.msgs, msg)
func (s *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
s.destroyed++
return nil
}
func (m *captureMessenger) drain() []string {
m.mu.Lock()
defer m.mu.Unlock()
out := append([]string(nil), m.msgs...)
m.msgs = nil
return out
func (s *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
return s.Create(ctx, cfg)
}
type captureNotifier struct {
mu sync.Mutex
events []ports.Event
}
type captureMessenger struct{ msgs []string }
func (n *captureNotifier) Notify(_ context.Context, e ports.Event) error {
n.mu.Lock()
defer n.mu.Unlock()
n.events = append(n.events, e)
func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
c.msgs = append(c.msgs, msg)
return nil
}
func (n *captureNotifier) drain() []ports.Event {
n.mu.Lock()
defer n.mu.Unlock()
out := append([]ports.Event(nil), n.events...)
n.events = nil
return out
type stack struct {
store *sqlite.Store
sm *session.Manager
lcm *lifecycle.Manager
prm *prsvc.Manager
rt *stubRuntime
ws *stubWorkspace
msg *captureMessenger
}
// ---- harness: real store + real LCM + real SM + change_log poller ----
type liveStack struct {
dataDir string
store *sqlite.Store
lcm *lifecycle.Manager
sm *session.Manager
notifier *captureNotifier
messenger *captureMessenger
closed bool // guard so the explicit close() and t.Cleanup don't double-close
}
// openLiveStack opens the store + hydrates the LCM/SM and registers an
// idempotent t.Cleanup so a mid-test t.Fatalf can't leak the SQLite handle.
// Tests that need to simulate a daemon restart still call close() explicitly
// between phases; the cleanup hook becomes a no-op once that runs.
func openLiveStack(t *testing.T, dataDir string) *liveStack {
func newStack(t *testing.T) *stack {
t.Helper()
store, err := sqlite.Open(dataDir)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
notifier := &captureNotifier{}
messenger := &captureMessenger{}
lcm := lifecycle.New(store, store, notifier, messenger)
wsRoot := t.TempDir()
sm := session.New(session.Deps{
Runtime: &stubRuntime{id: "h1", name: "tmux"},
Agent: stubAgent{},
Workspace: &stubWorkspace{root: wsRoot},
Store: store,
Messenger: messenger,
Lifecycle: lcm,
})
st := &liveStack{
dataDir: dataDir,
store: store,
lcm: lcm,
sm: sm,
notifier: notifier,
messenger: messenger,
}
t.Cleanup(func() {
if st.closed {
return
}
// Best-effort: failures here would be noise after t.Fatalf already
// recorded the real cause.
_ = st.store.Close()
st.closed = true
})
return st
}
func (s *liveStack) close(t *testing.T) {
t.Helper()
if s.closed {
return
}
s.closed = true
if err := s.store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
}
func seedProject(t *testing.T, store *sqlite.Store, id string) {
t.Helper()
if err := store.UpsertProject(context.Background(), sqlite.ProjectRow{
ID: id, Path: "/repo/" + id, RegisteredAt: time.Now(),
}); err != nil {
t.Fatalf("upsert project: %v", err)
}
}
func durableLifecycle(store *sqlite.Store, messenger ports.AgentMessenger) *lifecycle.Manager {
renderer := notification.NewRenderer(store)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
notifier := notification.NewEnqueuer(store, renderer, logger)
return lifecycle.New(store, store, notifier, messenger)
}
func durableRecord(project, issue, branch string) domain.SessionRecord {
now := time.Now().UTC().Truncate(time.Second)
return domain.SessionRecord{
ProjectID: domain.ProjectID(project),
IssueID: domain.IssueID(issue),
Kind: domain.KindWorker,
Lifecycle: domain.CanonicalSessionLifecycle{
Version: domain.LifecycleVersion,
Session: domain.SessionSubstate{State: domain.SessionWorking},
IsAlive: true,
Activity: domain.ActivitySubstate{
State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceHook,
},
},
Metadata: domain.SessionMetadata{Branch: branch, WorkspacePath: "/workspace/" + branch},
CreatedAt: now,
UpdatedAt: now,
}
}
// ---- tests ----
// TestHappyPath drives Spawn -> SCM PR observation (open + CI passing) -> Kill,
// asserting via direct store reads that the canonical row, the PR row, and the
// change_log stream all reflect what each step contributed.
func TestHappyPath_Spawn_PR_Kill(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := openLiveStack(t, t.TempDir())
defer st.close(t)
seedProject(t, st.store, "mer")
// 1. Spawn — SM inserts the session row, LCM marks it live. We only assert
// the structural invariant of the id (project-scoped, non-empty), not the
// literal counter — that's a store-internal detail.
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{
ProjectID: "mer", Kind: domain.KindWorker, Prompt: "ship it",
})
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("spawn: %v", err)
t.Fatal(err)
}
if sess.ID == "" || !strings.HasPrefix(string(sess.ID), "mer-") {
t.Fatalf("expected project-scoped id like mer-N, got %q", sess.ID)
t.Cleanup(func() { _ = store.Close() })
if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
t.Fatal(err)
}
msg := &captureMessenger{}
lcm := lifecycle.New(store, msg)
prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm})
rt := &stubRuntime{}
ws := &stubWorkspace{}
sm := session.New(session.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
}
rec, ok, err := st.store.GetSession(ctx, sess.ID)
if err != nil || !ok {
t.Fatalf("get session: ok=%v err=%v", ok, err)
func TestSpawnPRKillRoundTrip(t *testing.T) {
ctx := context.Background()
st := newStack(t)
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
if !rec.Lifecycle.IsAlive {
t.Fatal("post-spawn: is_alive should be true")
if sess.ID != "mer-1" || sess.Status != domain.StatusIdle {
t.Fatalf("spawn got %+v", sess)
}
if rec.Lifecycle.Session.State != domain.SessionNotStarted {
t.Fatalf("post-spawn state want not_started, got %q", rec.Lifecycle.Session.State)
rec, ok, _ := st.store.GetSession(ctx, sess.ID)
if !ok || rec.Metadata.RuntimeHandleID != "h1" || rec.IsTerminated {
t.Fatalf("post-spawn row wrong: %+v", rec)
}
if rec.Metadata.RuntimeHandleID != "h1" || rec.Metadata.RuntimeName != "tmux" {
t.Fatalf("post-spawn handles missing: %+v", rec.Metadata)
if err := st.prm.ApplyObservation(ctx, sess.ID, ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, CI: domain.CIFailing, Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}}); err != nil {
t.Fatal(err)
}
if rec.Metadata.WorkspacePath == "" || rec.Metadata.Prompt != "ship it" {
t.Fatalf("post-spawn metadata missing: %+v", rec.Metadata)
got, err := st.sm.Get(ctx, sess.ID)
if err != nil {
t.Fatal(err)
}
// 2. SCM observes a fresh PR — open, CI passing. LCM writes the pr row
// atomically (one tx, triggers fire pr_created).
prURL := "https://github.com/repo/mer/pull/1"
if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{
Fetched: true, URL: prURL, Number: 1,
CI: domain.CIPassing, Review: domain.ReviewNone, Mergeability: domain.MergeMergeable,
Checks: []domain.PRCheckRow{{
Name: "ci/build", CommitHash: "abc123", Status: "passed", CreatedAt: time.Now(),
}},
}); err != nil {
t.Fatalf("apply pr: %v", err)
if got.Status != domain.StatusCIFailed {
t.Fatalf("want ci_failed, got %q", got.Status)
}
prRow, ok, err := st.store.GetPR(ctx, prURL)
if err != nil || !ok {
t.Fatalf("get pr: ok=%v err=%v", ok, err)
}
if prRow.SessionID != string(sess.ID) || prRow.CI != domain.CIPassing || prRow.Draft || prRow.Merged || prRow.Closed {
t.Fatalf("pr row wrong: %+v", prRow)
}
// 3. Kill — SM routes to LCM and tears down runtime+workspace.
freed, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled)
freed, err := st.sm.Kill(ctx, sess.ID)
if err != nil || !freed {
t.Fatalf("kill freed=%v err=%v", freed, err)
}
rec, _, _ = st.store.GetSession(ctx, sess.ID)
if rec.Lifecycle.Session.State != domain.SessionTerminated ||
rec.Lifecycle.TerminationReason != domain.TermManuallyKilled ||
rec.Lifecycle.IsAlive {
t.Fatalf("post-kill canonical wrong: %+v", rec.Lifecycle)
}
// 4. Assert the change_log captured the full timeline. The DB triggers
// write the only durable CDC; we don't want to assume an ordering of
// interleaved events, just that each expected event_type shows up.
rows, err := st.store.ReadChangeLogAfter(ctx, 0, 100)
if err != nil {
t.Fatalf("read change_log: %v", err)
}
seen := map[string]bool{}
for _, r := range rows {
seen[r.EventType] = true
}
for _, want := range []string{"session_created", "session_updated", "pr_created", "pr_check_recorded"} {
if !seen[want] {
t.Fatalf("missing change_log event %q (got: %v)", want, seen)
}
if !rec.IsTerminated {
t.Fatalf("post-kill row should be terminated: %+v", rec)
}
}
// TestRestoreRoundTrip simulates a daemon restart: spawn a session, persist the
// kill, fully close the in-process LCM/SM, open a fresh stack against the SAME
// DB file, and Restore. The restored session must keep its metadata (the agent
// session id is the must-survive bit).
func TestRestoreRoundTrip_PreservesMetadata(t *testing.T) {
t.Parallel()
func TestRestoreRoundTripPreservesMetadata(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
st := openLiveStack(t, dir)
seedProject(t, st.store, "mer")
// Phase A: spawn with an agent session id, then kill so the row is terminal
// and Restore is legal.
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{
ProjectID: "mer", Kind: domain.KindWorker, Prompt: "remember me",
})
st := newStack(t)
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"})
if err != nil {
t.Fatalf("spawn: %v", err)
t.Fatal(err)
}
// fold an AgentSessionID into the row — the LCM does this through the spawn
// outcome on Restore too, but a fresh spawn doesn't (the agent has not
// reported one yet). We patch via the store so the restore branch has
// something to resume from. Check ok/err: without it, a missed row would
// hand UpdateSession a zero-value record (ID==""), which matches no rows
// and returns nil — Phase B would then fail with a misleading "agent id
// lost across restart" rather than the real cause.
rec, ok, err := st.store.GetSession(ctx, sess.ID)
if err != nil || !ok {
t.Fatalf("get session for patch: ok=%v err=%v", ok, err)
}
rec.Metadata.AgentSessionID = "agent-xyz"
rec, _, _ := st.store.GetSession(ctx, sess.ID)
rec.Metadata.AgentSessionID = "agent-x"
if err := st.store.UpdateSession(ctx, rec); err != nil {
t.Fatalf("patch agent id: %v", err)
t.Fatal(err)
}
if _, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled); err != nil {
t.Fatalf("kill: %v", err)
if _, err := st.sm.Kill(ctx, sess.ID); err != nil {
t.Fatal(err)
}
st.close(t)
// Phase B: reopen against the same data dir; everything in memory is gone.
st2 := openLiveStack(t, dir)
defer st2.close(t)
// Confirm the row survived the restart.
rec2, ok, err := st2.store.GetSession(ctx, sess.ID)
if err != nil || !ok {
t.Fatalf("reopen get: ok=%v err=%v", ok, err)
}
if rec2.Metadata.AgentSessionID != "agent-xyz" {
t.Fatalf("agent session id lost across restart: %+v", rec2.Metadata)
}
if rec2.Lifecycle.Session.State != domain.SessionTerminated {
t.Fatalf("expected terminal after reopen, got %q", rec2.Lifecycle.Session.State)
}
// Phase C: Restore — must drive a fresh OnSpawnCompleted and surface the
// preserved AgentSessionID into the new outcome.
restored, err := st2.sm.Restore(ctx, sess.ID)
restored, err := st.sm.Restore(ctx, sess.ID)
if err != nil {
t.Fatalf("restore: %v", err)
t.Fatal(err)
}
if !restored.Lifecycle.IsAlive {
t.Fatal("restored session should be is_alive after spawn-completed")
}
if restored.Metadata.AgentSessionID != "agent-xyz" {
t.Fatalf("restored row dropped AgentSessionID: %+v", restored.Metadata)
if restored.IsTerminated || restored.Metadata.AgentSessionID != "agent-x" {
t.Fatalf("restored wrong: %+v", restored)
}
}
// TestCIFailureAndRecovery drives the CI-failed reaction path: a failing
// observation injects a nudge into the agent (messenger), a recovery
// observation (CI passing) flips state without re-firing the nudge, and the
// pr_checks history records both runs so the brake's "last 3 all failed" query
// reads the truth.
func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) {
t.Parallel()
func TestCDCPollerReceivesSessionAndPREvents(t *testing.T) {
ctx := context.Background()
st := openLiveStack(t, t.TempDir())
defer st.close(t)
seedProject(t, st.store, "mer")
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."})
st := newStack(t)
b := cdc.NewBroadcaster()
var got []cdc.Event
b.Subscribe(func(e cdc.Event) { got = append(got, e) })
poller := cdc.NewPoller(st.store, b, cdc.PollerConfig{})
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if err != nil {
t.Fatalf("spawn: %v", err)
t.Fatal(err)
}
// Move the session out of not_started so the reaction path engages on real
// PR facts (not_started doesn't react on PRs).
if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{
Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(),
}); err != nil {
t.Fatalf("activity: %v", err)
if err := st.prm.ApplyObservation(ctx, sess.ID, ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, Review: domain.ReviewApproved}); err != nil {
t.Fatal(err)
}
_ = st.messenger.drain() // ignore startup nudges, focus on CI
prURL := "https://github.com/repo/mer/pull/2"
// Failing CI: handleCIFailure should send a CI-failed nudge with the log
// tail injected.
if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{
Fetched: true, URL: prURL, Number: 2,
CI: domain.CIFailing, Mergeability: domain.MergeUnstable,
Checks: []domain.PRCheckRow{{
Name: "ci/build", CommitHash: "c1", Status: "failed", LogTail: "panic: nil map", CreatedAt: time.Now(),
}},
}); err != nil {
t.Fatalf("apply pr (failing): %v", err)
}
got := st.messenger.drain()
if len(got) == 0 {
t.Fatal("expected CI-failed nudge to the agent")
}
if !strings.Contains(got[0], "CI is failing") || !strings.Contains(got[0], "panic: nil map") {
t.Fatalf("ci-failed message missing content: %q", got[0])
}
// Brake confirmation: only one failure so far, RecentCheckStatuses should
// reflect it.
history, err := st.store.RecentCheckStatuses(ctx, prURL, "ci/build", 3)
if err != nil {
t.Fatalf("recent checks: %v", err)
}
if len(history) != 1 || history[0] != "failed" {
t.Fatalf("ci history wrong: %v", history)
}
// Recovery: CI passing on a new commit. With the dedupe slot still on
// rxCIFailed, the dispatch path moves to rxApprovedGreen (mergeable) and
// the human notifier is the one that pages.
if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{
Fetched: true, URL: prURL, Number: 2,
CI: domain.CIPassing, Mergeability: domain.MergeMergeable,
Checks: []domain.PRCheckRow{{
Name: "ci/build", CommitHash: "c2", Status: "passed", CreatedAt: time.Now(),
}},
}); err != nil {
t.Fatalf("apply pr (recovery): %v", err)
}
ev := st.notifier.drain()
if len(ev) == 0 {
t.Fatal("recovery: notifier should have received an event (approved-and-green)")
}
if !anyEventType(ev, "reaction.approved-and-green") {
t.Fatalf("recovery should notify approved-and-green, got %+v", ev)
}
// And the pr row reflects the recovery in the canonical fact store.
prRow, ok, _ := st.store.GetPR(ctx, prURL)
if !ok || prRow.CI != domain.CIPassing {
t.Fatalf("pr ci_state should be passing post-recovery: %+v", prRow)
}
}
// TestDetectingPersistsAcrossRestart drives the runtime quarantine path: a
// failed probe puts the session into the detecting state, which means the
// decider's anti-flap memory MUST be flushed to the detecting_* columns and
// survive a restart. A subsequent alive probe must clear it.
func TestDetectingPersistsAcrossRestart(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
st := openLiveStack(t, dir)
seedProject(t, st.store, "mer")
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."})
if err != nil {
t.Fatalf("spawn: %v", err)
}
// Move to working so the runtime decider doesn't bail on not_started.
if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{
Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(),
}); err != nil {
t.Fatalf("activity: %v", err)
}
// One failed probe should park the session in detecting with attempts=1.
if err := st.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{
ObservedAt: time.Now(),
Runtime: ports.ProbeFailed,
Process: ports.ProbeFailed,
}); err != nil {
t.Fatalf("apply runtime: %v", err)
}
rec, ok, err := st.store.GetSession(ctx, sess.ID)
if err != nil || !ok {
t.Fatalf("get session post-probe: ok=%v err=%v", ok, err)
}
if rec.Lifecycle.Session.State != domain.SessionDetecting {
t.Fatalf("expected detecting state, got %q", rec.Lifecycle.Session.State)
}
if rec.Lifecycle.Detecting == nil || rec.Lifecycle.Detecting.Attempts == 0 {
t.Fatalf("detecting memory should be populated: %+v", rec.Lifecycle.Detecting)
}
// Restart: close, reopen, verify the detecting_* columns round-tripped.
st.close(t)
st2 := openLiveStack(t, dir)
defer st2.close(t)
rec2, ok, _ := st2.store.GetSession(ctx, sess.ID)
if !ok || rec2.Lifecycle.Detecting == nil {
t.Fatalf("detecting lost across restart: %+v", rec2.Lifecycle)
}
if rec2.Lifecycle.Detecting.Attempts != rec.Lifecycle.Detecting.Attempts {
t.Fatalf("attempts round-trip mismatch: pre=%d post=%d",
rec.Lifecycle.Detecting.Attempts, rec2.Lifecycle.Detecting.Attempts)
}
if rec2.Lifecycle.Detecting.EvidenceHash != rec.Lifecycle.Detecting.EvidenceHash {
t.Fatal("evidence hash dropped across restart")
}
// Recovery probe — alive — must clear detecting and flip state out of it.
if err := st2.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{
ObservedAt: time.Now(),
Runtime: ports.ProbeAlive,
Process: ports.ProbeAlive,
}); err != nil {
t.Fatalf("recovery probe: %v", err)
}
rec3, ok3, err := st2.store.GetSession(ctx, sess.ID)
if err != nil || !ok3 {
t.Fatalf("get session post-recovery: ok=%v err=%v", ok3, err)
}
if rec3.Lifecycle.Detecting != nil {
t.Fatalf("alive probe should clear detecting, got %+v", rec3.Lifecycle.Detecting)
}
if rec3.Lifecycle.Session.State == domain.SessionDetecting {
t.Fatalf("session state should leave detecting, got %q", rec3.Lifecycle.Session.State)
}
}
// TestCDCPollerReceivesAllStages drives the full real pipeline including the
// in-process CDC poller — proving the trigger writes become broadcaster events
// in the same order the storage layer observes them.
func TestCDCPollerReceivesAllStages(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := openLiveStack(t, t.TempDir())
defer st.close(t)
seedProject(t, st.store, "mer")
bcast := cdc.NewBroadcaster()
src := pollerSource{st.store}
poller := cdc.NewPoller(src, bcast, cdc.PollerConfig{Batch: 100})
var (
mu sync.Mutex
events []cdc.Event
)
bcast.Subscribe(func(e cdc.Event) {
mu.Lock()
defer mu.Unlock()
events = append(events, e)
})
sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."})
if err != nil {
t.Fatalf("spawn: %v", err)
}
if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{
Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(),
}); err != nil {
t.Fatalf("activity: %v", err)
}
if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{
Fetched: true, URL: "https://github.com/repo/mer/pull/3", Number: 3,
CI: domain.CIPassing, Mergeability: domain.MergeMergeable,
}); err != nil {
t.Fatalf("apply pr: %v", err)
}
if err := poller.Poll(ctx); err != nil {
t.Fatalf("poll: %v", err)
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
types := map[cdc.EventType]bool{}
for _, e := range events {
types[e.Type] = true
}
for _, want := range []cdc.EventType{cdc.EventSessionCreated, cdc.EventSessionUpdated, cdc.EventPRCreated} {
if !types[want] {
t.Fatalf("poller missed event %q (got %+v)", want, types)
}
}
// Seq monotonicity invariant — the wiring assumes it; assert it here.
var prev int64
for _, e := range events {
if e.Seq <= prev {
t.Fatalf("seq not monotonic: %d after %d", e.Seq, prev)
}
prev = e.Seq
if len(got) < 2 {
t.Fatalf("want CDC events, got %d", len(got))
}
}
func TestLifecycleDurableNotification_NeedsInput(t *testing.T) {
t.Parallel()
ctx := context.Background()
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer store.Close()
seedProject(t, store, "mer")
rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-1", "feat/input"))
if err != nil {
t.Fatalf("create session: %v", err)
}
lcm := durableLifecycle(store, &captureMessenger{})
startSeq, _ := store.MaxChangeLogSeq(ctx)
if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{
Valid: true, State: domain.ActivityWaitingInput, Source: domain.SourceHook, Timestamp: time.Now(),
}); err != nil {
t.Fatalf("activity: %v", err)
}
notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(notifications) != 1 || notifications[0].SemanticType != "session.needs_input" || notifications[0].DedupeKey == "" {
t.Fatalf("needs_input notification missing: %+v", notifications)
}
assertNotificationCreatedCDC(t, store, startSeq)
}
func TestLifecycleDurableNotification_ApprovedAndGreen(t *testing.T) {
t.Parallel()
ctx := context.Background()
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer store.Close()
seedProject(t, store, "mer")
rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-2", "feat/green"))
if err != nil {
t.Fatalf("create session: %v", err)
}
lcm := durableLifecycle(store, &captureMessenger{})
if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{
Fetched: true, URL: "https://github.com/org/repo/pull/2", Number: 2,
CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable,
}); err != nil {
t.Fatalf("apply pr: %v", err)
}
notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(notifications) != 1 || notifications[0].SemanticType != "merge.ready" {
t.Fatalf("approved-and-green notification missing: %+v", notifications)
}
}
func TestLifecycleDurableNotification_PRMerged(t *testing.T) {
t.Parallel()
ctx := context.Background()
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer store.Close()
seedProject(t, store, "mer")
rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-3", "feat/merge"))
if err != nil {
t.Fatalf("create session: %v", err)
}
lcm := durableLifecycle(store, &captureMessenger{})
startSeq, _ := store.MaxChangeLogSeq(ctx)
if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{
Fetched: true, URL: "https://github.com/org/repo/pull/3", Number: 3, Merged: true,
CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable,
}); err != nil {
t.Fatalf("apply pr: %v", err)
}
notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(notifications) != 1 || notifications[0].SemanticType != "pr.merged" {
t.Fatalf("pr_merged notification missing: %+v", notifications)
}
assertNotificationCreatedCDC(t, store, startSeq)
}
func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64) {
t.Helper()
evs, err := store.ReadChangeLogAfter(context.Background(), after, 20)
if err != nil {
t.Fatalf("read change_log: %v", err)
}
for _, e := range evs {
if e.EventType == string(cdc.EventNotificationCreated) {
return
}
}
t.Fatalf("missing notification_created CDC after %d: %+v", after, evs)
}
// ---- small helpers ----
type pollerSource struct{ *sqlite.Store }
func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) {
rows, err := s.ReadChangeLogAfter(ctx, after, limit)
if err != nil {
return nil, err
}
out := make([]cdc.Event, len(rows))
for i, r := range rows {
out[i] = cdc.Event{
Seq: r.Seq,
ProjectID: r.ProjectID,
SessionID: r.SessionID,
Type: cdc.EventType(r.EventType),
Payload: []byte(r.Payload),
CreatedAt: r.CreatedAt,
}
}
return out, nil
}
func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) {
return s.MaxChangeLogSeq(ctx)
}
func anyEventType(evs []ports.Event, t string) bool {
for _, e := range evs {
if e.Type == t {
return true
}
}
return false
}

View File

@ -1,112 +0,0 @@
package lifecycle
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/domain/decide"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// defaultRecentActivityWindow is how fresh the last activity must be for the
// probe decider to treat the agent as "recently active" — which keeps an
// ambiguous dead-runtime probe in detecting instead of concluding death.
const defaultRecentActivityWindow = 60 * time.Second
// probeInput maps a raw RuntimeFacts (plus the prior detecting memory and last
// activity) into the pure decider's input. A failed/unknown probe is reported as
// such, never as a death — that routes to the detecting quarantine.
func probeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput {
now := nowOr(f.ObservedAt)
var runtimeAlive, runtimeFailed bool
switch f.Runtime {
case ports.ProbeAlive:
runtimeAlive = true
case ports.ProbeFailed, ports.ProbeUnknown:
runtimeFailed = true // ambiguous: quarantine, never conclude death
}
var process decide.ProcessLiveness
var processFailed bool
switch f.Process {
case ports.ProbeAlive:
process = decide.ProcessAlive
case ports.ProbeDead:
process = decide.ProcessDead
case ports.ProbeFailed:
process, processFailed = decide.ProcessIndeterminate, true
default:
process = decide.ProcessIndeterminate
}
return decide.ProbeInput{
RuntimeAlive: runtimeAlive,
RuntimeFailed: runtimeFailed,
Process: process,
ProcessFailed: processFailed,
RecentActivity: hasRecentActivity(cur.Activity, now, window),
Prior: cur.Detecting,
Now: now,
}
}
// hasRecentActivity answers the decider's "heard from the agent recently?"
// question. Sticky states (waiting_input/blocked) count as recent (a live-but-
// paused agent); an explicit exited never counts; else age the timestamp.
func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool {
switch {
case a.State == domain.ActivityExited:
return false
case a.State.IsSticky():
return true
case a.LastActivityAt.IsZero():
return false
default:
return now.Sub(a.LastActivityAt) <= window
}
}
// activityToSession maps an activity classification onto the session state.
// exited returns ok=false: only the probe pipeline may conclude death.
func activityToSession(a domain.ActivityState) (domain.SessionState, bool) {
switch a {
case domain.ActivityActive:
return domain.SessionWorking, true
case domain.ActivityReady, domain.ActivityIdle:
return domain.SessionIdle, true
case domain.ActivityWaitingInput:
return domain.SessionNeedsInput, true
case domain.ActivityBlocked:
return domain.SessionStuck, true
default:
return "", false
}
}
// isTerminal reports a final session state — reopened only by an explicit
// Restore, never by an observation.
func isTerminal(s domain.SessionState) bool {
return s == domain.SessionDone || s == domain.SessionTerminated
}
// writeRuntimeSession reports whether a probe verdict may write the session
// state. A death-axis verdict (detecting/stuck/terminated) always writes; a
// healthy "working" verdict only recovers a detecting session — it must not
// clobber an activity-owned idle/needs_input.
func writeRuntimeSession(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool {
if isTerminal(cur.Session.State) {
return false
}
if d.SessionState == domain.SessionWorking {
return cur.Session.State == domain.SessionDetecting
}
return true
}
func nowOr(t time.Time) time.Time {
if t.IsZero() {
return time.Now()
}
return t
}

View File

@ -1,8 +1,7 @@
// Package lifecycle implements ports.LifecycleManager: the synchronous
// observe -> decide -> persist reducer. Every Apply*/On* entrypoint loads the
// session, runs the pure decider, and persists the full row under a single write
// lock. The DB triggers emit the CDC; the engine never writes the change log.
// After a transition it fires the mapped reaction (see reactions.go).
// Package lifecycle implements the synchronous reducer that writes durable
// session lifecycle facts. It deliberately keeps the session model small:
// activity_state plus an is_terminated bit are the only persisted status-like
// facts on the session row.
package lifecycle
import (
@ -12,206 +11,92 @@ import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/domain/decide"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Manager is the lifecycle engine. mu serialises the load->decide->persist
// read-modify-write across sessions; reactions dispatch after the lock releases
// so a slow agent send never blocks the write path.
type sessionStore interface {
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
UpdateSession(ctx context.Context, rec domain.SessionRecord) error
}
// Manager reduces runtime, activity, spawn, and termination observations into durable session facts.
// It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts.
type Manager struct {
store ports.SessionStore
pr ports.PRWriter
notifier ports.Notifier
store sessionStore
messenger ports.AgentMessenger
mu sync.Mutex
window time.Duration
clock func() time.Time
// in-memory ACT state (policy, not canonical truth — reset on restart).
react reactionState
react reactionState
}
var _ ports.LifecycleManager = (*Manager)(nil)
// New builds a Lifecycle Manager over its collaborators: the session store it
// is the sole writer of, the PR-facts writer, the notifier, and the messenger
// used to nudge running agents.
func New(store ports.SessionStore, pr ports.PRWriter, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager {
return &Manager{
store: store,
pr: pr,
notifier: notifier,
messenger: messenger,
window: defaultRecentActivityWindow,
clock: time.Now,
react: newReactionState(),
}
// New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges.
func New(store sessionStore, messenger ports.AgentMessenger) *Manager {
return &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: time.Now, react: newReactionState()}
}
// mutate runs the shared pipeline: load -> decideFn -> persist (only if changed).
// It returns whether a write happened. A stray observation for an unknown session
// is a clean no-op.
func (m *Manager) mutate(
ctx context.Context,
id domain.SessionID,
fn func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool),
) (bool, error) {
func (m *Manager) mutate(ctx context.Context, id domain.SessionID, fn func(domain.SessionRecord, time.Time) (domain.SessionRecord, bool)) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return false, err
}
next, changed := fn(rec.Lifecycle)
if !changed {
return false, nil
}
next.Version = domain.LifecycleVersion
rec.Lifecycle = next
rec.UpdatedAt = m.clock()
if err := m.store.UpdateSession(ctx, rec); err != nil {
return false, err
}
return true, nil
}
// ---- OBSERVE entrypoints ----
// ApplyRuntimeObservation feeds the probe decider. is_alive always tracks the
// verdict; the session state follows the runtime-write rule; a non-detecting
// verdict clears stale detecting memory.
func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error {
changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) {
d := decide.ResolveProbeDecision(probeInput(f, cur, m.window))
next := cur
ch := false
if next.IsAlive != d.IsAlive {
next.IsAlive, ch = d.IsAlive, true
}
if !isTerminal(cur.Session.State) {
if writeRuntimeSession(d, cur) {
ch = setSessionState(&next, d.SessionState, d.TerminationReason) || ch
}
ch = setDetecting(&next, d.Detecting) || ch
}
return next, ch
})
if err != nil || !changed {
return err
}
return m.runReactions(ctx, id, reactionContent{})
now := m.clock()
next, changed := fn(rec, now)
if !changed {
return nil
}
next.UpdatedAt = now
if err := m.store.UpdateSession(ctx, next); err != nil {
return err
}
return nil
}
// ApplyActivitySignal updates the activity axis. Only a valid signal is
// authoritative, and it is proof of life: it may resolve a detecting session and
// move the session out of any non-terminal state.
// ApplyRuntimeObservation only writes when runtime liveness is unambiguous. A
// failed probe or liveness disagreement is ignored; no transient lifecycle state is stored.
func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error {
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated || !runtimeClearlyDead(f, cur.Activity, now, m.window) {
return cur, false
}
next := cur
next.IsTerminated = true
next.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: timeOr(f.ObservedAt, now), Source: domain.SourceRuntime}
return next, true
})
}
// ApplyActivitySignal records an authoritative agent activity signal.
func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error {
if !s.Valid {
return nil
}
changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) {
if isTerminal(cur.Session.State) {
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated {
return cur, false
}
if !s.Source.CanOverride(cur.Activity.Source) {
return cur, false
}
next := cur
ch := false
act := domain.ActivitySubstate{State: s.State, LastActivityAt: nowOr(s.Timestamp), Source: s.Source}
if !sameActivity(cur.Activity, act) {
next.Activity, ch = act, true
act := domain.ActivitySubstate{State: s.State, LastActivityAt: timeOr(s.Timestamp, now), Source: s.Source}
if sameActivity(cur.Activity, act) {
return cur, false
}
if st, ok := activityToSession(s.State); ok {
ch = setSessionState(&next, st, domain.TermNone) || ch
if next.Detecting != nil {
next.Detecting, ch = nil, true
}
next.Activity = act
if s.State == domain.ActivityExited {
next.IsTerminated = true
}
if s.State != domain.ActivityExited && !next.IsAlive {
next.IsAlive, ch = true, true
}
return next, ch
return next, true
})
if err != nil || !changed {
return err
}
return m.runReactions(ctx, id, reactionContent{})
}
// ApplyPRObservation records the observed PR facts in the pr tables, terminates
// the session on a merge, and fires the PR-driven reactions. A failed fetch is
// dropped (failed probe != "PR closed").
func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
if !o.Fetched {
return nil
}
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
if err := m.writePR(ctx, id, o); err != nil {
return err
}
if o.Merged {
changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) {
if isTerminal(cur.Session.State) {
return cur, false
}
next := cur
next.Session.State = domain.SessionTerminated
next.TerminationReason = domain.TermPRMerged
next.IsAlive = false
next.Detecting = nil
return next, true
})
if err != nil {
return err
}
if changed {
m.clearReactions(id)
return m.fireNotify(ctx, id, rec.ProjectID, rxMerged, reactions[rxMerged])
}
return nil
}
return m.runReactions(ctx, id, prContent(o))
}
// writePR persists the observation's scalar facts, check runs, and comment set
// in one atomic store call. PR-table CDC is emitted by the DB triggers.
func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
now := m.clock()
row := domain.PRRow{
URL: o.URL, SessionID: string(id), Number: o.Number,
Draft: o.Draft, Merged: o.Merged, Closed: o.Closed,
CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now,
}
checks := make([]domain.PRCheckRow, len(o.Checks))
for i, c := range o.Checks {
c.PRURL = o.URL
if c.CreatedAt.IsZero() {
c.CreatedAt = now
}
checks[i] = c
}
comments := make([]domain.PRComment, len(o.Comments))
for i, c := range o.Comments {
if c.CreatedAt.IsZero() {
c.CreatedAt = now
}
comments[i] = c
}
return m.pr.WritePR(ctx, row, checks, comments)
}
// ---- mutation commands from the Session Manager ----
// OnSpawnCompleted marks a session live and folds in its handles. It serves a
// fresh spawn (not_started -> live) and a restore (terminal -> reopened): both
// land at not_started + is_alive, with the agent acknowledging via first activity.
func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error {
// MarkSpawned marks a newly spawned or restored session live and stores runtime/workspace handles.
func (m *Manager) MarkSpawned(ctx context.Context, id domain.SessionID, metadata domain.SessionMetadata) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
@ -219,115 +104,32 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p
return err
}
if !ok {
return fmt.Errorf("lifecycle: OnSpawnCompleted for unknown session %q", id)
return fmt.Errorf("lifecycle: MarkSpawned for unknown session %q", id)
}
rec.Lifecycle.Version = domain.LifecycleVersion
rec.Lifecycle.Session.State = domain.SessionNotStarted
rec.Lifecycle.TerminationReason = domain.TermNone
rec.Lifecycle.IsAlive = true
rec.Lifecycle.Detecting = nil
rec.Metadata = mergeMetadata(rec.Metadata, spawnMetadata(o))
rec.UpdatedAt = m.clock()
now := m.clock()
rec.IsTerminated = false
rec.Activity = domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceRuntime}
rec.Metadata = mergeMetadata(rec.Metadata, metadata)
rec.UpdatedAt = now
return m.store.UpdateSession(ctx, rec)
}
// OnKillRequested is the explicit terminal-write path (the one terminal that does
// not go through the inferred-death decider). It fires no reaction — an explicit
// kill is a human action — but drops the session's ACT state.
func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error {
_, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) {
if isTerminal(cur.Session.State) {
// MarkTerminated marks a session terminated without tearing down external resources.
func (m *Manager) MarkTerminated(ctx context.Context, id domain.SessionID) error {
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated {
return cur, false
}
if reason == domain.TermNone {
reason = domain.TermManuallyKilled
}
next := cur
next.Session.State = domain.SessionTerminated
next.TerminationReason = reason
next.IsAlive = false
next.Detecting = nil
return next, true
cur.IsTerminated = true
cur.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: now, Source: domain.SourceRuntime}
return cur, true
})
m.clearReactions(id)
return err
}
// RunningSessions snapshots every non-terminal session for the reaper to probe.
// Detecting sessions are included — a fresh probe is the only fact that recovers
// or escalates them.
func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) {
all, err := m.store.ListAllSessions(ctx)
if err != nil {
return nil, err
}
out := make([]domain.SessionRecord, 0, len(all))
for _, rec := range all {
if !isTerminal(rec.Lifecycle.Session.State) {
out = append(out, rec)
}
}
return out, nil
}
// ---- diff + metadata helpers ----
// setSessionState sets the state (and, for a terminal state, the reason) when it
// differs. An empty state means "decider doesn't address the session axis".
func setSessionState(next *domain.CanonicalSessionLifecycle, st domain.SessionState, reason domain.TerminationReason) bool {
if st == "" {
return false
}
changed := false
if next.Session.State != st {
next.Session.State, changed = st, true
}
want := domain.TermNone
if st == domain.SessionTerminated {
want = reason
}
if next.TerminationReason != want {
next.TerminationReason, changed = want, true
}
return changed
}
func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool {
if d != nil {
if next.Detecting != nil && *next.Detecting == *d {
return false
}
dc := *d
next.Detecting = &dc
return true
}
if next.Detecting != nil {
next.Detecting = nil
return true
}
return false
}
// sameActivity compares with time-aware equality (== on time.Time is
// monotonic-clock sensitive and would spuriously report changes).
func sameActivity(a, b domain.ActivitySubstate) bool {
return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt)
}
func spawnMetadata(o ports.SpawnOutcome) domain.SessionMetadata {
return domain.SessionMetadata{
Branch: o.Branch,
WorkspacePath: o.WorkspacePath,
RuntimeHandleID: o.RuntimeHandle.ID,
RuntimeName: o.RuntimeHandle.RuntimeName,
AgentSessionID: o.AgentSessionID,
Prompt: o.Prompt,
}
}
// mergeMetadata overlays set fields of in onto base without clobbering an
// existing value with an empty one (a partial spawn write keeps the branch set
// at creation).
func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata {
set := func(dst *string, v string) {
if v != "" {
@ -337,7 +139,6 @@ func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata {
set(&base.Branch, in.Branch)
set(&base.WorkspacePath, in.WorkspacePath)
set(&base.RuntimeHandleID, in.RuntimeHandleID)
set(&base.RuntimeName, in.RuntimeName)
set(&base.AgentSessionID, in.AgentSessionID)
set(&base.Prompt, in.Prompt)
return base

View File

@ -2,7 +2,7 @@ package lifecycle
import (
"context"
"fmt"
"errors"
"strings"
"testing"
"time"
@ -13,216 +13,139 @@ import (
var ctx = context.Background()
// ---- fakes ----
// fakeStore is a mini SessionStore + PRWriter: it derives PRFacts and recent
// check statuses from what the engine writes, so PR-reaction tests exercise the
// write path and the read-back together.
type fakeStore struct {
sessions map[domain.SessionID]domain.SessionRecord
pr map[domain.SessionID]domain.PRRow
comments map[string][]domain.PRComment
checks []domain.PRCheckRow
num int
}
func newFakeStore() *fakeStore {
return &fakeStore{
sessions: map[domain.SessionID]domain.SessionRecord{},
pr: map[domain.SessionID]domain.PRRow{},
comments: map[string][]domain.PRComment{},
}
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}}
}
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
f.num++
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
f.sessions[rec.ID] = rec
return rec, nil
}
func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
f.sessions[rec.ID] = rec
return nil
}
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
r, ok := f.sessions[id]
return r, ok, nil
}
func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
var out []domain.SessionRecord
for _, r := range f.sessions {
if r.ProjectID == p {
out = append(out, r)
}
}
return out, nil
}
func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
out := make([]domain.SessionRecord, 0, len(f.sessions))
for _, r := range f.sessions {
out = append(out, r)
}
return out, nil
}
func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) {
r, ok := f.pr[id]
if !ok {
return domain.PRFacts{}, nil
}
facts := domain.PRFacts{
URL: r.URL, Number: r.Number, Exists: true,
Draft: r.Draft, Merged: r.Merged, Closed: r.Closed,
CI: r.CI, Review: r.Review, Mergeability: r.Mergeability,
}
for _, c := range f.comments[r.URL] {
if !c.Resolved {
facts.ReviewComments = true
break
}
}
return facts, nil
}
func (f *fakeStore) WritePR(_ context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error {
f.pr[domain.SessionID(pr.SessionID)] = pr
f.checks = append(f.checks, checks...)
f.comments[pr.URL] = comments
return nil
}
func (f *fakeStore) RecentCheckStatuses(_ context.Context, url, name string, limit int) ([]string, error) {
var out []string
for i := len(f.checks) - 1; i >= 0 && len(out) < limit; i-- {
if f.checks[i].PRURL == url && f.checks[i].Name == name {
out = append(out, f.checks[i].Status)
}
}
return out, nil
}
type fakeNotifier struct{ events []ports.Event }
func (f *fakeNotifier) Notify(_ context.Context, e ports.Event) error {
f.events = append(f.events, e)
return nil
}
func (f *fakeNotifier) last() string {
if len(f.events) == 0 {
return ""
}
return f.events[len(f.events)-1].Type
}
type fakeMessenger struct{ msgs []string }
func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, m string) error {
f.msgs = append(f.msgs, m)
func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
f.sessions[rec.ID] = rec
return nil
}
func newManager() (*Manager, *fakeStore, *fakeNotifier, *fakeMessenger) {
st, n, msg := newFakeStore(), &fakeNotifier{}, &fakeMessenger{}
return New(st, st, n, msg), st, n, msg
type fakeMessenger struct {
msgs []string
err error
}
func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
if f.err != nil {
return f.err
}
f.msgs = append(f.msgs, msg)
return nil
}
func newManager() (*Manager, *fakeStore, *fakeMessenger) {
st := newFakeStore()
msg := &fakeMessenger{}
return New(st, msg), st, msg
}
func working(id domain.SessionID) domain.SessionRecord {
return domain.SessionRecord{
ID: id, ProjectID: "mer",
Lifecycle: domain.CanonicalSessionLifecycle{
Version: domain.LifecycleVersion,
Session: domain.SessionSubstate{State: domain.SessionWorking},
IsAlive: true,
},
}
return domain.SessionRecord{ID: id, ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: time.Now(), Source: domain.SourceNative}}
}
func openPR(o ports.PRObservation) ports.PRObservation {
o.Fetched, o.URL, o.Number = true, "https://example/pr/1", 1
return o
}
// ---- runtime observations ----
func TestRuntimeObservation_InferredDeath(t *testing.T) {
m, st, n, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeDead, Process: ports.ProbeDead}); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Lifecycle
if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermRuntimeLost || got.IsAlive {
t.Fatalf("want terminated/runtime_lost/dead, got %+v", got)
}
if n.last() != "reaction.agent-exited" {
t.Fatalf("want agent-exited notify, got %q", n.last())
}
}
func TestRuntimeObservation_FailedProbeQuarantines(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeFailed, Process: ports.ProbeFailed}); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Lifecycle
if got.Session.State != domain.SessionDetecting || !got.IsAlive || got.Detecting == nil {
t.Fatalf("failed probe should quarantine alive, got %+v", got)
}
}
func TestRuntimeObservation_RecoversDetecting(t *testing.T) {
m, st, _, _ := newManager()
func TestRuntimeObservation_InferredDeathSetsTerminated(t *testing.T) {
m, st, _ := newManager()
rec := working("mer-1")
rec.Lifecycle.Session.State = domain.SessionDetecting
rec.Lifecycle.Detecting = &domain.DetectingState{Attempts: 1}
rec.Activity.LastActivityAt = time.Now().Add(-2 * time.Minute)
st.sessions["mer-1"] = rec
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeAlive, Process: ports.ProbeAlive}); err != nil {
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeDead}); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Lifecycle
if got.Session.State != domain.SessionWorking || got.Detecting != nil {
t.Fatalf("healthy probe should recover to working, got %+v", got)
got := st.sessions["mer-1"]
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
t.Fatalf("want terminated/exited, got %+v", got)
}
}
// ---- activity signals ----
func TestActivity_WaitingInputPagesHuman(t *testing.T) {
m, st, n, _ := newManager()
func TestRuntimeObservation_FailedProbeDoesNotMutate(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput, Timestamp: time.Now()}); err != nil {
before := st.sessions["mer-1"]
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeFailed}); err != nil {
t.Fatal(err)
}
if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionNeedsInput {
t.Fatalf("want needs_input, got %v", st.sessions["mer-1"].Lifecycle.Session.State)
}
if n.last() != "reaction.agent-needs-input" {
t.Fatalf("want needs-input notify, got %q", n.last())
if st.sessions["mer-1"] != before {
t.Fatalf("failed probe should not persist a state, got %+v", st.sessions["mer-1"])
}
}
func TestActivity_InvalidIsIgnored(t *testing.T) {
m, st, _, _ := newManager()
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: false, State: domain.ActivityIdle}); err != nil {
t.Fatal(err)
}
if st.sessions["mer-1"] != before {
t.Fatal("invalid signal must not mutate the session")
t.Fatal("invalid signal must not mutate")
}
}
// ---- PR observations ----
func TestPR_CIFailingNudgesAgentWithLogs(t *testing.T) {
m, st, _, msg := newManager()
func TestActivity_WeakerSourceDoesNotOverrideStronger(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityIdle, Source: domain.SourceRuntime}); err != nil {
t.Fatal(err)
}
if st.sessions["mer-1"] != before {
t.Fatalf("weaker runtime signal should not override native activity, got %+v", st.sessions["mer-1"])
}
}
o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: "c1", Status: "failed", LogTail: "boom"}}})
func TestActivity_StrongerSourceOverridesWeaker(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceRuntime}}
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Source: domain.SourceNative}); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Activity
if got.State != domain.ActivityActive || got.Source != domain.SourceNative {
t.Fatalf("stronger native signal should override runtime, got %+v", got)
}
}
func TestMarkTerminated(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.MarkTerminated(ctx, "mer-1"); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"]
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
t.Fatalf("want terminated/exited, got %+v", got)
}
}
func TestMarkSpawnedStoresRuntimeMetadata(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true}
metadata := domain.SessionMetadata{Branch: "b", WorkspacePath: "/ws", RuntimeHandleID: "h1", AgentSessionID: "agent", Prompt: "prompt"}
if err := m.MarkSpawned(ctx, "mer-1", metadata); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"]
if got.IsTerminated || got.Activity.State != domain.ActivityIdle || got.Metadata.RuntimeHandleID != "h1" {
t.Fatalf("spawn metadata wrong: %+v", got)
}
}
func TestPRObservation_CIFailingNudgesAgentWithLogs(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.PRObservation{Fetched: true, URL: "pr1", CI: domain.CIFailing, Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}}
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
t.Fatal(err)
}
@ -231,135 +154,58 @@ func TestPR_CIFailingNudgesAgentWithLogs(t *testing.T) {
}
}
func TestPR_CIBrakeEscalatesAfterThreeFails(t *testing.T) {
m, st, n, msg := newManager()
func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
for _, commit := range []string{"c1", "c2", "c3"} {
o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: commit, Status: "failed", LogTail: "boom"}}})
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
t.Fatal(err)
}
}
if len(msg.msgs) != 2 {
t.Fatalf("want 2 nudges then escalate, got %d nudges", len(msg.msgs))
}
if n.last() != "reaction.escalated" {
t.Fatalf("3rd failure should escalate, got %q", n.last())
}
}
func TestPR_ReviewCommentsInjectedRegardlessOfAuthor(t *testing.T) {
m, st, _, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := openPR(ports.PRObservation{
Review: domain.ReviewChangesRequest,
Comments: []domain.PRComment{{ID: "1", Author: "greptileai", Body: "use a constant here"}},
})
o := ports.PRObservation{Fetched: true, URL: "pr1", Review: domain.ReviewChangesRequest, Comments: []ports.PRCommentObservation{{ID: "1", Body: "fix this"}}}
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
t.Fatal(err)
}
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "use a constant here") {
t.Fatalf("review feedback should be injected verbatim, got %v", msg.msgs)
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "fix this") {
t.Fatalf("want review nudge, got %v", msg.msgs)
}
}
func TestPR_ApprovedAndGreenNotifies(t *testing.T) {
m, st, n, _ := newManager()
func TestPRObservation_MergeConflictNudgesAgent(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := openPR(ports.PRObservation{Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable})
o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting}
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
t.Fatal(err)
}
if n.last() != "reaction.approved-and-green" {
t.Fatalf("want approved-and-green, got %q", n.last())
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "merge conflicts") {
t.Fatalf("want merge-conflict nudge, got %v", msg.msgs)
}
}
func TestPR_MergeTerminatesSession(t *testing.T) {
m, st, n, _ := newManager()
func TestPRObservation_MergedTerminatesWithoutNudge(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := openPR(ports.PRObservation{Merged: true})
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1", Merged: true}); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Lifecycle
if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermPRMerged {
t.Fatalf("merge should terminate with pr_merged, got %+v", got)
got := st.sessions["mer-1"]
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
t.Fatalf("merged PR should terminate session, got %+v", got)
}
if n.last() != "reaction.pr-merged" {
t.Fatalf("want pr-merged notify, got %q", n.last())
if len(msg.msgs) != 0 {
t.Fatalf("merged PR should not send nudge, got %v", msg.msgs)
}
}
func TestPR_FailedFetchIsDropped(t *testing.T) {
m, st, _, msg := newManager()
func TestPRObservation_RetriesAfterMessengerFailure(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: false, CI: domain.CIFailing}); err != nil {
t.Fatal(err)
o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting}
msg.err = errors.New("temporary send failure")
if err := m.ApplyPRObservation(ctx, "mer-1", o); err == nil {
t.Fatal("want send error")
}
if len(msg.msgs) != 0 || len(st.pr) != 0 {
t.Fatal("a failed fetch must write nothing and fire nothing")
}
}
// ---- explicit kill ----
func TestKill_TerminatesWithoutReacting(t *testing.T) {
m, st, n, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
if err := m.OnKillRequested(ctx, "mer-1", domain.TermManuallyKilled); err != nil {
t.Fatal(err)
}
got := st.sessions["mer-1"].Lifecycle
if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermManuallyKilled || got.IsAlive {
t.Fatalf("want terminated/manually_killed/dead, got %+v", got)
}
if len(n.events) != 0 {
t.Fatal("an explicit kill must not fire a reaction")
}
}
// ---- duration escalation ----
func TestTickEscalations_DurationPagesHuman(t *testing.T) {
m, st, n, msg := newManager()
now := time.Now()
m.clock = func() time.Time { return now }
st.sessions["mer-1"] = working("mer-1")
o := openPR(ports.PRObservation{Mergeability: domain.MergeConflicting})
msg.err = nil
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
t.Fatal(err)
}
if len(msg.msgs) != 1 {
t.Fatalf("merge-conflict should nudge once, got %d", len(msg.msgs))
}
if err := m.TickEscalations(ctx, now.Add(16*time.Minute)); err != nil {
t.Fatal(err)
}
if n.last() != "reaction.escalated" {
t.Fatalf("unaddressed conflict should escalate after 15m, got %q", n.last())
}
}
func TestRunningSessions_ExcludesTerminal(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
dead := working("mer-2")
dead.Lifecycle.Session.State = domain.SessionTerminated
st.sessions["mer-2"] = dead
got, err := m.RunningSessions(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "mer-1" {
t.Fatalf("want only the live session, got %+v", got)
t.Fatalf("want retry to send once, got %v", msg.msgs)
}
}

View File

@ -1,413 +1,117 @@
package lifecycle
// reactions.go is the ACT layer: after a persisted transition the engine maps
// the session's (state, PR facts) to at most one reaction and dispatches it —
// nudging the agent or paging the human. Two reactions inject live content (CI
// logs, review comments) and re-fire when that content changes; the rest fire
// once on entry, with duration escalation driven by TickEscalations.
//
// Budgets are in-memory: a restart re-arms them, which costs a few extra nudges,
// never a missed page.
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type reactionKey string
const (
rxCIFailed reactionKey = "ci-failed"
rxReviewComments reactionKey = "review-comments"
rxMergeConflicts reactionKey = "merge-conflicts"
rxIdle reactionKey = "agent-idle"
rxApprovedGreen reactionKey = "approved-and-green"
rxStuck reactionKey = "agent-stuck"
rxNeedsInput reactionKey = "agent-needs-input"
rxExited reactionKey = "agent-exited"
rxPRClosed reactionKey = "pr-closed"
rxMerged reactionKey = "pr-merged"
)
// Brakes: stop auto-handling and page a human after this many failed attempts.
const (
ciBrakeRuns = 3 // last N runs of a failing check all failed
reviewMaxNudge = 3 // re-nudged the agent N times over new review feedback
)
// reactionConfig is one row of the reaction table. toAgent reactions nudge the
// agent; the rest notify the human. escalateAfter (when set) drives a
// duration-based escalation via TickEscalations.
type reactionConfig struct {
toAgent bool
message string
eventType string
priority ports.Priority
escalateAfter time.Duration
}
var reactions = map[reactionKey]reactionConfig{
rxCIFailed: {toAgent: true, eventType: "reaction.ci-failed", priority: ports.PriorityAction, message: "CI is failing on your PR. Review the output below and push a fix."},
rxReviewComments: {toAgent: true, eventType: "reaction.review-comments", priority: ports.PriorityAction, message: "A reviewer left feedback on your PR. Address it and push."},
rxMergeConflicts: {toAgent: true, eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, escalateAfter: 15 * time.Minute, message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them."},
rxIdle: {toAgent: true, eventType: "reaction.agent-idle", priority: ports.PriorityInfo, escalateAfter: 15 * time.Minute, message: "You appear idle. Continue the task or say what is blocking you."},
rxApprovedGreen: {eventType: "reaction.approved-and-green", priority: ports.PriorityAction, message: "PR is approved and green — ready to merge."},
rxStuck: {eventType: "reaction.agent-stuck", priority: ports.PriorityUrgent, message: "Agent is stuck and needs attention."},
rxNeedsInput: {eventType: "reaction.agent-needs-input", priority: ports.PriorityUrgent, message: "Agent needs input to continue."},
rxExited: {eventType: "reaction.agent-exited", priority: ports.PriorityUrgent, message: "Agent process exited unexpectedly."},
rxPRClosed: {eventType: "reaction.pr-closed", priority: ports.PriorityAction, message: "PR was closed without merging."},
rxMerged: {eventType: "reaction.pr-merged", priority: ports.PriorityInfo, message: "PR merged — work complete."},
}
// reactionContent carries the live material the feedback reactions inject. Empty
// for runtime/activity transitions; populated from a PR observation.
type reactionContent struct {
ciCheck string
ciCommit string
ciURL string
ciLogTail string
comments []string
reviewSig string
}
// prContent extracts the CI failure + review feedback from a PR observation.
func prContent(o ports.PRObservation) reactionContent {
c := reactionContent{}
for _, ch := range o.Checks {
if ch.Status == "failed" {
c.ciCheck, c.ciCommit, c.ciLogTail, c.ciURL = ch.Name, ch.CommitHash, ch.LogTail, o.URL
break
}
}
var ids []string
for _, cm := range o.Comments {
if cm.Resolved {
continue
}
c.comments = append(c.comments, cm.Body)
ids = append(ids, cm.ID)
}
c.reviewSig = strings.Join(ids, ",")
return c
}
// ---- in-memory escalation state ----
type trackerKey struct {
id domain.SessionID
key reactionKey
}
type tracker struct {
attempts int
firstAt time.Time
escalated bool
seenSig bool
lastSig string
projectID domain.ProjectID
}
const reviewMaxNudge = 3
type reactionState struct {
mu sync.Mutex
trackers map[trackerKey]*tracker
lastKey map[domain.SessionID]reactionKey
seen map[string]string
attempts map[string]int
}
func newReactionState() reactionState {
return reactionState{trackers: map[trackerKey]*tracker{}, lastKey: map[domain.SessionID]reactionKey{}}
return reactionState{seen: map[string]string{}, attempts: map[string]int{}}
}
// trackerFor returns the (id,key) tracker, creating it on first use. Caller holds mu.
func (rs *reactionState) trackerFor(id domain.SessionID, key reactionKey) *tracker {
k := trackerKey{id, key}
t := rs.trackers[k]
if t == nil {
t = &tracker{}
rs.trackers[k] = t
// ApplyPRObservation reacts to a fetched PR observation after the PR service has
// persisted it. It does not write PR rows; it owns PR-driven lifecycle effects
// and sends actionable agent nudges such as rebase, fix-CI, and
// address-review-feedback prompts.
func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
if !o.Fetched {
return nil
}
return t
}
func (m *Manager) clearReactions(id domain.SessionID) {
m.react.mu.Lock()
defer m.react.mu.Unlock()
for k := range m.react.trackers {
if k.id == id {
delete(m.react.trackers, k)
}
if o.Merged {
return m.MarkTerminated(ctx, id)
}
if o.Closed {
return nil
}
delete(m.react.lastKey, id)
}
// ---- dispatch ----
// runReactions is the chokepoint called after every persisted transition. It
// runs unlocked (the write lock is already released) so a busy agent send never
// blocks the write path.
func (m *Manager) runReactions(ctx context.Context, id domain.SessionID, content reactionContent) error {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
lc := rec.Lifecycle
project := rec.ProjectID
if isTerminal(lc.Session.State) {
err := m.dispatch(ctx, id, project, terminalReaction(lc.TerminationReason))
m.clearReactions(id) // incident over: drop budgets after the final notify
return err
if rec.IsTerminated || rec.Activity.State == domain.ActivityBlocked || rec.Activity.State == domain.ActivityWaitingInput {
return nil
}
pr, err := m.store.PRFactsForSession(ctx, id)
if err != nil {
return err
}
// Feedback reactions inject live content and re-fire as it changes — only
// while the agent can actually act on it.
if pr.Exists && !pr.Closed && !needsHuman(lc.Session.State) {
if pr.CI == domain.CIFailing && content.ciCheck != "" {
if err := m.handleCIFailure(ctx, id, project, content); err != nil {
return err
}
}
if hasReviewFeedback(pr) {
if err := m.handleReviewFeedback(ctx, id, project, content); err != nil {
return err
if o.CI == domain.CIFailing {
for _, ch := range o.Checks {
if ch.Status == domain.PRCheckFailed {
msg := "CI is failing on your PR. Review the output below and push a fix."
if ch.LogTail != "" {
msg += "\n\nFailing output:\n" + ch.LogTail
}
return m.sendOnce(ctx, id, "ci:"+o.URL+":"+ch.Name, ch.CommitHash+":"+ch.LogTail, msg, 0)
}
}
}
return m.dispatch(ctx, id, project, reactionFor(lc, pr))
}
// dispatch fires the entry reaction for key, deduped so a steady state does not
// re-fire. Leaving a reaction drops its budget.
func (m *Manager) dispatch(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey) error {
m.react.mu.Lock()
if m.react.lastKey[id] == key {
m.react.mu.Unlock()
return nil
}
if prev := m.react.lastKey[id]; prev != "" {
delete(m.react.trackers, trackerKey{id, prev})
}
m.react.lastKey[id] = key
m.react.mu.Unlock()
if key == "" {
return nil
}
cfg := reactions[key]
if cfg.toAgent {
return m.fireAgentEntry(ctx, id, project, key, cfg)
}
return m.fireNotify(ctx, id, project, key, cfg)
}
// reactionFor maps (session state, PR facts) to the reaction to enter. CI failure
// and review feedback return "" here — they are handled by the feedback path.
func reactionFor(lc domain.CanonicalSessionLifecycle, pr domain.PRFacts) reactionKey {
switch lc.Session.State {
case domain.SessionStuck:
return rxStuck
case domain.SessionNeedsInput:
return rxNeedsInput
}
if pr.Exists {
if pr.Closed {
if !pr.Merged {
return rxPRClosed
}
return ""
if o.Review == domain.ReviewChangesRequest || hasUnresolvedComments(o.Comments) {
comments, sig := reviewContent(o.Comments)
msg := "A reviewer left feedback on your PR. Address it and push."
if comments != "" {
msg += "\n\n" + comments
}
switch {
case pr.CI == domain.CIFailing, hasReviewFeedback(pr):
return "" // feedback path
case pr.Mergeability == domain.MergeConflicting:
return rxMergeConflicts
case pr.Mergeability == domain.MergeMergeable, pr.Review == domain.ReviewApproved:
return rxApprovedGreen
if sig == "" {
sig = string(o.Review)
}
return m.sendOnce(ctx, id, "review:"+o.URL, sig, msg, reviewMaxNudge)
}
if lc.Session.State == domain.SessionIdle {
return rxIdle
}
return ""
}
func hasReviewFeedback(pr domain.PRFacts) bool {
return pr.Review == domain.ReviewChangesRequest || pr.ReviewComments
}
func needsHuman(s domain.SessionState) bool {
return s == domain.SessionStuck || s == domain.SessionNeedsInput
}
// terminalReaction is the notify fired when a session reaches a terminal state by
// inferred death. An explicit kill goes through OnKillRequested (no reaction);
// auto_cleanup / pr_merged are notified elsewhere.
func terminalReaction(r domain.TerminationReason) reactionKey {
switch r {
case domain.TermRuntimeLost, domain.TermAgentProcessExited, domain.TermProbeFailure, domain.TermErrorInProcess:
return rxExited
default:
return ""
}
}
// ---- feedback reactions (content-driven re-fire + brake) ----
func (m *Manager) handleCIFailure(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error {
msg := reactions[rxCIFailed].message + "\n\nFailing output:\n" + c.ciLogTail
return m.fireFeedback(ctx, id, project, rxCIFailed, c.ciCommit, msg, func(int) (bool, error) {
st, err := m.pr.RecentCheckStatuses(ctx, c.ciURL, c.ciCheck, ciBrakeRuns)
if err != nil {
return false, err
}
return allFailed(st, ciBrakeRuns), nil
})
}
func (m *Manager) handleReviewFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error {
msg := reactions[rxReviewComments].message
if len(c.comments) > 0 {
msg += "\n\n" + strings.Join(c.comments, "\n\n")
}
return m.fireFeedback(ctx, id, project, rxReviewComments, c.reviewSig, msg, func(attempts int) (bool, error) {
return attempts > reviewMaxNudge, nil
})
}
// fireFeedback nudges the agent with fresh content, deduped by signature so the
// same content is not re-sent each poll. braked decides whether to escalate to a
// human instead (CI: history; review: attempt count).
func (m *Manager) fireFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, sig, message string, braked func(attempts int) (bool, error)) error {
m.react.mu.Lock()
t := m.react.trackerFor(id, key)
if project != "" {
t.projectID = project
}
if t.escalated || (t.seenSig && t.lastSig == sig) {
m.react.mu.Unlock()
return nil
}
t.seenSig, t.lastSig = true, sig
t.attempts++
attempts, pid := t.attempts, t.projectID
m.react.lastKey[id] = key // feedback owns the slot so a later dispatch("") clears it
m.react.mu.Unlock()
brake, err := braked(attempts)
if err != nil {
return err
}
if brake {
m.react.mu.Lock()
t.escalated = true
m.react.mu.Unlock()
cause := "max_attempts"
if key == rxCIFailed {
cause = "max_retries"
}
return m.escalate(ctx, id, pid, key, ports.EscalationEvent{Attempts: attempts, Cause: cause})
}
return m.messenger.Send(ctx, id, message)
}
// ---- entry reactions ----
// fireAgentEntry nudges the agent once on entry into a static reaction
// (idle/merge-conflicts); escalation is duration-based via TickEscalations.
func (m *Manager) fireAgentEntry(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error {
m.react.mu.Lock()
t := m.react.trackerFor(id, key)
if project != "" {
t.projectID = project
}
if t.escalated {
m.react.mu.Unlock()
return nil
}
if t.firstAt.IsZero() {
t.firstAt = m.clock()
}
t.attempts++
m.react.mu.Unlock()
return m.messenger.Send(ctx, id, cfg.message)
}
func (m *Manager) fireNotify(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error {
return m.notifier.Notify(ctx, ports.Event{
Type: cfg.eventType, Priority: cfg.priority,
SessionID: id, ProjectID: project, Message: cfg.message,
Reaction: &ports.ReactionEvent{Key: string(key), Action: "notify"},
CauseKey: string(key),
OccurredAt: m.clock(),
})
}
func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, esc ports.EscalationEvent) error {
if esc.Cause == "" {
esc.Cause = "max_attempts"
}
return m.notifier.Notify(ctx, ports.Event{
Type: "reaction.escalated", Priority: ports.PriorityUrgent,
SessionID: id, ProjectID: project,
Message: fmt.Sprintf("Automatic handling of %q is exhausted — needs a human.", key),
Reaction: &ports.ReactionEvent{Key: string(key), Action: "escalated"},
Escalation: &esc,
CauseKey: string(key) + ":" + esc.Cause,
OccurredAt: m.clock(),
})
}
// TickEscalations fires the duration-based escalations the synchronous engine
// cannot wake itself for. The reaper calls it on a timer.
func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error {
type due struct {
id domain.SessionID
project domain.ProjectID
key reactionKey
attempts int
durationMs int64
}
var fire []due
m.react.mu.Lock()
for k, t := range m.react.trackers {
if t.escalated {
continue
}
cfg := reactions[k.key]
if cfg.escalateAfter > 0 && !t.firstAt.IsZero() && now.Sub(t.firstAt) >= cfg.escalateAfter {
t.escalated = true
fire = append(fire, due{k.id, t.projectID, k.key, t.attempts, now.Sub(t.firstAt).Milliseconds()})
}
}
m.react.mu.Unlock()
for _, d := range fire {
if err := m.escalate(ctx, d.id, d.project, d.key, ports.EscalationEvent{Attempts: d.attempts, Cause: "max_duration", DurationMs: d.durationMs}); err != nil {
return err
}
if o.Mergeability == domain.MergeConflicting {
return m.sendOnce(ctx, id, "merge-conflict:"+o.URL, string(o.Mergeability), "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", 0)
}
return nil
}
func allFailed(statuses []string, n int) bool {
if len(statuses) < n {
return false
}
for i := 0; i < n; i++ {
if statuses[i] != "failed" {
return false
func hasUnresolvedComments(comments []ports.PRCommentObservation) bool {
for _, c := range comments {
if !c.Resolved {
return true
}
}
return true
return false
}
func reviewContent(comments []ports.PRCommentObservation) (string, string) {
var bodies []string
var ids []string
for _, c := range comments {
if c.Resolved {
continue
}
bodies = append(bodies, c.Body)
ids = append(ids, c.ID)
}
return strings.Join(bodies, "\n\n"), strings.Join(ids, ",")
}
func (m *Manager) sendOnce(ctx context.Context, id domain.SessionID, key, sig, msg string, maxAttempts int) error {
if m.messenger == nil {
return nil
}
m.react.mu.Lock()
if m.react.seen[key] == sig {
m.react.mu.Unlock()
return nil
}
attempts := m.react.attempts[key]
if maxAttempts > 0 && attempts >= maxAttempts {
m.react.mu.Unlock()
return nil
}
if err := m.messenger.Send(ctx, id, msg); err != nil {
m.react.mu.Unlock()
return err
}
m.react.seen[key] = sig
m.react.attempts[key] = attempts + 1
m.react.mu.Unlock()
return nil
}

View File

@ -0,0 +1,35 @@
package lifecycle
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const defaultRecentActivityWindow = 60 * time.Second
func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool {
switch {
case a.State == domain.ActivityExited:
return false
case a.State.IsSticky():
return true
case a.LastActivityAt.IsZero():
return false
default:
return now.Sub(a.LastActivityAt) <= window
}
}
func runtimeClearlyDead(f ports.RuntimeFacts, activity domain.ActivitySubstate, now time.Time, window time.Duration) bool {
observedAt := timeOr(f.ObservedAt, now)
return f.Probe == ports.ProbeDead && !hasRecentActivity(activity, observedAt, window)
}
func timeOr(t, fallback time.Time) time.Time {
if t.IsZero() {
return fallback
}
return t
}

View File

@ -1,74 +0,0 @@
package notification
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// ConditionHash returns a deterministic, compact hash over a condition vector.
func ConditionHash(parts ...string) string {
b, _ := json.Marshal(parts)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:16])
}
// DedupeKey returns the stable durable notification idempotency key.
func DedupeKey(projectID domain.ProjectID, sessionID domain.SessionID, reactionKey, conditionHash string) string {
return fmt.Sprintf("v1:lifecycle:%s:%s:%s:%s", projectID, sessionID, reactionKey, conditionHash)
}
// ComputeDedupeKey derives a restart-safe dedupe key from the lifecycle event
// plus current persisted state. It avoids PR updated_at because re-polling the
// same facts after daemon restart would otherwise create duplicate notifications.
func ComputeDedupeKey(event ports.Event, rec domain.SessionRecord, pr domain.PRFacts) string {
projectID := event.ProjectID
if projectID == "" {
projectID = rec.ProjectID
}
reactionKey := reactionKeyForEvent(event)
condition := []string{
"session_state", string(rec.Lifecycle.Session.State),
"termination", string(rec.Lifecycle.TerminationReason),
"session_updated", timeKey(rec.UpdatedAt),
}
if pr.Exists {
condition = append(condition,
"pr_url", pr.URL,
"pr_number", fmt.Sprint(pr.Number),
"pr_draft", fmt.Sprint(pr.Draft),
"pr_merged", fmt.Sprint(pr.Merged),
"pr_closed", fmt.Sprint(pr.Closed),
"ci", string(pr.CI),
"review", string(pr.Review),
"mergeability", string(pr.Mergeability),
"review_comments", fmt.Sprint(pr.ReviewComments),
)
}
if event.CauseKey != "" {
condition = append(condition, "cause_key", event.CauseKey)
}
if event.Escalation != nil {
condition = append(condition, "escalation_cause", event.Escalation.Cause)
}
return DedupeKey(projectID, event.SessionID, reactionKey, ConditionHash(condition...))
}
func reactionKeyForEvent(event ports.Event) string {
if event.Reaction != nil && event.Reaction.Key != "" {
return event.Reaction.Key
}
return reactionKeyFromType(event.Type)
}
func timeKey(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}

View File

@ -1,63 +0,0 @@
package notification
import (
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestDedupeSameReactionConditionProducesSameKey(t *testing.T) {
rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}}
k1 := ComputeDedupeKey(e, rec, domain.PRFacts{})
k2 := ComputeDedupeKey(e, rec, domain.PRFacts{})
if k1 != k2 {
t.Fatalf("dedupe key unstable: %q != %q", k1, k2)
}
}
func TestDedupeChangedConditionProducesNewKey(t *testing.T) {
e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}}
r1 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
r2 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC))
if ComputeDedupeKey(e, r1, domain.PRFacts{}) == ComputeDedupeKey(e, r2, domain.PRFacts{}) {
t.Fatal("changed session updated timestamp should change dedupe key")
}
}
func TestDedupeEscalationIncludesCauseAndDoesNotCollideWithBase(t *testing.T) {
rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
base := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "notify"}}
esc := ports.Event{
SessionID: "ao-1",
Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"},
Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries"},
}
otherCause := esc
otherCause.Escalation = &ports.EscalationEvent{Attempts: 3, Cause: "max_duration"}
baseKey := ComputeDedupeKey(base, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing})
escKey := ComputeDedupeKey(esc, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing})
otherKey := ComputeDedupeKey(otherCause, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing})
if baseKey == escKey {
t.Fatal("escalation dedupe key should not collide with base reaction")
}
if escKey == otherKey {
t.Fatal("escalation cause should affect dedupe key")
}
}
func dedupeRecord(state domain.SessionState, updated time.Time) domain.SessionRecord {
return domain.SessionRecord{
ID: "ao-1",
ProjectID: "ao",
Lifecycle: domain.CanonicalSessionLifecycle{
Session: domain.SessionSubstate{State: state},
},
UpdatedAt: updated,
}
}

View File

@ -1,53 +0,0 @@
package notification
import (
"context"
"log/slog"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Store is the durable write-side used by the enqueuer. *sqlite.Store satisfies
// this interface.
type Store interface {
EnqueueNotification(ctx context.Context, row domain.Notification) (domain.Notification, bool, error)
}
// Enqueuer is a store-backed ports.Notifier. It does not deliver to external
// sinks; it renders and persists the notification for later dashboard/app sinks.
type Enqueuer struct {
store Store
renderer *Renderer
logger *slog.Logger
}
var _ ports.Notifier = (*Enqueuer)(nil)
// NewEnqueuer returns a Notifier that renders events and persists the resulting
// notification rows via store, defaulting the logger to slog.Default.
func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer {
if logger == nil {
logger = slog.Default()
}
return &Enqueuer{store: store, renderer: renderer, logger: logger}
}
// Notify renders the event and enqueues the resulting notification row.
func (e *Enqueuer) Notify(ctx context.Context, event ports.Event) error {
row, err := e.renderer.Render(ctx, event)
if err != nil {
return err
}
saved, created, err := e.store.EnqueueNotification(ctx, row)
if err != nil {
return err
}
e.logger.DebugContext(ctx, "notification enqueued",
"id", saved.ID,
"session", saved.SessionID,
"semantic_type", saved.SemanticType,
"created", created,
)
return nil
}

View File

@ -1,38 +0,0 @@
package notification
import (
"context"
"io"
"log/slog"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type fakeNotificationStore struct {
row domain.Notification
created bool
}
func (f *fakeNotificationStore) EnqueueNotification(_ context.Context, row domain.Notification) (domain.Notification, bool, error) {
f.row = row
f.created = true
return row, true, nil
}
func TestEnqueuerRendersAndPersists(t *testing.T) {
store := &fakeNotificationStore{}
renderer := NewRenderer(fakeReader{rec: renderRecord()})
enq := NewEnqueuer(store, renderer, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := enq.Notify(context.Background(), ports.Event{
Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent,
ProjectID: "ao", SessionID: "ao-7", Message: "needs input",
Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"},
}); err != nil {
t.Fatal(err)
}
if !store.created || store.row.SemanticType != "session.needs_input" || store.row.DedupeKey == "" {
t.Fatalf("store row not rendered: created=%v row=%+v", store.created, store.row)
}
}

View File

@ -1,75 +0,0 @@
package notification
// PayloadSchemaVersion is the durable notification payload contract version.
const PayloadSchemaVersion = 3
// Payload is the provider-neutral, rich notification data shape persisted in
// SQLite. It intentionally mirrors legacy AO's NotificationData V3 while only
// filling fields the Go rewrite can source today.
type Payload struct {
SchemaVersion int `json:"schemaVersion"`
SemanticType string `json:"semanticType"`
Subject SubjectPayload `json:"subject"`
Reaction *ReactionPayload `json:"reaction,omitempty"`
Escalation *EscalationPayload `json:"escalation,omitempty"`
CI *CIPayload `json:"ci,omitempty"`
Review *ReviewPayload `json:"review,omitempty"`
Merge *MergePayload `json:"merge,omitempty"`
}
// SubjectPayload identifies what a notification is about — the session and,
// when relevant, its PR, issue, and branch.
type SubjectPayload struct {
Session *SessionSubjectPayload `json:"session,omitempty"`
PR *PRSubjectPayload `json:"pr,omitempty"`
Issue *IssueSubjectPayload `json:"issue,omitempty"`
Branch string `json:"branch,omitempty"`
}
// SessionSubjectPayload identifies the session a notification concerns.
type SessionSubjectPayload struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
}
// PRSubjectPayload identifies the PR a notification concerns.
type PRSubjectPayload struct {
Number int `json:"number,omitempty"`
URL string `json:"url,omitempty"`
Draft bool `json:"draft,omitempty"`
}
// IssueSubjectPayload identifies the tracker issue a notification concerns.
type IssueSubjectPayload struct {
ID string `json:"id,omitempty"`
}
// ReactionPayload carries the reaction that produced the notification.
type ReactionPayload struct {
Key string `json:"key"`
Action string `json:"action"`
}
// EscalationPayload carries the escalation that produced the notification.
type EscalationPayload struct {
Attempts int `json:"attempts"`
Cause string `json:"cause"`
DurationMs int64 `json:"durationMs"`
}
// CIPayload is the CI context of a notification.
type CIPayload struct {
Status string `json:"status"`
}
// ReviewPayload is the review context of a notification.
type ReviewPayload struct {
Decision string `json:"decision"`
}
// MergePayload is the merge-readiness context of a notification.
type MergePayload struct {
Ready *bool `json:"ready,omitempty"`
Conflicts *bool `json:"conflicts,omitempty"`
IsBehind *bool `json:"isBehind,omitempty"`
}

View File

@ -1,201 +0,0 @@
package notification
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Reader is the subset of durable state the renderer rehydrates. *sqlite.Store
// satisfies it directly.
type Reader interface {
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error)
}
// Renderer converts lifecycle notification events into durable notification rows.
type Renderer struct {
reader Reader
clock func() time.Time
}
// NewRenderer returns a Renderer that sources session/PR facts via reader.
func NewRenderer(reader Reader) *Renderer {
return &Renderer{reader: reader, clock: time.Now}
}
// Render builds a durable Notification (subject + typed payload) from a
// lifecycle Event.
func (r *Renderer) Render(ctx context.Context, event ports.Event) (domain.Notification, error) {
if event.SessionID == "" {
return domain.Notification{}, fmt.Errorf("render notification: missing session id")
}
rec, ok, err := r.reader.GetSession(ctx, event.SessionID)
if err != nil {
return domain.Notification{}, fmt.Errorf("render notification: get session %s: %w", event.SessionID, err)
}
if !ok {
return domain.Notification{}, fmt.Errorf("render notification: session %s not found", event.SessionID)
}
pr, err := r.reader.PRFactsForSession(ctx, event.SessionID)
if err != nil {
return domain.Notification{}, fmt.Errorf("render notification: pr facts for %s: %w", event.SessionID, err)
}
projectID := event.ProjectID
if projectID == "" {
projectID = rec.ProjectID
}
reaction := reactionPayload(event)
semanticType := SemanticTypeForReaction(reaction.Key)
if semanticType == "" {
semanticType = event.Type
}
payload := Payload{
SchemaVersion: PayloadSchemaVersion,
SemanticType: semanticType,
Subject: SubjectPayload{
Session: &SessionSubjectPayload{ID: string(event.SessionID), ProjectID: string(projectID)},
Branch: rec.Metadata.Branch,
},
Reaction: &reaction,
}
if rec.IssueID != "" {
payload.Subject.Issue = &IssueSubjectPayload{ID: string(rec.IssueID)}
}
if pr.Exists {
payload.Subject.PR = &PRSubjectPayload{Number: pr.Number, URL: pr.URL, Draft: pr.Draft}
if pr.CI != "" {
payload.CI = &CIPayload{Status: string(pr.CI)}
}
if pr.Review != "" {
payload.Review = &ReviewPayload{Decision: string(pr.Review)}
}
payload.Merge = mergePayload(pr.Mergeability)
}
if event.Escalation != nil {
payload.Escalation = &EscalationPayload{
Attempts: event.Escalation.Attempts,
Cause: event.Escalation.Cause,
DurationMs: event.Escalation.DurationMs,
}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return domain.Notification{}, fmt.Errorf("render notification payload: %w", err)
}
occurredAt := event.OccurredAt
if occurredAt.IsZero() {
occurredAt = r.clock().UTC()
}
priority := string(event.Priority)
if priority == "" {
priority = string(ports.PriorityInfo)
}
dedupeKey := event.DedupeKey
if dedupeKey == "" {
dedupeKey = ComputeDedupeKey(event, rec, pr)
}
causeKey := event.CauseKey
if causeKey == "" {
causeKey = reaction.Key
if event.Escalation != nil && event.Escalation.Cause != "" {
causeKey += ":" + event.Escalation.Cause
}
}
return domain.Notification{
ProjectID: projectID,
SessionID: event.SessionID,
Source: "lifecycle",
EventType: event.Type,
SemanticType: semanticType,
Priority: priority,
Message: event.Message,
Payload: payloadJSON,
Actions: actionsFor(projectID, event.SessionID, pr),
DedupeKey: dedupeKey,
CauseKey: causeKey,
CreatedAt: occurredAt,
UpdatedAt: occurredAt,
}, nil
}
func reactionPayload(event ports.Event) ReactionPayload {
key := reactionKeyFromType(event.Type)
action := "notify"
if event.Reaction != nil {
if event.Reaction.Key != "" {
key = event.Reaction.Key
}
if event.Reaction.Action != "" {
action = event.Reaction.Action
}
}
if event.Escalation != nil && event.Reaction == nil {
action = "escalated"
}
return ReactionPayload{Key: key, Action: action}
}
func reactionKeyFromType(t string) string {
if strings.HasPrefix(t, "reaction.") {
return strings.TrimPrefix(t, "reaction.")
}
return t
}
func mergePayload(m domain.Mergeability) *MergePayload {
if m == "" {
return nil
}
ready := m == domain.MergeMergeable
conflicts := m == domain.MergeConflicting
return &MergePayload{Ready: &ready, Conflicts: &conflicts}
}
func actionsFor(projectID domain.ProjectID, sessionID domain.SessionID, pr domain.PRFacts) []domain.NotificationAction {
actions := []domain.NotificationAction{{
ID: "open-session",
Kind: "route",
Label: "Open session",
Route: fmt.Sprintf("/projects/%s/sessions/%s", projectID, sessionID),
}}
if pr.Exists && pr.URL != "" {
actions = append(actions, domain.NotificationAction{ID: "open-pr", Kind: "url", Label: "Open PR", URL: pr.URL})
}
return actions
}
// SemanticTypeForReaction maps internal reaction keys to public semantic types.
func SemanticTypeForReaction(key string) string {
switch key {
case "approved-and-green":
return "merge.ready"
case "agent-stuck":
return "session.stuck"
case "agent-needs-input":
return "session.needs_input"
case "agent-exited":
return "session.exited"
case "pr-closed":
return "pr.closed"
case "pr-merged":
return "pr.merged"
case "ci-failed":
return "ci.failing"
case "review-comments":
return "review.changes_requested"
case "merge-conflicts":
return "merge.conflicts"
default:
return ""
}
}

View File

@ -1,133 +0,0 @@
package notification
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type fakeReader struct {
rec domain.SessionRecord
pr domain.PRFacts
}
func (f fakeReader) GetSession(context.Context, domain.SessionID) (domain.SessionRecord, bool, error) {
return f.rec, true, nil
}
func (f fakeReader) PRFactsForSession(context.Context, domain.SessionID) (domain.PRFacts, error) {
return f.pr, nil
}
func TestSemanticTypeMapping(t *testing.T) {
cases := map[string]string{
"approved-and-green": "merge.ready",
"agent-stuck": "session.stuck",
"agent-needs-input": "session.needs_input",
"agent-exited": "session.exited",
"pr-closed": "pr.closed",
"pr-merged": "pr.merged",
"ci-failed": "ci.failing",
"review-comments": "review.changes_requested",
"merge-conflicts": "merge.conflicts",
}
for key, want := range cases {
if got := SemanticTypeForReaction(key); got != want {
t.Fatalf("SemanticTypeForReaction(%q) = %q, want %q", key, got, want)
}
}
}
func TestRendererPayloadIncludesSessionProjectIssueAndBranch(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord()})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent,
ProjectID: "ao", SessionID: "ao-7", Message: "needs input",
Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"},
OccurredAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.SchemaVersion != 3 || p.SemanticType != "session.needs_input" {
t.Fatalf("payload header = %+v", p)
}
if p.Subject.Session == nil || p.Subject.Session.ID != "ao-7" || p.Subject.Session.ProjectID != "ao" {
t.Fatalf("session subject missing: %+v", p.Subject.Session)
}
if p.Subject.Issue == nil || p.Subject.Issue.ID != "AO-12" || p.Subject.Branch != "feat/example" {
t.Fatalf("issue/branch missing: %+v", p.Subject)
}
}
func TestRendererPRPayloadIncludesFacts(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord(), pr: domain.PRFacts{
Exists: true, URL: "https://github.com/org/repo/pull/12", Number: 12,
CI: domain.CIFailing, Review: domain.ReviewChangesRequest, Mergeability: domain.MergeConflicting,
}})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.review-comments", Priority: ports.PriorityAction,
ProjectID: "ao", SessionID: "ao-7", Message: "review",
Reaction: &ports.ReactionEvent{Key: "review-comments", Action: "notify"},
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.Subject.PR == nil || p.Subject.PR.URL != "https://github.com/org/repo/pull/12" || p.Subject.PR.Number != 12 {
t.Fatalf("pr subject missing: %+v", p.Subject.PR)
}
if p.CI == nil || p.CI.Status != "failing" {
t.Fatalf("ci missing: %+v", p.CI)
}
if p.Review == nil || p.Review.Decision != "changes_requested" {
t.Fatalf("review missing: %+v", p.Review)
}
if p.Merge == nil || p.Merge.Conflicts == nil || *p.Merge.Conflicts != true || p.Merge.Ready == nil || *p.Merge.Ready != false {
t.Fatalf("merge missing: %+v", p.Merge)
}
}
func TestRendererEscalationPayloadIncludesDetails(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord()})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.escalated", Priority: ports.PriorityUrgent,
ProjectID: "ao", SessionID: "ao-7", Message: "escalated",
Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"},
Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries", DurationMs: 42},
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.Reaction == nil || p.Reaction.Key != "ci-failed" || p.Reaction.Action != "escalated" {
t.Fatalf("reaction missing: %+v", p.Reaction)
}
if p.Escalation == nil || p.Escalation.Attempts != 3 || p.Escalation.Cause != "max_retries" || p.Escalation.DurationMs != 42 {
t.Fatalf("escalation missing: %+v", p.Escalation)
}
}
func renderRecord() domain.SessionRecord {
return domain.SessionRecord{
ID: "ao-7",
ProjectID: "ao",
IssueID: "AO-12",
Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionNeedsInput}},
Metadata: domain.SessionMetadata{Branch: "feat/example"},
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
}

View File

@ -1,13 +1,9 @@
// Package reaper implements the OBSERVE-layer polling timer that supplies the
// LCM with the two facts the LCM cannot wake itself to discover: a periodic
// duration-based escalation heartbeat, and per-session runtime liveness probes.
// LCM with per-session runtime liveness probes.
//
// The reaper sits OUTSIDE the LCM's per-session serial loop. It only REPORTS
// facts — it never decides whether a session is "truly" dead. The decider
// (anti-flap Detecting quarantine, terminal-session rules) is owned by the LCM
// and consumes these facts through the regular ApplyRuntimeObservation entry
// point. A probe error is reported as a probe-failure fact, never collapsed to
// "alive" or "dead", so the LCM's failed-probe ≠ dead invariant holds.
// The reaper only reports facts — it never writes session rows directly. The LCM
// consumes these facts through ApplyRuntimeObservation. A probe error is
// reported as a probe-failure fact, never collapsed to "alive" or "dead".
package reaper
import (
@ -23,33 +19,14 @@ import (
// the design doc's 5s sampling window for runtime liveness.
const DefaultTickInterval = 5 * time.Second
// RuntimeRegistry resolves a runtime adapter by the RuntimeName recorded in a
// session's RuntimeHandle. The reaper looks the runtime up per-session so a
// single reaper instance can probe tmux- and zellij-backed sessions side by
// side without knowing about either at construction.
type RuntimeRegistry interface {
Runtime(name string) (ports.Runtime, bool)
}
// MapRegistry is the trivial RuntimeRegistry: a name->runtime map. Callers
// that need dynamic registration can implement RuntimeRegistry themselves.
type MapRegistry map[string]ports.Runtime
// Runtime implements RuntimeRegistry.
func (m MapRegistry) Runtime(name string) (ports.Runtime, bool) {
rt, ok := m[name]
return rt, ok
}
// Config holds the externally-tunable knobs for a Reaper. Every field is
// optional; zero values fall back to safe defaults so production wiring (which
// only needs to inject the LCM and registry) and tests (which inject a clock
// plus a fast tick) can both stay terse.
// optional; zero values fall back to safe defaults so production wiring and
// tests can both stay terse.
type Config struct {
// Tick is the interval between ticks. <=0 means DefaultTickInterval.
Tick time.Duration
// Clock supplies ObservedAt and TickEscalations now stamps. nil means
// time.Now. Injected in tests so assertions don't race wallclock.
// Clock supplies ObservedAt stamps. nil means time.Now. Injected in tests so
// assertions don't race wallclock.
Clock func() time.Time
// Logger receives operational diagnostics (probe errors, skipped sessions,
// LCM call failures). The reaper logs but does not propagate these errors
@ -58,23 +35,36 @@ type Config struct {
Logger *slog.Logger
}
type sessionSource interface {
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
}
type runtimeObservationSink interface {
ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error
}
type runtimeProber interface {
IsAlive(context.Context, ports.RuntimeHandle) (bool, error)
}
// Reaper is the polling timer. Construct it with New; start the background
// goroutine with Start, or drive a single cycle synchronously with Tick.
type Reaper struct {
lcm ports.LifecycleManager
registry RuntimeRegistry
sink runtimeObservationSink
sessions sessionSource
runtime runtimeProber
tick time.Duration
clock func() time.Time
logger *slog.Logger
}
// New constructs a Reaper. The LCM is the sole writer destination (the reaper
// reports facts via ApplyRuntimeObservation and TickEscalations); the registry
// resolves the runtime adapter to use per session.
func New(lcm ports.LifecycleManager, registry RuntimeRegistry, cfg Config) *Reaper {
// New constructs a Reaper. sink is the lifecycle fact destination; sessions
// supplies the rows to probe; runtime checks whether a stored handle is alive.
func New(sink runtimeObservationSink, sessions sessionSource, runtime runtimeProber, cfg Config) *Reaper {
r := &Reaper{
lcm: lcm,
registry: registry,
sink: sink,
sessions: sessions,
runtime: runtime,
tick: cfg.Tick,
clock: cfg.Clock,
logger: cfg.Logger,
@ -117,35 +107,27 @@ func (r *Reaper) loop(ctx context.Context, done chan<- struct{}) {
}
}
// Tick runs one observation cycle: it always fires TickEscalations first (the
// duration-based escalation heartbeat, which the synchronous LCM cannot wake
// itself to drive), then enumerates the LCM's running sessions, probes each
// one's runtime, and reports any non-alive result back as a fact.
// Tick runs one observation cycle: it enumerates non-terminated sessions,
// probes each one's runtime, and reports each result back as a fact.
//
// Tick is exported so the daemon (and tests) can drive cycles synchronously,
// and so the Start goroutine has a single chokepoint to log against.
//
// Errors: only the RunningSessions failure is propagated, since it short-
// circuits the rest of the cycle. TickEscalations and per-session
// ApplyRuntimeObservation failures are logged but never propagated — one
// failed call must not bring down the loop.
// Errors: only the session-listing failure is propagated, since it short-
// circuits the rest of the cycle. Per-session ApplyRuntimeObservation failures
// are logged but never propagated — one failed call must not bring down the loop.
func (r *Reaper) Tick(ctx context.Context) error {
now := r.clock()
// Heartbeat is best-effort and runs before enumeration so duration-based
// escalations still fire if the running-set lookup is the thing that
// errored. The LCM's TickEscalations is itself idempotent (no canonical
// writes) — at worst we miss escalating once and pick it up next tick.
if err := r.lcm.TickEscalations(ctx, now); err != nil {
r.logger.Error("reaper: TickEscalations failed", "err", err)
}
sessions, err := r.lcm.RunningSessions(ctx)
sessions, err := r.sessions.ListAllSessions(ctx)
if err != nil {
return err
}
for _, sess := range sessions {
if sess.IsTerminated {
continue
}
r.probeOne(ctx, sess, now)
}
return nil
@ -153,62 +135,47 @@ func (r *Reaper) Tick(ctx context.Context) error {
// probeOne handles a single session's probe + fact-report. Every probe result —
// alive, dead, or failed — is reported as a fact to the LCM. The reaper does
// not optimize away the "alive" case, because a session in Detecting (whose
// runtime axis is NOT alive) is included in the running set and needs the
// alive probe to recover; the reaper has no business deciding what counts as
// a no-op. The LCM's ApplyRuntimeObservation diffs against canonical and
// only Upserts on actual change, so steady-state alive is already cheap.
// not optimize away the "alive" case; the reaper has no business deciding what
// counts as a no-op. The LCM diffs and only writes on actual change.
func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now time.Time) {
handle, ok := handleFromRecord(sess)
if !ok {
// A session in the running-set without a handle is an anomaly worth
// surfacing (OnSpawnCompleted should have set both keys). Warn rather
// surfacing (MarkSpawned should have set both keys). Warn rather
// than Debug so it doesn't hide behind a noisy log level.
r.logger.Warn("reaper: session has no runtime handle metadata, skipping",
"session", sess.ID)
return
}
rt, ok := r.registry.Runtime(handle.RuntimeName)
if !ok {
r.logger.Warn("reaper: no runtime registered for session, skipping",
"session", sess.ID, "runtime", handle.RuntimeName)
return
}
alive, probeErr := rt.IsAlive(ctx, handle)
alive, probeErr := r.runtime.IsAlive(ctx, handle)
facts := ports.RuntimeFacts{ObservedAt: now}
switch {
case probeErr != nil:
// Failed probe must NOT be collapsed to alive — that would let a
// transient tmux/zellij outage hide a really-dead session, and a
// transient Zellij outage hide a really-dead session, and a
// transient adapter bug terminate a really-alive one. Report failed
// and let the LCM's detecting quarantine arbitrate.
facts.Runtime = ports.ProbeFailed
facts.Process = ports.ProbeFailed
// and let the LCM arbitrate.
facts.Probe = ports.ProbeFailed
r.logger.Debug("reaper: probe error reported as failed fact",
"session", sess.ID, "runtime", handle.RuntimeName, "err", probeErr)
"session", sess.ID, "err", probeErr)
case alive:
facts.Runtime = ports.ProbeAlive
facts.Process = ports.ProbeAlive
facts.Probe = ports.ProbeAlive
default:
facts.Runtime = ports.ProbeDead
facts.Process = ports.ProbeDead
facts.Probe = ports.ProbeDead
}
if err := r.lcm.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil {
if err := r.sink.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil {
r.logger.Error("reaper: ApplyRuntimeObservation failed",
"session", sess.ID, "err", err)
}
}
// handleFromRecord reconstructs the RuntimeHandle stored on the session by
// OnSpawnCompleted. Both fields are required; either being empty is the
// "session lacks a probable handle" signal that probeOne uses to skip.
// MarkSpawned. An empty handle id means the session cannot be probed.
func handleFromRecord(rec domain.SessionRecord) (ports.RuntimeHandle, bool) {
id := rec.Metadata.RuntimeHandleID
name := rec.Metadata.RuntimeName
if id == "" || name == "" {
if id == "" {
return ports.RuntimeHandle{}, false
}
return ports.RuntimeHandle{ID: id, RuntimeName: name}, true
return ports.RuntimeHandle{ID: id}, true
}

View File

@ -6,7 +6,6 @@ import (
"io"
"log/slog"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
@ -15,14 +14,9 @@ import (
var ctx = context.Background()
type fakeLCM struct {
running []domain.SessionRecord
observed map[domain.SessionID]ports.RuntimeFacts
escalated int
observed map[domain.SessionID]ports.RuntimeFacts
}
func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) {
return l.running, nil
}
func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID, f ports.RuntimeFacts) error {
if l.observed == nil {
l.observed = map[domain.SessionID]ports.RuntimeFacts{}
@ -30,18 +24,11 @@ func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID
l.observed[id] = f
return nil
}
func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { l.escalated++; return nil }
func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error {
return nil
}
func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error {
return nil
}
func (l *fakeLCM) OnSpawnCompleted(context.Context, domain.SessionID, ports.SpawnOutcome) error {
return nil
}
func (l *fakeLCM) OnKillRequested(context.Context, domain.SessionID, domain.TerminationReason) error {
return nil
type fakeSessions struct{ rows []domain.SessionRecord }
func (s fakeSessions) ListAllSessions(context.Context) ([]domain.SessionRecord, error) {
return s.rows, nil
}
type fakeRuntime struct {
@ -49,10 +36,6 @@ type fakeRuntime struct {
err error
}
func (r fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) {
return ports.RuntimeHandle{}, nil
}
func (r fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil }
func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) {
return r.alive, r.err
}
@ -60,53 +43,57 @@ func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error)
func probableSession(id domain.SessionID) domain.SessionRecord {
return domain.SessionRecord{
ID: id,
Metadata: domain.SessionMetadata{RuntimeHandleID: "h1", RuntimeName: "tmux"},
Lifecycle: domain.CanonicalSessionLifecycle{
Session: domain.SessionSubstate{State: domain.SessionWorking},
},
Activity: domain.ActivitySubstate{State: domain.ActivityActive},
Metadata: domain.SessionMetadata{RuntimeHandleID: "h1"},
}
}
func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func newReaper(lcm *fakeLCM, rt fakeRuntime) *Reaper {
return New(lcm, MapRegistry{"tmux": rt}, Config{Logger: quietLogger()})
func newReaper(lcm *fakeLCM, sessions fakeSessions, rt fakeRuntime) *Reaper {
return New(lcm, sessions, rt, Config{Logger: quietLogger()})
}
func TestTick_ReportsAliveProbe(t *testing.T) {
lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}}
if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil {
lcm := &fakeLCM{}
sessions := fakeSessions{rows: []domain.SessionRecord{probableSession("mer-1")}}
if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil {
t.Fatal(err)
}
if lcm.observed["mer-1"].Runtime != ports.ProbeAlive {
t.Fatalf("want alive probe, got %q", lcm.observed["mer-1"].Runtime)
if lcm.observed["mer-1"].Probe != ports.ProbeAlive {
t.Fatalf("want alive probe, got %q", lcm.observed["mer-1"].Probe)
}
}
func TestTick_ReportsProbeErrorAsFailed(t *testing.T) {
lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}}
if err := newReaper(lcm, fakeRuntime{err: errors.New("tmux gone")}).Tick(ctx); err != nil {
lcm := &fakeLCM{}
sessions := fakeSessions{rows: []domain.SessionRecord{probableSession("mer-1")}}
if err := newReaper(lcm, sessions, fakeRuntime{err: errors.New("Zellij gone")}).Tick(ctx); err != nil {
t.Fatal(err)
}
if lcm.observed["mer-1"].Runtime != ports.ProbeFailed {
t.Fatalf("probe error must be reported as failed, got %q", lcm.observed["mer-1"].Runtime)
if lcm.observed["mer-1"].Probe != ports.ProbeFailed {
t.Fatalf("probe error must be reported as failed, got %q", lcm.observed["mer-1"].Probe)
}
}
func TestTick_FiresEscalationHeartbeat(t *testing.T) {
func TestTick_SkipsTerminatedSession(t *testing.T) {
lcm := &fakeLCM{}
if err := newReaper(lcm, fakeRuntime{}).Tick(ctx); err != nil {
dead := probableSession("mer-1")
dead.IsTerminated = true
sessions := fakeSessions{rows: []domain.SessionRecord{dead}}
if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil {
t.Fatal(err)
}
if lcm.escalated != 1 {
t.Fatalf("tick must drive TickEscalations once, got %d", lcm.escalated)
if _, probed := lcm.observed["mer-1"]; probed {
t.Fatal("terminated sessions must not be probed")
}
}
func TestTick_SkipsSessionWithoutHandle(t *testing.T) {
lcm := &fakeLCM{}
noHandle := domain.SessionRecord{ID: "mer-1"} // no runtime metadata
lcm := &fakeLCM{running: []domain.SessionRecord{noHandle}}
if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil {
sessions := fakeSessions{rows: []domain.SessionRecord{noHandle}}
if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil {
t.Fatal(err)
}
if _, probed := lcm.observed["mer-1"]; probed {

View File

@ -0,0 +1,5 @@
// Package ports declares boundary interfaces and DTOs used to connect core
// services to replaceable adapters such as runtimes, workspaces, trackers, and
// storage writers. Domain models stay in internal/domain; generated storage rows
// stay inside storage packages.
package ports

View File

@ -1,69 +0,0 @@
// Package ports declares the boundary contracts for the lifecycle lane: the
// inbound interfaces the engine implements, the outbound interfaces its adapters
// implement, and the plain DTOs that cross those edges. It holds no logic.
package ports
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// ProbeResult is a single liveness reading. "failed" (the probe errored/timed
// out) and "unknown" (ran but couldn't tell) are kept distinct from dead — both
// route to the detecting quarantine, never to a death conclusion.
type ProbeResult string
// Probe readings. Alive/Dead are conclusions; Failed/Unknown route to the
// detecting quarantine instead of a death decision.
const (
ProbeAlive ProbeResult = "alive"
ProbeDead ProbeResult = "dead"
ProbeFailed ProbeResult = "failed"
ProbeUnknown ProbeResult = "unknown"
)
// RuntimeFacts is what the reaper reports each probe: is the runtime container
// up, and is the agent process inside it up.
type RuntimeFacts struct {
ObservedAt time.Time
Runtime ProbeResult
Process ProbeResult
}
// ActivitySignal is pushed by the agent hooks. Only a Valid signal is
// authoritative; a stale/absent one is ignored rather than read as idleness.
type ActivitySignal struct {
Valid bool
State domain.ActivityState
Timestamp time.Time
Source domain.ActivitySource
}
// PRObservation is what the SCM poller reports for one PR. Fetched is the
// failed-fetch guard: when false the rest is meaningless and the engine must not
// read it as "PR closed". Checks/Comments are the current full sets (the engine
// records the checks and replaces the comment set).
type PRObservation struct {
Fetched bool
URL string
Number int
Draft bool
Merged bool
Closed bool
CI domain.CIState
Review domain.ReviewDecision
Mergeability domain.Mergeability
Checks []domain.PRCheckRow
Comments []domain.PRComment
}
// SpawnOutcome is what the Session Manager reports once a spawn is live: the
// handles needed for later teardown/restore.
type SpawnOutcome struct {
Branch string
WorkspacePath string
RuntimeHandle RuntimeHandle
AgentSessionID string
Prompt string
}

View File

@ -1,53 +0,0 @@
package ports
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// LifecycleManager is the inbound contract the engine implements. Observers
// (reaper, SCM poller, activity hooks) and the Session Manager call in; the LCM
// is the sole writer of canonical transitions and the only place reactions fire.
type LifecycleManager interface {
ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f RuntimeFacts) error
ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ActivitySignal) error
ApplyPRObservation(ctx context.Context, id domain.SessionID, o PRObservation) error
// OnSpawnCompleted marks a session live and records its handles. It works for
// a fresh spawn (not_started -> live) and a restore (terminal -> reopened).
OnSpawnCompleted(ctx context.Context, id domain.SessionID, o SpawnOutcome) error
OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error
// TickEscalations fires the duration-based escalations the synchronous LCM
// can't wake itself for; the reaper calls it on a timer.
TickEscalations(ctx context.Context, now time.Time) error
// RunningSessions snapshots every non-terminal session for the reaper to probe.
RunningSessions(ctx context.Context) ([]domain.SessionRecord, error)
}
// SessionManager is the inbound contract the API/CLI call for explicit
// mutations. It drives the runtime/agent/workspace plugins and routes canonical
// writes to the LCM.
type SessionManager interface {
Spawn(ctx context.Context, cfg SpawnConfig) (domain.Session, error)
Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (freed bool, err error)
Restore(ctx context.Context, id domain.SessionID) (domain.Session, error)
List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error)
Get(ctx context.Context, id domain.SessionID) (domain.Session, error)
Send(ctx context.Context, id domain.SessionID, message string) error
Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error)
}
// SpawnConfig is the request to start a new session: which project/issue, which
// agent harness, and the branch/prompt/rules the agent launches with.
type SpawnConfig struct {
ProjectID domain.ProjectID
IssueID domain.IssueID
Kind domain.SessionKind
Harness domain.AgentHarness
Branch string
Prompt string
AgentRules string
}

View File

@ -2,95 +2,28 @@ package ports
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// SessionStore persists session records and serves the derived read-model's PR
// facts. The Session Manager creates rows; the Lifecycle Manager is the sole
// writer of canonical transitions thereafter.
type SessionStore interface {
CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error)
UpdateSession(ctx context.Context, rec domain.SessionRecord) error
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
// PRFactsForSession returns the PR facts that drive a session's display
// status: the most-recently-updated non-closed PR, else the most recent.
// Zero value (Exists=false) means the session has no PR.
PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error)
}
// PRWriter records the PR facts a PR observation carries. The pr table's own DB
// triggers emit the CDC; this just writes the rows.
type PRWriter interface {
// WritePR persists a full PR observation — scalar facts, check runs, and the
// replacement comment set — in one transaction, so the rows and the CDC
// events they emit are all-or-nothing.
WritePR(ctx context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error
// RecentCheckStatuses reads the last `limit` runs of a check (the CI brake).
RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error)
WritePR(ctx context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, comments []domain.PullRequestComment) error
}
// Notifier delivers an event to the human (desktop/Slack later). Push, never poll.
type Notifier interface {
Notify(ctx context.Context, event Event) error
}
// AgentMessenger injects a message into a running agent (busy-detecting until the
// agent is ready). Used by the auto-nudge reactions.
// AgentMessenger injects a message into a running agent.
type AgentMessenger interface {
Send(ctx context.Context, id domain.SessionID, message string) error
}
// Priority ranks a notification's urgency so a notifier can decide how loudly
// to surface it, from PriorityUrgent down to PriorityInfo.
type Priority string
// ---- runtime / agent / workspace plugin ports ----
// Notification priorities, highest urgency first.
const (
PriorityUrgent Priority = "urgent"
PriorityAction Priority = "action"
PriorityWarning Priority = "warning"
PriorityInfo Priority = "info"
)
// Event is a human-facing notification produced by a reaction. It carries the
// stable reaction/escalation context a durable notification renderer needs,
// while lifecycle remains responsible for deciding what should notify.
type Event struct {
Type string
Priority Priority
SessionID domain.SessionID
ProjectID domain.ProjectID
Message string
Reaction *ReactionEvent
Escalation *EscalationEvent
DedupeKey string
CauseKey string
OccurredAt time.Time
}
// ReactionEvent is the reaction context carried on an Event: which reaction
// fired and whether it merely notified or escalated.
type ReactionEvent struct {
Key string // agent-needs-input, approved-and-green, ci-failed, etc.
Action string // notify | escalated
}
// EscalationEvent is the escalation context carried on an Event once a reaction
// has exhausted its retry/attempt/duration budget.
type EscalationEvent struct {
Attempts int
Cause string // max_retries | max_attempts | max_duration
DurationMs int64
}
// ---- runtime / agent / workspace plugin ports (used by the Session Manager) ----
// Runtime is where a session's agent process runs — a tmux/zellij session or a
// bare process. The Session Manager creates one per session and tears it down.
// Runtime is the full runtime adapter contract: session creation/teardown plus
// liveness probing for reapers and terminal attachment.
type Runtime interface {
Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error)
Destroy(ctx context.Context, handle RuntimeHandle) error
@ -105,10 +38,10 @@ type RuntimeConfig struct {
Env map[string]string
}
// RuntimeHandle identifies a live runtime instance (e.g. a tmux session).
// RuntimeHandle identifies a live runtime instance. Its ID is opaque outside
// the concrete runtime adapter.
type RuntimeHandle struct {
ID string
RuntimeName string
ID string
}
// Agent is the AI coding tool driving a session (claude-code, codex, …): it

View File

@ -0,0 +1,40 @@
package ports
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// PRObservation is what the SCM poller reports for one PR. Fetched is the
// failed-fetch guard: when false the rest is meaningless and lifecycle must not
// read it as "PR closed". Checks/Comments are observation DTOs, not persistence
// rows; the PR Manager owns mapping them into stored domain.PullRequest rows.
type PRObservation struct {
Fetched bool
URL string
Number int
Draft bool
Merged bool
Closed bool
CI domain.CIState
Review domain.ReviewDecision
Mergeability domain.Mergeability
Checks []PRCheckObservation
Comments []PRCommentObservation
}
// PRCheckObservation is one SCM check result on the observed PR.
type PRCheckObservation struct {
Name string
CommitHash string
Status domain.PRCheckStatus
URL string
LogTail string
}
// PRCommentObservation is one review comment observed on the PR.
type PRCommentObservation struct {
ID string
Author string
File string
Line int
Body string
Resolved bool
}

View File

@ -0,0 +1,34 @@
package ports
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// ProbeResult is a single liveness reading. "failed" means the probe errored
// or timed out and is never treated as a death conclusion.
type ProbeResult string
// Probe readings. Alive/Dead are conclusions; Failed is ignored by lifecycle
// because it is not a reliable death decision.
const (
ProbeAlive ProbeResult = "alive"
ProbeDead ProbeResult = "dead"
ProbeFailed ProbeResult = "failed"
)
// RuntimeFacts is what the reaper reports each probe of a session runtime.
type RuntimeFacts struct {
ObservedAt time.Time
Probe ProbeResult
}
// ActivitySignal is pushed by the agent hooks. Only a Valid signal is
// authoritative; a stale/absent one is ignored rather than read as idleness.
type ActivitySignal struct {
Valid bool
State domain.ActivityState
Timestamp time.Time
Source domain.ActivitySource
}

View File

@ -0,0 +1,15 @@
package ports
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// SpawnConfig is the request to start a new session: which project/issue, which
// agent harness, and the branch/prompt/rules the agent launches with.
type SpawnConfig struct {
ProjectID domain.ProjectID
IssueID domain.IssueID
Kind domain.SessionKind
Harness domain.AgentHarness
Branch string
Prompt string
AgentRules string
}

View File

@ -6,8 +6,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Tracker is the outbound port for issue trackers (GitHub Issues, GitLab
// Issues, Linear). v1 is read-only:
// Tracker is the outbound read-only port for issue trackers:
//
// - Get returns a normalized snapshot of one issue, used by spawn-bootstrap
// to hydrate the agent prompt.
@ -16,13 +15,8 @@ import (
// - Preflight verifies the configured credential is actually valid against
// the provider so daemons fail fast at startup, not at first request.
//
// Mirroring agent lifecycle back onto the tracker (Comment, Transition) is
// deferred to issue #40. The observer / polling loop is deferred to #35.
//
// All v1 providers share this interface. Provider differences (label vs
// state machine vs close reason) are absorbed inside each adapter via
// domain.NormalizedIssueState. Fields on domain.Issue exist only when every
// provider can populate them; richer per-provider metadata belongs behind a
// Provider differences are absorbed inside each adapter via
// domain.NormalizedIssueState. Richer per-provider metadata belongs behind a
// separate port.
type Tracker interface {
Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error)

View File

@ -0,0 +1,67 @@
// Package pr records SCM observations for pull requests associated with sessions.
package pr
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type lifecycle interface {
ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error
}
// Manager persists PR observations and forwards them to lifecycle for agent
// nudges and direct lifecycle effects.
type Manager struct {
writer ports.PRWriter
lifecycle lifecycle
clock func() time.Time
}
// Deps are the collaborators a PR Manager needs.
type Deps struct {
Writer ports.PRWriter
Lifecycle lifecycle
Clock func() time.Time
}
// New builds a PR Manager from its dependencies, defaulting the clock to time.Now.
func New(d Deps) *Manager {
m := &Manager{writer: d.Writer, lifecycle: d.Lifecycle, clock: d.Clock}
if m.clock == nil {
m.clock = time.Now
}
return m
}
// ApplyObservation records a successfully fetched PR observation. Failed fetches
// are ignored because their fields are not authoritative facts.
func (m *Manager) ApplyObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
if !o.Fetched {
return nil
}
if err := m.write(ctx, id, o); err != nil {
return err
}
if m.lifecycle == nil {
return nil
}
return m.lifecycle.ApplyPRObservation(ctx, id, o)
}
func (m *Manager) write(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
now := m.clock()
row := domain.PullRequest{URL: o.URL, SessionID: id, Number: o.Number, Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now}
checks := make([]domain.PullRequestCheck, len(o.Checks))
for i, c := range o.Checks {
checks[i] = domain.PullRequestCheck{Name: c.Name, CommitHash: c.CommitHash, Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: now}
}
comments := make([]domain.PullRequestComment, len(o.Comments))
for i, c := range o.Comments {
comments[i] = domain.PullRequestComment{ID: c.ID, Author: c.Author, File: c.File, Line: c.Line, Body: c.Body, Resolved: c.Resolved, CreatedAt: now}
}
return m.writer.WritePR(ctx, row, checks, comments)
}

View File

@ -0,0 +1,87 @@
package pr
import (
"context"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type fakeWriter struct {
pr map[domain.SessionID]domain.PullRequest
comments map[string][]domain.PullRequestComment
checks []domain.PullRequestCheck
}
func (f *fakeWriter) WritePR(_ context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, comments []domain.PullRequestComment) error {
f.pr[pr.SessionID] = pr
f.checks = append(f.checks, checks...)
f.comments[pr.URL] = comments
return nil
}
type fakeLifecycle struct {
observed []ports.PRObservation
}
func (f *fakeLifecycle) ApplyPRObservation(_ context.Context, _ domain.SessionID, o ports.PRObservation) error {
f.observed = append(f.observed, o)
return nil
}
func newPRManager() (*Manager, *fakeWriter, *fakeLifecycle) {
fw := &fakeWriter{pr: map[domain.SessionID]domain.PullRequest{}, comments: map[string][]domain.PullRequestComment{}}
fl := &fakeLifecycle{}
m := New(Deps{
Writer: fw,
Lifecycle: fl,
Clock: func() time.Time { return time.Unix(1, 0).UTC() },
})
return m, fw, fl
}
func TestApplyObservation_WritesPRChecksAndComments(t *testing.T) {
m, fw, fl := newPRManager()
o := ports.PRObservation{
Fetched: true, URL: "https://example/pr/1", Number: 1, CI: domain.CIFailing,
Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}},
Comments: []ports.PRCommentObservation{{ID: "1", Author: "greptileai", Body: "use a constant here"}},
}
if err := m.ApplyObservation(context.Background(), "mer-1", o); err != nil {
t.Fatal(err)
}
if got := fw.pr["mer-1"]; got.URL != o.URL || got.CI != domain.CIFailing {
t.Fatalf("pr not written: %+v", got)
}
if len(fw.checks) != 1 || fw.checks[0].CreatedAt.IsZero() {
t.Fatalf("checks not normalized: %+v", fw.checks)
}
if len(fw.comments[o.URL]) != 1 || fw.comments[o.URL][0].CreatedAt.IsZero() {
t.Fatalf("comments not normalized: %+v", fw.comments)
}
if len(fl.observed) != 1 || fl.observed[0].URL != o.URL {
t.Fatalf("PR observation should be forwarded to lifecycle, got %v", fl.observed)
}
}
func TestApplyObservation_MergedForwardsToLifecycle(t *testing.T) {
m, _, fl := newPRManager()
if err := m.ApplyObservation(context.Background(), "mer-1", ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, Merged: true}); err != nil {
t.Fatal(err)
}
if len(fl.observed) != 1 || !fl.observed[0].Merged {
t.Fatalf("merged PR should be forwarded to lifecycle, got %v", fl.observed)
}
}
func TestApplyObservation_FailedFetchIsDropped(t *testing.T) {
m, fw, fl := newPRManager()
if err := m.ApplyObservation(context.Background(), "mer-1", ports.PRObservation{Fetched: false, URL: "pr1", CI: domain.CIFailing}); err != nil {
t.Fatal(err)
}
if len(fw.pr) != 0 || len(fl.observed) != 0 {
t.Fatalf("failed fetch must write nothing, pr=%v observed=%v", fw.pr, fl.observed)
}
}

View File

@ -0,0 +1,20 @@
//go:build !windows
// Package processalive probes whether an operating-system process id still
// maps to a live process.
package processalive
import (
"errors"
"syscall"
)
// Alive reports whether pid exists. EPERM counts as alive: the process exists
// even if the current user cannot signal it.
func Alive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}

View File

@ -0,0 +1,30 @@
//go:build windows
// Package processalive probes whether an operating-system process id still
// maps to a live process.
package processalive
import (
"errors"
"golang.org/x/sys/windows"
)
// Alive reports whether pid exists. Access denied counts as alive: the process
// exists even if the current user cannot wait on it.
func Alive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
return errors.Is(err, windows.ERROR_ACCESS_DENIED)
}
defer windows.CloseHandle(handle)
status, err := windows.WaitForSingleObject(handle, 0)
if err != nil {
return false
}
return status == uint32(windows.WAIT_TIMEOUT)
}

View File

@ -30,11 +30,9 @@ type AddInput struct {
// behaviour fields are mutable; identity fields (projectId, path, repo,
// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN.
type UpdateConfigInput struct {
Agent *string `json:"agent,omitempty"`
Runtime *string `json:"runtime,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
Reactions *map[string]*ReactionConfig `json:"reactions,omitempty"`
Agent *string `json:"agent,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
}
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.

View File

@ -18,8 +18,8 @@ type Row struct {
ArchivedAt time.Time
}
// Store is the project persistence the manager depends on; both the sqlite
// store and MemoryStore satisfy it.
// Store is the project persistence the manager depends on. MemoryStore is the
// current in-process implementation; the sqlite adapter uses the same row shape.
type Store interface {
List(ctx context.Context) ([]Row, error)
Get(ctx context.Context, id string) (Row, bool, error)

View File

@ -2,12 +2,9 @@
// the HTTP layer calls and the request/response DTOs that cross it (dto.go).
//
// This is the pilot for the feature-package layout the backend is migrating
// toward: a resource's interface and DTOs live with the resource, not in a
// central catch-all. Controllers depend on project.Manager and nothing
// beneath it — whether the implementation reaches into the config registry,
// the lifecycle manager (to stop sessions on remove), or a workspace adapter
// (to destroy worktrees) is a private concern of the impl, which lands in a
// later handler-impl PR. This PR defines only the contract.
// toward: a resource's interface, implementation, and DTOs live with the
// resource, not in a central catch-all. Controllers depend on project.Manager
// and nothing beneath it.
package project
import (
@ -17,7 +14,7 @@ import (
)
// Manager is the inbound contract for the /api/v1/projects surface. One
// implementation (this package, later); the HTTP controller is the consumer.
// implementation lives in this package; the HTTP controller is the consumer.
type Manager interface {
// List returns every registered project, including degraded entries
// (those whose config failed to load but whose registry entry survives).

View File

@ -10,11 +10,9 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// transport DTOs (dto.go) together is the feature-package layout the backend
// is migrating toward.
// Summary is the row shape returned by GET /api/v1/projects. It mirrors the TS
// ProjectInfo (packages/web/src/lib/project-name.ts) so the existing dashboard
// list view reads the Go daemon's response unchanged. ResolveError is set only
// for degraded projects (registry entry survives but config failed to load),
// so the list shows them with a warning instead of dropping them silently.
// Summary is the row shape returned by GET /api/v1/projects. ResolveError is
// set only for degraded projects, so the list can show them with a warning
// instead of dropping them silently.
type Summary struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
@ -26,16 +24,14 @@ type Summary struct {
// project resolves cleanly. It joins the registry identity fields with the
// project's behaviour config.
type Project struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Repo string `json:"repo"` // "owner/name" or ""
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent,omitempty"`
Runtime string `json:"runtime,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
Reactions map[string]*ReactionConfig `json:"reactions,omitempty"`
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Repo string `json:"repo"` // "owner/name" or ""
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
}
// Degraded is returned in place of Project when the project's config failed to
@ -49,11 +45,9 @@ type Degraded struct {
ResolveError string `json:"resolveError"`
}
// Behaviour-config shapes ported from the TS Zod schemas (packages/core/src/
// config.ts). Only the fields the projects API actually exposes are modelled;
// the passthrough/unknown-key round-trip the legacy schemas allowed lands with
// the handler implementation (and the SQLite persistence work), not in this
// interface-only PR.
// Behaviour-config shapes exposed by the projects API. Runtime selection and
// reaction rules are intentionally absent: the daemon has one runtime adapter and
// lifecycle owns agent nudges.
// TrackerConfig mirrors TrackerConfigSchema.
type TrackerConfig struct {
@ -80,17 +74,3 @@ type SCMWebhookConfig struct {
DeliveryHeader string `json:"deliveryHeader,omitempty"`
MaxBodyBytes int `json:"maxBodyBytes,omitempty"`
}
// ReactionConfig mirrors ReactionConfigSchema. EscalateAfter is either ms
// (number) or a duration string ("30m") in the TS schema, so it stays open as
// `any` until handler validation lands.
type ReactionConfig struct {
Auto *bool `json:"auto,omitempty"`
Action string `json:"action,omitempty"` // send-to-agent | notify | auto-merge
Message string `json:"message,omitempty"`
Priority string `json:"priority,omitempty"` // urgent | action | warning | info
Retries *int `json:"retries,omitempty"`
EscalateAfter any `json:"escalateAfter,omitempty"`
Threshold string `json:"threshold,omitempty"`
IncludeSummary *bool `json:"includeSummary,omitempty"`
}

View File

@ -1,24 +0,0 @@
//go:build unix
package runfile
import (
"errors"
"os"
"syscall"
)
// processAlive probes existence with signal 0: kill(pid, 0) returns nil if the
// process exists and we can signal it, EPERM if it exists but is owned by
// another user, and ESRCH (or any other error from FindProcess) if it is gone.
func processAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
if err == nil {
return true
}
return errors.Is(err, syscall.EPERM)
}

View File

@ -1,21 +0,0 @@
//go:build windows
package runfile
import (
"syscall"
)
// processAlive opens the process with the minimum-rights query flag. On
// Windows, OpenProcess returns ERROR_INVALID_PARAMETER for a PID that no
// longer maps to a live process, and a usable handle when one is. We close
// the handle immediately; the only thing we needed was the open's outcome.
func processAlive(pid int) bool {
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
_ = syscall.CloseHandle(h)
return true
}

View File

@ -12,6 +12,8 @@ import (
"os"
"path/filepath"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/processalive"
)
// Info is the on-disk handshake payload.
@ -86,6 +88,20 @@ func Remove(path string) error {
return nil
}
// RemoveIfOwned deletes running.json only if it still belongs to ownerPID. This
// prevents a shutting-down daemon from removing a successor's freshly written
// handshake after an overlapping restart.
func RemoveIfOwned(path string, ownerPID int) error {
info, err := Read(path)
if err != nil {
return err
}
if info == nil || info.PID != ownerPID {
return nil
}
return Remove(path)
}
// CheckStale inspects an existing run-file before the new daemon binds. It
// returns:
//
@ -104,7 +120,7 @@ func CheckStale(path string) (*Info, error) {
if info == nil || info.PID <= 0 {
return nil, nil
}
if processAlive(info.PID) {
if processalive.Alive(info.PID) {
return info, nil
}
return nil, nil

View File

@ -75,6 +75,32 @@ func TestRemoveIdempotent(t *testing.T) {
}
}
func TestRemoveIfOwnedDoesNotDeleteSuccessorRunfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "running.json")
if err := Write(path, Info{PID: 1, Port: 3001}); err != nil {
t.Fatalf("Write predecessor: %v", err)
}
if err := Write(path, Info{PID: 2, Port: 3002}); err != nil {
t.Fatalf("Write successor: %v", err)
}
if err := RemoveIfOwned(path, 1); err != nil {
t.Fatalf("RemoveIfOwned predecessor: %v", err)
}
got, err := Read(path)
if err != nil {
t.Fatalf("Read: %v", err)
}
if got == nil || got.PID != 2 || got.Port != 3002 {
t.Fatalf("successor runfile was removed or changed: %+v", got)
}
if err := RemoveIfOwned(path, 2); err != nil {
t.Fatalf("RemoveIfOwned successor: %v", err)
}
if got, err := Read(path); err != nil || got != nil {
t.Fatalf("after owner removal got=%+v err=%v", got, err)
}
}
func TestCheckStaleDeadPID(t *testing.T) {
path := filepath.Join(t.TempDir(), "running.json")
// PID 0x7FFFFFFF is effectively guaranteed not to exist.

View File

@ -1,7 +1,6 @@
// Package session implements ports.SessionManager: the explicit-mutation half of
// the lane. It drives the runtime/agent/workspace plugins to create and tear
// down sessions, routes canonical writes to the LCM, and is the single producer
// of the derived display status (attached on read in List/Get).
// Package session drives the runtime/agent/workspace plugins to create and tear
// down sessions, routes durable lifecycle fact writes through lifecycle, and
// attaches derived display status on read.
package session
import (
@ -28,27 +27,43 @@ const (
EnvIssueID = "AO_ISSUE_ID"
)
// Manager implements ports.SessionManager over the outbound ports.
type lifecycleRecorder interface {
MarkSpawned(ctx context.Context, id domain.SessionID, metadata domain.SessionMetadata) error
MarkTerminated(ctx context.Context, id domain.SessionID) error
}
type runtimeController interface {
Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error)
Destroy(ctx context.Context, handle ports.RuntimeHandle) error
}
type sessionStore interface {
CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error)
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
}
// Manager coordinates session spawn, restore, kill, listing, and cleanup over
// the outbound ports.
type Manager struct {
runtime ports.Runtime
runtime runtimeController
agent ports.Agent
workspace ports.Workspace
store ports.SessionStore
store sessionStore
messenger ports.AgentMessenger
lcm ports.LifecycleManager
lcm lifecycleRecorder
clock func() time.Time
}
var _ ports.SessionManager = (*Manager)(nil)
// Deps are the collaborators a Session Manager needs; New wires them together.
type Deps struct {
Runtime ports.Runtime
Runtime runtimeController
Agent ports.Agent
Workspace ports.Workspace
Store ports.SessionStore
Store sessionStore
Messenger ports.AgentMessenger
Lifecycle ports.LifecycleManager
Lifecycle lifecycleRecorder
Clock func() time.Time
}
@ -72,7 +87,7 @@ func New(d Deps) *Manager {
// Spawn creates the session row (which assigns the "{project}-{n}" id), then the
// workspace and runtime, then reports completion to the LCM. A failure after the
// row exists routes it to a terminal errored state and rolls back what was built.
// row exists parks it as terminated and rolls back what was built.
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock()))
if err != nil {
@ -82,7 +97,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch})
if err != nil {
m.markErrored(ctx, id)
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
}
@ -95,30 +110,30 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markErrored(ctx, id)
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
}
outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, Prompt: agentCfg.Prompt}
if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil {
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt}
if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil {
_ = m.runtime.Destroy(ctx, handle)
_ = m.workspace.Destroy(ctx, ws)
m.markErrored(ctx, id)
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: completed: %w", id, err)
}
return m.Get(ctx, id)
}
// markErrored best-effort parks an orphaned spawn in a terminal errored state
// (the store has no delete; a phantom "spawning" row is worse than a terminal one).
func (m *Manager) markErrored(ctx context.Context, id domain.SessionID) {
_ = m.lcm.OnKillRequested(ctx, id, domain.TermErrorInProcess)
// markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated.
// The store has no delete; a phantom half-spawned row is worse than a terminal one.
func (m *Manager) markSpawnFailedTerminated(ctx context.Context, id domain.SessionID) {
_ = m.lcm.MarkTerminated(ctx, id)
}
// Kill records terminal intent with the LCM, then tears down the runtime and
// workspace. A workspace teardown refused by the worktree-remove safety
// (uncommitted work) surfaces as an error with freed=false and is never forced.
func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (bool, error) {
func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return false, fmt.Errorf("kill %s: %w", id, err)
@ -131,7 +146,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.T
if handle.ID == "" || ws.Path == "" {
return false, fmt.Errorf("kill %s: %w", id, ErrIncompleteHandle)
}
if err := m.lcm.OnKillRequested(ctx, id, reason); err != nil {
if err := m.lcm.MarkTerminated(ctx, id); err != nil {
return false, fmt.Errorf("kill %s: %w", id, err)
}
if err := m.runtime.Destroy(ctx, handle); err != nil {
@ -144,7 +159,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.T
}
// Restore relaunches a torn-down session in its workspace. The fallible I/O runs
// before any canonical write, so a failure never resurrects the row or destroys
// before any durable session write, so a failure never resurrects the row or destroys
// the worktree (it may hold the agent's prior work).
func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
rec, ok, err := m.store.GetSession(ctx, id)
@ -154,7 +169,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if !ok {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound)
}
if !isTerminal(rec.Lifecycle.Session.State) {
if !rec.IsTerminated {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable)
}
meta := rec.Metadata
@ -180,8 +195,8 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: runtime: %w", id, err)
}
outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt}
if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil {
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt}
if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil {
_ = m.runtime.Destroy(ctx, handle)
return domain.Session{}, fmt.Errorf("restore %s: completed: %w", id, err)
}
@ -234,7 +249,7 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma
}
var cleaned []domain.SessionID
for _, rec := range recs {
if !isTerminal(rec.Lifecycle.Session.State) {
if !rec.IsTerminated {
continue
}
ws := workspaceInfo(rec)
@ -255,15 +270,14 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma
// ---- helpers ----
func (m *Manager) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) {
pr, err := m.store.PRFactsForSession(ctx, rec.ID)
pr, ok, err := m.store.GetDisplayPRFactsForSession(ctx, rec.ID)
if err != nil {
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
}
return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec.Lifecycle, pr)}, nil
}
func isTerminal(s domain.SessionState) bool {
return s == domain.SessionDone || s == domain.SessionTerminated
if !ok {
return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, nil)}, nil
}
return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, &pr)}, nil
}
func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
@ -273,11 +287,8 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
Kind: cfg.Kind,
CreatedAt: now,
UpdatedAt: now,
Lifecycle: domain.CanonicalSessionLifecycle{
Version: domain.LifecycleVersion,
Session: domain.SessionSubstate{State: domain.SessionNotStarted},
Harness: cfg.Harness,
},
Harness: cfg.Harness,
Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceNone},
}
}
@ -306,7 +317,7 @@ func spawnEnv(base map[string]string, id domain.SessionID, project domain.Projec
}
func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle {
return ports.RuntimeHandle{ID: meta.RuntimeHandleID, RuntimeName: meta.RuntimeName}
return ports.RuntimeHandle{ID: meta.RuntimeHandleID}
}
func workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo {

Some files were not shown because too many files have changed in this diff Show More