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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,8 +498,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로 두면 아무도 다시 못 가져가고(운영 테스트의 영구 정체),
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.6",
"version": "0.1.7",
"description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
"type": "module",
"bin": {
Expand Down
6 changes: 5 additions & 1 deletion src/bridge/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QuestionLike>;
Expand Down Expand Up @@ -85,7 +89,7 @@ export async function handleAskUserQuestion(input: Record<string, unknown>, 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) {
Expand Down
20 changes: 17 additions & 3 deletions src/domain/room/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────────────────────────────────────────────
Expand Down
16 changes: 9 additions & 7 deletions src/domain/room/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(서버)의 프로젝트 단위 알림은 모든 룸에 나온다.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/domain/task/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task | null> {
// 그 에이전트가 아직 잡고 있을 때만 멈춘다 — 늦게 온 종료 보고·감시가 다른 실행(재개로 다시 잡은 것 포함)을 멈추지 않게.
export async function blockStoppedTask(db: Queryable, taskId: string, agentId: string): Promise<Task | null> {
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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/task/stall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function blockStoppedTaskInTx(
taskId: string,
input: BlockInput,
): Promise<Task | null> {
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,
Expand Down
Loading
Loading