diff --git a/internal/daemon/coordinator_runtime.go b/internal/daemon/coordinator_runtime.go index cfa398d7..bf91465d 100644 --- a/internal/daemon/coordinator_runtime.go +++ b/internal/daemon/coordinator_runtime.go @@ -326,13 +326,9 @@ func (r *coordinatorRuntime) bootstrapRun( shouldPrompt := r.beginCoordinatorWakeLocked(existing, decision) r.mu.Unlock() r.dispatchDecision(ctx, decision, reason, coordinator.DecisionExisting) - if shouldPrompt { - if err := r.promptCoordinator(ctx, existing, decision, reason); err != nil { - r.finishCoordinatorWake(existing, decision) - r.dispatchFailed(ctx, decision, reason, err) - return existing, false, err - } - r.finishCoordinatorWake(existing, decision) + if err := r.wakeCoordinatorIfNeeded(ctx, existing, decision, reason, shouldPrompt); err != nil { + r.dispatchFailed(ctx, decision, reason, err) + return existing, false, err } return existing, false, nil } @@ -345,9 +341,21 @@ func (r *coordinatorRuntime) bootstrapRun( if !created { return nil, false, nil } + return r.reconcileCreatedCoordinator(ctx, info, decision, createdCfg, reason) +} +// reconcileCreatedCoordinator resolves the coordinator singleton after a fresh +// session was created: it supersedes a concurrently created coordinator or +// promotes the new session, dispatching the matching lifecycle decision. +func (r *coordinatorRuntime) reconcileCreatedCoordinator( + ctx context.Context, + info *session.Info, + decision coordinator.Decision, + createdCfg aghconfig.CoordinatorConfig, + reason string, +) (*session.Info, bool, error) { r.mu.Lock() - existing, err = r.activeCoordinator(ctx, decision.WorkspaceID) + existing, err := r.activeCoordinator(ctx, decision.WorkspaceID) if err != nil { r.mu.Unlock() cleanupErr := r.cleanupCreatedCoordinatorSession( @@ -376,28 +384,40 @@ func (r *coordinatorRuntime) bootstrapRun( r.dispatchFailed(ctx, decision, reason, err) return existing, false, err } - if shouldPrompt { - if err := r.promptCoordinator(ctx, existing, decision, reason); err != nil { - r.finishCoordinatorWake(existing, decision) - r.dispatchFailed(ctx, decision, reason, err) - return existing, false, err - } - r.finishCoordinatorWake(existing, decision) + if err := r.wakeCoordinatorIfNeeded(ctx, existing, decision, reason, shouldPrompt); err != nil { + r.dispatchFailed(ctx, decision, reason, err) + return existing, false, err } return existing, false, nil } shouldPrompt := r.beginCoordinatorWakeLocked(info, decision) r.mu.Unlock() - if shouldPrompt { - if err := r.promptCoordinator(ctx, info, decision, reason); err != nil { - r.finishCoordinatorWake(info, decision) - r.dispatchFailed(ctx, decision, reason, err) - return nil, false, err - } - r.finishCoordinatorWake(info, decision) + if err := r.wakeCoordinatorIfNeeded(ctx, info, decision, reason, shouldPrompt); err != nil { + r.dispatchFailed(ctx, decision, reason, err) + return nil, false, err } r.dispatchSpawned(ctx, decision, info, createdCfg, reason) - return info, created, nil + return info, true, nil +} + +// wakeCoordinatorIfNeeded prompts the coordinator when a wake was begun and +// always clears the wake state afterwards. The caller owns failure dispatch. +func (r *coordinatorRuntime) wakeCoordinatorIfNeeded( + ctx context.Context, + target *session.Info, + decision coordinator.Decision, + reason string, + shouldPrompt bool, +) error { + if !shouldPrompt { + return nil + } + if err := r.promptCoordinator(ctx, target, decision, reason); err != nil { + r.finishCoordinatorWake(target, decision) + return err + } + r.finishCoordinatorWake(target, decision) + return nil } func (r *coordinatorRuntime) cleanupCreatedCoordinatorSession( diff --git a/internal/daemon/harness_context_integration_test.go b/internal/daemon/harness_context_integration_test.go index 6d548b52..7faaafb6 100644 --- a/internal/daemon/harness_context_integration_test.go +++ b/internal/daemon/harness_context_integration_test.go @@ -158,8 +158,11 @@ func TestHarnessContextIntegrationStartupAndPromptShareResolverPolicy(t *testing if got := strings.Count(driver.startCalls[0].SystemPrompt, nativeToolsGuide); got != 1 { t.Fatalf("native tools guide occurrences = %d, want 1", got) } - if got := strings.Count(driver.startCalls[0].SystemPrompt, ""); got != 1 { - t.Fatalf("situation context occurrences = %d, want 1", got) + // The startup prompt embeds the bundled tools guide, which documents the + // "" open tag in prose; count the closing tag so the + // assertion measures rendered sections, not documentation mentions. + if got := strings.Count(driver.startCalls[0].SystemPrompt, ""); got != 1 { + t.Fatalf("situation context section occurrences = %d, want 1", got) } if got := strings.Count(driver.startCalls[0].SystemPrompt, aghRuntimeEnvelopeStart); got != 1 { t.Fatalf("AGH runtime envelope occurrences = %d, want 1", got) diff --git a/web/e2e/__tests__/sandbox.spec.ts b/web/e2e/__tests__/sandbox.spec.ts index 8e16b204..709e4cf3 100644 --- a/web/e2e/__tests__/sandbox.spec.ts +++ b/web/e2e/__tests__/sandbox.spec.ts @@ -177,6 +177,13 @@ test("operator manages a local sandbox profile and binds it to real session exec await appPage.getByTestId("settings-sandboxes-delete-cancel").click(); const session = await createSession(runtime, allowedAgent, workspace.id); + // The session lives in this freshly resolved workspace, while the active + // workspace is still the global one. The session route redirects away when + // the active workspace does not own the session, so activate the session's + // workspace first, exactly as an operator would before opening it. + await appPage.getByTestId("workspace-switcher").click(); + await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); + await expect(appPage.getByTestId("workspace-switcher")).toHaveAttribute("aria-expanded", "false"); await appPage.goto(runtime.url(sessionPath(allowedAgent, session.id)), { waitUntil: "domcontentloaded", });