Fix domain-mode exec approval pause status gating

This commit is contained in:
George Pickett
2026-03-02 00:26:29 -08:00
parent a765786579
commit a902d6cf4b
3 changed files with 123 additions and 49 deletions
+67 -14
View File
@@ -20,6 +20,7 @@ import {
useGatewayConnection,
type GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
import {
type GatewayModelChoice,
type GatewayModelPolicySnapshot,
@@ -40,7 +41,6 @@ import {
resolveLatestCronJobForAgent,
} from "@/lib/cron/types";
import {
createGatewayAgent,
readConfigAgentList,
resolveDefaultConfigAgentId,
slugifyAgentName,
@@ -104,6 +104,7 @@ import {
type CreateAgentBlockState,
} from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import {
@@ -114,7 +115,6 @@ import {
type SettingsRouteTab,
} from "@/features/agents/operations/settingsRouteWorkflow";
import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
type MobilePane = "fleet" | "chat";
@@ -228,9 +228,17 @@ const AgentStudioPage = () => {
setGatewayUrl,
setToken,
} = useGatewayConnection(settingsCoordinator);
const useDomainApiMode = domainApiModeEnabled ?? isStudioDomainIntentModeEnabled();
const useDomainApiMode = domainApiModeEnabled === true;
const coreConnected = useDomainApiMode ? true : status === "connected";
const coreStatus: GatewayStatus = coreConnected ? "connected" : status;
const runtimeWriteTransport = useMemo(
() =>
createRuntimeWriteTransport({
client,
useDomainIntents: useDomainApiMode,
}),
[client, useDomainApiMode]
);
const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore();
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
@@ -277,6 +285,8 @@ const AgentStudioPage = () => {
}) => Promise<void>
>((input) => Promise.reject(new Error(`Config mutation queue not ready for "${input.kind}".`)));
const approvalPausedRunIdByAgentRef = useRef<Map<string, string>>(new Map());
const domainEventIngressRef = useRef<(event: EventFrame) => void>(() => {});
const pendingDomainOutboxEntriesRef = useRef<ControlPlaneOutboxEntry[]>([]);
const agents = state.agents;
const selectedAgent = useMemo(() => getSelectedAgent(state), [state]);
@@ -472,6 +482,7 @@ const AgentStudioPage = () => {
isDisconnectLikeError: isGatewayDisconnectLikeError,
preferredSelectedAgentId: preferredSelectedAgentIdRef.current,
hasCurrentSelection: Boolean(stateRef.current.selectedAgentId),
useDomainApiMode,
logError: (message, error) => console.error(message, error),
});
executeStudioBootstrapLoadCommands({
@@ -497,6 +508,7 @@ const AgentStudioPage = () => {
gatewayConfigSnapshot,
settingsCoordinator,
coreConnected,
useDomainApiMode,
]);
const enqueueConfigMutationFromRef = useCallback(
@@ -522,6 +534,7 @@ const AgentStudioPage = () => {
const settingsMutationController = useAgentSettingsMutationController({
client,
runtimeWriteTransport,
status,
isLocalGateway,
agents,
@@ -553,6 +566,7 @@ const AgentStudioPage = () => {
setMobilePane("chat");
},
setError,
useDomainIntents: useDomainApiMode,
});
const hasRenameMutationBlock = settingsMutationController.hasRenameMutationBlock;
@@ -742,6 +756,31 @@ const AgentStudioPage = () => {
}
}, [agents, heartbeatTick, specialLatestUpdate]);
const ingestDomainOutboxEntries = useCallback((entries: ControlPlaneOutboxEntry[]) => {
if (!Array.isArray(entries) || entries.length === 0) return;
const handler = runtimeEventHandlerRef.current;
const ingestEvent = domainEventIngressRef.current;
if (!handler) {
pendingDomainOutboxEntriesRef.current.push(...entries);
const overflow = pendingDomainOutboxEntriesRef.current.length - 5_000;
if (overflow > 0) {
pendingDomainOutboxEntriesRef.current.splice(0, overflow);
}
return;
}
for (const entry of entries) {
if (entry.event.type !== "gateway.event") continue;
const frame: EventFrame = {
type: "event",
event: entry.event.event,
payload: entry.event.payload,
...(typeof entry.event.seq === "number" ? { seq: entry.event.seq } : {}),
};
handler.handleEvent(frame);
ingestEvent(frame);
}
}, []);
const {
loadSummarySnapshot,
loadAgentHistory,
@@ -758,6 +797,8 @@ const AgentStudioPage = () => {
runtimeEventHandlerRef.current?.clearRunTracking(runId);
},
isDisconnectLikeError: isGatewayDisconnectLikeError,
useDomainApiReads: useDomainApiMode,
ingestDomainOutboxEntries,
});
const {
@@ -772,6 +813,7 @@ const AgentStudioPage = () => {
clearPendingLivePatch,
} = useChatInteractionController({
client,
runtimeWriteTransport,
status: coreStatus,
agents,
dispatch,
@@ -876,7 +918,7 @@ const AgentStudioPage = () => {
await runCreateAgentMutationLifecycle(
{
payload,
status,
status: coreStatus,
hasCreateBlock: Boolean(createAgentBlock),
hasRenameBlock: hasRenameMutationBlock,
hasDeleteBlock: hasDeleteMutationBlock,
@@ -885,7 +927,7 @@ const AgentStudioPage = () => {
{
enqueueConfigMutation,
createAgent: async (name, avatarSeed) => {
const created = await createGatewayAgent({ client, name });
const created = await runtimeWriteTransport.agentCreate({ name });
if (avatarSeed) {
persistAvatarSeed(created.id, avatarSeed);
}
@@ -924,6 +966,7 @@ const AgentStudioPage = () => {
applyDefaultPermissions: async ({ agentId, sessionKey }) => {
await applyCreateAgentBootstrapPermissions({
client,
runtimeWriteTransport,
agentId,
sessionKey,
draft: { ...CREATE_AGENT_DEFAULT_PERMISSIONS },
@@ -974,8 +1017,9 @@ const AgentStudioPage = () => {
loadAgents,
persistAvatarSeed,
refreshGatewayConfigSnapshot,
runtimeWriteTransport,
setError,
status,
coreStatus,
]
);
@@ -1027,13 +1071,14 @@ const AgentStudioPage = () => {
agents: stateRef.current.agents,
dispatch,
client,
runtimeWriteTransport,
agentId,
sessionKey,
field,
value,
});
},
[client, dispatch]
[client, dispatch, runtimeWriteTransport]
);
const handleModelChange = useCallback(
@@ -1077,6 +1122,7 @@ const AgentStudioPage = () => {
async (approvalId: string, decision: ExecApprovalDecision) => {
await runResolveExecApprovalOperation({
client,
runtimeWriteTransport,
approvalId,
decision,
getAgents: () => stateRef.current.agents,
@@ -1106,14 +1152,14 @@ const AgentStudioPage = () => {
clearRunTracking: (runId) => runtimeEventHandlerRef.current?.clearRunTracking(runId),
});
},
[client, dispatch, loadAgentHistory]
[client, dispatch, loadAgentHistory, runtimeWriteTransport]
);
const pauseRunForExecApproval = useCallback(
async (approval: PendingExecApproval, preferredAgentId?: string | null) => {
await runPauseRunForExecApprovalOperation({
status,
client,
status: coreStatus,
runtimeWriteTransport,
approval,
preferredAgentId: preferredAgentId ?? null,
getAgents: () => stateRef.current.agents,
@@ -1122,7 +1168,7 @@ const AgentStudioPage = () => {
logWarn: (message, error) => console.warn(message, error),
});
},
[client, status]
[coreStatus, runtimeWriteTransport]
);
const handleGatewayEventIngress = useCallback(
@@ -1163,10 +1209,11 @@ const AgentStudioPage = () => {
},
[dispatch, pauseRunForExecApproval]
);
domainEventIngressRef.current = handleGatewayEventIngress;
useEffect(() => {
const handler = createGatewayRuntimeEventHandler({
getStatus: () => status,
getStatus: () => coreStatus,
getAgents: () => stateRef.current.agents,
dispatch,
queueLivePatch,
@@ -1191,6 +1238,11 @@ const AgentStudioPage = () => {
},
});
runtimeEventHandlerRef.current = handler;
if (pendingDomainOutboxEntriesRef.current.length > 0) {
const pendingEntries = pendingDomainOutboxEntriesRef.current;
pendingDomainOutboxEntriesRef.current = [];
ingestDomainOutboxEntries(pendingEntries);
}
let unsubscribeGatewayEvents: (() => void) | null = null;
let stream: EventSource | null = null;
if (useDomainApiMode) {
@@ -1242,8 +1294,9 @@ const AgentStudioPage = () => {
refreshHeartbeatLatestUpdate,
specialLatestUpdate,
handleGatewayEventIngress,
ingestDomainOutboxEntries,
useDomainApiMode,
status,
coreStatus,
]);
const handleAvatarShuffle = useCallback(
@@ -1314,7 +1367,7 @@ const AgentStudioPage = () => {
}
}, [gatewayError]);
if (!agentsLoadedOnce && (!didAttemptGatewayConnect || status === "connecting")) {
if (!agentsLoadedOnce && !coreConnected && (!didAttemptGatewayConnect || status === "connecting")) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="flex min-h-screen items-center justify-center px-6">
@@ -13,9 +13,8 @@ import {
planPauseRunControl,
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
import type { AgentState } from "@/features/agents/state/store";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
@@ -37,7 +36,7 @@ export const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000;
export async function runPauseRunForExecApprovalOperation(params: {
status: string;
client: GatewayClientLike;
runtimeWriteTransport: RuntimeWriteTransport;
approval: PendingExecApproval;
preferredAgentId?: string | null;
getAgents: () => AgentState[];
@@ -45,8 +44,9 @@ export async function runPauseRunForExecApprovalOperation(params: {
isDisconnectLikeError: (error: unknown) => boolean;
logWarn?: (message: string, error: unknown) => void;
}): Promise<void> {
if (params.status !== "connected") return;
const useDomainIntents = isStudioDomainIntentModeEnabled();
const canPause =
params.status === "connected" || params.runtimeWriteTransport.useDomainIntents === true;
if (!canPause) return;
const plan = planPauseRunControl({
approval: params.approval,
@@ -63,15 +63,9 @@ export async function runPauseRunForExecApprovalOperation(params: {
params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId);
try {
if (useDomainIntents) {
await postStudioIntent("/api/intents/chat-abort", {
sessionKey: plan.pauseIntent.sessionKey,
});
} else {
await params.client.call("chat.abort", {
sessionKey: plan.pauseIntent.sessionKey,
});
}
await params.runtimeWriteTransport.chatAbort({
sessionKey: plan.pauseIntent.sessionKey,
});
} catch (error) {
params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId);
if (!params.isDisconnectLikeError(error)) {
@@ -85,6 +79,7 @@ export async function runPauseRunForExecApprovalOperation(params: {
export async function runExecApprovalAutoResumeOperation(params: {
client: GatewayClientLike;
runtimeWriteTransport: RuntimeWriteTransport;
dispatch: RunControlDispatch;
approval: PendingExecApproval;
targetAgentId: string;
@@ -97,13 +92,12 @@ export async function runExecApprovalAutoResumeOperation(params: {
sendChatMessage?: typeof sendChatMessageViaStudio;
now?: () => number;
}): Promise<void> {
const useDomainIntents = isStudioDomainIntentModeEnabled();
const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio;
const pendingState = params.getPendingState();
const preWaitPendingState = params.getPendingState();
const prePlan = planAutoResumeRunControl({
approval: params.approval,
targetAgentId: params.targetAgentId,
pendingState,
pendingState: preWaitPendingState,
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
agents: params.getAgents(),
});
@@ -124,30 +118,25 @@ export async function runExecApprovalAutoResumeOperation(params: {
});
try {
if (useDomainIntents) {
await postStudioIntent("/api/intents/agent-wait", {
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
} else {
await params.client.call("agent.wait", {
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
}
await params.runtimeWriteTransport.agentWait({
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
} catch (error) {
if (!params.isDisconnectLikeError(error)) {
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
"Failed waiting for paused run before auto-resume.",
error
);
if (params.isDisconnectLikeError(error)) {
return;
}
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
"Failed waiting for paused run before auto-resume.",
error
);
}
const postWaitPendingState = params.getPendingState();
const postPlan = planAutoResumeRunControl({
approval: params.approval,
targetAgentId: preWaitIntent.targetAgentId,
pendingState,
pendingState: postWaitPendingState,
pausedRunIdByAgentId: new Map([[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId]]),
agents: params.getAgents(),
});
@@ -157,6 +146,7 @@ export async function runExecApprovalAutoResumeOperation(params: {
await sendChatMessage({
client: params.client,
runtimeWriteTransport: params.runtimeWriteTransport,
dispatch: params.dispatch,
getAgent: (agentId) => params.getAgents().find((entry) => entry.agentId === agentId) ?? null,
agentId: postPlan.postWaitIntent.targetAgentId,
@@ -169,6 +159,7 @@ export async function runExecApprovalAutoResumeOperation(params: {
export async function runResolveExecApprovalOperation(params: {
client: GatewayClientLike;
runtimeWriteTransport: RuntimeWriteTransport;
approvalId: string;
decision: ExecApprovalDecision;
getAgents: () => AgentState[];
@@ -188,7 +179,7 @@ export async function runResolveExecApprovalOperation(params: {
const runAutoResume = params.runAutoResume ?? runExecApprovalAutoResumeOperation;
await resolveExecApproval({
client: params.client,
runtimeWriteTransport: params.runtimeWriteTransport,
approvalId: params.approvalId,
decision: params.decision,
getAgents: params.getAgents,
@@ -201,6 +192,7 @@ export async function runResolveExecApprovalOperation(params: {
onAllowed: async ({ approval, targetAgentId }) => {
await runAutoResume({
client: params.client,
runtimeWriteTransport: params.runtimeWriteTransport,
dispatch: params.dispatch,
approval,
targetAgentId,
@@ -485,4 +485,33 @@ describe("execApprovalRunControlOperation", () => {
expect(call).not.toHaveBeenCalledWith("chat.abort", expect.anything());
expect(call).not.toHaveBeenCalledWith("agent.wait", expect.anything());
});
it("pauses in domain mode when status is disconnected", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
mockedPostStudioIntent.mockReset();
mockedPostStudioIntent.mockResolvedValue({ ok: true });
const call = vi.fn(async () => ({ ok: true }));
const pausedRunIdByAgentId = new Map<string, string>();
await runPauseRunForExecApprovalOperation({
status: "disconnected",
runtimeWriteTransport: createRuntimeWriteTransport({
client: { call } as never,
useDomainIntents: true,
postIntent: mockedPostStudioIntent,
}),
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
getAgents: () => [createAgent({ runId: "run-1" })],
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
});
expect(pausedRunIdByAgentId.get("agent-1")).toBe("run-1");
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-abort", {
sessionKey: "agent:agent-1:main",
});
expect(call).not.toHaveBeenCalledWith("chat.abort", expect.anything());
});
});