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
5 changes: 2 additions & 3 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,9 +762,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
...existingRow.value,
effectiveCwd: event.payload.effectiveCwd,
effectiveCwdSource:
event.payload.effectiveCwd === null
? null
: (event.payload.effectiveCwdSource ?? "session"),
event.payload.effectiveCwdSource ??
(event.payload.effectiveCwd === null ? null : "session"),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
34 changes: 16 additions & 18 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type OrchestrationThreadDiffStat,
type OrchestrationThreadDoneOverride,
type OrchestrationThreadShell,
type ThreadEffectiveCwdSource,
ModelSelection,
OrchestrationThreadGoal,
ProjectId,
Expand Down Expand Up @@ -110,6 +111,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
}),
);

function projectEffectiveCwdSource(input: {
readonly effectiveCwd: string | null;
readonly effectiveCwdSource?: ThreadEffectiveCwdSource | null | undefined;
}): { readonly effectiveCwdSource?: ThreadEffectiveCwdSource } {
const source =
input.effectiveCwdSource ?? (input.effectiveCwd === null ? null : ("session" as const));
return source === null ? {} : { effectiveCwdSource: source };
}
/**
* Per-thread rollup of the turn file summaries the checkpoints projector
* writes into `projection_turns.checkpoint_files_json`. Aggregated in SQL so a
Expand Down Expand Up @@ -1688,9 +1698,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: row.branch,
worktreePath: row.worktreePath,
effectiveCwd: row.effectiveCwd,
...(row.effectiveCwd === null
? {}
: { effectiveCwdSource: row.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(row),
goal: row.goal,
voiceActive: (row.voiceActive ?? 0) > 0,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
Expand Down Expand Up @@ -1933,9 +1941,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: row.branch,
worktreePath: row.worktreePath,
effectiveCwd: row.effectiveCwd,
...(row.effectiveCwd === null
? {}
: { effectiveCwdSource: row.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(row),
goal: row.goal,
voiceActive: (row.voiceActive ?? 0) > 0,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
Expand Down Expand Up @@ -2084,9 +2090,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: row.branch,
worktreePath: row.worktreePath,
effectiveCwd: row.effectiveCwd,
...(row.effectiveCwd === null
? {}
: { effectiveCwdSource: row.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(row),
goal: row.goal,
voiceActive: (row.voiceActive ?? 0) > 0,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
Expand Down Expand Up @@ -2238,9 +2242,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: row.branch,
worktreePath: row.worktreePath,
effectiveCwd: row.effectiveCwd,
...(row.effectiveCwd === null
? {}
: { effectiveCwdSource: row.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(row),
goal: row.goal,
voiceActive: (row.voiceActive ?? 0) > 0,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
Expand Down Expand Up @@ -2521,9 +2523,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
effectiveCwd: threadRow.value.effectiveCwd,
...(threadRow.value.effectiveCwd === null
? {}
: { effectiveCwdSource: threadRow.value.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(threadRow.value),
goal: threadRow.value.goal,
voiceActive: (threadRow.value.voiceActive ?? 0) > 0,
latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
Expand Down Expand Up @@ -2635,9 +2635,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
effectiveCwd: threadRow.value.effectiveCwd,
...(threadRow.value.effectiveCwd === null
? {}
: { effectiveCwdSource: threadRow.value.effectiveCwdSource ?? "session" }),
...projectEffectiveCwdSource(threadRow.value),
goal: threadRow.value.goal,
voiceActive: (threadRow.value.voiceActive ?? 0) > 0,
latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ describe("ProviderCommandReactor", () => {
readonly failNativeForkStart?: boolean;
readonly failRealtimeStart?: boolean;
readonly interruptTurn?: ProviderServiceShape["interruptTurn"];
/** Mirror the provider lifecycle's projected `starting` state while a
* replacement session is being bound. */
readonly projectStartingDuringRestart?: boolean;
}) {
const now = "2026-01-01T00:00:00.000Z";
const baseDir =
Expand All @@ -184,6 +187,8 @@ describe("ProviderCommandReactor", () => {
model: "gpt-5-codex",
};
const failNativeForkStart = input?.failNativeForkStart === true;
const projectStartingDuringRestart = input?.projectStartingDuringRestart === true;
let projectRestartStarting: ((session: ProviderSession) => Effect.Effect<void>) | null = null;
const startSession = vi.fn(
(_: unknown, input: unknown): Effect.Effect<ProviderSession, ProviderAdapterRequestError> => {
if (
Expand Down Expand Up @@ -254,7 +259,11 @@ describe("ProviderCommandReactor", () => {
updatedAt: now,
};
runtimeSessions.push(session);
return Effect.succeed(session);
const projectStarting =
projectStartingDuringRestart && sessionIndex > 1 && projectRestartStarting
? projectRestartStarting(session)
: Effect.void;
return projectStarting.pipe(Effect.as(session));
},
);
const sendTurn = vi.fn((_: unknown) =>
Expand Down Expand Up @@ -507,6 +516,28 @@ describe("ProviderCommandReactor", () => {
runtime = ManagedRuntime.make(layer);

const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
projectRestartStarting = (session) =>
engine
.dispatch({
type: "thread.session.set",
commandId: CommandId.make(`cmd-project-restart-starting-${nextSessionIndex}`),
threadId: session.threadId,
session: {
threadId: session.threadId,
status: "starting",
providerName: session.provider,
providerInstanceId: session.providerInstanceId,
providerSessionId: null,
providerThreadId: session.providerThreadId ?? null,
runtimeMode: session.runtimeMode,
checkoutCwd: session.cwd ?? null,
activeTurnId: null,
lastError: null,
updatedAt: now,
},
createdAt: now,
})
.pipe(Effect.orDie, Effect.asVoid);
const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
scope = await Effect.runPromise(Scope.make("sequential"));
Expand Down Expand Up @@ -2506,7 +2537,7 @@ describe("ProviderCommandReactor", () => {
});

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

Expand Down Expand Up @@ -2565,6 +2596,15 @@ describe("ProviderCommandReactor", () => {
});
expect(harness.sendTurn.mock.calls.length).toBe(1);
expect(harness.stopSession.mock.calls.length).toBe(0);
await waitFor(async () => {
const current = await harness.readModel();
const restarted = current.threads.find((thread) => thread.id === threadId)?.session;
return (
restarted?.status === "ready" &&
restarted.activeTurnId === null &&
restarted.checkoutCwd === PROJECT_WORKTREE_ROOT
);
});
});

it("keeps a running turn in its original checkout when a follow-up arrives after a checkout switch", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,9 @@ const make = Effect.gen(function* () {
options?: {
readonly modelSelection?: ModelSelection;
readonly excludeContextSeedMessageId?: MessageId;
/** Keep the projected startup state until a requested turn reaches the
* provider. Idle session recycling must settle back to ready. */
readonly preservePendingTurnStartup?: boolean;
/** Same-driver native fork request for a fresh session start. Falls
* back to a plain start (context-seed seeding) when the fork fails. */
readonly forkFrom?: ProviderSessionForkFrom;
Expand Down Expand Up @@ -923,7 +926,9 @@ const make = Effect.gen(function* () {
const latestSession = latestThread.session;
const mappedStatus = mapProviderSessionStatusToOrchestrationStatus(session.status);
const shouldPreservePendingTurnStartup =
latestSession?.status === "starting" && mappedStatus === "ready";
options?.preservePendingTurnStartup === true &&
latestSession?.status === "starting" &&
mappedStatus === "ready";
// Provider-side identifiers can arrive through runtime ingestion or
// directly on the started session. Prefer the durable projection, but
// let the runtime heal an older missing value. Projected identifiers
Expand Down Expand Up @@ -1192,6 +1197,7 @@ const make = Effect.gen(function* () {
const ensured = yield* ensureSessionForThread(input.threadId, input.createdAt, {
...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
excludeContextSeedMessageId: input.messageId,
preservePendingTurnStartup: true,
...(forkFrom !== undefined ? { forkFrom } : {}),
});
const nativeForkApplied = ensured.nativeForkApplied;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,54 @@ describe("ProviderRuntimeIngestion", () => {
expect((await harness.readModel()).threads[0]?.effectiveCwd).toBe(sessionCwd);
});

it("does not reassert a subagent worktree after the user selects the project checkout", async () => {
const harness = await createClaudeHarness();
const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`;
harness.setRepositoryWorktrees([harness.workspaceRoot, worktree]);
harness.setSubagentWorktree("toolu_a", worktree);

startAgentTask(harness, "task-a", "toolu_a");
await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === worktree);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.checkout.select",
commandId: CommandId.make("cmd-select-project-checkout"),
threadId: ThreadId.make("thread-1"),
branch: "main",
worktreePath: null,
}),
);
await waitForThread(
harness.readModel,
(thread) => thread.effectiveCwd === null && thread.effectiveCwdSource === "selection",
);

// Model a lookup that read the old state before the selection but only
// reached the serialized decider afterward.
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.effective-cwd.set",
commandId: CommandId.make("cmd-stale-subagent-result"),
threadId: ThreadId.make("thread-1"),
effectiveCwd: worktree,
effectiveCwdSource: "subagent",
createdAt: "2026-01-01T00:00:05.000Z",
}),
);
await waitForThread(
harness.readModel,
(thread) => thread.effectiveCwd === null && thread.effectiveCwdSource === "selection",
);

completeTask(harness, "task-a");
await harness.drain();
await Effect.runPromise(Effect.sleep("100 millis"));
const selected = (await harness.readModel()).threads[0];
expect(selected?.effectiveCwd).toBeNull();
expect(selected?.effectiveCwdSource).not.toBe("subagent");
});

it("stops following when the provider session exits", async () => {
const harness = await createClaudeHarness();
const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`;
Expand Down Expand Up @@ -1196,6 +1244,50 @@ describe("ProviderRuntimeIngestion", () => {
expect(thread?.session?.updatedAt).toBe(reboundAt);
});

it("settles an idle session restart instead of inventing a pending turn", async () => {
const harness = await createHarness();
const restartAt = "2026-01-01T00:00:01.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-idle-session-restart-starting"),
threadId: ThreadId.make("thread-1"),
session: {
threadId: ThreadId.make("thread-1"),
status: "starting",
providerName: "codex",
providerSessionId: null,
providerThreadId: null,
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: restartAt,
},
createdAt: restartAt,
}),
);

harness.emit({
type: "session.started",
eventId: asEventId("evt-idle-session-restarted"),
provider: ProviderDriverKind.make("codex"),
threadId: asThreadId("thread-1"),
createdAt: "2026-01-01T00:00:05.000Z",
payload: {
message: "ready",
},
});
await harness.drain();

const thread = (await harness.readModel()).threads.find(
(entry) => entry.id === asThreadId("thread-1"),
);
expect(thread?.session?.status).toBe("ready");
expect(thread?.session?.activeTurnId).toBeNull();
expect(thread?.session?.updatedAt).toBe("2026-01-01T00:00:05.000Z");
});

it("keeps pending turn startup visible until the provider turn starts", async () => {
const harness = await createHarness();
const requestedAt = "2026-01-01T00:00:01.000Z";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2504,13 +2504,20 @@ const make = Effect.gen(function* () {
return activeTurnId !== null ? "running" : "ready";
}
})();
const shouldPreservePendingTurnStartup =
const canPreservePendingTurnStartup =
thread.session?.status === "starting" &&
nextActiveTurnId === null &&
runtimeStatus === "ready" &&
(event.type === "session.started" ||
event.type === "thread.started" ||
event.type === "session.state.changed");
const shouldPreservePendingTurnStartup = canPreservePendingTurnStartup
? Option.isSome(
yield* projectionTurnRepository.getPendingTurnStartByThreadId({
threadId: thread.id,
}),
)
: false;
const status = shouldPreservePendingTurnStartup ? "starting" : runtimeStatus;
const sessionUpdatedAt = shouldPreservePendingTurnStartup
? (thread.session?.updatedAt ?? now)
Expand Down
Binary file modified apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts
Binary file not shown.
Loading
Loading