feat: interrupt canceled codex app-server turns

This commit is contained in:
AI Bot
2026-06-03 13:12:23 +08:00
parent 142fb2a4b3
commit 13201e6aee
11 changed files with 517 additions and 1 deletions

View File

@@ -72,6 +72,11 @@ function normalizePositiveInteger(value, fallback) {
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback;
}
function normalizeNonNegativeInteger(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : fallback;
}
function boolFromConfigOrEnv(configValue, envValue, fallback) {
if (configValue === true || configValue === false) {
return configValue;
@@ -2809,6 +2814,9 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
const progressCollector = createProgressCollector();
const progressEmits = [];
let lastProgressSnapshotJson = "";
let interruptRequested = false;
let interruptReason = "";
let interruptPollTimer;
const pending = new Map();
const retryTimers = new Set();
let resolveTurnCompleted;
@@ -2836,6 +2844,10 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
const cleanup = () => {
clearTimeout(timeout);
if (interruptPollTimer) {
clearInterval(interruptPollTimer);
interruptPollTimer = undefined;
}
for (const timer of retryTimers) {
clearTimeout(timer);
}
@@ -2984,12 +2996,67 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
const status = message.params?.turn?.status ?? message.params?.status ?? "completed";
if (status === "completed") {
resolveTurnCompleted(message.params ?? {});
} else if (status === "interrupted" && interruptRequested) {
resolveTurnCompleted({ ...(message.params ?? {}), interrupted: true });
} else {
rejectTurnCompleted(new Error(`CODEX_APP_SERVER_TURN_${String(status).toUpperCase()}`));
}
}
};
const startActiveTurnInterruptPolling = ({ threadId, turnId }) => {
if (
!threadId ||
!turnId ||
typeof runnerConfig.shouldInterruptActiveTurn !== "function"
) {
return;
}
const pollIntervalMs = normalizeNonNegativeInteger(
runnerConfig.interruptPollIntervalMs,
750,
);
const checkAndInterrupt = async () => {
if (interruptRequested || turnSettled || closed) {
return;
}
let decision;
try {
decision = await runnerConfig.shouldInterruptActiveTurn({
taskId: task?.taskId,
task,
threadId,
turnId,
});
} catch {
return;
}
const shouldInterrupt =
decision === true ||
decision?.interrupt === true ||
decision?.canceled === true ||
decision?.status === "canceled";
if (!shouldInterrupt || interruptRequested || turnSettled || closed) {
return;
}
interruptRequested = true;
interruptReason = trimToDefined(decision?.reason) || "USER_CANCELED_TASK";
try {
await request("turn/interrupt", { threadId, turnId });
} catch (error) {
rejectTurnCompleted(error);
}
};
void checkAndInterrupt();
if (pollIntervalMs > 0) {
interruptPollTimer = setInterval(() => {
void checkAndInterrupt();
}, pollIntervalMs);
}
};
try {
rpcTransport = await openCodexAppServerTransport(runnerConfig, cwd, {
onLine: handleTransportLine,
@@ -3041,17 +3108,34 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
model: runnerConfig.model,
});
activeTurnStarted = true;
const activeTurnId = trimToDefined(turnResult?.turn?.id) || targetTurnRef;
startActiveTurnInterruptPolling({ threadId, turnId: activeTurnId });
await turnCompleted;
if (progressEmits.length > 0) {
await Promise.allSettled(progressEmits);
}
const normalizedReply = (replyBody || completedMessageText).trim();
if (interruptRequested) {
return {
status: "interrupted",
replyBody: "已按用户要求中断当前 Codex turn。",
threadId,
turnId: activeTurnId,
turnControl: "interrupt",
interruptReason,
cwd,
transport: runnerConfig.transport,
executionProgress: progressCollector.snapshot(),
interThreadBroker,
canFallbackToCli: false,
};
}
return {
status: "completed",
replyBody: normalizedReply,
threadId,
turnId: trimToDefined(turnResult?.turn?.id) || targetTurnRef,
turnId: activeTurnId,
turnControl,
cwd,
transport: runnerConfig.transport,