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:
parent
a639e2025c
commit
cc75a519ab
|
|
@ -2069,6 +2069,8 @@ components:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
orchestratorAgent:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
resolveError:
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
|
|||
Path: row.Path,
|
||||
Kind: row.Kind.WithDefault(),
|
||||
SessionPrefix: resolveSessionPrefix(row),
|
||||
OrchestratorAgent: row.Config.Orchestrator.Harness,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
|
@ -390,13 +391,18 @@ func (m *Service) projectFromRow(row domain.ProjectRecord) Project {
|
|||
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
|
||||
Agent: string(m.defaultHarness),
|
||||
}
|
||||
if !row.Config.IsZero() {
|
||||
cfg := row.Config
|
||||
p.Config = &cfg
|
||||
}
|
||||
p.Config = projectConfigPtr(row.Config)
|
||||
return p
|
||||
}
|
||||
|
||||
func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig {
|
||||
if projectConfig.IsZero() {
|
||||
return nil
|
||||
}
|
||||
cfg := projectConfig
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func displayName(row domain.ProjectRecord) string {
|
||||
if strings.TrimSpace(row.DisplayName) != "" {
|
||||
return row.DisplayName
|
||||
|
|
|
|||
|
|
@ -436,6 +436,32 @@ func TestManager_SetConfig(t *testing.T) {
|
|||
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) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type Summary struct {
|
|||
Path string `json:"path"`
|
||||
Kind domain.ProjectKind `json:"kind"`
|
||||
SessionPrefix string `json:"sessionPrefix"`
|
||||
OrchestratorAgent domain.AgentHarness `json:"orchestratorAgent,omitempty"`
|
||||
ResolveError string `json:"resolveError,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, 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
|
||||
Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error)
|
||||
RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error)
|
||||
|
|
@ -87,6 +89,8 @@ type Service struct {
|
|||
scm scmProvider
|
||||
clock func() time.Time
|
||||
telemetry ports.EventSink
|
||||
orchestratorLocksMu sync.Mutex
|
||||
orchestratorLocks map[domain.ProjectID]*sync.Mutex
|
||||
// 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 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
|
||||
// belongs here, not in the HTTP controller.
|
||||
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
|
||||
if clean {
|
||||
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
|
||||
}
|
||||
for _, orch := range existing {
|
||||
if _, err := s.Kill(ctx, orch.ID); err != nil {
|
||||
return domain.Session{}, err
|
||||
_ = s.sendRetireNotice(ctx, orch.ID)
|
||||
if err := s.manager.RetireForReplacement(ctx, orch.ID); err != nil {
|
||||
return domain.Session{}, toAPIError(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -281,10 +293,90 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec
|
|||
return domain.Session{}, err
|
||||
}
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -201,10 +202,15 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
|
|||
// clean-orchestrator ordering without wiring a real session engine.
|
||||
type fakeCommander struct {
|
||||
killed []domain.SessionID
|
||||
retired []domain.SessionID
|
||||
sent []domain.SessionID
|
||||
cleanupProjects []domain.ProjectID
|
||||
killErr error
|
||||
retireErr error
|
||||
sendErr error
|
||||
cleanupErr error
|
||||
spawnErr error
|
||||
spawnRecord domain.SessionRecord
|
||||
spawned bool
|
||||
killsAtSpawn int
|
||||
}
|
||||
|
|
@ -214,7 +220,10 @@ func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.
|
|||
return domain.SessionRecord{}, f.spawnErr
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
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) {
|
||||
f.cleanupProjects = append(f.cleanupProjects, project)
|
||||
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.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
||||
// Two active orchestrators plus an unrelated worker and a terminated
|
||||
|
|
@ -310,11 +332,35 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T)
|
|||
t.Fatalf("SpawnOrchestrator: %v", err)
|
||||
}
|
||||
|
||||
if len(fc.killed) != 2 {
|
||||
t.Fatalf("killed = %v, want the two active orchestrators", fc.killed)
|
||||
if len(fc.retired) != 2 {
|
||||
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 {
|
||||
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 {
|
||||
out errorFreeClaimOutcome
|
||||
err error
|
||||
|
|
|
|||
|
|
@ -476,6 +476,59 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
|
|||
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
|
||||
// before any durable session write, so a failure never resurrects the row or destroys
|
||||
// the worktree (it may hold the agent's prior work).
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID)
|
|||
return f.worktrees[id], nil
|
||||
}
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -148,6 +151,7 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error {
|
|||
|
||||
type fakeRuntime struct {
|
||||
createErr error
|
||||
destroyErr error
|
||||
created, destroyed int
|
||||
lastCfg ports.RuntimeConfig
|
||||
// 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 {
|
||||
r.destroyed++
|
||||
r.destroyedIDs = append(r.destroyedIDs, handle.ID)
|
||||
return nil
|
||||
return r.destroyErr
|
||||
}
|
||||
func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) {
|
||||
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
|
||||
// worktree (StashUncommitted returns "") still writes a worktree row (with
|
||||
// empty preserved_ref). The row's presence is the shutdown-saved marker.
|
||||
|
|
|
|||
|
|
@ -746,6 +746,7 @@ export interface components {
|
|||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
orchestratorAgent?: string;
|
||||
path: string;
|
||||
resolveError?: string;
|
||||
sessionPrefix: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,15 +3,17 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getMock, putMock } = vi.hoisted(() => ({
|
||||
const { getMock, putMock, postMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
putMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/api-client", () => ({
|
||||
apiClient: {
|
||||
GET: getMock,
|
||||
PUT: putMock,
|
||||
POST: postMock,
|
||||
},
|
||||
apiErrorMessage: (error: unknown) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
|
@ -23,14 +25,19 @@ vi.mock("../lib/api-client", () => ({
|
|||
}));
|
||||
|
||||
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({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
if (workspaces) {
|
||||
queryClient.setQueryData(workspaceQueryKey, workspaces);
|
||||
}
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProjectSettingsForm projectId={projectId} />
|
||||
|
|
@ -86,7 +93,9 @@ function mockProject(project: Record<string, unknown>) {
|
|||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
putMock.mockReset();
|
||||
postMock.mockReset();
|
||||
putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
|
||||
postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } });
|
||||
});
|
||||
|
||||
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();
|
||||
}, 20_000);
|
||||
|
||||
|
|
@ -195,6 +208,7 @@ describe("ProjectSettingsForm", () => {
|
|||
|
||||
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
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 { DashboardSubhead } from "./DashboardSubhead";
|
||||
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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceQuery = useWorkspaceQuery();
|
||||
const config = project.config ?? {};
|
||||
const workspace = workspaceQuery.data?.find((item) => item.id === projectId);
|
||||
const activeOrchestrator = newestActiveOrchestrator(workspace?.sessions ?? []);
|
||||
const intake: TrackerIntakeConfig = config.trackerIntake ?? {};
|
||||
const [form, setForm] = useState({
|
||||
defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "",
|
||||
|
|
@ -83,7 +88,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
intakeAssignee: intake.assignee ?? "",
|
||||
});
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||
const [replacementError, setReplacementError] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const initialOrchestratorAgent = config.orchestrator?.agent ?? "";
|
||||
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
|
||||
const agentsQuery = useQuery(agentsQueryOptions);
|
||||
const agentCatalog = agentsQuery.data;
|
||||
|
|
@ -134,9 +141,23 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
body: { config: next },
|
||||
});
|
||||
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());
|
||||
setReplacementError(result.replacementError);
|
||||
setValidationError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||
onSaved();
|
||||
|
|
@ -149,6 +170,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSavedAt(null);
|
||||
setReplacementError(null);
|
||||
if (missingRequiredAgent) {
|
||||
setValidationError("Worker and orchestrator agents are required.");
|
||||
return;
|
||||
|
|
@ -304,6 +326,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
{savedAt && !mutation.isPending && !mutation.isError && (
|
||||
<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>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { type KeyboardEvent, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { AlertTriangle, Plus, RotateCw } from "lucide-react";
|
||||
import { DashboardSubhead } from "./DashboardSubhead";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
type AttentionZone,
|
||||
type WorkspaceSession,
|
||||
attentionZone,
|
||||
canonicalTrackerIssueId,
|
||||
newestActiveOrchestrator,
|
||||
orchestratorHealth,
|
||||
workerSessions,
|
||||
} from "../types/workspace";
|
||||
import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary";
|
||||
|
|
@ -16,9 +17,11 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery
|
|||
import { OrchestratorIcon } from "./icons";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
|
||||
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
|
||||
import { useUiStore } from "../stores/ui-store";
|
||||
|
||||
type SessionsBoardProps = {
|
||||
/** When set, the board shows only this project's sessions. */
|
||||
|
|
@ -77,12 +80,16 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
const workspaceQuery = useWorkspaceQuery();
|
||||
const all = workspaceQuery.data ?? [];
|
||||
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 orchestrator = projectId
|
||||
? workspaces[0]?.sessions.find((session) => session.kind === "orchestrator" && session.status !== "terminated")
|
||||
: undefined;
|
||||
const orchestrator = projectId ? newestActiveOrchestrator(workspaces[0]?.sessions ?? []) : undefined;
|
||||
const [isNewTaskOpen, setIsNewTaskOpen] = 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[]>();
|
||||
for (const session of sessions) {
|
||||
|
|
@ -101,7 +108,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
});
|
||||
|
||||
const openOrchestrator = async () => {
|
||||
if (!projectId) return;
|
||||
if (!projectId || isProjectRestarting) return;
|
||||
if (orchestrator) {
|
||||
void navigate({
|
||||
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) => {
|
||||
if (!projectId) return;
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
|
|
@ -136,6 +154,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
<button
|
||||
aria-label="New task"
|
||||
className="dashboard-app-header__accent-btn"
|
||||
disabled={isProjectRestarting}
|
||||
onClick={() => setIsNewTaskOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -145,12 +164,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
<button
|
||||
aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
|
||||
className="dashboard-app-header__primary-btn"
|
||||
disabled={isSpawning}
|
||||
disabled={isSpawning || isProjectRestarting}
|
||||
onClick={() => void openOrchestrator()}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
) : undefined;
|
||||
|
|
@ -164,6 +183,23 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
/>
|
||||
|
||||
<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 ? (
|
||||
<p className="py-10 text-center text-[12px] text-passive">Could not load sessions.</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export function ShellTopbar() {
|
|||
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
|
||||
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
|
||||
const toggleInspector = useUiStore((state) => state.toggleInspector);
|
||||
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
|
||||
const [isSpawning, setIsSpawning] = useState(false);
|
||||
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
|
||||
const all = useWorkspaceQuery().data ?? [];
|
||||
|
|
@ -74,17 +75,18 @@ export function ShellTopbar() {
|
|||
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
|
||||
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
|
||||
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
|
||||
const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
|
||||
|
||||
const openBoard = () =>
|
||||
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });
|
||||
|
||||
const openNewTask = () => {
|
||||
if (!projectId) return;
|
||||
if (!projectId || isProjectRestarting) return;
|
||||
setIsNewTaskOpen(true);
|
||||
};
|
||||
|
||||
const handleTaskCreated = async (sessionId: string) => {
|
||||
if (!projectId) return;
|
||||
if (!projectId || isProjectRestarting) return;
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
|
|
@ -171,6 +173,7 @@ export function ShellTopbar() {
|
|||
<button
|
||||
aria-label="New task"
|
||||
className="dashboard-app-header__primary-btn"
|
||||
disabled={isProjectRestarting}
|
||||
onClick={openNewTask}
|
||||
style={noDragStyle}
|
||||
type="button"
|
||||
|
|
@ -197,13 +200,13 @@ export function ShellTopbar() {
|
|||
<button
|
||||
aria-label="Open orchestrator"
|
||||
className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact"
|
||||
disabled={isSpawning}
|
||||
disabled={isSpawning || isProjectRestarting}
|
||||
onClick={() => void openOrchestrator()}
|
||||
style={noDragStyle}
|
||||
type="button"
|
||||
>
|
||||
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isSpawning ? "Spawning…" : "Orchestrator"}
|
||||
{isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : "Orchestrator"}
|
||||
</button>
|
||||
)}
|
||||
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
attentionZone,
|
||||
isOrchestratorSession,
|
||||
newestActiveOrchestrator,
|
||||
sessionIsActive,
|
||||
type WorkspaceSession,
|
||||
type WorkspaceSummary,
|
||||
|
|
@ -426,16 +426,19 @@ function ProjectItem({
|
|||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
const [isRemoving, setIsRemoving] = 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
|
||||
// reachable through the board's Done / Terminated bar (SessionsBoard).
|
||||
const sessions = workerSessions(workspace.sessions).filter(sessionIsActive);
|
||||
// The project's live orchestrator (if any) backs the hover Orchestrator
|
||||
// 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
|
||||
// spawn one via the daemon and follow it once the workspace refetches.
|
||||
const openOrchestrator = async () => {
|
||||
if (isProjectRestarting) return;
|
||||
if (orchestrator) {
|
||||
selection.goSession(workspace.id, orchestrator.id);
|
||||
return;
|
||||
|
|
@ -546,7 +549,7 @@ function ProjectItem({
|
|||
<button
|
||||
aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`}
|
||||
className={HOVER_ACTION_CLASS}
|
||||
disabled={isSpawning}
|
||||
disabled={isSpawning || isProjectRestarting}
|
||||
onClick={() => void openOrchestrator()}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -554,7 +557,7 @@ function ProjectItem({
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
|
||||
{isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,16 @@ describe("useWorkspaceQuery", () => {
|
|||
it("maps projects and their sessions, applying provider/status/title fallbacks", async () => {
|
||||
respondWith({
|
||||
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,
|
||||
},
|
||||
sessions: {
|
||||
|
|
@ -92,7 +101,7 @@ describe("useWorkspaceQuery", () => {
|
|||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
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[0]).toMatchObject({
|
||||
id: "sess-1",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined,
|
||||
sessions: (sessionsData?.sessions ?? [])
|
||||
.filter((session) => session.projectId === project.id)
|
||||
.map((session) => ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type CSSProperties, useCallback, useEffect, useRef } from "react";
|
||||
import { ShellTopbar } from "../components/ShellTopbar";
|
||||
import { OrchestratorReplacementDialog } from "../components/OrchestratorReplacementDialog";
|
||||
import { Sidebar } from "../components/Sidebar";
|
||||
import { SidebarProvider } from "../components/ui/sidebar";
|
||||
import { TitlebarNav } from "../components/TitlebarNav";
|
||||
|
|
@ -13,6 +14,8 @@ import { refreshDaemonStatus } from "../lib/daemon-status";
|
|||
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
||||
import { ShellProvider } from "../lib/shell-context";
|
||||
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 type { WorkspaceSummary } from "../types/workspace";
|
||||
import type { components } from "../../api/schema";
|
||||
|
|
@ -48,6 +51,10 @@ function ShellLayout() {
|
|||
const daemonStatus = useDaemonStatus(queryClient);
|
||||
const agentCatalogPortRef = useRef<number | undefined>(undefined);
|
||||
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(
|
||||
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
|
||||
|
|
@ -99,6 +106,7 @@ function ShellLayout() {
|
|||
name: data.project.name,
|
||||
path: data.project.path,
|
||||
type: "main",
|
||||
orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"],
|
||||
sessions: [],
|
||||
};
|
||||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||
|
|
@ -146,6 +154,22 @@ function ShellLayout() {
|
|||
[updateWorkspaces],
|
||||
);
|
||||
|
||||
const restartOrchestrator = useCallback(
|
||||
async (projectId: string) => {
|
||||
await restartProjectOrchestrator({
|
||||
projectId,
|
||||
queryClient,
|
||||
navigate,
|
||||
setProjectRestarting,
|
||||
setOrchestratorReplacementError,
|
||||
onError: (error) => {
|
||||
captureOrchestratorReplacementFailure(error, projectId);
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate, queryClient, setOrchestratorReplacementError, setProjectRestarting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
|
|
@ -229,6 +253,15 @@ function ShellLayout() {
|
|||
by window-drag even though DOM hit-testing looks correct. */}
|
||||
<TitlebarNav />
|
||||
</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>
|
||||
</ShellProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ type UiState = {
|
|||
isSidebarOpen: boolean;
|
||||
isInspectorOpen: boolean;
|
||||
theme: Theme;
|
||||
restartingProjectIds: ReadonlySet<string>;
|
||||
orchestratorReplacementErrors: Record<string, string>;
|
||||
setWorkbenchTab: (tab: WorkbenchTab) => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
toggleSidebar: () => void;
|
||||
toggleInspector: () => void;
|
||||
setProjectRestarting: (projectId: string, restarting: boolean) => void;
|
||||
setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
|
||||
};
|
||||
|
||||
const sidebarStorageKey = "ao.sidebar.open";
|
||||
|
|
@ -58,6 +62,8 @@ export const useUiStore = create<UiState>((set) => ({
|
|||
isSidebarOpen: initialSidebarOpen(),
|
||||
isInspectorOpen: initialInspectorOpen(),
|
||||
theme: initialTheme(),
|
||||
restartingProjectIds: new Set<string>(),
|
||||
orchestratorReplacementErrors: {},
|
||||
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
|
||||
setTheme: (theme) => {
|
||||
getLocalStorage()?.setItem(themeStorageKey, theme);
|
||||
|
|
@ -81,4 +87,24 @@ export const useUiStore = create<UiState>((set) => ({
|
|||
getLocalStorage()?.setItem(inspectorStorageKey, String(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 };
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
attentionZone,
|
||||
canonicalTrackerIssueId,
|
||||
findProjectOrchestrator,
|
||||
newestActiveOrchestrator,
|
||||
orchestratorHealth,
|
||||
sessionIsActive,
|
||||
sessionNeedsAttention,
|
||||
toAgentProvider,
|
||||
|
|
@ -144,6 +146,50 @@ describe("findProjectOrchestrator", () => {
|
|||
const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" });
|
||||
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", () => {
|
||||
|
|
@ -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", () => {
|
||||
it("pulses only for working and needs_you", () => {
|
||||
expect(workerStatusPulses("working")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -241,14 +241,31 @@ export function findProjectOrchestrator(
|
|||
projectId: string,
|
||||
): WorkspaceSession | undefined {
|
||||
const workspace = workspaces.find((w) => w.id === projectId);
|
||||
if (!workspace) return undefined;
|
||||
for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) {
|
||||
const session = workspace.sessions[i];
|
||||
if (isOrchestratorSession(session) && sessionIsActive(session)) {
|
||||
return session;
|
||||
return newestActiveOrchestrator(workspace?.sessions ?? []);
|
||||
}
|
||||
|
||||
export function newestActiveOrchestrator(sessions: WorkspaceSession[]): WorkspaceSession | undefined {
|
||||
const active = sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session));
|
||||
return active.reduce<WorkspaceSession | undefined>(
|
||||
(newest, session) => (!newest || sessionNewer(session, newest) ? session : newest),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
return 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[] {
|
||||
|
|
@ -340,6 +357,7 @@ export type WorkspaceSummary = {
|
|||
name: string;
|
||||
path: string;
|
||||
type?: "main" | "worktree";
|
||||
orchestratorAgent?: AgentProvider;
|
||||
accentColor?: string;
|
||||
diff?: {
|
||||
additions: number;
|
||||
|
|
@ -348,6 +366,42 @@ export type WorkspaceSummary = {
|
|||
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 {
|
||||
switch (provider) {
|
||||
case "claude-code":
|
||||
|
|
|
|||
Loading…
Reference in New Issue