diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 8ab35bb0f2..31ef009f2c 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -55,16 +55,17 @@ func (f *fakeSessionService) List(context.Context, sessionsvc.ListFilter) ([]dom return nil, nil } -func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) { f.spawned = cfg return domain.Session{ SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1")}, Status: domain.StatusIdle, - }, nil + }, len(cfg.Prompt), 0, nil } func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, _ bool) (domain.Session, error) { - return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + s, _, _, err := f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + return s, err } func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) { diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index 3b79639fcc..4a8fd82769 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -50,6 +50,8 @@ type spawnResult struct { ID string `json:"id"` Status string `json:"status"` } `json:"session"` + PromptBytes int `json:"promptBytes,omitempty"` + SystemPromptBytes int `json:"systemPromptBytes,omitempty"` } type agentProbeResult struct { @@ -147,7 +149,11 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { if claimed != "" { claimLabel = fmt.Sprintf(" (claimed %s)", claimed) } - _, err = fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel) + promptSize := "" + if res.PromptBytes > 0 || res.SystemPromptBytes > 0 { + promptSize = fmt.Sprintf(" [prompt %d B, system %d B]", res.PromptBytes, res.SystemPromptBytes) + } + _, err = fmt.Fprintf(out, "spawned session %s (%s)%s%s\n", res.Session.ID, res.Session.Status, claimLabel, promptSize) return err }, } diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index edb38b3313..f4f44d9810 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -191,7 +191,7 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatal(err) } - _, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`) + _, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"},"promptBytes":0,"systemPromptBytes":123}`) default: http.NotFound(w, r) } @@ -207,6 +207,9 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) { if !strings.Contains(out, "spawned session demo-11") { t.Fatalf("output missing spawn: %s", out) } + if !strings.Contains(out, "[prompt 0 B, system 123 B]") { + t.Fatalf("output missing system-only prompt metrics: %s", out) + } if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "worker" { t.Fatalf("spawn request = %#v", req) } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 73f5c924e7..04434dd114 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -252,7 +252,7 @@ func TestWiring_StartSessionSpawnsScratchWithoutGitRepo(t *testing.T) { t.Fatalf("startSession: %v", err) } - session, err := svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Prompt: "try scratch"}) + session, _, _, err := svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Prompt: "try scratch"}) if err != nil { t.Fatalf("Spawn scratch: %v", err) } @@ -306,7 +306,7 @@ func TestStartSession_SpawnDoesNotPanicWhenNoTrackerToken(t *testing.T) { // Spawn reaches withIssueContext (and the tracker guard) before the manager // tries to materialize a workspace. The manager may return an error from the // no-op runtime, but it must not panic — that is the regression. - _, _ = svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"}) + _, _, _, _ = svc.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"}) } func TestWiring_SeedScratchProjectOnBootUsesDataDir(t *testing.T) { diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 83d9d01bad..3cf50f9537 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1060,7 +1060,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SessionResponse' + $ref: '#/components/schemas/SpawnSessionResponse' description: Created "400": content: @@ -3441,6 +3441,19 @@ components: required: - projectId type: object + SpawnSessionResponse: + properties: + promptBytes: + type: integer + session: + $ref: '#/components/schemas/ControllersSessionView' + systemPromptBytes: + type: integer + required: + - session + - promptBytes + - systemPromptBytes + type: object SubmitReviewInput: properties: body: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 16438ac7f6..d5de9c09d4 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -150,6 +150,7 @@ var schemaNames = map[string]string{ "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", + "ControllersSpawnSessionResponse": "SpawnSessionResponse", "ControllersSessionResponse": "SessionResponse", "ControllersSessionPreviewResponse": "SessionPreviewResponse", "ControllersSetSessionPreviewRequest": "SetSessionPreviewRequest", @@ -746,7 +747,7 @@ func sessionOperations() []operation { summary: "Spawn a new agent session", reqBody: controllers.SpawnSessionRequest{}, resps: []respUnit{ - {http.StatusCreated, controllers.SessionResponse{}}, + {http.StatusCreated, controllers.SpawnSessionResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 3c20143305..3631be0cff 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -178,11 +178,20 @@ type SpawnAttachmentInput struct { Data string `json:"data"` } -// SessionResponse is the { session } body shared by session create/get. +// SessionResponse is the { session } body shared by session reads and updates. type SessionResponse struct { Session SessionView `json:"session"` } +// SpawnSessionResponse includes ephemeral measurements of the final assembled +// prompt texts. The fields are required so a measured zero remains distinct +// from a response that never measured prompt sizes. +type SpawnSessionResponse struct { + Session SessionView `json:"session"` + PromptBytes int `json:"promptBytes"` + SystemPromptBytes int `json:"systemPromptBytes"` +} + // ListWorkspaceFilesResponse is the body of GET /api/v1/sessions/{sessionId}/workspace/files. type ListWorkspaceFilesResponse struct { SessionID domain.SessionID `json:"sessionId"` diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index e776365b6f..c112fd3df2 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -62,7 +62,7 @@ var errPreviewFileNotFound = errors.New("preview file not found") // SessionService is the controller-facing session service contract. type SessionService interface { List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) - Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (sessionsvc.RestoreOutcome, error) @@ -177,12 +177,12 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", attachErr.code, attachErr.message, nil) return } - sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName, Attachments: attachments}) + sess, promptBytes, systemPromptBytes, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName, Attachments: attachments}) if err != nil { envelope.WriteError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sessionView(sess)}) + envelope.WriteJSON(w, http.StatusCreated, SpawnSessionResponse{Session: sessionView(sess), PromptBytes: promptBytes, SystemPromptBytes: systemPromptBytes}) } // spawnAttachmentError carries a client-facing API error code + message for a diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 7f6bfbceef..e9cb73019b 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -60,14 +60,14 @@ func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilte return out, nil } -func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) { if f.spawnErr != nil { - return domain.Session{}, f.spawnErr + return domain.Session{}, 0, 0, f.spawnErr } now := time.Now().UTC() s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} f.sessions[s.ID] = s - return s, nil + return s, len(cfg.Prompt), 0, nil } func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { @@ -83,7 +83,8 @@ func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID do } } } - return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + s, _, _, err := f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + return s, err } func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) { @@ -332,7 +333,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { t.Fatalf("POST session = %d, want 201; body=%s", status, body) } var spawned struct { - Session sessionBody `json:"session"` + Session sessionBody `json:"session"` + PromptBytes *int `json:"promptBytes"` + SystemPromptBytes *int `json:"systemPromptBytes"` } mustJSON(t, body, &spawned) if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { @@ -341,6 +344,12 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if spawned.Session.DisplayName != "my worker" { t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker") } + if spawned.PromptBytes == nil || *spawned.PromptBytes != len("fix") { + t.Fatalf("spawned promptBytes = %v, want %d", spawned.PromptBytes, len("fix")) + } + if spawned.SystemPromptBytes == nil || *spawned.SystemPromptBytes != 0 { + t.Fatalf("spawned systemPromptBytes = %v, want present zero", spawned.SystemPromptBytes) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") if status != http.StatusOK { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 6eb86f86a2..9035e63a14 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -157,7 +157,7 @@ func newStack(t *testing.T) *stack { func TestSpawnPRKillRoundTrip(t *testing.T) { ctx := context.Background() st := newStack(t) - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"}) + sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"}) if err != nil { t.Fatal(err) } @@ -191,7 +191,7 @@ func TestSpawnPRKillRoundTrip(t *testing.T) { func TestRestoreRoundTripPreservesMetadata(t *testing.T) { ctx := context.Background() st := newStack(t) - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"}) + sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"}) if err != nil { t.Fatal(err) } @@ -315,7 +315,7 @@ func TestCDCPollerReceivesSessionAndPREvents(t *testing.T) { var got []cdc.Event b.Subscribe(func(e cdc.Event) { got = append(got, e) }) poller := cdc.NewPoller(st.store, b, cdc.PollerConfig{}) - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + sess, _, _, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go index ec9430dd01..38aa2d5dfd 100644 --- a/backend/internal/observe/trackerintake/observer.go +++ b/backend/internal/observe/trackerintake/observer.go @@ -39,7 +39,7 @@ type Store interface { // Spawner is the session creation surface used by intake. type Spawner interface { - Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) } // TrackerResolver picks the tracker adapter for a project's configured @@ -203,7 +203,7 @@ func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord if issueID == "" || seen[issueID] { continue } - if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{ + if _, _, _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{ ProjectID: domain.ProjectID(project.ID), IssueID: issueID, Kind: domain.KindWorker, diff --git a/backend/internal/observe/trackerintake/observer_test.go b/backend/internal/observe/trackerintake/observer_test.go index c8510ac707..45d8a0cc6d 100644 --- a/backend/internal/observe/trackerintake/observer_test.go +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -381,12 +381,12 @@ type fakeSpawner struct { failIssue domain.IssueID } -func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) { f.calls = append(f.calls, cfg) if cfg.IssueID == f.failIssue { - return domain.Session{}, errors.New("spawn failed") + return domain.Session{}, 0, 0, errors.New("spawn failed") } - return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, nil + return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, len(cfg.Prompt), 0, nil } func discardLogger() *slog.Logger { diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 9fa23939d8..1676b56327 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -43,7 +43,7 @@ type ListFilter struct { // commander is the command-side surface Service delegates to: the // *sessionmanager.Manager in production, a fake in tests. type commander interface { - Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, int, int, error) RestoreWithMode(ctx context.Context, id domain.SessionID) (sessionmanager.RestoreResult, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) RetireForReplacement(ctx context.Context, id domain.SessionID) error @@ -155,28 +155,33 @@ func NewWithDeps(d Deps) *Service { return s } -// Spawn creates a session and returns the API-facing read model. -func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +// Spawn creates a session and returns the API-facing read model plus +// ephemeral prompt size measurements. +func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, int, int, error) { project, err := s.requireProject(ctx, cfg.ProjectID) if err != nil { - return domain.Session{}, err + return domain.Session{}, 0, 0, err } start := s.now() firstSession, err := s.isFirstSession(ctx) if err != nil { - return domain.Session{}, fmt.Errorf("count sessions: %w", err) + return domain.Session{}, 0, 0, fmt.Errorf("count sessions: %w", err) } cfg = s.withIssueContext(ctx, cfg, project) - rec, err := s.manager.Spawn(ctx, cfg) + rec, promptBytes, systemPromptBytes, err := s.manager.Spawn(ctx, cfg) if err != nil { s.emitSpawnFailed(cfg, err, s.now().Sub(start).Milliseconds()) - return domain.Session{}, toAPIError(err) + return domain.Session{}, 0, 0, toAPIError(err) } s.emitSpawned(rec, s.now().Sub(start).Milliseconds()) if firstSession { s.emitFirstSessionSpawned(rec, project) } - return s.toSession(ctx, rec) + sess, err := s.toSession(ctx, rec) + if err != nil { + return domain.Session{}, 0, 0, err + } + return sess, promptBytes, systemPromptBytes, nil } // requireProject verifies the project is registered before any spawn write @@ -319,7 +324,7 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec return newestSession(existing), nil } } - sess, err := 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 } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index feb64d4b62..8de8b6665c 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -500,17 +500,17 @@ type fakeCommander struct { restoreResult sessionmanager.RestoreResult } -func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { +func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, int, int, error) { if f.spawnErr != nil { - return domain.SessionRecord{}, f.spawnErr + return domain.SessionRecord{}, 0, 0, f.spawnErr } f.spawned = true f.spawnedCfg = cfg f.killsAtSpawn = len(f.retired) if f.spawnRecord.ID != "" { - return f.spawnRecord, nil + return f.spawnRecord, len(cfg.Prompt), 0, 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}, len(cfg.Prompt), 0, nil } func (f *fakeCommander) RestoreWithMode(context.Context, domain.SessionID) (sessionmanager.RestoreResult, error) { if f.restoreErr != nil { @@ -681,7 +681,7 @@ func TestSpawnUnknownProjectReturns404(t *testing.T) { fc := &fakeCommander{} svc := &Service{manager: fc, store: st} - _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "ghost", Kind: domain.KindWorker}) + _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "ghost", Kind: domain.KindWorker}) var e *apierr.Error if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "PROJECT_NOT_FOUND" { t.Fatalf("err = %v, want apierr.NotFound PROJECT_NOT_FOUND", err) @@ -703,7 +703,7 @@ func TestSpawnEmitsFirstSessionOnboardingAndDuration(t *testing.T) { Clock: func() time.Time { return time.Unix(102, 0).UTC() }, }) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err != nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err != nil { t.Fatalf("Spawn: %v", err) } if len(sink.events) != 2 { @@ -755,7 +755,7 @@ func TestSpawnEnrichesIssueContextFromTracker(t *testing.T) { }} svc := NewWithDeps(Deps{Manager: fc, Store: st, Tracker: tracker}) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "42"}); err != nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "42"}); err != nil { t.Fatalf("Spawn: %v", err) } if len(tracker.ids) != 1 || tracker.ids[0].Provider != domain.TrackerProviderGitHub || tracker.ids[0].Native != "acme/repo#42" { @@ -784,7 +784,7 @@ func TestSpawnIssueContextFetchFailureFallsBack(t *testing.T) { tracker := &fakeTracker{err: errors.New("tracker unavailable")} svc := NewWithDeps(Deps{Manager: fc, Store: st, Tracker: tracker}) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "42"}); err != nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "42"}); err != nil { t.Fatalf("Spawn: %v", err) } if len(tracker.ids) != 1 { @@ -806,7 +806,7 @@ func TestSpawnPreservesIssueIDWhenTrackerIsNil(t *testing.T) { fc := &fakeCommander{} svc := NewWithDeps(Deps{Manager: fc, Store: st, Tracker: nil}) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"}); err != nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "107"}); err != nil { t.Fatalf("Spawn: %v", err) } if fc.spawnedCfg.IssueID != "107" { @@ -824,7 +824,7 @@ func TestSpawnIssueContextSkipsUnresolvableIssueRef(t *testing.T) { tracker := &fakeTracker{} svc := NewWithDeps(Deps{Manager: fc, Store: st, Tracker: tracker}) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "not-an-issue"}); err != nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "not-an-issue"}); err != nil { t.Fatalf("Spawn: %v", err) } if len(tracker.ids) != 0 { @@ -852,7 +852,7 @@ func TestSpawnFailedEmitsDuration(t *testing.T) { }, }) - if _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err == nil { + if _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ProjectID: "mer"}); err == nil { t.Fatal("Spawn should fail") } if len(sink.events) != 1 || sink.events[0].Name != "ao.session.spawn_failed" { @@ -883,7 +883,7 @@ func TestSpawnEmitsTelemetryOnSuccess(t *testing.T) { ts := &fakeTelemetrySink{} svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }}) - _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ + _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex, @@ -910,7 +910,7 @@ func TestSpawnEmitsTelemetryOnFailure(t *testing.T) { ts := &fakeTelemetrySink{} svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }}) - _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ + _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex, @@ -952,7 +952,7 @@ func TestSpawnEmitsTypedErrorCodeOnFailure(t *testing.T) { ts := &fakeTelemetrySink{} svc := NewWithDeps(Deps{Manager: fc, Store: st, Telemetry: ts, Clock: func() time.Time { return time.Unix(1700000000, 0).UTC() }}) - _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ + _, _, _, err := svc.Spawn(context.Background(), ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex, diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 55a22a2754..b084ae5742 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -267,47 +267,49 @@ func New(d Deps) *Manager { // workspace and runtime, then reports completion to the LCM. If workspace // materialization fails the still-seed row is deleted outright; a later failure // parks the row as terminated and rolls back what was built. -func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { +func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, int, int, error) { project, err := m.loadProject(ctx, cfg.ProjectID) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: %w", err) } projectKind := project.Kind.WithDefault() if projectKind == domain.ProjectKindScratch && strings.TrimSpace(cfg.Branch) != "" { - return domain.SessionRecord{}, fmt.Errorf("spawn: %w", ErrScratchBranchUnsupported) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: %w", ErrScratchBranchUnsupported) } // A per-project role override picks the harness when the spawn names none, // so a project can default workers to one agent and orchestrators to another. cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config) if cfg.Harness == "" { - return domain.SessionRecord{}, fmt.Errorf("spawn: %w: configure project %s.agent or pass --harness", ErrMissingHarness, roleConfigName(cfg.Kind)) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: %w: configure project %s.agent or pass --harness", ErrMissingHarness, roleConfigName(cfg.Kind)) } // Reject an unknown harness before any durable state is created. Doing this // after CreateSession would leave a terminated orphan row and waste a // worktree on a spawn that can never launch. if _, ok := m.agents.Agent(cfg.Harness); !ok { - return domain.SessionRecord{}, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness) } if err := m.validateRuntimePrerequisites(); err != nil { - return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: %w", err) } prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: prompt: %w", err) } + promptBytes := len(prompt) + systemPromptBytes := len(systemPrompt) rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock())) if err != nil { - return domain.SessionRecord{}, fmt.Errorf("spawn: create: %w", err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn: create: %w", err) } id := rec.ID systemPromptFile, err := m.prepareSystemPromptFile(id, cfg.Harness, systemPrompt) if err != nil { m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: system prompt file: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: system prompt file: %w", id, err) } branch := cfg.Branch @@ -320,7 +322,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // row is deleted outright instead of accumulating as a terminated orphan // in session lists (e.g. when gitworktree refuses the branch). m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: workspace: %w", id, err) } // Per-project workspace provisioning: symlink shared files, then run any @@ -328,7 +330,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil { m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: provision: %w", id, err) } // CLI agents receive the prompt as text and cannot consume inline binary @@ -341,7 +343,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err != nil { _ = m.workspace.Destroy(ctx, ws) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: attachments: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: attachments: %w", id, err) } // Keep the attachments dir out of git status. Best-effort: the images are // already written and usable, so an exclude failure must not fail the spawn. @@ -355,7 +357,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if !ok { m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) env := m.runtimeEnv(id, cfg.ProjectID, cfg.IssueID, project.Config.Env) @@ -363,7 +365,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, systemPromptFile, agentConfig, env); err != nil { m.destroySpawnWorkspace(ctx, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: %w", id, err) } launchCfg := ports.LaunchConfig{ DataDir: m.dataDir, @@ -381,7 +383,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err != nil { m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: prompt delivery: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: prompt delivery: %w", id, err) } if delivery == ports.PromptDeliveryAfterStart { launchCfg.Prompt = "" @@ -390,7 +392,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err != nil { m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: launch command: %w", id, err) } // Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute // path the adapter returned) BEFORE handing the launch to the runtime. @@ -399,7 +401,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err := m.validateAgentBinary(argv); err != nil { m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: %w", id, err) } m.augmentRuntimePATHForLaunchBinary(ctx, env, argv) handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ @@ -411,7 +413,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err != nil { m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.rollbackSpawnSeedRow(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: runtime: %w", id, err) } metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, WorkspaceRepoPath: ws.RepoPath, RuntimeHandleID: handle.ID, Prompt: prompt} @@ -419,17 +421,21 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess _ = m.runtime.Destroy(ctx, handle) m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.markSpawnFailedTerminated(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: completed: %w", id, err) } if delivery == ports.PromptDeliveryAfterStart && prompt != "" { if err := m.deliverAfterStartPrompt(ctx, agent, launchCfg, handle, id, prompt); err != nil { _ = m.runtime.Destroy(ctx, handle) m.rollbackPreparedSpawnWorkspace(ctx, rec, ws, workspaceProject) m.markSpawnFailedTerminatedWithoutWorkspace(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: deliver prompt: %w", id, err) + return domain.SessionRecord{}, 0, 0, fmt.Errorf("spawn %s: deliver prompt: %w", id, err) } } - return m.getRecord(ctx, id) + rec, err = m.getRecord(ctx, id) + if err != nil { + return domain.SessionRecord{}, 0, 0, err + } + return rec, promptBytes, systemPromptBytes, nil } // loadProject loads the project record so spawn can resolve its per-project diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index d05d5e4a2f..c873ceb12b 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -671,7 +671,7 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + rec, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } @@ -695,7 +695,7 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) { // when the spawn explicitly names its agent. st.projects["bare"] = domain.ProjectRecord{ID: "bare"} agent.lastConfig = ports.AgentConfig{Model: "stale"} - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil { t.Fatal(err) } if !agent.lastConfig.IsZero() { @@ -712,13 +712,13 @@ func TestSpawn_RejectsMissingRoleHarness(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) { t.Fatalf("worker err = %v, want ErrMissingHarness", err) } if len(st.sessions) != 0 { t.Fatalf("missing worker harness must not create a session row, got %d", len(st.sessions)) } - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) { t.Fatalf("orchestrator err = %v, want ErrMissingHarness", err) } } @@ -731,7 +731,7 @@ func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) { Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil { t.Fatal(err) } if got := st.sessions["mer-1"].Harness; got != domain.HarnessCodex { @@ -741,7 +741,7 @@ func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) { func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { m, st, rt, _ := newManager() - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) if err != nil { t.Fatal(err) } @@ -759,6 +759,32 @@ func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { } } +func TestSpawn_ReturnsFinalPromptByteMetrics(t *testing.T) { + m, _, _, _ := newManager() + cfg := ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode} + wantPrompt, wantSystemPrompt, err := m.buildSpawnTexts(ctx, cfg) + if err != nil { + t.Fatalf("buildSpawnTexts: %v", err) + } + if wantPrompt != "" { + t.Fatalf("promptless spawn prompt = %q, want empty", wantPrompt) + } + if wantSystemPrompt == "" { + t.Fatal("promptless spawn system prompt is empty") + } + + _, promptBytes, systemPromptBytes, err := m.Spawn(ctx, cfg) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if promptBytes != len(wantPrompt) { + t.Fatalf("promptBytes = %d, want %d", promptBytes, len(wantPrompt)) + } + if systemPromptBytes != len(wantSystemPrompt) { + t.Fatalf("systemPromptBytes = %d, want %d", systemPromptBytes, len(wantSystemPrompt)) + } +} + func TestSpawn_DeliversPromptAfterStartWhenAgentRequestsIt(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} @@ -776,7 +802,7 @@ func TestSpawn_DeliversPromptAfterStartWhenAgentRequestsIt(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { t.Fatal(err) } if agent.lastLaunch.Prompt != "" { @@ -814,7 +840,7 @@ func TestSpawn_AfterStartPromptWaitsForReadinessHint(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { t.Fatal(err) } if rt.outputCalls != 2 { @@ -851,7 +877,7 @@ func TestSpawn_AfterStartPromptFallsBackWhenReadinessTimesOut(t *testing.T) { Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}); err != nil { t.Fatal(err) } if rt.outputCalls == 0 { @@ -890,7 +916,7 @@ func TestSpawn_AfterStartPromptFailureCleansUpSpawn(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) if err == nil { t.Fatal("Spawn err = nil, want prompt delivery error") } @@ -938,7 +964,7 @@ func TestSpawn_AfterStartPromptFailureCleansUpWorkspaceProjectRows(t *testing.T) LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) if err == nil || !strings.Contains(err.Error(), "deliver prompt") { t.Fatalf("Spawn err = %v, want deliver prompt failure", err) } @@ -1010,7 +1036,7 @@ func TestSpawn_AfterStartPromptSuppressedTerminationFailsSpawn(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) if err == nil { t.Fatal("Spawn err = nil, want failure because the after-start prompt was suppressed (session terminated)") } @@ -1042,7 +1068,7 @@ func TestSpawn_PromptDeliveryStrategyFailureCleansUpWorkspaceProjectRows(t *test LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix the button"}) if err == nil || !strings.Contains(err.Error(), "prompt delivery") { t.Fatalf("Spawn err = %v, want prompt delivery failure", err) } @@ -1069,7 +1095,7 @@ func TestSpawn_PromptDeliveryStrategyFailureCleansUpWorkspaceProjectRows(t *test // in `ao session get` (created in local time, updated in UTC). func TestSpawn_StampsUTCTimestamps(t *testing.T) { m, st, _, _ := newManager() - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatal(err) } rec := st.sessions["mer-1"] @@ -1084,7 +1110,7 @@ func TestSpawn_StampsUTCTimestamps(t *testing.T) { func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) { m, st, _, ws := newManager() m.runtime = &fakeRuntime{createErr: errors.New("boom")} - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil { t.Fatal("expected failure") } if ws.destroyed != 1 { @@ -1116,7 +1142,7 @@ func TestSpawn_RuntimeFailureCleansAgentWorkspaceAfterDestroy(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "runtime") { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "runtime") { t.Fatalf("Spawn err = %v, want runtime failure", err) } if ws.destroyed != 1 { @@ -1154,7 +1180,7 @@ func TestSpawn_PrepareFailureCleansAgentWorkspaceState(t *testing.T) { Executable: func() (string, error) { return "/daemon/ao", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "install hooks") { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "install hooks") { t.Fatalf("Spawn err = %v, want install hooks failure", err) } if agent.cleanupCalls != 1 { @@ -1192,7 +1218,7 @@ func TestSpawn_AgentRuntimeEnvAugmenterReachesRuntime(t *testing.T) { Executable: func() (string, error) { return "/daemon/ao", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatalf("Spawn: %v", err) } if got, want := rt.lastCfg.Env["AGENT_DATA_DIR"], filepath.Join("/ao/data", "agent"); got != want { @@ -1208,7 +1234,7 @@ func TestSpawn_AgentRuntimeEnvAugmenterReachesRuntime(t *testing.T) { func TestSpawn_DeletesSeedRowOnWorkspaceFailure(t *testing.T) { m, st, rt, ws := newManager() ws.createErr = ports.ErrWorkspaceBranchCheckedOutElsewhere - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if !errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere) { t.Fatalf("err = %v, want ports.ErrWorkspaceBranchCheckedOutElsewhere", err) } @@ -1227,7 +1253,7 @@ func TestSpawn_ParksRowTerminatedWhenSeedDeleteFails(t *testing.T) { m, st, _, ws := newManager() ws.createErr = ports.ErrWorkspaceBranchNotFetched st.deleteErr = errors.New("db locked") - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ports.ErrWorkspaceBranchNotFetched) { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ports.ErrWorkspaceBranchNotFetched) { t.Fatalf("err = %v, want ports.ErrWorkspaceBranchNotFetched", err) } if !st.sessions["mer-1"].IsTerminated { @@ -1257,7 +1283,7 @@ func TestSpawn_WorkspaceProjectRecordsRootAndChildWorktrees(t *testing.T) { LookPath: func(string) (string, error) { return "/bin/true", nil }, }) - rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + rec, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } @@ -1322,7 +1348,7 @@ func TestSpawn_WorkspaceProjectRollsBackAllWorktreesOnRuntimeFailure(t *testing. } st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} m.runtime = &fakeRuntime{createErr: errors.New("boom")} - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil { t.Fatal("expected failure") } if ws.projectDestroyed != 1 { @@ -1346,7 +1372,7 @@ func TestSpawn_WorkspaceProjectRollsBackWhenWorktreeRowsFail(t *testing.T) { } st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} st.upsertWTErr = errors.New("db locked") - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "record workspace worktree") { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "record workspace worktree") { t.Fatalf("err = %v, want worktree row failure", err) } if ws.projectDestroyed != 1 { @@ -1784,7 +1810,7 @@ func TestSpawnTeardown_WorkspaceRepoPathRoundTrip(t *testing.T) { // so the value is available to be persisted and later reused. ws.createRepoPath = repoPath - rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode}) + rec, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode}) if err != nil { t.Fatal(err) } @@ -1895,7 +1921,7 @@ func TestCleanup_WorkspaceProjectDirtyRowsAreSkipped(t *testing.T) { func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) { m, st, _, _ := newManager() - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } @@ -1912,7 +1938,7 @@ func TestSpawn_DefaultsBranchUnderDevNamespaceForDevDataDir(t *testing.T) { m, st, _, _ := newManager() m.dataDir = filepath.Join(home, ".ao", "dev", "data") - worker, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + worker, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } @@ -1920,7 +1946,7 @@ func TestSpawn_DefaultsBranchUnderDevNamespaceForDevDataDir(t *testing.T) { t.Fatalf("worker branch = %q, want ao/dev/mer-1/root", got) } - orchestrator, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) + orchestrator, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) if err != nil { t.Fatal(err) } @@ -1935,7 +1961,7 @@ func TestSpawn_ExplicitBranchBypassesDevNamespace(t *testing.T) { m, st, _, _ := newManager() m.dataDir = filepath.Join(home, ".ao", "dev", "data") - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "ao/custom"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "ao/custom"}) if err != nil { t.Fatal(err) } @@ -1957,7 +1983,7 @@ func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatal(err) } @@ -2005,7 +2031,7 @@ func TestSpawnWorker_IssueWithoutPromptGetsFallbackTaskPrompt(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "2272"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, IssueID: "2272"}) if err != nil { t.Fatal(err) } @@ -2036,7 +2062,7 @@ func TestSpawnWorker_ProjectRulesInSystemPrompt(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatal(err) } @@ -2058,7 +2084,7 @@ func TestSpawnWorker_IssueContextStaysInTaskPrompt(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, IssueID: "2272", @@ -2085,7 +2111,7 @@ func TestSpawnWorker_IncludesReviewCIAndPlanningInstructions(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}); err != nil { t.Fatal(err) } @@ -2115,7 +2141,7 @@ func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) if err != nil { t.Fatal(err) } @@ -2159,7 +2185,7 @@ func TestSpawnWorker_WritesSystemPromptFile(t *testing.T) { LookPath: lookPath, }) - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}) if err != nil { t.Fatal(err) } @@ -2195,7 +2221,7 @@ func TestSpawnWorker_FallsBackToInlineWhenPromptFileUnavailable(t *testing.T) { Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}); err != nil { t.Fatal(err) } if agent.lastLaunch.SystemPrompt == "" { @@ -2222,7 +2248,7 @@ func TestSpawnWorker_PromptFileFailureBlocksFileOnlyHarness(t *testing.T) { LookPath: lookPath, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessAider, Prompt: "do it"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessAider, Prompt: "do it"}) if err == nil { t.Fatal("Spawn succeeded, want prompt-file error for file-only harness") } @@ -2245,7 +2271,7 @@ func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) if err != nil { t.Fatal(err) } @@ -2264,7 +2290,7 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) if err != nil { t.Fatal(err) } @@ -2310,7 +2336,7 @@ func TestSpawnOrchestrator_ProjectRulesInSystemPrompt(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); err != nil { t.Fatal(err) } @@ -2336,7 +2362,7 @@ func TestSpawnOrchestrator_WorkspaceProjectPromptListsRepos(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) if err != nil { t.Fatal(err) } @@ -2370,7 +2396,7 @@ func TestSpawnWorker_WorkspaceProjectPromptListsRepos(t *testing.T) { lookPath := func(string) (string, error) { return "/bin/true", nil } m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix api"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "fix api"}) if err != nil { t.Fatal(err) } @@ -3060,7 +3086,7 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { } m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, DataDir: dataDir, LookPath: notFound}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if !errors.Is(err, ports.ErrAgentBinaryNotFound) { t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) } @@ -3096,7 +3122,7 @@ func TestSpawn_ValidatesBinaryAfterEnvPrefix(t *testing.T) { agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}} m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatalf("Spawn: %v", err) } wantLookups := []string{"opencode"} @@ -3130,7 +3156,7 @@ func TestSpawn_RejectsMissingBinaryAfterEnvPrefix(t *testing.T) { agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}} m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if !errors.Is(err, ports.ErrAgentBinaryNotFound) { t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) } @@ -3169,7 +3195,7 @@ func TestSpawn_RejectsEnvPrefixWithoutBinary(t *testing.T) { }, }) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if !errors.Is(err, ports.ErrAgentBinaryNotFound) { t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) } @@ -3197,7 +3223,7 @@ func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) { } m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") { t.Fatalf("err = %v, want missing tmux prerequisite", err) } @@ -3218,7 +3244,7 @@ func TestSpawn_RejectsUnknownHarness(t *testing.T) { ws := &fakeWorkspace{} m := New(Deps{Runtime: rt, Agents: missingAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: func(string) (string, error) { return "/bin/true", nil }}) - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: "bogus"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: "bogus"}) if !errors.Is(err, ErrUnknownHarness) { t.Fatalf("err = %v, want ErrUnknownHarness", err) } @@ -3270,7 +3296,7 @@ func TestSpawnAndRestore_PinHookPATHToDaemonBinary(t *testing.T) { { name: "spawn", launch: func(m *Manager, _ *fakeStore) error { - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) return err }, }, @@ -3311,7 +3337,7 @@ func TestSpawn_HookPATHPinUnavailable(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { m, _, rt, logBuf := pathPinManager(tc.executable) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatal(err) } if got, ok := rt.lastCfg.Env["PATH"]; ok { @@ -3334,7 +3360,7 @@ func TestSpawn_ProjectPATHIsPinBase(t *testing.T) { Env: map[string]string{"PATH": "/proj/bin"}, Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode}, }} - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatal(err) } want := filepath.Dir(daemonExe) + string(os.PathListSeparator) + "/proj/bin" @@ -3390,7 +3416,7 @@ func TestSpawnAndRestore_PrependsResolvedBinaryAndNodeDirsToRuntimePATH(t *testi Executable: func() (string, error) { return daemonExe, nil }, }) if operation == "spawn" { - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { t.Fatalf("Spawn: %v", err) } @@ -3433,7 +3459,7 @@ func TestSpawn_DoesNotAddNodeRuntimeForNativeBinary(t *testing.T) { }, Executable: func() (string, error) { return "/ao/bin/ao", nil }, }) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + if _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatalf("Spawn: %v", err) } if nodeLookups != 0 { @@ -3447,7 +3473,7 @@ func TestSpawn_DoesNotAddNodeRuntimeForNativeBinary(t *testing.T) { func TestSpawn_KeepsExplicitBranch(t *testing.T) { m, st, _, _ := newManager() - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"}) if err != nil { t.Fatal(err) } @@ -3460,7 +3486,7 @@ func TestSpawn_ScratchUsesBranchlessWorkspace(t *testing.T) { m, st, _, ws := newManager() st.projects["scratch"] = domain.ProjectRecord{ID: "scratch", Kind: domain.ProjectKindScratch, Config: testRoleAgents()} - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker}) + s, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker}) if err != nil { t.Fatalf("Spawn scratch: %v", err) } @@ -3479,7 +3505,7 @@ func TestSpawn_ScratchRejectsExplicitBranchBeforeSessionRow(t *testing.T) { m, st, _, _ := newManager() st.projects["scratch"] = domain.ProjectRecord{ID: "scratch", Kind: domain.ProjectKindScratch, Config: testRoleAgents()} - _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Branch: "feature/x"}) + _, _, _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "scratch", Kind: domain.KindWorker, Branch: "feature/x"}) if !errors.Is(err, ErrScratchBranchUnsupported) { t.Fatalf("Spawn scratch explicit branch err = %v, want ErrScratchBranchUnsupported", err) } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8c769a4fd2..4c272568c6 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -1295,6 +1295,11 @@ export interface components { projectId: string; prompt?: string; }; + SpawnSessionResponse: { + promptBytes: number; + session: components["schemas"]["ControllersSessionView"]; + systemPromptBytes: number; + }; SubmitReviewInput: { /** @description Review body recorded by AO. Required for changes_requested. */ body?: string; @@ -2763,7 +2768,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SessionResponse"]; + "application/json": components["schemas"]["SpawnSessionResponse"]; }; }; /** @description Bad Request */