authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 03:33:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 03:33:34-07:00
log519d64f6cec0a0f9ed18fbca4a3f8695cac44506
treed03bfae80f5ede9118aeef0399142ce79eb9c380
parentadb04202b25c1c45255e76be9f03f0b346b9c5c4
signature Signed by PGP key B5690EEEBB952194

fix(server): settled and idle-released threads no longer stay Waiting (#19355)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

7 files changed, 448 insertions(+), 7 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+2
......@@ -9065,6 +9065,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
90659065 getModelContextWindow: (selection) =>
90669066 resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection),
90679067 events: Stream.fromEffectRepeat(Queue.take(events)),
9068 // Background shells, agents, monitors, and workflows end with the CLI.
9069 backgroundTasksEndWithProcess: true,
90689070 hasPendingBackgroundWork: Effect.gen(function* () {
90699071 // Session capability: any native thread with pending work pins idle.
90709072 for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) {
apps/server/src/orchestration-v2/EventSink.ts+56
......@@ -2,6 +2,7 @@ import {
22 CommandId,
33 type OrchestrationV2Run,
44 OrchestrationV2DomainEvent,
5 type OrchestrationV2ProviderThread,
56 OrchestrationV2StoredEvent,
67 ProviderThreadId,
78 RunAttemptId,
......@@ -119,6 +120,22 @@ export interface EventSinkV2Shape {
119120 },
120121 EventSinkV2Error
121122 >;
123 /**
124 * Atomically commit only while the stored provider thread still equals
125 * `expected`, the row its writer read, so a read-modify-write of the row
126 * cannot overwrite an update published in between. The whole row is
127 * compared because not every writer moves `updatedAt`.
128 */
129 readonly writeIfProviderThreadUnchanged: (input: {
130 readonly expected: OrchestrationV2ProviderThread;
131 readonly events: ReadonlyArray<OrchestrationV2DomainEvent>;
132 }) => Effect.Effect<
133 {
134 readonly committed: boolean;
135 readonly storedEvents: ReadonlyArray<OrchestrationV2StoredEvent>;
136 },
137 EventSinkV2Error
138 >;
122139 readonly commitCommand: (input: {
123140 readonly commandId: CommandId;
124141 readonly threadId: ThreadId;
......@@ -509,6 +526,39 @@ const layerBase: Layer.Layer<
509526 );
510527 });
511528
529 const writeIfProviderThreadUnchangedEffect = Effect.fn(
530 "orchestrationV2.EventSink.writeIfProviderThreadUnchanged",
531 )(function* (input: Parameters<EventSinkV2Shape["writeIfProviderThreadUnchanged"]>[0]) {
532 yield* Effect.annotateCurrentSpan({
533 "orchestration_v2.event_count": input.events.length,
534 "orchestration_v2.provider_thread_id": input.expected.id,
535 });
536 const expectedJson = yield* ProjectionStore.encodeProviderThreadPayload(input.expected);
537
538 return yield* commitThenPublish(
539 Effect.gen(function* () {
540 const rows = yield* sql<{ readonly payload_json: string }>`
541 SELECT payload_json
542 FROM orchestration_v2_projection_provider_threads
543 WHERE provider_thread_id = ${input.expected.id}
544 LIMIT 1
545 `;
546 if (rows[0]?.payload_json !== expectedJson) {
547 return {
548 committed: false as const,
549 storedEvents: [] as ReadonlyArray<OrchestrationV2StoredEvent>,
550 };
551 }
552
553 const normalized = yield* normalizeEvents(input.events);
554 const storedEvents = yield* eventStore.append({ events: normalized });
555 yield* applyStoredEvents(storedEvents);
556 return { committed: true as const, storedEvents };
557 }),
558 (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void),
559 );
560 });
561
512562 const existingCommandResult = (commandId: CommandId) =>
513563 Effect.gen(function* () {
514564 const existing = yield* commandReceipts.getByCommandId(commandId);
......@@ -804,6 +854,12 @@ const layerBase: Layer.Layer<
804854 }),
805855 ),
806856 ),
857 writeIfProviderThreadUnchanged: (input) =>
858 writeIfProviderThreadUnchangedEffect(input).pipe(
859 Effect.mapError(
860 (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }),
861 ),
862 ),
807863 commitCommand: (input) =>
808864 commitCommandEffect(input).pipe(
809865 Effect.mapError(
apps/server/src/orchestration-v2/ProjectionStore.ts+2-1
......@@ -1056,7 +1056,8 @@ const encodeSubagentPayload = Schema.encodeEffect(
10561056const encodeProviderSessionPayload = Schema.encodeEffect(
10571057 Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema),
10581058);
1059const encodeProviderThreadPayload = Schema.encodeEffect(
1059/** Encodes a provider thread exactly as its projection row stores `payload_json`. */
1060export const encodeProviderThreadPayload = Schema.encodeEffect(
10601061 Schema.fromJsonString(OrchestrationV2ProviderThreadJsonSchema),
10611062);
10621063const encodeProviderTurnPayload = Schema.encodeEffect(
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+251-3
......@@ -147,6 +147,22 @@ const layerPausingAttachEventSink = (pause: {
147147 }),
148148 ).pipe(Layer.provide(layerTestEventSink));
149149
150const layerBeforeUnchangedWriteEventSink = (before: Ref.Ref<Effect.Effect<void>>) =>
151 Layer.effect(
152 EventSink.EventSinkV2,
153 Effect.gen(function* () {
154 const delegate = yield* EventSink.EventSinkV2;
155 return EventSink.EventSinkV2.of({
156 ...delegate,
157 writeIfProviderThreadUnchanged: (input) =>
158 Ref.getAndSet(before, Effect.void).pipe(
159 Effect.flatten,
160 Effect.andThen(delegate.writeIfProviderThreadUnchanged(input)),
161 ),
162 });
163 }),
164 ).pipe(Layer.provide(layerTestEventSink));
165
150166const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2;
151167const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = {
152168 ...CodexCapabilities,
......@@ -318,6 +334,8 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
318334 }) => Effect.Effect<void>;
319335 readonly hasPendingBackgroundWork?: Effect.Effect<boolean>;
320336 readonly hasPendingBackgroundWorkForThread?: Effect.Effect<boolean>;
337 /** False models a runtime whose background tasks can outlive its process. */
338 readonly backgroundTasksEndWithProcess?: boolean;
321339 readonly hangSessionScopeClose?: boolean;
322340 readonly startTurn?: Effect.Effect<void>;
323341 readonly beforeUnload?: Effect.Effect<void>;
......@@ -400,6 +418,9 @@ const makeProviderAdapter = Effect.fnUntraced(function* (
400418 }),
401419 )
402420 : Stream.fromQueue(events),
421 ...(options.backgroundTasksEndWithProcess === false
422 ? {}
423 : { backgroundTasksEndWithProcess: true as const }),
403424 ...(options.hasPendingBackgroundWork === undefined
404425 ? {}
405426 : { hasPendingBackgroundWork: options.hasPendingBackgroundWork }),
......@@ -458,6 +479,8 @@ function layerTest(input: {
458479 readonly failReleaseEventWrites?: boolean;
459480 readonly flakyReleaseWrites?: FlakyReleaseWrites;
460481 readonly pauseAttachWrite?: Parameters<typeof layerPausingAttachEventSink>[0];
482 /** Runs once, right before the next provider-thread compare-and-set write. */
483 readonly beforeUnchangedWrite?: Ref.Ref<Effect.Effect<void>>;
461484 /** Once armed, holds the next credential lookup until it is interrupted. */
462485 readonly pauseResolve?: {
463486 readonly armed: Ref.Ref<boolean>;
......@@ -465,6 +488,7 @@ function layerTest(input: {
465488 };
466489 readonly hasPendingBackgroundWork?: Effect.Effect<boolean>;
467490 readonly hasPendingBackgroundWorkForThread?: Effect.Effect<boolean>;
491 readonly backgroundTasksEndWithProcess?: boolean;
468492 readonly hangSessionScopeClose?: boolean;
469493 readonly startTurn?: Effect.Effect<void>;
470494 readonly beforeUnload?: Effect.Effect<void>;
......@@ -478,9 +502,11 @@ function layerTest(input: {
478502 ? layerFlakyReleaseEventSink(input.flakyReleaseWrites)
479503 : input.pauseAttachWrite !== undefined
480504 ? layerPausingAttachEventSink(input.pauseAttachWrite)
481 : input.failReleaseEventWrites
482 ? layerFailingReleaseEventSink
483 : layerTestEventSink;
505 : input.beforeUnchangedWrite !== undefined
506 ? layerBeforeUnchangedWriteEventSink(input.beforeUnchangedWrite)
507 : input.failReleaseEventWrites
508 ? layerFailingReleaseEventSink
509 : layerTestEventSink;
484510 const layerRegistry = Layer.unwrap(
485511 makeProviderAdapter(input.state, {
486512 failEventStream: input.failEventStream ?? false,
......@@ -493,6 +519,9 @@ function layerTest(input: {
493519 ...(input.hasPendingBackgroundWorkForThread === undefined
494520 ? {}
495521 : { hasPendingBackgroundWorkForThread: input.hasPendingBackgroundWorkForThread }),
522 ...(input.backgroundTasksEndWithProcess === undefined
523 ? {}
524 : { backgroundTasksEndWithProcess: input.backgroundTasksEndWithProcess }),
496525 ...(input.hangSessionScopeClose === undefined
497526 ? {}
498527 : { hangSessionScopeClose: input.hangSessionScopeClose }),
......@@ -2769,6 +2798,225 @@ it.effect("ProviderSessionManagerV2 persists release when session scope close ha
27692798 }),
27702799);
27712800
2801// Seeds a thread with a background command on a provider thread of the
2802// session it then opens, and one on a provider thread of another session.
2803const openSessionWithBackgroundCommand = (fixtureName: string) =>
2804 Effect.gen(function* () {
2805 const eventSink = yield* EventSink.EventSinkV2;
2806 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2807 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2808 const now = yield* DateTime.now;
2809 const threadId = yield* idAllocator.allocate.thread({
2810 fixtureName,
2811 projectId: yield* idAllocator.allocate.project({ fixtureName }),
2812 });
2813 const providerSessionId = yield* idAllocator.allocate.providerSession({
2814 providerInstanceId: modelSelection.instanceId,
2815 threadId,
2816 });
2817 const otherProviderSessionId = yield* idAllocator.allocate.providerSession({
2818 providerInstanceId: modelSelection.instanceId,
2819 threadId,
2820 });
2821 const pendingBackgroundTasks = [
2822 { taskId: "dev-server", kind: "command", description: "python3 -m http.server" },
2823 ] as const;
2824 const providerThreads = [
2825 {
2826 ...makeProviderThread({ idAllocator, threadId, providerSessionId, now }),
2827 pendingBackgroundTasks,
2828 },
2829 {
2830 ...makeProviderThread({
2831 idAllocator,
2832 threadId,
2833 providerSessionId: otherProviderSessionId,
2834 now,
2835 nativeThreadId: "native-thread-other-session",
2836 }),
2837 pendingBackgroundTasks,
2838 },
2839 ];
2840 const writeProviderThread = (payload: OrchestrationV2ProviderThread) =>
2841 Effect.gen(function* () {
2842 yield* eventSink.write({
2843 events: [
2844 {
2845 id: yield* idAllocator.allocate.event({ threadId }),
2846 type: "provider-thread.updated",
2847 threadId,
2848 driver: CODEX_DRIVER,
2849 occurredAt: now,
2850 payload,
2851 },
2852 ],
2853 });
2854 });
2855 yield* eventSink.write({
2856 events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
2857 });
2858 yield* Effect.forEach(providerThreads, writeProviderThread);
2859 yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy });
2860 // The task ids each seeded provider thread lists now.
2861 const rosters = Effect.gen(function* () {
2862 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
2863 const projection = yield* projectionStore.getThreadProjection(threadId);
2864 return providerThreads.map((seeded) =>
2865 (
2866 projection.providerThreads.find((thread) => thread.id === seeded.id)
2867 ?.pendingBackgroundTasks ?? []
2868 ).map((task) => task.taskId),
2869 );
2870 });
2871 return { threadId, providerSessionId, providerThreads, writeProviderThread, rosters };
2872 });
2873
2874it.effect(
2875 "ProviderSessionManagerV2 clears the background roster of a session a detach releases",
2876 () =>
2877 Effect.gen(function* () {
2878 const state = yield* Ref.make(emptyState);
2879 const effect = Effect.gen(function* () {
2880 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2881 const { threadId, providerSessionId, rosters } = yield* openSessionWithBackgroundCommand(
2882 "provider-session-manager-detach-roster",
2883 );
2884 assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]);
2885
2886 // Settle and archive detach the thread, which releases an exclusive
2887 // (Claude-like) session.
2888 yield* manager.detach({ providerSessionId, threadId, detail: "Thread settled." });
2889
2890 assert.isTrue(Option.isNone(yield* manager.get(providerSessionId)));
2891 assert.deepStrictEqual(yield* rosters, [[], ["dev-server"]]);
2892 });
2893
2894 yield* effect.pipe(
2895 Effect.provide(
2896 layerTest({ state, idleTimeoutMs: 60_000, capabilities: ExclusiveCapabilities }),
2897 ),
2898 );
2899 }),
2900);
2901
2902it.effect("ProviderSessionManagerV2 clears the background roster of an idle-released session", () =>
2903 Effect.gen(function* () {
2904 const state = yield* Ref.make(emptyState);
2905 const effect = Effect.gen(function* () {
2906 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2907 const { providerSessionId, rosters } = yield* openSessionWithBackgroundCommand(
2908 "provider-session-manager-idle-roster",
2909 );
2910 assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]);
2911
2912 yield* TestClock.adjust("1 second");
2913 yield* Effect.yieldNow;
2914
2915 assert.isTrue(Option.isNone(yield* manager.get(providerSessionId)));
2916 assert.deepStrictEqual(yield* rosters, [[], ["dev-server"]]);
2917 });
2918
2919 yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 })));
2920 }),
2921);
2922
2923it.effect("ProviderSessionManagerV2 keeps the roster of a runtime whose tasks can outlive it", () =>
2924 Effect.gen(function* () {
2925 const state = yield* Ref.make(emptyState);
2926 const effect = Effect.gen(function* () {
2927 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2928 const { providerSessionId, rosters } = yield* openSessionWithBackgroundCommand(
2929 "provider-session-manager-outliving-roster",
2930 );
2931
2932 // Like OpenCode, whose background subagents run on a shared server.
2933 yield* manager.close(providerSessionId);
2934
2935 assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]);
2936 });
2937
2938 yield* effect.pipe(
2939 Effect.provide(
2940 layerTest({ state, idleTimeoutMs: 60_000, backgroundTasksEndWithProcess: false }),
2941 ),
2942 );
2943 }),
2944);
2945
2946it.effect(
2947 "ProviderSessionManagerV2 keeps background tasks a replacement reports during release",
2948 () =>
2949 Effect.gen(function* () {
2950 const state = yield* Ref.make(emptyState);
2951 const scopeCloseReached = yield* Deferred.make<void>();
2952 const effect = Effect.gen(function* () {
2953 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2954 const { providerSessionId, providerThreads, writeProviderThread, rosters } =
2955 yield* openSessionWithBackgroundCommand("provider-session-manager-replacement-roster");
2956 const release = yield* manager.close(providerSessionId).pipe(Effect.forkChild);
2957
2958 // While the old process closes, a replacement session with the same id
2959 // reports a task of its own next to the dead one.
2960 yield* Deferred.await(scopeCloseReached);
2961 yield* writeProviderThread({
2962 ...providerThreads[0]!,
2963 pendingBackgroundTasks: [
2964 ...providerThreads[0]!.pendingBackgroundTasks,
2965 { taskId: "replacement-task", kind: "command" },
2966 ],
2967 });
2968 yield* TestClock.adjust("30 seconds");
2969 yield* Fiber.join(release);
2970
2971 assert.deepStrictEqual(yield* rosters, [["replacement-task"], ["dev-server"]]);
2972 });
2973
2974 yield* effect.pipe(
2975 Effect.provide(
2976 layerTest({
2977 state,
2978 idleTimeoutMs: 60_000,
2979 hangSessionScopeClose: true,
2980 scopeCloseReached,
2981 }),
2982 ),
2983 );
2984 }),
2985);
2986
2987it.effect("ProviderSessionManagerV2 keeps a provider update that lands while release clears", () =>
2988 Effect.gen(function* () {
2989 const state = yield* Ref.make(emptyState);
2990 const beforeUnchangedWrite = yield* Ref.make<Effect.Effect<void>>(Effect.void);
2991 const effect = Effect.gen(function* () {
2992 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2993 const { providerSessionId, providerThreads, writeProviderThread, rosters } =
2994 yield* openSessionWithBackgroundCommand("provider-session-manager-raced-roster");
2995 // A replacement reports a task after cleanup read the row, before it
2996 // writes, without moving updatedAt (not every writer does).
2997 const seeded = providerThreads[0]!;
2998 yield* Ref.set(
2999 beforeUnchangedWrite,
3000 writeProviderThread({
3001 ...seeded,
3002 pendingBackgroundTasks: [
3003 ...seeded.pendingBackgroundTasks,
3004 { taskId: "replacement-task", kind: "command" },
3005 ],
3006 }).pipe(Effect.orDie),
3007 );
3008
3009 yield* manager.close(providerSessionId);
3010
3011 assert.deepStrictEqual(yield* rosters, [["replacement-task"], ["dev-server"]]);
3012 });
3013
3014 yield* effect.pipe(
3015 Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, beforeUnchangedWrite })),
3016 );
3017 }),
3018);
3019
27723020it.effect("ProviderSessionManagerV2 defers idle release while background work is pending", () =>
27733021 Effect.gen(function* () {
27743022 const state = yield* Ref.make(emptyState);
apps/server/src/orchestration-v2/ProviderSessionManager.ts+127-3
......@@ -774,14 +774,126 @@ export const layerWithOptions = (
774774 }
775775 });
776776
777 // Reads the background tasks a released session's process left on its
778 // provider threads. Read as soon as the session leaves the live map:
779 // after that, a replacement session with the same id can open and report
780 // tasks of its own on the same provider threads. Server shutdown keeps
781 // rosters, since startup recovery clears them and records the cancelled
782 // work. Only runtimes whose tasks end with their process opt in (Claude,
783 // Muse). OpenCode's background subagents run on a server shared by its
784 // sessions and can outlive this one, so their roster stays.
785 const readReleasedBackgroundTasks = (input: {
786 readonly entry: LiveSessionEntry;
787 readonly reason: ProviderSessionReleaseReason;
788 readonly detachedThreadId?: ThreadId;
789 }) =>
790 Effect.gen(function* () {
791 if (
792 input.reason === "server_shutdown" ||
793 input.entry.runtime.backgroundTasksEndWithProcess !== true
794 ) {
795 return [];
796 }
797 const providerSessionId = input.entry.runtime.providerSessionId;
798 // A detach that released the session already removed its thread
799 // from the attached set.
800 const threadIds = new Set(input.entry.attachedThreadIds);
801 if (input.detachedThreadId !== undefined) threadIds.add(input.detachedThreadId);
802 const released = [];
803 for (const threadId of threadIds) {
804 const projection = yield* projectionStore.getThreadRecords(threadId, [
805 "providerThreads",
806 ]);
807 for (const providerThread of projection.providerThreads) {
808 const tasks = providerThread.pendingBackgroundTasks ?? [];
809 if (providerThread.providerSessionId !== providerSessionId || tasks.length === 0) {
810 continue;
811 }
812 released.push({
813 threadId,
814 providerSessionId,
815 providerThreadId: providerThread.id,
816 taskIds: new Set(tasks.map((task) => task.taskId)),
817 });
818 }
819 }
820 return released;
821 }).pipe(
822 Effect.catchCause((cause) =>
823 Effect.logWarning("orchestration-v2.provider-session-release-roster-read-failed", {
824 providerSessionId: input.entry.runtime.providerSessionId,
825 cause,
826 }).pipe(Effect.as([])),
827 ),
828 );
829 type ReleasedBackgroundTasks = Effect.Success<
830 ReturnType<typeof readReleasedBackgroundTasks>
831 >[number];
832
833 // Removes the tasks `readReleasedBackgroundTasks` found from their
834 // rosters. Their process is gone, so nothing will report them ending,
835 // and a leftover roster keeps the thread Waiting (#15239). Tasks a
836 // replacement session reported since are left in place. The write
837 // commits only if the stored row still equals what was read, and is
838 // retried a few times when a provider update lands in between.
839 const writeReleasedBackgroundWorkEvents = (
840 released: ReadonlyArray<ReleasedBackgroundTasks>,
841 ) =>
842 Effect.forEach(
843 released,
844 ({ threadId, providerSessionId, providerThreadId, taskIds }) =>
845 Effect.gen(function* () {
846 for (let attempt = 0; attempt < 3; attempt++) {
847 const projection = yield* projectionStore.getThreadRecords(threadId, [
848 "providerThreads",
849 ]);
850 const providerThread = projection.providerThreads.find(
851 (candidate) => candidate.id === providerThreadId,
852 );
853 const tasks = providerThread?.pendingBackgroundTasks ?? [];
854 const remaining = tasks.filter((task) => !taskIds.has(task.taskId));
855 if (providerThread === undefined || remaining.length === tasks.length) return;
856 const now = yield* DateTime.now;
857 const { committed } = yield* eventSink.writeIfProviderThreadUnchanged({
858 expected: providerThread,
859 events: [
860 {
861 id: yield* idAllocator.allocate.event({ threadId, providerSessionId }),
862 type: "provider-thread.updated",
863 threadId,
864 driver: providerThread.driver,
865 providerInstanceId: providerThread.providerInstanceId,
866 occurredAt: now,
867 payload: {
868 ...providerThread,
869 pendingBackgroundTasks: remaining,
870 updatedAt: now,
871 },
872 },
873 ],
874 });
875 if (committed) return;
876 }
877 yield* Effect.logWarning(
878 "orchestration-v2.provider-session-release-roster-conflict",
879 {
880 providerSessionId,
881 providerThreadId,
882 },
883 );
884 }),
885 { discard: true },
886 );
887
777888 // Records a released session as stopped and resolves the live runtime
778 // requests it left. Each write runs even if the other fails. Once a
779 // replacement session opens with the same id, it owns the session status,
780 // so only the requests are settled.
889 // requests and background-task rosters it left. Each write runs even if
890 // another fails. Once a replacement session opens with the same id, it
891 // owns the session status, so only the requests are settled.
781892 const writeReleaseRecords = (input: {
782893 readonly entry: LiveSessionEntry;
783894 readonly reason: ProviderSessionReleaseReason;
784895 readonly detail?: string;
896 readonly releasedBackgroundTasks: ReadonlyArray<ReleasedBackgroundTasks>;
785897 readonly releasedAt: DateTime.Utc;
786898 readonly replaced: boolean;
787899 }) =>
......@@ -795,6 +907,7 @@ export const layerWithOptions = (
795907 input.entry.requestEventPermit.withPermits(1),
796908 ),
797909 ),
910 Effect.exit(writeReleasedBackgroundWorkEvents(input.releasedBackgroundTasks)),
798911 ],
799912 { concurrency: 1 },
800913 ).pipe(Effect.flatMap(Exit.asVoidAll));
......@@ -937,6 +1050,8 @@ export const layerWithOptions = (
9371050 readonly providerSessionId: ProviderSessionId;
9381051 readonly reason: ProviderSessionReleaseReason;
9391052 readonly detail?: string;
1053 /** The thread whose detach released the session; it is no longer attached. */
1054 readonly detachedThreadId?: ThreadId;
9401055 readonly cancelIdleFiber?: boolean;
9411056 readonly onlyIfIdleGeneration?: number;
9421057 readonly gracefulSubscribers?: boolean;
......@@ -948,6 +1063,13 @@ export const layerWithOptions = (
9481063 onNone: () => Effect.void,
9491064 onSome: (entry) =>
9501065 Effect.gen(function* () {
1066 const releasedBackgroundTasks = yield* readReleasedBackgroundTasks({
1067 entry,
1068 reason: input.reason,
1069 ...(input.detachedThreadId === undefined
1070 ? {}
1071 : { detachedThreadId: input.detachedThreadId }),
1072 });
9511073 if (input.cancelIdleFiber !== false) {
9521074 yield* cancelIdleFiber(entry.idleFiber);
9531075 }
......@@ -969,6 +1091,7 @@ export const layerWithOptions = (
9691091 entry,
9701092 reason: input.reason,
9711093 ...(input.detail === undefined ? {} : { detail: input.detail }),
1094 releasedBackgroundTasks,
9721095 releasedAt,
9731096 };
9741097 const recorded = yield* Effect.exit(
......@@ -2406,6 +2529,7 @@ export const layerWithOptions = (
24062529 providerSessionId: input.providerSessionId,
24072530 reason: "manual_shutdown",
24082531 ...(input.detail === undefined ? {} : { detail: input.detail }),
2532 detachedThreadId: input.threadId,
24092533 });
24102534 return;
24112535 }
packages/provider-core/src/server/ProviderAdapter.ts+8
......@@ -565,6 +565,14 @@ export interface ProviderAdapterV2SessionRuntime {
565565 readonly hasPendingBackgroundWorkForThread?: (
566566 providerThread: OrchestrationV2ProviderThread,
567567 ) => Effect.Effect<boolean>;
568 /**
569 * Set when every background task this session lists on its provider threads
570 * runs inside the session's own process and ends when it closes. The session
571 * manager then clears those tasks when it releases the session (#15239).
572 * Leave unset when a task can outlive the session, for example on a shared
573 * server, so its thread keeps showing Waiting.
574 */
575 readonly backgroundTasksEndWithProcess?: true;
568576 /**
569577 * Capacity for the requested model/options, independent of native thread usage.
570578 * `cwd` is the thread's working directory, for providers whose project config
packages/provider-muse/src/server/adapter.ts+2
......@@ -2107,6 +2107,8 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
21072107 providerSessionId: input.providerSessionId,
21082108 providerSession: session,
21092109 events: Stream.fromQueue(events),
2110 // Workflows and subagents run in this session's own `muse` host.
2111 backgroundTasksEndWithProcess: true,
21102112 // A running workflow or a held Muse turn keeps the host: idle release must wait.
21112113 hasPendingBackgroundWork: Effect.sync(
21122114 () => observedChildren.size > 0 || wake !== undefined,