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...@@ -100,7 +100,10 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer<Ana
100 Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers),100 Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers),
101 ).pipe(Layer.provide(SqlitePersistenceMemory));101 ).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
105 return {108 return {
106 cwd,109 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";...@@ -39,7 +39,11 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3939
40import { deriveServerPaths, ServerConfig } from "../../config.ts";40import { deriveServerPaths, ServerConfig } from "../../config.ts";
41import { TextGenerationError } from "@t3tools/contracts";41import { TextGenerationError } from "@t3tools/contracts";
42import { ProviderAdapterRequestError } from "../../provider/Errors.ts";42import {
43 ProviderAdapterRequestError,
44 ProviderWorkspaceMissingError,
45 type ProviderServiceError,
46} from "../../provider/Errors.ts";
43import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";47import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
44import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";48import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
45import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";49import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
...@@ -181,7 +185,7 @@ describe("ProviderCommandReactor", () => {...@@ -181,7 +185,7 @@ describe("ProviderCommandReactor", () => {
181 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;185 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
182 readonly startSessionEffect?: (186 readonly startSessionEffect?: (
183 session: ProviderSession,187 session: ProviderSession,
184 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;188 ) => Effect.Effect<ProviderSession, ProviderServiceError>;
185 readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"];189 readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"];
186 }) {190 }) {
187 const now = "2026-01-01T00:00:00.000Z";191 const now = "2026-01-01T00:00:00.000Z";
...@@ -1217,6 +1221,58 @@ describe("ProviderCommandReactor", () => {...@@ -1217,6 +1221,58 @@ describe("ProviderCommandReactor", () => {
1217 }),1221 }),
1218 );1222 );
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
1220 effectIt.effect("settles a failed provider startup and allows a clean retry", () =>1276 effectIt.effect("settles a failed provider startup and allows a clean retry", () =>
1221 Effect.gen(function* () {1277 Effect.gen(function* () {
1222 let failStartup = true;1278 let failStartup = true;
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+5
...@@ -34,6 +34,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit...@@ -34,6 +34,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit
34import {34import {
35 ProviderAdapterRequestError,35 ProviderAdapterRequestError,
36 ProviderAdapterValidationError,36 ProviderAdapterValidationError,
37 ProviderWorkspaceMissingError,
37} from "../../provider/Errors.ts";38} from "../../provider/Errors.ts";
38import type { ProviderServiceError } from "../../provider/Errors.ts";39import type { ProviderServiceError } from "../../provider/Errors.ts";
39import { TextGeneration } from "../../textGeneration/TextGeneration.ts";40import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
...@@ -56,6 +57,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";...@@ -56,6 +57,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
56import { GitWorkflowService } from "../../git/GitWorkflowService.ts";57import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
57const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);58const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
58const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);59const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
60const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError);
59const isProviderDriverKind = Schema.is(ProviderDriverKind);61const isProviderDriverKind = Schema.is(ProviderDriverKind);
6062
61type ProviderIntentEvent = Extract<63type ProviderIntentEvent = Extract<
...@@ -394,6 +396,9 @@ const make = Effect.gen(function* () {...@@ -394,6 +396,9 @@ const make = Effect.gen(function* () {
394 if (isProviderAdapterValidationError(failReason?.error)) {396 if (isProviderAdapterValidationError(failReason?.error)) {
395 return failReason.error.issue;397 return failReason.error.issue;
396 }398 }
399 if (isProviderWorkspaceMissingError(failReason?.error)) {
400 return failReason.error.message;
401 }
397 return Cause.pretty(cause);402 return Cause.pretty(cause);
398 };403 };
399404
apps/server/src/provider/Errors.ts+17
...@@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide...@@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide
85 }85 }
86}86}
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
88/**104/**
89 * ProviderValidationError - Invalid provider API input.105 * ProviderValidationError - Invalid provider API input.
90 */106 */
...@@ -197,6 +213,7 @@ export type ProviderAdapterError =...@@ -197,6 +213,7 @@ export type ProviderAdapterError =
197export type ProviderServiceError =213export type ProviderServiceError =
198 | ProviderValidationError214 | ProviderValidationError
199 | ProviderUnsupportedError215 | ProviderUnsupportedError
216 | ProviderWorkspaceMissingError
200 | ProviderInstanceNotFoundError217 | ProviderInstanceNotFoundError
201 | ProviderSessionNotFoundError218 | ProviderSessionNotFoundError
202 | ProviderSessionDirectoryPersistenceError219 | ProviderSessionDirectoryPersistenceError
apps/server/src/provider/Layers/ProviderService.test.ts+111-29
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32} from "@t3tools/shared/assistantCitations";32} from "@t3tools/shared/assistantCitations";
33import { createModelSelection } from "@t3tools/shared/model";33import { createModelSelection } from "@t3tools/shared/model";
34import { it, assert, describe, vi } from "@effect/vitest";34import { it, assert, describe, vi } from "@effect/vitest";
35import { afterAll } from "vite-plus/test";
3536
36import * as Cause from "effect/Cause";37import * as Cause from "effect/Cause";
37import * as Deferred from "effect/Deferred";38import * as Deferred from "effect/Deferred";
...@@ -54,6 +55,7 @@ import {...@@ -54,6 +55,7 @@ import {
54 ProviderAdapterSessionNotFoundError,55 ProviderAdapterSessionNotFoundError,
55 ProviderUnsupportedError,56 ProviderUnsupportedError,
56 ProviderValidationError,57 ProviderValidationError,
58 ProviderWorkspaceMissingError,
57 type ProviderAdapterError,59 type ProviderAdapterError,
58} from "../Errors.ts";60} from "../Errors.ts";
59import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";61import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
...@@ -79,6 +81,16 @@ const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd(...@@ -79,6 +81,16 @@ const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd(
79 Layer.provide(NodeServices.layer),81 Layer.provide(NodeServices.layer),
80);82);
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
82const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);94const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
83const asEventId = (value: string): EventId => EventId.make(value);95const asEventId = (value: string): EventId => EventId.make(value);
84const asThreadId = (value: string): ThreadId => ThreadId.make(value);96const asThreadId = (value: string): ThreadId => ThreadId.make(value);
...@@ -425,6 +437,7 @@ function makeProviderServiceLayer(...@@ -425,6 +437,7 @@ function makeProviderServiceLayer(
425 const layer = it.layer(437 const layer = it.layer(
426 Layer.mergeAll(438 Layer.mergeAll(
427 makeProviderServiceLive().pipe(439 makeProviderServiceLive().pipe(
440 Layer.provide(NodeServices.layer),
428 Layer.provide(providerAdapterLayer),441 Layer.provide(providerAdapterLayer),
429 Layer.provide(directoryLayer),442 Layer.provide(directoryLayer),
430 Layer.provide(defaultServerSettingsLayer),443 Layer.provide(defaultServerSettingsLayer),
...@@ -477,6 +490,7 @@ for (const [enabled, completed] of [...@@ -477,6 +490,7 @@ for (const [enabled, completed] of [
477 const scope = yield* Scope.make();490 const scope = yield* Scope.make();
478 const services = yield* Layer.build(491 const services = yield* Layer.build(
479 makeProviderServiceLive().pipe(492 makeProviderServiceLive().pipe(
493 Layer.provide(NodeServices.layer),
480 Layer.provide(494 Layer.provide(
481 Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),495 Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
482 ),496 ),
...@@ -596,6 +610,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>...@@ -596,6 +610,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
596 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));610 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
597 const providerLayer = Layer.mergeAll(611 const providerLayer = Layer.mergeAll(
598 makeProviderServiceLive().pipe(612 makeProviderServiceLive().pipe(
613 Layer.provide(NodeServices.layer),
599 Layer.provide(providerAdapterLayer),614 Layer.provide(providerAdapterLayer),
600 Layer.provide(directoryLayer),615 Layer.provide(directoryLayer),
601 Layer.provide(defaultServerSettingsLayer),616 Layer.provide(defaultServerSettingsLayer),
...@@ -638,6 +653,7 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", ()...@@ -638,6 +653,7 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", ()
638 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));653 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
639 const providerLayer = Layer.mergeAll(654 const providerLayer = Layer.mergeAll(
640 makeProviderServiceLive().pipe(655 makeProviderServiceLive().pipe(
656 Layer.provide(NodeServices.layer),
641 Layer.provide(providerAdapterLayer),657 Layer.provide(providerAdapterLayer),
642 Layer.provide(directoryLayer),658 Layer.provide(directoryLayer),
643 Layer.provide(defaultServerSettingsLayer),659 Layer.provide(defaultServerSettingsLayer),
...@@ -774,6 +790,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()...@@ -774,6 +790,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
774 );790 );
775 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));791 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
776 const providerLayer = makeProviderServiceLive().pipe(792 const providerLayer = makeProviderServiceLive().pipe(
793 Layer.provide(NodeServices.layer),
777 Layer.provide(providerAdapterLayer),794 Layer.provide(providerAdapterLayer),
778 Layer.provide(directoryLayer),795 Layer.provide(directoryLayer),
779 Layer.provide(defaultServerSettingsLayer),796 Layer.provide(defaultServerSettingsLayer),
...@@ -857,6 +874,7 @@ it.effect(...@@ -857,6 +874,7 @@ it.effect(
857 Layer.provide(runtimeRepositoryLayer),874 Layer.provide(runtimeRepositoryLayer),
858 );875 );
859 const providerLayer = makeProviderServiceLive().pipe(876 const providerLayer = makeProviderServiceLive().pipe(
877 Layer.provide(NodeServices.layer),
860 Layer.provide(providerAdapterLayer),878 Layer.provide(providerAdapterLayer),
861 Layer.provide(directoryLayer),879 Layer.provide(directoryLayer),
862 Layer.provide(serverSettingsLayer),880 Layer.provide(serverSettingsLayer),
...@@ -926,6 +944,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -926,6 +944,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
926 );944 );
927 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));945 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
928 const providerLayer = makeProviderServiceLive().pipe(946 const providerLayer = makeProviderServiceLive().pipe(
947 Layer.provide(NodeServices.layer),
929 Layer.provide(providerAdapterLayer),948 Layer.provide(providerAdapterLayer),
930 Layer.provide(directoryLayer),949 Layer.provide(directoryLayer),
931 Layer.provide(defaultServerSettingsLayer),950 Layer.provide(defaultServerSettingsLayer),
...@@ -1044,7 +1063,7 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => {...@@ -1044,7 +1063,7 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => {
1044 yield* provider.startSession(threadId, {1063 yield* provider.startSession(threadId, {
1045 providerInstanceId: codexInstanceId,1064 providerInstanceId: codexInstanceId,
1046 threadId,1065 threadId,
1047 cwd: "/tmp/project",1066 cwd: fixtureCwd("project"),
1048 runtimeMode: "approval-required",1067 runtimeMode: "approval-required",
1049 });1068 });
1050 if (!active) {1069 if (!active) {
...@@ -1098,6 +1117,7 @@ it.effect(...@@ -1098,6 +1117,7 @@ it.effect(
1098 Layer.provide(runtimeRepositoryLayer),1117 Layer.provide(runtimeRepositoryLayer),
1099 );1118 );
1100 const providerLayer = makeProviderServiceLive().pipe(1119 const providerLayer = makeProviderServiceLive().pipe(
1120 Layer.provide(NodeServices.layer),
1101 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),1121 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
1102 Layer.provide(directoryLayer),1122 Layer.provide(directoryLayer),
1103 Layer.provide(defaultServerSettingsLayer),1123 Layer.provide(defaultServerSettingsLayer),
...@@ -1218,6 +1238,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -1218,6 +1238,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
1218 }).pipe(Effect.provide(directoryLayer));1238 }).pipe(Effect.provide(directoryLayer));
12191239
1220 const providerLayer = makeProviderServiceLive().pipe(1240 const providerLayer = makeProviderServiceLive().pipe(
1241 Layer.provide(NodeServices.layer),
1221 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),1242 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
1222 Layer.provide(directoryLayer),1243 Layer.provide(directoryLayer),
1223 Layer.provide(defaultServerSettingsLayer),1244 Layer.provide(defaultServerSettingsLayer),
...@@ -1283,6 +1304,7 @@ it.effect(...@@ -1283,6 +1304,7 @@ it.effect(
1283 Layer.provide(runtimeRepositoryLayer),1304 Layer.provide(runtimeRepositoryLayer),
1284 );1305 );
1285 const firstProviderLayer = makeProviderServiceLive().pipe(1306 const firstProviderLayer = makeProviderServiceLive().pipe(
1307 Layer.provide(NodeServices.layer),
1286 Layer.provide(1308 Layer.provide(
1287 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),1309 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
1288 ),1310 ),
...@@ -1310,7 +1332,7 @@ it.effect(...@@ -1310,7 +1332,7 @@ it.effect(
1310 const session = yield* provider.startSession(threadId, {1332 const session = yield* provider.startSession(threadId, {
1311 provider: ProviderDriverKind.make("codex"),1333 provider: ProviderDriverKind.make("codex"),
1312 providerInstanceId: codexInstanceId,1334 providerInstanceId: codexInstanceId,
1313 cwd: "/tmp/project",1335 cwd: fixtureCwd("project"),
1314 runtimeMode: "full-access",1336 runtimeMode: "full-access",
1315 threadId,1337 threadId,
1316 });1338 });
...@@ -1343,6 +1365,7 @@ it.effect(...@@ -1343,6 +1365,7 @@ it.effect(
1343 Layer.provide(runtimeRepositoryLayer),1365 Layer.provide(runtimeRepositoryLayer),
1344 );1366 );
1345 const secondProviderLayer = makeProviderServiceLive().pipe(1367 const secondProviderLayer = makeProviderServiceLive().pipe(
1368 Layer.provide(NodeServices.layer),
1346 Layer.provide(1369 Layer.provide(
1347 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),1370 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
1348 ),1371 ),
...@@ -1380,7 +1403,7 @@ it.effect(...@@ -1380,7 +1403,7 @@ it.effect(
1380 threadId?: string;1403 threadId?: string;
1381 };1404 };
1382 assert.equal(startPayload.provider, "codex");1405 assert.equal(startPayload.provider, "codex");
1383 assert.equal(startPayload.cwd, "/tmp/project");1406 assert.equal(startPayload.cwd, fixtureCwd("project"));
1384 assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor);1407 assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor);
1385 assert.equal(startPayload.threadId, startedSession.threadId);1408 assert.equal(startPayload.threadId, startedSession.threadId);
1386 }1409 }
...@@ -1394,6 +1417,61 @@ it.effect(...@@ -1394,6 +1417,61 @@ it.effect(
1394);1417);
13951418
1396routing.layer("ProviderServiceLive routing", (it) => {1419routing.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
1397 it.effect("allows promptless continuation only for capable providers", () =>1475 it.effect("allows promptless continuation only for capable providers", () =>
1398 Effect.gen(function* () {1476 Effect.gen(function* () {
1399 const provider = yield* ProviderService.ProviderService;1477 const provider = yield* ProviderService.ProviderService;
...@@ -1452,7 +1530,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1452,7 +1530,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1452 provider: ProviderDriverKind.make("codex"),1530 provider: ProviderDriverKind.make("codex"),
1453 providerInstanceId: codexInstanceId,1531 providerInstanceId: codexInstanceId,
1454 threadId: asThreadId("thread-1"),1532 threadId: asThreadId("thread-1"),
1455 cwd: "/tmp/project",1533 cwd: fixtureCwd("project"),
1456 runtimeMode: "full-access",1534 runtimeMode: "full-access",
1457 });1535 });
1458 assert.equal(session.provider, "codex");1536 assert.equal(session.provider, "codex");
...@@ -1522,7 +1600,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1522,7 +1600,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1522 threadId?: string;1600 threadId?: string;
1523 };1601 };
1524 assert.equal(startPayload.provider, "codex");1602 assert.equal(startPayload.provider, "codex");
1525 assert.equal(startPayload.cwd, "/tmp/project");1603 assert.equal(startPayload.cwd, fixtureCwd("project"));
1526 assert.deepEqual(startPayload.resumeCursor, session.resumeCursor);1604 assert.deepEqual(startPayload.resumeCursor, session.resumeCursor);
1527 assert.equal(startPayload.threadId, session.threadId);1605 assert.equal(startPayload.threadId, session.threadId);
1528 }1606 }
...@@ -1799,7 +1877,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1799,7 +1877,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1799 provider: CODEX_DRIVER,1877 provider: CODEX_DRIVER,
1800 providerInstanceId: codexInstanceId,1878 providerInstanceId: codexInstanceId,
1801 threadId,1879 threadId,
1802 cwd: "/tmp/feedback-project",1880 cwd: fixtureCwd("feedback-project"),
1803 runtimeMode: "full-access",1881 runtimeMode: "full-access",
1804 });1882 });
1805 yield* routing.codex.stopSession(threadId);1883 yield* routing.codex.stopSession(threadId);
...@@ -1862,7 +1940,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1862,7 +1940,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1862 provider: ProviderDriverKind.make("codex"),1940 provider: ProviderDriverKind.make("codex"),
1863 providerInstanceId: codexInstanceId,1941 providerInstanceId: codexInstanceId,
1864 threadId: asThreadId("thread-attach"),1942 threadId: asThreadId("thread-attach"),
1865 cwd: "/tmp/project",1943 cwd: fixtureCwd("project"),
1866 runtimeMode: "full-access",1944 runtimeMode: "full-access",
1867 });1945 });
18681946
...@@ -1937,7 +2015,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1937,7 +2015,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1937 provider: ProviderDriverKind.make("codex"),2015 provider: ProviderDriverKind.make("codex"),
1938 providerInstanceId: codexInstanceId,2016 providerInstanceId: codexInstanceId,
1939 threadId: asThreadId("thread-1"),2017 threadId: asThreadId("thread-1"),
1940 cwd: "/tmp/project",2018 cwd: fixtureCwd("project"),
1941 runtimeMode: "full-access",2019 runtimeMode: "full-access",
1942 });2020 });
1943 yield* routing.codex.stopSession(initial.threadId);2021 yield* routing.codex.stopSession(initial.threadId);
...@@ -1963,7 +2041,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1963,7 +2041,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1963 threadId?: string;2041 threadId?: string;
1964 };2042 };
1965 assert.equal(startPayload.provider, "codex");2043 assert.equal(startPayload.provider, "codex");
1966 assert.equal(startPayload.cwd, "/tmp/project");2044 assert.equal(startPayload.cwd, fixtureCwd("project"));
1967 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);2045 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
1968 assert.equal(startPayload.threadId, initial.threadId);2046 assert.equal(startPayload.threadId, initial.threadId);
1969 }2047 }
...@@ -1982,7 +2060,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1982,7 +2060,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1982 provider: ProviderDriverKind.make("codex"),2060 provider: ProviderDriverKind.make("codex"),
1983 providerInstanceId: codexInstanceId,2061 providerInstanceId: codexInstanceId,
1984 threadId: asThreadId("thread-reap-preserve"),2062 threadId: asThreadId("thread-reap-preserve"),
1985 cwd: "/tmp/project-reap-preserve",2063 cwd: fixtureCwd("project-reap-preserve"),
1986 runtimeMode: "full-access",2064 runtimeMode: "full-access",
1987 });2065 });
19882066
...@@ -2017,7 +2095,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2017,7 +2095,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2017 threadId?: string;2095 threadId?: string;
2018 };2096 };
2019 assert.equal(startPayload.provider, "codex");2097 assert.equal(startPayload.provider, "codex");
2020 assert.equal(startPayload.cwd, "/tmp/project-reap-preserve");2098 assert.equal(startPayload.cwd, fixtureCwd("project-reap-preserve"));
2021 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);2099 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
2022 assert.equal(startPayload.threadId, initial.threadId);2100 assert.equal(startPayload.threadId, initial.threadId);
2023 }2101 }
...@@ -2033,7 +2111,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2033,7 +2111,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2033 provider: ProviderDriverKind.make("claudeAgent"),2111 provider: ProviderDriverKind.make("claudeAgent"),
2034 providerInstanceId: claudeAgentInstanceId,2112 providerInstanceId: claudeAgentInstanceId,
2035 threadId: asThreadId("thread-claude"),2113 threadId: asThreadId("thread-claude"),
2036 cwd: "/tmp/project-claude",2114 cwd: fixtureCwd("project-claude"),
2037 runtimeMode: "full-access",2115 runtimeMode: "full-access",
2038 });2116 });
20392117
...@@ -2049,7 +2127,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2049,7 +2127,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2049 };2127 };
2050 assert.equal(startPayload.provider, "claudeAgent");2128 assert.equal(startPayload.provider, "claudeAgent");
2051 assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId);2129 assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId);
2052 assert.equal(startPayload.cwd, "/tmp/project-claude");2130 assert.equal(startPayload.cwd, fixtureCwd("project-claude"));
2053 }2131 }
2054 }),2132 }),
2055 );2133 );
...@@ -2064,7 +2142,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2064,7 +2142,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2064 provider: ProviderDriverKind.make("codex"),2142 provider: ProviderDriverKind.make("codex"),
2065 providerInstanceId: codexInstanceId,2143 providerInstanceId: codexInstanceId,
2066 threadId,2144 threadId,
2067 cwd: "/tmp/project-binding-mismatch",2145 cwd: fixtureCwd("project-binding-mismatch"),
2068 runtimeMode: "full-access",2146 runtimeMode: "full-access",
2069 });2147 });
2070 yield* directory.upsert({2148 yield* directory.upsert({
...@@ -2094,7 +2172,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2094,7 +2172,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2094 provider: ProviderDriverKind.make("codex"),2172 provider: ProviderDriverKind.make("codex"),
2095 providerInstanceId: codexInstanceId,2173 providerInstanceId: codexInstanceId,
2096 threadId,2174 threadId,
2097 cwd: "/tmp/project-provider-replacement",2175 cwd: fixtureCwd("project-provider-replacement"),
2098 runtimeMode: "full-access",2176 runtimeMode: "full-access",
2099 });2177 });
21002178
...@@ -2105,7 +2183,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2105,7 +2183,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2105 provider: ProviderDriverKind.make("claudeAgent"),2183 provider: ProviderDriverKind.make("claudeAgent"),
2106 providerInstanceId: claudeAgentInstanceId,2184 providerInstanceId: claudeAgentInstanceId,
2107 threadId,2185 threadId,
2108 cwd: "/tmp/project-provider-replacement",2186 cwd: fixtureCwd("project-provider-replacement"),
2109 runtimeMode: "full-access",2187 runtimeMode: "full-access",
2110 });2188 });
21112189
...@@ -2132,7 +2210,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2132,7 +2210,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2132 provider: ProviderDriverKind.make("codex"),2210 provider: ProviderDriverKind.make("codex"),
2133 providerInstanceId: codexInstanceId,2211 providerInstanceId: codexInstanceId,
2134 threadId: asThreadId("thread-1"),2212 threadId: asThreadId("thread-1"),
2135 cwd: "/tmp/project-send-turn",2213 cwd: fixtureCwd("project-send-turn"),
2136 runtimeMode: "full-access",2214 runtimeMode: "full-access",
2137 });2215 });
21382216
...@@ -2157,7 +2235,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2157,7 +2235,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2157 threadId?: string;2235 threadId?: string;
2158 };2236 };
2159 assert.equal(startPayload.provider, "codex");2237 assert.equal(startPayload.provider, "codex");
2160 assert.equal(startPayload.cwd, "/tmp/project-send-turn");2238 assert.equal(startPayload.cwd, fixtureCwd("project-send-turn"));
2161 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);2239 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
2162 assert.equal(startPayload.threadId, initial.threadId);2240 assert.equal(startPayload.threadId, initial.threadId);
2163 }2241 }
...@@ -2173,7 +2251,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2173,7 +2251,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2173 provider: ProviderDriverKind.make("claudeAgent"),2251 provider: ProviderDriverKind.make("claudeAgent"),
2174 providerInstanceId: claudeAgentInstanceId,2252 providerInstanceId: claudeAgentInstanceId,
2175 threadId: asThreadId("thread-claude-send-turn"),2253 threadId: asThreadId("thread-claude-send-turn"),
2176 cwd: "/tmp/project-claude-send-turn",2254 cwd: fixtureCwd("project-claude-send-turn"),
2177 modelSelection: createModelSelection(2255 modelSelection: createModelSelection(
2178 ProviderInstanceId.make("claudeAgent"),2256 ProviderInstanceId.make("claudeAgent"),
2179 "claude-opus-4-6",2257 "claude-opus-4-6",
...@@ -2204,7 +2282,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2204,7 +2282,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2204 threadId?: string;2282 threadId?: string;
2205 };2283 };
2206 assert.equal(startPayload.provider, "claudeAgent");2284 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"));
2208 assert.deepEqual(2286 assert.deepEqual(
2209 startPayload.modelSelection,2287 startPayload.modelSelection,
2210 createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [2288 createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
...@@ -2368,6 +2446,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2368,6 +2446,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2368 Layer.provide(runtimeRepositoryLayer),2446 Layer.provide(runtimeRepositoryLayer),
2369 );2447 );
2370 const firstProviderLayer = makeProviderServiceLive().pipe(2448 const firstProviderLayer = makeProviderServiceLive().pipe(
2449 Layer.provide(NodeServices.layer),
2371 Layer.provide(2450 Layer.provide(
2372 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),2451 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
2373 ),2452 ),
...@@ -2389,7 +2468,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2389,7 +2468,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2389 provider: ProviderDriverKind.make("claudeAgent"),2468 provider: ProviderDriverKind.make("claudeAgent"),
2390 providerInstanceId: claudeAgentInstanceId,2469 providerInstanceId: claudeAgentInstanceId,
2391 threadId: asThreadId("thread-claude-start"),2470 threadId: asThreadId("thread-claude-start"),
2392 cwd: "/tmp/project-claude-start",2471 cwd: fixtureCwd("project-claude-start"),
2393 runtimeMode: "full-access",2472 runtimeMode: "full-access",
2394 });2473 });
2395 }).pipe(Effect.provide(firstProviderLayer));2474 }).pipe(Effect.provide(firstProviderLayer));
...@@ -2407,6 +2486,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2407,6 +2486,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2407 Layer.provide(runtimeRepositoryLayer),2486 Layer.provide(runtimeRepositoryLayer),
2408 );2487 );
2409 const secondProviderLayer = makeProviderServiceLive().pipe(2488 const secondProviderLayer = makeProviderServiceLive().pipe(
2489 Layer.provide(NodeServices.layer),
2410 Layer.provide(2490 Layer.provide(
2411 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),2491 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
2412 ),2492 ),
...@@ -2430,7 +2510,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2430,7 +2510,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2430 provider: ProviderDriverKind.make("claudeAgent"),2510 provider: ProviderDriverKind.make("claudeAgent"),
2431 providerInstanceId: claudeAgentInstanceId,2511 providerInstanceId: claudeAgentInstanceId,
2432 threadId: initial.threadId,2512 threadId: initial.threadId,
2433 cwd: "/tmp/project-claude-start",2513 cwd: fixtureCwd("project-claude-start"),
2434 runtimeMode: "full-access",2514 runtimeMode: "full-access",
2435 });2515 });
2436 }).pipe(Effect.provide(secondProviderLayer));2516 }).pipe(Effect.provide(secondProviderLayer));
...@@ -2446,7 +2526,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2446,7 +2526,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2446 threadId?: string;2526 threadId?: string;
2447 };2527 };
2448 assert.equal(startPayload.provider, "claudeAgent");2528 assert.equal(startPayload.provider, "claudeAgent");
2449 assert.equal(startPayload.cwd, "/tmp/project-claude-start");2529 assert.equal(startPayload.cwd, fixtureCwd("project-claude-start"));
2450 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);2530 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
2451 assert.equal(startPayload.threadId, initial.threadId);2531 assert.equal(startPayload.threadId, initial.threadId);
2452 }2532 }
...@@ -2476,6 +2556,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2476,6 +2556,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2476 Layer.provide(runtimeRepositoryLayer),2556 Layer.provide(runtimeRepositoryLayer),
2477 );2557 );
2478 const firstProviderLayer = makeProviderServiceLive().pipe(2558 const firstProviderLayer = makeProviderServiceLive().pipe(
2559 Layer.provide(NodeServices.layer),
2479 Layer.provide(2560 Layer.provide(
2480 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),2561 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
2481 ),2562 ),
...@@ -2497,7 +2578,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2497,7 +2578,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2497 provider: ProviderDriverKind.make("claudeAgent"),2578 provider: ProviderDriverKind.make("claudeAgent"),
2498 providerInstanceId: claudeAgentInstanceId,2579 providerInstanceId: claudeAgentInstanceId,
2499 threadId: asThreadId("thread-claude-cwd"),2580 threadId: asThreadId("thread-claude-cwd"),
2500 cwd: "/tmp/project-claude-cwd",2581 cwd: fixtureCwd("project-claude-cwd"),
2501 runtimeMode: "full-access",2582 runtimeMode: "full-access",
2502 });2583 });
2503 }).pipe(Effect.provide(firstProviderLayer));2584 }).pipe(Effect.provide(firstProviderLayer));
...@@ -2510,6 +2591,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2510,6 +2591,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2510 Layer.provide(runtimeRepositoryLayer),2591 Layer.provide(runtimeRepositoryLayer),
2511 );2592 );
2512 const secondProviderLayer = makeProviderServiceLive().pipe(2593 const secondProviderLayer = makeProviderServiceLive().pipe(
2594 Layer.provide(NodeServices.layer),
2513 Layer.provide(2595 Layer.provide(
2514 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),2596 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
2515 ),2597 ),
...@@ -2548,7 +2630,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -2548,7 +2630,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
2548 threadId?: string;2630 threadId?: string;
2549 };2631 };
2550 assert.equal(startPayload.provider, "claudeAgent");2632 assert.equal(startPayload.provider, "claudeAgent");
2551 assert.equal(startPayload.cwd, "/tmp/project-claude-cwd");2633 assert.equal(startPayload.cwd, fixtureCwd("project-claude-cwd"));
2552 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);2634 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
2553 assert.equal(startPayload.threadId, initial.threadId);2635 assert.equal(startPayload.threadId, initial.threadId);
2554 }2636 }
...@@ -2742,7 +2824,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -2742,7 +2824,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
2742 provider: ProviderDriverKind.make("claudeAgent"),2824 provider: ProviderDriverKind.make("claudeAgent"),
2743 providerInstanceId: claudeAgentInstanceId,2825 providerInstanceId: claudeAgentInstanceId,
2744 threadId: asThreadId("thread-metrics"),2826 threadId: asThreadId("thread-metrics"),
2745 cwd: "/tmp/project",2827 cwd: fixtureCwd("project"),
2746 runtimeMode: "full-access",2828 runtimeMode: "full-access",
2747 });2829 });
27482830
...@@ -2820,7 +2902,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -2820,7 +2902,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
2820 provider: ProviderDriverKind.make("claudeAgent"),2902 provider: ProviderDriverKind.make("claudeAgent"),
2821 providerInstanceId: claudeAgentInstanceId,2903 providerInstanceId: claudeAgentInstanceId,
2822 threadId: asThreadId("thread-send-metrics"),2904 threadId: asThreadId("thread-send-metrics"),
2823 cwd: "/tmp/project-send-metrics",2905 cwd: fixtureCwd("project-send-metrics"),
2824 runtimeMode: "full-access",2906 runtimeMode: "full-access",
2825 });2907 });
28262908
...@@ -4155,7 +4237,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -4155,7 +4237,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
4155 provider: ProviderDriverKind.make("codex"),4237 provider: ProviderDriverKind.make("codex"),
4156 providerInstanceId: codexInstanceId,4238 providerInstanceId: codexInstanceId,
4157 threadId: asThreadId("thread-missing"),4239 threadId: asThreadId("thread-missing"),
4158 cwd: "/tmp/project",4240 cwd: fixtureCwd("project"),
4159 runtimeMode: "full-access",4241 runtimeMode: "full-access",
4160 });4242 });
41614243
...@@ -4204,7 +4286,7 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {...@@ -4204,7 +4286,7 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
4204 provider: CODEX_DRIVER,4286 provider: CODEX_DRIVER,
4205 providerInstanceId: codexInstanceId,4287 providerInstanceId: codexInstanceId,
4206 threadId: activeSessionThreadId,4288 threadId: activeSessionThreadId,
4207 cwd: "/tmp/project-active-session",4289 cwd: fixtureCwd("project-active-session"),
4208 runtimeMode: "full-access",4290 runtimeMode: "full-access",
4209 });4291 });
4210 listThreadIds.mockClear();4292 listThreadIds.mockClear();
apps/server/src/provider/Layers/ProviderService.ts+22-2
...@@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";...@@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
35import * as DateTime from "effect/DateTime";35import * as DateTime from "effect/DateTime";
36import * as Deferred from "effect/Deferred";36import * as Deferred from "effect/Deferred";
37import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
38import * as FileSystem from "effect/FileSystem";
38import * as Layer from "effect/Layer";39import * as Layer from "effect/Layer";
39import * as Option from "effect/Option";40import * as Option from "effect/Option";
40import * as PubSub from "effect/PubSub";41import * as PubSub from "effect/PubSub";
...@@ -55,8 +56,12 @@ import {...@@ -55,8 +56,12 @@ import {
55 providerTurnMetricAttributes,56 providerTurnMetricAttributes,
56 withMetrics,57 withMetrics,
57} from "../../observability/Metrics.ts";58} from "../../observability/Metrics.ts";
58import { ProviderAdapterRequestError } from "../Errors.ts";59import {
59import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";60 ProviderAdapterRequestError,
61 type ProviderAdapterError,
62 ProviderValidationError,
63 ProviderWorkspaceMissingError,
64} from "../Errors.ts";
60import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";65import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
61import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";66import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
62import * as ProviderService from "../Services/ProviderService.ts";67import * as ProviderService from "../Services/ProviderService.ts";
...@@ -322,6 +327,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -322,6 +327,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
322 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;327 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
323 const revokeMcpCredential =328 const revokeMcpCredential =
324 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;329 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
330 const fileSystem = yield* FileSystem.FileSystem;
325 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();331 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
326 const pendingCompactions = new Map<ThreadId, PendingCompaction>();332 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
327 const timedOutNativeCompactions = new Set<ThreadId>();333 const timedOutNativeCompactions = new Set<ThreadId>();
...@@ -1235,6 +1241,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1235,6 +1241,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1235 : "none",1241 : "none",
1236 "provider.cwd.effective": effectiveCwd ?? "",1242 "provider.cwd.effective": effectiveCwd ?? "",
1237 });1243 });
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 }
1238 const adapter = yield* registry.getByInstance(resolvedInstanceId);1258 const adapter = yield* registry.getByInstance(resolvedInstanceId);
1239 yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId);1259 yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId);
1240 yield* prepareMcpSession(threadId, resolvedInstanceId);1260 yield* prepareMcpSession(threadId, resolvedInstanceId);