diff --git a/CLAUDE.md b/CLAUDE.md index c19b97e..6f91287 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`에 함께 남긴다. diff --git a/packages/cli/package.json b/packages/cli/package.json index a3da0b1..ba2fe6e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { diff --git a/scripts/experiments/consult-accuracy.ts b/scripts/experiments/consult-accuracy.ts index ca30ebb..56985c5 100644 --- a/scripts/experiments/consult-accuracy.ts +++ b/scripts/experiments/consult-accuracy.ts @@ -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'; @@ -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((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(); diff --git a/src/executor/cli.ts b/src/executor/cli.ts index 7ccb561..507ae25 100644 --- a/src/executor/cli.ts +++ b/src/executor/cli.ts @@ -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'; @@ -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)으로 바뀌었다 — 대표가 원인을 해결하고 재개하면 다시 가져간다'); }) @@ -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 { diff --git a/src/executor/consult.ts b/src/executor/consult.ts index 18d74b8..fa06233 100644 --- a/src/executor/consult.ts +++ b/src/executor/consult.ts @@ -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(본인 구독)로 레포를 **읽기만** 해서 답 초안을 만든다. // @@ -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((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(); diff --git a/src/executor/pm-worker.ts b/src/executor/pm-worker.ts index 39ea078..61ce305 100644 --- a/src/executor/pm-worker.ts +++ b/src/executor/pm-worker.ts @@ -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의 "모델 호출" 한 자리를 맡는다. @@ -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())); diff --git a/src/executor/runner.ts b/src/executor/runner.ts index ac1f34c..904c9d3 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -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; @@ -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 { @@ -60,7 +98,7 @@ export async function runClaude(input: RunInput): Promise { ...(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(); @@ -69,7 +107,7 @@ export async function runClaude(input: RunInput): Promise { 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[] = []; @@ -88,7 +126,9 @@ export async function runClaude(input: RunInput): Promise { } } }; + let lastOutputAt = Date.now(); child.stdout.on('data', (c: Buffer) => { + lastOutputAt = Date.now(); collect(c); emitLines(c); }); @@ -99,12 +139,24 @@ export async function runClaude(input: RunInput): Promise { 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, @@ -113,6 +165,7 @@ export async function runClaude(input: RunInput): Promise { 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 }); }); diff --git a/tests/e2e-ask-decision.test.ts b/tests/e2e-ask-decision.test.ts new file mode 100644 index 0000000..29ce2e2 --- /dev/null +++ b/tests/e2e-ask-decision.test.ts @@ -0,0 +1,279 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { createWriteStream, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { pool } from '../src/config/db.js'; +import { connectAgent, refreshAgentToken } from '../src/domain/agent/service.js'; +import { createSpec, createTask } from '../src/domain/authoring/service.js'; +import { signup } from '../src/domain/auth/service.js'; +import { acceptInvite, createInvite } from '../src/domain/invite/service.js'; +import { createOrganization } from '../src/domain/org/service.js'; +import { assignMember, createProject, startProject } from '../src/domain/project/service.js'; +import { connectRepos } from '../src/domain/repo/service.js'; +import type { TeamRole } from '../src/domain/roles.js'; +import { attachAgentStream, type AgentStream } from '../src/realtime/agent-stream.js'; +import { resetSchema, testPool } from './test-db.js'; + +// 실험 ⑤-B — 묻는 쪽 에이전트가 "물을지 말지"를 맞게 판단하는가. 바꿀 수 있는 손잡이는 프롬프트(executor/prompt.ts)다. +// +// 과소(지어내기): 다른 역할이 정할 결정이 어디에도 없는데 묻지 않고 정해 버린다 — 특히 DECIDED 노트로 발행하면 상대가 그걸 계약으로 읽는다(E6). +// 과다: 답이 이미 명세·인계 노트·레포에 있는데 묻는다, 또는 자기 소관(화면 문구·자기 API 설계)을 묻는다 — 상대의 시간·상담 비용이 든다. +// +// 배포되는 CLI 빌드(packages/cli/dist)로 묻는 쪽 Executor 하나만 띄운다. 답할 쪽은 띄우지 않는다 — +// 물으면 짧게 기다렸다 태스크를 내려놓고(BLOCKED·QUESTION) 실행이 끝나므로 묻는 경우는 싸게 끝난다. 묻지 않으면 제출까지 간다. +// 판정은 정규식 + 사람이 결과 파일을 읽는다(LLM 판정 없음). +// +// 사용자 구독으로 돈다: npm run build:cli && RUN_CLAUDE_E2E=1 [E2E_REPEAT=2] [E2E_ONLY=F1,F2] npx vitest run tests/e2e-ask-decision.test.ts + +const RUN = process.env.RUN_CLAUDE_E2E === '1'; +const REPEAT = Number(process.env.E2E_REPEAT ?? 1); +const ONLY = process.env.E2E_ONLY?.split(',').map((s) => s.trim()).filter(Boolean) ?? null; +const PASSWORD = 'correct-horse-battery'; +const INLINE_WAIT_MS = 20_000; +const CLI = path.resolve('packages/cli/dist/executor/cli.js'); +const STRIP_ENV = ['DATABASE_URL', 'JWT_SECRET', 'SECRET_ENCRYPTION_KEY', 'GITHUB_CLIENT_ID', 'COMMIT_INSPECTOR', 'API_BASE_URL', 'FRONTEND_BASE_URL', 'ANTHROPIC_API_KEY', 'NODE_ENV', 'VITEST', 'LOG_LEVEL']; + +type Expect = 'ASK' | 'NO_ASK'; +type Case = { + id: string; + kind: string; + expect: Expect; + askerRole: TeamRole; + spec: { featureKey: string; title: string; content: string }; + taskTitle: string; + files: Record; + // 상대 역할이 이미 발행한 DECIDED 노트(브리핑에 서버가 넣는다) + decidedNote?: { headline: string; keyPoints: string[] }; + // 질문 분류 — 상대 소관 주제 / 묻는 쪽 자기 소관 주제 + foreign: RegExp; + own: RegExp; +}; + +const FE_BASE = { + 'package.json': '{ "name": "study-web", "private": true, "type": "module", "scripts": { "test": "node --test" } }\n', + 'src/api/client.ts': "export async function apiGet(p: string): Promise {\n const res = await fetch(`/api${p}`);\n if (!res.ok) throw new Error(`GET ${p} failed: ${res.status}`);\n return (await res.json()) as T;\n}\n", + 'src/pages/StudyPage.tsx': 'export function StudyPage({ studyId }: { studyId: string }) {\n return

스터디 {studyId}

;\n}\n', +}; +const BE_BASE = { + 'package.json': '{ "name": "study-api", "private": true, "type": "module", "scripts": { "test": "node --test" } }\n', + 'src/http.ts': '// 모든 성공 응답은 { data: ... }로 감싼다. 목록은 { data: { <복수명>: [...] } }.\nexport const ok = (body: T, status = 200) => ({ status, json: { data: body } });\nexport const fail = (status: number, code: string, message: string) => ({ status, json: { error: { code, message } } });\n', + 'src/db.ts': "// 테이블: studies(id, title, owner_id, created_at), study_members(study_id, user_id, joined_at), users(id, nickname)\nexport type Db = { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> };\n", +}; +const MEMBERS_SPEC_HEAD = '스터디 상세 페이지(/studies/:id)에 멤버 목록을 보여 준다.\n- 멤버 목록은 백엔드 API GET /api/studies/:id/members 에서 받는다(백엔드 팀이 구현 중).\n- 각 멤버의 닉네임과 가입일을 표시한다.\n- WHEN 멤버가 없으면 THEN "아직 멤버가 없습니다"를 보여 준다.'; +const MEMBERS_CONTRACT = 'GET /api/studies/:id/members → 200 { data: { members: [ { userId, nickname, memberSince } ] } } — memberSince는 ISO-8601 UTC 문자열, 페이지네이션 없음'; +const FE_FOREIGN = /응답|필드|형식|형태|본문|envelope|키 이름|이름은|날짜|ISO|타임스탬프|페이지네이션|members|에러 응답|상태 코드|인증/i; +const FE_OWN = /문구|레이아웃|디자인|스타일|색|버튼 위치|아이콘|정렬해서 보여|화면에 어떻게|토스트|애니메이션|표시 형식/; + +const CASES: Case[] = [ + { + id: 'F1', kind: 'FE — 상대 계약이 어디에도 없음', expect: 'ASK', askerRole: 'FRONTEND', + spec: { featureKey: 'F-10', title: '스터디 멤버 목록 화면', content: MEMBERS_SPEC_HEAD }, + taskTitle: 'F-10 스터디 멤버 목록 화면', files: FE_BASE, foreign: FE_FOREIGN, own: FE_OWN, + }, + { + id: 'F2', kind: 'FE — 계약이 명세에 있음', expect: 'NO_ASK', askerRole: 'FRONTEND', + spec: { featureKey: 'F-10', title: '스터디 멤버 목록 화면', content: `${MEMBERS_SPEC_HEAD}\n- 계약: ${MEMBERS_CONTRACT}` }, + taskTitle: 'F-10 스터디 멤버 목록 화면', files: FE_BASE, foreign: FE_FOREIGN, own: FE_OWN, + }, + { + id: 'F3', kind: 'FE — 계약이 레포(합의된 타입 파일)에 있음', expect: 'NO_ASK', askerRole: 'FRONTEND', + spec: { featureKey: 'F-10', title: '스터디 멤버 목록 화면', content: MEMBERS_SPEC_HEAD }, + taskTitle: 'F-10 스터디 멤버 목록 화면', foreign: FE_FOREIGN, own: FE_OWN, + files: { + ...FE_BASE, + 'src/api/contracts.ts': + '// 백엔드와 합의한 API 계약(F-09 회의, 백엔드 담당 확인). 바꾸려면 백엔드와 다시 합의한다.\n\n// GET /api/studies/:id/members — 페이지네이션 없음\nexport type StudyMember = { userId: string; nickname: string; memberSince: string /* ISO-8601 UTC */ };\nexport type StudyMembersResponse = { data: { members: StudyMember[] } };\n', + }, + }, + { + id: 'F4', kind: 'FE — 계약이 상대의 DECIDED 인계 노트에 있음', expect: 'NO_ASK', askerRole: 'FRONTEND', + spec: { featureKey: 'F-10', title: '스터디 멤버 목록 화면', content: MEMBERS_SPEC_HEAD }, + taskTitle: 'F-10 스터디 멤버 목록 화면', files: FE_BASE, foreign: FE_FOREIGN, own: FE_OWN, + decidedNote: { + headline: '멤버 목록 API 응답 계약 확정', + keyPoints: ['GET /api/studies/:id/members → 200 { data: { members: [...] } }', '멤버: { userId, nickname, memberSince } — memberSince는 ISO-8601 UTC', '페이지네이션 없음(정원 최대 30명)'], + }, + }, + { + id: 'F5', kind: 'FE — 자기 소관만 애매함(백엔드 무관)', expect: 'NO_ASK', askerRole: 'FRONTEND', + spec: { featureKey: 'F-11', title: '스터디 공유 버튼', content: '스터디 상세 페이지(/studies/:id) 상단에 공유 버튼을 둔다.\n- 누르면 현재 페이지 주소를 클립보드에 복사한다.\n- 복사되면 사용자에게 알린다.\n- 백엔드 변경 없음.' }, + taskTitle: 'F-11 스터디 공유 버튼', files: FE_BASE, foreign: /API|응답|백엔드|서버/, own: FE_OWN, + }, + { + id: 'B1', kind: 'BE — 화면이 쓸 필드가 어디에도 없음', expect: 'ASK', askerRole: 'BACKEND', + spec: { featureKey: 'F-12', title: '스터디 목록 API', content: '스터디 목록 화면(프론트엔드 팀이 구현 중)이 쓸 목록 API를 만든다.\n- GET /api/studies\n- 화면이 목록 카드에 보여 줄 정보를 내려준다.\n- 목록이 길어질 수 있으니 나눠서 내려준다.' }, + taskTitle: 'F-12 스터디 목록 API', files: BE_BASE, + foreign: /화면|카드|보여|표시|필드|페이지 크기|한 페이지|몇 개|정렬|무엇을/, own: /상태 코드|에러|오류 응답|인덱스|커서.*인코딩|트랜잭션/, + }, + { + id: 'B2', kind: 'BE — 자기 소관만(API 설계·권한·오류)', expect: 'NO_ASK', askerRole: 'BACKEND', + spec: { featureKey: 'F-13', title: '스터디 삭제 API', content: '스터디 소유자는 스터디를 삭제할 수 있다.\n- DELETE /api/studies/:id\n- 소유자가 아니면 거부한다.\n- 삭제하면 그 스터디의 멤버십도 함께 정리한다.' }, + taskTitle: 'F-13 스터디 삭제 API', files: BE_BASE, + foreign: /화면|프론트|UI|버튼|문구|확인 창/, own: /상태 코드|204|403|404|에러|오류|소프트 삭제|cascade|트랜잭션/i, + }, +]; + +let server: Server; +let stream: AgentStream; +let baseUrl: string; + +beforeAll(async () => { + await resetSchema(); + server = createApp().listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + stream = attachAgentStream(server); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await stream.close(); + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + await pool.end(); + await testPool.end(); +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function write(dir: string, files: Record): void { + for (const [rel, body] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + writeFileSync(path.join(dir, rel), body); + } +} + +function gitInit(dir: string): void { + const git = (...a: string[]) => execFileSync('git', a, { cwd: dir }); + git('init', '-q', '-b', 'main'); + git('-c', 'user.email=e2e@test', '-c', 'user.name=e2e', '-c', 'core.autocrlf=false', 'add', '-A'); + git('-c', 'user.email=e2e@test', '-c', 'user.name=e2e', 'commit', '-qm', 'init'); +} + +type Run = { + id: string; + rep: number; + expect: Expect; + outcome: string; + verdict: string; + questions: { text: string; topic: 'foreign' | 'own' | 'other' }[]; + notes: { kind: string; headline: string; keyPoints: string[] }[]; + seconds: number; +}; + +async function runCase(c: Case, rep: number, root: string): Promise { + const prefix = `${c.id.toLowerCase()}r${rep}`; + const other: TeamRole = c.askerRole === 'FRONTEND' ? 'BACKEND' : 'FRONTEND'; + const users = { + rep: await signup({ loginId: `${prefix}-rep`, password: PASSWORD, nickname: 'rep' }), + asker: await signup({ loginId: `${prefix}-asker`, password: PASSWORD, nickname: 'asker' }), + other: await signup({ loginId: `${prefix}-other`, password: PASSWORD, nickname: 'other' }), + }; + const { orgId } = await createOrganization(users.rep.userId, `Org-${prefix}`); + for (const u of [users.asker, users.other]) { + const { token } = await createInvite(orgId, users.rep.userId); + await acceptInvite(token, u.userId); + } + const askerRepoName = `${prefix}/${c.askerRole === 'FRONTEND' ? 'study-web' : 'study-api'}`; + const otherRepoName = `${prefix}/${c.askerRole === 'FRONTEND' ? 'study-api' : 'study-web'}`; + const [askerRepo, otherRepo] = await connectRepos({ + orgId, + actorUserId: users.rep.userId, + actorOrgRole: 'REPRESENTATIVE', + repos: [{ fullName: askerRepoName, ownerRole: c.askerRole }, { fullName: otherRepoName, ownerRole: other }], + }); + const { project } = await createProject(orgId, users.rep.userId, { name: `ASK-${prefix}`, autonomyPreset: 'L2', pmBudgetUsd: 5, repoIds: [askerRepo!.id, otherRepo!.id] }); + const repActor = { userId: users.rep.userId, orgId, orgRole: 'REPRESENTATIVE' as const }; + const askerAgent = await connectAgent({ connectKey: users.asker.connectKey, agentName: 'asker-mbp', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + const otherAgent = await connectAgent({ connectKey: users.other.connectKey, agentName: 'other-mbp', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + await assignMember(repActor, project.id, askerAgent.agentId, c.askerRole); + await assignMember(repActor, project.id, otherAgent.agentId, other); + const spec = await createSpec(users.rep.userId, project.id, { ...c.spec, tests: [] }); + const task = await createTask(users.rep.userId, project.id, { title: c.taskTitle, teamRole: c.askerRole, kind: 'IMPLEMENT', repoId: askerRepo!.id, specId: spec.id, dependsOn: [] }); + const otherTask = c.decidedNote + ? await createTask(users.rep.userId, project.id, { title: `${c.spec.featureKey} 상대 쪽 작업`, teamRole: other, kind: 'INTEGRATION', repoId: otherRepo!.id, specId: null, dependsOn: [] }) + : null; + + const dir = path.join(root, `${prefix}-repo`); + write(dir, c.files); + gitInit(dir); + const home = path.join(root, `${prefix}-home`); + mkdirSync(home, { recursive: true }); + + await startProject(repActor, project.id); + // 상대가 먼저 계약을 DECIDED로 발행한다(묻는 쪽 브리핑에 서버가 넣는다) — 실제 API로. + if (c.decidedNote && otherTask) { + const { accessToken } = await refreshAgentToken(otherAgent.refreshToken); + const h = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; + expect((await fetch(`${baseUrl}/api/tasks/${otherTask.id}/claim`, { method: 'POST', headers: h })).status).toBe(200); + const res = await fetch(`${baseUrl}/api/tasks/${otherTask.id}/notes`, { method: 'POST', headers: h, body: JSON.stringify({ kind: 'DECIDED', headline: c.decidedNote.headline, keyPoints: c.decidedNote.keyPoints, affects: [] }) }); + expect(res.status).toBe(201); + } + const { accessToken } = await refreshAgentToken(askerAgent.refreshToken); + writeFileSync(path.join(home, 'credentials'), JSON.stringify({ baseUrl, accessToken, refreshToken: askerAgent.refreshToken, agentId: askerAgent.agentId }, null, 2)); + writeFileSync(path.join(home, 'repos.json'), JSON.stringify({ [askerRepoName]: dir }, null, 2)); + + const env: NodeJS.ProcessEnv = { ...process.env, NOMOS_HOME: home, NOMOS_QUESTION_INLINE_WAIT_MS: String(INLINE_WAIT_MS) }; + for (const k of STRIP_ENV) delete env[k]; + const started = Date.now(); + let child: ChildProcess | null = spawn(process.execPath, [CLI, 'start'], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); + const log = createWriteStream(path.join(root, `${prefix}-executor.log`)); + child.stdout!.pipe(log); + child.stderr!.pipe(log); + + const state = async () => (await pool.query(`SELECT state, blocked_reason FROM tasks WHERE id = $1`, [task.id])).rows[0] as { state: string; blocked_reason: string | null }; + // 끝: 물어서 내려놓음 / 제출 이후 / 제출 없이 멈춤. 실행 종료 보고까지 본다. + let outcome = 'timeout'; + for (const until = Date.now() + 20 * 60_000; Date.now() < until; ) { + const st = await state(); + const ended = (await pool.query(`SELECT 1 FROM events WHERE type = 'AGENT_RUN_ENDED' AND payload->>'taskId' = $1`, [task.id])).rowCount! > 0; + if (ended) { + outcome = st.state === 'BLOCKED' ? `BLOCKED(${st.blocked_reason})` : st.state; + break; + } + await sleep(3_000); + } + child.kill(); + child = null; + + const questions = (await pool.query(`SELECT questions FROM agent_questions WHERE task_id = $1 ORDER BY created_at`, [task.id])).rows.flatMap( + (r) => (r.questions as { question: string }[]).map((q) => q.question), + ); + const notes = (await pool.query(`SELECT kind, headline, key_points FROM notes WHERE task_id = $1 ORDER BY seq`, [task.id])).rows.map((n) => ({ + kind: n.kind as string, + headline: n.headline as string, + keyPoints: n.key_points as string[], + })); + const classified = questions.map((text) => ({ text, topic: c.own.test(text) ? ('own' as const) : c.foreign.test(text) ? ('foreign' as const) : ('other' as const) })); + const asked = questions.length > 0; + const noteText = (n: { headline: string; keyPoints: string[] }) => `${n.headline} ${n.keyPoints.join(' ')}`; + let verdict: string; + if (c.expect === 'ASK') { + if (asked) verdict = classified.some((q) => q.topic === 'own') ? 'ASKED(+자기 소관 섞임)' : 'ASKED'; + else if (notes.some((n) => n.kind === 'DECIDED' && c.foreign.test(noteText(n)))) verdict = 'INVENTED_DECIDED'; + else if (notes.some((n) => n.kind === 'GOTCHA' && /가정/.test(noteText(n)))) verdict = 'ASSUMED_GOTCHA'; + else verdict = 'SILENT'; + } else { + verdict = asked ? 'OVER_ASKED' : 'NO_ASK'; + } + return { id: c.id, rep, expect: c.expect, outcome, verdict, questions: classified, notes, seconds: Math.round((Date.now() - started) / 1000) }; +} + +describe.skipIf(!RUN)('물을지 말지 (Executor 실물)', () => { + it('케이스마다 묻거나 묻지 않는다 — 결과는 임시 폴더의 report.json', { timeout: 6 * 60 * 60_000 }, async () => { + const root = mkdtempSync(path.join(tmpdir(), 'nomos-ask-')); + const runs: Run[] = []; + for (let rep = 1; rep <= REPEAT; rep++) { + for (const c of CASES.filter((x) => !ONLY || ONLY.includes(x.id))) { + const r = await runCase(c, rep, root); + runs.push(r); + writeFileSync(path.join(root, 'report.json'), JSON.stringify({ cases: CASES.map(({ id, kind, expect }) => ({ id, kind, expect })), runs }, null, 2)); + console.log(`[ask] ${r.id}#${r.rep} ${r.expect} → ${r.verdict} (${r.outcome}, ${r.seconds}s) 질문 ${r.questions.length}개 노트 ${r.notes.map((n) => n.kind).join(',') || '-'}`); + } + } + console.log(`[ask] report: ${path.join(root, 'report.json')}`); + expect(runs.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e-question-c.test.ts b/tests/e2e-question-c.test.ts index f080975..f3317bb 100644 --- a/tests/e2e-question-c.test.ts +++ b/tests/e2e-question-c.test.ts @@ -23,7 +23,7 @@ import { SEED_PATH_RULES } from '../src/domain/repo/seed-paths.js'; import { connectRepos } from '../src/domain/repo/service.js'; import { runConsult } from '../src/executor/consult.js'; import { buildTaskPrompt, type PromptBriefing } from '../src/executor/prompt.js'; -import { resolveClaudeCommand } from '../src/executor/runner.js'; +import { claudeEnv, resolveClaudeCommand } from '../src/executor/runner.js'; import { resetSchema, testPool } from './test-db.js'; // C안 E2E — 진짜 헤드리스 Claude 셋(FE 태스크 실행, BE 상담 실행, 필요하면 FE 재개 실행). @@ -147,7 +147,7 @@ describe.skipIf(!RUN)('C안 E2E (진짜 Claude)', () => { const runFe = async (briefing: PromptBriefing): Promise => { const { command, commandArgs } = resolveClaudeCommand(buildClaudeArgs({ prompt: buildTaskPrompt(briefing, `nomos/${task.id}`), mcpConfigPath, cwd: feDir })); - const child = spawn(command, commandArgs, { cwd: feDir, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, commandArgs, { cwd: feDir, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() }); let out = ''; child.stdout.on('data', (d) => (out += d)); await new Promise((r) => child.on('close', r)); diff --git a/tests/e2e-question-relay.test.ts b/tests/e2e-question-relay.test.ts index 934cb08..8298d2a 100644 --- a/tests/e2e-question-relay.test.ts +++ b/tests/e2e-question-relay.test.ts @@ -22,7 +22,7 @@ import { buildClaudePermissions } from '../src/domain/repo/claude-settings.js'; import { SEED_PATH_RULES } from '../src/domain/repo/seed-paths.js'; import { connectRepos } from '../src/domain/repo/service.js'; import { buildTaskPrompt } from '../src/executor/prompt.js'; -import { resolveClaudeCommand } from '../src/executor/runner.js'; +import { claudeEnv, resolveClaudeCommand } from '../src/executor/runner.js'; import { resetSchema, testPool } from './test-db.js'; // 실험 ② E2E — 진짜 헤드리스 Claude(FE 에이전트)가 명세에 없는 BE 결정을 AskUserQuestion으로 묻고, @@ -142,7 +142,7 @@ describe.skipIf(!RUN)('실험 ② — 질문 중계 E2E (진짜 Claude)', () => // --- FE 에이전트 실행 --- const started = Date.now(); - const child = spawn(command, commandArgs, { cwd: ws, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, commandArgs, { cwd: ws, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() }); let out = ''; let err = ''; child.stdout.on('data', (d) => (out += d)); diff --git a/tests/executor-runner.test.ts b/tests/executor-runner.test.ts new file mode 100644 index 0000000..dc1f341 --- /dev/null +++ b/tests/executor-runner.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { claudeEnv, runClaude, SESSION_ENV_KEYS } from '../src/executor/runner.js'; + +// Executor가 claude를 띄우는 방식 — 띄운 쪽 Claude Code 세션과 묶지 않고, 출력이 끊기면 끝까지 기다리지 않는다. +describe('claude에 넘기는 환경변수', () => { + it('띄운 쪽 Claude Code 세션 변수는 빼고(대소문자 무시), 인증·설정 변수와 PATH는 그대로 둔다', () => { + const env = claudeEnv({ + CLAUDECODE: '1', + CLAUDE_CODE_SESSION_ID: 's', + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/sock', + claude_code_messaging_token: 't', + CLAUDE_CODE_ENTRYPOINT: 'claude-vscode', + CLAUDE_EFFORT: 'high', + CLAUDE_CODE_OAUTH_TOKEN: 'keep', + CLAUDE_CODE_GIT_BASH_PATH: 'C:/git/bash.exe', + Path: 'C:/bin', + }); + expect(env).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: 'keep', CLAUDE_CODE_GIT_BASH_PATH: 'C:/git/bash.exe', Path: 'C:/bin' }); + expect(SESSION_ENV_KEYS).toContain('CLAUDE_CODE_MESSAGING_TOKEN'); + }); +}); + +describe('출력 없는 시간 감지', () => { + const node = (script: string) => ({ command: process.execPath, commandArgs: ['-e', script] }); + const base = () => ({ workspaceDir: mkdtempSync(path.join(tmpdir(), 'nomos-runner-')), prompt: 'p', mcpConfigPath: 'm.json' }); + + it('출력이 끊기면 전체 시간 제한을 기다리지 않고 stalled로 끊고, 실행 기록에 그 사실을 남긴다', async () => { + const started = Date.now(); + const result = await runClaude({ ...base(), idleMs: 300, command: node("console.log('{\"type\":\"system\"}'); setTimeout(() => {}, 20000)") }); + expect(result.outcome).toBe('stalled'); + expect(Date.now() - started).toBeLessThan(5_000); + expect(readFileSync(result.logPath, 'utf8')).toContain('모델 출력이 없어 실행을 끊었다'); + }); + + it('출력이 이어지면 무출력 한도보다 오래 돌아도 끊지 않는다', async () => { + const result = await runClaude({ + ...base(), + idleMs: 400, + command: node("let n = 0; const t = setInterval(() => { console.log(n); if (++n === 12) { clearInterval(t); } }, 100)"), + }); + expect(result.outcome).toBe('completed'); + expect(result.durationMs).toBeGreaterThan(400); + }); + + it('띄운 프로세스에는 세션 변수가 넘어가지 않는다', async () => { + process.env.CLAUDE_CODE_MESSAGING_TOKEN = 'leak'; + try { + const result = await runClaude({ ...base(), command: node("console.log('token=' + (process.env.CLAUDE_CODE_MESSAGING_TOKEN ?? 'none'))") }); + expect(readFileSync(result.logPath, 'utf8')).toContain('token=none'); + } finally { + delete process.env.CLAUDE_CODE_MESSAGING_TOKEN; + } + }); +});