Skip to content

Commit 2473ca7

Browse files
feat: add remote folder browsing for web sessions (#489)
* feat: add remote folder browsing and harden provider turns Support picking server-side workspaces from remote web sessions and recover more cleanly when Codex turn startup, steering, or interruption state goes stale. * feat: add remote folder browsing and harden provider turns Support picking server-side workspaces from remote web sessions and recover more cleanly when Codex turn startup, steering, or interruption state goes stale. * commit * refactor: split Codex turn steering from remote folder work Keep the remote workspace branch focused on folder browsing and provider hardening so it can be tested and reviewed independently. * fix review feedback for remote folder browsing --------- Co-authored-by: Jason <jasnsy@gmail.com>
1 parent a0377f2 commit 2473ca7

23 files changed

Lines changed: 1239 additions & 118 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.
66
- NEVER run `bun test`. Always use `bun run test` (runs Vitest).
7+
- 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.
78

89
## Project Snapshot
910

apps/server/src/codexAppServerManager.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,12 @@ describe("isRecoverableThreadResumeError", () => {
256256
),
257257
).toBe(false);
258258
});
259+
260+
it("treats thread-resume timeouts as recoverable", () => {
261+
expect(isRecoverableThreadResumeError(new Error("Timed out waiting for thread/resume."))).toBe(
262+
true,
263+
);
264+
});
259265
});
260266

261267
describe("readCodexAccountSnapshot", () => {

apps/server/src/codexAppServerManager.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
170170
"no such thread",
171171
"unknown thread",
172172
"does not exist",
173+
"timed out waiting for thread/resume",
173174
];
174175
const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
175176
const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
@@ -394,9 +395,9 @@ export function resolveCodexModelForAccount(
394395
}
395396

396397
/**
397-
* On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
398-
* wrapper, leaving the actual command running. Use `taskkill /T` to kill the
399-
* entire process tree instead.
398+
* Codex can spawn nested helper processes. On Unix we run each session in its
399+
* own process group so teardown can kill the entire subtree instead of leaking
400+
* orphaned `@github/copilot` workers after restarts and timeouts.
400401
*/
401402
function killChildTree(child: ChildProcessWithoutNullStreams): void {
402403
if (process.platform === "win32" && child.pid !== undefined) {
@@ -407,7 +408,15 @@ function killChildTree(child: ChildProcessWithoutNullStreams): void {
407408
// fallback to direct kill
408409
}
409410
}
410-
child.kill();
411+
if (child.pid !== undefined) {
412+
try {
413+
process.kill(-child.pid, "SIGKILL");
414+
return;
415+
} catch {
416+
// fallback to direct kill
417+
}
418+
}
419+
child.kill("SIGKILL");
411420
}
412421

413422
export function normalizeCodexModelSlug(
@@ -599,6 +608,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
599608
cwd: resolvedCwd,
600609
env: sessionEnv,
601610
stdio: ["pipe", "pipe", "pipe"],
611+
detached: process.platform !== "win32",
602612
shell: process.platform === "win32",
603613
});
604614
const output = readline.createInterface({ input: child.stdout });

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,6 +1927,125 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
19271927
),
19281928
);
19291929

1930+
it.effect("clears pending turn-start rows when provider turn start fails", () =>
1931+
Effect.gen(function* () {
1932+
const eventStore = yield* OrchestrationEventStore;
1933+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
1934+
const sql = yield* SqlClient.SqlClient;
1935+
1936+
const threadId = ThreadId.makeUnsafe("thread-start-failure");
1937+
const messageId = MessageId.makeUnsafe("message-start-failure");
1938+
const now = "2026-02-26T15:00:00.000Z";
1939+
1940+
const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1941+
eventStore
1942+
.append(event)
1943+
.pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1944+
1945+
yield* appendAndProject({
1946+
type: "project.created",
1947+
eventId: EventId.makeUnsafe("evt-start-failure-project"),
1948+
aggregateKind: "project",
1949+
aggregateId: ProjectId.makeUnsafe("project-start-failure"),
1950+
occurredAt: now,
1951+
commandId: CommandId.makeUnsafe("cmd-start-failure-project"),
1952+
causationEventId: null,
1953+
correlationId: CorrelationId.makeUnsafe("cmd-start-failure-project"),
1954+
metadata: {},
1955+
payload: {
1956+
projectId: ProjectId.makeUnsafe("project-start-failure"),
1957+
title: "Project Start Failure",
1958+
workspaceRoot: "/tmp/project-start-failure",
1959+
defaultModel: null,
1960+
scripts: [],
1961+
createdAt: now,
1962+
updatedAt: now,
1963+
},
1964+
});
1965+
1966+
yield* appendAndProject({
1967+
type: "thread.created",
1968+
eventId: EventId.makeUnsafe("evt-start-failure-thread"),
1969+
aggregateKind: "thread",
1970+
aggregateId: threadId,
1971+
occurredAt: now,
1972+
commandId: CommandId.makeUnsafe("cmd-start-failure-thread"),
1973+
causationEventId: null,
1974+
correlationId: CorrelationId.makeUnsafe("cmd-start-failure-thread"),
1975+
metadata: {},
1976+
payload: {
1977+
threadId,
1978+
projectId: ProjectId.makeUnsafe("project-start-failure"),
1979+
title: "Thread Start Failure",
1980+
model: "gpt-5-codex",
1981+
runtimeMode: "full-access",
1982+
branch: null,
1983+
worktreePath: null,
1984+
createdAt: now,
1985+
updatedAt: now,
1986+
},
1987+
});
1988+
1989+
yield* appendAndProject({
1990+
type: "thread.turn-start-requested",
1991+
eventId: EventId.makeUnsafe("evt-start-failure-requested"),
1992+
aggregateKind: "thread",
1993+
aggregateId: threadId,
1994+
occurredAt: now,
1995+
commandId: CommandId.makeUnsafe("cmd-start-failure-requested"),
1996+
causationEventId: null,
1997+
correlationId: CorrelationId.makeUnsafe("cmd-start-failure-requested"),
1998+
metadata: {},
1999+
payload: {
2000+
threadId,
2001+
messageId,
2002+
runtimeMode: "full-access",
2003+
createdAt: now,
2004+
},
2005+
});
2006+
2007+
yield* appendAndProject({
2008+
type: "thread.activity-appended",
2009+
eventId: EventId.makeUnsafe("evt-start-failure-activity"),
2010+
aggregateKind: "thread",
2011+
aggregateId: threadId,
2012+
occurredAt: now,
2013+
commandId: CommandId.makeUnsafe("cmd-start-failure-activity"),
2014+
causationEventId: null,
2015+
correlationId: CorrelationId.makeUnsafe("cmd-start-failure-activity"),
2016+
metadata: {},
2017+
payload: {
2018+
threadId,
2019+
activity: {
2020+
id: EventId.makeUnsafe("activity-start-failure"),
2021+
tone: "error",
2022+
kind: "provider.turn.start.failed",
2023+
summary: "Provider turn start failed",
2024+
payload: {
2025+
detail: "Timed out waiting for thread/start.",
2026+
},
2027+
turnId: null,
2028+
createdAt: now,
2029+
},
2030+
},
2031+
});
2032+
2033+
const pendingRows = yield* sql<{ readonly threadId: string }>`
2034+
SELECT thread_id AS "threadId"
2035+
FROM projection_turns
2036+
WHERE thread_id = ${threadId}
2037+
AND turn_id IS NULL
2038+
AND state = 'pending'
2039+
`;
2040+
2041+
assert.deepEqual(pendingRows, []);
2042+
}).pipe(
2043+
Effect.provide(
2044+
makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-start-failure-"),
2045+
),
2046+
),
2047+
);
2048+
19302049
const engineLayer = it.layer(
19312050
OrchestrationEngineLive.pipe(
19322051
Layer.provide(OrchestrationProjectionPipelineLive),

apps/server/src/orchestration/Layers/ProjectionPipeline.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,11 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
768768
: {}),
769769
createdAt: event.payload.activity.createdAt,
770770
});
771+
if (event.payload.activity.kind === "provider.turn.start.failed") {
772+
yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
773+
threadId: event.payload.threadId,
774+
});
775+
}
771776
return;
772777

773778
case "thread.reverted": {

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,84 @@ describe("ProviderCommandReactor", () => {
11081108
expect(thread?.session?.runtimeMode).toBe("full-access");
11091109
});
11101110

1111+
it("stops and clears a session when turn start fails while the thread is stuck starting", async () => {
1112+
const harness = await createHarness();
1113+
const now = new Date().toISOString();
1114+
1115+
await Effect.runPromise(
1116+
harness.engine.dispatch({
1117+
type: "thread.turn.start",
1118+
commandId: CommandId.makeUnsafe("cmd-turn-start-bind-session"),
1119+
threadId: ThreadId.makeUnsafe("thread-1"),
1120+
message: {
1121+
messageId: asMessageId("user-message-bind-session"),
1122+
role: "user",
1123+
text: "first",
1124+
attachments: [],
1125+
},
1126+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1127+
runtimeMode: "approval-required",
1128+
createdAt: now,
1129+
}),
1130+
);
1131+
1132+
await waitFor(() => harness.startSession.mock.calls.length === 1);
1133+
1134+
await Effect.runPromise(
1135+
harness.engine.dispatch({
1136+
type: "thread.session.set",
1137+
commandId: CommandId.makeUnsafe("cmd-session-set-starting-after-bind"),
1138+
threadId: ThreadId.makeUnsafe("thread-1"),
1139+
session: {
1140+
threadId: ThreadId.makeUnsafe("thread-1"),
1141+
status: "starting",
1142+
providerName: "codex",
1143+
runtimeMode: "approval-required",
1144+
activeTurnId: null,
1145+
lastError: null,
1146+
updatedAt: now,
1147+
},
1148+
createdAt: now,
1149+
}),
1150+
);
1151+
1152+
harness.startSession.mockImplementationOnce(
1153+
(_: unknown, __: unknown) => Effect.fail(new Error("simulated start failure")) as never,
1154+
);
1155+
1156+
await Effect.runPromise(
1157+
harness.engine.dispatch({
1158+
type: "thread.turn.start",
1159+
commandId: CommandId.makeUnsafe("cmd-turn-start-fail-while-starting"),
1160+
threadId: ThreadId.makeUnsafe("thread-1"),
1161+
message: {
1162+
messageId: asMessageId("user-message-fail-while-starting"),
1163+
role: "user",
1164+
text: "second",
1165+
attachments: [],
1166+
},
1167+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1168+
runtimeMode: "approval-required",
1169+
createdAt: now,
1170+
}),
1171+
);
1172+
1173+
await waitFor(async () => {
1174+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
1175+
const thread = readModel.threads.find(
1176+
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
1177+
);
1178+
return thread?.session?.status === "stopped";
1179+
});
1180+
1181+
expect(harness.stopSession.mock.calls.length).toBe(1);
1182+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
1183+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
1184+
expect(thread?.session?.status).toBe("stopped");
1185+
expect(thread?.session?.activeTurnId).toBeNull();
1186+
expect(thread?.session?.lastError).toContain("simulated start failure");
1187+
});
1188+
11111189
it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => {
11121190
const harness = await createHarness();
11131191
const now = new Date().toISOString();
@@ -1147,6 +1225,53 @@ describe("ProviderCommandReactor", () => {
11471225
});
11481226
});
11491227

1228+
it("clears stale active turns when interrupt is requested without a live provider turn", async () => {
1229+
const harness = await createHarness();
1230+
const now = new Date().toISOString();
1231+
1232+
await Effect.runPromise(
1233+
harness.engine.dispatch({
1234+
type: "thread.session.set",
1235+
commandId: CommandId.makeUnsafe("cmd-session-set-stale-interrupt"),
1236+
threadId: ThreadId.makeUnsafe("thread-1"),
1237+
session: {
1238+
threadId: ThreadId.makeUnsafe("thread-1"),
1239+
status: "running",
1240+
providerName: "codex",
1241+
runtimeMode: "approval-required",
1242+
activeTurnId: asTurnId("turn-stale"),
1243+
lastError: null,
1244+
updatedAt: now,
1245+
},
1246+
createdAt: now,
1247+
}),
1248+
);
1249+
1250+
await Effect.runPromise(
1251+
harness.engine.dispatch({
1252+
type: "thread.turn.interrupt",
1253+
commandId: CommandId.makeUnsafe("cmd-turn-interrupt-stale"),
1254+
threadId: ThreadId.makeUnsafe("thread-1"),
1255+
turnId: asTurnId("turn-stale"),
1256+
createdAt: now,
1257+
}),
1258+
);
1259+
1260+
await waitFor(async () => {
1261+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
1262+
return (
1263+
readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.session
1264+
?.activeTurnId === null
1265+
);
1266+
});
1267+
1268+
expect(harness.interruptTurn.mock.calls.length).toBe(0);
1269+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
1270+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
1271+
expect(thread?.session?.status).toBe("ready");
1272+
expect(thread?.session?.activeTurnId).toBeNull();
1273+
});
1274+
11501275
it("reacts to thread.approval.respond by forwarding provider approval response", async () => {
11511276
const harness = await createHarness();
11521277
const now = new Date().toISOString();

0 commit comments

Comments
 (0)