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
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
orchestratorAgent:
|
||||||
|
type: string
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
resolveError:
|
resolveError:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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). */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue