fix: restore regressions from discord-only refactor

This commit is contained in:
Eyejoker
2026-03-20 03:41:53 +09:00
parent 2b08851c2f
commit a293a701f4
24 changed files with 1779 additions and 396 deletions

View File

@@ -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,

View File

@@ -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.",

View 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();
}

View 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);
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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}`);