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
148 changes: 148 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2196,6 +2196,154 @@ describe("ProviderCommandReactor", () => {
});
});

it("applies a queued checkout switch on its own once background tasks settle", async () => {
const harness = await createHarness();
const threadId = ThreadId.make("thread-1");
const now = "2026-01-01T00:00:00.000Z";
const settleSession = async (pendingBackgroundTaskCount: number, commandSuffix: string) => {
const snapshot = await harness.readModel();
const session = snapshot.threads.find((thread) => thread.id === threadId)?.session;
if (!session) throw new Error("expected a projected session for thread-1");
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make(`cmd-idle-apply-${commandSuffix}`),
threadId,
session: {
...session,
status: "ready",
activeTurnId: null,
pendingBackgroundTaskCount,
updatedAt: now,
},
createdAt: now,
}),
);
};

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-idle-apply-1"),
threadId,
message: {
messageId: asMessageId("user-message-idle-apply-1"),
role: "user",
text: "first in project root",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);

// The turn finished but a subagent still runs inside the session, and the
// user queues a checkout switch on top of it. Nothing may cycle yet.
await settleSession(1, "busy");
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-worktree-change-idle-apply"),
threadId,
worktreePath: "/tmp/provider-project-worktree",
}),
);
await waitFor(async () => {
const snapshot = await harness.readModel();
return (
snapshot.threads.find((thread) => thread.id === threadId)?.worktreePath ===
"/tmp/provider-project-worktree"
);
});
expect(harness.startSession.mock.calls.length).toBe(1);

// The background task finishing is enough to apply the switch: no new
// user message is dispatched, the session cycles on the settle alone.
await settleSession(0, "settled");
await waitFor(() => harness.startSession.mock.calls.length === 2);
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
threadId,
cwd: "/tmp/provider-project-worktree",
resumeCursor: { opaque: "resume-1" },
});
expect(harness.sendTurn.mock.calls.length).toBe(1);
expect(harness.stopSession.mock.calls.length).toBe(0);

// The restarted session reports the new checkout, which clears the
// pending-switch chip on the client.
await waitFor(async () => {
const snapshot = await harness.readModel();
return (
snapshot.threads.find((thread) => thread.id === threadId)?.session?.checkoutCwd ===
"/tmp/provider-project-worktree"
);
});
});

it("applies a checkout switch immediately when the session is idle", async () => {
const harness = await createHarness();
const threadId = ThreadId.make("thread-1");
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-idle-pick-1"),
threadId,
message: {
messageId: asMessageId("user-message-idle-pick-1"),
role: "user",
text: "first in project root",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);

const snapshot = await harness.readModel();
const session = snapshot.threads.find((thread) => thread.id === threadId)?.session;
if (!session) throw new Error("expected a projected session for thread-1");
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-idle-pick-settle"),
threadId,
session: {
...session,
status: "ready",
activeTurnId: null,
pendingBackgroundTaskCount: 0,
updatedAt: now,
},
createdAt: now,
}),
);

// Picking a different checkout while the session sits idle cycles it
// right away instead of waiting for the next message.
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-worktree-change-idle-pick"),
threadId,
worktreePath: "/tmp/provider-project-worktree",
}),
);
await waitFor(() => harness.startSession.mock.calls.length === 2);
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
threadId,
cwd: "/tmp/provider-project-worktree",
resumeCursor: { opaque: "resume-1" },
});
expect(harness.sendTurn.mock.calls.length).toBe(1);
expect(harness.stopSession.mock.calls.length).toBe(0);
});

it("keeps a running turn in its original checkout when a follow-up arrives after a checkout switch", async () => {
const harness = await createHarness();
const threadId = ThreadId.make("thread-1");
Expand Down
94 changes: 94 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ type ProviderIntentEvent = Extract<
{
type:
| "thread.runtime-mode-set"
| "thread.meta-updated"
| "thread.turn-start-requested"
| "thread.follow-up-submitted"
| "thread.turn-interrupt-requested"
Expand Down Expand Up @@ -1959,6 +1960,79 @@ const make = Effect.gen(function* () {
});
});

/**
* Session statuses in which a queued checkout switch can apply right away:
* the runtime is alive but owns no in-flight turn whose files a restart
* would swap out from under it.
*/
const idleSessionStatuses: ReadonlySet<OrchestrationSession["status"]> = new Set([
"idle",
"ready",
"interrupted",
]);

/**
* A queued checkout switch normally applies when the next turn is
* dispatched. When the session is already idle with no background tasks
* left, waiting for the user's next message just leaves every surface
* pointed at the old checkout, so cycle the session now instead. Only a
* live, projected-and-bound session is moved — a thread without a running
* runtime keeps lazy semantics (its next turn starts in the right place).
*/
const maybeApplyQueuedCheckoutSwitch = Effect.fnUntraced(function* (
threadId: ThreadId,
occurredAt: string,
) {
const thread = yield* resolveThread(threadId);
const session = thread?.session;
if (!thread || !session || !idleSessionStatuses.has(session.status)) {
return;
}
if (session.activeTurnId !== null || (session.pendingBackgroundTaskCount ?? 0) > 0) {
return;
}
// The projected checkoutCwd is rewritten on every (re)bind; null means we
// never learned where the runtime runs, so leave the switch to the next
// turn dispatch rather than guessing.
const sessionCheckoutCwd = session.checkoutCwd ?? undefined;
if (sessionCheckoutCwd === undefined) {
return;
}
const project = yield* resolveProject(thread.projectId);
if (!project || project.kind === "general-chat") {
return;
}
const targetCwd = resolveThreadWorkspaceCwd({ thread, projects: [project] });
if (!targetCwd || isSameWorkspaceCwd(targetCwd, sessionCheckoutCwd)) {
return;
}
const activeSession = (yield* providerService.listSessions()).find(
(candidate) => candidate.threadId === threadId,
);
if (!activeSession || isSameWorkspaceCwd(targetCwd, activeSession.cwd)) {
return;
}
yield* Effect.logInfo("provider command reactor applying queued checkout switch on idle", {
threadId,
fromCwd: sessionCheckoutCwd,
toCwd: targetCwd,
});
const cachedModelSelection = threadModelSelections.get(threadId);
yield* ensureSessionForThread(
threadId,
occurredAt,
cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {},
).pipe(
Effect.catch((error) =>
Effect.logWarning("provider command reactor failed to apply queued checkout switch", {
threadId,
toCwd: targetCwd,
detail: String(error),
}),
),
);
});

/**
* Pending approval / user-input prompts are answered through the live
* provider session; once that session stops (explicit stop, inactivity
Expand All @@ -1971,6 +2045,16 @@ const make = Effect.gen(function* () {
event: Extract<ProviderIntentEvent, { type: "thread.session-set" }>,
) {
if (event.payload.session.status !== "stopped") {
// Cheap payload precheck; the apply path re-validates against the
// freshly projected thread before touching the session.
const session = event.payload.session;
if (
idleSessionStatuses.has(session.status) &&
session.activeTurnId === null &&
(session.pendingBackgroundTaskCount ?? 0) === 0
) {
yield* maybeApplyQueuedCheckoutSwitch(event.payload.threadId, event.occurredAt);
}
return;
}
const thread = yield* resolveThread(event.payload.threadId);
Expand Down Expand Up @@ -2025,6 +2109,15 @@ const make = Effect.gen(function* () {
);
return;
}
case "thread.meta-updated": {
// Only a checkout retarget can queue a switch; title/model updates
// (which fire constantly, e.g. auto-titling) never need this.
if (event.payload.worktreePath === undefined) {
return;
}
yield* maybeApplyQueuedCheckoutSwitch(event.payload.threadId, event.occurredAt);
return;
}
case "thread.turn-start-requested":
yield* processTurnStartRequested(event);
return;
Expand Down Expand Up @@ -2088,6 +2181,7 @@ const make = Effect.gen(function* () {
const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) {
if (
event.type === "thread.runtime-mode-set" ||
event.type === "thread.meta-updated" ||
event.type === "thread.turn-start-requested" ||
event.type === "thread.follow-up-submitted" ||
event.type === "thread.turn-interrupt-requested" ||
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
resolveBranchToolbarValue,
resolveLockedWorkspaceLabel,
resolvePendingCheckoutSwitch,
queuedCheckoutSwitchToast,
shouldIncludeBranchPickerItem,
} from "./BranchToolbar.logic";

Expand Down Expand Up @@ -480,6 +481,71 @@ describe("resolvePendingCheckoutSwitch", () => {
});
});

describe("queuedCheckoutSwitchToast", () => {
it("announces a switch queued behind a running background task", () => {
const toast = queuedCheckoutSwitchToast({
session: {
orchestrationStatus: "running",
checkoutCwd: "/repo",
pendingBackgroundTaskCount: 1,
},
activeProjectCwd: "/repo",
nextWorktreePath: "/repo/.threadlines/worktrees/feature-a",
});
expect(toast?.title).toBe("Checkout switch queued");
expect(toast?.description).toContain("background task");
expect(toast?.description).toContain("feature-a");
});

it("announces a switch queued behind the running turn", () => {
const toast = queuedCheckoutSwitchToast({
session: {
orchestrationStatus: "running",
checkoutCwd: "/repo",
pendingBackgroundTaskCount: 0,
},
activeProjectCwd: "/repo",
nextWorktreePath: "/repo/.threadlines/worktrees/feature-a",
});
expect(toast?.description).toContain("current turn");
});

it("stays quiet when the session is idle: the server applies the switch right away", () => {
expect(
queuedCheckoutSwitchToast({
session: {
orchestrationStatus: "ready",
checkoutCwd: "/repo",
pendingBackgroundTaskCount: 0,
},
activeProjectCwd: "/repo",
nextWorktreePath: "/repo/.threadlines/worktrees/feature-a",
}),
).toBeNull();
});

it("stays quiet when the pick does not move the session", () => {
expect(
queuedCheckoutSwitchToast({
session: {
orchestrationStatus: "running",
checkoutCwd: "/repo",
pendingBackgroundTaskCount: 0,
},
activeProjectCwd: "/repo",
nextWorktreePath: null,
}),
).toBeNull();
expect(
queuedCheckoutSwitchToast({
session: null,
activeProjectCwd: "/repo",
nextWorktreePath: "/repo/.threadlines/worktrees/feature-a",
}),
).toBeNull();
});
});

describe("shouldIncludeBranchPickerItem", () => {
it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => {
expect(
Expand Down
Loading
Loading