Skip to content

Commit df3035d

Browse files
fix: subdirectory worktree turns send checkout-rooted mentions and reopen sessions when the agent cwd moves
- Saved and queued mentions are checkout-relative; when the agent runs in a subdirectory of the worktree, the provider now gets them joined to the checkout instead of reading them against its own cwd. - An idle provider session whose cwd no longer matches the runtime policy (a branch gained or dropped the project subdirectory) is released and reopened at the new cwd. Busy sessions, sessions shared with other threads, and sessions with pending background work are kept. - Composer mention chips resolve icons and tooltips from the file panel's root. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 46d7a11 commit df3035d

5 files changed

Lines changed: 141 additions & 5 deletions

File tree

‎apps/server/src/orchestration-v2/ProviderSessionManager.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,14 @@ function makeProviderSession(input: {
205205
readonly providerSessionId: ProviderSessionId;
206206
readonly now: DateTime.Utc;
207207
readonly capabilities?: OrchestrationV2ProviderCapabilities;
208+
readonly cwd?: string | null;
208209
}): OrchestrationV2ProviderSession {
209210
return {
210211
id: input.providerSessionId,
211212
driver: CODEX_DRIVER,
212213
providerInstanceId: modelSelection.instanceId,
213214
status: "ready",
214-
cwd: process.cwd(),
215+
cwd: input.cwd ?? process.cwd(),
215216
model: "gpt-5.4",
216217
capabilities: input.capabilities ?? CodexCapabilities,
217218
createdAt: input.now,
@@ -381,6 +382,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
381382
const session = makeProviderSession({
382383
providerSessionId: input.providerSessionId,
383384
now,
385+
cwd: input.runtimePolicy.cwd,
384386
...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }),
385387
});
386388
yield* Ref.update(state, (current) => {
@@ -955,6 +957,62 @@ it.effect("ProviderSessionManagerV2 closes every live session for a provider ins
955957
}),
956958
);
957959

960+
it.effect("ProviderSessionManagerV2 reopens an idle session when the agent cwd moves", () =>
961+
Effect.gen(function* () {
962+
const state = yield* Ref.make(emptyState);
963+
const effect = Effect.gen(function* () {
964+
const eventSink = yield* EventSink.EventSinkV2;
965+
const idAllocator = yield* IdAllocator.IdAllocatorV2;
966+
const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
967+
const fileSystem = yield* FileSystem.FileSystem;
968+
const now = yield* DateTime.now;
969+
const checkout = yield* fileSystem.makeTempDirectoryScoped();
970+
const nested = `${checkout}/ios`;
971+
yield* fileSystem.makeDirectory(nested);
972+
const threadId = ThreadId.make("thread-provider-session-manager-cwd-move");
973+
const providerSessionId = yield* idAllocator.allocate.providerSession({
974+
providerInstanceId: modelSelection.instanceId,
975+
threadId,
976+
});
977+
yield* eventSink.write({
978+
events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
979+
});
980+
const open = (cwd: string) =>
981+
manager.open({
982+
threadId,
983+
providerSessionId,
984+
modelSelection,
985+
runtimePolicy: { ...runtimePolicy, cwd },
986+
});
987+
988+
const atRoot = yield* open(checkout);
989+
assert.equal(atRoot.providerSession.cwd, checkout);
990+
assert.strictEqual(yield* open(checkout), atRoot);
991+
992+
const atNested = yield* open(nested);
993+
assert.equal(atNested.providerSession.cwd, nested);
994+
const backAtRoot = yield* open(checkout);
995+
assert.equal(backAtRoot.providerSession.cwd, checkout);
996+
const current = yield* Ref.get(state);
997+
assert.equal(current.openCount, 3);
998+
assert.equal(current.closeCount, 2);
999+
});
1000+
1001+
yield* effect.pipe(
1002+
Effect.scoped,
1003+
Effect.provide(
1004+
Layer.merge(
1005+
layerTest({
1006+
state,
1007+
idleTimeoutMs: 60_000,
1008+
}),
1009+
NodeServices.layer,
1010+
),
1011+
),
1012+
);
1013+
}),
1014+
);
1015+
9581016
it.effect("ProviderSessionManagerV2 opens a duplicate session only once", () =>
9591017
Effect.gen(function* () {
9601018
const state = yield* Ref.make(emptyState);

‎apps/server/src/orchestration-v2/ProviderSessionManager.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2118,7 +2118,31 @@ export const layerWithOptions = (
21182118
}
21192119
}
21202120
const key = sessionKey(input.providerSessionId);
2121-
const existing = (yield* Ref.get(sessions)).get(key);
2121+
let existing = (yield* Ref.get(sessions)).get(key);
2122+
// A subdirectory project's worktree thread moves its agent cwd when
2123+
// a branch gains or drops the subdirectory. A provider keeps its
2124+
// spawn cwd, so an idle session is reopened there and resumes.
2125+
if (
2126+
existing !== undefined &&
2127+
cwd !== null &&
2128+
existing.runtime.providerSession.cwd !== cwd &&
2129+
existing.busyTurns.size === 0 &&
2130+
[...existing.attachedThreadIds].every((threadId) => threadId === input.threadId) &&
2131+
!(existing.runtime.hasPendingBackgroundWork === undefined
2132+
? false
2133+
: yield* existing.runtime.hasPendingBackgroundWork.pipe(
2134+
Effect.catchCause(() => Effect.succeed(true)),
2135+
))
2136+
) {
2137+
yield* releaseEntry({
2138+
providerSessionId: input.providerSessionId,
2139+
reason: "manual_shutdown",
2140+
detail: "The agent's working directory changed.",
2141+
gracefulSubscribers: true,
2142+
onlyIfIdleGeneration: existing.idleGeneration,
2143+
}).pipe(logReleaseFailure(input.providerSessionId));
2144+
existing = (yield* Ref.get(sessions)).get(key);
2145+
}
21222146
if (existing !== undefined) {
21232147
const attached = existing.attachedThreadIds.has(input.threadId);
21242148
if (!attached && !existing.supportsMultipleProviderThreads) {

‎apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ it.each([
259259

260260
function makeLocalCommandHarness(input: {
261261
readonly text: string;
262+
readonly context?: OrchestrationV2ThreadProjection["messages"][number]["context"];
263+
readonly agentCwd?: string;
262264
readonly worktreePath?: string;
263265
readonly worktreesDir?: string;
264266
readonly fileSystem?: FileSystem.FileSystem;
@@ -334,6 +336,7 @@ function makeLocalCommandHarness(input: {
334336
nodeId: rootNodeId,
335337
role: "user",
336338
text: input.text,
339+
...(input.context === undefined ? {} : { context: input.context }),
337340
attachments: [],
338341
streaming: false,
339342
createdBy: "user",
@@ -666,7 +669,9 @@ function makeLocalCommandHarness(input: {
666669
);
667670
const resolveRuntimePolicy = vi.fn(
668671
({ thread }: Parameters<RuntimePolicy.RuntimePolicyV2Shape["resolve"]>[0]) =>
669-
Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never),
672+
Effect.succeed({
673+
cwd: input.agentCwd ?? thread.worktreePath ?? "/tmp/native-account-command",
674+
} as never),
670675
);
671676
const createWorktree = vi.fn(() => Effect.succeed({} as never));
672677
const getThreadRecords = vi.fn<ProjectionStore.ProjectionStoreV2Shape["getThreadRecords"]>(() =>
@@ -796,6 +801,38 @@ effectIt.effect("recovers an abandoned failed turn before continuing its native
796801
}),
797802
);
798803

804+
effectIt.effect.each([
805+
{ agentCwd: "/tmp/checkout/ios", expected: "path: /tmp/checkout/README.md" },
806+
{ agentCwd: "/tmp/checkout", expected: "path: README.md" },
807+
])("resolves saved checkout mentions for an agent cwd of $agentCwd", ({ agentCwd, expected }) =>
808+
Effect.gen(function* () {
809+
const harness = makeLocalCommandHarness({
810+
text: "Read [README.md](t3-context://v1/mention/ctx_readme)",
811+
context: {
812+
version: 1,
813+
records: [
814+
{
815+
version: 1,
816+
contextId: "ctx_readme",
817+
label: "README.md",
818+
kind: "mention",
819+
path: "README.md",
820+
},
821+
],
822+
} as never,
823+
worktreePath: "/tmp/checkout",
824+
agentCwd,
825+
previousTurn: { runStatus: "completed", turnStatus: "completed" },
826+
});
827+
828+
yield* harness.start;
829+
830+
const text = harness.startRootRun.mock.calls[0]?.[0].message.text;
831+
expect(text).toContain(expected);
832+
expect(text).not.toContain("path: /tmp/checkout/ios/");
833+
}),
834+
);
835+
799836
effectIt.effect.each([
800837
{ runStatus: "failed", turnStatus: "completed" },
801838
{ runStatus: "completed", turnStatus: "completed" },

‎apps/server/src/orchestration-v2/ProviderTurnStartService.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,9 +1032,25 @@ export const layer: Layer.Layer<
10321032
const routableSubagents = projection.subagents.filter((subagent) =>
10331033
RunExecutionService.canRouteRelatedSubagent(subagent.status),
10341034
);
1035+
// Mentions are saved relative to the checkout; an agent in a subdirectory
1036+
// of it would otherwise read them against its own cwd.
1037+
const checkoutRoot = projection.thread.worktreePath;
1038+
const mentionRoot =
1039+
checkoutRoot !== null &&
1040+
resolvedRuntimePolicy.cwd !== null &&
1041+
path.resolve(checkoutRoot) !== path.resolve(resolvedRuntimePolicy.cwd)
1042+
? checkoutRoot
1043+
: null;
10351044
const userText = projectComposerContextForProvider({
10361045
text: message.text,
1037-
records: message.context?.records ?? [],
1046+
records: (message.context?.records ?? []).map((record) =>
1047+
mentionRoot !== null &&
1048+
record.kind === "mention" &&
1049+
"path" in record &&
1050+
!path.isAbsolute(record.path)
1051+
? { ...record, path: path.join(mentionRoot, record.path) }
1052+
: record,
1053+
),
10381054
});
10391055
// Delivered once: this run's provider turn marks the work as told. A
10401056
// restart continuation is prompted by its own text or resumes natively.

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
763763
const previewFile = composerFiles.find((file) => file.id === previewFileId);
764764
const composerContextActions = {
765765
environmentId,
766-
cwd: gitCwd ?? undefined,
766+
// Mention chips name paths relative to the file panel's root.
767+
cwd: workspaceRoot ?? gitCwd ?? undefined,
767768
expandImage: (imageId: string) => {
768769
const preview = buildExpandedImagePreview(composerImages, imageId);
769770
if (preview) onExpandImage(preview);

0 commit comments

Comments
 (0)