Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ owner가 NULL이므로, 그 파일들은 `**`의 소유 역할을 따른다. 상
`onTokensChanged`로 갱신된 토큰을 파일에 쓴다.
- **재발급 대상은 `POLICY_STALE`과 `UNAUTHENTICATED` 둘 다다.** 만료(1시간)를 빼면 한 시간 뒤부터 조용히 죽는다.
재발급 후에도 같은 이유로 막히면 `PolicyStaleLoopError`로 멈춘다 — 두 번째 재발급은 하지 않는다.
- **claude는 `claudeEnv()`로 띄운다**(`executor/runner.ts` — 작업 실행·상담·PM 워커, 실험 스크립트도). 띄운 쪽 Claude Code 세션 변수(`CLAUDECODE`·`CLAUDE_CODE_SESSION_ID`·
`CLAUDE_CODE_MESSAGING_*` 등 `SESSION_ENV_KEYS`)를 빼지 않으면, Claude Code 안에서 CLI를 띄웠을 때 태스크 실행이 그 대화의 하위 세션이 된다
(진입점이 claude-vscode로 기록되고 부모 세션의 알림이 모델 맥락에 끼어들었다 — 실측). 인증·설정 변수는 남긴다.
- **모델 출력이 15분(`DEFAULT_IDLE_MS`) 없으면 끊는다**(`stalled`). 도구 결과 뒤 다음 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다. 서버에는 `timeout`으로 보고하고
사유 문장 `[응답 없음]`으로 구분한다(옛 서버는 outcome에 stalled를 모른다). 질문 답 대기(3분)·시험 실행(최대 10분)보다 길어야 한다.
- 작업공간은 `~/.nomos/workspaces/{projectId}/{taskId}/`에 **git worktree로** 만든다. 레포 자체와 분리되므로
사용자의 작업 트리를 건드리지 않는다. 태스크가 끝나도 **남긴다** — 실패 원인은 남은 파일에서 드러난다.
- `.claude/settings.json`이 로컬 방어선이다. 생성에 쓴 `policy_hash`를 `.claude/.nomos-policy.json`에 함께 남긴다.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@capstone-nomos/cli",
"version": "0.1.7",
"version": "0.1.8",
"description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
"type": "module",
"bin": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/experiments/consult-accuracy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import path from 'node:path';
import { buildClaudePermissions } from '../../src/domain/repo/claude-settings.js';
import { SEED_PATH_RULES } from '../../src/domain/repo/seed-paths.js';
import { buildConsultPrompt, consultArgs, parseConsultDraft } from '../../src/executor/consult.js';
import { resolveClaudeCommand } from '../../src/executor/runner.js';
import { claudeEnv, resolveClaudeCommand } from '../../src/executor/runner.js';

const CANARY = 'nomos-canary-7f3a91';

Expand Down Expand Up @@ -150,7 +150,7 @@ async function consultOnce(dir: string, c: Case, scratch: string): Promise<{ tex
const prompt = buildConsultPrompt({ role: 'BACKEND', repoName: 'acme/todo-api', question: { id: c.id, askerRole: 'FRONTEND', questions: [{ question: c.question }] } });
const { command, commandArgs } = resolveClaudeCommand(consultArgs(prompt, emptyMcp));
const stdout = await new Promise<string>((resolve, reject) => {
const child = spawn(command, commandArgs, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(command, commandArgs, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
let out = '';
const timer = setTimeout(() => {
child.kill();
Expand Down
16 changes: 14 additions & 2 deletions src/executor/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { cliVersion, mcpServerPath } from './paths.js';
import { checkTools } from './preflight.js';
import { handleNextPmJob } from './pm-worker.js';
import { buildTaskPrompt } from './prompt.js';
import { runClaude } from './runner.js';
import { DEFAULT_IDLE_MS, runClaude } from './runner.js';
import { streamTaskSource } from './stream-source.js';
import { pollingTaskSource, type TaskSource, type TaskSummary } from './task-source.js';
import { runLint, runSpecTests, type SpecTest, type StageReport } from './verify.js';
Expand Down Expand Up @@ -178,8 +178,18 @@ async function handleTask(client: NomosClient, projectId: string, task: TaskSumm
if (reporter) {
await reporter.close();
// 제출했는지는 서버가 태스크 상태로 판단해 룸에 남긴다("끝났지만 제출하지 않았다"가 대표에게 보인다).
// 응답 없음(stalled)은 서버에 timeout으로 보고하고 사유 문장으로 구분한다 — 옛 서버는 outcome에 stalled를 모른다.
const summary = observer.summary();
const stalledNote = `[응답 없음] ${Math.round(DEFAULT_IDLE_MS / 60_000)}분 동안 모델 출력이 없어 Executor가 실행을 끊었다`;
await client
.endRun(task.id, { outcome: result.outcome, committed, durationMs: result.durationMs, exitCode: result.exitCode, ...observer.summary() })
.endRun(task.id, {
outcome: result.outcome === 'stalled' ? 'timeout' : result.outcome,
committed,
durationMs: result.durationMs,
exitCode: result.exitCode,
...summary,
...(result.outcome === 'stalled' ? { lastMessage: (summary.lastMessage ? `${stalledNote} — 마지막 말: ${summary.lastMessage}` : stalledNote).slice(0, 2000) } : {}),
})
.then((r) => {
if (r.blocked) log('제출 없이 끝나 태스크가 멈춤(BLOCKED)으로 바뀌었다 — 대표가 원인을 해결하고 재개하면 다시 가져간다');
})
Expand All @@ -193,6 +203,8 @@ async function handleTask(client: NomosClient, projectId: string, task: TaskSumm
await reportBridgeStages(client, task.id, workspace.dir, briefing.specTests);
} else if (result.outcome === 'completed') {
log(`정상 종료했지만 커밋이 없다. 태스크는 그대로 둔다. 로그: ${result.logPath}`);
} else if (result.outcome === 'stalled') {
log(`${Math.round(DEFAULT_IDLE_MS / 60_000)}분 동안 모델 출력이 없어 끊었다(응답 없음). 재시도하지 않는다. 로그: ${result.logPath}`);
} else if (result.outcome === 'timeout') {
log(`30분 초과로 종료했다. 재시도하지 않는다. 로그: ${result.logPath}`);
} else {
Expand Down
4 changes: 2 additions & 2 deletions src/executor/consult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { resolveClaudeCommand } from './runner.js';
import { claudeEnv, resolveClaudeCommand } from './runner.js';

// 상담 실행 — 다른 역할 에이전트가 이 역할 소관을 물으면, 이 노트북의 Claude Code(본인 구독)로 레포를 **읽기만** 해서 답 초안을 만든다.
//
Expand Down Expand Up @@ -111,7 +111,7 @@ export async function runConsult(input: { dir: string; role: string; repoName: s
writeFileSync(emptyMcp, JSON.stringify({ mcpServers: {} }));
const { command, commandArgs } = resolveClaudeCommand(consultArgs(buildConsultPrompt(input), emptyMcp));
const out = await new Promise<string>((resolve, reject) => {
const child = spawn(command, commandArgs, { cwd: input.dir, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(command, commandArgs, { cwd: input.dir, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
let stdout = '';
const timer = setTimeout(() => {
child.kill();
Expand Down
4 changes: 2 additions & 2 deletions src/executor/pm-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveClaudeCommand } from './runner.js';
import { claudeEnv, resolveClaudeCommand } from './runner.js';
import { removeQuietly } from './cleanup.js';

// executor pm-worker — 중계 모드(PM_PROVIDER=relay)에서 대표 노트북이 PM의 "모델 호출" 한 자리를 맡는다.
Expand Down Expand Up @@ -101,7 +101,7 @@ const spawnClaude: RunClaudeJson = (args, stdin, cwd) => {
const { command, commandArgs } = resolveClaudeCommand(args);
return new Promise((resolve, reject) => {
// shell을 쓰지 않는다 — 인자가 명령으로 해석되지 않게(runner.ts와 같은 이유).
const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: claudeEnv() });
let stdout = '';
let stderr = '';
child.stdout.on('data', (c: Buffer) => (stdout += c.toString()));
Expand Down
63 changes: 58 additions & 5 deletions src/executor/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,43 @@ import path from 'node:path';
import { buildClaudeArgs } from '../bridge/claude-args.js';

export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
// 모델 출력(stream-json)이 이만큼 없으면 응답이 끊긴 것으로 보고 끊는다. 30분을 다 기다리지 않고, 사유도 "오래 일함"과 구분된다.
// 실측: 도구 결과를 받은 뒤 다음 모델 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다(같은 조건 재실행은 정상).
// 정상적인 무출력 구간보다 길어야 한다 — 질문 답 대기(실행 안 3분)·시험 실행(Bash 기본 2분, 최대 10분)·긴 생각.
export const DEFAULT_IDLE_MS = 15 * 60 * 1000;

export type RunOutcome = 'completed' | 'timeout' | 'failed';
export type RunOutcome = 'completed' | 'timeout' | 'stalled' | 'failed';

// Executor가 띄우는 claude에 넘기지 않는 환경변수 — 띄운 쪽 Claude Code 세션과 묶는 값들이다.
// Claude Code 안에서(예: "nomos connect 실행해 줘") CLI를 띄우면 이 값이 그대로 넘어가 태스크 실행이 그 대화 세션의
// 하위 세션이 된다(진입점이 claude-vscode로 기록되고, 부모 세션의 토큰 잔량 알림이 모델 맥락에 끼어들었다 — 실측).
// 인증·설정용 변수(CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_BEDROCK 등)는 건드리지 않는다.
export const SESSION_ENV_KEYS = [
'CLAUDECODE',
'CLAUDE_PID',
'CLAUDE_EFFORT',
'CLAUDE_AGENT_SDK_VERSION',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_EXECPATH',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ATTENDED',
'CLAUDE_CODE_MESSAGING_SOCKET',
'CLAUDE_CODE_MESSAGING_TOKEN',
'CLAUDE_CODE_SSE_PORT',
'CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING',
'CLAUDE_CODE_ENABLE_TASKS',
'CLAUDE_CODE_EMIT_STARTUP_TIMING',
'CLAUDE_CODE_QUESTION_PREVIEW_FORMAT',
] as const;

export function claudeEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const env = { ...base };
for (const key of Object.keys(env)) {
if ((SESSION_ENV_KEYS as readonly string[]).includes(key.toUpperCase())) delete env[key];
}
return env;
}

export type RunResult = {
outcome: RunOutcome;
Expand Down Expand Up @@ -45,11 +80,14 @@ export type RunInput = {
mcpConfigPath: string;
model?: string;
timeoutMs?: number;
idleMs?: number;
questionRelay?: boolean;
// stdout(stream-json) 한 줄씩 실시간으로. 룸 활동 보고가 쓴다. 던져도 실행은 계속된다.
onStdoutLine?: (line: string) => void;
// PATH 앞에 붙일 폴더(작업공간의 파이썬 가상환경) — 모델의 `python -m pytest`가 미리 설치한 의존성을 쓰게.
pathPrepend?: string | null;
// 테스트용 — claude 대신 띄울 명령
command?: { command: string; commandArgs: string[] };
};

export async function runClaude(input: RunInput): Promise<RunResult> {
Expand All @@ -60,7 +98,7 @@ export async function runClaude(input: RunInput): Promise<RunResult> {
...(input.model === undefined ? {} : { model: input.model }),
...(input.questionRelay === undefined ? {} : { questionRelay: input.questionRelay }),
});
const { command, commandArgs } = resolveClaudeCommand(args);
const { command, commandArgs } = input.command ?? resolveClaudeCommand(args);
const logPath = path.join(input.workspaceDir, '.nomos-run.log');
const startedAt = Date.now();

Expand All @@ -69,7 +107,7 @@ export async function runClaude(input: RunInput): Promise<RunResult> {
const child = spawn(command, commandArgs, {
cwd: input.workspaceDir,
stdio: ['ignore', 'pipe', 'pipe'],
...(input.pathPrepend ? { env: withPathPrepended(process.env, input.pathPrepend) } : {}),
env: input.pathPrepend ? withPathPrepended(claudeEnv(), input.pathPrepend) : claudeEnv(),
});

const chunks: string[] = [];
Expand All @@ -88,7 +126,9 @@ export async function runClaude(input: RunInput): Promise<RunResult> {
}
}
};
let lastOutputAt = Date.now();
child.stdout.on('data', (c: Buffer) => {
lastOutputAt = Date.now();
collect(c);
emitLines(c);
});
Expand All @@ -99,12 +139,24 @@ export async function runClaude(input: RunInput): Promise<RunResult> {
timedOut = true;
child.kill();
}, input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
// 출력 없는 시간 감지. 마지막 출력 시각만 보므로 도구가 오래 돌아도 그 사이 다른 출력이 있으면 끊지 않는다.
const idleMs = input.idleMs ?? DEFAULT_IDLE_MS;
let stalled = false;
const idleCheck = setInterval(() => {
if (Date.now() - lastOutputAt >= idleMs) {
stalled = true;
child.kill();
}
}, Math.min(30_000, Math.max(50, Math.floor(idleMs / 4))));

child.on('close', (code) => {
clearTimeout(timer);
writeFileSync(logPath, chunks.join(''));
clearInterval(idleCheck);
writeFileSync(logPath, stalled ? `${chunks.join('')}
[executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다
` : chunks.join(''));
resolve({
outcome: timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
outcome: stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
exitCode: code,
durationMs: Date.now() - startedAt,
logPath,
Expand All @@ -113,6 +165,7 @@ export async function runClaude(input: RunInput): Promise<RunResult> {

child.on('error', (err) => {
clearTimeout(timer);
clearInterval(idleCheck);
writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
});
Expand Down
Loading
Loading