Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aba6296
fix(codex): finished delegated children leave Codex's thread list
shivamhwp Nov 3, 2026
4cd0b94
fix(codex): delegated children archive safely after late result delivery
shivamhwp Nov 3, 2026
a1fb5b7
fix(codex): keep pending descendants out of archive cleanup
shivamhwp Nov 3, 2026
adc0eb4
fix(codex): archive initial delegated task sessions
maria-rcks Nov 3, 2026
60db677
fix(codex): keep pending descendants out of archive cleanup
maria-rcks Nov 3, 2026
a0f6dd6
fix(codex): retain sessions with pending descendants
shivamhwp Nov 3, 2026
45da8d1
fix(codex): settle stopped children and retain failed archive cleanup
shivamhwp Nov 4, 2026
7fbe7cd
Merge remote-tracking branch 'github/main' into issue-watch/29778
maria-rcks Nov 6, 2026
d05019a
Merge remote-tracking branch 'origin/main' into prbatch/29868
shivamhwp Nov 7, 2026
1869f53
refactor(codex): keep delegated archive cleanup to the archive fix
shivamhwp Nov 7, 2026
b9ac86b
Merge remote-tracking branch 'origin/main' into issue-watch/29778
shivamhwp Nov 8, 2026
3b24462
Merge remote-tracking branch 'origin/main' into issue-watch/29778
shivamhwp Nov 10, 2026
4e47fb5
Merge remote-tracking branch 'origin/main' into issue-watch/29778
shivamhwp Nov 10, 2026
66ed0da
refactor(codex): trim delegated-child archive to one decision in the …
shivamhwp Nov 10, 2026
b1ec4aa
fix(codex): hold a delegated child until its result is delivered, unl…
shivamhwp Nov 10, 2026
cbc5ee7
fix(codex): unload after a failed or timed-out archive, keep the sess…
shivamhwp Nov 10, 2026
15b09ec
Merge remote-tracking branch 'origin/main' into issue-watch/29778
shivamhwp Nov 10, 2026
b075d8b
Merge main (52da4e50987) into issue-watch/29778
shivamhwp Nov 12, 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
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7650,6 +7650,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
function* (unloadInput) {
const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread);
sessionApprovals.delete(nativeThreadId);
// Archive also unloads, inline and without thread/closed; resumeNativeThread
// unarchives it on the next turn.
if (unloadInput.archive === true) {
yield* client.request("thread/archive", { threadId: nativeThreadId });
return;
}
// A resume may already wait on the pending entry; replacing it would strand that wait.
if (!threadClosings.has(nativeThreadId)) {
threadClosings.set(nativeThreadId, yield* Deferred.make<void>());
Expand Down
186 changes: 184 additions & 2 deletions apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
readonly startTurn?: Effect.Effect<void>;
readonly interruptTurn?: Effect.Effect<void>;
readonly beforeUnload?: Effect.Effect<void>;
readonly unloads?: Queue.Queue<string>;
readonly archive?: Effect.Effect<void, ProviderAdapter.ProviderAdapterProtocolError>;
readonly spawnBeforeOpen?: boolean;
readonly scopeCloseReached?: Deferred.Deferred<void>;
} = {},
Expand All @@ -457,7 +459,11 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
Ref.update(state, (current) => ({
...current,
closeCount: current.closeCount + 1,
})),
})).pipe(
Effect.andThen(
options.unloads === undefined ? Effect.void : Queue.offer(options.unloads, "close"),
),
),
);
// Register after countClose to wedge teardown before its finalizer, matching a provider stream that never yields.
const hangClose = Effect.addFinalizer(() =>
Expand Down Expand Up @@ -563,7 +569,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
...current,
interruptCount: current.interruptCount + 1,
})).pipe(Effect.andThen(options.interruptTurn ?? Effect.void)),
unloadThread: ({ providerThread }) =>
unloadThread: ({ providerThread, archive }) =>
(options.beforeUnload ?? Effect.void).pipe(
Effect.andThen(
Ref.update(state, (current) => ({
Expand All @@ -574,6 +580,15 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
],
})),
),
Effect.andThen(
options.unloads === undefined
? Effect.void
: Queue.offer(
options.unloads,
`${archive === true ? "archive" : "unload"}:${providerThread.nativeThreadRef?.nativeId}`,
),
),
Effect.andThen(archive === true ? (options.archive ?? Effect.void) : Effect.void),
),
respondToRuntimeRequest: () => Effect.void,
readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"),
Expand Down Expand Up @@ -614,6 +629,8 @@ function layerTest(input: {
readonly startTurn?: Effect.Effect<void>;
readonly interruptTurn?: Effect.Effect<void>;
readonly beforeUnload?: Effect.Effect<void>;
readonly unloads?: Queue.Queue<string>;
readonly archive?: Effect.Effect<void, ProviderAdapter.ProviderAdapterProtocolError>;
readonly spawnBeforeOpen?: boolean;
readonly scopeCloseReached?: Deferred.Deferred<void>;
readonly serverSettingsLayer?: ReturnType<typeof ServerSettings.layerTest>;
Expand Down Expand Up @@ -657,6 +674,8 @@ function layerTest(input: {
...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }),
...(input.interruptTurn === undefined ? {} : { interruptTurn: input.interruptTurn }),
...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }),
...(input.unloads === undefined ? {} : { unloads: input.unloads }),
...(input.archive === undefined ? {} : { archive: input.archive }),
...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }),
...(input.scopeCloseReached === undefined
? {}
Expand Down Expand Up @@ -4706,6 +4725,169 @@ it.effect(
}),
);

it.effect.each([
{ keptOpen: true, archive: "succeeds" },
{ keptOpen: false, archive: "succeeds" },
{ keptOpen: false, archive: "fails" },
{ keptOpen: false, archive: "hangs" },
] as const)(
"ProviderSessionManagerV2 archives an idle delegated child once its parent has the result (session kept open: $keptOpen, archive $archive)",
({ keptOpen, archive }) =>
Effect.gen(function* () {
const state = yield* Ref.make(emptyState);
const unloads = yield* Queue.unbounded<string>();
const effect = Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
const now = yield* DateTime.now;
const parentThreadId = ThreadId.make("thread-provider-session-manager-archive-parent");
const parentRunId = idAllocator.derive.run({ threadId: parentThreadId, ordinal: 1 });
const providerSessionId = idAllocator.derive.providerSession({
providerInstanceId: modelSelection.instanceId,
});
yield* eventSink.write({
events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentThreadId, now })],
});
let deliverPending: OrchestrationV2DomainEvent | undefined;
for (const delivery of ["pending", "delivered"] as const) {
const threadId = ThreadId.make(`thread-provider-session-manager-archive-${delivery}`);
const runId = idAllocator.derive.run({ threadId, ordinal: 1 });
const taskId = idAllocator.derive.rootNode({ runId });
const providerThread = makeProviderThread({
idAllocator,
threadId,
providerSessionId,
now,
nativeThreadId: `native-${delivery}`,
});
const created = yield* makeThreadCreatedEvent({
idAllocator,
threadId,
now,
delegatedBy: parentThreadId,
});
const taskEvent = (state: "pending" | "delivered") =>
Effect.gen(function* () {
return {
id: yield* idAllocator.allocate.event({ threadId: parentThreadId }),
type: "subagent.updated",
threadId: parentThreadId,
nodeId: taskId,
providerInstanceId: modelSelection.instanceId,
occurredAt: now,
payload: {
id: taskId,
threadId: parentThreadId,
runId: parentRunId,
parentNodeId: idAllocator.derive.rootNode({ runId: parentRunId }),
origin: "app_owned",
createdBy: "agent",
driver: CODEX_DRIVER,
providerInstanceId: modelSelection.instanceId,
providerThreadId: null,
childThreadId: threadId,
nativeTaskRef: null,
prompt: "Delegated work",
title: null,
model: null,
completionDelivery: { state, observedByRunId: null },
status: "completed",
result: "Done.",
startedAt: now,
completedAt: now,
updatedAt: now,
},
} satisfies OrchestrationV2DomainEvent;
});
if (delivery === "pending") deliverPending = yield* taskEvent("delivered");
yield* eventSink.write({
events: [
{
...created,
payload: { ...created.payload, forkedFrom: { type: "node", nodeId: taskId } },
},
yield* taskEvent(delivery),
],
});
const runtime = yield* manager.open({
threadId,
providerSessionId,
modelSelection,
runtimePolicy,
});
yield* runtime.startTurn({
appThread: (yield* projectionStore.getThreadProjection(threadId)).thread,
threadId,
runId,
runOrdinal: 1,
providerTurnOrdinal: 1,
attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }),
rootNodeId: idAllocator.derive.rootNode({ runId }),
providerThread,
message: {
createdBy: "agent",
creationSource: "server",
messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }),
text: "Delegated work",
attachments: [],
},
modelSelection,
runtimePolicy,
});
// The pump marks the thread idle before subscribers see its terminal.
const subscription = yield* runtime.subscribeEvents!;
yield* Queue.offer((yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!, {
type: "turn.terminal",
driver: CODEX_DRIVER,
providerThreadId: providerThread.id,
providerTurnId: idAllocator.derive.providerTurn({
driver: CODEX_DRIVER,
nativeTurnId: `native-turn-${delivery}`,
}),
runOrdinal: 1,
status: "completed",
failure: null,
threadDisposition: "reusable",
});
yield* subscription.events.pipe(Stream.runHead);
}

yield* TestClock.adjust("1 second");
assert.equal(yield* Queue.take(unloads), "archive:native-delivered");
if (archive !== "succeeds") {
if (archive === "hangs") yield* TestClock.adjust("10 seconds");
// An archive that never finished may have left the thread loaded.
assert.equal(yield* Queue.take(unloads), "unload:native-delivered");
return;
}
// The pending child stays loaded, and keeps the session open, until its parent takes the result.
yield* eventSink.write({ events: [deliverPending!] });
yield* TestClock.adjust("1 second");
assert.equal(yield* Queue.take(unloads), "archive:native-pending");
if (!keptOpen) assert.equal(yield* Queue.take(unloads), "close");
});

yield* effect.pipe(
Effect.provide(
layerTest({
state,
idleTimeoutMs: 1000,
unloads,
...(archive === "fails"
? { archive: unimplemented("archive refused") }
: archive === "hangs"
? { archive: Effect.never }
: {}),
// Another thread's work keeps the shared session open past the children's idle unloads.
hasPendingBackgroundWork: Effect.succeed(keptOpen),
}),
),
);
}),
);

it.effect(
"ProviderSessionManagerV2 re-attaching a thread waits for its in-flight unload, then reloads it",
() =>
Expand Down
83 changes: 78 additions & 5 deletions apps/server/src/orchestration-v2/ProviderSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,11 +1216,19 @@ export const layerWithOptions = (
// Capture runtime identity before yielding; replacements can reuse the session id.
const probedRuntime = entry.runtime;
const hasPendingWork =
probedRuntime.hasPendingBackgroundWork === undefined
(probedRuntime.hasPendingBackgroundWork === undefined
? false
: yield* probedRuntime.hasPendingBackgroundWork.pipe(
Effect.catchCause(() => Effect.succeed(false)),
);
)) ||
// Closing now would skip the archive of a child whose parent has not taken its result.
Option.isSome(
yield* Effect.findFirst(entry.idleThreadUnloads.keys(), (threadId) =>
delegatedChildArchive(threadId).pipe(
Effect.map((child) => child === "awaiting_delivery"),
),
),
);
if (hasPendingWork) {
const now = yield* Clock.currentTimeMillis;
const pinnedSinceMs = entry.pinnedSinceMs ?? now;
Expand Down Expand Up @@ -1255,6 +1263,18 @@ export const layerWithOptions = (
pinnedForMs: now - pinnedSinceMs,
});
}
// Closing skips the threads' own idle unloads, so archive delivered children first.
yield* Effect.forEach(
entry.idleThreadUnloads,
([threadId, pending]) =>
unloadIdleThread({
providerSessionId: input.providerSessionId,
threadId,
generation: pending.generation,
archiveOnly: true,
}),
{ discard: true },
);
// Adapter probes yield; guard busyTurns and idleGeneration atomically when removing the entry.
yield* releaseEntry({
providerSessionId: input.providerSessionId,
Expand Down Expand Up @@ -1672,11 +1692,44 @@ export const layerWithOptions = (
},
);

// Provider clients list a delegated child as an ordinary conversation, so a
// finished one is archived once its parent has the result.
const delegatedChildArchive = Effect.fn("ProviderSessionManager.delegatedChildArchive")(
function* (threadId: ThreadId) {
const { thread, runs } = yield* projectionStore.getThreadRecords(threadId, ["runs"], {
runStatuses: [],
includeLatestRun: true,
});
// Follow-up tasks name their run; the initial task is the node the child forked from.
const taskId =
runs.at(-1)?.delegatedTaskId ??
(thread.forkedFrom?.type === "node" ? thread.forkedFrom.nodeId : undefined);
const parentThreadId = thread.lineage.parentThreadId;
if (parentThreadId === null || taskId === undefined) return undefined;
const task = (yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], {
subagentIds: [taskId],
})).subagents[0];
if (
task?.origin !== "app_owned" ||
task.childThreadId !== threadId ||
task.status !== "completed"
) {
return undefined;
}
const delivery = task.completionDelivery?.state;
if (delivery === "pending" || delivery === "claimed") return "awaiting_delivery";
return delivery === "delivered" || delivery === "acknowledged" ? "ready" : undefined;
},
(effect) => effect.pipe(Effect.orElseSucceed(() => undefined)),
);

const unloadIdleThread = Effect.fn("ProviderSessionManager.unloadIdleThread")(
function* (input: {
readonly providerSessionId: ProviderSessionId;
readonly threadId: ThreadId;
readonly generation: number;
/** Session release unloads only the threads it would archive, and never waits. */
readonly archiveOnly?: boolean;
}): Effect.fn.Return<void> {
const outcome = yield* threadAttachment.withLock(
threadAttachmentKey(input),
Expand All @@ -1703,6 +1756,11 @@ export const layerWithOptions = (
.hasPendingBackgroundWorkForThread(pending.providerThread)
.pipe(Effect.catchCause(() => Effect.succeed(false)));
if (hasPendingWork) return "deferred" as const;
const child = yield* delegatedChildArchive(input.threadId);
// Unloading now would miss the archive once the parent takes the result.
if (child === "awaiting_delivery") return "deferred" as const;
const archive = child === "ready";
if (input.archiveOnly === true && !archive) return "skipped" as const;
const unloading = yield* Ref.modify(sessions, (current) => {
const latest = current.get(key);
if (
Expand All @@ -1726,8 +1784,23 @@ export const layerWithOptions = (
return [true, updated] as const;
});
if (!unloading) return "skipped" as const;
yield* unloadThread({ providerThread: pending.providerThread }).pipe(
Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS),
const unload = (archiveThread: boolean) =>
unloadThread({
providerThread: pending.providerThread,
archive: archiveThread,
}).pipe(Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS));
yield* unload(archive).pipe(
// A failed or timed-out archive may have left the thread loaded.
Effect.catch((cause) =>
archive
? Effect.logWarning("orchestration-v2.driver-session.idle-archive-failed", {
providerSessionId: input.providerSessionId,
threadId: input.threadId,
providerThreadId: pending.providerThread.id,
cause,
}).pipe(Effect.andThen(unload(false)))
: Effect.fail(cause),
),
Effect.tap(() =>
Ref.update(sessions, (current) => {
const latest = current.get(key);
Expand All @@ -1750,7 +1823,7 @@ export const layerWithOptions = (
}),
);
// Wait outside the lock so the next attach is not blocked.
if (outcome === "deferred") {
if (outcome === "deferred" && input.archiveOnly !== true) {
yield* Effect.sleep(Duration.millis(idleTimeoutMs));
return yield* unloadIdleThread(input);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/ProviderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,8 @@ export interface ProviderAdapterV2SessionRuntime {
}) => Effect.Effect<void | ProviderAdapterV2SubagentOutcome, ProviderAdapterV2Error>;
readonly unloadThread?: (input: {
readonly providerThread: OrchestrationV2ProviderThread;
/** Also hide it from the provider's own clients where it can; resuming it must restore it. */
readonly archive?: boolean;
}) => Effect.Effect<void, ProviderAdapterV2Error>;
readonly respondToRuntimeRequest: (
input: ProviderAdapterV2RuntimeRequestResponseInput,
Expand Down
Loading