Skip to content
Merged
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
76 changes: 76 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
ScopedThreadRef,
ServerConfig as T3ServerConfig,
UsageLimitsReport,
RunId,
} from "@t3tools/contracts";
import {
appendCodexArtifactTemplateUsePrompt,
Expand Down Expand Up @@ -156,8 +157,10 @@ import {
import { appAtomRegistry } from "../../state/atom-registry";
import { threadStreamHaptics } from "./threadStreamHaptics";
import { useAtomCommand } from "../../state/use-atom-command";
import { orchestrationEnvironment } from "../../state/orchestration";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution";
import type {
PendingApproval,
PendingUserInput,
Expand Down Expand Up @@ -339,6 +342,9 @@ const threadVisitTracker = createThreadVisitTracker({

// Past the composer's 180 ms height animation.
const COMPOSER_HEIGHT_SETTLE_MS = 220;
const NO_RUN_STOP_ATOM = Atom.make<RunStopPresentation | null>(null).pipe(
Atom.withLabel("mobile-thread-run-stop:empty"),
);

// Anchors, the submitted message, follow state and pending scrolls belong to
// one thread, so the body remounts when the thread changes.
Expand Down Expand Up @@ -528,6 +534,50 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const anyRunStop = useAtomValue(
props.selectedThreadDetailRef === null
? NO_RUN_STOP_ATOM
: environmentThreadDetails.runStopAtom(props.selectedThreadDetailRef),
);
// A background Stop matters only while that work is still pending.
const runStop =
anyRunStop?.background && props.selectedThread.pendingBackgroundTasks.length === 0
? null
: anyRunStop;
const forceStopThread = useAtomCommand(orchestrationEnvironment.forceStopThread, {
reportFailure: false,
});
const canForceStop =
useAtomValue(orchestrationEnvironment.forceStopThread.permissionAtom(props.environmentId)) &&
props.serverConfig?.environment.capabilities.threadForceStop === true;
const [forceStoppingKey, setForceStoppingKey] = useState<string | null>(null);
// The run that ignored Stop, so a confirmation left open cannot hit a newer run.
const forceStop = async (runId: RunId | undefined) => {
const threadKey = selectedThreadKey;
setForceStoppingKey(threadKey);
const result = await forceStopThread({
environmentId: props.environmentId,
input:
runId === undefined
? { threadId: props.selectedThread.id }
: { threadId: props.selectedThread.id, runId },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
setForceStoppingKey((current) => (current === threadKey ? null : current));
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) Dialog.alert("Could not force stop the agent");
return;
}
const others = result.value.sharedWithThreadIds.length;
if (result.value.outcome === "releasing") {
Dialog.alert("Turn stopped; the agent is still shutting down");
}
if (result.value.outcome === "shared") {
Dialog.alert(
"Turn stopped; the agent process kept running",
`It is shared with ${others === 0 ? "other" : others} working ${others === 1 ? "thread" : "threads"}. Stop those to free it.`,
);
}
};
const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null);
const statusPending = statusRequest?.threadKey === selectedThreadKey;
const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString());
Expand Down Expand Up @@ -619,6 +669,32 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
if (connectionStatus !== null && !props.draftMode) {
return connectionStatus;
}
// Before the request check: an agent stuck on an approval may also ignore Stop.
if (runStop !== null && contentPresentationKind === "ready") {
const unresponsive = runStop.state === "unresponsive";
return {
kind: "stop",
unresponsive,
label: unresponsive ? "Not responding to Stop" : "Stopping…",
action:
unresponsive && canForceStop
? {
label: forceStoppingKey === selectedThreadKey ? "Force stopping..." : "Force stop",
accessibilityLabel: "Force stop the agent",
disabled: forceStoppingKey === selectedThreadKey,
onPress: () =>
Dialog.confirm({
title: "Force stop the agent?",
message:
"It has not answered Stop. Force stop ends the turn and kills the agent's processes, unless other working threads share them.",
confirmText: "Force stop",
destructive: true,
onConfirm: () => void forceStop(runStop.runId),
}),
}
: null,
};
}
if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
return null;
}
Expand Down
28 changes: 27 additions & 1 deletion apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export function FloatingWorkingControl(props: {
const [queueWidth, setQueueWidth] = useState(0);
const [actionWidth, setActionWidth] = useState(0);
const action =
props.status?.kind === "background" || props.status?.kind === "lifecycle"
props.status?.kind === "background" ||
props.status?.kind === "lifecycle" ||
props.status?.kind === "stop"
? props.status.action
: null;
const [agentsWidth, setAgentsWidth] = useState(0);
Expand Down Expand Up @@ -471,6 +473,30 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "stop") {
return (
<StatusLabelRow
key="stop"
accessibilityLabel={props.status.label}
className="gap-2"
onLayout={props.onLayout}
>
{props.status.unresponsive ? (
<SymbolView
name="exclamationmark.triangle"
size={13}
tintColorClassName="accent-warning-foreground"
type="monochrome"
/>
) : (
<AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
)}
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "goal") {
return (
<StatusLabelRow
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export type FloatingWorkingStatus =
readonly label: string;
readonly action: FloatingStatusAction;
}
| {
readonly kind: "stop";
readonly label: string;
readonly unresponsive: boolean;
readonly action: FloatingStatusAction | null;
}
| { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
| { readonly kind: "preparing"; readonly label: string }
| {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export const make = Effect.gen(function* () {
projectSettingsOverrides: true,
projectProviderInstanceOverrides: true,
threadSnooze: true,
threadForceStop: true,
environmentThemes: true,
usageLimitSources: true,
providerWorkspaceSnapshotsOnDemand: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.forceStopThread]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7498,6 +7498,96 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live("lets a caller abandon Stop while hard teardown stalls, and finishes the teardown", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
const teardownStarted = yield* Deferred.make<void>();
const releaseTeardown = yield* Deferred.make<void>();
let runtimeOrdinalSeen = 0;
const instanceId = ProviderInstanceId.make("acp-test");
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
interruptPromptOnCancel: true,
restartRuntimeAfterInterrupt: true,
terminateRuntimeProcessGroupOnInterrupt: true,
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
ownDetachedProcessGroup: true,
processGroupPlatform: "win32",
windowsProcessTreeTerminator: () =>
Deferred.succeed(teardownStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseTeardown)),
),
environment: (runtimeOrdinal) => {
runtimeOrdinalSeen = runtimeOrdinal;
return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
},
protocolEvents,
}),
},
selfInvocation,
});
const threadId = ThreadId.make("thread-acp-abandoned-hard-teardown");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-abandoned-hard-teardown"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
const now = yield* DateTime.now;
yield* runtime
.startTurn(
makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
)
.pipe(Effect.forkScoped);
const providerTurnId = idAllocator.derive.providerTurn({
driver: ACP_TEST_DRIVER,
nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
});
yield* pollProtocolMethods(protocolEvents);
const interruptFiber = yield* runtime
.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
.pipe(Effect.forkScoped);
yield* Deferred.await(teardownStarted);

// The session manager's Stop timeout interrupts this call; it must not wait for teardown.
yield* Fiber.interrupt(interruptFiber);

const startFiber = yield* runtime
.startTurn(
makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
)
.pipe(Effect.forkScoped);
yield* Deferred.succeed(releaseTeardown, undefined);
yield* Fiber.join(startFiber);
assert.equal(runtimeOrdinalSeen, 2);
const replacementMethods = yield* pollProtocolMethods(protocolEvents);
assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect(
"direct Stop on a deferred subagent hold terminalizes the subagent and does not carry it forward",
() =>
Expand Down
46 changes: 20 additions & 26 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500;
// A steer waits at most this long, from its request, for its foreground
// subagents to move to the background before it is sent.
const BACKGROUND_MOVE_TIMEOUT_MS = 5_000;
// How long a Stop waits for the CLI to exit before the user is offered Force stop.
const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000;

const CLAUDE_GROUP_WATCH_SCRIPT = [
"{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } </dev/null >/dev/null 2>&1 &",
Expand Down Expand Up @@ -10433,44 +10435,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* existing.query.interrupt;
yield* existing.query.close.pipe(Effect.ignore);
const closed = yield* Deferred.await(existing.closed).pipe(
Effect.timeoutOption("10 seconds"),
Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS),
);
if (Option.isSome(closed)) {
return;
}

const completedAt = yield* DateTime.now;
// The turn stays open: the CLI may still be working, and the user decides
// whether to Force stop it.
yield* Effect.logWarning("orchestration-v2.claude-query-interrupt-timeout", {
providerSessionId: input.providerSessionId,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
});
const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
current?.query === existing.query ? [true, null] : [false, current],
);
if (ownedLiveQuery) {
existing.stopping = true;
yield* finalizeActiveTurnAfterQueryExit(existing);
yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
} else {
yield* finalizeActiveTurn({
context: currentTurn,
status: "interrupted",
completedAt,
});
}
yield* Deferred.succeed(existing.closed, undefined);
return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
timeoutMs: CLAUDE_STOP_CONFIRM_TIMEOUT_MS,
});
},
(effect, turnInput) =>
effect.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapter.ProviderAdapterInterruptError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
cause,
}),
Effect.mapError((cause) =>
cause._tag === "ProviderAdapterInterruptTimeoutError"
? cause
: new ProviderAdapter.ProviderAdapterInterruptError({
driver: CLAUDE_PROVIDER,
providerThreadId: turnInput.providerThread.id,
providerTurnId: turnInput.providerTurnId,
cause,
}),
),
),
);
Expand Down
Loading
Loading