Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/ComposerErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { View } from "react-native";
import { Button } from "../../components/ui/Button";
import { Notice } from "../../components/ui/Notice";

export function ComposerErrorNotice(props: {
readonly message: string;
readonly onDismiss: () => void;
readonly onStop?: (() => void) | undefined;
}) {
return (
<View className="px-4 pb-3">
<View className="gap-2 px-4 pb-3">
<Notice
message={props.message}
icon="exclamationmark.circle"
Expand All @@ -15,6 +17,11 @@ export function ComposerErrorNotice(props: {
onDismiss={props.onDismiss}
dismissLabel="Dismiss error"
/>
{props.onStop ? (
<View className="flex-row justify-end">
<Button label="Stop" icon="stop.fill" size="compact" onPress={props.onStop} />
</View>
) : null}
</View>
);
}
14 changes: 13 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice";
import { FreshSessionNotice } from "./FreshSessionNotice";
import { Notice } from "../../components/ui/Notice";
import { formatTokens } from "@t3tools/shared/usageFormat";
import { isBackgroundWorkRefusal } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
Expand Down Expand Up @@ -692,7 +693,7 @@ function ThreadDetailBody(
latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null,
});
const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => {
if (statusPending || !props.canOperateThread) return;
if (statusPending || !props.canOperateThread) return false;
const request: BackgroundWorkStopRequest = {
threadKey: selectedThreadKey,
taskIds:
Expand Down Expand Up @@ -753,6 +754,7 @@ function ThreadDetailBody(
error instanceof Error ? error.message : "Try again.",
);
}
return result._tag !== "Failure";
};
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
Expand Down Expand Up @@ -1744,6 +1746,16 @@ function ThreadDetailBody(
<ComposerErrorNotice
message={composerError}
onDismiss={() => clearThreadComposerError(selectedThreadKey)}
onStop={
// The refusal says "press Stop"; work only the provider sees has no other Stop.
props.canOperateThread && isBackgroundWorkRefusal(composerError)
? () =>
// A failed or skipped Stop keeps the notice, so Stop can be retried.
void runStatusAction("stop").then((stopped) => {
if (stopped) clearThreadComposerError(selectedThreadKey);
})
: undefined
}
/>
</Animated.View>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ const stopEarlierBackgroundWork = ({
backgroundWorkAfterStop,
stalledRun,
workflowMember = false,
probeOnly = false,
}: {
readonly wakeAfterStop?: boolean;
readonly failedStart?: boolean;
readonly backgroundWorkAfterStop?: "running" | "unknown";
readonly stopWithQueue?: "thread.stop" | "run.interrupt";
readonly olderStart?: boolean;
readonly workflowMember?: boolean;
readonly probeOnly?: boolean;
readonly stalledRun?: "missing-session-terminal" | "returned-interrupt" | "superseded-attempt";
}) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -98,7 +100,12 @@ const stopEarlierBackgroundWork = ({
return backgroundWorkAfterStop === "running";
}),
}
: {}),
: probeOnly
? {
hasPendingBackgroundWorkForThread: () =>
Effect.succeed(interrupts.length === 0),
}
: {}),
ensureThread: ({ threadId }) =>
Effect.succeed({
id: ProviderThreadId.make(`provider-thread:codex:${threadId}`),
Expand Down Expand Up @@ -246,33 +253,35 @@ const stopEarlierBackgroundWork = ({
const devServerId = TurnItemId.make("turn-item:dev-server");
const now = yield* DateTime.now;
yield* sink.write({
events: [
{
id: EventId.make("dev-server"),
type: "turn-item.updated",
threadId,
runId: first.runId,
occurredAt: now,
payload: {
id: devServerId,
threadId,
runId: first.runId,
nodeId: first.rootNodeId,
providerThreadId: codexTurn.providerThreadId,
providerTurnId: codexTurn.id,
nativeItemRef: null,
parentItemId: null,
ordinal: 100,
status: "running",
title: null,
startedAt: now,
completedAt: null,
updatedAt: now,
type: "command_execution",
input: "vp run dev --share",
},
},
],
events: probeOnly
? []
: [
{
id: EventId.make("dev-server"),
type: "turn-item.updated",
threadId,
runId: first.runId,
occurredAt: now,
payload: {
id: devServerId,
threadId,
runId: first.runId,
nodeId: first.rootNodeId,
providerThreadId: codexTurn.providerThreadId,
providerTurnId: codexTurn.id,
nativeItemRef: null,
parentItemId: null,
ordinal: 100,
status: "running",
title: null,
startedAt: now,
completedAt: null,
updatedAt: now,
type: "command_execution",
input: "vp run dev --share",
},
},
],
});
if (stalledRun !== undefined) {
const before = yield* orchestrator.getThreadProjection(threadId);
Expand Down Expand Up @@ -470,6 +479,20 @@ const stopEarlierBackgroundWork = ({
threadDisposition: "reusable",
});
yield* Fiber.join(settled);
if (probeOnly) {
// Checkpoint capture has not run, so the run is still waiting on a completed turn.
yield* orchestrator.dispatch({
type: "thread.stop",
commandId: CommandId.make("stop-waiting-run"),
threadId,
});
yield* worker.drain();
assert.deepEqual(
interrupts.map((input) => [input.providerTurnId, input.requestRuntimeRestart]),
[[codexTurn.id, true]],
);
return;
}
yield* worker.drain();

const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId))
Expand Down Expand Up @@ -1058,6 +1081,10 @@ it.effect.each(["missing-session-terminal", "returned-interrupt", "superseded-at
(stalledRun) => stopEarlierBackgroundWork({ stalledRun }),
);

it.effect("Stop reaches work only the provider sees while the settled run waits", () =>
stopEarlierBackgroundWork({ probeOnly: true }),
);

it.effect("Stop holds a provider wake that queues before the stopped turn ends", () =>
stopEarlierBackgroundWork({ wakeAfterStop: true }),
);
48 changes: 40 additions & 8 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return false;
});

// Some background work (Claude native subagents, held wakes) only the adapter probe sees.
// Stop probes it too, or an idle thread cannot stop the work a switch is refused over.
const runHasLiveBackgroundWork = Effect.fn("Orchestrator.runHasLiveBackgroundWork")(function* (
run: OrchestrationV2Run | undefined,
projection: Pick<OrchestrationV2ThreadProjection, "providerThreads" | "providerSessions">,
) {
const providerThread = projection.providerThreads.find(
(candidate) => candidate.id === run?.providerThreadId,
);
const session = projection.providerSessions.find(
(candidate) => candidate.id === providerThread?.providerSessionId,
);
if (session === undefined || session.status === "stopped" || session.status === "error")
return false;
return yield* sessionHasBackgroundWork(
session,
providerThread === undefined ? [] : [providerThread],
);
});

// Runs in every command transaction; avoid reading thousands of finished leaf nodes.
const readCommandProjection = (
threadId: ThreadId,
Expand Down Expand Up @@ -12855,6 +12875,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
"turnItems",
"subagents",
"runtimeRequests",
"providerSessions",
],
// Thread-wide, like the Waiting strip: a settled thread's background
// work can belong to an earlier run than the one Stop targets.
Expand All @@ -12880,15 +12901,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
run?.providerThreadId === null
? undefined
: projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId);
const isLatestRun =
run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id;
const hasBackgroundWork =
run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id &&
derivePendingBackgroundWork({
isLatestRun &&
(derivePendingBackgroundWork({
latestRun: run,
providerThreads: projection.providerThreads,
turnItems: projection.turnItems,
activeProviderThreadId: projection.thread.activeProviderThreadId,
runs: projection.runs,
}).length > 0;
}).length > 0 ||
(run !== undefined &&
run.status !== "preparing" &&
run.status !== "starting" &&
run.status !== "running" &&
(yield* runHasLiveBackgroundWork(run, projection))));
// A failed start has no provider turn. Background work still belongs
// to the provider thread, so Stop reaches its latest accepted turn.
const providerTurn =
Expand Down Expand Up @@ -13467,10 +13495,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>,
) {
const projection = yield* projectionStore
.getThreadRecords(command.threadId, ["runs", "providerThreads", "turnItems", "subagents"], {
turnItemTypes: ["command_execution", "dynamic_tool", "subagent"],
turnItemStatuses: ["pending", "running", "waiting"],
})
.getThreadRecords(
command.threadId,
["runs", "providerThreads", "turnItems", "subagents", "providerSessions"],
{
turnItemTypes: ["command_execution", "dynamic_tool", "subagent"],
turnItemStatuses: ["pending", "running", "waiting"],
},
)
.pipe(
Effect.mapError(
(cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
Expand All @@ -13497,7 +13529,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
turnItems: projection.turnItems,
activeProviderThreadId: projection.thread.activeProviderThreadId,
runs: projection.runs,
}).length > 0
}).length > 0 || (yield* runHasLiveBackgroundWork(latestRun, projection))
? latestRun
: undefined);
if (target !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import {
TurnItemId,
ProviderDriverKind,
} from "@t3tools/contracts";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
derivePendingBackgroundWork,
isBackgroundWorkRefusal,
} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -444,7 +447,10 @@ function makeTestAdapter(input: {
}
}),
steerTurn: () => Effect.void,
interruptTurn: () => Effect.void,
interruptTurn: (interrupt) =>
interrupt.requestRuntimeRestart === true && input.backgroundWork !== undefined
? Ref.set(input.backgroundWork, false)
: Effect.void,
respondToRuntimeRequest: () => Effect.void,
readThreadSnapshot: () =>
unimplemented(input.driver, "readThreadSnapshot unused in provider switch test"),
Expand Down Expand Up @@ -2801,6 +2807,10 @@ describe("orchestration v2 provider switching", () => {
"refuses the hand-off while the previous instance still runs background work",
{ background: true, queued: false, sharedHistory: false },
],
[
"lets Stop release an idle session whose projection has no background work",
{ background: true, queued: false, sharedHistory: false, stopAfterRefusal: true },
],
[
"keeps a busy previous session running when a queued hand-off starts",
{ background: true, queued: true, sharedHistory: false },
Expand All @@ -2809,7 +2819,7 @@ describe("orchestration v2 provider switching", () => {
"holds a queued account switch while the replaced session still runs background work",
{ background: true, queued: true, sharedHistory: true },
],
] as const)("%s", ([, { background, queued, sharedHistory }]) =>
] as const)("%s", ([, { background, queued, sharedHistory, ...options }]) =>
Effect.gen(function* () {
const cwd = yield* checkpointWorkspace("handoff-releases-session");
const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
Expand Down Expand Up @@ -2975,7 +2985,7 @@ describe("orchestration v2 provider switching", () => {
// pendingBackgroundTasks stays empty while the probe still reports work.
assert.deepEqual(afterFirst.providerThreads[0]?.pendingBackgroundTasks ?? [], []);
const refused = yield* send(2, secondSelection, ":refused").pipe(Effect.flip);
assert.include(String(refused.cause), "still running background agents or commands");
assert.isTrue(isBackgroundWorkRefusal(String(refused.cause)));
const unchanged = yield* orchestrator.getThreadProjection(handoffThreadId);
assert.lengthOf(unchanged.runs, 1);
assert.deepEqual(yield* Ref.get(closedSessions), []);
Expand All @@ -2984,7 +2994,16 @@ describe("orchestration v2 provider switching", () => {
unchanged.providerSessions.map((session) => [session.id, session.status]),
[[firstSession!.id, "ready"]],
);
yield* Ref.set(backgroundWork, false);
if ("stopAfterRefusal" in options) {
yield* orchestrator.dispatch({
type: "thread.stop",
commandId: CommandId.make("command:handoff-releases-session:stop"),
threadId: handoffThreadId,
});
yield* worker.drain();
} else {
yield* Ref.set(backgroundWork, false);
}
}

yield* send(2, secondSelection);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3421,6 +3421,7 @@ export default function ChatView(props: ChatViewProps) {
threadErrorBannerKey={threadErrorBannerKey}
setThreadError={setThreadError}
setThreadErrorBannerDismissTick={setThreadErrorBannerDismissTick}
onStop={canOperateThread ? onInterruptStable : undefined}
/>
<div
{...conversationArea}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/chat/ChatBannerLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function ChatBannerLane(props: {
threadErrorBannerKey: string | null;
setThreadError: (threadId: ThreadId | null, error: string | null) => void;
setThreadErrorBannerDismissTick: Dispatch<SetStateAction<number>>;
onStop: (() => void) | undefined;
}) {
const {
providerStatus,
Expand All @@ -40,6 +41,7 @@ export function ChatBannerLane(props: {
threadErrorBannerKey,
setThreadError,
setThreadErrorBannerDismissTick,
onStop,
} = props;
return (
<div className="chat-banner-lane pointer-events-none absolute top-0 z-30 flex flex-col">
Expand All @@ -53,6 +55,15 @@ export function ChatBannerLane(props: {
error={threadError}
errorClass={threadErrorClass}
errorCode={threadErrorCode}
onStop={
onStop === undefined
? undefined
: () => {
// Not a session dismissal: a repeated refusal must show again.
setThreadError(threadId, null);
onStop();
}
}
onDismiss={() => {
setThreadError(threadId, null);
dismissThreadErrorBannerForSession(threadErrorBannerKey);
Expand Down
Loading
Loading