fix: restore regressions from discord-only refactor
This commit is contained in:
@@ -511,6 +511,18 @@ async function runQuery(
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
...(process.env.MEMENTO_MCP_SSE_URL
|
||||
? {
|
||||
'memento-mcp': {
|
||||
command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote',
|
||||
args: [
|
||||
process.env.MEMENTO_MCP_SSE_URL,
|
||||
'--header',
|
||||
`Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`,
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
@@ -539,7 +551,22 @@ async function runQuery(
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
const isError = message.subtype?.startsWith('error');
|
||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
||||
if (isError) {
|
||||
// Log full error details for debugging
|
||||
const msg = message as Record<string, unknown>;
|
||||
const errorDetail = JSON.stringify({
|
||||
subtype: message.subtype,
|
||||
result: textResult?.slice(0, 500),
|
||||
errors: msg.errors,
|
||||
stop_reason: msg.stop_reason,
|
||||
duration_ms: msg.duration_ms,
|
||||
duration_api_ms: msg.duration_api_ms,
|
||||
session_id: msg.session_id,
|
||||
});
|
||||
log(`Error result detail: ${errorDetail}`);
|
||||
}
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
@@ -747,7 +774,11 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||
const errorCause = err instanceof Error && err.cause ? String(err.cause) : undefined;
|
||||
log(`Agent error: ${errorMessage}`);
|
||||
if (errorStack) log(`Stack: ${errorStack}`);
|
||||
if (errorCause) log(`Cause: ${errorCause}`);
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
|
||||
@@ -10,6 +10,11 @@ import { z } from 'zod';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import {
|
||||
buildCiWatchPrompt,
|
||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
normalizeWatchCiIntervalSeconds,
|
||||
} from './watch-ci.js';
|
||||
|
||||
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
||||
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
||||
@@ -152,6 +157,90 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'watch_ci',
|
||||
'Schedule a background CI watcher that checks until a run or check reaches a terminal state, then sends one message and cancels itself.',
|
||||
{
|
||||
target: z
|
||||
.string()
|
||||
.describe(
|
||||
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
|
||||
),
|
||||
check_instructions: z
|
||||
.string()
|
||||
.describe(
|
||||
'Exact steps or commands to check status and what details matter when it finishes.',
|
||||
),
|
||||
poll_interval_seconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(30)
|
||||
.max(3600)
|
||||
.default(60)
|
||||
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
||||
context_mode: z
|
||||
.enum(['group', 'isolated'])
|
||||
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
||||
.describe(
|
||||
'group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.',
|
||||
),
|
||||
target_group_jid: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'(Main group only) JID of the group to schedule the watcher for. Defaults to the current group.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
let pollSeconds: number;
|
||||
try {
|
||||
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds);
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const targetJid =
|
||||
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const prompt = buildCiWatchPrompt({
|
||||
taskId,
|
||||
target: args.target,
|
||||
checkInstructions: args.check_instructions,
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: 'schedule_task',
|
||||
taskId,
|
||||
prompt,
|
||||
schedule_type: 'interval' as const,
|
||||
schedule_value: String(pollSeconds * 1000),
|
||||
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
targetJid,
|
||||
createdBy: groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'list_tasks',
|
||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||
|
||||
72
runners/agent-runner/src/watch-ci.ts
Normal file
72
runners/agent-runner/src/watch-ci.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
||||
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
||||
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
||||
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
||||
|
||||
export interface BuildCiWatchPromptArgs {
|
||||
taskId: string;
|
||||
target: string;
|
||||
checkInstructions: string;
|
||||
}
|
||||
|
||||
export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
|
||||
if (seconds === undefined) {
|
||||
return DEFAULT_WATCH_CI_INTERVAL_SECONDS;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(seconds)) {
|
||||
throw new Error('poll_interval_seconds must be an integer.');
|
||||
}
|
||||
|
||||
if (
|
||||
seconds < MIN_WATCH_CI_INTERVAL_SECONDS ||
|
||||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
||||
) {
|
||||
throw new Error(
|
||||
`poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return seconds;
|
||||
}
|
||||
|
||||
export function buildCiWatchPrompt({
|
||||
taskId,
|
||||
target,
|
||||
checkInstructions,
|
||||
}: BuildCiWatchPromptArgs): string {
|
||||
return `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
You are running as an EJClaw background CI watcher.
|
||||
|
||||
Watch target:
|
||||
${target}
|
||||
|
||||
Task ID:
|
||||
${taskId}
|
||||
|
||||
Check instructions:
|
||||
${checkInstructions}
|
||||
|
||||
Rules:
|
||||
- Use the watch target and check instructions in this prompt as the source of truth for what to inspect.
|
||||
- On each run, check whether the target is still queued, pending, running, in progress, or otherwise non-terminal.
|
||||
- If it is still not finished, send no visible message and end this run quietly.
|
||||
- If it reached a terminal state such as success, failure, cancelled, timed out, neutral, skipped, or action required:
|
||||
1. Send exactly one concise completion message with \`send_message\`.
|
||||
2. Format it as a short multiline summary when possible, not one long paragraph.
|
||||
3. Preferred shape:
|
||||
- First line: \`CI 완료: <target>\`
|
||||
- Second line: \`판정: <one-line conclusion>\`
|
||||
- Then 2-4 flat bullet points with only the most important metrics, errors, or comparisons.
|
||||
- Optional final line: \`다음: <next action>\` if a concrete follow-up is needed.
|
||||
4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit.
|
||||
5. Avoid tables unless they are clearly the shortest readable format.
|
||||
6. Keep the message compact and easy for other agents to parse.
|
||||
7. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself.
|
||||
- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry.
|
||||
- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`<internal>\` tags if needed.
|
||||
- Do not claim continued monitoring after you cancel the task.
|
||||
`.trim();
|
||||
}
|
||||
49
runners/agent-runner/test/watch-ci.test.ts
Normal file
49
runners/agent-runner/test/watch-ci.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCiWatchPrompt,
|
||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
normalizeWatchCiIntervalSeconds,
|
||||
} from '../src/watch-ci.js';
|
||||
|
||||
describe('watch-ci helpers', () => {
|
||||
it('builds a self-cancelling CI watch prompt', () => {
|
||||
const prompt = buildCiWatchPrompt({
|
||||
taskId: 'task-123',
|
||||
target: 'PR #42 checks',
|
||||
checkInstructions:
|
||||
'Use gh pr checks 42 and summarize only terminal results.',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('PR #42 checks');
|
||||
expect(prompt).toContain('task-123');
|
||||
expect(prompt).toContain('cancel_task');
|
||||
expect(prompt).toContain('send_message');
|
||||
expect(prompt).toContain('gh pr checks 42');
|
||||
expect(prompt).toContain('CI 완료: <target>');
|
||||
expect(prompt).toContain('판정: <one-line conclusion>');
|
||||
expect(prompt).toContain(
|
||||
'Use the watch target and check instructions in this prompt as the source of truth',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults CI watchers to isolated context', () => {
|
||||
expect(DEFAULT_WATCH_CI_CONTEXT_MODE).toBe('isolated');
|
||||
});
|
||||
|
||||
it('normalizes valid poll intervals', () => {
|
||||
expect(normalizeWatchCiIntervalSeconds()).toBe(60);
|
||||
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
|
||||
expect(normalizeWatchCiIntervalSeconds(600)).toBe(600);
|
||||
});
|
||||
|
||||
it('rejects invalid poll intervals', () => {
|
||||
expect(() => normalizeWatchCiIntervalSeconds(29)).toThrow(
|
||||
/between 30 and 3600/i,
|
||||
);
|
||||
expect(() => normalizeWatchCiIntervalSeconds(3601)).toThrow(
|
||||
/between 30 and 3600/i,
|
||||
);
|
||||
expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i);
|
||||
});
|
||||
});
|
||||
14
runners/codex-runner/package-lock.json
generated
14
runners/codex-runner/package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "nanoclaw-codex-runner",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@openai/codex-sdk": "^0.115.0"
|
||||
"@openai/codex": "^0.115.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -103,18 +103,6 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz",
|
||||
"integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.115.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-win32-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.115.0-win32-arm64",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
"name": "nanoclaw-codex-runner",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Container-side Codex CLI runner for NanoClaw",
|
||||
"description": "Codex app-server runner for NanoClaw",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openai/codex-sdk": "^0.115.0"
|
||||
"@openai/codex": "^0.115.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CodexAppServerTurnOptions {
|
||||
cwd: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CodexAppServerTurnResult {
|
||||
@@ -69,6 +70,7 @@ interface PendingRequest {
|
||||
interface ActiveTurn {
|
||||
threadId: string;
|
||||
state: AppServerTurnState;
|
||||
onProgress?: (message: string) => void;
|
||||
resolve: (value: CodexAppServerTurnResult) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
@@ -215,6 +217,7 @@ export class CodexAppServerClient {
|
||||
this.activeTurn = {
|
||||
threadId,
|
||||
state: createInitialAppServerTurnState(),
|
||||
onProgress: options.onProgress,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
@@ -366,6 +369,20 @@ export class CodexAppServerClient {
|
||||
private handleNotification(message: JsonRpcNotification): void {
|
||||
if (!this.activeTurn) return;
|
||||
|
||||
if (message.method === 'item/completed') {
|
||||
const item =
|
||||
(message.params?.item as Record<string, unknown> | undefined) ||
|
||||
undefined;
|
||||
if (
|
||||
item?.type === 'agentMessage' &&
|
||||
item.phase !== 'final_answer' &&
|
||||
typeof item.text === 'string' &&
|
||||
item.text.trim().length > 0
|
||||
) {
|
||||
this.activeTurn.onProgress?.(item.text);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeTurn.state = reduceAppServerTurnState(
|
||||
this.activeTurn.state,
|
||||
message as AppServerTurnEvent,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* NanoClaw Codex Runner
|
||||
*
|
||||
* Default runtime is Codex app-server, with SDK fallback available via
|
||||
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
|
||||
* App-server only runtime.
|
||||
*
|
||||
* Input protocol:
|
||||
* Stdin: Full ContainerInput JSON (read until EOF)
|
||||
@@ -13,7 +12,6 @@
|
||||
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
||||
*/
|
||||
|
||||
import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -38,6 +36,7 @@ interface ContainerInput {
|
||||
interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
phase?: 'progress' | 'final';
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -51,7 +50,6 @@ const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||
const IPC_POLL_MS = 500;
|
||||
const MAX_TURNS = 100;
|
||||
const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase();
|
||||
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
@@ -168,25 +166,6 @@ function extractImagePaths(text: string): { cleanText: string; imagePaths: strin
|
||||
};
|
||||
}
|
||||
|
||||
function parseSdkInput(text: string): string | UserInput[] {
|
||||
const { cleanText, imagePaths } = extractImagePaths(text);
|
||||
if (imagePaths.length === 0) return text;
|
||||
|
||||
const input: UserInput[] = [];
|
||||
if (cleanText) {
|
||||
input.push({ type: 'text', text: cleanText });
|
||||
}
|
||||
for (const imgPath of imagePaths) {
|
||||
if (fs.existsSync(imgPath)) {
|
||||
input.push({ type: 'local_image', path: imgPath });
|
||||
log(`Adding image input: ${imgPath}`);
|
||||
} else {
|
||||
log(`Image not found, skipping: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
return input.length > 0 ? input : text;
|
||||
}
|
||||
|
||||
function parseAppServerInput(text: string): AppServerInputItem[] {
|
||||
const { cleanText, imagePaths } = extractImagePaths(text);
|
||||
const input: AppServerInputItem[] = [];
|
||||
@@ -211,66 +190,43 @@ function parseAppServerInput(text: string): AppServerInputItem[] {
|
||||
return input;
|
||||
}
|
||||
|
||||
function getThreadOptions(): ThreadOptions {
|
||||
const threadOptions: ThreadOptions = {
|
||||
workingDirectory: EFFECTIVE_CWD,
|
||||
approvalPolicy: 'never',
|
||||
sandboxMode: 'danger-full-access',
|
||||
networkAccessEnabled: true,
|
||||
webSearchMode: 'live',
|
||||
};
|
||||
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
|
||||
if (CODEX_EFFORT) {
|
||||
threadOptions.modelReasoningEffort =
|
||||
CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
|
||||
}
|
||||
return threadOptions;
|
||||
}
|
||||
function formatProgressElapsed(ms: number): string {
|
||||
const elapsedSeconds = Math.floor(ms / 10_000) * 10;
|
||||
const hours = Math.floor(elapsedSeconds / 3600);
|
||||
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
||||
const seconds = elapsedSeconds % 60;
|
||||
const parts: string[] = [];
|
||||
|
||||
async function executeSdkTurn(
|
||||
thread: Thread,
|
||||
input: string | UserInput[],
|
||||
): Promise<{ result: string; error?: string }> {
|
||||
const ac = new AbortController();
|
||||
if (hours > 0) parts.push(`${hours}시간`);
|
||||
if (minutes > 0) parts.push(`${minutes}분`);
|
||||
parts.push(`${seconds}초`);
|
||||
|
||||
let turnSeconds = 0;
|
||||
const sentinel = setInterval(() => {
|
||||
if (consumeCloseSentinel()) {
|
||||
log('Close sentinel detected during SDK turn, aborting');
|
||||
ac.abort();
|
||||
return;
|
||||
}
|
||||
turnSeconds += 5;
|
||||
if (turnSeconds % 60 === 0) {
|
||||
log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
const turn = await thread.run(input, { signal: ac.signal });
|
||||
return { result: turn.finalResponse };
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) {
|
||||
return { result: '' };
|
||||
}
|
||||
return {
|
||||
result: '',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
clearInterval(sentinel);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
async function executeAppServerTurn(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
prompt: string,
|
||||
): Promise<{ result: string; error?: string }> {
|
||||
): Promise<{ result: string | null; error?: string }> {
|
||||
let lastProgressMessage: string | null = null;
|
||||
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
|
||||
cwd: EFFECTIVE_CWD,
|
||||
model: CODEX_MODEL || undefined,
|
||||
effort: CODEX_EFFORT || undefined,
|
||||
onProgress: (message) => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || trimmed === lastProgressMessage) {
|
||||
return;
|
||||
}
|
||||
lastProgressMessage = trimmed;
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: trimmed,
|
||||
newSessionId: threadId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let elapsedMs = 0;
|
||||
@@ -308,7 +264,7 @@ async function executeAppServerTurn(
|
||||
|
||||
elapsedMs += IPC_POLL_MS;
|
||||
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
|
||||
log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`);
|
||||
log(`Turn in progress... (${formatProgressElapsed(elapsedMs)})`);
|
||||
}
|
||||
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
|
||||
};
|
||||
@@ -318,13 +274,13 @@ async function executeAppServerTurn(
|
||||
try {
|
||||
const { state, result } = await activeTurn.wait();
|
||||
if (state.status === 'completed') {
|
||||
return { result: result || '' };
|
||||
return { result };
|
||||
}
|
||||
if (state.status === 'interrupted' && consumeCloseSentinel()) {
|
||||
return { result: result || '' };
|
||||
return { result };
|
||||
}
|
||||
return {
|
||||
result: result || '',
|
||||
result,
|
||||
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
|
||||
};
|
||||
} finally {
|
||||
@@ -332,87 +288,6 @@ async function executeAppServerTurn(
|
||||
}
|
||||
}
|
||||
|
||||
async function runSdkSession(
|
||||
containerInput: ContainerInput,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
const threadOptions = getThreadOptions();
|
||||
const codex = new Codex();
|
||||
|
||||
let thread: Thread;
|
||||
if (containerInput.sessionId) {
|
||||
thread = codex.resumeThread(containerInput.sessionId, threadOptions);
|
||||
log(`Thread resuming (session: ${containerInput.sessionId})`);
|
||||
} else {
|
||||
thread = codex.startThread(threadOptions);
|
||||
log('Thread started (new session)');
|
||||
}
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (turnCount > MAX_TURNS) {
|
||||
log(`Turn limit reached (${MAX_TURNS}), exiting`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
|
||||
newSessionId: thread.id || undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const input = parseSdkInput(prompt);
|
||||
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
|
||||
|
||||
let { result, error } = await executeSdkTurn(thread, input);
|
||||
|
||||
if (error && turnCount === 1 && containerInput.sessionId) {
|
||||
log(`Resume may have failed, retrying with new thread: ${error}`);
|
||||
thread = codex.startThread(threadOptions);
|
||||
({ result, error } = await executeSdkTurn(thread, input));
|
||||
}
|
||||
|
||||
if (consumeCloseSentinel()) {
|
||||
if (result) {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result,
|
||||
newSessionId: thread.id || undefined,
|
||||
});
|
||||
}
|
||||
log('Close sentinel detected, exiting SDK runtime');
|
||||
break;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
log(`SDK turn error: ${error}`);
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: result || null,
|
||||
newSessionId: thread.id || undefined,
|
||||
error,
|
||||
});
|
||||
} else {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: result || null,
|
||||
newSessionId: thread.id || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
log('SDK turn done, waiting for next IPC message...');
|
||||
|
||||
const nextMessage = await waitForIpcMessage();
|
||||
if (nextMessage === null) {
|
||||
log('Close sentinel received, exiting SDK runtime');
|
||||
break;
|
||||
}
|
||||
|
||||
log(`Got new SDK message (${nextMessage.length} chars)`);
|
||||
prompt = nextMessage;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppServerCompact(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string | undefined,
|
||||
@@ -529,6 +404,7 @@ async function runAppServerSession(
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: result || null,
|
||||
...(result ? { phase: 'final' as const } : {}),
|
||||
newSessionId: threadId,
|
||||
});
|
||||
}
|
||||
@@ -549,10 +425,6 @@ async function runAppServerSession(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseAppServer(): boolean {
|
||||
return CODEX_RUNTIME !== 'sdk';
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -595,28 +467,9 @@ async function main(): Promise<void> {
|
||||
prompt += '\n' + pending.join('\n');
|
||||
}
|
||||
|
||||
const preferAppServer = shouldUseAppServer();
|
||||
try {
|
||||
if (preferAppServer) {
|
||||
try {
|
||||
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
|
||||
await runAppServerSession(containerInput, prompt);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (CODEX_RUNTIME === 'app-server') {
|
||||
log(
|
||||
`App-server runtime failed, falling back to SDK: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('Runtime selected: sdk');
|
||||
await runSdkSession(containerInput, prompt);
|
||||
log('Runtime selected: app-server');
|
||||
await runAppServerSession(containerInput, prompt);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log(`Runner error: ${errorMessage}`);
|
||||
|
||||
Reference in New Issue
Block a user