feat: update codex live runtime and restart flow

This commit is contained in:
2026-04-23 18:10:43 +09:00
parent b0b9980a6c
commit 6e863feafd
36 changed files with 1636 additions and 358 deletions

View File

@@ -1205,6 +1205,24 @@ export function extractDiffCodeBlocks(output: string) {
.filter(Boolean);
}
function protectDiffCodeBlocks(output: string) {
const blocks: string[] = [];
const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
blocks.push(match);
return token;
});
return { text, blocks };
}
function restoreDiffCodeBlocks(output: string, blocks: string[]) {
return blocks.reduce(
(current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
String(output ?? ''),
);
}
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
@@ -1323,6 +1341,7 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
}
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
@@ -1330,39 +1349,36 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
'g',
);
const matches = [...output.matchAll(candidatePattern)];
const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
let rewrittenOutput = outputWithoutDiffBlocks;
if (matches.length > 0) {
const replacementMap = new Map<string, string>();
if (matches.length === 0) {
return output;
}
for (const match of matches) {
const rawCandidate = match[0]?.trim();
const replacementMap = new Map<string, string>();
if (!rawCandidate || replacementMap.has(rawCandidate)) {
continue;
}
for (const match of matches) {
const rawCandidate = match[0]?.trim();
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
if (!rawCandidate || replacementMap.has(rawCandidate)) {
continue;
if (stagedUrl) {
replacementMap.set(rawCandidate, stagedUrl);
}
}
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
if (stagedUrl) {
replacementMap.set(rawCandidate, stagedUrl);
for (const [sourcePath, publicUrl] of replacements) {
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
}
}
let rewrittenOutput = output;
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
for (const [sourcePath, publicUrl] of replacements) {
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
}
rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput);
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, rewrittenOutput);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
return appendDiffResourceLinks(rewrittenOutput, diffUrls);
}
@@ -1648,6 +1664,7 @@ async function runAgenticCodexReply(
requestId: string,
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
@@ -1670,8 +1687,21 @@ async function runAgenticCodexReply(
let lastProgressText = '';
let completedAgentMessage = '';
let hasIncrementalDelta = false;
const throwIfCancelled = async () => {
if (!isCancellationRequested?.()) {
return;
}
await cancelRunnerCodexExecution(requestId).catch(() => false);
throw new Error('CHAT_RUNTIME_CANCELLED');
};
await throwIfCancelled();
activeChatProcessRegistry.set(requestId, {
cancel: () => cancelRunnerCodexExecution(requestId),
cancel: async () => {
const cancelled = await cancelRunnerCodexExecution(requestId);
return cancelled || isCancellationRequested?.() === true;
},
});
await new Promise<void>(async (resolve, reject) => {
@@ -1688,6 +1718,7 @@ async function runAgenticCodexReply(
};
try {
await throwIfCancelled();
const response = await requestCommandRunner('/api/codex-live/execute', {
method: 'POST',
body: JSON.stringify({
@@ -1705,6 +1736,8 @@ async function runAgenticCodexReply(
return;
}
await throwIfCancelled();
if (!response.body) {
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
return;
@@ -1792,6 +1825,7 @@ async function runAgenticCodexReply(
};
while (true) {
await throwIfCancelled();
const { value, done } = await reader.read();
if (done) {
@@ -2130,8 +2164,17 @@ async function buildCodexReply(
requestId: string,
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
return runAgenticCodexReply(
context,
input,
sessionId,
requestId,
onProgress,
onActivity,
isCancellationRequested,
);
}
export class ChatService {
@@ -2364,6 +2407,25 @@ export class ChatService {
private async cancelRuntimeJob(requestId: string) {
const execution = activeChatProcessRegistry.get(requestId);
const detail = chatRuntimeService.getJobDetail(requestId);
if (!execution && detail.item && detail.terminalStatus == null) {
chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
this.cancelledRequestIds.add(requestId);
const session = this.findSessionByRequestId(requestId);
if (session) {
void upsertChatConversationRequest(session.sessionId, {
requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
}).catch((error: unknown) => {
this.logger.warn(error, 'failed to persist pending chat request cancellation state');
});
}
return true;
}
if (!execution) {
return false;
@@ -2384,7 +2446,14 @@ export class ChatService {
}
try {
return await execution.cancel();
const cancelled = await execution.cancel();
if (!cancelled && this.cancelledRequestIds.has(requestId)) {
chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
return true;
}
return cancelled;
} catch (error) {
this.logger.warn(error, 'failed to cancel chat runtime job');
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
@@ -3221,6 +3290,7 @@ export class ChatService {
(activityLine) => {
appendActivityLine(activityLine);
},
() => this.cancelledRequestIds.has(request.requestId),
);
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');