Skip to content
Open
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
129 changes: 129 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1579,4 +1579,133 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.threadId).toBe("thread-1");
expect(thread?.session?.activeTurnId).toBeNull();
});

it("returns clear error when project workspace path does not exist", async () => {
const now = new Date().toISOString();
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-missing-path-"));
createdBaseDirs.add(baseDir);
const { stateDir } = deriveServerPathsSync(baseDir, undefined);
createdStateDirs.add(stateDir);
const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
const modelSelection = {
provider: "claudeAgent",
model: "claude-sonnet-4-6",
};

const startSession = vi.fn(() => Effect.die(new Error("Should not be called")));
const sendTurn = vi.fn(() => Effect.die(new Error("Should not be called")));
const interruptTurn = vi.fn(() => Effect.void);
const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
const stopSession = vi.fn(() => Effect.void);
const renameBranch = vi.fn(() => Effect.succeed({ branch: "test-branch" }));
const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
Effect.fail(new TextGenerationError({ operation: "generateBranchName", detail: "disabled" })),
);
const generateThreadTitle = vi.fn<TextGenerationShape["generateThreadTitle"]>(() =>
Effect.fail(new TextGenerationError({ operation: "generateThreadTitle", detail: "disabled" })),
);

const service: ProviderServiceShape = {
startSession: startSession as ProviderServiceShape["startSession"],
sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
stopSession: stopSession as ProviderServiceShape["stopSession"],
listSessions: () => Effect.succeed([]),
getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
rollbackConversation: () => Effect.die(new Error("Unsupported")) as never,
streamEvents: Stream.fromPubSub(runtimeEventPubSub),
};

const orchestrationLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(SqlitePersistenceMemory),
);
const layer = ProviderCommandReactorLive.pipe(
Layer.provideMerge(orchestrationLayer),
Layer.provideMerge(Layer.succeed(ProviderService, service)),
Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
Layer.provideMerge(
Layer.mock(TextGeneration, {
generateBranchName,
generateThreadTitle,
}),
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
);
const runtime = ManagedRuntime.make(layer);

const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
scope = await Effect.runPromise(Scope.make("sequential"));
await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));

const nonExistentPath = "/tmp/this-path-definitely-does-not-exist-t3code-test-12345";
await Effect.runPromise(
engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-project-missing"),
projectId: asProjectId("project-missing"),
title: "Missing Project",
workspaceRoot: nonExistentPath,
defaultModelSelection: modelSelection,
createdAt: now,
}),
);
await Effect.runPromise(
engine.dispatch({
type: "thread.create",
commandId: CommandId.makeUnsafe("cmd-thread-missing"),
threadId: ThreadId.makeUnsafe("thread-missing"),
projectId: asProjectId("project-missing"),
title: "Thread",
modelSelection: modelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: now,
}),
);

let errorOccurred = false;
let errorDetail: string | undefined;
try {
await Effect.runPromise(
engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-missing"),
threadId: ThreadId.makeUnsafe("thread-missing"),
message: {
messageId: asMessageId("user-message-missing"),
role: "user",
text: "hello",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
} catch (e) {
errorOccurred = true;
if (e instanceof ProviderAdapterRequestError) {
errorDetail = e.detail;
}
}

expect(errorOccurred).toBe(true);
expect(errorDetail).toContain("no longer exists");
expect(errorDetail).toContain(nonExistentPath);
expect(startSession).not.toHaveBeenCalled();

await runtime.dispose();
});
});
22 changes: 21 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
import { Cache, Cause, Duration, Effect, Equal, FileSystem, Layer, Option, Result, Schema, Stream } from "effect";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";

import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
Expand Down Expand Up @@ -256,6 +256,26 @@ const make = Effect.gen(function* () {
projects: readModel.projects,
});

// Validate that the workspace root exists before attempting to start a session
// This provides a clear error message when a project directory has been moved/deleted
if (effectiveCwd) {
const pathExists = yield* FileSystem.FileSystem.pipe(
Effect.flatMap((fs) => fs.stat(effectiveCwd)),
Effect.map(() => true),
Effect.catchTag("SystemError", (e) =>
e.reason === "NotFound" ? Effect.succeed(false) : Effect.fail(e),
),
Effect.result,
);
if (Result.isFailure(pathExists) || pathExists.success === Option.none()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical Layers/ProviderCommandReactor.ts:270

When the workspace path does not exist, Effect.succeed(false) creates a Result.Success(false), but the condition pathExists.success === Option.none() never matches because false is not Option.none(). The missing-path error is therefore never raised. Consider checking Result.isSuccess(pathExists) && !pathExists.value to detect the non-existent path case.

Also found in 1 other location(s)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts:1705

The path existence check logic at line 276 is incorrect. The condition pathExists.success === Option.none() will never match the case where the path doesn't exist. When fs.stat fails with NotFound, the code returns Effect.succeed(false), meaning pathExists.success will be Some(false), not None. Comparing Some(false) === Option.none() will always be false (reference inequality). The check should verify the actual boolean value, e.g., Option.isNone(pathExists.success) || !pathExists.success.value or using the appropriate Result accessor to check if the value is false.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/orchestration/Layers/ProviderCommandReactor.ts around line 270:

When the workspace path does not exist, `Effect.succeed(false)` creates a `Result.Success(false)`, but the condition `pathExists.success === Option.none()` never matches because `false` is not `Option.none()`. The missing-path error is therefore never raised. Consider checking `Result.isSuccess(pathExists) && !pathExists.value` to detect the non-existent path case.

Evidence trail:
1. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts lines 262-277 (viewed at REVIEWED_COMMIT) - shows the pathExists logic and the condition check
2. package.json shows effect version 4.0.0-beta.43
3. Effect v4 Result.ts source (https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/Result.ts) - confirms `Success` interface has `readonly success: A` (direct value, not Option-wrapped)
4. The condition `pathExists.success === Option.none()` at line 270 compares boolean to Option object, which never matches

Also found in 1 other location(s):
- apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts:1705 -- The path existence check logic at line 276 is incorrect. The condition `pathExists.success === Option.none()` will never match the case where the path doesn't exist. When `fs.stat` fails with `NotFound`, the code returns `Effect.succeed(false)`, meaning `pathExists.success` will be `Some(false)`, not `None`. Comparing `Some(false) === Option.none()` will always be `false` (reference inequality). The check should verify the actual boolean value, e.g., `Option.isNone(pathExists.success) || !pathExists.success.value` or using the appropriate `Result` accessor to check if the value is `false`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path validation never triggers due to wrong comparison

High Severity

The condition pathExists.success === Option.none() always evaluates to false, completely defeating the workspace path validation. After Effect.result, pathExists.success is a boolean (true or false), not an Option. Comparing a boolean to Option.none() via === can never match. When a path doesn't exist, the catchTag produces Effect.succeed(false), so pathExists.success is false — but the check never catches it, and the stale path is still passed to the provider, reproducing the exact bug this PR intended to fix.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b949ec7. Configure here.

return yield* new ProviderAdapterRequestError({
provider: preferredProvider ?? "claudeAgent",
method: "thread.turn.start",
detail: `Project workspace path '${effectiveCwd}' no longer exists. The project directory may have been moved or deleted. Please recreate the project or select a different project.`,
});
}
}

const resolveActiveSession = (threadId: ThreadId) =>
providerService
.listSessions()
Expand Down
Loading