From 75ee1938d8ec5ec1e85dcabfa0ccd6b93c2fd735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=98=81=ED=9B=88?= <102127984+oyeonghun@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Chore:=20=EC=A7=88=EB=AC=B8=20=EB=8B=B5=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=ED=8F=B4=EB=A7=81=202=EC=B4=88=20?= =?UTF-8?q?=E2=86=92=205=EC=B4=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/bridge/permission.ts | 6 +++++- tests/permission.test.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/bridge/permission.ts b/src/bridge/permission.ts index 2e318e9..c95eeaa 100644 --- a/src/bridge/permission.ts +++ b/src/bridge/permission.ts @@ -53,6 +53,10 @@ export const DENY_SELF_OWNED: PermissionResult = { 'continue the task, and record the decision with publish_note (kind DECIDED) so others follow it.', }; +// 답을 기다리는 동안 서버에 묻는 간격. 사람이 답하는 데 몇십 초~몇 분이 걸려 2초는 지나치게 잦았다(대표 결정 5초). +// MCP 서버는 Claude Code가 띄운 별도 프로세스라 웹소켓을 따로 붙이는 이득이 작다 — 실행 안에서 기다리는 3분 동안 최대 36회. +export const QUESTION_POLL_MS = 5_000; + export type WaitDeps = { ask: (questions: unknown[]) => Promise<{ id: string } & QuestionLike>; get: (questionId: string) => Promise; @@ -85,7 +89,7 @@ export async function handleAskUserQuestion(input: Record, deps } if (now() - started >= deps.maxWaitMs) return denyUnanswered('No answer arrived in time.', texts); await deps.onWaiting?.(now() - started); - await deps.sleep(deps.pollMs ?? 2_000); + await deps.sleep(deps.pollMs ?? QUESTION_POLL_MS); state = await deps.get(asked.id); } if ((state.status === 'answered' || state.status === 'agent_answered') && state.answers) { diff --git a/tests/permission.test.ts b/tests/permission.test.ts index 0aacaeb..407ac20 100644 --- a/tests/permission.test.ts +++ b/tests/permission.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DENY_OTHER, handleAskUserQuestion, isHandled, type QuestionLike, type WaitDeps } from '../src/bridge/permission.js'; +import { DENY_OTHER, handleAskUserQuestion, isHandled, QUESTION_POLL_MS, type QuestionLike, type WaitDeps } from '../src/bridge/permission.js'; // 권한 도구는 보안 경계다 — Claude Code는 여기서 돌려준 답대로 실행한다(실험 E2: 전부 허용으로 답하면 curl·작업 폴더 밖 쓰기가 실제로 실행됐다). describe('권한 도구 — 판단', () => { @@ -100,6 +100,20 @@ describe('권한 도구 — 라우터가 자기 소관이라고 판정한 질문 }); describe('권한 도구 — C안: 실행 안에서 정한 시간만 기다린다', () => { + it('답을 기다리는 동안 5초 간격으로 서버에 묻는다', async () => { + const slept: number[] = []; + await handleAskUserQuestion( + { questions: [QUESTION] }, + deps([{ status: 'pending', answers: null }, { status: 'pending', answers: null }, { status: 'answered', answers: { [QUESTION.question]: 'x' } }], { + sleep: async (ms) => { + slept.push(ms); + }, + }), + ); + expect(QUESTION_POLL_MS).toBe(5_000); + expect(slept).toEqual([5_000, 5_000]); + }); + it('에이전트 답(agent_answered)도 바로 쓴다', async () => { const result = await handleAskUserQuestion({ questions: [QUESTION] }, deps([{ status: 'pending', answers: null }, { status: 'agent_answered', answers: { [QUESTION.question]: 'camelCase' } }])); expect(result).toMatchObject({ behavior: 'allow', updatedInput: { answers: { [QUESTION.question]: 'camelCase' } } }); From fa1b3c7a8735f0067f3a2954fce4349ed4fc12f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=98=81=ED=9B=88?= <102127984+oyeonghun@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:33:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Test:=20=EC=A7=88=EB=AC=B8=20=EC=A4=91?= =?UTF-8?q?=EA=B3=84=20=EC=9E=AC=EA=B0=9C(C1)=C2=B7=EB=8B=B5=ED=95=A0=20?= =?UTF-8?q?=EC=AA=BD=20=EA=BA=BC=EC=A7=90(C3)=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=B0=ED=8F=AC=20CLI=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20Executor=20=EB=91=98=EC=9D=84=20=EB=9D=84=EC=9B=8C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8(RUN=5FCLAUDE=5FE2E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/e2e-question-executor.test.ts | 256 ++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 tests/e2e-question-executor.test.ts diff --git a/tests/e2e-question-executor.test.ts b/tests/e2e-question-executor.test.ts new file mode 100644 index 0000000..a233e8e --- /dev/null +++ b/tests/e2e-question-executor.test.ts @@ -0,0 +1,256 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { createWriteStream, mkdirSync, mkdtempSync, readFileSync, 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 { login, 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 { attachAgentStream, type AgentStream } from '../src/realtime/agent-stream.js'; +import { resetSchema, testPool } from './test-db.js'; + +// 질문 중계의 재개(C1)·답할 쪽 꺼짐(C3) — **배포되는 CLI 빌드 그대로**(packages/cli/dist) Executor 둘을 띄워 확인한다. +// C1: BE Executor는 켜져 있고 BE 코드에 답이 없다 → 상담 '미정' → FE가 기다리다 내려놓음(BLOCKED·QUESTION) +// → BE 담당자가 답 → READY → FE Executor가 푸시를 받아 같은 작업공간에서 재개 → 커밋·제출 +// C3: BE Executor가 꺼져 있고 BE 코드에 답이 있다 → FE가 내려놓음 → 나중에 BE Executor를 켬 +// → 연결 직후 확인으로 밀린 질문을 상담 → 전부 코드에 정해져 있어 곧 답(agent_answered) → 사람 없이 FE 재개 +// 사용자 구독으로 돈다: npm run build:cli && RUN_CLAUDE_E2E=1 npx vitest run tests/e2e-question-executor.test.ts +// 결과(Executor 로그·타임라인)는 실행마다 임시 폴더에 남기고 경로를 출력한다. + +const RUN = process.env.RUN_CLAUDE_E2E === '1'; +const PASSWORD = 'correct-horse-battery'; +// 기본 3분 대신 짧게 — 재개 경로를 보는 실험이라 기다림 자체는 줄인다. +const INLINE_WAIT_MS = Number(process.env.E2E_INLINE_WAIT_MS ?? 90_000); +const CLI = path.resolve('packages/cli/dist/executor/cli.js'); +const HUMAN_DECISION = 'BE 결정: 200 { data: { members: [ { userId, nickname, memberSince } ] } } — memberSince는 ISO-8601 UTC 문자열, 페이지네이션 없음'; +// 서버 비밀값·테스트 DB 주소는 Executor에 넘기지 않는다. API 키가 있으면 구독 대신 API로 과금되므로 뺀다. +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']; + +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', 'add', '-A'); + git('-c', 'user.email=e2e@test', '-c', 'user.name=e2e', 'commit', '-qm', 'init'); +} + +const MEMBERS_CODE = + "import { ok } from '../http.ts';\n\n// GET /api/studies/:id/members — 페이지네이션 없음(정원 최대 30명).\n// 가입일 필드 이름은 memberSince(ISO-8601 UTC). 닉네임은 nickname, 식별자는 userId.\ntype MemberView = { userId: string; nickname: string; memberSince: string };\n\nexport function listMembers(rows: { user_id: string; nickname: string; joined_at: Date }[]) {\n const members: MemberView[] = rows.map((r) => ({ userId: r.user_id, nickname: r.nickname, memberSince: r.joined_at.toISOString() }));\n return ok({ members });\n}\n"; + +type Scenario = { name: string; beHasAnswer: boolean; beOnlineFromStart: boolean }; + +async function runScenario(s: Scenario) { + const prefix = s.name.toLowerCase(); + const rep = await signup({ loginId: `${prefix}-rep`, password: PASSWORD, nickname: 'rep' }); + const fe = await signup({ loginId: `${prefix}-fe`, password: PASSWORD, nickname: 'fe' }); + const be = await signup({ loginId: `${prefix}-be`, password: PASSWORD, nickname: 'be' }); + const { orgId } = await createOrganization(rep.userId, `Acme-${s.name}`); + for (const u of [fe, be]) { + const { token } = await createInvite(orgId, rep.userId); + await acceptInvite(token, u.userId); + } + const [web, api] = await connectRepos({ + orgId, + actorUserId: rep.userId, + actorOrgRole: 'REPRESENTATIVE', + repos: [{ fullName: `${prefix}/study-web`, ownerRole: 'FRONTEND' }, { fullName: `${prefix}/study-api`, ownerRole: 'BACKEND' }], + }); + const { project } = await createProject(orgId, rep.userId, { name: `E2E-${s.name}`, autonomyPreset: 'L2', pmBudgetUsd: 5, repoIds: [web!.id, api!.id] }); + const repActor = { userId: rep.userId, orgId, orgRole: 'REPRESENTATIVE' as const }; + const feAgent = await connectAgent({ connectKey: fe.connectKey, agentName: 'fe-mbp', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + const beAgent = await connectAgent({ connectKey: be.connectKey, agentName: 'be-mbp', harness: 'claude-code', skills: [], maxConcurrent: 1 }); + await assignMember(repActor, project.id, feAgent.agentId, 'FRONTEND'); + await assignMember(repActor, project.id, beAgent.agentId, 'BACKEND'); + const spec = await createSpec(rep.userId, project.id, { + featureKey: 'F-10', + title: '스터디 멤버 목록 화면', + content: + '스터디 상세 페이지(/studies/:id)에 멤버 목록을 보여 준다.\n- 멤버 목록은 백엔드 API GET /api/studies/:id/members 에서 받는다(백엔드 팀이 구현 중).\n- 각 멤버의 닉네임과 가입일을 표시한다.\n- WHEN 멤버가 없으면 THEN "아직 멤버가 없습니다"를 보여 준다.', + tests: [], + }); + const task = await createTask(rep.userId, project.id, { title: 'F-10 스터디 멤버 목록 화면', teamRole: 'FRONTEND', kind: 'IMPLEMENT', repoId: web!.id, specId: spec.id, dependsOn: [] }); + + // --- 레포·NOMOS_HOME(에이전트마다) --- + const root = mkdtempSync(path.join(tmpdir(), `nomos-e2e-${prefix}-`)); + const beDir = path.join(root, 'study-api'); + write(beDir, { + 'package.json': '{ "name": "study-api", "private": true, "type": "module" }\n', + 'src/http.ts': '// 모든 성공 응답은 { data: ... }로 감싼다. 목록은 { data: { <복수명>: [...] } }.\nexport const ok = (body: T) => ({ status: 200, json: { data: body } });\n', + ...(s.beHasAnswer ? { 'src/routes/members.ts': MEMBERS_CODE } : {}), + }); + gitInit(beDir); + const feDir = path.join(root, 'study-web'); + write(feDir, { + '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', + }); + gitInit(feDir); + + const homeFor = async (name: string, agent: { agentId: string; refreshToken: string }, repos: Record) => { + const home = path.join(root, `${name}-home`); + mkdirSync(home, { recursive: true }); + // 배정 뒤 재발급해야 토큰에 project_id가 실린다. + const { accessToken } = await refreshAgentToken(agent.refreshToken); + writeFileSync(path.join(home, 'credentials'), JSON.stringify({ baseUrl, accessToken, refreshToken: agent.refreshToken, agentId: agent.agentId }, null, 2)); + writeFileSync(path.join(home, 'repos.json'), JSON.stringify(repos, null, 2)); + return home; + }; + const feHome = await homeFor('fe', feAgent, { [`${prefix}/study-web`]: feDir }); + const beHome = await homeFor('be', beAgent, { [`${prefix}/study-api`]: beDir }); + + const children: ChildProcess[] = []; + const startExecutor = (name: string, home: string) => { + 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 child = spawn(process.execPath, [CLI, 'start'], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); + const out = createWriteStream(path.join(root, `${name}-executor.log`)); + child.stdout!.pipe(out); + child.stderr!.pipe(out); + children.push(child); + return child; + }; + + const t0 = Date.now(); + const timeline: string[] = []; + const mark = (line: string) => timeline.push(`${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s ${line}`); + const taskState = async () => (await pool.query(`SELECT state, blocked_reason, retry_count FROM tasks WHERE id = $1`, [task.id])).rows[0] as { state: string; blocked_reason: string | null; retry_count: number }; + const waitFor = async (what: string, cond: () => Promise, maxMs: number) => { + const until = Date.now() + maxMs; + while (Date.now() < until) { + if (await cond()) { + mark(what); + return true; + } + await sleep(2_000); + } + mark(`시간 초과: ${what}`); + return false; + }; + + try { + if (s.beOnlineFromStart) startExecutor('be', beHome); + startExecutor('fe', feHome); + await startProject(repActor, project.id); + mark(`프로젝트 시작 (BE Executor ${s.beOnlineFromStart ? '켜짐' : '꺼짐'}, BE 코드에 답 ${s.beHasAnswer ? '있음' : '없음'})`); + + await waitFor('FE가 질문함', async () => (await pool.query(`SELECT 1 FROM agent_questions WHERE task_id = $1`, [task.id])).rowCount! > 0, 15 * 60_000); + await waitFor('FE가 태스크를 내려놓음(BLOCKED·QUESTION)', async () => { + const st = await taskState(); + return st.state !== 'CLAIMED' && st.state !== 'READY'; + }, INLINE_WAIT_MS + 5 * 60_000); + mark(`태스크 ${JSON.stringify(await taskState())}`); + + if (!s.beOnlineFromStart) { + await sleep(5_000); + startExecutor('be', beHome); + mark('BE Executor를 켬'); + } + + if (!s.beHasAnswer) { + // 상담 초안(미정)이 올라오기를 기다렸다가 BE 담당자가 답한다 — 초안에서 정해진 것은 그대로, 미정인 것은 결정해서. + await waitFor('BE 상담 초안', async () => (await pool.query(`SELECT 1 FROM agent_questions WHERE task_id = $1 AND draft IS NOT NULL`, [task.id])).rowCount! > 0, 5 * 60_000); + const beToken = (await login({ loginId: `${prefix}-be`, password: PASSWORD })).accessToken; + const mine = await fetch(`${baseUrl}/api/me/questions`, { headers: { Authorization: `Bearer ${beToken}` } }); + type Q = { id: string; questions: { question: string }[]; draft: { answers: Record; decided: Record } | null }; + const body = (await mine.json()) as { data?: { questions: { question: Q; projectName: string; taskTitle: string }[] } }; + if (!body.data) throw new Error(`/me/questions ${mine.status}: ${JSON.stringify(body)}`); + mark(`BE 담당자의 /me/questions: ${body.data.questions.map((x) => `${x.projectName} · ${x.taskTitle}`).join(', ')}`); + for (const { question: q } of body.data.questions) { + const answers = Object.fromEntries(q.questions.map((x) => [x.question, q.draft?.decided[x.question] ? q.draft.answers[x.question]! : HUMAN_DECISION])); + const res = await fetch(`${baseUrl}/api/questions/${q.id}/answer`, { method: 'POST', headers: { Authorization: `Bearer ${beToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ answers }) }); + mark(`BE 담당자 답 ${res.status}`); + } + } + + await waitFor('태스크 결론(제출 이후 또는 멈춤)', async () => ['VERIFYING', 'AWAITING_APPROVAL', 'DONE', 'ESCALATED'].includes((await taskState()).state) || (await taskState()).blocked_reason === 'AGENT_STOPPED', 25 * 60_000); + // 제출 뒤 Executor의 V2·V4 보고까지 + await waitFor('검증 결론', async () => ['AWAITING_APPROVAL', 'DONE', 'ESCALATED', 'BLOCKED', 'READY'].includes((await taskState()).state), 5 * 60_000); + } finally { + for (const c of children) c.kill(); + } + + // --- 결과 --- + const final = await taskState(); + const events = (await pool.query( + `SELECT ts, type, payload FROM events WHERE project_id = $1 AND type NOT IN ('PROJECT_CREATED','MEMBER_ASSIGNED','SPEC_CREATED','TASK_CREATED') ORDER BY ts`, + [project.id], + )).rows.map((e) => `${String(Math.round((new Date(e.ts as string).getTime() - t0) / 1000)).padStart(4)}s ${e.type} ${JSON.stringify(e.payload).slice(0, 160)}`); + const questions = (await pool.query(`SELECT status, answer_source, draft, created_at, answered_at FROM agent_questions WHERE task_id = $1 ORDER BY created_at`, [task.id])).rows; + const ws = path.join(feHome, 'workspaces', project.id, task.id); + const git = (...a: string[]) => execFileSync('git', a, { cwd: ws }).toString().trim(); + let commits: string[] = []; + let code = ''; + try { + commits = git('log', '--format=%h %s', 'main..HEAD').split('\n').filter(Boolean); + code = git('diff', 'main..HEAD').toString(); + } catch (err) { + commits = [`작업공간 읽기 실패: ${(err as Error).message}`]; + } + const repToken = (await login({ loginId: `${prefix}-rep`, password: PASSWORD })).accessToken; + const feed = (await (await fetch(`${baseUrl}/api/projects/${project.id}/rooms/FRONTEND/feed`, { headers: { Authorization: `Bearer ${repToken}` } })).json()) as { data: { messages: { speaker: string; text: string }[] } }; + const report = { + scenario: s, + root, + final, + timeline, + questions: questions.map((q) => ({ status: q.status, source: q.answer_source, decided: q.draft?.decided ?? null })), + events, + commits, + usesAnswer: { memberSince: code.includes('memberSince'), members: code.includes('members') }, + room: [...feed.data.messages].reverse().map((m) => `[${m.speaker}] ${m.text}`), + }; + writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + return report; +} + +describe.skipIf(!RUN)('질문 중계 — 재개·답할 쪽 꺼짐 (Executor 실물)', () => { + it('C1: 상담이 미정이면 FE가 내려놓고, BE 담당자가 답하면 같은 작업공간에서 재개해 제출한다', { timeout: 60 * 60_000 }, async () => { + const r = await runScenario({ name: 'C1', beHasAnswer: false, beOnlineFromStart: true }); + expect(r.events.some((e) => e.includes('TASK_BLOCKED_ON_QUESTION'))).toBe(true); + expect(r.final.retry_count).toBe(0); + expect(r.usesAnswer.memberSince).toBe(true); + }); + + it('C3: BE가 꺼져 있다 켜지면 밀린 질문을 상담해 곧 답하고, 사람 없이 FE가 재개한다', { timeout: 60 * 60_000 }, async () => { + const r = await runScenario({ name: 'C3', beHasAnswer: true, beOnlineFromStart: false }); + expect(r.questions[0]?.source).toBe('agent'); + expect(r.usesAnswer.memberSince).toBe(true); + }); +}); From 01d91451e77efdb1f61292457fcb7222137979cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=98=81=ED=9B=88?= <102127984+oyeonghun@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:54:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Fix:=20=EC=8B=A4=ED=96=89=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=EC=9D=98=20=EC=A0=9C=EC=B6=9C=C2=B7=EB=8B=B5=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=95=88=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EB=8B=B5=EC=9D=B4=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EB=B3=B4=EA=B3=A0=EB=B3=B4=EB=8B=A4=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=EC=98=A4=EB=A9=B4=20'=EC=A0=9C=EC=B6=9C=ED=95=A8'=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=B8=EB=8D=98=20=EA=B2=83=20(=EC=8B=A4?= =?UTF-8?q?=ED=97=98=20C1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findRunOutcome: 실행 시작 이벤트 이후 이 에이전트의 ARTIFACT_SUBMITTED·TASK_BLOCKED_ON_QUESTION으로 판정. - 멈춤(AGENT_STOPPED)은 그 에이전트가 아직 잡고 있을 때만 — 늦게 온 종료 보고·감시가 다시 잡은 실행을 멈추지 않게. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++++-- src/domain/room/repository.ts | 20 +++++++++++++++++--- src/domain/room/service.ts | 16 +++++++++------- src/domain/task/repository.ts | 7 ++++--- src/domain/task/stall.ts | 2 +- tests/rooms.test.ts | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 41c0b2d..0327648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -495,8 +495,10 @@ PM_REVIEW 반려·피드백 분류·이의 설명·보고서는 아직 없다. - **"실행해 주세요"는 서비스마다 부르지 않는다.** `tasksChanged` 신호를 받아(`room/dispatch.ts`, `createApp`에서 구독) 가져갈 수 있게 된 태스크를 attempt마다 한 번 기록한다 — `task_dispatches` PK가 중복을, 같은 트랜잭션의 이벤트가 P5를 지킨다. 조건은 `listClaimableTasks`에서 역할만 뺀 것이다(둘이 갈리면 지시는 왔는데 못 가져간다). 테스트는 `truncateAll`이 `drainTasksChanged()`로 리스너를 기다린 뒤 TRUNCATE한다(겹치면 교착). -- **제출 여부는 서버가 정한다.** Executor는 정상 종료·시간 초과·비정상과 커밋 여부만 보고하고, 서버가 태스크 상태로 `submitted`를 정해 - "끝났지만 제출하지 않았다"를 룸에 남긴다 — 데모에서 팀원 터미널에만 보이던 막다른 길이다. +- **제출 여부는 서버가 정한다.** Executor는 정상 종료·시간 초과·비정상과 커밋 여부만 보고하고, 서버가 **그 실행이 시작된 뒤의 이벤트** + (이 에이전트의 `ARTIFACT_SUBMITTED`·`TASK_BLOCKED_ON_QUESTION`, `findRunOutcome`)로 `submitted`·`waitingQuestion`을 정해 — 종료 순간의 태스크 상태로 짐작하지 말 것 + (답이 종료 보고보다 먼저 오면 이미 READY라 "제출함"으로 세어졌다, 실험 C1). 제출도 내려놓음도 없으면 "끝났지만 제출하지 않았다"를 룸에 남긴다 — + 데모에서 팀원 터미널에만 보이던 막다른 길이다. 멈춤(AGENT_STOPPED)은 그 에이전트가 아직 잡고 있을 때만 건다(늦은 종료 보고가 다시 잡은 실행을 멈추지 않게). - **활동은 도구 종류와 대상(작업공간 기준 경로·명령 첫 줄, 300자)만** 받는다. 파일 내용·명령 결과·모델 설명 문장은 받지 않는다(설명은 인계 노트의 몫, 대표 결정). 상태 변화가 아니라 `events`가 아닌 `agent_activity`이고, 열린 실행(STARTED 뒤 ENDED 전)에만 붙는다. 화면 신호는 `room/activity-hub` → `/api/stream`의 `room` 토픽(서버 1대 전제). - **제출 없이 끝난 태스크는 BLOCKED(`AGENT_STOPPED`)로 멈춘다**(`task/stall.ts`, 종료 보고와 같은 트랜잭션). CLAIMED로 두면 아무도 다시 못 가져가고(운영 테스트의 영구 정체), diff --git a/src/domain/room/repository.ts b/src/domain/room/repository.ts index e234f52..a536062 100644 --- a/src/domain/room/repository.ts +++ b/src/domain/room/repository.ts @@ -47,15 +47,29 @@ export async function insertNewDispatches(db: Queryable, projectId: string): Pro // ── 실행(run) ───────────────────────────────────────────────────────────── // 이 에이전트가 이 태스크에서 시작하고 아직 끝내지 않은 실행 — 마지막 실행 이벤트가 STARTED면 그 attempt, 아니면 null. -export async function findOpenRun(db: Queryable, taskId: string, agentId: string): Promise<{ attempt: number } | null> { +export async function findOpenRun(db: Queryable, taskId: string, agentId: string): Promise<{ attempt: number; startedEventId: string } | null> { const { rows } = await db.query( - `SELECT type, payload->>'attempt' AS attempt FROM events + `SELECT id::text AS id, type, payload->>'attempt' AS attempt FROM events WHERE type IN ('AGENT_RUN_STARTED', 'AGENT_RUN_ENDED') AND actor_agent_id = $2 AND payload->>'taskId' = $1 ORDER BY id DESC LIMIT 1`, [taskId, agentId], ); - return rows[0]?.type === 'AGENT_RUN_STARTED' ? { attempt: Number(rows[0].attempt) } : null; + return rows[0]?.type === 'AGENT_RUN_STARTED' ? { attempt: Number(rows[0].attempt), startedEventId: rows[0].id as string } : null; +} + +// 이 실행(시작 이벤트 이후) 안에서 이 에이전트가 이 태스크를 제출했는가·질문의 답을 기다리느라 내려놓았는가. +// 종료 순간의 태스크 상태로 짐작하지 않는다 — 실행이 끝난 뒤에도 답·재개·대표의 조치로 상태는 바뀐다(실험 C1: 답이 종료 보고보다 +// 먼저 와 READY였고, 그 실행이 '제출함'으로 세어졌다). 이후 이벤트의 id는 시작 이벤트보다 크다(시작이 커밋된 뒤에야 생긴다). +export async function findRunOutcome(db: Queryable, taskId: string, agentId: string, startedEventId: string): Promise<{ submitted: boolean; detachedForQuestion: boolean }> { + const { rows } = await db.query( + `SELECT bool_or(type = 'ARTIFACT_SUBMITTED') AS submitted, bool_or(type = 'TASK_BLOCKED_ON_QUESTION') AS detached + FROM events + WHERE id > $3::bigint AND actor_agent_id = $2 AND payload->>'taskId' = $1 + AND type IN ('ARTIFACT_SUBMITTED', 'TASK_BLOCKED_ON_QUESTION')`, + [taskId, agentId, startedEventId], + ); + return { submitted: rows[0]?.submitted === true, detachedForQuestion: rows[0]?.detached === true }; } // ── 활동(agent_activity) ───────────────────────────────────────────────── diff --git a/src/domain/room/service.ts b/src/domain/room/service.ts index 0957d99..0992c4b 100644 --- a/src/domain/room/service.ts +++ b/src/domain/room/service.ts @@ -12,7 +12,7 @@ import { tasksChanged } from '../dispatch/tasks-changed.js'; import { blockStoppedTaskInTx } from '../task/stall.js'; import { activityRecorded } from './activity-hub.js'; import { parseCursor, renderFeedRow, type RoomMessage } from './render.js'; -import { findOpenRun, insertActivity, listActiveRoleTasks, listRoomFeed, type ActivityKind, type RoomTask } from './repository.js'; +import { findOpenRun, findRunOutcome, insertActivity, listActiveRoleTasks, listRoomFeed, type ActivityKind, type RoomTask } from './repository.js'; // 룸 = 프로젝트 × 역할. 그 역할의 에이전트가 무엇을 하는지(활동), 서버(PM)가 무엇을 지시·판정했는지를 한 줄씩 보여 준다. // 팀원은 자기 역할 룸만, 대표는 전부 본다. PM(서버)의 프로젝트 단위 알림은 모든 룸에 나온다. @@ -66,9 +66,10 @@ export type EndRunInput = { deniedCommands?: string[]; }; -// Claude 실행이 끝났다. 제출 여부는 Executor가 아니라 서버가 정한다 — 이 에이전트가 아직 그 태스크를 잡고 있으면(CLAIMED·IN_PROGRESS) -// 제출하지 않은 것이다. 제출 뒤에는 검증이 상태를 이미 옮겨 두었다(VERIFYING·DONE·READY…). -// 제출하지 않았으면 같은 트랜잭션에서 BLOCKED(AGENT_STOPPED)로 멈추고 사유를 남긴다(task/stall.ts) — 재시도 횟수는 그대로. +// Claude 실행이 끝났다. 제출 여부는 Executor가 아니라 서버가 정한다 — 이 실행이 시작된 뒤 남은 이벤트로 판정한다(findRunOutcome). +// 종료 순간의 태스크 상태로 짐작하지 않는다: 답이 종료 보고보다 먼저 오면 태스크는 이미 READY다(실험 C1). +// 제출도 내려놓음도 없으면 같은 트랜잭션에서 BLOCKED(AGENT_STOPPED)로 멈추고 사유를 남긴다(task/stall.ts) — 재시도 횟수는 그대로. +// 멈추는 것은 이 에이전트가 아직 잡고 있을 때만이다(늦게 온 종료 보고가 다른 실행을 멈추지 않게). export async function endRun( ctx: AgentContext, taskId: string, @@ -78,9 +79,10 @@ export async function endRun( const task = await taskInProject(ctx, taskId, tx); const open = await findOpenRun(tx, taskId, ctx.agentId); if (open === null) throw new AppError('RUN_NOT_OPEN', `no open run for task ${taskId}`); - // 질문의 답을 기다리느라 내려놓았다(BLOCKED·QUESTION, 담당도 비었다) — 제출한 것도, 멈춘 것도 아니다. 답이 오면 다시 시작된다. - const waitingQuestion = task.state === 'BLOCKED' && task.blockedReason === 'QUESTION'; - const submitted = !waitingQuestion && !(task.assigneeAgentId === ctx.agentId && RUNNING_STATES.includes(task.state)); + // 질문의 답을 기다리느라 내려놓았다(담당도 비었다) — 제출한 것도, 멈춘 것도 아니다. 답이 오면 다시 시작된다. + const outcome = await findRunOutcome(tx, taskId, ctx.agentId, open.startedEventId); + const submitted = outcome.submitted; + const waitingQuestion = !submitted && outcome.detachedForQuestion; await appendEvent(tx, { orgId: ctx.orgId, projectId: ctx.projectId, diff --git a/src/domain/task/repository.ts b/src/domain/task/repository.ts index 76868f1..82a0348 100644 --- a/src/domain/task/repository.ts +++ b/src/domain/task/repository.ts @@ -338,12 +338,13 @@ export async function escalateQuestionTask(db: Queryable, taskId: string): Promi // 에이전트가 잡고 있던(CLAIMED·IN_PROGRESS) 태스크를 BLOCKED(AGENT_STOPPED)로. 담당은 남겨 둔다(누가 멈췄는지 화면에 보인다). // 조건부 UPDATE라 그사이 제출로 상태가 바뀌었으면 아무것도 하지 않고 null. -export async function blockStoppedTask(db: Queryable, taskId: string): Promise { +// 그 에이전트가 아직 잡고 있을 때만 멈춘다 — 늦게 온 종료 보고·감시가 다른 실행(재개로 다시 잡은 것 포함)을 멈추지 않게. +export async function blockStoppedTask(db: Queryable, taskId: string, agentId: string): Promise { const { rows } = await db.query( `UPDATE tasks SET state = 'BLOCKED', blocked_reason = 'AGENT_STOPPED', updated_at = now() - WHERE id = $1 AND state IN ('CLAIMED', 'IN_PROGRESS') + WHERE id = $1 AND state IN ('CLAIMED', 'IN_PROGRESS') AND assignee_agent_id = $2 RETURNING *`, - [taskId], + [taskId, agentId], ); return rows[0] ? toTask(rows[0]) : null; } diff --git a/src/domain/task/stall.ts b/src/domain/task/stall.ts index b2854c7..c8094f5 100644 --- a/src/domain/task/stall.ts +++ b/src/domain/task/stall.ts @@ -25,7 +25,7 @@ export async function blockStoppedTaskInTx( taskId: string, input: BlockInput, ): Promise { - const blocked = await blockStoppedTask(tx, taskId); + const blocked = await blockStoppedTask(tx, taskId, input.agentId); if (blocked === null) return null; await appendEvent(tx, { orgId: ctx.orgId, diff --git a/tests/rooms.test.ts b/tests/rooms.test.ts index e76831e..28ef19a 100644 --- a/tests/rooms.test.ts +++ b/tests/rooms.test.ts @@ -304,4 +304,38 @@ describe('질문 중계와 룸', () => { const be = (await feed(w.be.token, w.projectId, 'BACKEND')).messages.map((m) => m.text); expect(be).toContain('FRONTEND 에이전트가 BACKEND에게 질문했습니다 (1개)'); }); + + it('답이 종료 보고보다 먼저 와도(이미 READY·다시 수령) 그 실행은 "제출함"도 "멈춤"도 아니다 — 판정은 실행 안의 이벤트로 (실험 C1)', async () => { + const w = await world(); + await http('POST', `/tasks/${w.feTaskId}/runs/start`, w.feAgentToken); + await http('POST', `/tasks/${w.feTaskId}/claim`, w.feAgentToken); + const q = 'GET /api/todos 응답 필드 이름은?'; + const asked = await http('POST', `/tasks/${w.feTaskId}/questions`, w.feAgentToken, { + questions: [{ question: q, header: '필드', options: [{ label: 'title' }, { label: 'name' }], multiSelect: false }], + }); + const questionId = asked.data.id as string; + await http('POST', `/tasks/${w.feTaskId}/questions/${questionId}/detach`, w.feAgentToken); + // 사람이 빨리 답했다 → READY. 그리고 (늦게 온 종료 보고 전에) 같은 에이전트가 다시 잡았다. + expect((await http('POST', `/questions/${questionId}/answer`, w.be.token, { answers: { [q]: 'title' } })).status).toBe(200); + expect((await http('POST', `/tasks/${w.feTaskId}/claim`, w.feAgentToken)).status).toBe(200); + + const ended = await http('POST', `/tasks/${w.feTaskId}/runs/end`, w.feAgentToken, { outcome: 'completed', committed: false, durationMs: 130_000, exitCode: 0 }); + expect(ended.data).toEqual({ submitted: false, taskState: 'CLAIMED', blocked: false }); + const { rows } = await testPool.query(`SELECT payload FROM events WHERE type = 'AGENT_RUN_ENDED'`); + expect(rows[0]!.payload).toMatchObject({ submitted: false, waitingQuestion: true }); + // 다시 잡은 실행은 멈추지 않는다. + expect((await testPool.query(`SELECT state FROM tasks WHERE id = $1`, [w.feTaskId])).rows[0]).toEqual({ state: 'CLAIMED' }); + expect((await testPool.query(`SELECT 1 FROM events WHERE type = 'TASK_BLOCKED'`)).rowCount).toBe(0); + }); + + it('다른 에이전트가 잡고 있는 태스크는 늦게 온 종료 보고로 멈추지 않는다', async () => { + const w = await world(); + await http('POST', `/tasks/${w.feTaskId}/runs/start`, w.feAgentToken); + await http('POST', `/tasks/${w.feTaskId}/claim`, w.feAgentToken); + // 대표가 멈춤을 풀고(READY) 다시 잡히기 전까지의 상황을 흉내 낸다 — 담당이 다른 에이전트로 바뀌었다. + await testPool.query(`UPDATE tasks SET assignee_agent_id = (SELECT id FROM agents WHERE id <> assignee_agent_id LIMIT 1) WHERE id = $1`, [w.feTaskId]); + const ended = await http('POST', `/tasks/${w.feTaskId}/runs/end`, w.feAgentToken, { outcome: 'completed', committed: false, durationMs: 1_000, exitCode: 0 }); + expect(ended.data).toMatchObject({ submitted: false, blocked: false }); + expect((await testPool.query(`SELECT state FROM tasks WHERE id = $1`, [w.feTaskId])).rows[0]).toEqual({ state: 'CLAIMED' }); + }); }); From 837521a490538f75378d94c78e3436a4508f5756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=98=81=ED=9B=88?= <102127984+oyeonghun@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:56:17 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Chore:=20CLI=200.1.7=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EB=8B=B5=20=EB=8C=80=EA=B8=B0=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=205=EC=B4=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 77b0473..a3da0b1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@capstone-nomos/cli", - "version": "0.1.6", + "version": "0.1.7", "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다", "type": "module", "bin": {