diff --git a/AGENTS.md b/AGENTS.md index a5dbc8e56..8801ebde7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. - NEVER run `bun test`. Always use `bun run test` (runs Vitest). +- For any change that must be visible on the served app endpoint, rebuild and restart the served app before considering the task complete. A browser hard refresh is not sufficient for the Tailscale-served production bundle. ## Project Snapshot diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index bb6b6429f..e32676b3c 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -256,6 +256,12 @@ describe("isRecoverableThreadResumeError", () => { ), ).toBe(false); }); + + it("treats thread-resume timeouts as recoverable", () => { + expect(isRecoverableThreadResumeError(new Error("Timed out waiting for thread/resume."))).toBe( + true, + ); + }); }); describe("readCodexAccountSnapshot", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index da1f3a706..c8d69b520 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -170,6 +170,7 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "no such thread", "unknown thread", "does not exist", + "timed out waiting for thread/resume", ]; const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; @@ -394,9 +395,9 @@ export function resolveCodexModelForAccount( } /** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. + * Codex can spawn nested helper processes. On Unix we run each session in its + * own process group so teardown can kill the entire subtree instead of leaking + * orphaned `@github/copilot` workers after restarts and timeouts. */ function killChildTree(child: ChildProcessWithoutNullStreams): void { if (process.platform === "win32" && child.pid !== undefined) { @@ -407,7 +408,15 @@ function killChildTree(child: ChildProcessWithoutNullStreams): void { // fallback to direct kill } } - child.kill(); + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + return; + } catch { + // fallback to direct kill + } + } + child.kill("SIGKILL"); } export function normalizeCodexModelSlug( @@ -599,6 +608,7 @@ export class CodexAppServerManager extends EventEmitter + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + const threadId = ThreadId.makeUnsafe("thread-start-failure"); + const messageId = MessageId.makeUnsafe("message-start-failure"); + const now = "2026-02-26T15:00:00.000Z"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-start-failure-project"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-start-failure"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-project"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-project"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-start-failure"), + title: "Project Start Failure", + workspaceRoot: "/tmp/project-start-failure", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-start-failure-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-thread"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-start-failure"), + title: "Thread Start Failure", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-start-requested", + eventId: EventId.makeUnsafe("evt-start-failure-requested"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-requested"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-requested"), + metadata: {}, + payload: { + threadId, + messageId, + runtimeMode: "full-access", + createdAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-start-failure-activity"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-start-failure-activity"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-start-failure-activity"), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.makeUnsafe("activity-start-failure"), + tone: "error", + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + payload: { + detail: "Timed out waiting for thread/start.", + }, + turnId: null, + createdAt: now, + }, + }, + }); + + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + `; + + assert.deepEqual(pendingRows, []); + }).pipe( + Effect.provide( + makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-start-failure-"), + ), + ), +); + const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fd679574f..d3f2ff780 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -768,6 +768,11 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { : {}), createdAt: event.payload.activity.createdAt, }); + if (event.payload.activity.kind === "provider.turn.start.failed") { + yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + } return; case "thread.reverted": { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c2c43d9fa..efc5b33d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1108,6 +1108,84 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("full-access"); }); + it("stops and clears a session when turn start fails while the thread is stuck starting", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-bind-session"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-bind-session"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-starting-after-bind"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated start failure")) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-fail-while-starting"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-fail-while-starting"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return thread?.session?.status === "stopped"; + }); + + expect(harness.stopSession.mock.calls.length).toBe(1); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.session?.lastError).toContain("simulated start failure"); + }); + it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1147,6 +1225,53 @@ describe("ProviderCommandReactor", () => { }); }); + it("clears stale active turns when interrupt is requested without a live provider turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-stale-interrupt"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-stale"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-stale"), + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.session + ?.activeTurnId === null + ); + }); + + expect(harness.interruptTurn.mock.calls.length).toBe(0); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index dead00300..fdab52232 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -88,6 +88,10 @@ function resolveThreadModelSelection(thread: { const turnStartKeyForEvent = (event: ProviderIntentEvent): string => event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; +const shouldClearSessionAfterTurnStartFailure = ( + session: OrchestrationSession | null | undefined, +): boolean => session?.status === "starting"; + const serverCommandId = (tag: string): CommandId => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -265,11 +269,73 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); + const clearFailedTurnStartSession = (input: { + readonly threadId: ThreadId; + readonly detail: string; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const thread = yield* resolveThread(input.threadId); + if (!shouldClearSessionAfterTurnStartFailure(thread?.session)) { + return; + } + + yield* providerService + .stopSession({ threadId: input.threadId }) + .pipe(Effect.orElseSucceed(() => undefined)); + + const refreshedThread = yield* resolveThread(input.threadId); + if (!refreshedThread?.session) { + return; + } + + yield* setThreadSession({ + threadId: input.threadId, + session: { + threadId: input.threadId, + status: "stopped", + providerName: refreshedThread.session.providerName, + runtimeMode: refreshedThread.session.runtimeMode, + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); return readModel.threads.find((entry) => entry.id === threadId); }); + const resolveLiveSession = (threadId: ThreadId) => + providerService + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); + + const clearThreadActiveTurn = (input: { + readonly threadId: ThreadId; + readonly createdAt: string; + readonly providerName: OrchestrationSession["providerName"]; + readonly runtimeMode: OrchestrationSession["runtimeMode"]; + readonly status: OrchestrationSession["status"]; + readonly lastError: string | null; + }) => + setThreadSession({ + threadId: input.threadId, + session: { + threadId: input.threadId, + status: input.status, + providerName: input.providerName, + runtimeMode: input.runtimeMode, + activeTurnId: null, + lastError: input.lastError, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + const resolveRequestedTurnProvider = (input: { readonly threadSelection: ModelSelection; readonly provider?: ProviderKind; @@ -682,16 +748,24 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail: Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - }), - ), + Effect.catchCause((cause) => { + const detail = Cause.pretty(cause); + return Effect.gen(function* () { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }); + yield* clearFailedTurnStartSession({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }); + }); + }), ); }); @@ -714,6 +788,19 @@ const make = Effect.gen(function* () { }); } + const liveSession = yield* resolveLiveSession(event.payload.threadId); + if (!liveSession || liveSession.activeTurnId === undefined) { + yield* clearThreadActiveTurn({ + threadId: event.payload.threadId, + createdAt: event.payload.createdAt, + providerName: liveSession?.provider ?? thread.session?.providerName ?? null, + runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + status: mapProviderSessionStatusToOrchestrationStatus(liveSession?.status ?? "ready"), + lastError: liveSession?.lastError ?? null, + }); + return; + } + // Orchestration turn ids are not provider turn ids, so interrupt by session. yield* providerService.interruptTurn({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 82d05c0d8..3eac23303 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -964,12 +964,6 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": @@ -987,6 +981,16 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" + ? null + : event.type === "session.state.changed" + ? status === "running" + ? activeTurnId + : null + : activeTurnId; const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 7cbb51c57..d4e0dab85 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -9,6 +9,7 @@ import { checkCodexProviderStatus, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, + setCodexAppServerThreadStartProbeForTest, } from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -92,6 +93,17 @@ function withTempCodexHome(configContent?: string) { }); } +function withCodexAppServerThreadStartProbe( + probe: (() => Promise) | null, + effect: Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => setCodexAppServerThreadStartProbeForTest(probe)), + () => effect, + (restoreProbe) => Effect.sync(restoreProbe), + ); +} + it.layer(NodeServices.layer)("ProviderHealth", (it) => { // ── checkCodexProviderStatus tests ──────────────────────────────── // @@ -160,7 +172,10 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it.effect("returns unauthenticated when auth probe reports login required", () => Effect.gen(function* () { yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; + const status = yield* withCodexAppServerThreadStartProbe( + () => Promise.reject(new Error("probe disabled in test")), + checkCodexProviderStatus, + ); assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.available, true); @@ -186,7 +201,10 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it.effect("returns unauthenticated when login status output includes 'not logged in'", () => Effect.gen(function* () { yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; + const status = yield* withCodexAppServerThreadStartProbe( + () => Promise.reject(new Error("probe disabled in test")), + checkCodexProviderStatus, + ); assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.available, true); @@ -208,6 +226,33 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect( + "returns ready when app-server turns can start despite unauthenticated login status", + () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* withCodexAppServerThreadStartProbe( + () => Promise.resolve(), + checkCodexProviderStatus, + ); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns warning when login status command is unsupported", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -284,7 +329,10 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it.effect("still runs auth probe when model_provider is openai", () => Effect.gen(function* () { yield* withTempCodexHome('model_provider = "openai"\n'); - const status = yield* checkCodexProviderStatus; + const status = yield* withCodexAppServerThreadStartProbe( + () => Promise.reject(new Error("probe disabled in test")), + checkCodexProviderStatus, + ); // The auth probe runs and sees "not logged in" → error assert.strictEqual(status.status, "error"); assert.strictEqual(status.authStatus, "unauthenticated"); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cc4511cf7..7f5bcec92 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -7,6 +7,9 @@ * * @module ProviderHealthLive */ +import { spawn } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { createInterface } from "node:readline"; import { CopilotClient } from "@github/copilot-sdk"; import type { ServerProvider, @@ -18,6 +21,7 @@ import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from " import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { serverBuildInfo } from "../../buildInfo.ts"; +import { buildCodexInitializeParams } from "../../codexAppServerManager.ts"; import { OpenclawGatewayClient, OpenclawGatewayClientError } from "../../openclaw/GatewayClient.ts"; import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { @@ -274,6 +278,141 @@ const runCodexCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +async function probeCodexAppServerThreadStart(): Promise { + const child = spawn("codex", ["app-server"], { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + shell: process.platform === "win32", + }); + + await new Promise((resolve, reject) => { + let settled = false; + let nextId = 1; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + const stdout = createInterface({ input: child.stdout }); + const stderrLines: string[] = []; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out while probing codex app-server thread/start readiness.")); + }, DEFAULT_TIMEOUT_MS); + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + stdout.close(); + killCodexProbeChildTree(child); + }; + + child.stderr.on("data", (chunk) => { + stderrLines.push(chunk.toString("utf-8")); + }); + + child.on("error", (error) => { + cleanup(); + reject(error); + }); + + child.on("exit", (code) => { + if (settled) return; + cleanup(); + reject( + new Error( + `Codex app-server exited before thread/start completed (code ${code ?? "unknown"}). ${stderrLines.join("").trim()}`, + ), + ); + }); + + stdout.on("line", (line) => { + let parsed: { id?: number; result?: unknown; error?: { message?: string } }; + try { + parsed = JSON.parse(line); + } catch { + return; + } + if (typeof parsed.id !== "number") { + return; + } + const request = pending.get(parsed.id); + if (!request) { + return; + } + pending.delete(parsed.id); + if (parsed.error) { + request.reject(new Error(parsed.error.message ?? "JSON-RPC request failed.")); + return; + } + request.resolve(parsed.result); + }); + + const sendRequest = (method: string, params?: unknown) => + new Promise((requestResolve, requestReject) => { + const id = nextId++; + pending.set(id, { resolve: requestResolve, reject: requestReject }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })}\n`, + ); + }); + + void (async () => { + try { + await sendRequest("initialize", buildCodexInitializeParams()); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "initialized" })}\n`); + await sendRequest("thread/start", {}); + cleanup(); + resolve(); + } catch (error) { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); +} + +function killCodexProbeChildTree(child: ChildProcessWithoutNullStreams): void { + if (child.killed) { + return; + } + + try { + if (process.platform === "win32") { + if (typeof child.pid === "number") { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.unref(); + } + return; + } + + if (typeof child.pid === "number") { + process.kill(-child.pid, "SIGKILL"); + return; + } + + child.kill("SIGKILL"); + } catch { + // Best-effort cleanup only. + } +} + +let codexAppServerThreadStartProbe: () => Promise = probeCodexAppServerThreadStart; + +export function setCodexAppServerThreadStartProbeForTest( + probe: (() => Promise) | null, +): () => void { + const previous = codexAppServerThreadStartProbe; + codexAppServerThreadStartProbe = probe ?? probeCodexAppServerThreadStart; + return () => { + codexAppServerThreadStartProbe = previous; + }; +} + const runClaudeCommand = (args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -555,6 +694,27 @@ export const checkCodexProviderStatus: Effect.Effect< } const parsed = parseAuthStatusFromOutput(authProbe.success.value); + if (parsed.authStatus === "unauthenticated") { + const runtimeProbe = yield* Effect.tryPromise({ + try: () => codexAppServerThreadStartProbe(), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe(Effect.result); + + if (Result.isSuccess(runtimeProbe)) { + return createServerProviderStatus({ + provider: CODEX_PROVIDER, + enabled: true, + installed: true, + version: nonEmptyVersion(version.stdout, version.stderr), + status: "ready" as const, + auth: { status: "unknown" as const }, + checkedAt, + message: + "Codex app-server can start turns with the current configuration even though `codex login status` did not report an authenticated account.", + }); + } + } + return createServerProviderStatus({ provider: CODEX_PROVIDER, enabled: true, diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index 91d3ed4df..b0329454b 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -112,6 +112,28 @@ describe("searchWorkspaceEntries", () => { ); }); + it("keeps shallow directory listing to a single readdir call", async () => { + const cwd = makeTempDir("okcode-workspace-list-directory-shallow-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "README.md"); + const readdirSpy = vi.spyOn(fsPromises, "readdir"); + + const result = await listWorkspaceDirectory({ cwd, shallow: true }); + + assert.deepEqual( + result.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + hasChildren: entry.hasChildren, + })), + [ + { path: "README.md", kind: "file", hasChildren: false }, + { path: "src", kind: "directory", hasChildren: true }, + ], + ); + assert.strictEqual(readdirSpy.mock.calls.length, 1); + }); + it("filters and ranks entries by query", async () => { const cwd = makeTempDir("okcode-workspace-query-"); writeFile(cwd, "src/components/Composer.tsx"); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index d73881e62..c7d6455be 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -1012,6 +1012,10 @@ export function clearWorkspaceIndexCache(cwd: string): void { export async function listWorkspaceDirectory( input: ProjectListDirectoryInput, ): Promise { + if (input.shallow) { + return await listWorkspaceDirectoryShallow(input); + } + const index = await getWorkspaceIndex(input.cwd); const parentKey = input.directoryPath ?? ROOT_PARENT_KEY; const entries = index.entriesByParent.get(parentKey) ?? []; @@ -1118,6 +1122,68 @@ async function searchFileContents( return matches; } +async function listWorkspaceDirectoryShallow( + input: ProjectListDirectoryInput, +): Promise { + const relativeDirectoryPath = input.directoryPath?.trim() ?? ""; + const absoluteDirectoryPath = path.resolve(input.cwd, relativeDirectoryPath || "."); + const normalizedRelativePath = path.relative(input.cwd, absoluteDirectoryPath); + + if (normalizedRelativePath.startsWith("..") || path.isAbsolute(normalizedRelativePath)) { + throw new Error(`Directory path escapes workspace root: ${relativeDirectoryPath || "."}`); + } + + const directoryEntries = await fs.readdir(absoluteDirectoryPath, { withFileTypes: true }); + const entries: ProjectDirectoryEntry[] = []; + + for (const dirent of directoryEntries.toSorted((left, right) => + left.name.localeCompare(right.name), + )) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDirectoryPath ? path.join(relativeDirectoryPath, dirent.name) : dirent.name, + ); + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } + + // Keep shallow listings to a single readdir() for the requested directory. + // Directory rows stay navigable without paying an extra filesystem probe per entry. + const hasChildren = dirent.isDirectory(); + + const parentPath = parentPathOf(relativePath); + if (parentPath) { + entries.push({ + path: relativePath, + kind: dirent.isDirectory() ? "directory" : "file", + parentPath, + hasChildren, + }); + continue; + } + + entries.push({ + path: relativePath, + kind: dirent.isDirectory() ? "directory" : "file", + hasChildren, + }); + } + + return { + entries, + truncated: false, + }; +} + export async function searchWorkspaceEntries( input: ProjectSearchEntriesInput, ): Promise { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17bf1656f..2ead3fd17 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -991,6 +991,7 @@ export default function ChatView({ const isTransportReady = transportState === "open"; const isRemoteActionBlocked = !isTransportReady; const isWorking = isTurnActive || isSendBusy || isConnecting || isRevertingCheckpoint; + const canInterruptComposerWork = isTurnActive || isSendBusy || isConnecting; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -3868,16 +3869,18 @@ export default function ChatView({ const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread || isRemoteActionBlocked) return; + const activeTurnId = activeThread.session?.activeTurnId ?? undefined; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), threadId: activeThread.id, - ...(activeThread.session?.activeTurnId !== undefined && - activeThread.session?.activeTurnId !== null - ? { turnId: activeThread.session.activeTurnId } - : {}), + ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}), createdAt: new Date().toISOString(), }); + if (activeTurnId === undefined) { + sendInFlightRef.current = false; + resetSendPhase(); + } }; const onClearQueue = useCallback(() => { @@ -5669,13 +5672,14 @@ export default function ChatView({ : "Next question"} - ) : isTurnActive ? ( + ) : canInterruptComposerWork ? (
- + + + ) : null}
) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( diff --git a/apps/web/src/components/CloneRepositoryDialog.tsx b/apps/web/src/components/CloneRepositoryDialog.tsx index 717d9bdea..7872c1ee6 100644 --- a/apps/web/src/components/CloneRepositoryDialog.tsx +++ b/apps/web/src/components/CloneRepositoryDialog.tsx @@ -1,10 +1,14 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FolderOpenIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isElectron } from "~/env"; import { gitCloneRepositoryMutationOptions } from "~/lib/gitReactQuery"; import { parseGitHubRepositoryUrl, type ParsedGitHubUrl } from "~/githubRepositoryUrl"; +import { deriveRemoteFolderBrowserRoot, isProbablyLocalWebSession } from "~/lib/remoteFolderPicker"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { readNativeApi } from "~/nativeApi"; +import { RemoteFolderPickerDialog } from "./RemoteFolderPickerDialog"; import { Button } from "./ui/button"; import { Dialog, @@ -17,6 +21,7 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; interface CloneRepositoryDialogProps { open: boolean; @@ -35,9 +40,19 @@ export function CloneRepositoryDialog({ const [urlDirty, setUrlDirty] = useState(false); const [targetDir, setTargetDir] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const cloneMutation = useMutation(gitCloneRepositoryMutationOptions({ queryClient })); const { reset: resetCloneMutation } = cloneMutation; + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const parsed: ParsedGitHubUrl | null = useMemo(() => parseGitHubRepositoryUrl(url), [url]); @@ -62,6 +77,18 @@ export function CloneRepositoryDialog({ const pickTargetDir = useCallback(async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); try { @@ -74,7 +101,7 @@ export function CloneRepositoryDialog({ } finally { setIsPickingFolder(false); } - }, [isPickingFolder]); + }, [canUseRemoteFolderBrowser, isPickingFolder, shouldUseWebFolderBrowser]); const handleClone = useCallback(async () => { if (!parsed) { @@ -193,7 +220,7 @@ export function CloneRepositoryDialog({ disabled={isPickingFolder || cloneMutation.isPending} > - Browse + {shouldUseWebFolderBrowser ? "Browse server folders" : "Browse"} {parsed && targetDir ? ( @@ -232,6 +259,15 @@ export function CloneRepositoryDialog({ + ); } diff --git a/apps/web/src/components/RemoteFolderPickerDialog.tsx b/apps/web/src/components/RemoteFolderPickerDialog.tsx new file mode 100644 index 000000000..15fd85164 --- /dev/null +++ b/apps/web/src/components/RemoteFolderPickerDialog.tsx @@ -0,0 +1,193 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronRightIcon, FolderIcon, HouseIcon, MoveUpIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { projectListDirectoryQueryOptions } from "~/lib/projectReactQuery"; +import { joinRemoteFolderPath, relativeRemoteFolderPath } from "~/lib/remoteFolderPicker"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { ScrollArea } from "./ui/scroll-area"; +import { Spinner } from "./ui/spinner"; + +interface RemoteFolderPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rootPath: string; + initialPath?: string | undefined; + title: string; + description: string; + onPick: (path: string) => void; +} + +export function RemoteFolderPickerDialog({ + open, + onOpenChange, + rootPath, + initialPath, + title, + description, + onPick, +}: RemoteFolderPickerDialogProps) { + const [currentRelativePath, setCurrentRelativePath] = useState(""); + + useEffect(() => { + if (!open) { + return; + } + setCurrentRelativePath(relativeRemoteFolderPath(initialPath, rootPath)); + }, [initialPath, open, rootPath]); + + const currentAbsolutePath = useMemo( + () => joinRemoteFolderPath(rootPath, currentRelativePath), + [currentRelativePath, rootPath], + ); + + const directoryQuery = useQuery( + projectListDirectoryQueryOptions({ + cwd: rootPath || null, + ...(currentRelativePath ? { directoryPath: currentRelativePath } : {}), + shallow: true, + enabled: open && rootPath.trim().length > 0, + }), + ); + + const directoryEntries = useMemo( + () => directoryQuery.data?.entries.filter((entry) => entry.kind === "directory") ?? [], + [directoryQuery.data?.entries], + ); + const isLoadingDirectories = + directoryQuery.isPending || + (directoryQuery.isFetching && directoryEntries.length === 0 && !directoryQuery.error); + + const pathSegments = useMemo( + () => (currentRelativePath ? currentRelativePath.split("/").filter(Boolean) : []), + [currentRelativePath], + ); + + const navigateUp = () => { + if (!currentRelativePath) { + return; + } + const nextSegments = pathSegments.slice(0, -1); + setCurrentRelativePath(nextSegments.join("/")); + }; + + const chooseCurrentFolder = () => { + onPick(currentAbsolutePath); + onOpenChange(false); + }; + + return ( + + + + {title} + {description} + + +
+
+ + Current folder +
+
+ + {pathSegments.map((segment, index) => { + const nextPath = pathSegments.slice(0, index + 1).join("/"); + return ( +
+ + +
+ ); + })} +
+
+ +
+ + +
+ +
+ +
+ {isLoadingDirectories ? ( +
+ + Loading folders... +
+ ) : directoryQuery.error ? ( +
+ {directoryQuery.error instanceof Error + ? directoryQuery.error.message + : "Unable to browse folders."} +
+ ) : directoryEntries.length === 0 ? ( +
No subfolders here.
+ ) : ( + directoryEntries.map((entry) => ( + + )) + )} +
+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ecafda802..5dc3c424a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -61,6 +61,7 @@ import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog"; import { ProjectIcon } from "~/components/ProjectIcon"; +import { RemoteFolderPickerDialog } from "~/components/RemoteFolderPickerDialog"; import { useClientMode } from "~/hooks/useClientMode"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates"; @@ -84,6 +85,10 @@ import { isElectron } from "../env"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { shortcutLabelForCommand } from "../keybindings"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { + deriveRemoteFolderBrowserRoot, + isProbablyLocalWebSession, +} from "../lib/remoteFolderPicker"; import { serverConfigQueryOptions, serverUpdateQueryOptions } from "../lib/serverReactQuery"; import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -583,10 +588,8 @@ export default function Sidebar() { strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ - ...serverConfigQueryOptions(), - select: (config) => config.keybindings, - }); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates(); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); @@ -597,6 +600,7 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); + const [remoteFolderPickerOpen, setRemoteFolderPickerOpen] = useState(false); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false); const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState( @@ -624,6 +628,14 @@ export default function Sidebar() { const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const shouldUseWebFolderBrowser = !isElectron && !isProbablyLocalWebSession(); + const canUseRemoteFolderBrowser = + shouldUseWebFolderBrowser && Boolean(serverConfigQuery.data?.cwd); + const remoteFolderBrowserRoot = useMemo( + () => + serverConfigQuery.data?.cwd ? deriveRemoteFolderBrowserRoot(serverConfigQuery.data.cwd) : "", + [serverConfigQuery.data?.cwd], + ); const { editingThreadId, draftTitle: editingThreadTitle, @@ -948,6 +960,18 @@ export default function Sidebar() { const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; + if (shouldUseWebFolderBrowser) { + if (!canUseRemoteFolderBrowser) { + toastManager.add({ + type: "error", + title: "Folder browser is still loading", + description: "Wait a moment for the server path to load, then try again.", + }); + return; + } + setRemoteFolderPickerOpen(true); + return; + } setIsPickingFolder(true); let pickedPath: string | null = null; try { @@ -2232,7 +2256,12 @@ export default function Sidebar() { aria-label="Open workspace" className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" onClick={() => { - useRightPanelStore.getState().open("workspace"); + const panelState = useRightPanelStore.getState(); + if (panelState.isOpen && panelState.activeTab === "workspace") { + panelState.close(); + return; + } + panelState.open("workspace"); }} /> } @@ -2285,7 +2314,11 @@ export default function Sidebar() { disabled={isPickingFolder || isAddingProject} > - {isPickingFolder ? "Picking folder..." : "Browse for folder"} + {isPickingFolder + ? "Picking folder..." + : shouldUseWebFolderBrowser + ? "Browse server folders" + : "Browse on this computer"}