diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6d354e57c508b..ac22a8b7d9e10 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -58,7 +58,7 @@ import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import * as Haptics from "expo-haptics"; -import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import { AppState, Keyboard, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 8cf9a4f28a439..1281f578e34c0 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -327,6 +327,141 @@ it("routes only enrolled workflow members after the root turn ends", () => { ); }); +it("moves a child thread to the run that shows its subagent now", () => { + const threadId = ThreadId.make("thread:handoff"); + const rootProviderTurnId = ProviderTurnId.make("provider-turn:handoff"); + const identity: RunExecutionService.ProviderEventRouteIdentity = { + threadId, + runId: RunId.make("run:handoff:1"), + attemptId: RunAttemptId.make("attempt:handoff:1"), + providerThreadId: ProviderThreadId.make("provider-thread:handoff"), + }; + const resumedChild = ThreadId.make("thread:handoff:resumed"); + const siblingChild = ThreadId.make("thread:handoff:sibling"); + const providerThreadOf = (childThreadId: ThreadId) => + ProviderThreadId.make(`provider-thread:${childThreadId}`); + const subagent = (childThreadId: ThreadId, runId: RunId): OrchestrationV2Subagent => ({ + id: NodeId.make(`node:${childThreadId}`), + threadId, + runId, + parentNodeId: NodeId.make("node:handoff"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: providerThreadOf(childThreadId), + childThreadId, + nativeTaskRef: null, + prompt: "Work", + title: "Work", + model: null, + status: "running", + result: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + }); + const childMessage = (childThreadId: ThreadId) => + ({ + type: "message.updated", + driver, + message: { + id: MessageId.make(`message:${childThreadId}`), + threadId: childThreadId, + runId: null, + }, + }) as ProviderAdapter.ProviderAdapterV2Event; + const childTurn = (childThreadId: ThreadId, ordinal: number) => + ({ + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${childThreadId}:${ordinal}`), + providerThreadId: providerThreadOf(childThreadId), + runAttemptId: null, + status: "running", + }, + }) as ProviderAdapter.ProviderAdapterV2Event; + const ended = RunExecutionService.routeProviderEvent( + { + type: "turn.terminal", + driver, + providerThreadId: identity.providerThreadId, + providerTurnId: rootProviderTurnId, + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }, + identity, + RunExecutionService.makeProviderEventRoutingState({ + identity, + providerTurnId: rootProviderTurnId, + relatedThreadIds: [resumedChild, siblingChild], + relatedProviderThreadIds: [providerThreadOf(resumedChild), providerThreadOf(siblingChild)], + }), + )[1]; + const [firstTurnAccepted, withChildTurn] = RunExecutionService.routeProviderEvent( + childTurn(resumedChild, 1), + identity, + ended, + ); + assert.isTrue(firstTurnAccepted); + const [movedAccepted, afterMove] = RunExecutionService.routeProviderEvent( + { + type: "subagent.updated", + driver, + subagent: subagent(resumedChild, RunId.make("run:handoff:2")), + }, + identity, + withChildTurn, + ); + assert.isFalse(movedAccepted); + assert.isFalse( + RunExecutionService.routeProviderEvent(childMessage(resumedChild), identity, afterMove)[0], + ); + assert.isTrue( + RunExecutionService.routeProviderEvent(childMessage(siblingChild), identity, afterMove)[0], + ); + assert.isFalse( + RunExecutionService.routeProviderEvent(childTurn(resumedChild, 1), identity, afterMove)[0], + ); + assert.isFalse( + RunExecutionService.routeProviderEvent(childTurn(resumedChild, 2), identity, afterMove)[0], + ); + assert.isTrue( + RunExecutionService.routeProviderEvent(childTurn(siblingChild, 1), identity, afterMove)[0], + ); + assert.isTrue( + RunExecutionService.routeProviderEvent( + { type: "subagent.updated", driver, subagent: subagent(siblingChild, identity.runId) }, + identity, + afterMove, + )[0], + ); + const resuming: RunExecutionService.ProviderEventRouteIdentity = { + ...identity, + runId: RunId.make("run:handoff:2"), + attemptId: RunAttemptId.make("attempt:handoff:2"), + }; + const resumingState = RunExecutionService.makeProviderEventRoutingState({ + identity: resuming, + providerTurnId: ProviderTurnId.make("provider-turn:handoff:2"), + }); + assert.isFalse( + RunExecutionService.routeProviderEvent(childMessage(resumedChild), resuming, resumingState)[0], + ); + const [moveAccepted, afterTake] = RunExecutionService.routeProviderEvent( + { type: "subagent.updated", driver, subagent: subagent(resumedChild, resuming.runId) }, + resuming, + resumingState, + ); + assert.isTrue(moveAccepted); + assert.isTrue( + RunExecutionService.routeProviderEvent(childMessage(resumedChild), resuming, afterTake)[0], + ); +}); + it("does not route a superseded attempt through a reused provider thread", () => { const threadId = ThreadId.make("thread:shared-runtime:restart"); const providerThreadId = ProviderThreadId.make("provider-thread:shared-runtime:restart"); @@ -1740,6 +1875,65 @@ it.effect("keeps ingesting a child thread's late background item completion", () }), ); +it.effect.each([null, "run:nested-sdk"] as const)( + "releases a settled run when its live child transfers to another run (%s)", + (nestedRunId) => + Effect.gen(function* () { + const consumed = yield* Deferred.make(); + const result = yield* Effect.raceFirst( + runBackgroundItemScenario( + "child-transfer-release", + (ids) => { + const running = subagentEvent(ids, "running"); + if (running.type !== "subagent.updated") throw new Error("Missing subagent"); + const linked = { + ...running, + subagent: { + ...running.subagent, + origin: "provider_native" as const, + childThreadId: ids.childThreadId, + providerThreadId: null, + }, + }; + return [ + childThreadCreatedEvent(ids), + linked, + { + ...linked, + subagent: { + ...linked.subagent, + id: NodeId.make("node:child-transfer-release:nested"), + threadId: ids.childThreadId, + runId: nestedRunId === null ? null : RunId.make(nestedRunId), + childThreadId: null, + }, + }, + childBackgroundTurnItemEvent(ids, "running", 1), + rootTerminalEvent(ids, "completed"), + { ...linked, subagent: { ...linked.subagent, runId: RunId.make("run:replacement") } }, + ]; + }, + { + keepEventStreamOpen: true, + onEventsConsumed: Deferred.succeed(consumed, undefined).pipe(Effect.asVoid), + }, + ).pipe(Effect.map(Option.some)), + Deferred.await(consumed).pipe(Effect.as(Option.none())), + ); + assert.isTrue( + Option.isSome(result), + "the transferred child must release its prior subscription", + ); + if (Option.isSome(result)) + assert.deepEqual(result.value, [ + "subagent:running", + "subagent:running", + "turn_item:running", + "root-finalized", + ]); + }), +); + it.effect("keeps ingesting until the last of several background items terminalizes", () => Effect.gen(function* () { const secondItemId = TurnItemId.make("turn-item:bg-multi:second"); @@ -4377,6 +4571,7 @@ function runBackgroundItemScenario( ReadonlyArray<{ readonly id: TurnItemId; readonly runId: RunId }> >; readonly onSubscribe?: Effect.Effect; + readonly onEventsConsumed?: Effect.Effect; readonly onFinalized?: ( events: ReadonlyArray, ) => Effect.Effect; @@ -4444,7 +4639,11 @@ function runBackgroundItemScenario( events: Stream.empty, subscribeEvents: Effect.gen(function* () { yield* options?.onSubscribe ?? Effect.void; - const events = Stream.fromIterable(makeEvents(ids)); + const events = Stream.fromIterable(makeEvents(ids)).pipe( + Stream.concat( + Stream.fromEffect(options?.onEventsConsumed ?? Effect.void).pipe(Stream.drain), + ), + ); return { events: options?.keepEventStreamOpen === true diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 74916428333f4..07c19e73e9cba 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -60,7 +60,7 @@ export interface ProviderEventRoutingState { readonly rootTurnEnded: boolean; readonly workflowMemberParents: ReadonlyMap; readonly ownedProviderThreadIds: ReadonlySet; - readonly ownedProviderTurnIds: ReadonlySet; + readonly ownedProviderTurns: ReadonlyMap; readonly inheritedBackgroundTurnItems: ReadonlyMap; readonly rootProviderTurnId: ProviderTurnId | null; } @@ -337,8 +337,10 @@ export function makeProviderEventRoutingState(input: { input.identity.providerThreadId, ...(input.relatedProviderThreadIds ?? []), ]), - ownedProviderTurnIds: - input.providerTurnId === null ? new Set() : new Set([input.providerTurnId]), + ownedProviderTurns: + input.providerTurnId === null + ? new Map() + : new Map([[input.providerTurnId, input.identity.providerThreadId]]), inheritedBackgroundTurnItems: new Map( (input.inheritedBackgroundTurnItems ?? []).map((item) => [item.id, item.runId]), ), @@ -361,10 +363,11 @@ export function routeProviderEvent( }); const addProviderTurn = ( providerTurnId: ProviderTurnId, + providerThreadId: ProviderThreadId, root: boolean, ): ProviderEventRoutingState => ({ ...state, - ownedProviderTurnIds: new Set([...state.ownedProviderTurnIds, providerTurnId]), + ownedProviderTurns: new Map(state.ownedProviderTurns).set(providerTurnId, providerThreadId), rootProviderTurnId: root ? providerTurnId : state.rootProviderTurnId, }); @@ -405,9 +408,14 @@ export function routeProviderEvent( isRoot || (event.providerTurn.providerThreadId !== input.providerThreadId && state.ownedProviderThreadIds.has(event.providerTurn.providerThreadId)) || - state.ownedProviderTurnIds.has(event.providerTurn.id) || + state.ownedProviderTurns.has(event.providerTurn.id) || (event.threadId !== undefined && ownsChildThread(event.threadId)); - return belongs ? [true, addProviderTurn(event.providerTurn.id, isRoot)] : [false, state]; + return belongs + ? [ + true, + addProviderTurn(event.providerTurn.id, event.providerTurn.providerThreadId, isRoot), + ] + : [false, state]; } case "node.updated": { const belongs = ownsRun(event.node.runId) || ownsChildThread(event.node.threadId); @@ -417,6 +425,25 @@ export function routeProviderEvent( return [true, addProviderThread(event.node.providerThreadId)]; } case "subagent.updated": { + const moved = event.subagent; + if ( + moved.origin === "provider_native" && + moved.threadId === input.threadId && + moved.runId !== null && + !ownsRun(moved.runId) && + moved.childThreadId !== null && + ownsChildThread(moved.childThreadId) + ) { + const ownedThreadIds = new Set(state.ownedThreadIds); + ownedThreadIds.delete(moved.childThreadId); + const ownedProviderThreadIds = new Set(state.ownedProviderThreadIds); + if (moved.providerThreadId !== null) ownedProviderThreadIds.delete(moved.providerThreadId); + const ownedProviderTurns = new Map(state.ownedProviderTurns); + for (const [turnId, threadId] of ownedProviderTurns) { + if (threadId === moved.providerThreadId) ownedProviderTurns.delete(turnId); + } + return [false, { ...state, ownedThreadIds, ownedProviderThreadIds, ownedProviderTurns }]; + } const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId); const coordinator = event.subagent; // A provider-native subagent's child thread follows the live run it is @@ -478,7 +505,7 @@ export function routeProviderEvent( return [ (event.threadId !== undefined && ownsChildThread(event.threadId)) || (event.runtimeRequest.providerTurnId !== null && - state.ownedProviderTurnIds.has(event.runtimeRequest.providerTurnId)), + state.ownedProviderTurns.has(event.runtimeRequest.providerTurnId)), state, ]; case "command_output.delta": @@ -974,7 +1001,7 @@ export const layer: Layer.Layer< const rootRunFinalized = yield* Ref.make(false); const providerThreadOwnerLost = yield* Ref.make(false); const activeChildProviderTurns = yield* Ref.make>(new Set()); - const activeChildSubagents = yield* Ref.make>(new Set()); + const activeChildSubagents = yield* Ref.make>(new Map()); const activeBackgroundTurnItems = yield* Ref.make< ReadonlySet >(new Set(inheritedBackgroundTurnItemsById.keys())); @@ -1050,11 +1077,11 @@ export const layer: Layer.Layer< routing.ownedThreadIds.has(event.subagent.threadId); if (belongsToRootRun || belongsToOwnedChildThread) { yield* Ref.update(activeChildSubagents, (current) => { - const next = new Set(current); + const next = new Map(current); if (isSettledSubagentStatus(event.subagent.status)) { next.delete(event.subagent.id); } else { - next.add(event.subagent.id); + next.set(event.subagent.id, event.subagent.threadId); } return next; }); @@ -1209,11 +1236,84 @@ export const layer: Layer.Layer< // when finally written, behind every item that began after it. const reservedHeldTurnItemIds = new Set(); const providerEventFiber = yield* eventSubscription.events.pipe( - Stream.filterEffect((event) => - Ref.modify(eventRouting, (state) => routeProviderEvent(event, routeIdentity, state)), + Stream.mapEffect((event) => + Effect.gen(function* () { + const previous = yield* Ref.get(eventRouting); + const [accepted, routing] = routeProviderEvent(event, routeIdentity, previous); + yield* Ref.set(eventRouting, routing); + if ( + event.type === "subagent.updated" && + event.subagent.childThreadId !== null && + previous.ownedThreadIds.has(event.subagent.childThreadId) && + !routing.ownedThreadIds.has(event.subagent.childThreadId) + ) { + const moved = event.subagent; + const detached = yield* Ref.modify(openRunOwnedSubagents, (current) => { + const subagentIds = new Set([moved.id]); + for (const subagent of current.subagents.values()) { + if (subagent.threadId === moved.childThreadId) subagentIds.add(subagent.id); + } + const childTurnItems = new Map( + [...current.childTurnItems].filter( + ([, item]) => item.threadId !== moved.childThreadId, + ), + ); + const turnItems = new Map(current.turnItems); + turnItems.delete(moved.id); + const detachedItems = new Set( + [...current.childTurnItems.values(), ...current.turnItems.values()] + .filter( + (item) => item.threadId === moved.childThreadId || item.nodeId === moved.id, + ) + .map((item) => item.id), + ); + return [ + { itemIds: detachedItems, subagentIds }, + { + subagents: new Map( + [...current.subagents].filter(([id]) => !subagentIds.has(id)), + ), + turnItems, + childTurnItems, + nodes: new Map( + [...current.nodes].filter( + ([id, node]) => id !== moved.id && node.threadId !== moved.childThreadId, + ), + ), + linkedChildThreadIds: new Set( + [...current.linkedChildThreadIds].filter( + (id) => id !== moved.childThreadId, + ), + ), + }, + ]; + }); + yield* Ref.update( + activeChildSubagents, + (current) => + new Map( + [...current].filter( + ([id, threadId]) => + !detached.subagentIds.has(id) && threadId !== moved.childThreadId, + ), + ), + ); + yield* Ref.update( + activeChildProviderTurns, + (current) => + new Set([...current].filter((id) => routing.ownedProviderTurns.has(id))), + ); + yield* Ref.update( + activeBackgroundTurnItems, + (current) => new Set([...current].filter((id) => !detached.itemIds.has(id))), + ); + } + return { event, accepted }; + }), ), - Stream.tap((event) => + Stream.tap(({ event, accepted }) => Effect.gen(function* () { + if (!accepted) return; let storedEventCount = 0; const deliveredEvent = filterAssistantEvent( event,