Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 43 additions & 23 deletions internal/daemon/coordinator_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions internal/daemon/harness_context_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<agh-situation-context>"); got != 1 {
t.Fatalf("situation context occurrences = %d, want 1", got)
// The startup prompt embeds the bundled tools guide, which documents the
// "<agh-situation-context>" 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, "</agh-situation-context>"); 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)
Expand Down
7 changes: 7 additions & 0 deletions web/e2e/__tests__/sandbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
Loading