authorgravatar for joaquin@freedomcodecompliance.comJoaquin Navarro <joaquin@freedomcodecompliance.com> 2026-09-04 20:31:05-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:31:05-07:00
logfce8508456e7f9218eebb02ecd764f9b3a47df6b
treef8117d2ecbd6cf45860178cc9ef5a06fd69890f4
parent8faf031c2dac4f147c43fe32e326b76678c20c94
signature Signed by PGP key B5690EEEBB952194

fix(server): surface a missing workspace folder instead of a spawn error (#5040)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

6 files changed, 217 insertions(+), 34 deletions(-)

apps/server/integration/providerService.integration.test.ts+4-1
......@@ -100,7 +100,10 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer<Ana
100100 Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers),
101101 ).pipe(Layer.provide(SqlitePersistenceMemory));
102102
103 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
103 const layer = makeProviderServiceLive().pipe(
104 Layer.provide(NodeServices.layer),
105 Layer.provide(shared),
106 );
104107
105108 return {
106109 cwd,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+58-2
......@@ -39,7 +39,11 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3939
4040import { deriveServerPaths, ServerConfig } from "../../config.ts";
4141import { TextGenerationError } from "@t3tools/contracts";
42import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
42import {
43 ProviderAdapterRequestError,
44 ProviderWorkspaceMissingError,
45 type ProviderServiceError,
46} from "../../provider/Errors.ts";
4347import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4448import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
4549import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -181,7 +185,7 @@ describe("ProviderCommandReactor", () => {
181185 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
182186 readonly startSessionEffect?: (
183187 session: ProviderSession,
184 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
188 ) => Effect.Effect<ProviderSession, ProviderServiceError>;
185189 readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"];
186190 }) {
187191 const now = "2026-01-01T00:00:00.000Z";
......@@ -1217,6 +1221,58 @@ describe("ProviderCommandReactor", () => {
12171221 }),
12181222 );
12191223
1224 effectIt.effect("shows the missing workspace message without a provider stack trace", () =>
1225 Effect.gen(function* () {
1226 const attempted = yield* Deferred.make<void>();
1227 const missingCwd = "/missing/project/worktree";
1228 const missingWorkspace = new ProviderWorkspaceMissingError({
1229 threadId: ThreadId.make("thread-1"),
1230 cwd: missingCwd,
1231 });
1232 const harness = yield* Effect.promise(() =>
1233 createHarness({
1234 startSessionEffect: () =>
1235 Deferred.succeed(attempted, undefined).pipe(
1236 Effect.andThen(Effect.fail(missingWorkspace)),
1237 ),
1238 }),
1239 );
1240
1241 yield* harness.engine.dispatch({
1242 type: "thread.turn.start",
1243 commandId: CommandId.make("cmd-turn-start-missing-workspace"),
1244 threadId: ThreadId.make("thread-1"),
1245 message: {
1246 messageId: asMessageId("user-message-missing-workspace"),
1247 role: "user",
1248 text: "continue",
1249 attachments: [],
1250 },
1251 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1252 runtimeMode: "approval-required",
1253 createdAt: "2026-01-01T00:00:00.000Z",
1254 });
1255 yield* Deferred.await(attempted);
1256 yield* Effect.promise(() => harness.drain());
1257
1258 const thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1259 (entry) => entry.id === ThreadId.make("thread-1"),
1260 );
1261 expect(thread?.session).toMatchObject({
1262 status: "error",
1263 activeTurnId: null,
1264 lastError: missingWorkspace.message,
1265 });
1266 const failure = thread?.activities.find(
1267 (activity) => activity.kind === "provider.turn.start.failed",
1268 );
1269 expect(failure?.payload).toMatchObject({ detail: missingWorkspace.message });
1270 expect(harness.runtimeSessions).toEqual([]);
1271 expect(harness.sendTurn).not.toHaveBeenCalled();
1272 expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]);
1273 }),
1274 );
1275
12201276 effectIt.effect("settles a failed provider startup and allows a clean retry", () =>
12211277 Effect.gen(function* () {
12221278 let failStartup = true;
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5
......@@ -34,6 +34,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit
3434import {
3535 ProviderAdapterRequestError,
3636 ProviderAdapterValidationError,
37 ProviderWorkspaceMissingError,
3738} from "../../provider/Errors.ts";
3839import type { ProviderServiceError } from "../../provider/Errors.ts";
3940import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
......@@ -56,6 +57,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5657import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5758const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
5859const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
60const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError);
5961const isProviderDriverKind = Schema.is(ProviderDriverKind);
6062
6163type ProviderIntentEvent = Extract<
......@@ -394,6 +396,9 @@ const make = Effect.gen(function* () {
394396 if (isProviderAdapterValidationError(failReason?.error)) {
395397 return failReason.error.issue;
396398 }
399 if (isProviderWorkspaceMissingError(failReason?.error)) {
400 return failReason.error.message;
401 }
397402 return Cause.pretty(cause);
398403 };
399404
apps/server/src/provider/Errors.ts+17
......@@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide
8585 }
8686}
8787
88/**
89 * ProviderWorkspaceMissingError - The session's working directory no longer
90 * exists on disk, so no provider process can start in it.
91 */
92export class ProviderWorkspaceMissingError extends Schema.TaggedErrorClass<ProviderWorkspaceMissingError>()(
93 "ProviderWorkspaceMissingError",
94 {
95 threadId: Schema.String,
96 cwd: Schema.String,
97 },
98) {
99 override get message(): string {
100 return `This thread's workspace folder no longer exists or is not a directory: ${this.cwd}. Restore the folder at this path before retrying.`;
101 }
102}
103
88104/**
89105 * ProviderValidationError - Invalid provider API input.
90106 */
......@@ -197,6 +213,7 @@ export type ProviderAdapterError =
197213export type ProviderServiceError =
198214 | ProviderValidationError
199215 | ProviderUnsupportedError
216 | ProviderWorkspaceMissingError
200217 | ProviderInstanceNotFoundError
201218 | ProviderSessionNotFoundError
202219 | ProviderSessionDirectoryPersistenceError
apps/server/src/provider/Layers/ProviderService.test.ts+111-29
......@@ -32,6 +32,7 @@ import {
3232} from "@t3tools/shared/assistantCitations";
3333import { createModelSelection } from "@t3tools/shared/model";
3434import { it, assert, describe, vi } from "@effect/vitest";
35import { afterAll } from "vite-plus/test";
3536
3637import * as Cause from "effect/Cause";
3738import * as Deferred from "effect/Deferred";
......@@ -54,6 +55,7 @@ import {
5455 ProviderAdapterSessionNotFoundError,
5556 ProviderUnsupportedError,
5657 ProviderValidationError,
58 ProviderWorkspaceMissingError,
5759 type ProviderAdapterError,
5860} from "../Errors.ts";
5961import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
......@@ -79,6 +81,16 @@ const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd(
7981 Layer.provide(NodeServices.layer),
8082);
8183
84// startSession verifies the workspace folder exists before dispatching to an
85// adapter, so session cwd fixtures must be real directories.
86const fixtureCwdRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "provider-service-test-"));
87afterAll(() => NodeFS.rmSync(fixtureCwdRoot, { recursive: true, force: true }));
88function fixtureCwd(name: string): string {
89 const dir = NodePath.join(fixtureCwdRoot, name);
90 NodeFS.mkdirSync(dir, { recursive: true });
91 return dir;
92}
93
8294const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
8395const asEventId = (value: string): EventId => EventId.make(value);
8496const asThreadId = (value: string): ThreadId => ThreadId.make(value);
......@@ -425,6 +437,7 @@ function makeProviderServiceLayer(
425437 const layer = it.layer(
426438 Layer.mergeAll(
427439 makeProviderServiceLive().pipe(
440 Layer.provide(NodeServices.layer),
428441 Layer.provide(providerAdapterLayer),
429442 Layer.provide(directoryLayer),
430443 Layer.provide(defaultServerSettingsLayer),
......@@ -477,6 +490,7 @@ for (const [enabled, completed] of [
477490 const scope = yield* Scope.make();
478491 const services = yield* Layer.build(
479492 makeProviderServiceLive().pipe(
493 Layer.provide(NodeServices.layer),
480494 Layer.provide(
481495 Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
482496 ),
......@@ -596,6 +610,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
596610 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
597611 const providerLayer = Layer.mergeAll(
598612 makeProviderServiceLive().pipe(
613 Layer.provide(NodeServices.layer),
599614 Layer.provide(providerAdapterLayer),
600615 Layer.provide(directoryLayer),
601616 Layer.provide(defaultServerSettingsLayer),
......@@ -638,6 +653,7 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", ()
638653 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
639654 const providerLayer = Layer.mergeAll(
640655 makeProviderServiceLive().pipe(
656 Layer.provide(NodeServices.layer),
641657 Layer.provide(providerAdapterLayer),
642658 Layer.provide(directoryLayer),
643659 Layer.provide(defaultServerSettingsLayer),
......@@ -774,6 +790,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
774790 );
775791 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
776792 const providerLayer = makeProviderServiceLive().pipe(
793 Layer.provide(NodeServices.layer),
777794 Layer.provide(providerAdapterLayer),
778795 Layer.provide(directoryLayer),
779796 Layer.provide(defaultServerSettingsLayer),
......@@ -857,6 +874,7 @@ it.effect(
857874 Layer.provide(runtimeRepositoryLayer),
858875 );
859876 const providerLayer = makeProviderServiceLive().pipe(
877 Layer.provide(NodeServices.layer),
860878 Layer.provide(providerAdapterLayer),
861879 Layer.provide(directoryLayer),
862880 Layer.provide(serverSettingsLayer),
......@@ -926,6 +944,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
926944 );
927945 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
928946 const providerLayer = makeProviderServiceLive().pipe(
947 Layer.provide(NodeServices.layer),
929948 Layer.provide(providerAdapterLayer),
930949 Layer.provide(directoryLayer),
931950 Layer.provide(defaultServerSettingsLayer),
......@@ -1044,7 +1063,7 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => {
10441063 yield* provider.startSession(threadId, {
10451064 providerInstanceId: codexInstanceId,
10461065 threadId,
1047 cwd: "/tmp/project",
1066 cwd: fixtureCwd("project"),
10481067 runtimeMode: "approval-required",
10491068 });
10501069 if (!active) {
......@@ -1098,6 +1117,7 @@ it.effect(
10981117 Layer.provide(runtimeRepositoryLayer),
10991118 );
11001119 const providerLayer = makeProviderServiceLive().pipe(
1120 Layer.provide(NodeServices.layer),
11011121 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
11021122 Layer.provide(directoryLayer),
11031123 Layer.provide(defaultServerSettingsLayer),
......@@ -1218,6 +1238,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
12181238 }).pipe(Effect.provide(directoryLayer));
12191239
12201240 const providerLayer = makeProviderServiceLive().pipe(
1241 Layer.provide(NodeServices.layer),
12211242 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
12221243 Layer.provide(directoryLayer),
12231244 Layer.provide(defaultServerSettingsLayer),
......@@ -1283,6 +1304,7 @@ it.effect(
12831304 Layer.provide(runtimeRepositoryLayer),
12841305 );
12851306 const firstProviderLayer = makeProviderServiceLive().pipe(
1307 Layer.provide(NodeServices.layer),
12861308 Layer.provide(
12871309 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
12881310 ),
......@@ -1310,7 +1332,7 @@ it.effect(
13101332 const session = yield* provider.startSession(threadId, {
13111333 provider: ProviderDriverKind.make("codex"),
13121334 providerInstanceId: codexInstanceId,
1313 cwd: "/tmp/project",
1335 cwd: fixtureCwd("project"),
13141336 runtimeMode: "full-access",
13151337 threadId,
13161338 });
......@@ -1343,6 +1365,7 @@ it.effect(
13431365 Layer.provide(runtimeRepositoryLayer),
13441366 );
13451367 const secondProviderLayer = makeProviderServiceLive().pipe(
1368 Layer.provide(NodeServices.layer),
13461369 Layer.provide(
13471370 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
13481371 ),
......@@ -1380,7 +1403,7 @@ it.effect(
13801403 threadId?: string;
13811404 };
13821405 assert.equal(startPayload.provider, "codex");
1383 assert.equal(startPayload.cwd, "/tmp/project");
1406 assert.equal(startPayload.cwd, fixtureCwd("project"));
13841407 assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor);
13851408 assert.equal(startPayload.threadId, startedSession.threadId);
13861409 }
......@@ -1394,6 +1417,61 @@ it.effect(
13941417);
13951418
13961419routing.layer("ProviderServiceLive routing", (it) => {
1420 it.effect.each([CODEX_DRIVER, CLAUDE_AGENT_DRIVER, CURSOR_DRIVER])(
1421 "rejects missing, file, and saved workspace paths before starting %s",
1422 (driver) =>
1423 Effect.gen(function* () {
1424 const provider = yield* ProviderService.ProviderService;
1425 const adapter =
1426 driver === CODEX_DRIVER
1427 ? routing.codex
1428 : driver === CLAUDE_AGENT_DRIVER
1429 ? routing.claude
1430 : routing.cursor;
1431 const cwd = fixtureCwd(`missing-workspace-${driver}`);
1432 const movedCwd = `${cwd}-moved`;
1433 const threadId = asThreadId(`missing-workspace-${driver}`);
1434 const input = {
1435 provider: driver,
1436 providerInstanceId: ProviderInstanceId.make(driver),
1437 threadId,
1438 runtimeMode: "full-access" as const,
1439 cwd,
1440 };
1441
1442 yield* provider.startSession(threadId, input);
1443 yield* provider.stopSession({ threadId });
1444 adapter.startSession.mockClear();
1445 NodeFS.renameSync(cwd, movedCwd);
1446
1447 const failure = yield* provider.startSession(threadId, input).pipe(Effect.flip);
1448 assert.instanceOf(failure, ProviderWorkspaceMissingError);
1449 assert.include(failure.message, cwd);
1450 assert.equal(adapter.startSession.mock.calls.length, 0);
1451
1452 const { cwd: _cwd, ...savedInput } = input;
1453 const savedFailure = yield* provider.startSession(threadId, savedInput).pipe(Effect.flip);
1454 assert.instanceOf(savedFailure, ProviderWorkspaceMissingError);
1455 assert.include(savedFailure.message, cwd);
1456 assert.equal(adapter.startSession.mock.calls.length, 0);
1457
1458 NodeFS.writeFileSync(cwd, "not a directory");
1459 const fileFailure = yield* provider.startSession(threadId, input).pipe(Effect.flip);
1460 assert.instanceOf(fileFailure, ProviderWorkspaceMissingError);
1461 assert.include(fileFailure.message, cwd);
1462 assert.equal(adapter.startSession.mock.calls.length, 0);
1463
1464 NodeFS.unlinkSync(cwd);
1465 NodeFS.renameSync(movedCwd, cwd);
1466 const restored = yield* provider.startSession(threadId, savedInput);
1467 assert.equal(restored.cwd, cwd);
1468 assert.equal(adapter.startSession.mock.calls.length, 1);
1469 yield* provider.stopSession({ threadId });
1470 adapter.startSession.mockClear();
1471 adapter.stopSession.mockClear();
1472 }),
1473 );
1474
13971475 it.effect("allows promptless continuation only for capable providers", () =>
13981476 Effect.gen(function* () {
13991477 const provider = yield* ProviderService.ProviderService;
......@@ -1452,7 +1530,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
14521530 provider: ProviderDriverKind.make("codex"),
14531531 providerInstanceId: codexInstanceId,
14541532 threadId: asThreadId("thread-1"),
1455 cwd: "/tmp/project",
1533 cwd: fixtureCwd("project"),
14561534 runtimeMode: "full-access",
14571535 });
14581536 assert.equal(session.provider, "codex");
......@@ -1522,7 +1600,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
15221600 threadId?: string;
15231601 };
15241602 assert.equal(startPayload.provider, "codex");
1525 assert.equal(startPayload.cwd, "/tmp/project");
1603 assert.equal(startPayload.cwd, fixtureCwd("project"));
15261604 assert.deepEqual(startPayload.resumeCursor, session.resumeCursor);
15271605 assert.equal(startPayload.threadId, session.threadId);
15281606 }
......@@ -1799,7 +1877,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
17991877 provider: CODEX_DRIVER,
18001878 providerInstanceId: codexInstanceId,
18011879 threadId,
1802 cwd: "/tmp/feedback-project",
1880 cwd: fixtureCwd("feedback-project"),
18031881 runtimeMode: "full-access",
18041882 });
18051883 yield* routing.codex.stopSession(threadId);
......@@ -1862,7 +1940,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
18621940 provider: ProviderDriverKind.make("codex"),
18631941 providerInstanceId: codexInstanceId,
18641942 threadId: asThreadId("thread-attach"),
1865 cwd: "/tmp/project",
1943 cwd: fixtureCwd("project"),
18661944 runtimeMode: "full-access",
18671945 });
18681946
......@@ -1937,7 +2015,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
19372015 provider: ProviderDriverKind.make("codex"),
19382016 providerInstanceId: codexInstanceId,
19392017 threadId: asThreadId("thread-1"),
1940 cwd: "/tmp/project",
2018 cwd: fixtureCwd("project"),
19412019 runtimeMode: "full-access",
19422020 });
19432021 yield* routing.codex.stopSession(initial.threadId);
......@@ -1963,7 +2041,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
19632041 threadId?: string;
19642042 };
19652043 assert.equal(startPayload.provider, "codex");
1966 assert.equal(startPayload.cwd, "/tmp/project");
2044 assert.equal(startPayload.cwd, fixtureCwd("project"));
19672045 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
19682046 assert.equal(startPayload.threadId, initial.threadId);
19692047 }
......@@ -1982,7 +2060,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
19822060 provider: ProviderDriverKind.make("codex"),
19832061 providerInstanceId: codexInstanceId,
19842062 threadId: asThreadId("thread-reap-preserve"),
1985 cwd: "/tmp/project-reap-preserve",
2063 cwd: fixtureCwd("project-reap-preserve"),
19862064 runtimeMode: "full-access",
19872065 });
19882066
......@@ -2017,7 +2095,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
20172095 threadId?: string;
20182096 };
20192097 assert.equal(startPayload.provider, "codex");
2020 assert.equal(startPayload.cwd, "/tmp/project-reap-preserve");
2098 assert.equal(startPayload.cwd, fixtureCwd("project-reap-preserve"));
20212099 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
20222100 assert.equal(startPayload.threadId, initial.threadId);
20232101 }
......@@ -2033,7 +2111,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
20332111 provider: ProviderDriverKind.make("claudeAgent"),
20342112 providerInstanceId: claudeAgentInstanceId,
20352113 threadId: asThreadId("thread-claude"),
2036 cwd: "/tmp/project-claude",
2114 cwd: fixtureCwd("project-claude"),
20372115 runtimeMode: "full-access",
20382116 });
20392117
......@@ -2049,7 +2127,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
20492127 };
20502128 assert.equal(startPayload.provider, "claudeAgent");
20512129 assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId);
2052 assert.equal(startPayload.cwd, "/tmp/project-claude");
2130 assert.equal(startPayload.cwd, fixtureCwd("project-claude"));
20532131 }
20542132 }),
20552133 );
......@@ -2064,7 +2142,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
20642142 provider: ProviderDriverKind.make("codex"),
20652143 providerInstanceId: codexInstanceId,
20662144 threadId,
2067 cwd: "/tmp/project-binding-mismatch",
2145 cwd: fixtureCwd("project-binding-mismatch"),
20682146 runtimeMode: "full-access",
20692147 });
20702148 yield* directory.upsert({
......@@ -2094,7 +2172,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
20942172 provider: ProviderDriverKind.make("codex"),
20952173 providerInstanceId: codexInstanceId,
20962174 threadId,
2097 cwd: "/tmp/project-provider-replacement",
2175 cwd: fixtureCwd("project-provider-replacement"),
20982176 runtimeMode: "full-access",
20992177 });
21002178
......@@ -2105,7 +2183,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
21052183 provider: ProviderDriverKind.make("claudeAgent"),
21062184 providerInstanceId: claudeAgentInstanceId,
21072185 threadId,
2108 cwd: "/tmp/project-provider-replacement",
2186 cwd: fixtureCwd("project-provider-replacement"),
21092187 runtimeMode: "full-access",
21102188 });
21112189
......@@ -2132,7 +2210,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
21322210 provider: ProviderDriverKind.make("codex"),
21332211 providerInstanceId: codexInstanceId,
21342212 threadId: asThreadId("thread-1"),
2135 cwd: "/tmp/project-send-turn",
2213 cwd: fixtureCwd("project-send-turn"),
21362214 runtimeMode: "full-access",
21372215 });
21382216
......@@ -2157,7 +2235,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
21572235 threadId?: string;
21582236 };
21592237 assert.equal(startPayload.provider, "codex");
2160 assert.equal(startPayload.cwd, "/tmp/project-send-turn");
2238 assert.equal(startPayload.cwd, fixtureCwd("project-send-turn"));
21612239 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
21622240 assert.equal(startPayload.threadId, initial.threadId);
21632241 }
......@@ -2173,7 +2251,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
21732251 provider: ProviderDriverKind.make("claudeAgent"),
21742252 providerInstanceId: claudeAgentInstanceId,
21752253 threadId: asThreadId("thread-claude-send-turn"),
2176 cwd: "/tmp/project-claude-send-turn",
2254 cwd: fixtureCwd("project-claude-send-turn"),
21772255 modelSelection: createModelSelection(
21782256 ProviderInstanceId.make("claudeAgent"),
21792257 "claude-opus-4-6",
......@@ -2204,7 +2282,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
22042282 threadId?: string;
22052283 };
22062284 assert.equal(startPayload.provider, "claudeAgent");
2207 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
2285 assert.equal(startPayload.cwd, fixtureCwd("project-claude-send-turn"));
22082286 assert.deepEqual(
22092287 startPayload.modelSelection,
22102288 createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
......@@ -2368,6 +2446,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
23682446 Layer.provide(runtimeRepositoryLayer),
23692447 );
23702448 const firstProviderLayer = makeProviderServiceLive().pipe(
2449 Layer.provide(NodeServices.layer),
23712450 Layer.provide(
23722451 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
23732452 ),
......@@ -2389,7 +2468,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
23892468 provider: ProviderDriverKind.make("claudeAgent"),
23902469 providerInstanceId: claudeAgentInstanceId,
23912470 threadId: asThreadId("thread-claude-start"),
2392 cwd: "/tmp/project-claude-start",
2471 cwd: fixtureCwd("project-claude-start"),
23932472 runtimeMode: "full-access",
23942473 });
23952474 }).pipe(Effect.provide(firstProviderLayer));
......@@ -2407,6 +2486,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
24072486 Layer.provide(runtimeRepositoryLayer),
24082487 );
24092488 const secondProviderLayer = makeProviderServiceLive().pipe(
2489 Layer.provide(NodeServices.layer),
24102490 Layer.provide(
24112491 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
24122492 ),
......@@ -2430,7 +2510,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
24302510 provider: ProviderDriverKind.make("claudeAgent"),
24312511 providerInstanceId: claudeAgentInstanceId,
24322512 threadId: initial.threadId,
2433 cwd: "/tmp/project-claude-start",
2513 cwd: fixtureCwd("project-claude-start"),
24342514 runtimeMode: "full-access",
24352515 });
24362516 }).pipe(Effect.provide(secondProviderLayer));
......@@ -2446,7 +2526,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
24462526 threadId?: string;
24472527 };
24482528 assert.equal(startPayload.provider, "claudeAgent");
2449 assert.equal(startPayload.cwd, "/tmp/project-claude-start");
2529 assert.equal(startPayload.cwd, fixtureCwd("project-claude-start"));
24502530 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
24512531 assert.equal(startPayload.threadId, initial.threadId);
24522532 }
......@@ -2476,6 +2556,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
24762556 Layer.provide(runtimeRepositoryLayer),
24772557 );
24782558 const firstProviderLayer = makeProviderServiceLive().pipe(
2559 Layer.provide(NodeServices.layer),
24792560 Layer.provide(
24802561 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
24812562 ),
......@@ -2497,7 +2578,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
24972578 provider: ProviderDriverKind.make("claudeAgent"),
24982579 providerInstanceId: claudeAgentInstanceId,
24992580 threadId: asThreadId("thread-claude-cwd"),
2500 cwd: "/tmp/project-claude-cwd",
2581 cwd: fixtureCwd("project-claude-cwd"),
25012582 runtimeMode: "full-access",
25022583 });
25032584 }).pipe(Effect.provide(firstProviderLayer));
......@@ -2510,6 +2591,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
25102591 Layer.provide(runtimeRepositoryLayer),
25112592 );
25122593 const secondProviderLayer = makeProviderServiceLive().pipe(
2594 Layer.provide(NodeServices.layer),
25132595 Layer.provide(
25142596 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
25152597 ),
......@@ -2548,7 +2630,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
25482630 threadId?: string;
25492631 };
25502632 assert.equal(startPayload.provider, "claudeAgent");
2551 assert.equal(startPayload.cwd, "/tmp/project-claude-cwd");
2633 assert.equal(startPayload.cwd, fixtureCwd("project-claude-cwd"));
25522634 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
25532635 assert.equal(startPayload.threadId, initial.threadId);
25542636 }
......@@ -2742,7 +2824,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
27422824 provider: ProviderDriverKind.make("claudeAgent"),
27432825 providerInstanceId: claudeAgentInstanceId,
27442826 threadId: asThreadId("thread-metrics"),
2745 cwd: "/tmp/project",
2827 cwd: fixtureCwd("project"),
27462828 runtimeMode: "full-access",
27472829 });
27482830
......@@ -2820,7 +2902,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
28202902 provider: ProviderDriverKind.make("claudeAgent"),
28212903 providerInstanceId: claudeAgentInstanceId,
28222904 threadId: asThreadId("thread-send-metrics"),
2823 cwd: "/tmp/project-send-metrics",
2905 cwd: fixtureCwd("project-send-metrics"),
28242906 runtimeMode: "full-access",
28252907 });
28262908
......@@ -4155,7 +4237,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
41554237 provider: ProviderDriverKind.make("codex"),
41564238 providerInstanceId: codexInstanceId,
41574239 threadId: asThreadId("thread-missing"),
4158 cwd: "/tmp/project",
4240 cwd: fixtureCwd("project"),
41594241 runtimeMode: "full-access",
41604242 });
41614243
......@@ -4204,7 +4286,7 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
42044286 provider: CODEX_DRIVER,
42054287 providerInstanceId: codexInstanceId,
42064288 threadId: activeSessionThreadId,
4207 cwd: "/tmp/project-active-session",
4289 cwd: fixtureCwd("project-active-session"),
42084290 runtimeMode: "full-access",
42094291 });
42104292 listThreadIds.mockClear();
apps/server/src/provider/Layers/ProviderService.ts+22-2
......@@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
3535import * as DateTime from "effect/DateTime";
3636import * as Deferred from "effect/Deferred";
3737import * as Effect from "effect/Effect";
38import * as FileSystem from "effect/FileSystem";
3839import * as Layer from "effect/Layer";
3940import * as Option from "effect/Option";
4041import * as PubSub from "effect/PubSub";
......@@ -55,8 +56,12 @@ import {
5556 providerTurnMetricAttributes,
5657 withMetrics,
5758} from "../../observability/Metrics.ts";
58import { ProviderAdapterRequestError } from "../Errors.ts";
59import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
59import {
60 ProviderAdapterRequestError,
61 type ProviderAdapterError,
62 ProviderValidationError,
63 ProviderWorkspaceMissingError,
64} from "../Errors.ts";
6065import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
6166import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
6267import * as ProviderService from "../Services/ProviderService.ts";
......@@ -322,6 +327,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
322327 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
323328 const revokeMcpCredential =
324329 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
330 const fileSystem = yield* FileSystem.FileSystem;
325331 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
326332 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
327333 const timedOutNativeCompactions = new Set<ThreadId>();
......@@ -1235,6 +1241,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
12351241 : "none",
12361242 "provider.cwd.effective": effectiveCwd ?? "",
12371243 });
1244 if (effectiveCwd !== undefined) {
1245 // Fail fast with an actionable error when the workspace folder is
1246 // gone (e.g. moved, deleted, or replaced by a plain file).
1247 // Otherwise every adapter surfaces this as a misleading "failed to
1248 // spawn <binary>" process error. Stat failures other than "missing"
1249 // fall through to the adapter.
1250 const workspaceIsDirectory = yield* fileSystem.stat(effectiveCwd).pipe(
1251 Effect.map((workspaceStat) => workspaceStat.type === "Directory"),
1252 Effect.catch((statError) => Effect.succeed(statError.reason._tag !== "NotFound")),
1253 );
1254 if (!workspaceIsDirectory) {
1255 return yield* new ProviderWorkspaceMissingError({ threadId, cwd: effectiveCwd });
1256 }
1257 }
12381258 const adapter = yield* registry.getByInstance(resolvedInstanceId);
12391259 yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId);
12401260 yield* prepareMcpSession(threadId, resolvedInstanceId);