From aba629625322f40b4252c8a2fd0287538dc02edf Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:57:52 +0000 Subject: [PATCH 01/11] fix(codex): finished delegated children leave Codex's thread list T3 delegated children run as ordinary Codex threads, so every completed child stayed in Codex's active thread list and crowded other Codex clients (#29778). Codex has no supported way to mark a thread as a child. Sessions now archive an app-owned delegated child natively when it idles out, once its latest task completed, the parent has the result, and nothing in the child still runs, queues, or waits. A child whose result reaches the parent after its own idle unload is archived when the shared session idles out. Archiving drops the loaded-thread cache, so a follow-up resumes, and Codex unarchives, the same native thread. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.ts | 20 ++ .../ProviderSessionManager.test.ts | 259 ++++++++++++++++++ .../ProviderSessionManager.ts | 195 ++++++++++++- docs/user/thread-sidebar.md | 5 + .../src/server/ProviderAdapter.ts | 7 + 5 files changed, 485 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 07c4e4e72eca7..d71fd4d44a9d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7329,6 +7329,26 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), ), + // Archived threads leave the active Codex thread list; resumeNativeThread unarchives them. + archiveThread: Effect.fn("CodexAdapterV2.archiveThread")( + function* (archiveInput) { + const nativeThreadId = yield* getNativeThreadId(archiveInput.providerThread); + sessionApprovals.delete(nativeThreadId); + yield* client.request("thread/archive", { threadId: nativeThreadId }); + }, + (effect, archiveInput) => + effect.pipe( + Effect.mapError((cause) => + cause._tag === "ProviderAdapterProtocolError" + ? cause + : new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: `Failed to archive Codex thread for provider thread ${archiveInput.providerThread.id}`, + cause, + }), + ), + ), + ), interruptTurn: Effect.fn("CodexAdapterV2.interruptTurn")( function* (requestedInput) { const nativeThreadId = requestedInput.providerThread.nativeThreadRef?.nativeId; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index d5556128ce796..5b129b370dfef 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -11,11 +11,15 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, + type OrchestrationV2Run, + type OrchestrationV2Subagent, type Project, ProjectId, ProviderDriverKind, ProviderInstanceId, type ProviderSessionId, + MessageId, + NodeId, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -439,6 +443,8 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; + /** Offers `unload:` and `archive:`; also gives the runtime archiveThread. */ + readonly nativeThreadExits?: Queue.Queue; } = {}, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; @@ -547,7 +553,28 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( ], })), ), + Effect.andThen( + options.nativeThreadExits === undefined + ? Effect.void + : Queue.offer( + options.nativeThreadExits, + `unload:${providerThread.nativeThreadRef?.nativeId}`, + ), + ), ), + ...(options.nativeThreadExits === undefined + ? {} + : { + archiveThread: ({ + providerThread, + }: { + providerThread: OrchestrationV2ProviderThread; + }) => + Queue.offer( + options.nativeThreadExits!, + `archive:${providerThread.nativeThreadRef?.nativeId}`, + ).pipe(Effect.asVoid), + }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"), rollbackThread: () => unimplemented("rollbackThread unused in test"), @@ -587,6 +614,7 @@ function layerTest(input: { readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; + readonly nativeThreadExits?: Queue.Queue; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { @@ -625,6 +653,9 @@ function layerTest(input: { ...(input.scopeCloseReached === undefined ? {} : { scopeCloseReached: input.scopeCloseReached }), + ...(input.nativeThreadExits === undefined + ? {} + : { nativeThreadExits: input.nativeThreadExits }), }).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), ); const layerConfiguredMcpRegistry = @@ -3905,6 +3936,234 @@ it.effect( }), ); +it.effect( + "ProviderSessionManagerV2 archives a delegated child only once its parent took its result", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const exits = yield* Queue.unbounded(); + 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 parentId = ThreadId.make("thread-provider-session-manager-archive-parent"); + const parentRunId = idAllocator.derive.run({ threadId: parentId, ordinal: 1 }); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + const parentThread = { + ...makeProviderThread({ idAllocator, threadId: parentId, providerSessionId, now }), + nativeThreadRef: null, + }; + const task = ( + childThreadId: ThreadId, + delivery: "pending" | "acknowledged" | "delivered", + ): OrchestrationV2Subagent => ({ + id: NodeId.make(`node-task-${childThreadId}`), + threadId: parentId, + runId: parentRunId, + parentNodeId: idAllocator.derive.rootNode({ runId: parentRunId }), + origin: "app_owned", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Implement it.", + title: null, + model: null, + completionDelivery: { state: delivery, observedByRunId: null }, + status: "completed", + result: "Done.", + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const taskEvent = Effect.fnUntraced(function* (payload: OrchestrationV2Subagent) { + return { + id: yield* idAllocator.allocate.event({ threadId: parentId }), + type: "subagent.updated" as const, + threadId: parentId, + runId: parentRunId, + nodeId: payload.id, + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload, + }; + }); + const makeChild = Effect.fnUntraced(function* ( + name: string, + delivery: "pending" | "acknowledged", + ) { + const threadId = ThreadId.make(`thread-provider-session-manager-archive-${name}`); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${name}`, + }); + const created = yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + delegatedBy: parentId, + }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const run: OrchestrationV2Run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: providerThread.id, + userMessageId: MessageId.make(`message-${name}`), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + delegatedTaskId: task(threadId, delivery).id, + }; + yield* eventSink.write({ + events: [ + { + ...created, + payload: { ...created.payload, activeProviderThreadId: providerThread.id }, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload: providerThread, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: run, + }, + yield* taskEvent(task(threadId, delivery)), + ], + }); + return { threadId, providerThread }; + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now })], + }); + const delivered = yield* makeChild("delivered", "acknowledged"); + const late = yield* makeChild("late", "pending"); + const runtime = yield* manager.open({ + threadId: parentId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + for (const child of [delivered, late]) { + yield* manager.open({ + threadId: child.threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + } + const resumeDelivered = runtime.resumeThread({ + providerThread: delivered.providerThread, + threadId: delivered.threadId, + modelSelection, + runtimePolicy, + }); + yield* resumeDelivered; + const startTurn = Effect.fnUntraced(function* ( + threadId: ThreadId, + providerThread: OrchestrationV2ProviderThread, + ) { + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + 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: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }); + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!; + // The manager has scheduled its idle work once a subscriber sees the terminal. + const endTurn = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + const subscription = yield* runtime.subscribeEvents!; + yield* Queue.offer(queue, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThread.id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: `turn-${providerThread.id}`, + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* subscription.events.pipe(Stream.runHead); + }); + yield* startTurn(parentId, parentThread); + for (const child of [delivered, late]) { + yield* startTurn(child.threadId, child.providerThread); + yield* endTurn(child.providerThread); + } + + // The busy parent keeps the session; each child reaches its own idle unload. + yield* TestClock.adjust("1 second"); + const idleExits = [yield* Queue.take(exits), yield* Queue.take(exits)].sort(); + assert.deepEqual(idleExits, ["archive:native-delivered", "unload:native-late"]); + // Archiving dropped the loaded cache, so reuse resumes (and unarchives) natively. + yield* resumeDelivered; + assert.equal((yield* Ref.get(state)).resumeCount, 2); + + // The late result lands after its child unloaded; the session's idle release archives it. + yield* eventSink.write({ + events: [yield* taskEvent(task(late.threadId, "delivered"))], + }); + yield* endTurn(parentThread); + yield* TestClock.adjust("1 second"); + assert.deepEqual( + [yield* Queue.take(exits), yield* Queue.take(exits)], + ["archive:native-delivered", "archive:native-late"], + ); + }); + + yield* effect.pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 1000, nativeThreadExits: exits })), + ); + }), +); + it.effect( "ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f06011676c667..0129a061e8633 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -3,6 +3,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { isOrchestrationV2WorkActive, + isProviderNativeSubagentThread, ModelSelection, OrchestrationV2DomainEvent, OrchestrationV2ProviderSession, @@ -70,6 +71,12 @@ const WORKING_RUN_STATUSES: ReadonlySet = new Set([ "running", "waiting", ]); +const SETTLED_TASK_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); export const ProviderSessionReleaseReason = Schema.Literals([ "idle_timeout", @@ -233,6 +240,8 @@ interface LiveSessionEntry { readonly idleFiber: Fiber.Fiber | null; readonly pinnedSinceMs: number | null; readonly idleThreadUnloads: ReadonlyMap; + /** Archived natively since they last loaded, so a later idle release skips them. */ + readonly archivedThreadIds: ReadonlySet; } interface IdleThreadUnload { @@ -1168,6 +1177,7 @@ export const layerWithOptions = ( pinnedForMs: now - pinnedSinceMs, }); } + yield* archiveSettledDelegatedChildren(input.providerSessionId); // Adapter probes yield; guard busyTurns and idleGeneration atomically when removing the entry. yield* releaseEntry({ providerSessionId: input.providerSessionId, @@ -1324,8 +1334,10 @@ export const layerWithOptions = ( } const loadedProviderThreadKeyByThread = new Map(entry.loadedProviderThreadKeyByThread); loadedProviderThreadKeyByThread.set(input.threadId, input.providerThreadKey); + const archivedThreadIds = new Set(entry.archivedThreadIds); + archivedThreadIds.delete(input.threadId); const updated = new Map(current); - updated.set(key, { ...entry, loadedProviderThreadKeyByThread }); + updated.set(key, { ...entry, loadedProviderThreadKeyByThread, archivedThreadIds }); return updated; }); @@ -1562,6 +1574,172 @@ export const layerWithOptions = ( }, ); + /** + * The provider thread of an app-owned delegated child whose latest task completed and whose + * result its parent took, once nothing in the child runs or waits. Provider clients list + * these as ordinary threads; T3 keeps their history and resume unarchives them. + */ + const settledDelegatedChildProviderThread = Effect.fn( + "ProviderSessionManager.settledDelegatedChildProviderThread", + )( + function* (entry: LiveSessionEntry, threadId: ThreadId) { + const thread = yield* projectionStore.getThread(threadId); + const parentThreadId = thread.lineage.parentThreadId; + if ( + thread.lineage.relationshipToParent !== "subagent" || + parentThreadId === null || + isProviderNativeSubagentThread(thread) || + thread.deletedAt !== null + ) { + return undefined; + } + const child = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "subagents", "providerThreads"], + { + runStatuses: ["preparing", "queued", "starting", "running", "waiting"], + includeLatestRun: true, + }, + ); + const latestRun = child.runs.reduce<(typeof child.runs)[number] | undefined>( + (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest), + undefined, + ); + const taskId = latestRun?.delegatedTaskId; + const providerThread = child.providerThreads.find( + (candidate) => + candidate.id === thread.activeProviderThreadId && + candidate.providerSessionId === entry.runtime.providerSessionId && + candidate.nativeThreadRef?.nativeId != null, + ); + if ( + taskId === undefined || + providerThread === undefined || + child.runs.some((run) => run.status !== "completed") || + child.subagents.some( + (task) => + !SETTLED_TASK_STATUSES.has(task.status) || + task.completionDelivery?.state === "pending" || + task.completionDelivery?.state === "claimed", + ) || + ((yield* projectionStore.getThreadShell(threadId))?.pendingBackgroundTasks?.length ?? + 0) > 0 + ) { + return undefined; + } + const parent = yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], { + subagentIds: [taskId], + }); + const task = parent.subagents.find((candidate) => candidate.id === taskId); + const delivery = task?.completionDelivery?.state; + if ( + task?.origin !== "app_owned" || + task.childThreadId !== threadId || + task.status !== "completed" || + (delivery !== "delivered" && delivery !== "acknowledged") + ) { + return undefined; + } + // Codex archive also archives the native subagents this thread spawned. + const pendingWork = + entry.runtime.hasPendingBackgroundWorkForThread === undefined + ? false + : yield* entry.runtime.hasPendingBackgroundWorkForThread(providerThread); + return pendingWork ? undefined : providerThread; + }, + (effect) => effect.pipe(Effect.orElseSucceed(() => undefined)), + ); + + /** Callers hold the thread's attachment lock and have already dropped it from the loaded cache. */ + const archiveProviderThread = Effect.fn("ProviderSessionManager.archiveProviderThread")( + function* (input: { + readonly entry: LiveSessionEntry; + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + }) { + const archiveThread = input.entry.exposedRuntime.archiveThread; + if (archiveThread === undefined) return false; + const archived = yield* archiveThread({ providerThread: input.providerThread }).pipe( + Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.archive-failed", { + providerSessionId: input.entry.runtime.providerSessionId, + threadId: input.threadId, + providerThreadId: input.providerThread.id, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (!archived) return false; + yield* Ref.update(sessions, (current) => { + const key = sessionKey(input.entry.runtime.providerSessionId); + const latest = current.get(key); + if (latest?.runtime !== input.entry.runtime) return current; + const updated = new Map(current); + updated.set(key, { + ...latest, + archivedThreadIds: new Set(latest.archivedThreadIds).add(input.threadId), + }); + return updated; + }); + return true; + }, + ); + + // Archives children whose result reached the parent after their own idle unload. + const archiveSettledDelegatedChildren = Effect.fn( + "ProviderSessionManager.archiveSettledDelegatedChildren", + )(function* (providerSessionId: ProviderSessionId) { + const key = sessionKey(providerSessionId); + const entry = (yield* Ref.get(sessions)).get(key); + if ( + entry === undefined || + !entry.supportsMultipleProviderThreads || + entry.exposedRuntime.archiveThread === undefined + ) { + return; + } + yield* Effect.forEach( + entry.attachedThreadIds, + (threadId) => + threadAttachment.withLock( + threadAttachmentKey({ providerSessionId, threadId }), + Effect.gen(function* () { + if (entry.archivedThreadIds.has(threadId)) return; + const providerThread = yield* settledDelegatedChildProviderThread(entry, threadId); + if (providerThread === undefined) return; + const claimed = yield* Ref.modify(sessions, (current) => { + const latest = current.get(key); + if ( + latest?.runtime !== entry.runtime || + latest.busyTurns.size > 0 || + !latest.attachedThreadIds.has(threadId) || + latest.archivedThreadIds.has(threadId) + ) { + return [false, current] as const; + } + const loadedProviderThreadKeyByThread = new Map( + latest.loadedProviderThreadKeyByThread, + ); + loadedProviderThreadKeyByThread.delete(threadId); + const idleThreadUnloads = new Map(latest.idleThreadUnloads); + idleThreadUnloads.delete(threadId); + const updated = new Map(current); + updated.set(key, { + ...latest, + loadedProviderThreadKeyByThread, + idleThreadUnloads, + }); + return [true, updated] as const; + }); + if (claimed) yield* archiveProviderThread({ entry, threadId, providerThread }); + }), + ), + { discard: true }, + ); + }); + const unloadIdleThread = Effect.fn("ProviderSessionManager.unloadIdleThread")( function* (input: { readonly providerSessionId: ProviderSessionId; @@ -1593,6 +1771,10 @@ export const layerWithOptions = ( .hasPendingBackgroundWorkForThread(pending.providerThread) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (hasPendingWork) return "deferred" as const; + const archivable = + entry.exposedRuntime.archiveThread !== undefined && + (yield* settledDelegatedChildProviderThread(entry, input.threadId))?.id === + pending.providerThread.id; const unloading = yield* Ref.modify(sessions, (current) => { const latest = current.get(key); if ( @@ -1616,6 +1798,16 @@ export const layerWithOptions = ( return [true, updated] as const; }); if (!unloading) return "skipped" as const; + if ( + archivable && + (yield* archiveProviderThread({ + entry, + threadId: input.threadId, + providerThread: pending.providerThread, + })) + ) { + return "unloaded" as const; + } yield* unloadThread({ providerThread: pending.providerThread }).pipe( Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), Effect.catchCause((cause) => @@ -2253,6 +2445,7 @@ export const layerWithOptions = ( idleFiber: null, pinnedSinceMs: null, idleThreadUnloads: new Map(), + archivedThreadIds: new Set(), }; yield* Ref.update(sessions, (current) => { const updated = new Map(current); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4829fa3c6b7b5..366882c5ac7b4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -269,6 +269,11 @@ layouts with an inspector, its thread opens beside the parent. Maximize it to fill the screen; close the full-screen view to return to the parent and inspector. Stop on a thread also stops the subagents it delegated to. +With Codex, a delegated task's thread is archived in Codex's own history once the +task has completed, the parent agent has its result, and the thread has sat idle. +This keeps those threads out of other Codex apps' thread lists. T3 Code keeps the +full history, and a follow-up to the task continues the same Codex conversation. + Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent thread asks for it. diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9ea7597e6d087..179ccfa9b23e4 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -674,6 +674,13 @@ export interface ProviderAdapterV2SessionRuntime { readonly unloadThread?: (input: { readonly providerThread: OrchestrationV2ProviderThread; }) => Effect.Effect; + /** + * Unloads the thread and hides it from the provider's own clients. `resumeThread` must + * restore it under the same native id. + */ + readonly archiveThread?: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + }) => Effect.Effect; readonly respondToRuntimeRequest: ( input: ProviderAdapterV2RuntimeRequestResponseInput, ) => Effect.Effect; From 4cd0b9485c9f4f9d954e654649a3c67406bf8e54 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:16:23 +0000 Subject: [PATCH 02/11] fix(codex): delegated children archive safely after late result delivery Archiving a Codex delegated child also archives the native subagents it spawned. The pending-work probe found those only through a root turn still held in memory, so a grandchild still running under a finished direct child went unseen and archive would stop it. Replacement checks (includeSubagents) now follow each live turn's ancestry back to the thread. A child whose parent takes the result after the child's session idles out lost its cleanup when the session released first. The idle release now pins the session, within the existing background-work pin budget, while an attached app-owned child's completed result awaits delivery, then archives it once delivered. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 126 +++++++++++ .../Adapters/CodexAdapterV2.ts | 21 +- .../ProviderSessionManager.test.ts | 195 +++++++++++++++++- .../ProviderSessionManager.ts | 57 +++-- 4 files changed, 375 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 84c78b91453c2..7068384dc8dbd 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7401,6 +7401,132 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("counts a running grandchild after its root and direct child settled", () => + Effect.scoped( + Effect.gen(function* () { + const grandchildThread = "native-codex-resume-grandchild-thread"; + const grandchildTurn = "native-codex-resume-grandchild-turn"; + // Notifications run in order, so the model update fences everything before it. + const fence = (model: string): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + frame: { + method: "thread/settings/updated", + params: { + threadId: grandchildThread, + threadSettings: { + model, + effort: "low", + serviceTier: null, + modelProvider: "openai", + cwd: "/workspace", + approvalPolicy: "never", + approvalsReviewer: "auto_review", + collaborationMode: { mode: "default", settings: { model } }, + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + }, + }); + const { spawned, rootFinished } = lateChildPrefix(); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...spawned, + { + type: "emit_inbound", + frame: { + method: "item/completed", + params: { + item: { + type: "subAgentActivity", + id: "call-codex-grandchild-spawn", + kind: "started", + agentThreadId: grandchildThread, + agentPath: "/root/resume_agent/grandchild", + }, + threadId: RESUME_CHILD_THREAD, + turnId: RESUME_CHILD_TURN_1, + completedAtMs: 1782622441500, + }, + }, + }, + { + type: "emit_inbound", + frame: { + method: "turn/started", + params: { + threadId: grandchildThread, + turn: makeCodexReplayTurn({ id: grandchildTurn, status: "inProgress" }), + }, + }, + }, + ...rootFinished, + childAgentMessage({ + id: "child-answer", + text: "CHILD_DONE", + turnId: RESUME_CHILD_TURN_1, + completedAtMs: 1782622470000, + afterMs: 30_000, + }), + childTurnCompleted(RESUME_CHILD_TURN_1), + fence("gpt-fence-child-settled"), + { + type: "emit_inbound", + afterMs: 30_000, + frame: { + method: "turn/completed", + params: { + threadId: grandchildThread, + turn: makeCodexReplayTurn({ id: grandchildTurn, status: "completed" }), + }, + }, + }, + fence("gpt-fence-grandchild-settled"), + ], + }), + ); + const fenced = (model: string) => + harness.until(() => + harness.subagentUpdates().some((event) => event.subagent.model === model), + ); + const ownsRunningWork = harness.runtime.hasPendingBackgroundWorkForThread!( + harness.providerThread, + { includeSubagents: true }, + ); + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-settled-child-grandchild"), + text: RESUME_PROMPT, + }), + ); + yield* harness.awaitTerminal(1); + + yield* TestClock.adjust("30 seconds"); + yield* fenced("gpt-fence-child-settled"); + assert.include(harness.continuationRequests[0]?.detail, "CHILD_DONE"); + assert.isTrue(yield* ownsRunningWork); + + yield* TestClock.adjust("30 seconds"); + yield* fenced("gpt-fence-grandchild-settled"); + assert.isFalse(yield* ownsRunningWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("leaves a late child's result to the root's next turn when one is running", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d71fd4d44a9d4..b8ddeead03ebe 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7073,7 +7073,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), hasPendingBackgroundWorkForThread: Effect.fn( "CodexAdapterV2.hasPendingBackgroundWorkForThread", - )(function* (providerThread) { + )(function* (providerThread, options) { const contexts = [ ...(yield* Ref.get(activeTurns)).values(), ...(yield* Ref.get(settledTurns)).values(), @@ -7081,9 +7081,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const roots = contexts.filter( (context) => context.providerThread.id === providerThread.id, ); + // Settled ancestors leave these maps, so replacement checks walk each live turn's lineage. + const owned = (context: ActiveCodexTurnContext) => { + if (options?.includeSubagents !== true) { + return roots.some( + (root) => context === root || isDescendantCodexTurn(context, root), + ); + } + for ( + let ancestor: ActiveCodexTurnContext | undefined = context; + ancestor !== undefined; + ancestor = ancestor.subagent?.parentContext + ) { + if (ancestor.providerThread.id === providerThread.id) return true; + } + return false; + }; for (const context of contexts) { - if (!roots.some((root) => context === root || isDescendantCodexTurn(context, root))) - continue; + if (!owned(context)) continue; if (yield* turnHasRetainedBackgroundWork(context.nativeTurnId)) return true; if ( context.subagent !== null && diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 5b129b370dfef..c96014e4fdc26 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -443,7 +443,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; - /** Offers `unload:` and `archive:`; also gives the runtime archiveThread. */ + /** Offers `unload:`, `archive:` and `close`; also gives the runtime archiveThread. */ readonly nativeThreadExits?: Queue.Queue; } = {}, ) { @@ -452,7 +452,13 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( Ref.update(state, (current) => ({ ...current, closeCount: current.closeCount + 1, - })), + })).pipe( + Effect.andThen( + options.nativeThreadExits === undefined + ? Effect.void + : Queue.offer(options.nativeThreadExits, "close"), + ), + ), ); // Register after countClose to wedge teardown before its finalizer, matching a provider stream that never yields. const hangClose = Effect.addFinalizer(() => @@ -4152,9 +4158,10 @@ it.effect( }); yield* endTurn(parentThread); yield* TestClock.adjust("1 second"); + // Each child archives once before the session closes. assert.deepEqual( - [yield* Queue.take(exits), yield* Queue.take(exits)], - ["archive:native-delivered", "archive:native-late"], + [yield* Queue.take(exits), yield* Queue.take(exits), yield* Queue.take(exits)], + ["archive:native-delivered", "archive:native-late", "close"], ); }); @@ -4164,6 +4171,186 @@ it.effect( }), ); +it.effect( + "ProviderSessionManagerV2 keeps a delegated child's session until its parent takes the result", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const exits = yield* Queue.unbounded(); + 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; + // The parent runs elsewhere, so nothing else keeps the child's session alive. + const parentId = ThreadId.make("thread-provider-session-manager-remote-parent"); + const parentRunId = idAllocator.derive.run({ threadId: parentId, ordinal: 1 }); + const threadId = ThreadId.make("thread-provider-session-manager-awaiting-child"); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-awaiting-child", + }); + const taskId = NodeId.make("node-task-awaiting-child"); + const taskEvent = Effect.fnUntraced(function* (delivery: "pending" | "delivered") { + return { + id: yield* idAllocator.allocate.event({ threadId: parentId }), + type: "subagent.updated" as const, + threadId: parentId, + runId: parentRunId, + nodeId: taskId, + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: taskId, + threadId: parentId, + 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: "Implement it.", + title: null, + model: null, + completionDelivery: { state: delivery, observedByRunId: null }, + status: "completed", + result: "Done.", + startedAt: now, + completedAt: now, + updatedAt: now, + } satisfies OrchestrationV2Subagent, + }; + }); + const created = yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + delegatedBy: parentId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now }), + { + ...created, + payload: { ...created.payload, activeProviderThreadId: providerThread.id }, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload: providerThread, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: providerThread.id, + userMessageId: MessageId.make("message-awaiting-child"), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + delegatedTaskId: taskId, + } satisfies OrchestrationV2Run, + }, + yield* taskEvent("pending"), + ], + }); + 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: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + // The manager has scheduled its idle release once a subscriber sees the 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: "turn-awaiting-child", + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* subscription.events.pipe(Stream.runHead); + + // The child unloads without archiving, and its result's pending delivery pins the session. + yield* TestClock.adjust("1 second"); + assert.deepEqual([yield* Queue.take(exits), yield* Queue.take(exits)].sort(), [ + "probe", + "unload:native-awaiting-child", + ]); + // A busy parent takes the result after the idle timeout; the pinned session archives it. + yield* eventSink.write({ events: [yield* taskEvent("delivered")] }); + yield* TestClock.adjust("1 second"); + for (const exit of ["probe", "archive:native-awaiting-child", "close"]) { + assert.equal(yield* Queue.take(exits), exit); + } + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + nativeThreadExits: exits, + hasPendingBackgroundWork: Queue.offer(exits, "probe").pipe(Effect.as(false)), + }), + ), + ); + }), +); + it.effect( "ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 0129a061e8633..8adca0d7152ba 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1137,12 +1137,13 @@ export const layerWithOptions = ( } // Capture runtime identity before yielding; replacements can reuse the session id. const probedRuntime = entry.runtime; + // A child's late result can archive it only while this session lives, so it pins like work. const hasPendingWork = - probedRuntime.hasPendingBackgroundWork === undefined + (probedRuntime.hasPendingBackgroundWork === undefined ? false : yield* probedRuntime.hasPendingBackgroundWork.pipe( Effect.catchCause(() => Effect.succeed(false)), - ); + )) || (yield* delegatedChildAwaitsDelivery(entry)); if (hasPendingWork) { const now = yield* Clock.currentTimeMillis; const pinnedSinceMs = entry.pinnedSinceMs ?? now; @@ -1575,13 +1576,11 @@ export const layerWithOptions = ( ); /** - * The provider thread of an app-owned delegated child whose latest task completed and whose - * result its parent took, once nothing in the child runs or waits. Provider clients list - * these as ordinary threads; T3 keeps their history and resume unarchives them. + * An app-owned delegated child is ready to archive once its latest task completed, its parent + * took the result, and nothing in the child runs or waits. Provider clients list these as + * ordinary threads; T3 keeps their history and resume unarchives them. */ - const settledDelegatedChildProviderThread = Effect.fn( - "ProviderSessionManager.settledDelegatedChildProviderThread", - )( + const delegatedChildArchive = Effect.fn("ProviderSessionManager.delegatedChildArchive")( function* (entry: LiveSessionEntry, threadId: ThreadId) { const thread = yield* projectionStore.getThread(threadId); const parentThreadId = thread.lineage.parentThreadId; @@ -1635,17 +1634,22 @@ export const layerWithOptions = ( if ( task?.origin !== "app_owned" || task.childThreadId !== threadId || - task.status !== "completed" || - (delivery !== "delivered" && delivery !== "acknowledged") + task.status !== "completed" ) { return undefined; } + if (delivery === "pending" || delivery === "claimed") { + return { state: "awaiting_delivery" } as const; + } + if (delivery !== "delivered" && delivery !== "acknowledged") return undefined; // Codex archive also archives the native subagents this thread spawned. const pendingWork = entry.runtime.hasPendingBackgroundWorkForThread === undefined ? false - : yield* entry.runtime.hasPendingBackgroundWorkForThread(providerThread); - return pendingWork ? undefined : providerThread; + : yield* entry.runtime.hasPendingBackgroundWorkForThread(providerThread, { + includeSubagents: true, + }); + return pendingWork ? undefined : { state: "ready" as const, providerThread }; }, (effect) => effect.pipe(Effect.orElseSucceed(() => undefined)), ); @@ -1687,6 +1691,22 @@ export const layerWithOptions = ( }, ); + const delegatedChildAwaitsDelivery = Effect.fn( + "ProviderSessionManager.delegatedChildAwaitsDelivery", + )(function* (entry: LiveSessionEntry) { + if ( + !entry.supportsMultipleProviderThreads || + entry.exposedRuntime.archiveThread === undefined + ) + return false; + for (const threadId of entry.attachedThreadIds) { + if (entry.archivedThreadIds.has(threadId)) continue; + if ((yield* delegatedChildArchive(entry, threadId))?.state === "awaiting_delivery") + return true; + } + return false; + }); + // Archives children whose result reached the parent after their own idle unload. const archiveSettledDelegatedChildren = Effect.fn( "ProviderSessionManager.archiveSettledDelegatedChildren", @@ -1707,8 +1727,9 @@ export const layerWithOptions = ( threadAttachmentKey({ providerSessionId, threadId }), Effect.gen(function* () { if (entry.archivedThreadIds.has(threadId)) return; - const providerThread = yield* settledDelegatedChildProviderThread(entry, threadId); - if (providerThread === undefined) return; + const child = yield* delegatedChildArchive(entry, threadId); + if (child?.state !== "ready") return; + const { providerThread } = child; const claimed = yield* Ref.modify(sessions, (current) => { const latest = current.get(key); if ( @@ -1771,10 +1792,12 @@ export const layerWithOptions = ( .hasPendingBackgroundWorkForThread(pending.providerThread) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (hasPendingWork) return "deferred" as const; + const child = + entry.exposedRuntime.archiveThread === undefined + ? undefined + : yield* delegatedChildArchive(entry, input.threadId); const archivable = - entry.exposedRuntime.archiveThread !== undefined && - (yield* settledDelegatedChildProviderThread(entry, input.threadId))?.id === - pending.providerThread.id; + child?.state === "ready" && child.providerThread.id === pending.providerThread.id; const unloading = yield* Ref.modify(sessions, (current) => { const latest = current.get(key); if ( From a1fb5b7eeed6cb50f7c489d4a717660c67d47320 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:24:30 +0000 Subject: [PATCH 03/11] fix(codex): keep pending descendants out of archive cleanup Count native descendants between spawn and their first turn, and extend the existing regression through pending, running and completed states. Document cleanup of older completed sessions and provide the crypto service needed by the imported archived-fork regression on current Effect. Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- .../Adapters/CodexAdapterV2.test.ts | 28 +++++++++++-------- .../Adapters/CodexAdapterV2.ts | 7 +++++ docs/user/thread-sidebar.md | 4 +++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 7068384dc8dbd..62fa91e4c89c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7401,7 +7401,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("counts a running grandchild after its root and direct child settled", () => + it.effect("counts a pending or running grandchild after its root and direct child settled", () => Effect.scoped( Effect.gen(function* () { const grandchildThread = "native-codex-resume-grandchild-thread"; @@ -7451,16 +7451,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - { - type: "emit_inbound", - frame: { - method: "turn/started", - params: { - threadId: grandchildThread, - turn: makeCodexReplayTurn({ id: grandchildTurn, status: "inProgress" }), - }, - }, - }, ...rootFinished, childAgentMessage({ id: "child-answer", @@ -7471,6 +7461,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), childTurnCompleted(RESUME_CHILD_TURN_1), fence("gpt-fence-child-settled"), + { + type: "emit_inbound", + afterMs: 30_000, + frame: { + method: "turn/started", + params: { + threadId: grandchildThread, + turn: makeCodexReplayTurn({ id: grandchildTurn, status: "inProgress" }), + }, + }, + }, + fence("gpt-fence-grandchild-running"), { type: "emit_inbound", afterMs: 30_000, @@ -7511,6 +7513,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(harness.continuationRequests[0]?.detail, "CHILD_DONE"); assert.isTrue(yield* ownsRunningWork); + yield* TestClock.adjust("30 seconds"); + yield* fenced("gpt-fence-grandchild-running"); + assert.isTrue(yield* ownsRunningWork); + yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-grandchild-settled"); assert.isFalse(yield* ownsRunningWork); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index b8ddeead03ebe..93c5e2421f3be 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7097,6 +7097,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } return false; }; + if (options?.includeSubagents === true) { + for (const subagent of (yield* Ref.get(subagentThreads)).values()) { + if (subagent.task.status === "running" && owned(subagent.parentContext)) { + return true; + } + } + } for (const context of contexts) { if (!owned(context)) continue; if (yield* turnHasRetainedBackgroundWork(context.nativeTurnId)) return true; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 366882c5ac7b4..def7f18f0393e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -274,6 +274,10 @@ task has completed, the parent agent has its result, and the thread has sat idle This keeps those threads out of other Codex apps' thread lists. T3 Code keeps the full history, and a follow-up to the task continues the same Codex conversation. +To clean up older entries, wait for the task and its subagents to finish and for +the parent to receive the result. Then run `codex archive ` on the +machine hosting that Codex session. Keep the T3 thread to continue it later. + Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent thread asks for it. From adc0eb4fb0510faf9b6ac90678a0b6e64e83add5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 06:34:44 +0000 Subject: [PATCH 04/11] fix(codex): archive initial delegated task sessions --- .../src/orchestration-v2/ProviderSessionManager.test.ts | 7 +++++-- apps/server/src/orchestration-v2/ProviderSessionManager.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index c96014e4fdc26..1cc014cf018c5 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -4036,13 +4036,16 @@ it.effect( completedAt: now, checkpointId: null, contextHandoffId: null, - delegatedTaskId: task(threadId, delivery).id, }; yield* eventSink.write({ events: [ { ...created, - payload: { ...created.payload, activeProviderThreadId: providerThread.id }, + payload: { + ...created.payload, + activeProviderThreadId: providerThread.id, + forkedFrom: { type: "node", nodeId: task(threadId, delivery).id }, + }, }, { id: yield* idAllocator.allocate.event({ threadId }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 8adca0d7152ba..91da1c2423788 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1604,7 +1604,11 @@ export const layerWithOptions = ( (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest), undefined, ); - const taskId = latestRun?.delegatedTaskId; + const taskId = + latestRun?.delegatedTaskId ?? + (latestRun?.ordinal === 1 && thread.forkedFrom?.type === "node" + ? thread.forkedFrom.nodeId + : undefined); const providerThread = child.providerThreads.find( (candidate) => candidate.id === thread.activeProviderThreadId && From 60db677f915456456aa9e35686d9d7bedafae184 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 06:45:13 +0000 Subject: [PATCH 05/11] fix(codex): keep pending descendants out of archive cleanup --- .../Adapters/CodexAdapterV2.test.ts | 13 +++++++++---- .../src/orchestration-v2/Adapters/CodexAdapterV2.ts | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 62fa91e4c89c7..c7716f544231f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7439,11 +7439,15 @@ describe("CodexAdapterV2 post-settle continuation", () => { method: "item/completed", params: { item: { - type: "subAgentActivity", + type: "collabAgentToolCall", id: "call-codex-grandchild-spawn", - kind: "started", - agentThreadId: grandchildThread, - agentPath: "/root/resume_agent/grandchild", + tool: "spawnAgent", + status: "completed", + senderThreadId: RESUME_CHILD_THREAD, + receiverThreadIds: [grandchildThread], + agentsStates: { + [grandchildThread]: { status: "pendingInit", message: null }, + }, }, threadId: RESUME_CHILD_THREAD, turnId: RESUME_CHILD_TURN_1, @@ -7511,6 +7515,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-child-settled"); assert.include(harness.continuationRequests[0]?.detail, "CHILD_DONE"); + assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "pending"); assert.isTrue(yield* ownsRunningWork); yield* TestClock.adjust("30 seconds"); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 93c5e2421f3be..35d63ae769499 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7099,7 +7099,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }; if (options?.includeSubagents === true) { for (const subagent of (yield* Ref.get(subagentThreads)).values()) { - if (subagent.task.status === "running" && owned(subagent.parentContext)) { + if ( + isOrchestrationV2WorkActive(subagent.task.status) && + owned(subagent.parentContext) + ) { return true; } } From a0f6dd6430b67a75396db2d7ca0dbacdc9c0fc68 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:57:27 +0000 Subject: [PATCH 06/11] fix(codex): retain sessions with pending descendants Count pending and waiting native descendants in the session-wide idle probe, matching the per-thread archive guard. Extend the existing nested-descendant regression to check the session remains busy while pending and clears after completion. --- .../server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts | 2 ++ apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c7716f544231f..191c8aa2ee427 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7517,6 +7517,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(harness.continuationRequests[0]?.detail, "CHILD_DONE"); assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "pending"); assert.isTrue(yield* ownsRunningWork); + assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-grandchild-running"); @@ -7525,6 +7526,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-grandchild-settled"); assert.isFalse(yield* ownsRunningWork); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 35d63ae769499..0154098191486 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7065,7 +7065,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (state.running.size > 0 || state.saved.size > 0) return true; } for (const subagent of (yield* Ref.get(subagentThreads)).values()) { - if (subagent.task.status === "running") { + if (isOrchestrationV2WorkActive(subagent.task.status)) { return true; } } From 45da8d17eed4ea7612b5c5c384a5f4e93ce7f149 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:32:52 +0000 Subject: [PATCH 07/11] fix(codex): settle stopped children and retain failed archive cleanup Settle a rejected late child only on its completion, reopen it on a later native turn, and preserve newer task state against stale completions. Fall back to bounded unload after sweep archive failure while preserving replacement tracking. Co-authored-by: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 187 +++++++++++++++++- .../Adapters/CodexAdapterV2.ts | 21 +- .../ProviderSessionManager.test.ts | 53 +++-- .../ProviderSessionManager.ts | 43 +++- 4 files changed, 282 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 191c8aa2ee427..81b2d12edab91 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5434,17 +5434,70 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const INTERRUPT_PENDING_CHILD_NATIVE_THREAD = "native-codex-interrupt-pending-child-thread"; + const INTERRUPT_RESUMED_ROOT_TURN = "native-codex-interrupt-resumed-root-turn"; + const INTERRUPT_RESUMED_CHILD_TURN = "native-codex-interrupt-resumed-child-turn"; + const INTERRUPT_RESUME_PROMPT = "Continue the stopped child."; + // Notifications run in order, so the model update fences everything before it. + const pendingChildFence = (model: string): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + frame: { + method: "thread/settings/updated", + params: { + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + threadSettings: { + model, + effort: "low", + serviceTier: null, + modelProvider: "openai", + cwd: "/workspace", + approvalPolicy: "never", + approvalsReviewer: "auto_review", + collaborationMode: { mode: "default", settings: { model } }, + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + }, + }); + const childSpawnIndex = interruptSubagentCommandTranscript.entries.findIndex( + (entry) => + entry.type === "emit_inbound" && entry.label === "item/completed/subAgentActivity-started", + ); const interruptSubagentTurnAfterStopTranscript = makeCodexReplayTranscript({ scenario: "codex-interrupt-subagent-turn-after-stop", entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, -1), + ...interruptSubagentCommandTranscript.entries.slice(0, childSpawnIndex + 1), + { + type: "emit_inbound", + label: "item/completed/collab-spawn-pending-child", + frame: { + method: "item/completed", + params: { + item: { + type: "collabAgentToolCall", + id: "call-codex-interrupt-pending-child", + tool: "spawnAgent", + status: "completed", + senderThreadId: INTERRUPT_NATIVE_THREAD, + receiverThreadIds: [INTERRUPT_PENDING_CHILD_NATIVE_THREAD], + agentsStates: { + [INTERRUPT_PENDING_CHILD_NATIVE_THREAD]: { status: "pendingInit", message: null }, + }, + }, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + completedAtMs: 1782622441200, + }, + }, + }, + ...interruptSubagentCommandTranscript.entries.slice(childSpawnIndex + 1, -1), { type: "emit_inbound", label: "turn/started/child-after-stop", frame: { method: "turn/started", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, turn: makeCodexReplayTurn({ id: INTERRUPT_LATE_CHILD_NATIVE_TURN, status: "inProgress", @@ -5459,16 +5512,19 @@ describe("CodexAdapterV2 post-settle continuation", () => { id: 7, method: "turn/interrupt", params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, }, }, }, + pendingChildFence("gpt-fence-interrupt-requested"), { type: "emit_inbound", label: "turn/interrupt/child-after-stop", + afterMs: 4_000, frame: { id: 7, result: {} }, }, + pendingChildFence("gpt-fence-interrupt-acked"), { type: "emit_inbound", label: "thread/goal/updated/root", @@ -5490,6 +5546,82 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, + { + type: "emit_inbound", + label: "turn/completed/child-after-stop", + afterMs: 4_000, + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "interrupted", + }), + }, + }, + }, + pendingChildFence("gpt-fence-late-turn-completed"), + ...codexReplayPreamble({ + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_RESUMED_ROOT_TURN, + prompt: INTERRUPT_RESUME_PROMPT, + }) + .slice(-3) + .map((entry) => + entry.type !== "runtime_exit" && + typeof entry.frame === "object" && + entry.frame !== null && + "id" in entry.frame + ? { ...entry, frame: { ...entry.frame, id: 8 } } + : entry, + ), + { + type: "emit_inbound", + frame: { + method: "turn/started", + params: { + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_CHILD_TURN, status: "inProgress" }), + }, + }, + }, + pendingChildFence("gpt-fence-child-resumed"), + { + type: "emit_inbound", + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ + id: INTERRUPT_LATE_CHILD_NATIVE_TURN, + status: "interrupted", + }), + }, + }, + }, + pendingChildFence("gpt-fence-old-completion"), + { + type: "emit_inbound", + afterMs: 1_000, + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_CHILD_TURN, status: "completed" }), + }, + }, + }, + { + type: "emit_inbound", + frame: { + method: "turn/completed", + params: { + threadId: INTERRUPT_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_ROOT_TURN, status: "completed" }), + }, + }, + }, { type: "runtime_exit", status: "success" }, ], }); @@ -5536,8 +5668,26 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); yield* harness.awaitTerminal(1); + const pendingChildUpdates = () => + harness + .subagentUpdates() + .filter( + (event) => + event.subagent.nativeTaskRef?.nativeId === + `call-codex-interrupt-pending-child:${INTERRUPT_PENDING_CHILD_NATIVE_THREAD}`, + ); + const fenced = (model: string) => + harness.until(() => + pendingChildUpdates().some((event) => event.subagent.model === model), + ); + yield* fenced("gpt-fence-interrupt-requested"); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "pending"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + // The TestClock never reaches the interrupt's timeout, so this arrives only // if the reader is free to read the interrupt response and the frame after it. + yield* TestClock.adjust("4 seconds"); + yield* fenced("gpt-fence-interrupt-acked"); yield* harness.until(() => harness.events.some( (event) => @@ -5545,6 +5695,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.providerThread.goal?.status === "paused", ), ); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "pending"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* TestClock.adjust("4 seconds"); + yield* fenced("gpt-fence-late-turn-completed"); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "interrupted"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); assert.isFalse( harness.events.some( (event) => @@ -5552,6 +5710,29 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, ), ); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-resumed"), + text: INTERRUPT_RESUME_PROMPT, + }), + runOrdinal: 2, + providerTurnOrdinal: 2, + }); + yield* fenced("gpt-fence-child-resumed"); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "running"); + assert.isNull(pendingChildUpdates().at(-1)?.subagent.completedAt); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* fenced("gpt-fence-old-completion"); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "running"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* TestClock.adjust("1 second"); + yield* harness.awaitTerminal(2); + assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "completed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 0154098191486..5b9706f6be28e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2670,6 +2670,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( let ancestor: ActiveCodexTurnContext | undefined = subagent.parentContext; while (ancestor !== undefined) { if (terminalizedNativeTurns.has(ancestor.nativeTurnId)) { + yield* Ref.update(terminalizedNonCompletedNativeTurns, (current) => + new Set(current).add(turn.nativeTurnId), + ); const nativeThreadId = yield* getNativeThreadId(subagent.providerThread); // Fork requests off the notification reader so it can read their responses. yield* client @@ -2747,7 +2750,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( subagent, status: "running", startedAt: turn.startedAt, - ...(providerTurnOrdinal > 1 ? { reopen: true, result: null } : {}), + ...(providerTurnOrdinal > 1 || subagent.nativeTurnIds.size > 1 + ? { reopen: true, result: null } + : {}), }); const now = yield* DateTime.now; yield* emitProviderEvent({ @@ -6567,6 +6572,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.gen(function* () { const context = (yield* Ref.get(activeTurns)).get(payload.turn.id); if (context === undefined) { + // A child turn rejected after Stop has no context; its own completion settles the task. + const subagent = (yield* Ref.get(subagentThreads)).get(payload.threadId); + if ( + subagent !== undefined && + isOrchestrationV2WorkActive(subagent.task.status) && + Array.from(subagent.nativeTurnIds).at(-1) === payload.turn.id && + (yield* Ref.get(terminalizedNonCompletedNativeTurns)).has(payload.turn.id) + ) { + yield* emitSubagentTaskUpdate({ + subagent, + status: "interrupted", + completedAt: codexTimestamp(payload.turn.completedAt), + }); + } return; } const nativeStatus = mapCodexTurnStatus(payload.turn.status); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 1cc014cf018c5..cd5bec30629ae 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -441,6 +441,9 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly beforeArchive?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; /** Offers `unload:`, `archive:` and `close`; also gives the runtime archiveThread. */ @@ -576,10 +579,15 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( }: { providerThread: OrchestrationV2ProviderThread; }) => - Queue.offer( - options.nativeThreadExits!, - `archive:${providerThread.nativeThreadRef?.nativeId}`, - ).pipe(Effect.asVoid), + (options.beforeArchive?.(providerThread) ?? Effect.void).pipe( + Effect.andThen( + Queue.offer( + options.nativeThreadExits!, + `archive:${providerThread.nativeThreadRef?.nativeId}`, + ), + ), + Effect.asVoid, + ), }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"), @@ -618,6 +626,9 @@ function layerTest(input: { readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly beforeArchive?: ( + providerThread: OrchestrationV2ProviderThread, + ) => Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; readonly nativeThreadExits?: Queue.Queue; @@ -655,6 +666,7 @@ function layerTest(input: { ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), ...(input.interruptTurn === undefined ? {} : { interruptTurn: input.interruptTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), + ...(input.beforeArchive === undefined ? {} : { beforeArchive: input.beforeArchive }), ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), ...(input.scopeCloseReached === undefined ? {} @@ -3942,9 +3954,9 @@ it.effect( }), ); -it.effect( - "ProviderSessionManagerV2 archives a delegated child only once its parent took its result", - () => +it.effect.each([false, true])( + "ProviderSessionManagerV2 archives a delegated child only once its parent took its result (archive fails: %s)", + (archiveFails) => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const exits = yield* Queue.unbounded(); @@ -4161,15 +4173,32 @@ it.effect( }); yield* endTurn(parentThread); yield* TestClock.adjust("1 second"); - // Each child archives once before the session closes. - assert.deepEqual( - [yield* Queue.take(exits), yield* Queue.take(exits), yield* Queue.take(exits)], - ["archive:native-delivered", "archive:native-late", "close"], + // Failed sweep archives fall back to unload before the session closes. + assert.equal(yield* Queue.take(exits), "archive:native-delivered"); + assert.equal( + yield* Queue.take(exits), + archiveFails ? "unload:native-late" : "archive:native-late", ); + assert.equal(yield* Queue.take(exits), "close"); }); yield* effect.pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 1000, nativeThreadExits: exits })), + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + nativeThreadExits: exits, + beforeArchive: (providerThread) => + archiveFails && providerThread.nativeThreadRef?.nativeId === "native-late" + ? Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_DRIVER, + detail: "test archive failed", + }), + ) + : Effect.void, + }), + ), ); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 91da1c2423788..43d1eb83aceee 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1742,23 +1742,54 @@ export const layerWithOptions = ( !latest.attachedThreadIds.has(threadId) || latest.archivedThreadIds.has(threadId) ) { - return [false, current] as const; + return [undefined, current] as const; } const loadedProviderThreadKeyByThread = new Map( latest.loadedProviderThreadKeyByThread, ); loadedProviderThreadKeyByThread.delete(threadId); - const idleThreadUnloads = new Map(latest.idleThreadUnloads); - idleThreadUnloads.delete(threadId); const updated = new Map(current); updated.set(key, { ...latest, loadedProviderThreadKeyByThread, - idleThreadUnloads, }); - return [true, updated] as const; + return [{ pending: latest.idleThreadUnloads.get(threadId) }, updated] as const; + }); + if (claimed === undefined) return; + const archived = yield* archiveProviderThread({ entry, threadId, providerThread }); + if (!archived) { + const unloadThread = entry.exposedRuntime.unloadThread; + const latest = (yield* Ref.get(sessions)).get(key); + if ( + unloadThread === undefined || + latest?.runtime !== entry.runtime || + latest.idleThreadUnloads.get(threadId) !== claimed.pending || + hasBusyTurn(latest, providerThread.id) + ) + return; + yield* unloadThread({ providerThread }).pipe( + Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.idle-unload-failed", { + providerSessionId, + threadId, + providerThreadId: providerThread.id, + cause, + }), + ), + ); + } + yield* Ref.update(sessions, (current) => { + const latest = current.get(key); + if ( + latest?.runtime !== entry.runtime || + latest.idleThreadUnloads.get(threadId) !== claimed.pending + ) + return current; + const idleThreadUnloads = new Map(latest.idleThreadUnloads); + idleThreadUnloads.delete(threadId); + return new Map(current).set(key, { ...latest, idleThreadUnloads }); }); - if (claimed) yield* archiveProviderThread({ entry, threadId, providerThread }); }), ), { discard: true }, From 1869f53417e75f5362a17d44ddd1a6905d2c9232 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:41:20 +0000 Subject: [PATCH 08/11] refactor(codex): keep delegated archive cleanup to the archive fix Drop the Stop-settlement and session-wide pending-probe changes, which are separable from archiving. A failed release-sweep archive now leaves idle tracking untouched instead of falling back to unload. Share one fixture between the manager tests and narrow the user docs. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 190 +----- .../Adapters/CodexAdapterV2.ts | 23 +- .../ProviderSessionManager.test.ts | 558 +++++++----------- .../ProviderSessionManager.ts | 25 +- docs/user/thread-sidebar.md | 11 +- 5 files changed, 228 insertions(+), 579 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 81b2d12edab91..ef41bd6040392 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5434,70 +5434,17 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const INTERRUPT_PENDING_CHILD_NATIVE_THREAD = "native-codex-interrupt-pending-child-thread"; - const INTERRUPT_RESUMED_ROOT_TURN = "native-codex-interrupt-resumed-root-turn"; - const INTERRUPT_RESUMED_CHILD_TURN = "native-codex-interrupt-resumed-child-turn"; - const INTERRUPT_RESUME_PROMPT = "Continue the stopped child."; - // Notifications run in order, so the model update fences everything before it. - const pendingChildFence = (model: string): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - frame: { - method: "thread/settings/updated", - params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, - threadSettings: { - model, - effort: "low", - serviceTier: null, - modelProvider: "openai", - cwd: "/workspace", - approvalPolicy: "never", - approvalsReviewer: "auto_review", - collaborationMode: { mode: "default", settings: { model } }, - sandboxPolicy: { type: "dangerFullAccess" }, - }, - }, - }, - }); - const childSpawnIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => - entry.type === "emit_inbound" && entry.label === "item/completed/subAgentActivity-started", - ); const interruptSubagentTurnAfterStopTranscript = makeCodexReplayTranscript({ scenario: "codex-interrupt-subagent-turn-after-stop", entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, childSpawnIndex + 1), - { - type: "emit_inbound", - label: "item/completed/collab-spawn-pending-child", - frame: { - method: "item/completed", - params: { - item: { - type: "collabAgentToolCall", - id: "call-codex-interrupt-pending-child", - tool: "spawnAgent", - status: "completed", - senderThreadId: INTERRUPT_NATIVE_THREAD, - receiverThreadIds: [INTERRUPT_PENDING_CHILD_NATIVE_THREAD], - agentsStates: { - [INTERRUPT_PENDING_CHILD_NATIVE_THREAD]: { status: "pendingInit", message: null }, - }, - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - completedAtMs: 1782622441200, - }, - }, - }, - ...interruptSubagentCommandTranscript.entries.slice(childSpawnIndex + 1, -1), + ...interruptSubagentCommandTranscript.entries.slice(0, -1), { type: "emit_inbound", label: "turn/started/child-after-stop", frame: { method: "turn/started", params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, turn: makeCodexReplayTurn({ id: INTERRUPT_LATE_CHILD_NATIVE_TURN, status: "inProgress", @@ -5512,19 +5459,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { id: 7, method: "turn/interrupt", params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, + threadId: INTERRUPT_CHILD_NATIVE_THREAD, turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, }, }, }, - pendingChildFence("gpt-fence-interrupt-requested"), { type: "emit_inbound", label: "turn/interrupt/child-after-stop", - afterMs: 4_000, frame: { id: 7, result: {} }, }, - pendingChildFence("gpt-fence-interrupt-acked"), { type: "emit_inbound", label: "thread/goal/updated/root", @@ -5546,82 +5490,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - { - type: "emit_inbound", - label: "turn/completed/child-after-stop", - afterMs: 4_000, - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "interrupted", - }), - }, - }, - }, - pendingChildFence("gpt-fence-late-turn-completed"), - ...codexReplayPreamble({ - nativeThreadId: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_RESUMED_ROOT_TURN, - prompt: INTERRUPT_RESUME_PROMPT, - }) - .slice(-3) - .map((entry) => - entry.type !== "runtime_exit" && - typeof entry.frame === "object" && - entry.frame !== null && - "id" in entry.frame - ? { ...entry, frame: { ...entry.frame, id: 8 } } - : entry, - ), - { - type: "emit_inbound", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_CHILD_TURN, status: "inProgress" }), - }, - }, - }, - pendingChildFence("gpt-fence-child-resumed"), - { - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "interrupted", - }), - }, - }, - }, - pendingChildFence("gpt-fence-old-completion"), - { - type: "emit_inbound", - afterMs: 1_000, - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_PENDING_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_CHILD_TURN, status: "completed" }), - }, - }, - }, - { - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_RESUMED_ROOT_TURN, status: "completed" }), - }, - }, - }, { type: "runtime_exit", status: "success" }, ], }); @@ -5668,26 +5536,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); yield* harness.awaitTerminal(1); - const pendingChildUpdates = () => - harness - .subagentUpdates() - .filter( - (event) => - event.subagent.nativeTaskRef?.nativeId === - `call-codex-interrupt-pending-child:${INTERRUPT_PENDING_CHILD_NATIVE_THREAD}`, - ); - const fenced = (model: string) => - harness.until(() => - pendingChildUpdates().some((event) => event.subagent.model === model), - ); - yield* fenced("gpt-fence-interrupt-requested"); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "pending"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - // The TestClock never reaches the interrupt's timeout, so this arrives only // if the reader is free to read the interrupt response and the frame after it. - yield* TestClock.adjust("4 seconds"); - yield* fenced("gpt-fence-interrupt-acked"); yield* harness.until(() => harness.events.some( (event) => @@ -5695,14 +5545,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.providerThread.goal?.status === "paused", ), ); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "pending"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - yield* TestClock.adjust("4 seconds"); - yield* fenced("gpt-fence-late-turn-completed"); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); assert.isFalse( harness.events.some( (event) => @@ -5710,29 +5552,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, ), ); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-resumed"), - text: INTERRUPT_RESUME_PROMPT, - }), - runOrdinal: 2, - providerTurnOrdinal: 2, - }); - yield* fenced("gpt-fence-child-resumed"); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "running"); - assert.isNull(pendingChildUpdates().at(-1)?.subagent.completedAt); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* fenced("gpt-fence-old-completion"); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "running"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* TestClock.adjust("1 second"); - yield* harness.awaitTerminal(2); - assert.equal(pendingChildUpdates().at(-1)?.subagent.status, "completed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( Layer.mergeAll( @@ -7695,10 +7514,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-child-settled"); - assert.include(harness.continuationRequests[0]?.detail, "CHILD_DONE"); assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "pending"); assert.isTrue(yield* ownsRunningWork); - assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-grandchild-running"); @@ -7707,7 +7524,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* TestClock.adjust("30 seconds"); yield* fenced("gpt-fence-grandchild-settled"); assert.isFalse(yield* ownsRunningWork); - assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 5eb65f6cf1c23..1d238b285b51a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2670,9 +2670,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( let ancestor: ActiveCodexTurnContext | undefined = subagent.parentContext; while (ancestor !== undefined) { if (terminalizedNativeTurns.has(ancestor.nativeTurnId)) { - yield* Ref.update(terminalizedNonCompletedNativeTurns, (current) => - new Set(current).add(turn.nativeTurnId), - ); const nativeThreadId = yield* getNativeThreadId(subagent.providerThread); // Fork requests off the notification reader so it can read their responses. yield* client @@ -2750,9 +2747,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( subagent, status: "running", startedAt: turn.startedAt, - ...(providerTurnOrdinal > 1 || subagent.nativeTurnIds.size > 1 - ? { reopen: true, result: null } - : {}), + ...(providerTurnOrdinal > 1 ? { reopen: true, result: null } : {}), }); const now = yield* DateTime.now; yield* emitProviderEvent({ @@ -6572,20 +6567,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.gen(function* () { const context = (yield* Ref.get(activeTurns)).get(payload.turn.id); if (context === undefined) { - // A child turn rejected after Stop has no context; its own completion settles the task. - const subagent = (yield* Ref.get(subagentThreads)).get(payload.threadId); - if ( - subagent !== undefined && - isOrchestrationV2WorkActive(subagent.task.status) && - Array.from(subagent.nativeTurnIds).at(-1) === payload.turn.id && - (yield* Ref.get(terminalizedNonCompletedNativeTurns)).has(payload.turn.id) - ) { - yield* emitSubagentTaskUpdate({ - subagent, - status: "interrupted", - completedAt: codexTimestamp(payload.turn.completedAt), - }); - } return; } const nativeStatus = mapCodexTurnStatus(payload.turn.status); @@ -7084,7 +7065,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (state.running.size > 0 || state.saved.size > 0) return true; } for (const subagent of (yield* Ref.get(subagentThreads)).values()) { - if (isOrchestrationV2WorkActive(subagent.task.status)) { + if (subagent.task.status === "running") { return true; } } diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 067323a87bd7c..f0ac50675ec06 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -442,9 +442,6 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; - readonly beforeArchive?: ( - providerThread: OrchestrationV2ProviderThread, - ) => Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; /** Offers `unload:`, `archive:` and `close`; also gives the runtime archiveThread. */ @@ -593,15 +590,10 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( }: { providerThread: OrchestrationV2ProviderThread; }) => - (options.beforeArchive?.(providerThread) ?? Effect.void).pipe( - Effect.andThen( - Queue.offer( - options.nativeThreadExits!, - `archive:${providerThread.nativeThreadRef?.nativeId}`, - ), - ), - Effect.asVoid, - ), + Queue.offer( + options.nativeThreadExits!, + `archive:${providerThread.nativeThreadRef?.nativeId}`, + ).pipe(Effect.asVoid), }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"), @@ -641,9 +633,6 @@ function layerTest(input: { readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; - readonly beforeArchive?: ( - providerThread: OrchestrationV2ProviderThread, - ) => Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; readonly nativeThreadExits?: Queue.Queue; @@ -684,7 +673,6 @@ function layerTest(input: { ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), ...(input.interruptTurn === undefined ? {} : { interruptTurn: input.interruptTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), - ...(input.beforeArchive === undefined ? {} : { beforeArchive: input.beforeArchive }), ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), ...(input.scopeCloseReached === undefined ? {} @@ -4235,9 +4223,184 @@ it.effect( }), ); -it.effect.each([false, true])( - "ProviderSessionManagerV2 archives a delegated child only once its parent took its result (archive fails: %s)", - (archiveFails) => +// An app-owned delegated child whose task completed, linked to its parent's task record. +const writeDelegatedChild = Effect.fnUntraced(function* (input: { + readonly parentId: ThreadId; + readonly providerSessionId: ProviderSessionId; + readonly name: string; + readonly delivery: "pending" | "acknowledged"; + /** Initial tasks link through `forkedFrom`; follow-ups through the run's `delegatedTaskId`. */ + readonly link: "forkedFrom" | "run"; +}) { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread-provider-session-manager-archive-${input.name}`); + const parentRunId = idAllocator.derive.run({ threadId: input.parentId, ordinal: 1 }); + const taskId = NodeId.make(`node-task-${input.name}`); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId: input.providerSessionId, + now, + nativeThreadId: `native-${input.name}`, + }); + const taskEvent = Effect.fnUntraced(function* ( + delivery: "pending" | "acknowledged" | "delivered", + ) { + return { + id: yield* idAllocator.allocate.event({ threadId: input.parentId }), + type: "subagent.updated" as const, + threadId: input.parentId, + runId: parentRunId, + nodeId: taskId, + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: taskId, + threadId: input.parentId, + 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: "Implement it.", + title: null, + model: null, + completionDelivery: { state: delivery, observedByRunId: null }, + status: "completed", + result: "Done.", + startedAt: now, + completedAt: now, + updatedAt: now, + } satisfies OrchestrationV2Subagent, + }; + }); + const created = yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + delegatedBy: input.parentId, + }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + yield* eventSink.write({ + events: [ + { + ...created, + payload: { + ...created.payload, + activeProviderThreadId: providerThread.id, + ...(input.link === "forkedFrom" + ? { forkedFrom: { type: "node" as const, nodeId: taskId } } + : {}), + }, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "provider-thread.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload: providerThread, + }, + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: providerThread.id, + userMessageId: MessageId.make(`message-${input.name}`), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + ...(input.link === "run" ? { delegatedTaskId: taskId } : {}), + } satisfies OrchestrationV2Run, + }, + yield* taskEvent(input.delivery), + ], + }); + return { threadId, providerThread, taskEvent }; +}); + +// Runs one completed turn; the manager has scheduled its idle work once a subscriber sees the terminal. +const runTurn = Effect.fnUntraced(function* (input: { + readonly state: Ref.Ref; + readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + readonly end?: boolean; +}) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, providerThread, runtime } = input; + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + 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: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + if (input.end === false) return; + yield* endTurn(input); +}); + +const endTurn = Effect.fnUntraced(function* (input: { + readonly state: Ref.Ref; + readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; + readonly providerThread: OrchestrationV2ProviderThread; +}) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const subscription = yield* input.runtime.subscribeEvents!; + const providerSessionId = input.providerThread.providerSessionId!; + yield* Queue.offer((yield* Ref.get(input.state)).eventQueues.get(String(providerSessionId))!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: input.providerThread.id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: `turn-${input.providerThread.id}`, + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* subscription.events.pipe(Stream.runHead); +}); + +it.effect( + "ProviderSessionManagerV2 archives a delegated child only once its parent took its result", + () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const exits = yield* Queue.unbounded(); @@ -4245,10 +4408,8 @@ it.effect.each([false, true])( 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 parentId = ThreadId.make("thread-provider-session-manager-archive-parent"); - const parentRunId = idAllocator.derive.run({ threadId: parentId, ordinal: 1 }); const providerSessionId = idAllocator.derive.providerSession({ providerInstanceId: modelSelection.instanceId, }); @@ -4256,130 +4417,24 @@ it.effect.each([false, true])( ...makeProviderThread({ idAllocator, threadId: parentId, providerSessionId, now }), nativeThreadRef: null, }; - const task = ( - childThreadId: ThreadId, - delivery: "pending" | "acknowledged" | "delivered", - ): OrchestrationV2Subagent => ({ - id: NodeId.make(`node-task-${childThreadId}`), - threadId: parentId, - runId: parentRunId, - parentNodeId: idAllocator.derive.rootNode({ runId: parentRunId }), - origin: "app_owned", - createdBy: "agent", - driver: CODEX_DRIVER, - providerInstanceId: modelSelection.instanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Implement it.", - title: null, - model: null, - completionDelivery: { state: delivery, observedByRunId: null }, - status: "completed", - result: "Done.", - startedAt: now, - completedAt: now, - updatedAt: now, - }); - const taskEvent = Effect.fnUntraced(function* (payload: OrchestrationV2Subagent) { - return { - id: yield* idAllocator.allocate.event({ threadId: parentId }), - type: "subagent.updated" as const, - threadId: parentId, - runId: parentRunId, - nodeId: payload.id, - driver: CODEX_DRIVER, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload, - }; - }); - const makeChild = Effect.fnUntraced(function* ( - name: string, - delivery: "pending" | "acknowledged", - ) { - const threadId = ThreadId.make(`thread-provider-session-manager-archive-${name}`); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - nativeThreadId: `native-${name}`, - }); - const created = yield* makeThreadCreatedEvent({ - idAllocator, - threadId, - now, - delegatedBy: parentId, - }); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - const run: OrchestrationV2Run = { - id: runId, - threadId, - ordinal: 1, - providerInstanceId: modelSelection.instanceId, - modelSelection, - providerThreadId: providerThread.id, - userMessageId: MessageId.make(`message-${name}`), - rootNodeId: null, - activeAttemptId: null, - status: "completed", - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - }; - yield* eventSink.write({ - events: [ - { - ...created, - payload: { - ...created.payload, - activeProviderThreadId: providerThread.id, - forkedFrom: { type: "node", nodeId: task(threadId, delivery).id }, - }, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "provider-thread.updated", - threadId, - driver: CODEX_DRIVER, - occurredAt: now, - payload: providerThread, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "run.updated", - threadId, - runId, - occurredAt: now, - payload: run, - }, - yield* taskEvent(task(threadId, delivery)), - ], - }); - return { threadId, providerThread }; - }); - yield* eventSink.write({ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now })], }); - const delivered = yield* makeChild("delivered", "acknowledged"); - const late = yield* makeChild("late", "pending"); + const child = { parentId, providerSessionId, link: "forkedFrom" } as const; + const delivered = yield* writeDelegatedChild({ + ...child, + name: "delivered", + delivery: "acknowledged", + }); + const late = yield* writeDelegatedChild({ ...child, name: "late", delivery: "pending" }); const runtime = yield* manager.open({ threadId: parentId, providerSessionId, modelSelection, runtimePolicy, }); - for (const child of [delivered, late]) { - yield* manager.open({ - threadId: child.threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); + for (const { threadId } of [delivered, late]) { + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); } const resumeDelivered = runtime.resumeThread({ providerThread: delivered.providerThread, @@ -4388,56 +4443,15 @@ it.effect.each([false, true])( runtimePolicy, }); yield* resumeDelivered; - const startTurn = Effect.fnUntraced(function* ( - threadId: ThreadId, - providerThread: OrchestrationV2ProviderThread, - ) { - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - 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: "turn", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - }); - const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!; - // The manager has scheduled its idle work once a subscriber sees the terminal. - const endTurn = Effect.fnUntraced(function* ( - providerThread: OrchestrationV2ProviderThread, - ) { - const subscription = yield* runtime.subscribeEvents!; - yield* Queue.offer(queue, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: providerThread.id, - providerTurnId: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: `turn-${providerThread.id}`, - }), - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* subscription.events.pipe(Stream.runHead); + yield* runTurn({ + state, + runtime, + threadId: parentId, + providerThread: parentThread, + end: false, }); - yield* startTurn(parentId, parentThread); - for (const child of [delivered, late]) { - yield* startTurn(child.threadId, child.providerThread); - yield* endTurn(child.providerThread); + for (const { threadId, providerThread } of [delivered, late]) { + yield* runTurn({ state, runtime, threadId, providerThread }); } // The busy parent keeps the session; each child reaches its own idle unload. @@ -4449,37 +4463,16 @@ it.effect.each([false, true])( assert.equal((yield* Ref.get(state)).resumeCount, 2); // The late result lands after its child unloaded; the session's idle release archives it. - yield* eventSink.write({ - events: [yield* taskEvent(task(late.threadId, "delivered"))], - }); - yield* endTurn(parentThread); + yield* eventSink.write({ events: [yield* late.taskEvent("delivered")] }); + yield* endTurn({ state, runtime, providerThread: parentThread }); yield* TestClock.adjust("1 second"); - // Failed sweep archives fall back to unload before the session closes. - assert.equal(yield* Queue.take(exits), "archive:native-delivered"); - assert.equal( - yield* Queue.take(exits), - archiveFails ? "unload:native-late" : "archive:native-late", - ); - assert.equal(yield* Queue.take(exits), "close"); + for (const exit of ["archive:native-delivered", "archive:native-late", "close"]) { + assert.equal(yield* Queue.take(exits), exit); + } }); yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - nativeThreadExits: exits, - beforeArchive: (providerThread) => - archiveFails && providerThread.nativeThreadRef?.nativeId === "native-late" - ? Effect.fail( - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CODEX_DRIVER, - detail: "test archive failed", - }), - ) - : Effect.void, - }), - ), + Effect.provide(layerTest({ state, idleTimeoutMs: 1000, nativeThreadExits: exits })), ); }), ); @@ -4494,148 +4487,29 @@ it.effect( 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; // The parent runs elsewhere, so nothing else keeps the child's session alive. const parentId = ThreadId.make("thread-provider-session-manager-remote-parent"); - const parentRunId = idAllocator.derive.run({ threadId: parentId, ordinal: 1 }); - const threadId = ThreadId.make("thread-provider-session-manager-awaiting-child"); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); const providerSessionId = idAllocator.derive.providerSession({ providerInstanceId: modelSelection.instanceId, }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - nativeThreadId: "native-awaiting-child", - }); - const taskId = NodeId.make("node-task-awaiting-child"); - const taskEvent = Effect.fnUntraced(function* (delivery: "pending" | "delivered") { - return { - id: yield* idAllocator.allocate.event({ threadId: parentId }), - type: "subagent.updated" as const, - threadId: parentId, - runId: parentRunId, - nodeId: taskId, - driver: CODEX_DRIVER, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - id: taskId, - threadId: parentId, - 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: "Implement it.", - title: null, - model: null, - completionDelivery: { state: delivery, observedByRunId: null }, - status: "completed", - result: "Done.", - startedAt: now, - completedAt: now, - updatedAt: now, - } satisfies OrchestrationV2Subagent, - }; - }); - const created = yield* makeThreadCreatedEvent({ - idAllocator, - threadId, - now, - delegatedBy: parentId, - }); yield* eventSink.write({ - events: [ - yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now }), - { - ...created, - payload: { ...created.payload, activeProviderThreadId: providerThread.id }, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "provider-thread.updated", - threadId, - driver: CODEX_DRIVER, - occurredAt: now, - payload: providerThread, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "run.updated", - threadId, - runId, - occurredAt: now, - payload: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId: modelSelection.instanceId, - modelSelection, - providerThreadId: providerThread.id, - userMessageId: MessageId.make("message-awaiting-child"), - rootNodeId: null, - activeAttemptId: null, - status: "completed", - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - delegatedTaskId: taskId, - } satisfies OrchestrationV2Run, - }, - yield* taskEvent("pending"), - ], + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now })], }); - const runtime = yield* manager.open({ - threadId, + const child = yield* writeDelegatedChild({ + parentId, providerSessionId, - modelSelection, - runtimePolicy, + name: "awaiting-child", + delivery: "pending", + link: "run", }); - 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: "turn", - attachments: [], - }, + const runtime = yield* manager.open({ + threadId: child.threadId, + providerSessionId, modelSelection, runtimePolicy, }); - // The manager has scheduled its idle release once a subscriber sees the 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: "turn-awaiting-child", - }), - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* subscription.events.pipe(Stream.runHead); + yield* runTurn({ state, runtime, ...child }); // The child unloads without archiving, and its result's pending delivery pins the session. yield* TestClock.adjust("1 second"); @@ -4644,7 +4518,7 @@ it.effect( "unload:native-awaiting-child", ]); // A busy parent takes the result after the idle timeout; the pinned session archives it. - yield* eventSink.write({ events: [yield* taskEvent("delivered")] }); + yield* eventSink.write({ events: [yield* child.taskEvent("delivered")] }); yield* TestClock.adjust("1 second"); for (const exit of ["probe", "archive:native-awaiting-child", "close"]) { assert.equal(yield* Queue.take(exits), exit); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index c8045b38ecf3e..84e87c8a95a89 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1769,29 +1769,8 @@ export const layerWithOptions = ( return [{ pending: latest.idleThreadUnloads.get(threadId) }, updated] as const; }); if (claimed === undefined) return; - const archived = yield* archiveProviderThread({ entry, threadId, providerThread }); - if (!archived) { - const unloadThread = entry.exposedRuntime.unloadThread; - const latest = (yield* Ref.get(sessions)).get(key); - if ( - unloadThread === undefined || - latest?.runtime !== entry.runtime || - latest.idleThreadUnloads.get(threadId) !== claimed.pending || - hasBusyTurn(latest, providerThread.id) - ) - return; - yield* unloadThread({ providerThread }).pipe( - Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), - Effect.catchCause((cause) => - Effect.logWarning("orchestration-v2.driver-session.idle-unload-failed", { - providerSessionId, - threadId, - providerThreadId: providerThread.id, - cause, - }), - ), - ); - } + // A failed archive keeps the idle unload tracked; the cleared cache only forces a resume. + if (!(yield* archiveProviderThread({ entry, threadId, providerThread }))) return; yield* Ref.update(sessions, (current) => { const latest = current.get(key); if ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bc62a708cb8cb..6e274c9a28d3a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -274,13 +274,12 @@ fill the screen; close the full-screen view to return to the parent and inspecto Stop on a thread also stops the subagents it delegated to. With Codex, a delegated task's thread is archived in Codex's own history once the -task has completed, the parent agent has its result, and the thread has sat idle. -This keeps those threads out of other Codex apps' thread lists. T3 Code keeps the -full history, and a follow-up to the task continues the same Codex conversation. +task has finished, the parent agent has its result, and the thread has sat idle. +T3 Code keeps the full history, and a follow-up to the task continues the same +Codex conversation. -To clean up older entries, wait for the task and its subagents to finish and for -the parent to receive the result. Then run `codex archive ` on the -machine hosting that Codex session. Keep the T3 thread to continue it later. +Threads from before this change stay in Codex's history. To archive one, run +`codex archive ` with the same Codex home on the machine hosting it. Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent From 66ed0dae1ff3ac647ae241aa90782a41889be5c9 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:50:52 +0000 Subject: [PATCH 09/11] refactor(codex): trim delegated-child archive to one decision in the idle unload The session manager's idle unload asks one question, whether the thread is a completed T3-delegated child whose parent has its result, and passes the answer to the adapter's unloadThread as `archive`. Codex sends thread/archive instead of thread/unsubscribe; its existing resume path unarchives on the next turn. An idle session release runs the same unload in archive-only mode first, so a child that was the session's last activity is archived too. Drops the separate archiveThread adapter method, archived-thread bookkeeping, session pinning while a delivery is pending, the native-descendant probe change, and the user-doc paragraph. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 136 ----- .../Adapters/CodexAdapterV2.ts | 58 +- .../ProviderSessionManager.test.ts | 520 ++++++------------ .../ProviderSessionManager.ts | 274 ++------- docs/user/thread-sidebar.md | 8 - .../src/server/ProviderAdapter.ts | 9 +- 6 files changed, 217 insertions(+), 788 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 40e685badf351..8e231195de8fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7687,142 +7687,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("counts a pending or running grandchild after its root and direct child settled", () => - Effect.scoped( - Effect.gen(function* () { - const grandchildThread = "native-codex-resume-grandchild-thread"; - const grandchildTurn = "native-codex-resume-grandchild-turn"; - // Notifications run in order, so the model update fences everything before it. - const fence = (model: string): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - frame: { - method: "thread/settings/updated", - params: { - threadId: grandchildThread, - threadSettings: { - model, - effort: "low", - serviceTier: null, - modelProvider: "openai", - cwd: "/workspace", - approvalPolicy: "never", - approvalsReviewer: "auto_review", - collaborationMode: { mode: "default", settings: { model } }, - sandboxPolicy: { type: "dangerFullAccess" }, - }, - }, - }, - }); - const { spawned, rootFinished } = lateChildPrefix(); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: RESUME_SCENARIO, - entries: [ - ...spawned, - { - type: "emit_inbound", - frame: { - method: "item/completed", - params: { - item: { - type: "collabAgentToolCall", - id: "call-codex-grandchild-spawn", - tool: "spawnAgent", - status: "completed", - senderThreadId: RESUME_CHILD_THREAD, - receiverThreadIds: [grandchildThread], - agentsStates: { - [grandchildThread]: { status: "pendingInit", message: null }, - }, - }, - threadId: RESUME_CHILD_THREAD, - turnId: RESUME_CHILD_TURN_1, - completedAtMs: 1782622441500, - }, - }, - }, - ...rootFinished, - childAgentMessage({ - id: "child-answer", - text: "CHILD_DONE", - turnId: RESUME_CHILD_TURN_1, - completedAtMs: 1782622470000, - afterMs: 30_000, - }), - childTurnCompleted(RESUME_CHILD_TURN_1), - fence("gpt-fence-child-settled"), - { - type: "emit_inbound", - afterMs: 30_000, - frame: { - method: "turn/started", - params: { - threadId: grandchildThread, - turn: makeCodexReplayTurn({ id: grandchildTurn, status: "inProgress" }), - }, - }, - }, - fence("gpt-fence-grandchild-running"), - { - type: "emit_inbound", - afterMs: 30_000, - frame: { - method: "turn/completed", - params: { - threadId: grandchildThread, - turn: makeCodexReplayTurn({ id: grandchildTurn, status: "completed" }), - }, - }, - }, - fence("gpt-fence-grandchild-settled"), - ], - }), - ); - const fenced = (model: string) => - harness.until(() => - harness.subagentUpdates().some((event) => event.subagent.model === model), - ); - const ownsRunningWork = harness.runtime.hasPendingBackgroundWorkForThread!( - harness.providerThread, - { includeSubagents: true }, - ); - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-settled-child-grandchild"), - text: RESUME_PROMPT, - }), - ); - yield* harness.awaitTerminal(1); - - yield* TestClock.adjust("30 seconds"); - yield* fenced("gpt-fence-child-settled"); - assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "pending"); - assert.isTrue(yield* ownsRunningWork); - - yield* TestClock.adjust("30 seconds"); - yield* fenced("gpt-fence-grandchild-running"); - assert.isTrue(yield* ownsRunningWork); - - yield* TestClock.adjust("30 seconds"); - yield* fenced("gpt-fence-grandchild-settled"); - assert.isFalse(yield* ownsRunningWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - it.effect("leaves a late child's result to the root's next turn when one is running", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 363a6768e1d8d..ef8365ab3405b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7183,7 +7183,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return false; }), // Untraced: the run's event stream calls this once per provider event. - hasPendingBackgroundWorkForThread: Effect.fnUntraced(function* (providerThread, options) { + hasPendingBackgroundWorkForThread: Effect.fnUntraced(function* (providerThread) { const contexts = [ ...(yield* Ref.get(activeTurns)).values(), ...(yield* Ref.get(settledTurns)).values(), @@ -7191,34 +7191,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const roots = contexts.filter( (context) => context.providerThread.id === providerThread.id, ); - // Settled ancestors leave these maps, so replacement checks walk each live turn's lineage. - const owned = (context: ActiveCodexTurnContext) => { - if (options?.includeSubagents !== true) { - return roots.some( - (root) => context === root || isDescendantCodexTurn(context, root), - ); - } - for ( - let ancestor: ActiveCodexTurnContext | undefined = context; - ancestor !== undefined; - ancestor = ancestor.subagent?.parentContext - ) { - if (ancestor.providerThread.id === providerThread.id) return true; - } - return false; - }; - if (options?.includeSubagents === true) { - for (const subagent of (yield* Ref.get(subagentThreads)).values()) { - if ( - isOrchestrationV2WorkActive(subagent.task.status) && - owned(subagent.parentContext) - ) { - return true; - } - } - } for (const context of contexts) { - if (!owned(context)) continue; + if (!roots.some((root) => context === root || isDescendantCodexTurn(context, root))) + continue; if (yield* turnHasRetainedBackgroundWork(context.nativeTurnId)) return true; if ( context.subagent !== null && @@ -7449,7 +7424,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (unloadInput) { const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); - yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); + // Archive also unloads; resumeNativeThread unarchives on the next turn. + if (unloadInput.archive === true) { + yield* client.request("thread/archive", { threadId: nativeThreadId }); + } else { + yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); + } }, (effect, unloadInput) => effect.pipe( @@ -7464,26 +7444,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), ), - // Archived threads leave the active Codex thread list; resumeNativeThread unarchives them. - archiveThread: Effect.fn("CodexAdapterV2.archiveThread")( - function* (archiveInput) { - const nativeThreadId = yield* getNativeThreadId(archiveInput.providerThread); - sessionApprovals.delete(nativeThreadId); - yield* client.request("thread/archive", { threadId: nativeThreadId }); - }, - (effect, archiveInput) => - effect.pipe( - Effect.mapError((cause) => - cause._tag === "ProviderAdapterProtocolError" - ? cause - : new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CODEX_PROVIDER, - detail: `Failed to archive Codex thread for provider thread ${archiveInput.providerThread.id}`, - cause, - }), - ), - ), - ), interruptTurn: Effect.fn("CodexAdapterV2.interruptTurn")( function* (requestedInput) { const nativeThreadId = requestedInput.providerThread.nativeThreadRef?.nativeId; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 3caabc84fd0cb..fcf1d8aecd154 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -12,15 +12,11 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, - type OrchestrationV2Run, - type OrchestrationV2Subagent, type Project, ProjectId, ProviderDriverKind, ProviderInstanceId, type ProviderSessionId, - MessageId, - NodeId, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -443,10 +439,9 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly unloads?: Queue.Queue; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; - /** Offers `unload:`, `archive:` and `close`; also gives the runtime archiveThread. */ - readonly nativeThreadExits?: Queue.Queue; } = {}, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; @@ -456,9 +451,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( closeCount: current.closeCount + 1, })).pipe( Effect.andThen( - options.nativeThreadExits === undefined - ? Effect.void - : Queue.offer(options.nativeThreadExits, "close"), + options.unloads === undefined ? Effect.void : Queue.offer(options.unloads, "close"), ), ), ); @@ -563,7 +556,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) => ({ @@ -575,27 +568,14 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( })), ), Effect.andThen( - options.nativeThreadExits === undefined + options.unloads === undefined ? Effect.void : Queue.offer( - options.nativeThreadExits, - `unload:${providerThread.nativeThreadRef?.nativeId}`, + options.unloads, + `${archive === true ? "archive" : "unload"}:${providerThread.nativeThreadRef?.nativeId}`, ), ), ), - ...(options.nativeThreadExits === undefined - ? {} - : { - archiveThread: ({ - providerThread, - }: { - providerThread: OrchestrationV2ProviderThread; - }) => - Queue.offer( - options.nativeThreadExits!, - `archive:${providerThread.nativeThreadRef?.nativeId}`, - ).pipe(Effect.asVoid), - }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"), rollbackThread: () => unimplemented("rollbackThread unused in test"), @@ -634,9 +614,9 @@ function layerTest(input: { readonly startTurn?: Effect.Effect; readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly unloads?: Queue.Queue; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; - readonly nativeThreadExits?: Queue.Queue; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { @@ -674,13 +654,11 @@ 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.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), ...(input.scopeCloseReached === undefined ? {} : { scopeCloseReached: input.scopeCloseReached }), - ...(input.nativeThreadExits === undefined - ? {} - : { nativeThreadExits: input.nativeThreadExits }), }).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), ); const layerConfiguredMcpRegistry = @@ -4265,6 +4243,161 @@ it.effect( }), ); +it.effect.each([true, false])( + "ProviderSessionManagerV2 archives an idle delegated child once its parent has the result (session kept open: %s)", + (keptOpen) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const unloads = yield* Queue.unbounded(); + 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 })], + }); + 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, + }); + yield* eventSink.write({ + events: [ + { + ...created, + payload: { ...created.payload, forkedFrom: { type: "node", nodeId: taskId } }, + }, + { + 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: delivery, observedByRunId: null }, + status: "completed", + result: "Done.", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + ], + }); + 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"); + if (keptOpen) { + assert.deepEqual([yield* Queue.take(unloads), yield* Queue.take(unloads)].toSorted(), [ + "archive:native-delivered", + "unload:native-pending", + ]); + return; + } + // The release may also unload the pending child, but it archives only the delivered one. + const exits: Array = []; + for ( + let exit = yield* Queue.take(unloads); + exit !== "close"; + exit = yield* Queue.take(unloads) + ) { + exits.push(exit); + } + assert.deepEqual( + exits.filter((exit) => exit.startsWith("archive:")), + ["archive:native-delivered"], + ); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + unloads, + // 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", () => @@ -4361,333 +4494,6 @@ it.effect( }), ); -// An app-owned delegated child whose task completed, linked to its parent's task record. -const writeDelegatedChild = Effect.fnUntraced(function* (input: { - readonly parentId: ThreadId; - readonly providerSessionId: ProviderSessionId; - readonly name: string; - readonly delivery: "pending" | "acknowledged"; - /** Initial tasks link through `forkedFrom`; follow-ups through the run's `delegatedTaskId`. */ - readonly link: "forkedFrom" | "run"; -}) { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make(`thread-provider-session-manager-archive-${input.name}`); - const parentRunId = idAllocator.derive.run({ threadId: input.parentId, ordinal: 1 }); - const taskId = NodeId.make(`node-task-${input.name}`); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId: input.providerSessionId, - now, - nativeThreadId: `native-${input.name}`, - }); - const taskEvent = Effect.fnUntraced(function* ( - delivery: "pending" | "acknowledged" | "delivered", - ) { - return { - id: yield* idAllocator.allocate.event({ threadId: input.parentId }), - type: "subagent.updated" as const, - threadId: input.parentId, - runId: parentRunId, - nodeId: taskId, - driver: CODEX_DRIVER, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - id: taskId, - threadId: input.parentId, - 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: "Implement it.", - title: null, - model: null, - completionDelivery: { state: delivery, observedByRunId: null }, - status: "completed", - result: "Done.", - startedAt: now, - completedAt: now, - updatedAt: now, - } satisfies OrchestrationV2Subagent, - }; - }); - const created = yield* makeThreadCreatedEvent({ - idAllocator, - threadId, - now, - delegatedBy: input.parentId, - }); - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - yield* eventSink.write({ - events: [ - { - ...created, - payload: { - ...created.payload, - activeProviderThreadId: providerThread.id, - ...(input.link === "forkedFrom" - ? { forkedFrom: { type: "node" as const, nodeId: taskId } } - : {}), - }, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "provider-thread.updated", - threadId, - driver: CODEX_DRIVER, - occurredAt: now, - payload: providerThread, - }, - { - id: yield* idAllocator.allocate.event({ threadId }), - type: "run.updated", - threadId, - runId, - occurredAt: now, - payload: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId: modelSelection.instanceId, - modelSelection, - providerThreadId: providerThread.id, - userMessageId: MessageId.make(`message-${input.name}`), - rootNodeId: null, - activeAttemptId: null, - status: "completed", - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - ...(input.link === "run" ? { delegatedTaskId: taskId } : {}), - } satisfies OrchestrationV2Run, - }, - yield* taskEvent(input.delivery), - ], - }); - return { threadId, providerThread, taskEvent }; -}); - -// Runs one completed turn; the manager has scheduled its idle work once a subscriber sees the terminal. -const runTurn = Effect.fnUntraced(function* (input: { - readonly state: Ref.Ref; - readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - readonly end?: boolean; -}) { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const { threadId, providerThread, runtime } = input; - const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); - 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: "turn", - attachments: [], - }, - modelSelection, - runtimePolicy, - }); - if (input.end === false) return; - yield* endTurn(input); -}); - -const endTurn = Effect.fnUntraced(function* (input: { - readonly state: Ref.Ref; - readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; - readonly providerThread: OrchestrationV2ProviderThread; -}) { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const subscription = yield* input.runtime.subscribeEvents!; - const providerSessionId = input.providerThread.providerSessionId!; - yield* Queue.offer((yield* Ref.get(input.state)).eventQueues.get(String(providerSessionId))!, { - type: "turn.terminal", - driver: CODEX_DRIVER, - providerThreadId: input.providerThread.id, - providerTurnId: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: `turn-${input.providerThread.id}`, - }), - runOrdinal: 1, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* subscription.events.pipe(Stream.runHead); -}); - -it.effect( - "ProviderSessionManagerV2 archives a delegated child only once its parent took its result", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const exits = yield* Queue.unbounded(); - const settings = yield* ServerSettings.ServerSettingsService.pipe( - Effect.provide(ServerSettings.layerTest()), - ); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const parentId = ThreadId.make("thread-provider-session-manager-archive-parent"); - const providerSessionId = idAllocator.derive.providerSession({ - providerInstanceId: modelSelection.instanceId, - }); - const parentThread = { - ...makeProviderThread({ idAllocator, threadId: parentId, providerSessionId, now }), - nativeThreadRef: null, - }; - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now })], - }); - const child = { parentId, providerSessionId, link: "forkedFrom" } as const; - const delivered = yield* writeDelegatedChild({ - ...child, - name: "delivered", - delivery: "acknowledged", - }); - const late = yield* writeDelegatedChild({ ...child, name: "late", delivery: "pending" }); - const runtime = yield* manager.open({ - threadId: parentId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - for (const { threadId } of [delivered, late]) { - yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); - } - const resumeDelivered = runtime.resumeThread({ - providerThread: delivered.providerThread, - threadId: delivered.threadId, - modelSelection, - runtimePolicy, - }); - yield* resumeDelivered; - yield* runTurn({ - state, - runtime, - threadId: parentId, - providerThread: parentThread, - end: false, - }); - for (const { threadId, providerThread } of [delivered, late]) { - yield* runTurn({ state, runtime, threadId, providerThread }); - } - - // The busy parent keeps the session; each child reaches its own idle unload. - yield* TestClock.adjust("1 second"); - const idleExits = [yield* Queue.take(exits), yield* Queue.take(exits)].sort(); - assert.deepEqual(idleExits, ["archive:native-delivered", "unload:native-late"]); - // Archiving dropped the loaded cache, so reuse resumes (and unarchives) natively. - // An archive is an unload, so a Linked environments change after it needs no extra unload. - yield* settings.updateSettings({ linkedEnvironmentsEnabled: true }); - yield* resumeDelivered; - assert.equal((yield* Ref.get(state)).resumeCount, 2); - - // The late result lands after its child unloaded; the session's idle release archives it. - yield* eventSink.write({ events: [yield* late.taskEvent("delivered")] }); - yield* endTurn({ state, runtime, providerThread: parentThread }); - yield* TestClock.adjust("1 second"); - for (const exit of ["archive:native-delivered", "archive:native-late", "close"]) { - assert.equal(yield* Queue.take(exits), exit); - } - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - nativeThreadExits: exits, - serverSettingsLayer: Layer.succeed(ServerSettings.ServerSettingsService, settings), - }), - ), - ); - }), -); - -it.effect( - "ProviderSessionManagerV2 keeps a delegated child's session until its parent takes the result", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const exits = yield* Queue.unbounded(); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - // The parent runs elsewhere, so nothing else keeps the child's session alive. - const parentId = ThreadId.make("thread-provider-session-manager-remote-parent"); - const providerSessionId = idAllocator.derive.providerSession({ - providerInstanceId: modelSelection.instanceId, - }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId: parentId, now })], - }); - const child = yield* writeDelegatedChild({ - parentId, - providerSessionId, - name: "awaiting-child", - delivery: "pending", - link: "run", - }); - const runtime = yield* manager.open({ - threadId: child.threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - yield* runTurn({ state, runtime, ...child }); - - // The child unloads without archiving, and its result's pending delivery pins the session. - yield* TestClock.adjust("1 second"); - assert.deepEqual([yield* Queue.take(exits), yield* Queue.take(exits)].sort(), [ - "probe", - "unload:native-awaiting-child", - ]); - // A busy parent takes the result after the idle timeout; the pinned session archives it. - yield* eventSink.write({ events: [yield* child.taskEvent("delivered")] }); - yield* TestClock.adjust("1 second"); - for (const exit of ["probe", "archive:native-awaiting-child", "close"]) { - assert.equal(yield* Queue.take(exits), exit); - } - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 1000, - nativeThreadExits: exits, - hasPendingBackgroundWork: Queue.offer(exits, "probe").pipe(Effect.as(false)), - }), - ), - ); - }), -); - it.effect( "ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive, and reopens it when Linked environments changes", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 1aca512fb1e1f..2c65232774035 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -3,7 +3,6 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { isOrchestrationV2WorkActive, - isProviderNativeSubagentThread, ModelSelection, OrchestrationV2DomainEvent, OrchestrationV2ProviderSession, @@ -75,12 +74,6 @@ const WORKING_RUN_STATUSES: ReadonlySet = new Set([ "running", "waiting", ]); -const SETTLED_TASK_STATUSES: ReadonlySet = new Set([ - "completed", - "failed", - "cancelled", - "interrupted", -]); export const ProviderSessionReleaseReason = Schema.Literals([ "idle_timeout", @@ -251,8 +244,6 @@ interface LiveSessionEntry { readonly idleFiber: Fiber.Fiber | null; readonly pinnedSinceMs: number | null; readonly idleThreadUnloads: ReadonlyMap; - /** Archived natively since they last loaded, so a later idle release skips them. */ - readonly archivedThreadIds: ReadonlySet; } interface IdleThreadUnload { @@ -1199,13 +1190,12 @@ export const layerWithOptions = ( } // Capture runtime identity before yielding; replacements can reuse the session id. const probedRuntime = entry.runtime; - // A child's late result can archive it only while this session lives, so it pins like work. const hasPendingWork = - (probedRuntime.hasPendingBackgroundWork === undefined + probedRuntime.hasPendingBackgroundWork === undefined ? false : yield* probedRuntime.hasPendingBackgroundWork.pipe( Effect.catchCause(() => Effect.succeed(false)), - )) || (yield* delegatedChildAwaitsDelivery(entry)); + ); if (hasPendingWork) { const now = yield* Clock.currentTimeMillis; const pinnedSinceMs = entry.pinnedSinceMs ?? now; @@ -1240,7 +1230,18 @@ export const layerWithOptions = ( pinnedForMs: now - pinnedSinceMs, }); } - yield* archiveSettledDelegatedChildren(input.providerSessionId); + // 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, @@ -1402,8 +1403,6 @@ export const layerWithOptions = ( } const loadedProviderThreadKeyByThread = new Map(entry.loadedProviderThreadKeyByThread); loadedProviderThreadKeyByThread.set(input.threadId, input.providerThreadKey); - const archivedThreadIds = new Set(entry.archivedThreadIds); - archivedThreadIds.delete(input.threadId); const linkedEnvironmentsByThread = new Map(entry.linkedEnvironmentsByThread); if ( input.linkedEnvironments !== undefined && @@ -1416,7 +1415,6 @@ export const layerWithOptions = ( ...entry, loadedProviderThreadKeyByThread, linkedEnvironmentsByThread, - archivedThreadIds, }); return updated; }); @@ -1661,214 +1659,43 @@ export const layerWithOptions = ( }, ); - /** - * An app-owned delegated child is ready to archive once its latest task completed, its parent - * took the result, and nothing in the child runs or waits. Provider clients list these as - * ordinary threads; T3 keeps their history and resume unarchives them. - */ - const delegatedChildArchive = Effect.fn("ProviderSessionManager.delegatedChildArchive")( - function* (entry: LiveSessionEntry, threadId: ThreadId) { - const thread = yield* projectionStore.getThread(threadId); - const parentThreadId = thread.lineage.parentThreadId; - if ( - thread.lineage.relationshipToParent !== "subagent" || - parentThreadId === null || - isProviderNativeSubagentThread(thread) || - thread.deletedAt !== null - ) { - return undefined; - } - const child = yield* projectionStore.getThreadRecords( - threadId, - ["runs", "subagents", "providerThreads"], - { - runStatuses: ["preparing", "queued", "starting", "running", "waiting"], - includeLatestRun: true, - }, - ); - const latestRun = child.runs.reduce<(typeof child.runs)[number] | undefined>( - (latest, run) => (latest === undefined || run.ordinal > latest.ordinal ? run : latest), - undefined, - ); + // Provider clients list a delegated child as an ordinary conversation, so a + // finished one is archived once its parent has the result. + const isDeliveredDelegatedChild = Effect.fn( + "ProviderSessionManager.isDeliveredDelegatedChild", + )( + 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 = - latestRun?.delegatedTaskId ?? - (latestRun?.ordinal === 1 && thread.forkedFrom?.type === "node" - ? thread.forkedFrom.nodeId - : undefined); - const providerThread = child.providerThreads.find( - (candidate) => - candidate.id === thread.activeProviderThreadId && - candidate.providerSessionId === entry.runtime.providerSessionId && - candidate.nativeThreadRef?.nativeId != null, - ); - if ( - taskId === undefined || - providerThread === undefined || - child.runs.some((run) => run.status !== "completed") || - child.subagents.some( - (task) => - !SETTLED_TASK_STATUSES.has(task.status) || - task.completionDelivery?.state === "pending" || - task.completionDelivery?.state === "claimed", - ) || - ((yield* projectionStore.getThreadShell(threadId))?.pendingBackgroundTasks?.length ?? - 0) > 0 - ) { - return undefined; - } - const parent = yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], { + runs.at(-1)?.delegatedTaskId ?? + (thread.forkedFrom?.type === "node" ? thread.forkedFrom.nodeId : undefined); + const parentThreadId = thread.lineage.parentThreadId; + if (parentThreadId === null || taskId === undefined) return false; + const task = (yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], { subagentIds: [taskId], - }); - const task = parent.subagents.find((candidate) => candidate.id === taskId); + })).subagents[0]; const delivery = task?.completionDelivery?.state; - if ( - task?.origin !== "app_owned" || - task.childThreadId !== threadId || - task.status !== "completed" - ) { - return undefined; - } - if (delivery === "pending" || delivery === "claimed") { - return { state: "awaiting_delivery" } as const; - } - if (delivery !== "delivered" && delivery !== "acknowledged") return undefined; - // Codex archive also archives the native subagents this thread spawned. - const pendingWork = - entry.runtime.hasPendingBackgroundWorkForThread === undefined - ? false - : yield* entry.runtime.hasPendingBackgroundWorkForThread(providerThread, { - includeSubagents: true, - }); - return pendingWork ? undefined : { state: "ready" as const, providerThread }; - }, - (effect) => effect.pipe(Effect.orElseSucceed(() => undefined)), - ); - - /** Callers hold the thread's attachment lock and have already dropped it from the loaded cache. */ - const archiveProviderThread = Effect.fn("ProviderSessionManager.archiveProviderThread")( - function* (input: { - readonly entry: LiveSessionEntry; - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - }) { - const archiveThread = input.entry.exposedRuntime.archiveThread; - if (archiveThread === undefined) return false; - const archived = yield* archiveThread({ providerThread: input.providerThread }).pipe( - Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), - Effect.as(true), - Effect.catchCause((cause) => - Effect.logWarning("orchestration-v2.driver-session.archive-failed", { - providerSessionId: input.entry.runtime.providerSessionId, - threadId: input.threadId, - providerThreadId: input.providerThread.id, - cause, - }).pipe(Effect.as(false)), - ), + return ( + task?.origin === "app_owned" && + task.childThreadId === threadId && + task.status === "completed" && + (delivery === "delivered" || delivery === "acknowledged") ); - if (!archived) return false; - yield* Ref.update(sessions, (current) => { - const key = sessionKey(input.entry.runtime.providerSessionId); - const latest = current.get(key); - if (latest?.runtime !== input.entry.runtime) return current; - const linkedEnvironmentsByThread = new Map(latest.linkedEnvironmentsByThread); - linkedEnvironmentsByThread.delete(input.threadId); - const updated = new Map(current); - updated.set(key, { - ...latest, - linkedEnvironmentsByThread, - archivedThreadIds: new Set(latest.archivedThreadIds).add(input.threadId), - }); - return updated; - }); - return true; }, + (effect) => effect.pipe(Effect.orElseSucceed(() => false)), ); - const delegatedChildAwaitsDelivery = Effect.fn( - "ProviderSessionManager.delegatedChildAwaitsDelivery", - )(function* (entry: LiveSessionEntry) { - if ( - !entry.supportsMultipleProviderThreads || - entry.exposedRuntime.archiveThread === undefined - ) - return false; - for (const threadId of entry.attachedThreadIds) { - if (entry.archivedThreadIds.has(threadId)) continue; - if ((yield* delegatedChildArchive(entry, threadId))?.state === "awaiting_delivery") - return true; - } - return false; - }); - - // Archives children whose result reached the parent after their own idle unload. - const archiveSettledDelegatedChildren = Effect.fn( - "ProviderSessionManager.archiveSettledDelegatedChildren", - )(function* (providerSessionId: ProviderSessionId) { - const key = sessionKey(providerSessionId); - const entry = (yield* Ref.get(sessions)).get(key); - if ( - entry === undefined || - !entry.supportsMultipleProviderThreads || - entry.exposedRuntime.archiveThread === undefined - ) { - return; - } - yield* Effect.forEach( - entry.attachedThreadIds, - (threadId) => - threadAttachment.withLock( - threadAttachmentKey({ providerSessionId, threadId }), - Effect.gen(function* () { - if (entry.archivedThreadIds.has(threadId)) return; - const child = yield* delegatedChildArchive(entry, threadId); - if (child?.state !== "ready") return; - const { providerThread } = child; - const claimed = yield* Ref.modify(sessions, (current) => { - const latest = current.get(key); - if ( - latest?.runtime !== entry.runtime || - latest.busyTurns.size > 0 || - !latest.attachedThreadIds.has(threadId) || - latest.archivedThreadIds.has(threadId) - ) { - return [undefined, current] as const; - } - const loadedProviderThreadKeyByThread = new Map( - latest.loadedProviderThreadKeyByThread, - ); - loadedProviderThreadKeyByThread.delete(threadId); - const updated = new Map(current); - updated.set(key, { - ...latest, - loadedProviderThreadKeyByThread, - }); - return [{ pending: latest.idleThreadUnloads.get(threadId) }, updated] as const; - }); - if (claimed === undefined) return; - // A failed archive keeps the idle unload tracked; the cleared cache only forces a resume. - if (!(yield* archiveProviderThread({ entry, threadId, providerThread }))) return; - yield* Ref.update(sessions, (current) => { - const latest = current.get(key); - if ( - latest?.runtime !== entry.runtime || - latest.idleThreadUnloads.get(threadId) !== claimed.pending - ) - return current; - const idleThreadUnloads = new Map(latest.idleThreadUnloads); - idleThreadUnloads.delete(threadId); - return new Map(current).set(key, { ...latest, idleThreadUnloads }); - }); - }), - ), - { discard: true }, - ); - }); - 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 { const outcome = yield* threadAttachment.withLock( threadAttachmentKey(input), @@ -1895,12 +1722,8 @@ export const layerWithOptions = ( .hasPendingBackgroundWorkForThread(pending.providerThread) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (hasPendingWork) return "deferred" as const; - const child = - entry.exposedRuntime.archiveThread === undefined - ? undefined - : yield* delegatedChildArchive(entry, input.threadId); - const archivable = - child?.state === "ready" && child.providerThread.id === pending.providerThread.id; + const archive = yield* isDeliveredDelegatedChild(input.threadId); + if (input.archiveOnly === true && !archive) return "skipped" as const; const unloading = yield* Ref.modify(sessions, (current) => { const latest = current.get(key); if ( @@ -1924,17 +1747,7 @@ export const layerWithOptions = ( return [true, updated] as const; }); if (!unloading) return "skipped" as const; - if ( - archivable && - (yield* archiveProviderThread({ - entry, - threadId: input.threadId, - providerThread: pending.providerThread, - })) - ) { - return "unloaded" as const; - } - yield* unloadThread({ providerThread: pending.providerThread }).pipe( + yield* unloadThread({ providerThread: pending.providerThread, archive }).pipe( Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), Effect.tap(() => Ref.update(sessions, (current) => { @@ -1958,7 +1771,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); } @@ -2766,7 +2579,6 @@ export const layerWithOptions = ( idleFiber: null, pinnedSinceMs: null, idleThreadUnloads: new Map(), - archivedThreadIds: new Set(), }; yield* Ref.update(sessions, (current) => { const updated = new Map(current); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 872af765b6bc8..09ce63e2a3e65 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -274,14 +274,6 @@ layouts with an inspector, its thread opens beside the parent. Maximize it to fill the screen; close the full-screen view to return to the parent and inspector. Stop on a thread also stops the subagents it delegated to. -With Codex, a delegated task's thread is archived in Codex's own history once the -task has finished, the parent agent has its result, and the thread has sat idle. -T3 Code keeps the full history, and a follow-up to the task continues the same -Codex conversation. - -Threads from before this change stay in Codex's history. To archive one, run -`codex archive ` with the same Codex home on the machine hosting it. - Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent thread asks for it. diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 7c3a7eda9ded9..ce6fd62cb42a6 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -685,13 +685,8 @@ export interface ProviderAdapterV2SessionRuntime { }) => Effect.Effect; readonly unloadThread?: (input: { readonly providerThread: OrchestrationV2ProviderThread; - }) => Effect.Effect; - /** - * Unloads the thread and hides it from the provider's own clients. `resumeThread` must - * restore it under the same native id. - */ - readonly archiveThread?: (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; readonly respondToRuntimeRequest: ( input: ProviderAdapterV2RuntimeRequestResponseInput, From b1ec4aa67aec8d9295130b70e26b54b91c0075e2 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:58:36 +0000 Subject: [PATCH 10/11] fix(codex): hold a delegated child until its result is delivered, unload when archive fails A child whose result is still pending at its idle unload now waits another idle period instead of unloading without archive, so a late delivery still archives it. A failed Codex thread/archive falls back to thread/unsubscribe so the thread does not stay loaded. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.ts | 9 +++++- .../ProviderSessionManager.test.ts | 32 +++++++++++-------- .../ProviderSessionManager.ts | 30 +++++++++-------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index ef8365ab3405b..ae21160dea40d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7426,7 +7426,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( sessionApprovals.delete(nativeThreadId); // Archive also unloads; resumeNativeThread unarchives on the next turn. if (unloadInput.archive === true) { - yield* client.request("thread/archive", { threadId: nativeThreadId }); + yield* client.request("thread/archive", { threadId: nativeThreadId }).pipe( + // A failed archive must still unload; the archive error is still reported. + Effect.tapError(() => + client + .request("thread/unsubscribe", { threadId: nativeThreadId }) + .pipe(Effect.ignore), + ), + ); } else { yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); } diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index fcf1d8aecd154..61cbd5da2d051 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -4263,6 +4263,7 @@ it.effect.each([true, false])( 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 }); @@ -4280,13 +4281,9 @@ it.effect.each([true, false])( now, delegatedBy: parentThreadId, }); - yield* eventSink.write({ - events: [ - { - ...created, - payload: { ...created.payload, forkedFrom: { type: "node", nodeId: taskId } }, - }, - { + const taskEvent = (state: "pending" | "delivered") => + Effect.gen(function* () { + return { id: yield* idAllocator.allocate.event({ threadId: parentThreadId }), type: "subagent.updated", threadId: parentThreadId, @@ -4308,14 +4305,23 @@ it.effect.each([true, false])( prompt: "Delegated work", title: null, model: null, - completionDelivery: { state: delivery, observedByRunId: 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({ @@ -4363,13 +4369,13 @@ it.effect.each([true, false])( yield* TestClock.adjust("1 second"); if (keptOpen) { - assert.deepEqual([yield* Queue.take(unloads), yield* Queue.take(unloads)].toSorted(), [ - "archive:native-delivered", - "unload:native-pending", - ]); + assert.equal(yield* Queue.take(unloads), "archive:native-delivered"); + // The pending child stays loaded 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"); return; } - // The release may also unload the pending child, but it archives only the delivered one. const exits: Array = []; for ( let exit = yield* Queue.take(unloads); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 2c65232774035..f3139e27a4c2d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1661,9 +1661,7 @@ 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 isDeliveredDelegatedChild = Effect.fn( - "ProviderSessionManager.isDeliveredDelegatedChild", - )( + const delegatedChildArchive = Effect.fn("ProviderSessionManager.delegatedChildArchive")( function* (threadId: ThreadId) { const { thread, runs } = yield* projectionStore.getThreadRecords(threadId, ["runs"], { runStatuses: [], @@ -1674,19 +1672,22 @@ export const layerWithOptions = ( runs.at(-1)?.delegatedTaskId ?? (thread.forkedFrom?.type === "node" ? thread.forkedFrom.nodeId : undefined); const parentThreadId = thread.lineage.parentThreadId; - if (parentThreadId === null || taskId === undefined) return false; + if (parentThreadId === null || taskId === undefined) return undefined; const task = (yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], { subagentIds: [taskId], })).subagents[0]; - const delivery = task?.completionDelivery?.state; - return ( - task?.origin === "app_owned" && - task.childThreadId === threadId && - task.status === "completed" && - (delivery === "delivered" || delivery === "acknowledged") - ); + 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(() => false)), + (effect) => effect.pipe(Effect.orElseSucceed(() => undefined)), ); const unloadIdleThread = Effect.fn("ProviderSessionManager.unloadIdleThread")( @@ -1722,7 +1723,10 @@ export const layerWithOptions = ( .hasPendingBackgroundWorkForThread(pending.providerThread) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (hasPendingWork) return "deferred" as const; - const archive = yield* isDeliveredDelegatedChild(input.threadId); + 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); From cbc5ee7d6343dfa57331cf1500b69882e194b603 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:21:32 +0000 Subject: [PATCH 11/11] fix(codex): unload after a failed or timed-out archive, keep the session for undelivered children An archive that timed out was interrupted before the adapter's unsubscribe fallback ran, leaving the native thread loaded. The manager now falls back to a plain unload, with its own timeout, after any archive failure or timeout. Session release closed the runtime while a finished child still waited for its parent to take the result, so the child was never archived. Such a child now holds the session's existing bounded idle pin until delivery. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.ts | 16 ++----- .../ProviderSessionManager.test.ts | 48 +++++++++++-------- .../ProviderSessionManager.ts | 31 ++++++++++-- 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index ae21160dea40d..d23d7aec3fe55 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7425,18 +7425,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); // Archive also unloads; resumeNativeThread unarchives on the next turn. - if (unloadInput.archive === true) { - yield* client.request("thread/archive", { threadId: nativeThreadId }).pipe( - // A failed archive must still unload; the archive error is still reported. - Effect.tapError(() => - client - .request("thread/unsubscribe", { threadId: nativeThreadId }) - .pipe(Effect.ignore), - ), - ); - } else { - yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); - } + yield* client.request( + unloadInput.archive === true ? "thread/archive" : "thread/unsubscribe", + { threadId: nativeThreadId }, + ); }, (effect, unloadInput) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 61cbd5da2d051..51ee9edc24d87 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -440,6 +440,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly unloads?: Queue.Queue; + readonly archive?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; } = {}, @@ -575,6 +576,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( `${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"), @@ -615,6 +617,7 @@ function layerTest(input: { readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly unloads?: Queue.Queue; + readonly archive?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; @@ -655,6 +658,7 @@ function layerTest(input: { ...(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 ? {} @@ -4243,9 +4247,14 @@ it.effect( }), ); -it.effect.each([true, false])( - "ProviderSessionManagerV2 archives an idle delegated child once its parent has the result (session kept open: %s)", - (keptOpen) => +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(); @@ -4368,26 +4377,18 @@ it.effect.each([true, false])( } yield* TestClock.adjust("1 second"); - if (keptOpen) { - assert.equal(yield* Queue.take(unloads), "archive:native-delivered"); - // The pending child stays loaded 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"); + 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; } - const exits: Array = []; - for ( - let exit = yield* Queue.take(unloads); - exit !== "close"; - exit = yield* Queue.take(unloads) - ) { - exits.push(exit); - } - assert.deepEqual( - exits.filter((exit) => exit.startsWith("archive:")), - ["archive:native-delivered"], - ); + // 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( @@ -4396,6 +4397,11 @@ it.effect.each([true, false])( 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), }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f3139e27a4c2d..cbd80a99e8eca 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1191,11 +1191,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; @@ -1751,8 +1759,23 @@ export const layerWithOptions = ( return [true, updated] as const; }); if (!unloading) return "skipped" as const; - yield* unloadThread({ providerThread: pending.providerThread, archive }).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);