refactor: move session status assembly to service (#62) (#67)

Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
This commit is contained in:
prateek 2026-06-01 23:31:21 +05:30 committed by GitHub
parent f9b08aada4
commit 424e6e824b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1138 additions and 179 deletions

View File

@ -4,7 +4,7 @@ Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`)
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).
Lifecycle Manager + Session Service lane in [`docs/architecture.md`](docs/architecture.md).
## Backend daemon

View File

@ -19,52 +19,3 @@ const (
StatusIdle SessionStatus = "idle"
StatusTerminated SessionStatus = "terminated"
)
// 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 rec.Activity.State == ActivityWaitingInput {
return StatusNeedsInput
}
if pr != nil {
if pr.Merged {
return StatusMerged
}
if !pr.Closed {
return prPipelineStatus(*pr)
}
}
if rec.Activity.State == ActivityActive {
return StatusWorking
}
return StatusIdle
}
func prPipelineStatus(pr PRFacts) SessionStatus {
switch {
case pr.CI == CIFailing:
return StatusCIFailed
case pr.Draft:
return StatusDraft
case pr.Review == ReviewChangesRequest || pr.ReviewComments:
return StatusChangesRequested
case pr.Mergeability == MergeMergeable:
return StatusMergeable
case pr.Review == ReviewApproved:
return StatusApproved
case pr.Review == ReviewRequired:
return StatusReviewPending
default:
return StatusPROpen
}
}

View File

@ -1,38 +0,0 @@
package domain
import "testing"
func rec(activity ActivityState, terminated bool) SessionRecord {
return SessionRecord{Activity: Activity{State: activity}, IsTerminated: terminated}
}
func pr(facts PRFacts) *PRFacts { return &facts }
func TestDeriveStatusFromSessionFactsAndPR(t *testing.T) {
tests := []struct {
name string
rec SessionRecord
pr *PRFacts
want SessionStatus
}{
{"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},
{"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.rec, tt.pr); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}

View File

@ -19,6 +19,7 @@ import (
// registered but returns the OpenAPI-backed 501 response.
type APIDeps struct {
Projects project.Manager
Sessions controllers.SessionService
}
// API owns one controller per resource and is the single Register call the
@ -26,6 +27,7 @@ type APIDeps struct {
type API struct {
cfg config.Config
projects *controllers.ProjectsController
sessions *controllers.SessionsController
}
// NewAPI constructs the API surface from its dependencies. cfg carries the
@ -37,6 +39,9 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
projects: &controllers.ProjectsController{
Mgr: deps.Projects,
},
sessions: &controllers.SessionsController{
Svc: deps.Sessions,
},
}
}
@ -55,6 +60,7 @@ func (a *API) Register(root chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.Timeout(timeout))
a.projects.Register(r)
a.sessions.Register(r)
// Sibling REST controllers plug in here.
})
// Surfaces that intentionally bypass the REST timeout register at this level.

View File

@ -15,6 +15,8 @@ servers:
tags:
- name: projects
description: Project registry, configuration, and lifecycle administration
- name: sessions
description: Agent session lifecycle and messaging
paths:
/api/v1/projects:
@ -228,6 +230,219 @@ paths:
"404": { $ref: "#/components/responses/ProjectNotFound" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/sessions:
get:
operationId: listSessions
tags: [sessions]
summary: List sessions
parameters:
- name: project
in: query
schema: { type: string }
- name: active
in: query
schema: { type: boolean }
- name: orchestratorOnly
in: query
schema: { type: boolean }
- name: fresh
in: query
schema: { type: boolean }
responses:
"200":
description: Sessions listed
content:
application/json:
schema:
type: object
required: [sessions]
properties:
sessions:
type: array
items: { $ref: "#/components/schemas/Session" }
"400":
description: Bad request
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
"501": { $ref: "#/components/responses/NotImplemented" }
post:
operationId: spawnSession
tags: [sessions]
summary: Spawn a new agent session
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SpawnSessionRequest" }
responses:
"201":
description: Session spawned
content:
application/json:
schema:
type: object
required: [session]
properties:
session: { $ref: "#/components/schemas/Session" }
"400":
description: Bad request
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
examples:
invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } }
projectRequired: { value: { error: bad_request, code: PROJECT_ID_REQUIRED, message: "projectId is required" } }
promptTooLong: { value: { error: bad_request, code: PROMPT_TOO_LONG, message: "prompt is too long" } }
"404": { $ref: "#/components/responses/ProjectNotFound" }
"500": { $ref: "#/components/responses/SessionOperationFailed" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/sessions/{sessionId}:
parameters:
- $ref: "#/components/parameters/SessionIDPath"
get:
operationId: getSession
tags: [sessions]
summary: Fetch one session
responses:
"200":
description: Session fetched
content:
application/json:
schema:
type: object
required: [session]
properties:
session: { $ref: "#/components/schemas/Session" }
"404": { $ref: "#/components/responses/SessionNotFound" }
"501": { $ref: "#/components/responses/NotImplemented" }
patch:
operationId: renameSession
tags: [sessions]
summary: Rename a session display name
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [displayName]
properties:
displayName: { type: string, minLength: 1 }
responses:
"200":
description: Session renamed
content:
application/json:
schema:
type: object
required: [session]
properties:
session: { $ref: "#/components/schemas/Session" }
"400":
description: Bad request
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
"404": { $ref: "#/components/responses/SessionNotFound" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/sessions/{sessionId}/restore:
parameters:
- $ref: "#/components/parameters/SessionIDPath"
post:
operationId: restoreSession
tags: [sessions]
summary: Restore a terminated session
responses:
"200":
description: Session restored
content:
application/json:
schema:
type: object
required: [ok, sessionId, session]
properties:
ok: { type: boolean }
sessionId: { type: string }
session: { $ref: "#/components/schemas/Session" }
"404": { $ref: "#/components/responses/SessionNotFound" }
"409": { $ref: "#/components/responses/SessionConflict" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/sessions/{sessionId}/kill:
parameters:
- $ref: "#/components/parameters/SessionIDPath"
post:
operationId: killSession
tags: [sessions]
summary: Mark a session terminated and tear down runtime/workspace resources
responses:
"200":
description: Kill attempted
content:
application/json:
schema: { $ref: "#/components/schemas/KillSessionResponse" }
"404": { $ref: "#/components/responses/SessionNotFound" }
"409": { $ref: "#/components/responses/SessionConflict" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/sessions/{sessionId}/send:
parameters:
- $ref: "#/components/parameters/SessionIDPath"
post:
operationId: sendSessionMessage
tags: [sessions]
summary: Send a message to a running session's agent
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SendSessionMessageRequest" }
responses:
"200":
description: Message accepted
content:
application/json:
schema: { $ref: "#/components/schemas/SendSessionMessageResponse" }
"400":
description: Bad request
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
examples:
invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } }
messageRequired: { value: { error: bad_request, code: MESSAGE_REQUIRED, message: "Message is required" } }
"404": { $ref: "#/components/responses/SessionNotFound" }
"500": { $ref: "#/components/responses/SessionOperationFailed" }
"501": { $ref: "#/components/responses/NotImplemented" }
/api/v1/orchestrators:
post:
operationId: spawnOrchestrator
tags: [sessions]
summary: Spawn an orchestrator session
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SpawnOrchestratorRequest" }
responses:
"201":
description: Orchestrator spawned
content:
application/json:
schema: { $ref: "#/components/schemas/SpawnOrchestratorResponse" }
"400":
description: Bad request
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
"404": { $ref: "#/components/responses/ProjectNotFound" }
"500": { $ref: "#/components/responses/SessionOperationFailed" }
"501": { $ref: "#/components/responses/NotImplemented" }
components:
parameters:
ProjectIDPath:
@ -237,6 +452,13 @@ components:
schema: { type: string, minLength: 1 }
description: Project identifier (registry key).
SessionIDPath:
name: sessionId
in: path
required: true
schema: { type: string, minLength: 1 }
description: Session identifier, e.g. project-1.
responses:
NotImplemented:
description: |
@ -255,6 +477,29 @@ components:
schema: { $ref: "#/components/schemas/APIError" }
example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" }
SessionNotFound:
description: Session not found
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
example: { error: not_found, code: SESSION_NOT_FOUND, message: "Unknown session" }
SessionConflict:
description: Session is not in a valid state for the requested operation
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
examples:
notRestorable: { value: { error: conflict, code: SESSION_NOT_RESTORABLE, message: "Session is not restorable" } }
incompleteHandle: { value: { error: conflict, code: SESSION_INCOMPLETE_HANDLE, message: "Session is missing runtime or workspace handles" } }
SessionOperationFailed:
description: Session operation failed
content:
application/json:
schema: { $ref: "#/components/schemas/APIError" }
example: { error: internal, code: SESSION_OPERATION_FAILED, message: "Session operation failed" }
schemas:
APIError:
type: object
@ -369,6 +614,84 @@ components:
projectCount: { type: integer }
degradedCount: { type: integer }
Session:
type: object
required: [id, projectId, kind, activity, isTerminated, createdAt, updatedAt, status]
properties:
id: { type: string }
projectId: { type: string }
issueId: { type: string }
kind: { type: string, enum: [worker, orchestrator] }
harness: { type: string, enum: ["", claude-code, codex, aider, opencode] }
activity: { $ref: "#/components/schemas/SessionActivity" }
isTerminated: { type: boolean }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
status:
type: string
enum: [working, pr_open, draft, ci_failed, review_pending, changes_requested, approved, mergeable, merged, needs_input, idle, terminated]
SessionActivity:
type: object
required: [state, lastActivityAt]
properties:
state: { type: string, enum: [active, idle, waiting_input, exited] }
lastActivityAt: { type: string, format: date-time }
SpawnSessionRequest:
type: object
required: [projectId]
properties:
projectId: { type: string }
issueId: { type: string }
kind: { type: string, enum: [worker, orchestrator], default: worker }
harness: { type: string, enum: ["", claude-code, codex, aider, opencode] }
branch: { type: string }
prompt: { type: string, maxLength: 4096 }
agentRules: { type: string }
SendSessionMessageRequest:
type: object
required: [message]
properties:
message: { type: string, minLength: 1, maxLength: 4096 }
SendSessionMessageResponse:
type: object
required: [ok, sessionId, message]
properties:
ok: { type: boolean }
sessionId: { type: string }
message: { type: string }
KillSessionResponse:
type: object
required: [ok, sessionId]
properties:
ok: { type: boolean }
sessionId: { type: string }
freed: { type: boolean }
SpawnOrchestratorRequest:
type: object
required: [projectId]
properties:
projectId: { type: string }
clean: { type: boolean, default: false }
SpawnOrchestratorResponse:
type: object
required: [orchestrator]
properties:
orchestrator:
type: object
required: [id, projectId]
properties:
id: { type: string }
projectId: { type: string }
projectName: { type: string }
# ---- Behaviour config blobs ----
TrackerConfig:

View File

@ -0,0 +1,276 @@
package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/service"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
)
const (
maxPromptLen = 4096
maxMessageLen = 4096
)
// SessionService is the controller-facing session service contract.
type SessionService interface {
List(ctx context.Context, filter service.SessionListFilter) ([]domain.Session, error)
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
Get(ctx context.Context, id domain.SessionID) (domain.Session, error)
Restore(ctx context.Context, id domain.SessionID) (domain.Session, error)
Kill(ctx context.Context, id domain.SessionID) (bool, error)
Send(ctx context.Context, id domain.SessionID, message string) error
}
// SessionsController owns the session routes. Nil keeps routes registered but
// returns OpenAPI-backed 501s.
type SessionsController struct {
Svc SessionService
}
// Register mounts the session routes on the supplied router.
func (c *SessionsController) Register(r chi.Router) {
r.Get("/sessions", c.list)
r.Post("/sessions", c.spawn)
r.Get("/sessions/{sessionId}", c.get)
r.Patch("/sessions/{sessionId}", c.rename)
r.Post("/sessions/{sessionId}/restore", c.restore)
r.Post("/sessions/{sessionId}/kill", c.kill)
r.Post("/sessions/{sessionId}/send", c.send)
r.Post("/orchestrators", c.spawnOrchestrator)
}
func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions")
return
}
filter, err := parseSessionListFilter(r)
if err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_QUERY", err.Error(), nil)
return
}
sessions, err := c.Svc.List(r.Context(), filter)
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}
type spawnSessionRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
IssueID domain.IssueID `json:"issueId"`
Kind domain.SessionKind `json:"kind"`
Harness domain.AgentHarness `json:"harness"`
Branch string `json:"branch"`
Prompt string `json:"prompt"`
AgentRules string `json:"agentRules"`
}
func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions")
return
}
var in spawnSessionRequest
if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
if in.ProjectID == "" {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil)
return
}
if len(in.Prompt) > maxPromptLen {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil)
return
}
if in.Kind == "" {
in.Kind = domain.KindWorker
}
sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, AgentRules: in.AgentRules})
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"session": sess})
}
func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}")
return
}
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"session": sess})
}
func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) {
apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}")
}
func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/restore")
return
}
sess, err := c.Svc.Restore(r.Context(), sessionID(r))
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "session": sess})
}
func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/kill")
return
}
freed, err := c.Svc.Kill(r.Context(), sessionID(r))
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed})
}
type sendSessionRequest struct {
Message string `json:"message"`
}
func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send")
return
}
var in sendSessionRequest
if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
if in.Message == "" {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_REQUIRED", "Message is required", nil)
return
}
if len(in.Message) > maxMessageLen {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_TOO_LONG", "Message is too long", nil)
return
}
message := stripUnsafeControlChars(in.Message)
if err := c.Svc.Send(r.Context(), sessionID(r), message); err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message})
}
type spawnOrchestratorRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
Clean bool `json:"clean"`
}
func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators")
return
}
var in spawnOrchestratorRequest
if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
if in.ProjectID == "" {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil)
return
}
if in.Clean {
active := true
orchestrators, err := c.Svc.List(r.Context(), service.SessionListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true})
if err != nil {
writeSessionError(w, r, err)
return
}
for _, existing := range orchestrators {
if _, err := c.Svc.Kill(r.Context(), existing.ID); err != nil {
writeSessionError(w, r, err)
return
}
}
}
sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, Kind: domain.KindOrchestrator})
if err != nil {
writeSessionError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"orchestrator": map[string]any{"id": sess.ID, "projectId": sess.ProjectID}})
}
func sessionID(r *http.Request) domain.SessionID {
return domain.SessionID(chi.URLParam(r, "sessionId"))
}
func parseSessionListFilter(r *http.Request) (service.SessionListFilter, error) {
q := r.URL.Query()
filter := service.SessionListFilter{ProjectID: domain.ProjectID(q.Get("project"))}
if raw := q.Get("active"); raw != "" {
active, err := strconv.ParseBool(raw)
if err != nil {
return service.SessionListFilter{}, errors.New("active must be a boolean")
}
filter.Active = &active
}
if raw := q.Get("orchestratorOnly"); raw != "" {
orchestratorOnly, err := strconv.ParseBool(raw)
if err != nil {
return service.SessionListFilter{}, errors.New("orchestratorOnly must be a boolean")
}
filter.OrchestratorOnly = orchestratorOnly
}
if raw := q.Get("fresh"); raw != "" {
fresh, err := strconv.ParseBool(raw)
if err != nil {
return service.SessionListFilter{}, errors.New("fresh must be a boolean")
}
filter.Fresh = fresh
}
return filter, nil
}
func stripUnsafeControlChars(message string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return -1
}
return r
}, message)
}
func writeSessionError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, sessionmanager.ErrNotFound):
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil)
case errors.Is(err, sessionmanager.ErrNotRestorable):
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil)
case errors.Is(err, sessionmanager.ErrIncompleteHandle):
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil)
default:
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil)
}
}

View File

@ -0,0 +1,174 @@
package controllers_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/service"
)
type fakeSessionService struct {
sessions map[domain.SessionID]domain.Session
sent string
}
func newFakeSessionService() *fakeSessionService {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}}
}
func (f *fakeSessionService) List(_ context.Context, filter service.SessionListFilter) ([]domain.Session, error) {
var out []domain.Session
for _, s := range f.sessions {
if filter.ProjectID != "" && s.ProjectID != filter.ProjectID {
continue
}
if filter.Active != nil && s.IsTerminated == *filter.Active {
continue
}
if filter.OrchestratorOnly && s.Kind != domain.KindOrchestrator {
continue
}
out = append(out, s)
}
return out, nil
}
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
f.sessions[s.ID] = s
return s, nil
}
func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) {
return f.sessions[id], nil
}
func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) {
s := f.sessions[id]
s.IsTerminated = false
s.Status = domain.StatusIdle
f.sessions[id] = s
return s, nil
}
func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) {
s := f.sessions[id]
s.IsTerminated = true
s.Status = domain.StatusTerminated
f.sessions[id] = s
return true, nil
}
func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error {
f.sent = message
return nil
}
func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc}))
t.Cleanup(srv.Close)
return srv
}
func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "")
if status != http.StatusOK {
t.Fatalf("GET sessions = %d, want 200; body=%s", status, body)
}
var list struct {
Sessions []sessionBody `json:"sessions"`
}
mustJSON(t, body, &list)
if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) {
t.Fatalf("list = %#v", list)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`)
if status != http.StatusCreated {
t.Fatalf("POST session = %d, want 201; body=%s", status, body)
}
var spawned struct {
Session sessionBody `json:"session"`
}
mustJSON(t, body, &spawned)
if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
t.Fatalf("spawned = %#v", spawned)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
if status != http.StatusOK {
t.Fatalf("GET session = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/send", "{\"message\":\"con\\u0000tinue\"}")
if status != http.StatusOK || svc.sent != "continue" {
t.Fatalf("send status=%d sent=%q body=%s", status, svc.sent, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/kill", "")
if status != http.StatusOK {
t.Fatalf("kill = %d, want 200; body=%s", status, body)
}
var killed struct {
SessionID string `json:"sessionId"`
Freed bool `json:"freed"`
}
mustJSON(t, body, &killed)
if killed.SessionID != "ao-2" || !killed.Freed {
t.Fatalf("kill response = %#v", killed)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/restore", "")
if status != http.StatusOK {
t.Fatalf("restore = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`)
if status != http.StatusCreated {
t.Fatalf("orchestrator = %d, want 201; body=%s", status, body)
}
}
func TestSessionsAPI_SendValidation(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/send", `{"message":""}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED")
}
type sessionBody struct {
ID string `json:"id"`
IssueID string `json:"issueId"`
Harness string `json:"harness"`
Status string `json:"status"`
}

View File

@ -11,7 +11,8 @@ import (
"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/service"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
@ -54,7 +55,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin
type stack struct {
store *sqlite.Store
sm *session.Manager
sm *service.Session
lcm *lifecycle.Manager
prm *prsvc.Manager
rt *stubRuntime
@ -78,7 +79,8 @@ func newStack(t *testing.T) *stack {
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})
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
sm := service.NewSession(mgr, store)
return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
}

View File

@ -0,0 +1,143 @@
package service
import (
"context"
"fmt"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
)
// SessionStore is the read-only persistence surface needed to assemble controller-facing session read models.
type SessionStore interface {
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)
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
}
// SessionListFilter captures API-facing session list query filters.
type SessionListFilter struct {
ProjectID domain.ProjectID
Active *bool
OrchestratorOnly bool
Fresh bool
}
// Session is the controller-facing session service. It delegates command-side
// session operations to the internal sessionmanager.Manager and owns read-model
// assembly, including user-facing display status derivation.
type Session struct {
manager *sessionmanager.Manager
store SessionStore
}
// NewSession wires a controller-facing session service over an internal session Manager.
func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session {
return &Session{manager: manager, store: store}
}
// Spawn creates a session and returns the API-facing read model.
func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
rec, err := s.manager.Spawn(ctx, cfg)
if err != nil {
return domain.Session{}, err
}
return s.toSession(ctx, rec)
}
// Restore relaunches a terminated session and returns the API-facing read model.
func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
rec, err := s.manager.Restore(ctx, id)
if err != nil {
return domain.Session{}, err
}
return s.toSession(ctx, rec)
}
// Kill delegates terminal intent and teardown to the internal manager.
func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
return s.manager.Kill(ctx, id)
}
// Send delegates agent messaging to the internal manager.
func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error {
return s.manager.Send(ctx, id, message)
}
// Cleanup delegates terminal workspace cleanup to the internal manager.
func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) {
return s.manager.Cleanup(ctx, project)
}
// List returns sessions as enriched display models after applying API filters.
func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.Session, error) {
recs, err := s.listRecords(ctx, filter.ProjectID)
if err != nil {
return nil, err
}
out := make([]domain.Session, 0, len(recs))
for _, rec := range recs {
if !matchesSessionFilter(rec, filter) {
continue
}
sess, err := s.toSession(ctx, rec)
if err != nil {
return nil, err
}
out = append(out, sess)
}
return out, nil
}
func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) {
if project == "" {
recs, err := s.store.ListAllSessions(ctx)
if err != nil {
return nil, fmt.Errorf("list all sessions: %w", err)
}
return recs, nil
}
recs, err := s.store.ListSessions(ctx, project)
if err != nil {
return nil, fmt.Errorf("list %s: %w", project, err)
}
return recs, nil
}
func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bool {
if filter.Active != nil && rec.IsTerminated == *filter.Active {
return false
}
if filter.OrchestratorOnly && rec.Kind != domain.KindOrchestrator {
return false
}
if filter.Fresh && rec.IsTerminated {
return false
}
return true
}
// Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent.
func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
rec, ok, err := s.store.GetSession(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("get %s: %w", id, err)
}
if !ok {
return domain.Session{}, fmt.Errorf("get %s: %w", id, sessionmanager.ErrNotFound)
}
return s.toSession(ctx, rec)
}
func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) {
pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID)
if err != nil {
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
}
if !ok {
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil)}, nil
}
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr)}, nil
}

View File

@ -0,0 +1,49 @@
package service
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
func deriveStatus(rec domain.SessionRecord, pr *domain.PRFacts) domain.SessionStatus {
if rec.IsTerminated {
if pr != nil && pr.Merged {
return domain.StatusMerged
}
return domain.StatusTerminated
}
if rec.Activity.State == domain.ActivityWaitingInput {
return domain.StatusNeedsInput
}
if pr != nil {
if pr.Merged {
return domain.StatusMerged
}
if !pr.Closed {
return prPipelineStatus(*pr)
}
}
if rec.Activity.State == domain.ActivityActive {
return domain.StatusWorking
}
return domain.StatusIdle
}
func prPipelineStatus(pr domain.PRFacts) domain.SessionStatus {
switch {
case pr.CI == domain.CIFailing:
return domain.StatusCIFailed
case pr.Draft:
return domain.StatusDraft
case pr.Review == domain.ReviewChangesRequest || pr.ReviewComments:
return domain.StatusChangesRequested
case pr.Mergeability == domain.MergeMergeable:
return domain.StatusMergeable
case pr.Review == domain.ReviewApproved:
return domain.StatusApproved
case pr.Review == domain.ReviewRequired:
return domain.StatusReviewPending
default:
return domain.StatusPROpen
}
}

View File

@ -0,0 +1,42 @@
package service
import (
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func statusRec(activity domain.ActivityState, terminated bool) domain.SessionRecord {
return domain.SessionRecord{Activity: domain.Activity{State: activity}, IsTerminated: terminated}
}
func statusPR(facts domain.PRFacts) *domain.PRFacts { return &facts }
func TestServiceDerivesStatusFromSessionFactsAndPR(t *testing.T) {
tests := []struct {
name string
rec domain.SessionRecord
pr *domain.PRFacts
want domain.SessionStatus
}{
{"terminated", statusRec(domain.ActivityExited, true), nil, domain.StatusTerminated},
{"merged-pr", statusRec(domain.ActivityIdle, true), statusPR(domain.PRFacts{Merged: true}), domain.StatusMerged},
{"needs-input", statusRec(domain.ActivityWaitingInput, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusNeedsInput},
{"ci-failed", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusCIFailed},
{"draft", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Draft: true}), domain.StatusDraft},
{"changes-requested", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewChangesRequest}), domain.StatusChangesRequested},
{"mergeable", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Mergeability: domain.MergeMergeable}), domain.StatusMergeable},
{"approved", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewApproved}), domain.StatusApproved},
{"review-pending", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewRequired}), domain.StatusReviewPending},
{"pr-open", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{}), domain.StatusPROpen},
{"working", statusRec(domain.ActivityActive, false), nil, domain.StatusWorking},
{"idle", statusRec(domain.ActivityIdle, false), nil, domain.StatusIdle},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := deriveStatus(tt.rec, tt.pr); got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,68 @@
package service
import (
"context"
"fmt"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
type fakeSessionStore struct {
sessions map[domain.SessionID]domain.SessionRecord
pr map[domain.SessionID]domain.PRFacts
num int
}
func newFakeSessionStore() *fakeSessionStore {
return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
}
func (f *fakeSessionStore) 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 *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
r, ok := f.sessions[id]
return r, ok, nil
}
func (f *fakeSessionStore) 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 *fakeSessionStore) 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 *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
pr, ok := f.pr[id]
return pr, ok, nil
}
func TestSessionListDerivesStatusFromPRFacts(t *testing.T) {
st := newFakeSessionStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}
list, err := (&Session{store: st}).List(context.Background(), SessionListFilter{ProjectID: "mer"})
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
t.Fatalf("got %+v", list)
}
}

View File

@ -1,7 +1,6 @@
// 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
// Package sessionmanager drives internal session command operations over runtime,
// agent, workspace, storage, messenger, and lifecycle dependencies.
package sessionmanager
import (
"context"
@ -37,20 +36,20 @@ type runtimeController interface {
Destroy(ctx context.Context, handle ports.RuntimeHandle) error
}
type sessionStore interface {
// Store is the persistence surface needed by the internal session Manager.
type Store 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.
// Manager coordinates internal session spawn, restore, kill, and cleanup over
// the outbound ports. User-facing read-model assembly lives in the service package.
type Manager struct {
runtime runtimeController
agent ports.Agent
workspace ports.Workspace
store sessionStore
store Store
messenger ports.AgentMessenger
lcm lifecycleRecorder
clock func() time.Time
@ -61,7 +60,7 @@ type Deps struct {
Runtime runtimeController
Agent ports.Agent
Workspace ports.Workspace
Store sessionStore
Store Store
Messenger ports.AgentMessenger
Lifecycle lifecycleRecorder
Clock func() time.Time
@ -88,17 +87,17 @@ 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 parks it as terminated and rolls back what was built.
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) {
rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock()))
if err != nil {
return domain.Session{}, fmt.Errorf("spawn: create: %w", err)
return domain.SessionRecord{}, fmt.Errorf("spawn: create: %w", err)
}
id := rec.ID
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch})
if err != nil {
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)}
@ -111,7 +110,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
}
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt}
@ -119,9 +118,9 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
_ = m.runtime.Destroy(ctx, handle)
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.Session{}, fmt.Errorf("spawn %s: completed: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err)
}
return m.Get(ctx, id)
return m.getRecord(ctx, id)
}
// markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated.
@ -161,25 +160,25 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
// Restore relaunches a torn-down session in its workspace. The fallible I/O runs
// 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) {
func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
if !ok {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound)
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotFound)
}
if !rec.IsTerminated {
return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable)
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable)
}
meta := rec.Metadata
if meta.AgentSessionID == "" && meta.Prompt == "" {
return domain.Session{}, fmt.Errorf("restore %s: nothing to resume from", id)
return domain.SessionRecord{}, fmt.Errorf("restore %s: nothing to resume from", id)
}
ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ProjectID: rec.ProjectID, SessionID: id, Branch: meta.Branch})
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: workspace: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("restore %s: workspace: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: meta.Prompt}
launch := m.agent.GetRestoreCommand(meta.AgentSessionID)
@ -193,43 +192,25 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID),
})
if err != nil {
return domain.Session{}, fmt.Errorf("restore %s: runtime: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err)
}
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)
return domain.SessionRecord{}, fmt.Errorf("restore %s: completed: %w", id, err)
}
return m.Get(ctx, id)
return m.getRecord(ctx, id)
}
// List returns the project's sessions as enriched display models.
func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) {
recs, err := m.store.ListSessions(ctx, project)
if err != nil {
return nil, fmt.Errorf("list %s: %w", project, err)
}
out := make([]domain.Session, 0, len(recs))
for _, rec := range recs {
s, err := m.toSession(ctx, rec)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, nil
}
// Get returns one session as a display model, or ErrNotFound if it is absent.
func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
func (m *Manager) getRecord(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return domain.Session{}, fmt.Errorf("get %s: %w", id, err)
return domain.SessionRecord{}, fmt.Errorf("get %s: %w", id, err)
}
if !ok {
return domain.Session{}, fmt.Errorf("get %s: %w", id, ErrNotFound)
return domain.SessionRecord{}, fmt.Errorf("get %s: %w", id, ErrNotFound)
}
return m.toSession(ctx, rec)
return rec, nil
}
// Send delivers a message to a running session's agent via the messenger.
@ -269,17 +250,6 @@ 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, ok, err := m.store.GetDisplayPRFactsForSession(ctx, rec.ID)
if err != nil {
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
}
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 {
return domain.SessionRecord{
ProjectID: cfg.ProjectID,

View File

@ -1,4 +1,4 @@
package session
package sessionmanager
import (
"context"
@ -149,8 +149,8 @@ func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) {
if s.ID != "mer-1" {
t.Fatalf("got %q", s.ID)
}
if s.Status != domain.StatusIdle {
t.Fatalf("fresh session displays idle, got %q", s.Status)
if s.Activity.State != domain.ActivityIdle {
t.Fatalf("fresh session records idle, got %q", s.Activity.State)
}
if rt.created != 1 {
t.Fatal("runtime not created")
@ -197,8 +197,8 @@ func TestRestore_ReopensTerminal(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if s.Status != domain.StatusIdle {
t.Fatalf("restored displays idle, got %q", s.Status)
if s.Activity.State != domain.ActivityIdle {
t.Fatalf("restored records idle, got %q", s.Activity.State)
}
if rt.created != 1 {
t.Fatal("restore should relaunch")
@ -211,18 +211,6 @@ func TestRestore_RefusesLiveSession(t *testing.T) {
t.Fatalf("want ErrNotRestorable, got %v", err)
}
}
func TestList_DerivesStatusFromPRFacts(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = mkLive("mer-1")
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}
list, err := m.List(ctx, "mer")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
t.Fatalf("got %+v", list)
}
}
func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) {
m, st, _, ws := newManager()
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"})

View File

@ -14,4 +14,4 @@ Persist durable facts, derive display status:
- session table: `activity_state`, `is_terminated`, identity, metadata
- PR tables: PR/CI/review facts
- derived read model: `domain.DeriveStatus(session, prFacts)`
- derived read model: `service.Session` computes display status from session + PR facts

View File

@ -17,15 +17,16 @@ The durable session facts are:
- `is_terminated` — whether the session should be treated as over.
- PR facts in the `pr`, `pr_checks`, and `pr_comment` tables.
The UI status is not stored. `domain.DeriveStatus` computes it from the session
record plus PR facts.
The UI status is not stored. `service.Session` computes it from the session
record plus PR facts while assembling controller-facing read models.
## Package layout
```
backend/internal/domain shared vocabulary and display-status derivation
backend/internal/domain shared vocabulary and API status value types
backend/internal/ports inbound/outbound interfaces
backend/internal/session explicit mutations: spawn, kill, restore, send, cleanup
backend/internal/service controller-facing services and read-model assembly
backend/internal/session_manager internal session command manager
backend/internal/lifecycle runtime/activity/spawn/termination session fact reducer
backend/internal/pr PR observation ingestion
backend/internal/storage SQLite persistence and DB-triggered CDC
@ -37,8 +38,8 @@ backend/internal/adapters Zellij/git-worktree/GitHub adapters
## Status derivation
`session.Manager` selects the display PR from all PR snapshots for a session, then
`domain.DeriveStatus(session, prFacts)` applies this rough precedence:
`service.Session` selects the display PR from all PR snapshots for a session, then
applies this rough precedence:
1. `is_terminated``terminated`, except merged PRs display `merged`.
2. `activity_state=waiting_input``needs_input`.
@ -69,14 +70,16 @@ consumed at read time for display status.
## Session manager
`session.Manager` performs explicit user mutations:
`session_manager.Manager` performs internal session mutations:
- `Spawn` creates a row, creates workspace/runtime resources, and reports the
handles to the lifecycle manager.
- `Kill` marks the row terminated, then tears down runtime/workspace resources.
- `Restore` relaunches a terminated session and clears `is_terminated` via the
spawn-completed path.
- `List`/`Get` attach the derived display status.
`service.Session` is the controller-facing boundary. It delegates commands to
`session_manager.Manager` and attaches derived display status on read paths.
## Persistence and CDC

View File

@ -1,7 +1,7 @@
# agent-orchestrator status
Current main contains the Go backend daemon, Cobra CLI foundation, SQLite store,
CDC poller/broadcaster, lifecycle/session managers, terminal mux, project API
CDC poller/broadcaster, lifecycle/session services, terminal mux, project API
controller/manager work, runtime/workspace/tracker adapters, and CDC-backed event rows.
## Build & test
@ -18,9 +18,11 @@ npm run lint
from those plus PR facts.
- SQLite: migrations create projects, sessions, PR/check/comment, and `change_log` tables.
- CDC: DB triggers append to `change_log`; the poller broadcasts live events.
- Session Manager: spawn/kill/restore/list/get/send/cleanup over runtime,
workspace, agent, store, messenger, and lifecycle ports. It is package-level
code today; daemon HTTP routes for session commands are not wired yet.
- Session Manager: internal spawn/kill/restore/send/cleanup over runtime,
workspace, agent, store, messenger, and lifecycle ports.
- Service package: controller-facing session boundary that delegates commands to
the manager and assembles list/get/spawn/restore read models with display status.
Daemon HTTP routes for session commands are not wired yet.
## Next integration work