Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) {
// SupportsHarness reports whether a harness has an activity pipeline at all:
// a registered deriver here means its adapter installs `ao hooks <harness>`
// callbacks that can reach the daemon. Status derivation uses this to decide
// whether prolonged silence is suspicious (no_signal) or simply all a hook-less
// whether prolonged silence is suspicious (a broken-pipeline stall) or simply all a hook-less
// harness can ever report (idle). Harness names and `ao hooks` agent tokens are
// the same strings by convention.
func SupportsHarness(h domain.AgentHarness) bool {
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
PRClaimer: store,
SCM: scmProvider,
Telemetry: telemetry,
// no_signal only makes sense for harnesses whose adapters install
// activity hooks; the deriver registry is the source of truth for that.
// The broken-pipeline stall only makes sense for harnesses whose adapters
// install activity hooks; the deriver registry is the source of truth.
SignalCapable: activitydispatch.SupportsHarness,
})
// Triggering a review spawns a reviewer over the worker's worktree, resolved
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/domain/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type SessionRecord struct {
// FirstSignalAt is when the FIRST agent hook callback arrived for the
// current spawn/restore: raw signal receipt, independent of the derived
// activity state. Zero means no hook has ever reported, which deriveStatus
// surfaces as StatusNoSignal after a grace period. Internal fact, not part
// surfaces as StatusStalled after a boot grace. Internal fact, not part
// of the API read model.
FirstSignalAt time.Time `json:"-"`
IsTerminated bool `json:"isTerminated"`
Expand Down
35 changes: 18 additions & 17 deletions backend/internal/domain/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@ package domain

// SessionStatus is the single-word DISPLAY status the dashboard renders. It is
// derived from persisted session facts plus PR facts and is never stored.
//
// There are five states, one per distinct move a human makes when scanning a
// wall of agents: leave it alone, respond, act on a clean PR, get it moving, or
// nothing. Finer PR detail (CI failing vs changes requested vs approved) lives
// in the inspector, not in the glanceable status.
type SessionStatus string

// The display statuses the dashboard renders.
const (
StatusWorking SessionStatus = "working"
StatusPROpen SessionStatus = "pr_open"
StatusDraft SessionStatus = "draft"
StatusCIFailed SessionStatus = "ci_failed"
StatusReviewPending SessionStatus = "review_pending"
StatusChangesRequested SessionStatus = "changes_requested"
StatusApproved SessionStatus = "approved"
StatusMergeable SessionStatus = "mergeable"
StatusMerged SessionStatus = "merged"
StatusNeedsInput SessionStatus = "needs_input"
StatusIdle SessionStatus = "idle"
StatusTerminated SessionStatus = "terminated"
// StatusNoSignal marks a live session whose agent has never delivered a
// hook callback for the current spawn/restore: AO cannot tell whether the
// agent is working or stuck (broken hook pipeline, blocked interactive
// prompt). Rendered instead of a confident idle.
StatusNoSignal SessionStatus = "no_signal"
// StatusWorking — the agent is actively running. Leave it alone.
StatusWorking SessionStatus = "working"
// StatusNeedsInput — the agent is blocked on you. Respond.
StatusNeedsInput SessionStatus = "needs_input"
// StatusReady — a clean PR is waiting on you (mergeable, approved, or needs
// your review). Merge it / go review it.
StatusReady SessionStatus = "ready"
// StatusStalled — the agent will not finish on its own (hung, never booted,
// or stopped with unfinished work). Get it moving.
StatusStalled SessionStatus = "stalled"
// StatusIdle — nothing is happening, or the work is finished (also covers
// merged and terminated). Nothing to do.
StatusIdle SessionStatus = "idle"
)
2 changes: 1 addition & 1 deletion backend/internal/httpd/controllers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (do
func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) {
s := f.sessions[id]
s.IsTerminated = true
s.Status = domain.StatusTerminated
s.Status = domain.StatusIdle
f.sessions[id] = s
return true, nil
}
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/integration/lifecycle_sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ func TestSpawnPRKillRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Status != domain.StatusCIFailed {
t.Fatalf("want ci_failed, got %q", got.Status)
if got.Status != domain.StatusStalled {
t.Fatalf("want stalled, got %q", got.Status)
}
freed, err := st.sm.Kill(ctx, sess.ID)
if err != nil || !freed {
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/lifecycle/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID,
act := domain.Activity{State: s.State, LastActivityAt: timeOr(s.Timestamp, now)}
// A same-state repeat is still a write when it is the FIRST signal for
// this spawn: the receipt itself is a durable fact (it clears the
// no_signal display status). Hook deliveries are best-effort, so the
// never-booted stalled status). Hook deliveries are best-effort, so the
// first to ARRIVE may match the seeded state — e.g. a turn's "active"
// POST is lost and its Stop hook lands idle on the idle-seeded row.
if sameActivity(rec.Activity, act) && !rec.FirstSignalAt.IsZero() {
Expand Down Expand Up @@ -243,8 +243,8 @@ func (m *Manager) MarkSpawned(ctx context.Context, id domain.SessionID, metadata
rec.IsTerminated = false
rec.Activity = domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}
// Each spawn/restore must re-prove its hook pipeline: clear the receipt so
// a relaunch with broken hooks degrades to no_signal instead of inheriting
// a stale "signals worked once" fact.
// a relaunch with broken hooks degrades to a never-booted stall instead of
// inheriting a stale "signals worked once" fact.
rec.FirstSignalAt = time.Time{}
rec.Metadata = mergeMetadata(rec.Metadata, metadata)
rec.UpdatedAt = now
Expand Down
10 changes: 5 additions & 5 deletions backend/internal/service/session/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ type Service struct {
telemetry ports.EventSink
// signalCapable reports whether a harness has a hook pipeline that can
// deliver activity signals at all. Only capable harnesses are eligible for
// the no_signal downgradea hook-less harness staying silent forever is
// normal, not a broken pipeline. nil means "unknown": never downgrade.
// the never-booted stall downgrade: a hook-less harness staying silent
// forever is normal, not a broken pipeline. nil means "unknown": never stall.
signalCapable func(domain.AgentHarness) bool
}

Expand All @@ -104,9 +104,9 @@ type Deps struct {
SCM scmProvider
Clock func() time.Time
Telemetry ports.EventSink
// SignalCapable gates the no_signal status downgrade per harness; daemon
// SignalCapable gates the never-booted stall downgrade per harness; daemon
// wiring passes activitydispatch.SupportsHarness. Left nil, no session is
// ever downgraded to no_signal.
// ever stalled for silence.
SignalCapable func(domain.AgentHarness) bool
}

Expand Down Expand Up @@ -484,7 +484,7 @@ func (s *Service) now() time.Time {

// harnessSignals tolerates a zero-value Service the same way now does. Without
// an injected capability predicate the service cannot tell a broken pipeline
// from a hook-less harness, so it never claims no_signal.
// from a hook-less harness, so it never stalls a session for silence.
func (s *Service) harnessSignals(h domain.AgentHarness) bool {
if s.signalCapable == nil {
return false
Expand Down
6 changes: 4 additions & 2 deletions backend/internal/service/session/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,16 @@ func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectReco

func TestSessionListDerivesStatusFromPRFacts(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
// Stopped agent on a failing-CI PR: it had the move and quit, so the
// session reads Stalled (PR facts drive the status, not the activity).
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityIdle}}
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}

list, err := (&Service{store: st}).List(context.Background(), ListFilter{ProjectID: "mer"})
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
if len(list) != 1 || list[0].Status != domain.StatusStalled {
t.Fatalf("got %+v", list)
}
}
Expand Down
53 changes: 27 additions & 26 deletions backend/internal/service/session/stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,59 +42,59 @@ func TestBuildStacksMergedParentUnblocksChild(t *testing.T) {
}
}

func TestDeriveStatusWorstWinsAcrossIndependentPRs(t *testing.T) {
// Two independent open PRs (both target main): mergeable vs ci_failed.
// CI failure is more urgent, so the session reports ci_failed.
func TestDeriveStatusUnfinishedPRWinsAcrossIndependentPRs(t *testing.T) {
// Two independent open PRs: one mergeable (clean), one ci_failed
// (unfinished). Stopped on undone work outranks the clean PR → Stalled.
prs := []domain.PRFacts{
{URL: "a", SourceBranch: "ao/a", TargetBranch: "main", Mergeability: domain.MergeMergeable},
{URL: "b", SourceBranch: "ao/b", TargetBranch: "main", CI: domain.CIFailing},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusCIFailed {
t.Fatalf("got %q want ci_failed", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusStalled {
t.Fatalf("got %q want stalled", got)
}
}

func TestDeriveStatusAllMergeableReportsMergeable(t *testing.T) {
func TestDeriveStatusAllCleanIsReady(t *testing.T) {
prs := []domain.PRFacts{
{URL: "a", SourceBranch: "ao/a", TargetBranch: "main", Mergeability: domain.MergeMergeable},
{URL: "b", SourceBranch: "ao/b", TargetBranch: "main", Mergeability: domain.MergeMergeable},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusMergeable {
t.Fatalf("got %q want mergeable", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusReady {
t.Fatalf("got %q want ready", got)
}
}

func TestDeriveStatusStackedChildExemptFromAggregation(t *testing.T) {
// Root mergeable; blocked child is pr_open. Child is exempt, so the session
// reports mergeable rather than being dragged down to pr_open.
func TestDeriveStatusStackedChildExemptFromReadiness(t *testing.T) {
// Root mergeable (clean); blocked child is a bare open PR (neutral). The
// child contributes nothing, so the session reads Ready off the root.
prs := []domain.PRFacts{
{URL: "root", SourceBranch: "ao/abc", TargetBranch: "main", Mergeability: domain.MergeMergeable},
{URL: "child", SourceBranch: "ao/abc/x", TargetBranch: "ao/abc"},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusMergeable {
t.Fatalf("got %q want mergeable (child exempt)", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusReady {
t.Fatalf("got %q want ready (child neutral)", got)
}
}

func TestDeriveStatusMergedParentOpenChildStaysOnChild(t *testing.T) {
// Parent merged, child now unblocked and review_pending: still alive, status
// follows the open child.
func TestDeriveStatusMergedParentOpenCleanChildIsReady(t *testing.T) {
// Parent merged, child now unblocked and review_required (clean): stopped on
// a clean PR reads Ready.
prs := []domain.PRFacts{
{URL: "root", SourceBranch: "ao/abc", TargetBranch: "main", Merged: true},
{URL: "child", SourceBranch: "ao/abc/x", TargetBranch: "main", Review: domain.ReviewRequired},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusReviewPending {
t.Fatalf("got %q want review_pending", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusReady {
t.Fatalf("got %q want ready", got)
}
}

func TestDeriveStatusAllMergedReportsMerged(t *testing.T) {
func TestDeriveStatusAllMergedIsIdle(t *testing.T) {
prs := []domain.PRFacts{
{URL: "a", Merged: true},
{URL: "b", Merged: true},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusMerged {
t.Fatalf("got %q want merged", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusIdle {
t.Fatalf("got %q want idle", got)
}
}

Expand All @@ -114,14 +114,15 @@ func TestDeriveStatusEmptyPRsUsesActivity(t *testing.T) {
}
}

func TestDeriveStatusDegenerateAllBlockedStillAggregates(t *testing.T) {
// Two PRs each targeting the other's source branch (no visible root). The
// fallback aggregates across all so the session never goes dark.
func TestDeriveStatusDegenerateAllBlockedSurfacesUnfinished(t *testing.T) {
// Two PRs each targeting the other's source branch (no visible root). Both
// are blocked, but a failing-CI child surfaces as unfinished work, so the
// stopped session reads Stalled rather than going dark.
prs := []domain.PRFacts{
{URL: "a", SourceBranch: "x", TargetBranch: "y", CI: domain.CIFailing},
{URL: "b", SourceBranch: "y", TargetBranch: "x", Mergeability: domain.MergeMergeable},
}
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusCIFailed {
t.Fatalf("got %q want ci_failed (degenerate fallback)", got)
if got := deriveStatus(live(), prs, statusNow, true); got != domain.StatusStalled {
t.Fatalf("got %q want stalled (degenerate, unfinished surfaces)", got)
}
}
Loading
Loading