Skip to content

Commit 45f491e

Browse files
fix(server): Force stop keeps a session only for a newer run on it
Matching the newer run by provider instance is not enough: one-thread-per- session providers run a fresh-context follow-up on its own session of the same instance, so Force stop kept the stopped run's session alive while it reported released. Keep a session only when a newer active run is bound to a provider thread on that session. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 0b5c0f1 commit 45f491e

2 files changed

Lines changed: 46 additions & 31 deletions

File tree

‎apps/server/src/orchestration-v2/ThreadManagementService.ts‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,30 +1304,27 @@ const make = Effect.gen(function* () {
13041304
providerThread.appThreadId === input.threadId ? [providerThread.id] : [],
13051305
);
13061306
// Settling the target can promote a queued follow-up onto the same session; that run is
1307-
// not Force stop's to end. Sessions are per provider instance and a follow-up may switch
1308-
// instance, so only a session of the newer run's instance is kept. An unreadable thread
1309-
// counts as a newer run, so its sessions are kept.
1307+
// not Force stop's to end. Only a newer run bound to a provider thread on this session
1308+
// counts. An unreadable thread counts as one, so its sessions are kept.
13101309
const throughRunOrdinal = target?.ordinal ?? 0;
1311-
const laterRunActive = (providerSessionId: ProviderSessionId) => {
1312-
const instanceIds = new Set(
1313-
records.providerThreads.flatMap((providerThread) =>
1314-
providerThread.providerSessionId === providerSessionId
1315-
? [providerThread.providerInstanceId]
1316-
: [],
1317-
),
1318-
);
1319-
return orchestrator.getThreadRecords(input.threadId, ["runs"]).pipe(
1320-
Effect.map(({ runs }) =>
1321-
runs.some(
1310+
const laterRunActive = (providerSessionId: ProviderSessionId) =>
1311+
orchestrator.getThreadRecords(input.threadId, ["runs", "providerThreads"]).pipe(
1312+
Effect.map(({ runs, providerThreads }) => {
1313+
const onSession = new Set(
1314+
providerThreads.flatMap((providerThread) =>
1315+
providerThread.providerSessionId === providerSessionId ? [providerThread.id] : [],
1316+
),
1317+
);
1318+
return runs.some(
13221319
(run) =>
13231320
run.ordinal > throughRunOrdinal &&
13241321
isActiveRun(run) &&
1325-
instanceIds.has(run.providerInstanceId),
1326-
),
1327-
),
1322+
run.providerThreadId !== null &&
1323+
onSession.has(run.providerThreadId),
1324+
);
1325+
}),
13281326
Effect.orElseSucceed(() => true),
13291327
);
1330-
};
13311328
const detach = (providerSessionId: ProviderSessionId) =>
13321329
sessions
13331330
.detach({ providerSessionId, threadId: input.threadId, detail: FORCE_STOP_REASON })

‎apps/server/src/orchestration-v2/ThreadStop.test.ts‎

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,34 +1840,49 @@ it.effect.each(["Stop", "Force stop"] as const)(
18401840
);
18411841

18421842
it.effect.each([
1843-
{ followUp: "started its turn", startedBeforeRelease: true, switchesInstance: false },
1844-
{ followUp: "was promoted", startedBeforeRelease: false, switchesInstance: false },
1843+
{ followUp: "started its turn", startedBeforeRelease: true },
1844+
{ followUp: "was promoted", startedBeforeRelease: false },
18451845
{ followUp: "switched provider instance", startedBeforeRelease: true, switchesInstance: true },
1846+
{ followUp: "has fresh context", startedBeforeRelease: true, freshContext: true },
18461847
])(
18471848
"Force stop keeps only the session of a queued follow-up that $followUp",
1848-
({ startedBeforeRelease, switchesInstance }) =>
1849+
({ startedBeforeRelease, switchesInstance = false, freshContext = false }) =>
18491850
Effect.scoped(
18501851
Effect.gen(function* () {
18511852
const cwd = yield* checkpointWorkspace("thread-stop-force-stop-follow-up");
18521853
const driver = adapter.driver;
18531854
const otherInstanceId = ProviderInstanceId.make("codex-other");
1854-
const eventQueues = new Map([
1855-
[instanceId, yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>()],
1856-
[otherInstanceId, yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>()],
1857-
]);
1855+
const eventQueues = new Map<
1856+
ProviderSessionId,
1857+
Queue.Queue<ProviderAdapter.ProviderAdapterV2Event>
1858+
>();
18581859
const started: Array<ProviderAdapter.ProviderAdapterV2TurnInput> = [];
18591860
const interrupted: Array<ProviderAdapter.ProviderAdapterV2InterruptInput> = [];
18601861
let opened = 0;
1862+
// Fresh context on a one-thread-per-session provider puts the follow-up on its own session.
1863+
// The session itself reports shared threads so fresh-context cleanup leaves the stopped
1864+
// run's session to Force stop, as pending background work would.
1865+
const adapterCapabilities = freshContext
1866+
? {
1867+
...CodexProviderCapabilitiesV2,
1868+
sessions: {
1869+
...CodexProviderCapabilitiesV2.sessions,
1870+
supportsMultipleProviderThreadsPerSession: false,
1871+
},
1872+
}
1873+
: CodexProviderCapabilitiesV2;
18611874
const liveAdapter = (
18621875
instanceId: ProviderInstanceId,
18631876
): ProviderAdapter.ProviderAdapterV2["Service"] => ({
18641877
...adapter,
18651878
instanceId,
1879+
getCapabilities: () => Effect.succeed(adapterCapabilities),
18661880
openSession: (input) =>
18671881
Effect.gen(function* () {
18681882
opened += 1;
18691883
const now = yield* DateTime.now;
1870-
const events = eventQueues.get(instanceId)!;
1884+
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
1885+
eventQueues.set(input.providerSessionId, events);
18711886
return {
18721887
instanceId,
18731888
driver,
@@ -1887,7 +1902,9 @@ it.effect.each([
18871902
events: Stream.fromQueue(events),
18881903
ensureThread: ({ threadId }) =>
18891904
Effect.succeed({
1890-
id: ProviderThreadId.make(`provider-thread:${instanceId}:${threadId}`),
1905+
id: ProviderThreadId.make(
1906+
`provider-thread:${input.providerSessionId}:${threadId}`,
1907+
),
18911908
driver,
18921909
providerInstanceId: instanceId,
18931910
providerSessionId: input.providerSessionId,
@@ -2017,6 +2034,7 @@ it.effect.each([
20172034
...(switchesInstance
20182035
? { modelSelection: { ...modelSelection, instanceId: otherInstanceId } }
20192036
: {}),
2037+
...(freshContext ? { freshContext } : {}),
20202038
createdBy: "user",
20212039
creationSource: "web",
20222040
});
@@ -2031,15 +2049,15 @@ it.effect.each([
20312049
);
20322050
assert.equal(
20332051
opened,
2034-
switchesInstance ? 2 : 1,
2052+
switchesInstance || freshContext ? 2 : 1,
20352053
"Force stop tore down the session the follow-up was promoted onto",
20362054
);
20372055
const [stopped, followUp] = [started[0]!, started[1]!];
20382056
const providerSessionId = followUp.providerThread.providerSessionId!;
20392057
assert.equal(
20402058
yield* sessions.isLive(stopped.providerThread.providerSessionId!),
2041-
!switchesInstance,
2042-
"Force stop kept the stopped run's session for a follow-up on another instance",
2059+
!switchesInstance && !freshContext,
2060+
"Force stop kept the stopped run's session for a follow-up on another session",
20432061
);
20442062
assert.isFalse(
20452063
interrupted.some((interrupt) =>
@@ -2058,7 +2076,7 @@ it.effect.each([
20582076
);
20592077

20602078
// The first run's provider never confirms the stop; the follow-up's turn ends normally.
2061-
yield* Queue.offer(eventQueues.get(followUp.providerThread.providerInstanceId)!, {
2079+
yield* Queue.offer(eventQueues.get(providerSessionId)!, {
20622080
type: "turn.terminal",
20632081
driver,
20642082
providerThreadId: followUp.providerThread.id,

0 commit comments

Comments
 (0)