Files
openclaw/extensions/codex/src/app-server/run-attempt-lifecycle-controller.ts
Peter Steinberger 0e792b6de3 refactor(channels): centralize inbound orchestration and remove internal compat (#109716)
* refactor(channels): centralize inbound turn orchestration

* refactor(runtime): remove stale compatibility paths

* chore(guards): reject internal deprecated API use

* refactor(channels): simplify core turn planning

* chore(guards): keep deprecated checks boundary-focused

* refactor(memory): keep modern config off compat barrel

* fix(msteams): preserve feedback learning

* test(channels): align modern inbound fixtures

* refactor(channels): finish modern inbound migration

* refactor(channels): tighten core inbound kernel

* fix(channels): preserve turn assembly narrowing

* test(sdk): keep runtime mock binding immutable

* test(matrix): isolate read policy runtime

* test(msteams): mock canonical reply factory

* test(slack): mock core inbound turn dispatch

* test(telegram): inject core session recorder

* test(signal): inject core session recorder

* test(googlechat): assert canonical inbound routing

* test(synology-chat): align core turn fixture

* fix(sdk): preserve direct DM runtime compat

* refactor(channels): own inbound envelope compat in core

* refactor(channels): trim inbound dispatch seams

* refactor(channels): remove redundant async wrappers

* test(synology-chat): type canonical dispatcher mock

* refactor(channels): remove remaining dead compat seams

* chore(sdk): refresh API baseline after rebase

* fix(channels): preserve direct DM identity metadata
2026-07-17 00:56:46 -07:00

271 lines
9.5 KiB
TypeScript

import {
embeddedAgentLog,
FAST_MODE_AUTO_PROGRESS_KIND,
formatErrorMessage,
formatFastModeAutoProgressText,
resolveAgentRunAbortLifecycleFields,
resolveFastModeForElapsed,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS,
interruptCodexTurnBestEffort,
} from "./attempt-client-cleanup.js";
import { reportCodexExecutionNotification } from "./attempt-notification-state.js";
import {
resolveTerminalDynamicToolBatchAction,
shouldReleaseTurnAfterTerminalDynamicTool,
} from "./dynamic-tool-execution.js";
import type {
CodexDynamicToolCallParams,
CodexDynamicToolCallResponse,
CodexServerNotification,
} from "./protocol.js";
import { buildCodexLifecycleTerminalMeta } from "./run-attempt-lifecycle-terminal.js";
import { emitCodexAppServerEvent } from "./run-attempt-lifecycle.js";
import type { CodexAttemptResources } from "./run-attempt-resources.js";
import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js";
export function createCodexAttemptLifecycleController(
resources: CodexAttemptResources,
turnRuntime: CodexAttemptTurnState,
) {
const { prompt, state: resourceState, trajectoryRecorder } = resources;
const { connection } = prompt.context.runtime;
const {
params,
attemptStartedAt,
runAbortController,
fastModeAutoStartedAtMs,
fastModeAutoProgressState,
} = connection;
const { state, activeTurnItemIds, pendingOpenClawDynamicToolCompletionIds, turnWatches } =
turnRuntime;
const releaseTurnAfterTerminalDynamicTool = (value: {
call: CodexDynamicToolCallParams;
response: CodexDynamicToolCallResponse;
durationMs: number;
}) => {
if (
!shouldReleaseTurnAfterTerminalDynamicTool({
completed: state.completed,
aborted: runAbortController.signal.aborted,
responseSuccess: value.response.success,
currentTurnHadNonTerminalDynamicToolResult:
state.currentTurnHadNonTerminalDynamicToolResult,
activeAppServerTurnRequests: state.activeAppServerTurnRequests,
activeTurnItemIdsCount: activeTurnItemIds.size,
pendingOpenClawDynamicToolCompletionIdsCount: pendingOpenClawDynamicToolCompletionIds.size,
})
) {
return;
}
state.pendingTerminalDynamicToolRelease = undefined;
trajectoryRecorder?.recordEvent("turn.dynamic_tool_terminal_release", {
threadId: value.call.threadId,
turnId: value.call.turnId,
toolCallId: value.call.callId,
name: value.call.tool,
durationMs: value.durationMs,
});
embeddedAgentLog.info("codex app-server turn released after terminal dynamic tool result", {
threadId: value.call.threadId,
turnId: value.call.turnId,
toolCallId: value.call.callId,
tool: value.call.tool,
durationMs: value.durationMs,
});
// Interrupt drops accepted pending input. Reject unconsumed steering first so
// completion delivery can use its fallback path instead of reporting success.
turnRuntime.steeringQueueRef.current?.cancel();
interruptCodexTurnBestEffort(resourceState.client, {
threadId: value.call.threadId,
turnId: value.call.turnId,
timeoutMs: CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS,
});
state.completed = true;
turnWatches.clearCompletionIdleTimer();
turnWatches.clearAssistantCompletionIdleTimer();
turnWatches.clearTerminalIdleTimer();
state.resolveCompletion?.();
};
const scheduleTerminalDynamicToolReleaseCheck = () => {
if (
state.terminalDynamicToolReleaseCheckScheduled ||
(!state.pendingTerminalDynamicToolRelease &&
!state.currentTurnHadNonTerminalDynamicToolResult)
) {
return;
}
// The JSON-RPC response must flush before the terminal tool interrupts its turn.
state.terminalDynamicToolReleaseCheckScheduled = true;
const immediate = setImmediate(() => {
state.terminalDynamicToolReleaseCheckScheduled = false;
if (
state.pendingTerminalDynamicToolRelease?.response.success === true &&
!state.currentTurnHadNonTerminalDynamicToolResult &&
state.activeAppServerTurnRequests === 0 &&
pendingOpenClawDynamicToolCompletionIds.size === 0
) {
// Tool response flush plus sibling classification commits terminal release.
// Fence steering now; active Codex items may delay the actual interrupt.
turnRuntime.steeringQueueRef.current?.cancel();
}
const action = resolveTerminalDynamicToolBatchAction({
activeAppServerTurnRequests: state.activeAppServerTurnRequests,
activeTurnItemIdsCount: activeTurnItemIds.size,
pendingOpenClawDynamicToolCompletionIdsCount: pendingOpenClawDynamicToolCompletionIds.size,
currentTurnHadNonTerminalDynamicToolResult:
state.currentTurnHadNonTerminalDynamicToolResult,
hasPendingTerminalDynamicToolRelease: state.pendingTerminalDynamicToolRelease !== undefined,
});
if (action === "release-pending-terminal" && state.pendingTerminalDynamicToolRelease) {
releaseTurnAfterTerminalDynamicTool(state.pendingTerminalDynamicToolRelease);
} else if (action === "clear-nonterminal-batch") {
state.pendingTerminalDynamicToolRelease = undefined;
state.currentTurnHadNonTerminalDynamicToolResult = false;
}
});
immediate.unref?.();
};
const scheduleTurnReleaseAfterTerminalDynamicTool = (value: {
call: CodexDynamicToolCallParams;
response: CodexDynamicToolCallResponse;
durationMs: number;
}) => {
state.pendingTerminalDynamicToolRelease = value;
scheduleTerminalDynamicToolReleaseCheck();
};
const emitLifecycleStart = () => {
void emitCodexAppServerEvent(params, {
stream: "lifecycle",
data: { phase: "start", startedAt: attemptStartedAt },
});
state.lifecycleStarted = true;
};
const emitLifecycleTerminal = (data: Record<string, unknown> & { phase: "end" | "error" }) => {
if (!state.lifecycleStarted || state.lifecycleTerminalEmitted) {
return;
}
void emitCodexAppServerEvent(params, {
stream: "lifecycle",
data: {
startedAt: attemptStartedAt,
endedAt: Date.now(),
...data,
...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}),
},
});
state.lifecycleTerminalEmitted = true;
};
const buildLifecycleTerminalMeta = (input: {
aborted: boolean;
timedOut: boolean;
yielded?: boolean;
}) => {
const abortFields = input.aborted
? resolveAgentRunAbortLifecycleFields(runAbortController.signal)
: undefined;
return buildCodexLifecycleTerminalMeta({
...input,
abortStopReason: abortFields?.stopReason,
});
};
const executionPhaseKeys = new Set<string>();
const emitExecutionPhaseOnce = (
key: string,
info: Parameters<NonNullable<EmbeddedRunAttemptParams["onExecutionPhase"]>>[0],
) => {
if (executionPhaseKeys.has(key)) {
return;
}
executionPhaseKeys.add(key);
params.onExecutionPhase?.({
provider: params.provider,
model: params.modelId,
backend: "codex-app-server",
...info,
});
};
const reportExecutionNotification = (notification: CodexServerNotification) => {
reportCodexExecutionNotification({ notification, emitExecutionPhaseOnce });
};
const emitFastModeAutoProgress = async (payload: {
enabled: boolean;
elapsedSeconds: number;
fastAutoOnSeconds?: number;
}) => {
const summary = formatFastModeAutoProgressText(payload);
await emitCodexAppServerEvent(params, {
stream: "item",
data: { kind: "status", title: "Fast", phase: "update", summary },
});
try {
await params.onToolResult?.({
text: summary,
channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
});
} catch (error) {
embeddedAgentLog.debug("codex app-server fast mode auto progress delivery failed", { error });
}
};
const maybeAnnounceFastModeAutoOff = async () => {
if (
params.fastModeAuto !== true ||
fastModeAutoStartedAtMs === undefined ||
fastModeAutoProgressState.offAnnounced
) {
return;
}
const next = resolveFastModeForElapsed({
mode: "auto",
startedAtMs: fastModeAutoStartedAtMs,
fastAutoOnSeconds: params.fastModeAutoOnSeconds,
});
if (next.enabled) {
return;
}
fastModeAutoProgressState.offAnnounced = true;
await emitFastModeAutoProgress(next);
};
const maybeEmitFastModeAutoReset = async () => {
if (
params.fastModeAuto !== true ||
!fastModeAutoProgressState.offAnnounced ||
fastModeAutoProgressState.resetAnnounced
) {
return;
}
fastModeAutoProgressState.resetAnnounced = true;
await emitFastModeAutoProgress({
enabled: true,
elapsedSeconds: 0,
fastAutoOnSeconds: params.fastModeAutoOnSeconds,
});
};
const maybeEmitFastModeAutoResetBestEffort = async () => {
try {
await maybeEmitFastModeAutoReset();
} catch (error) {
embeddedAgentLog.warn(
`codex app-server fast mode auto reset progress failed: ${formatErrorMessage(error)}`,
);
}
};
return {
scheduleTerminalDynamicToolReleaseCheck,
scheduleTurnReleaseAfterTerminalDynamicTool,
emitLifecycleStart,
emitLifecycleTerminal,
buildLifecycleTerminalMeta,
emitExecutionPhaseOnce,
reportExecutionNotification,
maybeAnnounceFastModeAutoOff,
maybeEmitFastModeAutoResetBestEffort,
};
}
export type CodexAttemptLifecycleController = ReturnType<
typeof createCodexAttemptLifecycleController
>;