fix: replace orchestrators through safe canonical branch handoff (#2338)

* fix: replace orchestrators through canonical handoff

* fix(tests): correct logic in TestRetireForReplacementCapturesAndReleasesWorkspace

* fix: enhance tests and add project config handling in service and workspace components

* fix: update project summary to include orchestrator agent and adjust related tests

* fix: block orchestrator replacement on runtime teardown failure

* fix: enhance orchestrator handling in ProjectSettingsForm and SessionsBoard components

* fix: rename parameter for clarity in projectConfigPtr function
This commit is contained in:
NIKHIL ACHALE 2026-07-04 11:35:03 +05:30 committed by GitHub
parent a639e2025c
commit cc75a519ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1068 additions and 63 deletions

View File

@ -2069,6 +2069,8 @@ components:
type: string type: string
name: name:
type: string type: string
orchestratorAgent:
type: string
path: path:
type: string type: string
resolveError: resolveError:

View File

@ -104,11 +104,12 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
out := make([]Summary, 0, len(projects)) out := make([]Summary, 0, len(projects))
for _, row := range projects { for _, row := range projects {
out = append(out, Summary{ out = append(out, Summary{
ID: domain.ProjectID(row.ID), ID: domain.ProjectID(row.ID),
Name: displayName(row), Name: displayName(row),
Path: row.Path, Path: row.Path,
Kind: row.Kind.WithDefault(), Kind: row.Kind.WithDefault(),
SessionPrefix: resolveSessionPrefix(row), SessionPrefix: resolveSessionPrefix(row),
OrchestratorAgent: row.Config.Orchestrator.Harness,
}) })
} }
return out, nil return out, nil
@ -390,13 +391,18 @@ func (m *Service) projectFromRow(row domain.ProjectRecord) Project {
DefaultBranch: row.Config.WithDefaults().DefaultBranch, DefaultBranch: row.Config.WithDefaults().DefaultBranch,
Agent: string(m.defaultHarness), Agent: string(m.defaultHarness),
} }
if !row.Config.IsZero() { p.Config = projectConfigPtr(row.Config)
cfg := row.Config
p.Config = &cfg
}
return p return p
} }
func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig {
if projectConfig.IsZero() {
return nil
}
cfg := projectConfig
return &cfg
}
func displayName(row domain.ProjectRecord) string { func displayName(row domain.ProjectRecord) string {
if strings.TrimSpace(row.DisplayName) != "" { if strings.TrimSpace(row.DisplayName) != "" {
return row.DisplayName return row.DisplayName

View File

@ -436,6 +436,32 @@ func TestManager_SetConfig(t *testing.T) {
wantCode(t, err, "PROJECT_NOT_FOUND") wantCode(t, err, "PROJECT_NOT_FOUND")
} }
func TestManager_ListIncludesOnlySummarySafeProjectConfig(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"GITHUB_TOKEN": "secret"},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex},
}
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Config: &cfg}); err != nil {
t.Fatalf("Add: %v", err)
}
list, err := m.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List len = %d, want 1", len(list))
}
if list[0].OrchestratorAgent != domain.HarnessCodex {
t.Fatalf("summary orchestrator agent = %q, want codex", list[0].OrchestratorAgent)
}
}
func TestManager_ReaddAfterRemove(t *testing.T) { func TestManager_ReaddAfterRemove(t *testing.T) {
ctx := context.Background() ctx := context.Background()
m := newManager(t) m := newManager(t)

View File

@ -4,12 +4,13 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// Summary is the row shape returned by GET /api/v1/projects. // Summary is the row shape returned by GET /api/v1/projects.
type Summary struct { type Summary struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Kind domain.ProjectKind `json:"kind"` Kind domain.ProjectKind `json:"kind"`
SessionPrefix string `json:"sessionPrefix"` SessionPrefix string `json:"sessionPrefix"`
ResolveError string `json:"resolveError,omitempty"` OrchestratorAgent domain.AgentHarness `json:"orchestratorAgent,omitempty"`
ResolveError string `json:"resolveError,omitempty"`
} }
// Project is the full read-model returned by GET /api/v1/projects/{id}. // Project is the full read-model returned by GET /api/v1/projects/{id}.

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -45,6 +46,7 @@ type commander interface {
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error)
Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error)
Kill(ctx context.Context, id domain.SessionID) (bool, error) Kill(ctx context.Context, id domain.SessionID) (bool, error)
RetireForReplacement(ctx context.Context, id domain.SessionID) error
Send(ctx context.Context, id domain.SessionID, message string) error Send(ctx context.Context, id domain.SessionID, message string) error
Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error)
RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error) RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error)
@ -81,12 +83,14 @@ type scmProvider interface {
// session operations to the internal sessionmanager.Manager and owns read-model // session operations to the internal sessionmanager.Manager and owns read-model
// assembly, including user-facing display status derivation. // assembly, including user-facing display status derivation.
type Service struct { type Service struct {
manager commander manager commander
store Store store Store
prClaimer ports.PRClaimer prClaimer ports.PRClaimer
scm scmProvider scm scmProvider
clock func() time.Time clock func() time.Time
telemetry ports.EventSink telemetry ports.EventSink
orchestratorLocksMu sync.Mutex
orchestratorLocks map[domain.ProjectID]*sync.Mutex
// signalCapable reports whether a harness has a hook pipeline that can // signalCapable reports whether a harness has a hook pipeline that can
// deliver activity signals at all. Only capable harnesses are eligible for // deliver activity signals at all. Only capable harnesses are eligible for
// the no_signal downgrade: a hook-less harness staying silent forever is // the no_signal downgrade: a hook-less harness staying silent forever is
@ -263,6 +267,13 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i
// active orchestrator already exists it is returned as-is. A business rule that // active orchestrator already exists it is returned as-is. A business rule that
// belongs here, not in the HTTP controller. // belongs here, not in the HTTP controller.
func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) {
unlock := s.lockOrchestratorProject(projectID)
defer unlock()
project, err := s.requireProject(ctx, projectID)
if err != nil {
return domain.Session{}, err
}
active := true active := true
if clean { if clean {
existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true})
@ -270,8 +281,9 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
return domain.Session{}, err return domain.Session{}, err
} }
for _, orch := range existing { for _, orch := range existing {
if _, err := s.Kill(ctx, orch.ID); err != nil { _ = s.sendRetireNotice(ctx, orch.ID)
return domain.Session{}, err if err := s.manager.RetireForReplacement(ctx, orch.ID); err != nil {
return domain.Session{}, toAPIError(err)
} }
} }
} else { } else {
@ -281,10 +293,90 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
return domain.Session{}, err return domain.Session{}, err
} }
if len(existing) > 0 { if len(existing) > 0 {
return existing[0], nil return newestSession(existing), nil
} }
} }
return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
if err != nil {
return domain.Session{}, err
}
if err := verifyOrchestratorReplacement(project, sess); err != nil {
return domain.Session{}, err
}
return sess, nil
}
const orchestratorRetireNotice = "AO is replacing this project orchestrator. Stop coordinating new work now; a fresh orchestrator will take over on the canonical branch."
func (s *Service) sendRetireNotice(ctx context.Context, id domain.SessionID) error {
if err := s.manager.Send(ctx, id, orchestratorRetireNotice); err != nil {
return fmt.Errorf("send retire notice to %s: %w", id, err)
}
return nil
}
func verifyOrchestratorReplacement(project domain.ProjectRecord, sess domain.Session) error {
if sess.IsTerminated {
return fmt.Errorf("orchestrator replacement verification failed: new session %s is terminated", sess.ID)
}
if sess.Kind != domain.KindOrchestrator {
return fmt.Errorf("orchestrator replacement verification failed: new session %s has kind %q", sess.ID, sess.Kind)
}
if expected := project.Config.Orchestrator.Harness; expected != "" && sess.Harness != expected {
return fmt.Errorf("orchestrator replacement verification failed: new session %s uses harness %q, want %q", sess.ID, sess.Harness, expected)
}
expectedBranch := "ao/" + serviceSessionPrefix(project) + "-orchestrator"
if sess.Metadata.Branch != "" && sess.Metadata.Branch != expectedBranch {
return fmt.Errorf("orchestrator replacement verification failed: new session %s uses branch %q, want %q", sess.ID, sess.Metadata.Branch, expectedBranch)
}
return nil
}
func serviceSessionPrefix(project domain.ProjectRecord) string {
if p := strings.TrimSpace(project.Config.SessionPrefix); p != "" {
return p
}
id := project.ID
if len(id) <= 12 {
return id
}
return id[:12]
}
func newestSession(sessions []domain.Session) domain.Session {
newest := sessions[0]
for _, sess := range sessions[1:] {
if sessionNewer(sess.SessionRecord, newest.SessionRecord) {
newest = sess
}
}
return newest
}
func sessionNewer(a, b domain.SessionRecord) bool {
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
if !a.UpdatedAt.Equal(b.UpdatedAt) {
return a.UpdatedAt.After(b.UpdatedAt)
}
return string(a.ID) > string(b.ID)
}
func (s *Service) lockOrchestratorProject(projectID domain.ProjectID) func() {
s.orchestratorLocksMu.Lock()
if s.orchestratorLocks == nil {
s.orchestratorLocks = make(map[domain.ProjectID]*sync.Mutex)
}
mu := s.orchestratorLocks[projectID]
if mu == nil {
mu = &sync.Mutex{}
s.orchestratorLocks[projectID] = mu
}
s.orchestratorLocksMu.Unlock()
mu.Lock()
return mu.Unlock
} }
// Restore relaunches a terminated session and returns the API-facing read model. // Restore relaunches a terminated session and returns the API-facing read model.

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
@ -201,10 +202,15 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
// clean-orchestrator ordering without wiring a real session engine. // clean-orchestrator ordering without wiring a real session engine.
type fakeCommander struct { type fakeCommander struct {
killed []domain.SessionID killed []domain.SessionID
retired []domain.SessionID
sent []domain.SessionID
cleanupProjects []domain.ProjectID cleanupProjects []domain.ProjectID
killErr error killErr error
retireErr error
sendErr error
cleanupErr error cleanupErr error
spawnErr error spawnErr error
spawnRecord domain.SessionRecord
spawned bool spawned bool
killsAtSpawn int killsAtSpawn int
} }
@ -214,7 +220,10 @@ func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.
return domain.SessionRecord{}, f.spawnErr return domain.SessionRecord{}, f.spawnErr
} }
f.spawned = true f.spawned = true
f.killsAtSpawn = len(f.killed) f.killsAtSpawn = len(f.retired)
if f.spawnRecord.ID != "" {
return f.spawnRecord, nil
}
return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil
} }
func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) {
@ -227,7 +236,20 @@ func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, erro
f.killed = append(f.killed, id) f.killed = append(f.killed, id)
return true, nil return true, nil
} }
func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } func (f *fakeCommander) RetireForReplacement(_ context.Context, id domain.SessionID) error {
if f.retireErr != nil {
return f.retireErr
}
f.retired = append(f.retired, id)
return nil
}
func (f *fakeCommander) Send(_ context.Context, id domain.SessionID, _ string) error {
if f.sendErr != nil {
return f.sendErr
}
f.sent = append(f.sent, id)
return nil
}
func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) { func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) {
f.cleanupProjects = append(f.cleanupProjects, project) f.cleanupProjects = append(f.cleanupProjects, project)
if f.cleanupErr != nil { if f.cleanupErr != nil {
@ -293,7 +315,7 @@ func TestTeardownProjectStopsOnKillError(t *testing.T) {
} }
} }
func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { func TestSpawnOrchestratorCleanRetiresActiveOrchestratorsBeforeSpawn(t *testing.T) {
st := newFakeStore() st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"} st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
// Two active orchestrators plus an unrelated worker and a terminated // Two active orchestrators plus an unrelated worker and a terminated
@ -310,11 +332,35 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T)
t.Fatalf("SpawnOrchestrator: %v", err) t.Fatalf("SpawnOrchestrator: %v", err)
} }
if len(fc.killed) != 2 { if len(fc.retired) != 2 {
t.Fatalf("killed = %v, want the two active orchestrators", fc.killed) t.Fatalf("retired = %v, want the two active orchestrators", fc.retired)
}
if len(fc.sent) != 2 {
t.Fatalf("retire notices = %v, want the two active orchestrators", fc.sent)
} }
if !fc.spawned || fc.killsAtSpawn != 2 { if !fc.spawned || fc.killsAtSpawn != 2 {
t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) t.Fatalf("spawn must run after both retirements: spawned=%v retirementsAtSpawn=%d", fc.spawned, fc.killsAtSpawn)
}
if len(fc.killed) != 0 {
t.Fatalf("interactive Kill must not be used for replacement: killed=%v", fc.killed)
}
}
func TestSpawnOrchestratorCleanContinuesWhenRetireNoticeFails(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
fc := &fakeCommander{sendErr: errors.New("pane closed")}
svc := &Service{manager: fc, store: st}
if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil {
t.Fatalf("SpawnOrchestrator: %v", err)
}
if len(fc.retired) != 1 || fc.retired[0] != "mer-1" {
t.Fatalf("retired = %v, want mer-1 despite retire notice failure", fc.retired)
}
if !fc.spawned {
t.Fatal("replacement should still spawn when retire notice delivery fails")
} }
} }
@ -620,6 +666,29 @@ func TestSpawnOrchestratorNoCleanSpawnsWhenNoneExists(t *testing.T) {
} }
} }
func TestSpawnOrchestratorVerifiesReplacementHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{
ID: "mer",
Config: domain.ProjectConfig{Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}},
}
fc := &fakeCommander{
spawnRecord: domain.SessionRecord{
ID: "mer-9",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Harness: domain.HarnessClaudeCode,
Metadata: domain.SessionMetadata{Branch: "ao/mer-orchestrator"},
},
}
svc := &Service{manager: fc, store: st}
_, err := svc.SpawnOrchestrator(context.Background(), "mer", false)
if err == nil || !strings.Contains(err.Error(), `uses harness "claude-code", want "codex"`) {
t.Fatalf("SpawnOrchestrator err = %v, want harness verification failure", err)
}
}
type fakePRClaimer struct { type fakePRClaimer struct {
out errorFreeClaimOutcome out errorFreeClaimOutcome
err error err error

View File

@ -476,6 +476,59 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
return freed, nil return freed, nil
} }
// RetireForReplacement terminates a live orchestrator and releases its branch
// for a replacement session. Unlike Kill, this captures uncommitted work before
// force-removing the worktree, so a dirty canonical orchestrator worktree does
// not block the replacement from claiming the canonical branch.
//
// This deliberately does not write a session_worktrees row: those rows are
// boot-restore markers, and a replaced orchestrator must stay terminated.
func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) error {
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return fmt.Errorf("retire replacement %s: %w", id, err)
}
if !ok || rec.IsTerminated {
return nil
}
if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" {
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err)
}
handle := runtimeHandle(rec.Metadata)
if handle.ID != "" {
if err := m.runtime.Destroy(ctx, handle); err != nil {
return fmt.Errorf("retire replacement %s: runtime: %w", id, err)
}
}
if err := m.lcm.MarkTerminated(ctx, id); err != nil {
return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err)
}
return nil
}
ws := workspaceInfo(rec)
if _, err := m.workspace.StashUncommitted(ctx, ws); err != nil {
return fmt.Errorf("retire replacement %s: stash: %w", id, err)
}
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err)
}
handle := runtimeHandle(rec.Metadata)
if handle.ID != "" {
if err := m.runtime.Destroy(ctx, handle); err != nil {
return fmt.Errorf("retire replacement %s: runtime: %w", id, err)
}
}
if err := m.workspace.ForceDestroy(ctx, ws); err != nil {
return fmt.Errorf("retire replacement %s: force destroy: %w", id, err)
}
if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil {
return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err)
}
return nil
}
// Restore relaunches a torn-down session in its workspace. The fallible I/O runs // 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 // before any durable session write, so a failure never resurrects the row or destroys
// the worktree (it may hold the agent's prior work). // the worktree (it may hold the agent's prior work).

View File

@ -114,6 +114,9 @@ func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID)
return f.worktrees[id], nil return f.worktrees[id], nil
} }
func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error { func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error {
if f.sharedLog != nil {
*f.sharedLog = append(*f.sharedLog, "DeleteSessionWorktrees:"+string(id))
}
delete(f.worktrees, id) delete(f.worktrees, id)
return nil return nil
} }
@ -148,6 +151,7 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error {
type fakeRuntime struct { type fakeRuntime struct {
createErr error createErr error
destroyErr error
created, destroyed int created, destroyed int
lastCfg ports.RuntimeConfig lastCfg ports.RuntimeConfig
// aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false.
@ -167,7 +171,7 @@ func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.
func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error { func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error {
r.destroyed++ r.destroyed++
r.destroyedIDs = append(r.destroyedIDs, handle.ID) r.destroyedIDs = append(r.destroyedIDs, handle.ID)
return nil return r.destroyErr
} }
func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) { func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) {
if r.aliveErr != nil { if r.aliveErr != nil {
@ -1427,6 +1431,130 @@ func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) {
} }
} }
func TestRetireForReplacementCapturesAndReleasesWorkspace(t *testing.T) {
m, st, rt, ws := newLifecycleManager()
var sharedLog []string
st.sharedLog = &sharedLog
ws.sharedLog = &sharedLog
ws.stashRef = "refs/ao/preserved/mer-orch"
st.sessions["mer-orch"] = domain.SessionRecord{
ID: "mer-orch",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
Activity: domain.Activity{State: domain.ActivityActive},
}
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
SessionID: "mer-orch",
RepoName: domain.RootWorkspaceRepoName,
Branch: "ao/mer-orchestrator",
WorktreePath: "/ws/mer-orch",
PreservedRef: "refs/ao/preserved/old",
}}
if err := m.RetireForReplacement(ctx, "mer-orch"); err != nil {
t.Fatalf("RetireForReplacement err = %v", err)
}
if rows := st.worktrees["mer-orch"]; len(rows) != 0 {
t.Fatalf("replacement retirement must not write restore markers, got %#v", rows)
}
if !st.sessions["mer-orch"].IsTerminated {
t.Fatal("retired orchestrator must be marked terminated")
}
if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" {
t.Fatalf("runtime destroyed = %d ids=%v, want orch-handle", rt.destroyed, rt.destroyedIDs)
}
stashIdx, deleteIdx, forceIdx := -1, -1, -1
for i, c := range sharedLog {
switch c {
case "StashUncommitted:mer-orch":
stashIdx = i
case "DeleteSessionWorktrees:mer-orch":
deleteIdx = i
case "ForceDestroy:mer-orch":
forceIdx = i
}
}
if stashIdx == -1 || deleteIdx == -1 || forceIdx == -1 {
t.Fatalf("missing expected calls in shared log: %v", sharedLog)
}
if stashIdx >= deleteIdx || deleteIdx >= forceIdx {
t.Fatalf("replacement retire must capture, clear restore marker, then force release; log=%v", sharedLog)
}
}
func TestRetireForReplacementForceDestroyFailureLeavesSessionActive(t *testing.T) {
m, st, rt, ws := newLifecycleManager()
ws.forceDestroyErr = errors.New("worktree still registered")
ws.stashRef = "refs/ao/preserved/mer-orch"
st.sessions["mer-orch"] = domain.SessionRecord{
ID: "mer-orch",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
Activity: domain.Activity{State: domain.ActivityActive},
}
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
SessionID: "mer-orch",
RepoName: domain.RootWorkspaceRepoName,
Branch: "ao/mer-orchestrator",
WorktreePath: "/ws/mer-orch",
PreservedRef: "refs/ao/preserved/old",
}}
err := m.RetireForReplacement(ctx, "mer-orch")
if err == nil || !strings.Contains(err.Error(), "force destroy") {
t.Fatalf("RetireForReplacement err = %v, want force destroy failure", err)
}
if st.sessions["mer-orch"].IsTerminated {
t.Fatal("session must remain active so retry can retire it again")
}
if rt.destroyed != 1 {
t.Fatalf("runtime destroyed = %d, want 1 before workspace release", rt.destroyed)
}
if ws.stashCalls != 1 {
t.Fatalf("stash calls = %d, want 1", ws.stashCalls)
}
}
func TestRetireForReplacementRuntimeDestroyFailureBlocksWorkspaceRelease(t *testing.T) {
m, st, rt, ws := newLifecycleManager()
rt.destroyErr = errors.New("tmux transient")
ws.stashRef = "refs/ao/preserved/mer-orch"
st.sessions["mer-orch"] = domain.SessionRecord{
ID: "mer-orch",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
Activity: domain.Activity{State: domain.ActivityActive},
}
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
SessionID: "mer-orch",
RepoName: domain.RootWorkspaceRepoName,
Branch: "ao/mer-orchestrator",
WorktreePath: "/ws/mer-orch",
PreservedRef: "refs/ao/preserved/old",
}}
err := m.RetireForReplacement(ctx, "mer-orch")
if err == nil || !strings.Contains(err.Error(), "runtime") {
t.Fatalf("RetireForReplacement err = %v, want runtime failure", err)
}
if st.sessions["mer-orch"].IsTerminated {
t.Fatal("session must remain active when runtime destroy fails")
}
if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" {
t.Fatalf("runtime destroyed = %d ids=%v, want one attempt for orch-handle", rt.destroyed, rt.destroyedIDs)
}
for _, call := range ws.calls {
if call == "ForceDestroy:mer-orch" {
t.Fatalf("ForceDestroy must not run after runtime destroy failure; calls=%v", ws.calls)
}
}
}
// TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean // TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean
// worktree (StashUncommitted returns "") still writes a worktree row (with // worktree (StashUncommitted returns "") still writes a worktree row (with
// empty preserved_ref). The row's presence is the shutdown-saved marker. // empty preserved_ref). The row's presence is the shutdown-saved marker.

View File

@ -746,6 +746,7 @@ export interface components {
id: string; id: string;
kind: string; kind: string;
name: string; name: string;
orchestratorAgent?: string;
path: string; path: string;
resolveError?: string; resolveError?: string;
sessionPrefix: string; sessionPrefix: string;

View File

@ -0,0 +1,75 @@
import * as Dialog from "@radix-ui/react-dialog";
import { useNavigate } from "@tanstack/react-router";
import { AlertTriangle, RotateCw, X } from "lucide-react";
import { findProjectOrchestrator, type WorkspaceSummary } from "../types/workspace";
type OrchestratorReplacementDialogProps = {
projectId: string | null;
error?: string;
workspaces: WorkspaceSummary[];
onOpenChange: (open: boolean) => void;
onRetry: (projectId: string) => void;
};
export function OrchestratorReplacementDialog({
projectId,
error,
workspaces,
onOpenChange,
onRetry,
}: OrchestratorReplacementDialogProps) {
const navigate = useNavigate();
const open = Boolean(projectId && error);
const orchestrator = projectId ? findProjectOrchestrator(workspaces, projectId) : undefined;
const openCurrent = () => {
if (!projectId || !orchestrator) return;
onOpenChange(false);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(460px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 shadow-lg">
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-surface-subtle text-warning">
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<Dialog.Title className="text-sm font-medium text-foreground">Orchestrator replacement failed</Dialog.Title>
<Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground">
{error ?? "The project orchestrator could not be replaced."}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground" type="button">
<X className="size-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</button>
</Dialog.Close>
</div>
<div className="mt-5 flex justify-end gap-2">
{orchestrator ? (
<button className="dashboard-app-header__primary-btn" onClick={openCurrent} type="button">
Open current orchestrator
</button>
) : null}
<button
className="dashboard-app-header__accent-btn"
onClick={() => projectId && onRetry(projectId)}
type="button"
>
<RotateCw className="size-3.5" aria-hidden="true" />
Retry
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -3,15 +3,17 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { getMock, putMock } = vi.hoisted(() => ({ const { getMock, putMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(), getMock: vi.fn(),
putMock: vi.fn(), putMock: vi.fn(),
postMock: vi.fn(),
})); }));
vi.mock("../lib/api-client", () => ({ vi.mock("../lib/api-client", () => ({
apiClient: { apiClient: {
GET: getMock, GET: getMock,
PUT: putMock, PUT: putMock,
POST: postMock,
}, },
apiErrorMessage: (error: unknown) => { apiErrorMessage: (error: unknown) => {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
@ -23,14 +25,19 @@ vi.mock("../lib/api-client", () => ({
})); }));
import { ProjectSettingsForm } from "./ProjectSettingsForm"; import { ProjectSettingsForm } from "./ProjectSettingsForm";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSummary } from "../types/workspace";
function renderSettings(projectId = "proj-1") { function renderSettings(projectId = "proj-1", workspaces?: WorkspaceSummary[]) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false },
mutations: { retry: false }, mutations: { retry: false },
}, },
}); });
if (workspaces) {
queryClient.setQueryData(workspaceQueryKey, workspaces);
}
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ProjectSettingsForm projectId={projectId} /> <ProjectSettingsForm projectId={projectId} />
@ -86,7 +93,9 @@ function mockProject(project: Record<string, unknown>) {
beforeEach(() => { beforeEach(() => {
getMock.mockReset(); getMock.mockReset();
putMock.mockReset(); putMock.mockReset();
postMock.mockReset();
putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } });
}); });
describe("ProjectSettingsForm", () => { describe("ProjectSettingsForm", () => {
@ -168,6 +177,10 @@ describe("ProjectSettingsForm", () => {
}, },
}, },
}); });
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj-1", clean: true },
});
expect(await screen.findByText("Saved.")).toBeInTheDocument(); expect(await screen.findByText("Saved.")).toBeInTheDocument();
}, 20_000); }, 20_000);
@ -195,6 +208,7 @@ describe("ProjectSettingsForm", () => {
expect(await screen.findByText("invalid permissions")).toBeInTheDocument(); expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();
}); });
it("requires worker and orchestrator agents for existing projects missing role config", async () => { it("requires worker and orchestrator agents for existing projects missing role config", async () => {
@ -323,4 +337,101 @@ describe("ProjectSettingsForm", () => {
expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2);
expect(putMock).not.toHaveBeenCalled(); expect(putMock).not.toHaveBeenCalled();
}); });
it("restarts when the saved orchestrator agent already differs from the running orchestrator", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "goose" },
},
},
},
error: undefined,
});
renderSettings("proj-1", [
{
id: "proj-1",
name: "Project One",
path: "/repo/project-one",
orchestratorAgent: "goose",
sessions: [
{
id: "proj-1-orchestrator",
workspaceId: "proj-1",
workspaceName: "Project One",
title: "Orchestrator",
provider: "claude-code",
kind: "orchestrator",
branch: "ao/proj-1-orchestrator",
status: "working",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
prs: [],
},
],
},
]);
const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" });
expect(orchestratorAgent).toHaveTextContent("goose");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj-1", clean: true },
});
});
it("keeps the config save successful when orchestrator replacement fails", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
},
},
error: undefined,
});
postMock.mockResolvedValue({
data: undefined,
error: { message: "missing goose binary" },
response: { status: 500 },
});
const queryClient = renderSettings();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" });
await chooseOption(orchestratorAgent, "goose");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Saved.")).toBeInTheDocument();
expect(await screen.findByText("Orchestrator restart failed: missing goose binary")).toBeInTheDocument();
expect(screen.queryByText("Save failed")).not.toBeInTheDocument();
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["project", "proj-1"] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
});
}); });

View File

@ -1,9 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import type { components } from "../../api/schema"; import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { newestActiveOrchestrator } from "../types/workspace";
import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { RequiredAgentField } from "./CreateProjectAgentSheet";
import { DashboardSubhead } from "./DashboardSubhead"; import { DashboardSubhead } from "./DashboardSubhead";
import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields";
@ -68,7 +70,10 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) {
function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const workspaceQuery = useWorkspaceQuery();
const config = project.config ?? {}; const config = project.config ?? {};
const workspace = workspaceQuery.data?.find((item) => item.id === projectId);
const activeOrchestrator = newestActiveOrchestrator(workspace?.sessions ?? []);
const intake: TrackerIntakeConfig = config.trackerIntake ?? {}; const intake: TrackerIntakeConfig = config.trackerIntake ?? {};
const [form, setForm] = useState({ const [form, setForm] = useState({
defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "",
@ -83,7 +88,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
intakeAssignee: intake.assignee ?? "", intakeAssignee: intake.assignee ?? "",
}); });
const [savedAt, setSavedAt] = useState<number | null>(null); const [savedAt, setSavedAt] = useState<number | null>(null);
const [replacementError, setReplacementError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const initialOrchestratorAgent = config.orchestrator?.agent ?? "";
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
const agentsQuery = useQuery(agentsQueryOptions); const agentsQuery = useQuery(agentsQueryOptions);
const agentCatalog = agentsQuery.data; const agentCatalog = agentsQuery.data;
@ -134,9 +141,23 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
body: { config: next }, body: { config: next },
}); });
if (error) throw new Error(apiErrorMessage(error)); if (error) throw new Error(apiErrorMessage(error));
if (
form.orchestratorAgent !== initialOrchestratorAgent ||
(activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent)
) {
try {
await spawnOrchestrator(projectId, true);
} catch (error) {
return {
replacementError: error instanceof Error ? error.message : "Could not replace orchestrator",
};
}
}
return { replacementError: null };
}, },
onSuccess: () => { onSuccess: (result) => {
setSavedAt(Date.now()); setSavedAt(Date.now());
setReplacementError(result.replacementError);
setValidationError(null); setValidationError(null);
void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved(); onSaved();
@ -149,6 +170,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
setSavedAt(null); setSavedAt(null);
setReplacementError(null);
if (missingRequiredAgent) { if (missingRequiredAgent) {
setValidationError("Worker and orchestrator agents are required."); setValidationError("Worker and orchestrator agents are required.");
return; return;
@ -304,6 +326,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
{savedAt && !mutation.isPending && !mutation.isError && ( {savedAt && !mutation.isPending && !mutation.isError && (
<span className="text-[12px] text-success">Saved.</span> <span className="text-[12px] text-success">Saved.</span>
)} )}
{replacementError && !mutation.isPending && !mutation.isError && (
<span className="text-[12px] text-warning">Orchestrator restart failed: {replacementError}</span>
)}
</div> </div>
</form> </form>
); );

View File

@ -1,14 +1,15 @@
import { type KeyboardEvent, useState } from "react"; import { type KeyboardEvent, useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Plus, RotateCw } from "lucide-react";
import { DashboardSubhead } from "./DashboardSubhead"; import { DashboardSubhead } from "./DashboardSubhead";
import { Plus } from "lucide-react";
import { import {
type AttentionZone, type AttentionZone,
type WorkspaceSession, type WorkspaceSession,
attentionZone, attentionZone,
canonicalTrackerIssueId, canonicalTrackerIssueId,
newestActiveOrchestrator,
orchestratorHealth,
workerSessions, workerSessions,
} from "../types/workspace"; } from "../types/workspace";
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
@ -16,9 +17,11 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery
import { OrchestratorIcon } from "./icons"; import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog"; import { NewTaskDialog } from "./NewTaskDialog";
import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
import { useUiStore } from "../stores/ui-store";
type SessionsBoardProps = { type SessionsBoardProps = {
/** When set, the board shows only this project's sessions. */ /** When set, the board shows only this project's sessions. */
@ -77,12 +80,16 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
const workspaceQuery = useWorkspaceQuery(); const workspaceQuery = useWorkspaceQuery();
const all = workspaceQuery.data ?? []; const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
const workspace = projectId ? workspaces[0] : undefined;
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
const orchestrator = projectId const orchestrator = projectId ? newestActiveOrchestrator(workspaces[0]?.sessions ?? []) : undefined;
? workspaces[0]?.sessions.find((session) => session.kind === "orchestrator" && session.status !== "terminated")
: undefined;
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const [isSpawning, setIsSpawning] = useState(false); const [isSpawning, setIsSpawning] = useState(false);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
const health = workspace ? orchestratorHealth(workspace, isProjectRestarting) : { state: "ok" as const };
const byZone = new Map<AttentionZone, WorkspaceSession[]>(); const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) { for (const session of sessions) {
@ -101,7 +108,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
}); });
const openOrchestrator = async () => { const openOrchestrator = async () => {
if (!projectId) return; if (!projectId || isProjectRestarting) return;
if (orchestrator) { if (orchestrator) {
void navigate({ void navigate({
to: "/projects/$projectId/sessions/$sessionId", to: "/projects/$projectId/sessions/$sessionId",
@ -122,6 +129,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
} }
}; };
const restartOrchestrator = async () => {
if (!projectId) return;
await restartProjectOrchestrator({
projectId,
queryClient,
navigate,
setProjectRestarting,
setOrchestratorReplacementError,
});
};
const handleTaskCreated = async (sessionId: string) => { const handleTaskCreated = async (sessionId: string) => {
if (!projectId) return; if (!projectId) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
@ -136,6 +154,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
<button <button
aria-label="New task" aria-label="New task"
className="dashboard-app-header__accent-btn" className="dashboard-app-header__accent-btn"
disabled={isProjectRestarting}
onClick={() => setIsNewTaskOpen(true)} onClick={() => setIsNewTaskOpen(true)}
type="button" type="button"
> >
@ -145,12 +164,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
<button <button
aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"} aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
className="dashboard-app-header__primary-btn" className="dashboard-app-header__primary-btn"
disabled={isSpawning} disabled={isSpawning || isProjectRestarting}
onClick={() => void openOrchestrator()} onClick={() => void openOrchestrator()}
type="button" type="button"
> >
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> <OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"} {isProjectRestarting ? "Restarting..." : isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
</button> </button>
</> </>
) : undefined; ) : undefined;
@ -164,6 +183,23 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
/> />
<div className="min-h-0 flex-1 overflow-hidden p-[18px]"> <div className="min-h-0 flex-1 overflow-hidden p-[18px]">
{projectId && health.state !== "ok" ? (
<div className="mb-3 flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-[12px] text-muted-foreground">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden="true" />
<span className="min-w-0 flex-1">{health.message}</span>
{health.state === "restart_needed" || health.state === "duplicates" ? (
<button
className="dashboard-app-header__primary-btn"
disabled={isProjectRestarting}
onClick={() => void restartOrchestrator()}
type="button"
>
<RotateCw className="size-3.5" aria-hidden="true" />
Restart
</button>
) : null}
</div>
) : null}
{workspaceQuery.isError ? ( {workspaceQuery.isError ? (
<p className="py-10 text-center text-[12px] text-passive">Could not load sessions.</p> <p className="py-10 text-center text-[12px] text-passive">Could not load sessions.</p>
) : ( ) : (

View File

@ -55,6 +55,7 @@ export function ShellTopbar() {
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string }; const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen); const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector); const toggleInspector = useUiStore((state) => state.toggleInspector);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const [isSpawning, setIsSpawning] = useState(false); const [isSpawning, setIsSpawning] = useState(false);
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const all = useWorkspaceQuery().data ?? []; const all = useWorkspaceQuery().data ?? [];
@ -74,17 +75,18 @@ export function ShellTopbar() {
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined; const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator"); const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined; const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
const openBoard = () => const openBoard = () =>
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });
const openNewTask = () => { const openNewTask = () => {
if (!projectId) return; if (!projectId || isProjectRestarting) return;
setIsNewTaskOpen(true); setIsNewTaskOpen(true);
}; };
const handleTaskCreated = async (sessionId: string) => { const handleTaskCreated = async (sessionId: string) => {
if (!projectId) return; if (!projectId || isProjectRestarting) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({ void navigate({
to: "/projects/$projectId/sessions/$sessionId", to: "/projects/$projectId/sessions/$sessionId",
@ -171,6 +173,7 @@ export function ShellTopbar() {
<button <button
aria-label="New task" aria-label="New task"
className="dashboard-app-header__primary-btn" className="dashboard-app-header__primary-btn"
disabled={isProjectRestarting}
onClick={openNewTask} onClick={openNewTask}
style={noDragStyle} style={noDragStyle}
type="button" type="button"
@ -197,13 +200,13 @@ export function ShellTopbar() {
<button <button
aria-label="Open orchestrator" aria-label="Open orchestrator"
className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact" className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact"
disabled={isSpawning} disabled={isSpawning || isProjectRestarting}
onClick={() => void openOrchestrator()} onClick={() => void openOrchestrator()}
style={noDragStyle} style={noDragStyle}
type="button" type="button"
> >
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> <OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Orchestrator"} {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : "Orchestrator"}
</button> </button>
)} )}
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */}

View File

@ -16,7 +16,7 @@ import {
import { useRef, useState, type ReactNode } from "react"; import { useRef, useState, type ReactNode } from "react";
import { import {
attentionZone, attentionZone,
isOrchestratorSession, newestActiveOrchestrator,
sessionIsActive, sessionIsActive,
type WorkspaceSession, type WorkspaceSession,
type WorkspaceSummary, type WorkspaceSummary,
@ -426,16 +426,19 @@ function ProjectItem({
const [removeError, setRemoveError] = useState<string | null>(null); const [removeError, setRemoveError] = useState<string | null>(null);
const [isRemoving, setIsRemoving] = useState(false); const [isRemoving, setIsRemoving] = useState(false);
const [isSpawning, setIsSpawning] = useState(false); const [isSpawning, setIsSpawning] = useState(false);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const isProjectRestarting = restartingProjectIds.has(workspace.id);
// Live workers only: merged/terminated sessions leave the sidebar and stay // Live workers only: merged/terminated sessions leave the sidebar and stay
// reachable through the board's Done / Terminated bar (SessionsBoard). // reachable through the board's Done / Terminated bar (SessionsBoard).
const sessions = workerSessions(workspace.sessions).filter(sessionIsActive); const sessions = workerSessions(workspace.sessions).filter(sessionIsActive);
// The project's live orchestrator (if any) backs the hover Orchestrator // The project's live orchestrator (if any) backs the hover Orchestrator
// button: navigate to it when present, otherwise spawn one first. // button: navigate to it when present, otherwise spawn one first.
const orchestrator = workspace.sessions.find((s) => isOrchestratorSession(s) && sessionIsActive(s)); const orchestrator = newestActiveOrchestrator(workspace.sessions);
// Mirrors ShellTopbar's launcher: attach to the running orchestrator, or // Mirrors ShellTopbar's launcher: attach to the running orchestrator, or
// spawn one via the daemon and follow it once the workspace refetches. // spawn one via the daemon and follow it once the workspace refetches.
const openOrchestrator = async () => { const openOrchestrator = async () => {
if (isProjectRestarting) return;
if (orchestrator) { if (orchestrator) {
selection.goSession(workspace.id, orchestrator.id); selection.goSession(workspace.id, orchestrator.id);
return; return;
@ -546,7 +549,7 @@ function ProjectItem({
<button <button
aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`} aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`}
className={HOVER_ACTION_CLASS} className={HOVER_ACTION_CLASS}
disabled={isSpawning} disabled={isSpawning || isProjectRestarting}
onClick={() => void openOrchestrator()} onClick={() => void openOrchestrator()}
type="button" type="button"
> >
@ -554,7 +557,7 @@ function ProjectItem({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu> <DropdownMenu>

View File

@ -51,7 +51,16 @@ describe("useWorkspaceQuery", () => {
it("maps projects and their sessions, applying provider/status/title fallbacks", async () => { it("maps projects and their sessions, applying provider/status/title fallbacks", async () => {
respondWith({ respondWith({
projects: { projects: {
data: { projects: [{ id: "proj-1", name: "my-app", path: "/home/me/my-app" }] }, data: {
projects: [
{
id: "proj-1",
name: "my-app",
path: "/home/me/my-app",
orchestratorAgent: "codex",
},
],
},
error: undefined, error: undefined,
}, },
sessions: { sessions: {
@ -92,7 +101,7 @@ describe("useWorkspaceQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? []; const [workspace] = result.current.data ?? [];
expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app" }); expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" });
expect(workspace.sessions).toHaveLength(2); expect(workspace.sessions).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({ expect(workspace.sessions[0]).toMatchObject({
id: "sess-1", id: "sess-1",

View File

@ -44,6 +44,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
id: project.id, id: project.id,
name: project.name, name: project.name,
path: project.path, path: project.path,
orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined,
sessions: (sessionsData?.sessions ?? []) sessions: (sessionsData?.sessions ?? [])
.filter((session) => session.projectId === project.id) .filter((session) => session.projectId === project.id)
.map((session) => ({ .map((session) => ({

View File

@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
const { captureRendererExceptionMock } = vi.hoisted(() => ({
captureRendererExceptionMock: vi.fn(),
}));
vi.mock("./telemetry", () => ({
captureRendererException: captureRendererExceptionMock,
}));
import { captureOrchestratorReplacementFailure } from "./orchestrator-replacement-telemetry";
describe("captureOrchestratorReplacementFailure", () => {
it("records the shell restart-failure telemetry payload", () => {
const error = new Error("missing goose binary");
captureOrchestratorReplacementFailure(error, "proj-1");
expect(captureRendererExceptionMock).toHaveBeenCalledWith(error, {
source: "orchestrator-replace",
operation: "replace_orchestrator",
surface: "shell",
project_id: "proj-1",
});
});
});

View File

@ -0,0 +1,10 @@
import { captureRendererException } from "./telemetry";
export function captureOrchestratorReplacementFailure(error: unknown, projectId: string) {
void captureRendererException(error, {
source: "orchestrator-replace",
operation: "replace_orchestrator",
surface: "shell",
project_id: projectId,
});
}

View File

@ -0,0 +1,73 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
}));
vi.mock("./spawn-orchestrator", () => ({
spawnOrchestrator: spawnMock,
}));
import { restartProjectOrchestrator } from "./restart-orchestrator";
describe("restartProjectOrchestrator", () => {
beforeEach(() => {
spawnMock.mockReset();
});
it("invalidates workspace state and records an error when clean restart fails", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue();
const navigate = vi.fn();
const setProjectRestarting = vi.fn();
const setOrchestratorReplacementError = vi.fn();
const onError = vi.fn();
const failure = new Error("missing goose binary");
spawnMock.mockRejectedValue(failure);
await restartProjectOrchestrator({
projectId: "proj-1",
queryClient,
navigate,
setProjectRestarting,
setOrchestratorReplacementError,
onError,
});
expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
expect(setProjectRestarting).toHaveBeenNthCalledWith(1, "proj-1", true);
expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false);
expect(onError).toHaveBeenCalledWith(failure);
expect(navigate).not.toHaveBeenCalled();
});
it("still records the replacement error when workspace invalidation fails", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.spyOn(queryClient, "invalidateQueries").mockRejectedValue(new Error("refetch failed"));
const navigate = vi.fn();
const setProjectRestarting = vi.fn();
const setOrchestratorReplacementError = vi.fn();
const onError = vi.fn();
const failure = new Error("missing goose binary");
spawnMock.mockRejectedValue(failure);
await restartProjectOrchestrator({
projectId: "proj-1",
queryClient,
navigate,
setProjectRestarting,
setOrchestratorReplacementError,
onError,
});
expect(setOrchestratorReplacementError).toHaveBeenLastCalledWith("proj-1", "missing goose binary");
expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false);
expect(onError).toHaveBeenCalledWith(failure);
expect(navigate).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,52 @@
import type { QueryClient } from "@tanstack/react-query";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "./spawn-orchestrator";
type NavigateToSession = (options: {
to: "/projects/$projectId/sessions/$sessionId";
params: { projectId: string; sessionId: string };
}) => unknown;
type RestartProjectOrchestratorOptions = {
projectId: string;
queryClient: QueryClient;
navigate: NavigateToSession;
setProjectRestarting: (projectId: string, restarting: boolean) => void;
setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
onError?: (error: unknown) => void;
};
async function refreshWorkspaceState(queryClient: QueryClient) {
try {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch {
// The restart outcome is more important than cache refresh bookkeeping:
// callers still need navigation/error state even if refetching fails.
}
}
export async function restartProjectOrchestrator({
projectId,
queryClient,
navigate,
setProjectRestarting,
setOrchestratorReplacementError,
onError,
}: RestartProjectOrchestratorOptions) {
setProjectRestarting(projectId, true);
setOrchestratorReplacementError(projectId, null);
try {
const sessionId = await spawnOrchestrator(projectId, true);
await refreshWorkspaceState(queryClient);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
await refreshWorkspaceState(queryClient);
setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator");
onError?.(error);
} finally {
setProjectRestarting(projectId, false);
}
}

View File

@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { type CSSProperties, useCallback, useEffect, useRef } from "react";
import { ShellTopbar } from "../components/ShellTopbar"; import { ShellTopbar } from "../components/ShellTopbar";
import { OrchestratorReplacementDialog } from "../components/OrchestratorReplacementDialog";
import { Sidebar } from "../components/Sidebar"; import { Sidebar } from "../components/Sidebar";
import { SidebarProvider } from "../components/ui/sidebar"; import { SidebarProvider } from "../components/ui/sidebar";
import { TitlebarNav } from "../components/TitlebarNav"; import { TitlebarNav } from "../components/TitlebarNav";
@ -13,6 +14,8 @@ import { refreshDaemonStatus } from "../lib/daemon-status";
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { ShellProvider } from "../lib/shell-context"; import { ShellProvider } from "../lib/shell-context";
import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
import { captureOrchestratorReplacementFailure } from "../lib/orchestrator-replacement-telemetry";
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
import type { WorkspaceSummary } from "../types/workspace"; import type { WorkspaceSummary } from "../types/workspace";
import type { components } from "../../api/schema"; import type { components } from "../../api/schema";
@ -48,6 +51,10 @@ function ShellLayout() {
const daemonStatus = useDaemonStatus(queryClient); const daemonStatus = useDaemonStatus(queryClient);
const agentCatalogPortRef = useRef<number | undefined>(undefined); const agentCatalogPortRef = useRef<number | undefined>(undefined);
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors);
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null;
const updateWorkspaces = useCallback( const updateWorkspaces = useCallback(
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => { (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
@ -99,6 +106,7 @@ function ShellLayout() {
name: data.project.name, name: data.project.name,
path: data.project.path, path: data.project.path,
type: "main", type: "main",
orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"],
sessions: [], sessions: [],
}; };
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id }); void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
@ -146,6 +154,22 @@ function ShellLayout() {
[updateWorkspaces], [updateWorkspaces],
); );
const restartOrchestrator = useCallback(
async (projectId: string) => {
await restartProjectOrchestrator({
projectId,
queryClient,
navigate,
setProjectRestarting,
setOrchestratorReplacementError,
onError: (error) => {
captureOrchestratorReplacementFailure(error, projectId);
},
});
},
[navigate, queryClient, setOrchestratorReplacementError, setProjectRestarting],
);
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
@ -229,6 +253,15 @@ function ShellLayout() {
by window-drag even though DOM hit-testing looks correct. */} by window-drag even though DOM hit-testing looks correct. */}
<TitlebarNav /> <TitlebarNav />
</SidebarProvider> </SidebarProvider>
<OrchestratorReplacementDialog
error={replacementErrorProjectId ? orchestratorReplacementErrors[replacementErrorProjectId] : undefined}
onOpenChange={(open) => {
if (!open && replacementErrorProjectId) setOrchestratorReplacementError(replacementErrorProjectId, null);
}}
onRetry={(projectId) => void restartOrchestrator(projectId)}
projectId={replacementErrorProjectId}
workspaces={workspaces}
/>
</div> </div>
</ShellProvider> </ShellProvider>
); );

View File

@ -13,11 +13,15 @@ type UiState = {
isSidebarOpen: boolean; isSidebarOpen: boolean;
isInspectorOpen: boolean; isInspectorOpen: boolean;
theme: Theme; theme: Theme;
restartingProjectIds: ReadonlySet<string>;
orchestratorReplacementErrors: Record<string, string>;
setWorkbenchTab: (tab: WorkbenchTab) => void; setWorkbenchTab: (tab: WorkbenchTab) => void;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
toggleSidebar: () => void; toggleSidebar: () => void;
toggleInspector: () => void; toggleInspector: () => void;
setProjectRestarting: (projectId: string, restarting: boolean) => void;
setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
}; };
const sidebarStorageKey = "ao.sidebar.open"; const sidebarStorageKey = "ao.sidebar.open";
@ -58,6 +62,8 @@ export const useUiStore = create<UiState>((set) => ({
isSidebarOpen: initialSidebarOpen(), isSidebarOpen: initialSidebarOpen(),
isInspectorOpen: initialInspectorOpen(), isInspectorOpen: initialInspectorOpen(),
theme: initialTheme(), theme: initialTheme(),
restartingProjectIds: new Set<string>(),
orchestratorReplacementErrors: {},
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }), setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
setTheme: (theme) => { setTheme: (theme) => {
getLocalStorage()?.setItem(themeStorageKey, theme); getLocalStorage()?.setItem(themeStorageKey, theme);
@ -81,4 +87,24 @@ export const useUiStore = create<UiState>((set) => ({
getLocalStorage()?.setItem(inspectorStorageKey, String(isInspectorOpen)); getLocalStorage()?.setItem(inspectorStorageKey, String(isInspectorOpen));
return { isInspectorOpen }; return { isInspectorOpen };
}), }),
setProjectRestarting: (projectId, restarting) =>
set((state) => {
const restartingProjectIds = new Set(state.restartingProjectIds);
if (restarting) {
restartingProjectIds.add(projectId);
} else {
restartingProjectIds.delete(projectId);
}
return { restartingProjectIds };
}),
setOrchestratorReplacementError: (projectId, message) =>
set((state) => {
const orchestratorReplacementErrors = { ...state.orchestratorReplacementErrors };
if (message) {
orchestratorReplacementErrors[projectId] = message;
} else {
delete orchestratorReplacementErrors[projectId];
}
return { orchestratorReplacementErrors };
}),
})); }));

View File

@ -3,6 +3,8 @@ import {
attentionZone, attentionZone,
canonicalTrackerIssueId, canonicalTrackerIssueId,
findProjectOrchestrator, findProjectOrchestrator,
newestActiveOrchestrator,
orchestratorHealth,
sessionIsActive, sessionIsActive,
sessionNeedsAttention, sessionNeedsAttention,
toAgentProvider, toAgentProvider,
@ -144,6 +146,50 @@ describe("findProjectOrchestrator", () => {
const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" }); const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" });
expect(findProjectOrchestrator([workspaceWith([live])], "other")).toBeUndefined(); expect(findProjectOrchestrator([workspaceWith([live])], "other")).toBeUndefined();
}); });
it("selects the newest active orchestrator, not the first active one", () => {
const older = sessionWith({
id: "skills-1",
kind: "orchestrator",
status: "working",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
const newer = sessionWith({
id: "skills-2",
kind: "orchestrator",
status: "working",
createdAt: "2026-01-02T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer);
});
it("uses updatedAt and id as newest orchestrator tie breakers", () => {
const oldUpdate = sessionWith({
id: "skills-2",
kind: "orchestrator",
status: "working",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
const newUpdate = sessionWith({
id: "skills-1",
kind: "orchestrator",
status: "working",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
const sameTimesHigherID = sessionWith({
id: "skills-3",
kind: "orchestrator",
status: "working",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
expect(newestActiveOrchestrator([oldUpdate, newUpdate])).toBe(newUpdate);
expect(newestActiveOrchestrator([newUpdate, sameTimesHigherID])).toBe(sameTimesHigherID);
});
}); });
describe("sessionNeedsAttention", () => { describe("sessionNeedsAttention", () => {
@ -164,6 +210,50 @@ describe("sessionNeedsAttention", () => {
}); });
}); });
describe("orchestratorHealth", () => {
it("reports restart_needed when the configured orchestrator agent differs from the newest active orchestrator", () => {
const older = sessionWith({
id: "skills-1",
kind: "orchestrator",
provider: "codex",
status: "working",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
const newest = sessionWith({
id: "skills-2",
kind: "orchestrator",
provider: "claude-code",
status: "working",
createdAt: "2026-01-02T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
expect(
orchestratorHealth({
id: "skills",
name: "skills",
path: "/tmp/skills",
orchestratorAgent: "codex",
sessions: [older, newest],
}),
).toEqual({
state: "duplicates",
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
});
expect(
orchestratorHealth({
id: "skills",
name: "skills",
path: "/tmp/skills",
orchestratorAgent: "codex",
sessions: [newest],
}).state,
).toBe("restart_needed");
});
});
describe("workerStatusPulses", () => { describe("workerStatusPulses", () => {
it("pulses only for working and needs_you", () => { it("pulses only for working and needs_you", () => {
expect(workerStatusPulses("working")).toBe(true); expect(workerStatusPulses("working")).toBe(true);

View File

@ -241,14 +241,31 @@ export function findProjectOrchestrator(
projectId: string, projectId: string,
): WorkspaceSession | undefined { ): WorkspaceSession | undefined {
const workspace = workspaces.find((w) => w.id === projectId); const workspace = workspaces.find((w) => w.id === projectId);
if (!workspace) return undefined; return newestActiveOrchestrator(workspace?.sessions ?? []);
for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { }
const session = workspace.sessions[i];
if (isOrchestratorSession(session) && sessionIsActive(session)) { export function newestActiveOrchestrator(sessions: WorkspaceSession[]): WorkspaceSession | undefined {
return session; const active = sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session));
} return active.reduce<WorkspaceSession | undefined>(
} (newest, session) => (!newest || sessionNewer(session, newest) ? session : newest),
return undefined; undefined,
);
}
function sessionNewer(a: WorkspaceSession, b: WorkspaceSession): boolean {
const aCreated = timestamp(a.createdAt);
const bCreated = timestamp(b.createdAt);
if (aCreated !== bCreated) return aCreated > bCreated;
const aUpdated = timestamp(a.updatedAt);
const bUpdated = timestamp(b.updatedAt);
if (aUpdated !== bUpdated) return aUpdated > bUpdated;
return a.id > b.id;
}
function timestamp(value?: string): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
} }
export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] { export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {
@ -340,6 +357,7 @@ export type WorkspaceSummary = {
name: string; name: string;
path: string; path: string;
type?: "main" | "worktree"; type?: "main" | "worktree";
orchestratorAgent?: AgentProvider;
accentColor?: string; accentColor?: string;
diff?: { diff?: {
additions: number; additions: number;
@ -348,6 +366,42 @@ export type WorkspaceSummary = {
sessions: WorkspaceSession[]; sessions: WorkspaceSession[];
}; };
export function orchestratorNeedsRestart(workspace: WorkspaceSummary, orchestrator?: WorkspaceSession): boolean {
if (!orchestrator || !workspace.orchestratorAgent) return false;
return orchestrator.provider !== workspace.orchestratorAgent;
}
export type OrchestratorHealth =
| { state: "ok" }
| { state: "restarting"; message: string }
| { state: "restart_needed"; message: string }
| { state: "missing"; message: string }
| { state: "duplicates"; message: string };
export function orchestratorHealth(workspace: WorkspaceSummary, restarting = false): OrchestratorHealth {
if (restarting) {
return { state: "restarting", message: "Restarting orchestrator. New tasks wait until the replacement is ready." };
}
const active = workspace.sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session));
if (active.length > 1) {
return {
state: "duplicates",
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
};
}
const orchestrator = newestActiveOrchestrator(workspace.sessions);
if (!orchestrator) {
return { state: "missing", message: "No orchestrator is running for this project." };
}
if (orchestratorNeedsRestart(workspace, orchestrator)) {
return {
state: "restart_needed",
message: `Configured orchestrator agent is ${workspace.orchestratorAgent}; running agent is ${orchestrator.provider}.`,
};
}
return { state: "ok" };
}
export function toAgentProvider(provider?: string): AgentProvider { export function toAgentProvider(provider?: string): AgentProvider {
switch (provider) { switch (provider) {
case "claude-code": case "claude-code":