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
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function layerExecutorFor(input: {
detach: () => record("detach"),
otherWorkingThreads: () => Effect.succeed([]),
releaseUnlessBusy: () => Effect.succeed(true),
forgetLoadedProviderThread: () => Effect.void,
subscribePromptSuggestion: () => Stream.empty,
}),
),
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/orchestration-v2/ProviderSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ export interface ProviderSessionManagerV2Shape {
readonly providerSessionId: ProviderSessionId;
readonly threadId: ThreadId;
}) => Effect.Effect<ReadonlyArray<ThreadId>>;
/**
* Forgets that the thread has this provider thread loaded, so its next resume reaches the
* adapter. Leaves the attachment, idle unload and MCP credential alone, unlike detach.
*/
readonly forgetLoadedProviderThread: (input: {
readonly providerSessionId: ProviderSessionId;
readonly threadId: ThreadId;
readonly providerThread: OrchestrationV2ProviderThread;
readonly modelSelection: ModelSelection;
readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
}) => Effect.Effect<void>;
/** The thread's latest predicted next prompt, then each replacement. */
readonly subscribePromptSuggestion: (
threadId: ThreadId,
Expand Down Expand Up @@ -3166,6 +3177,22 @@ export const layerWithOptions = (
),
),
),
forgetLoadedProviderThread: (input) =>
Ref.update(sessions, (current) => {
const key = sessionKey(input.providerSessionId);
const entry = current.get(key);
// Only the matching load: a different key belongs to a later load of this thread.
if (
entry === undefined ||
entry.loadedProviderThreadKeyByThread.get(input.threadId) !==
providerThreadLoadKey({ ...input, runtime: entry.runtime })
) {
return current;
}
const loadedProviderThreadKeyByThread = new Map(entry.loadedProviderThreadKeyByThread);
loadedProviderThreadKeyByThread.delete(input.threadId);
return new Map(current).set(key, { ...entry, loadedProviderThreadKeyByThread });
}),
// Stored suggestions can outlive a deleted thread, so check the thread before serving one.
subscribePromptSuggestion: (threadId) =>
SubscriptionRef.changes(promptSuggestionStore).pipe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ it.effect(
detach: () => Effect.void,
otherWorkingThreads: () => Effect.succeed([]),
releaseUnlessBusy: () => Effect.succeed(true),
forgetLoadedProviderThread: () => Effect.void,
subscribePromptSuggestion: () => Stream.empty,
}),
);
Expand Down
36 changes: 33 additions & 3 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1456,9 +1456,32 @@ export const layer: Layer.Layer<
}),
);
if (replacement === undefined) return undefined;
if (
replacement.nativeThreadRef?.nativeId != null &&
replacement.nativeThreadRef.driver === providerThread.nativeThreadRef?.driver &&
replacement.nativeThreadRef.nativeId === providerThread.nativeThreadRef.nativeId
) {
// Never retried. Forget the load first, or the next turn would skip resume and start on
// the reused session without this error or the recovery handoff.
yield* providerSessions.forgetLoadedProviderThread({
providerSessionId: session.providerSessionId,
threadId: projection.thread.id,
providerThread: replacement,
modelSelection: run.modelSelection,
runtimePolicy: resolvedRuntimePolicy,
});
yield* settleStartFailure({
signal: "provider-thread-load-failure",
title: "Provider turn failed to start",
error: new ProviderAdapter.ProviderAdapterProtocolError({
driver: session.driver,
detail:
"the provider returned its existing session when asked for a fresh one, so the conversation context could not be moved to a new session",
}),
});
return undefined;
}
const { handoff, transferId, createdAt } = yield* recoveryHandoff;
effectiveHandoffs = withRecoveryHandoff(handoff);
resumeFellBack = true;
const previousRun = projection.runs.findLast(
(candidate) =>
candidate.providerThreadId === providerThread.id &&
Expand Down Expand Up @@ -1503,7 +1526,11 @@ export const layer: Layer.Layer<
strategy: handoff.strategy,
summary: handoff.summaryText,
};
yield* eventSink.write({
const replacementWrite = yield* eventSink.writeIfRunCurrent({
threadId: projection.thread.id,
runId: run.id,
activeAttemptId: attempt.id,
expectedStatus: "starting",
events: [
{
id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }),
Expand Down Expand Up @@ -1542,6 +1569,9 @@ export const layer: Layer.Layer<
},
],
});
if (!replacementWrite.committed) return undefined;
effectiveHandoffs = withRecoveryHandoff(handoff);
resumeFellBack = true;
recoveryTurnItem = handoffTurnItem;
return replacement;
});
Expand Down
Loading
Loading