Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aade036
fix(server): provider sessions clean up when their start is interrupted
juliusmarminge Oct 4, 2026
be865b2
fix(server): keep a Pi turn whose prompt was sent, clean up sessions …
juliusmarminge Oct 4, 2026
e7741ec
fix(server): a kept Pi turn always has the response that settles it
juliusmarminge Oct 4, 2026
5beedc5
fix(server): a stuck session close no longer holds up an interrupted …
juliusmarminge Oct 5, 2026
b1c31ee
fix(server): a stopped turn start undoes only its own busy mark
juliusmarminge Oct 5, 2026
c32cef8
fix(server): a session tracks busy turns, not a count
juliusmarminge Oct 5, 2026
47a46bc
Merge remote-tracking branch 'origin/main' into t3code/session-scope-…
juliusmarminge Oct 5, 2026
53a6b7e
fix(server): OpenCode turns end on the provider thread they started on
juliusmarminge Oct 5, 2026
30f5e82
Merge branch 'main' into t3code/session-scope-interrupt-cleanup-2
juliusmarminge Oct 5, 2026
113f25e
Merge remote-tracking branch 'origin/main' into t3code/session-scope-…
juliusmarminge Oct 6, 2026
b084738
Merge remote-tracking branch 'origin/main' into t3code/session-scope-…
juliusmarminge Oct 6, 2026
fb47723
fix(server): a device hub stopped mid-start is always cleaned up
juliusmarminge Oct 6, 2026
850d5dd
fix(server): an interrupted attach no longer undoes a later attach of…
juliusmarminge Oct 6, 2026
35b7b0b
fix(server): an interrupted attach leaves a replacement session alone
juliusmarminge Oct 6, 2026
3a82d44
fix(server): a resume stopped while checking its credential drops the…
juliusmarminge Oct 6, 2026
5cc6a98
Merge remote-tracking branch 'origin/main' into t3code/session-scope-…
juliusmarminge Oct 6, 2026
b0a9ce3
Merge branch 'main' into t3code/session-scope-interrupt-cleanup-2
juliusmarminge Oct 6, 2026
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
156 changes: 81 additions & 75 deletions apps/server/src/device/LocalDeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,62 +383,63 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
);
const origin = `http://127.0.0.1:${port}`;
const scope = yield* Scope.make("sequential");
const child = yield* spawner
.spawn(
ChildProcess.make(
nodePath,
[
hubTool.entryPath,
"--port",
String(port),
"--host",
"127.0.0.1",
"--hide-sidebar",
"--hide-boot-device",
],
{
detached: false,
shell: false,
stdout: "pipe",
stderr: "pipe",
env: yield* hubEnvironment(hostEnvironment).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(HostProcessPlatform, hostPlatform),
),
},
),
)
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.mapError(
(cause) =>
new DeviceHost.DeviceHostError({
hostId,
step: "starting the device hub",
cause,
}),
),
);
const startedAtMillis = yield* Clock.currentTimeMillis;
const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
yield* Effect.forkIn(observeHubOutput(hub), scope);
yield* waitForHttpReady({
baseUrl: origin,
path: "/readyz",
timeoutMs: HUB_READY_TIMEOUT_MS,
makeError: (info) =>
new DeviceHost.DeviceHostError({
hostId,
step: "waiting for the device hub to answer",
cause: info.cause,
}),
}).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.tapError(() => stopHub(hub)),
);
yield* recordHub(hub, hubTool);
yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
return hub;
// On failure or interrupt, from the spawn on: the hub is not recorded yet,
// so nothing else stops it.
return yield* Effect.gen(function* () {
const child = yield* spawner
.spawn(
ChildProcess.make(
nodePath,
[
hubTool.entryPath,
"--port",
String(port),
"--host",
"127.0.0.1",
"--hide-sidebar",
"--hide-boot-device",
],
{
detached: false,
shell: false,
stdout: "pipe",
stderr: "pipe",
env: yield* hubEnvironment(hostEnvironment).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(HostProcessPlatform, hostPlatform),
),
},
),
)
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.mapError(
(cause) =>
new DeviceHost.DeviceHostError({
hostId,
step: "starting the device hub",
cause,
}),
),
);
const startedAtMillis = yield* Clock.currentTimeMillis;
const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath };
yield* Effect.forkIn(observeHubOutput(hub), scope);
yield* waitForHttpReady({
baseUrl: origin,
path: "/readyz",
timeoutMs: HUB_READY_TIMEOUT_MS,
makeError: (info) =>
new DeviceHost.DeviceHostError({
hostId,
step: "waiting for the device hub to answer",
cause: info.cause,
}),
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
yield* recordHub(hub, hubTool);
yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
return hub;
}).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const observeHubOutput = (hub: HubProcess) =>
Expand Down Expand Up @@ -622,25 +623,30 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
);
yield* onPhase("starting");
const hub = yield* spawnHub(hubTool, nodePath);
yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe(
Effect.provideService(Path.Path, path),
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.ignore,
);
const candidate = helperPaths(hubTool);
const [axExists, cliExists] = yield* Effect.all([
fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
]);
const next: RunningHost = {
hub,
agentDevice: null,
helpers: {
serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
serveSimCli: cliExists ? candidate.serveSimCli : null,
},
};
yield* Ref.set(runningRef, next);
// Until the hub is in runningRef, nothing else stops it, so publishing it
// is part of the guarded step.
const next = yield* Effect.gen(function* () {
yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe(
Effect.provideService(Path.Path, path),
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.ignore,
);
const candidate = helperPaths(hubTool);
const [axExists, cliExists] = yield* Effect.all([
fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
]);
const next: RunningHost = {
hub,
agentDevice: null,
helpers: {
serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
serveSimCli: cliExists ? candidate.serveSimCli : null,
},
};
yield* Ref.set(runningRef, next);
return next;
}).pipe(Effect.onError(() => stopHub(hub)));
yield* Ref.set(restartDelayRef, 0);
yield* Effect.forkDetach(superviseHub(hub, hubTool));
return next;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7148,7 +7148,8 @@ export function makeClaudeAdapterV2(
options: queryOptions,
})
.pipe(
Effect.tapError(() =>
// An interrupted open leaves the old process just as dead.
Effect.onError(() =>
// Same-native-thread replacement: the old process is already
// dead, so its process-scoped roster is not authoritative.
// First-ever failed open (no prior live query) must not invent
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6174,8 +6174,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
yield* Ref.update(pendingRootTurns, (current) =>
new Map(current).set(threadId, turnInput),
);
// Cleared on interrupt too, as startTurn does: an interrupted start
// must not adopt the native turn that a late turn/started reports.
yield* client.request("thread/compact/start", { threadId }).pipe(
Effect.tapError(() =>
Effect.onError(() =>
Ref.update(pendingRootTurns, (current) => {
const next = new Map(current);
next.delete(threadId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,25 @@ describe("OpenCode2 adapter", () => {
}).pipe(Effect.scoped),
);

it.effect("ends a turn on the provider thread it started on", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
]);
// A forked run starts on its own row for the same native session, while
// the adapter tracks the session under the id it minted for the fork.
const forkedRow = {
...thread,
id: ProviderThreadId.make("provider-thread:opencode2-adapter:forked-run-row"),
};
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn(turnInput(forkedRow));
assert.equal((yield* Fiber.join(terminal))?.providerThreadId, forkedRow.id);
}).pipe(Effect.scoped),
);

it.effect("ends the turn when its terminal event is one this build cannot decode", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
Expand Down
16 changes: 10 additions & 6 deletions apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1786,7 +1786,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
const base = {
type: "turn.terminal" as const,
driver,
providerThreadId: state.providerThread.id,
// The provider thread the turn started on. After a native fork the
// session's own thread has a fresh id, and the terminal must name the
// thread the start was recorded against.
providerThreadId: turn.providerTurn.providerThreadId,
providerTurnId: turn.providerTurn.id,
runOrdinal: turn.input.runOrdinal,
threadDisposition,
Expand Down Expand Up @@ -2825,12 +2828,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
* reconciles. Retried a few times; the caller fails everything after that.
*/
const reconnect = Effect.gen(function* () {
// Returned on any failure, including the interrupt a closing session
// sends a reconnect still in flight, so a spawned server can idle-stop.
const scope = yield* Scope.make();
const next = yield* borrow.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.tapError(() => Scope.close(scope, Exit.void)),
);
const stream = yield* next.events.pipe(Effect.tapError(() => Scope.close(scope, Exit.void)));
const { next, stream } = yield* Effect.gen(function* () {
const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
return { next, stream: yield* next.events };
}).pipe(Effect.onError(() => Scope.close(scope, Exit.void)));
const previous = currentScope;
connection = next;
client = next.client;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
runtimePolicy: policy,
});
const now = yield* DateTime.now;
const startTurn = (text = "hello") =>
const startTurn = (
text = "hello",
startProviderThread: OrchestrationV2ProviderThread = providerThread,
) =>
runtime.startTurn({
appThread: {
id: threadId,
Expand Down Expand Up @@ -213,7 +216,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
providerTurnOrdinal: 1,
attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`),
rootNodeId: NodeId.make(`node-opencode-${suffix}`),
providerThread,
providerThread: startProviderThread,
message: {
createdBy: "user",
creationSource: "web",
Expand Down Expand Up @@ -1291,6 +1294,45 @@ describe("OpenCodeAdapterV2", () => {
}).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
);

it.effect("ends a turn on the provider thread it started on", () =>
Effect.gen(function* () {
const nativeEvents = asyncEventStream();
const harness = yield* makeOpenCodeRuntimeHarness(
"forked-row",
"native-opencode-forked-row",
{
event: {
subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => {
options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true });
return { stream: nativeEvents.stream };
},
},
session: {
create: async () => ({
data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } },
}),
summarize: async () => ({ data: true }),
},
},
);
// A forked run starts on its own row for the same native session, while
// the adapter tracks the session under the id it minted.
const forkedRow = {
...harness.providerThread,
id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"),
};
yield* harness.startTurn("/compact", forkedRow);
const terminal = yield* harness.runtime.events.pipe(
Stream.filter(
(event): event is Extract<typeof event, { type: "turn.terminal" }> =>
event.type === "turn.terminal",
),
Stream.runHead,
);
assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id);
}).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
);

it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () =>
Effect.gen(function* () {
const nativeEvents = asyncEventStream();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ interface ActiveOpenCodeTurn {
readonly modelSelection: ModelSelection;
readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
/** The provider thread the turn started on, which its terminal names. */
readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"];
readonly providerTurnOrdinal: number;
readonly runOrdinal: number;
readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"];
Expand Down Expand Up @@ -2118,7 +2120,7 @@ export function makeOpenCodeAdapterV2(
? {
type: "turn.terminal",
driver: OPENCODE_PROVIDER,
providerThreadId: state.providerThread.id,
providerThreadId: turn.providerThreadId,
providerTurnId: turn.providerTurnId,
runOrdinal: turn.runOrdinal,
failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`),
Expand All @@ -2134,7 +2136,7 @@ export function makeOpenCodeAdapterV2(
: {
type: "turn.terminal",
driver: OPENCODE_PROVIDER,
providerThreadId: state.providerThread.id,
providerThreadId: turn.providerThreadId,
providerTurnId: turn.providerTurnId,
runOrdinal: turn.runOrdinal,
status,
Expand Down Expand Up @@ -2256,6 +2258,7 @@ export function makeOpenCodeAdapterV2(
modelSelection: state.appThread.modelSelection,
runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy,
providerTurnId,
providerThreadId: providerTurn.providerThreadId,
providerTurnOrdinal: providerTurn.ordinal,
runOrdinal: state.parentSubagent.parentTurn.runOrdinal,
runAttemptId: null,
Expand Down Expand Up @@ -3192,6 +3195,7 @@ export function makeOpenCodeAdapterV2(
modelSelection: turnInput.modelSelection,
runtimePolicy: turnInput.runtimePolicy,
providerTurnId,
providerThreadId: turnInput.providerThread.id,
providerTurnOrdinal: turnInput.providerTurnOrdinal,
runOrdinal: turnInput.runOrdinal,
runAttemptId: turnInput.attemptId,
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2679,8 +2679,9 @@ export function makePiAdapterV2(
}
// Pi fork replaces the session file, including for rollback. Persist
// its new identity before any later request can fail or restart.
// An interrupted read leaves the identity just as unknown as a failed one.
const forkState = yield* request({ type: "get_state" }).pipe(
Effect.tapError(() =>
Effect.onError(() =>
Effect.sync(() => {
threadState = null;
}),
Expand Down
Loading
Loading