@capstone-nomos/cli 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/briefing-notes.js +25 -6
- package/dist/bridge/mcp-server.js +14 -8
- package/dist/bridge/nomos-client.js +21 -1
- package/dist/executor/background.js +5 -0
- package/dist/executor/cli.js +73 -20
- package/dist/executor/consult.js +10 -1
- package/dist/executor/contract.js +2 -0
- package/dist/executor/prompt.js +11 -0
- package/dist/executor/runner.js +12 -4
- package/dist/executor/stop.js +66 -0
- package/dist/executor/stream-source.js +6 -0
- package/package.json +1 -1
|
@@ -3,29 +3,48 @@ import path from 'node:path';
|
|
|
3
3
|
// 브리핑으로 프롬프트에 넣은 인계 노트의 id. 제출할 때 "이미 받은 노트"로 서버에 함께 보낸다.
|
|
4
4
|
// 서버는 관련 노트를 전부 확인해야 제출을 받는다(NOTES_UNACKNOWLEDGED). 프롬프트로 전달된 노트는 확인한 것이므로
|
|
5
5
|
// 모델이 id를 옮겨 적게 하지 않고 브릿지가 넣는다 — 모델이 직접 넣는 건 반려 뒤 새로 받은 노트뿐이다.
|
|
6
|
+
// 질문으로 정해진 결정(decisions)도 같다 — 브리핑으로 받은 결정 id를 함께 남기고 제출 때 보낸다(DECISIONS_UNACKNOWLEDGED).
|
|
6
7
|
// Executor가 작업공간에 쓰고(브리핑 직후), MCP 서버의 submit_artifact가 읽는다. 둘은 다른 프로세스다.
|
|
7
8
|
export const BRIEFING_NOTES_FILE = '.nomos-briefing.json';
|
|
8
|
-
export function writeBriefingNotes(workspaceDir, taskId, noteIds) {
|
|
9
|
-
writeFileSync(path.join(workspaceDir, BRIEFING_NOTES_FILE), `${JSON.stringify({ taskId, noteIds }, null, 2)}\n`);
|
|
9
|
+
export function writeBriefingNotes(workspaceDir, taskId, noteIds, decisionIds = []) {
|
|
10
|
+
writeFileSync(path.join(workspaceDir, BRIEFING_NOTES_FILE), `${JSON.stringify({ taskId, noteIds, decisionIds }, null, 2)}\n`);
|
|
10
11
|
}
|
|
11
|
-
// 다른 태스크의 파일이거나 없으면 빈 목록 — 엉뚱한 태스크의
|
|
12
|
-
|
|
12
|
+
// 다른 태스크의 파일이거나 없으면 빈 목록 — 엉뚱한 태스크의 노트·결정을 확인한 것으로 보내지 않는다.
|
|
13
|
+
function readBriefingIds(workspaceDir, taskId, key) {
|
|
13
14
|
const file = path.join(workspaceDir, BRIEFING_NOTES_FILE);
|
|
14
15
|
if (!existsSync(file))
|
|
15
16
|
return [];
|
|
16
17
|
try {
|
|
17
18
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
18
|
-
|
|
19
|
+
const ids = parsed[key];
|
|
20
|
+
if (parsed.taskId !== taskId || !Array.isArray(ids))
|
|
19
21
|
return [];
|
|
20
|
-
return
|
|
22
|
+
return ids.filter((id) => typeof id === 'string');
|
|
21
23
|
}
|
|
22
24
|
catch {
|
|
23
25
|
return [];
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
export function readBriefingNoteIds(workspaceDir, taskId) {
|
|
29
|
+
return readBriefingIds(workspaceDir, taskId, 'noteIds');
|
|
30
|
+
}
|
|
31
|
+
export function readBriefingDecisionIds(workspaceDir, taskId) {
|
|
32
|
+
return readBriefingIds(workspaceDir, taskId, 'decisionIds');
|
|
33
|
+
}
|
|
26
34
|
export function mergeAcknowledged(briefed, fromModel) {
|
|
27
35
|
return [...new Set([...briefed, ...(fromModel ?? [])])].sort();
|
|
28
36
|
}
|
|
37
|
+
// submit_artifact가 서버에 보내는 본문. 브리핑으로 받은 노트·결정과 모델이 반려 뒤 확인한 것을 합친다.
|
|
38
|
+
// acknowledgedDecisionIds는 비어 있어도 **항상** 보낸다 — 서버는 이 필드가 없으면 옛 CLI(0.1.14 이하)로 보고 결정 확인을 건너뛴다.
|
|
39
|
+
export function buildSubmitBody(input) {
|
|
40
|
+
const acknowledgedNoteIds = mergeAcknowledged(readBriefingNoteIds(input.workspaceDir, input.taskId), input.acknowledgedNoteIds);
|
|
41
|
+
return {
|
|
42
|
+
commitSha: input.commitSha,
|
|
43
|
+
changedPaths: input.changedPaths,
|
|
44
|
+
...(acknowledgedNoteIds.length === 0 ? {} : { acknowledgedNoteIds }),
|
|
45
|
+
acknowledgedDecisionIds: mergeAcknowledged(readBriefingDecisionIds(input.workspaceDir, input.taskId), input.acknowledgedDecisionIds),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
29
48
|
// 지금 작업공간의 태스크 id — 권한 도구가 AskUserQuestion을 어느 태스크의 질문으로 올릴지 정한다. 없으면 null.
|
|
30
49
|
export function readBriefingTaskId(workspaceDir) {
|
|
31
50
|
const file = path.join(workspaceDir, BRIEFING_NOTES_FILE);
|
|
@@ -7,7 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { readCredentials, updateAccessToken } from './credentials.js';
|
|
9
9
|
import { NomosClient, PolicyStaleLoopError } from './nomos-client.js';
|
|
10
|
-
import {
|
|
10
|
+
import { buildSubmitBody, readBriefingTaskId } from './briefing-notes.js';
|
|
11
11
|
import { PERMISSION_TOOL } from './claude-args.js';
|
|
12
12
|
import { DENY_OTHER, denyUnanswered, handleAskUserQuestion, isHandled } from './permission.js';
|
|
13
13
|
import { pushTaskBranch } from './push.js';
|
|
@@ -72,7 +72,7 @@ server.registerTool('claim_task', {
|
|
|
72
72
|
const workspaceDir = process.env.NOMOS_WORKSPACE_DIR ?? process.cwd();
|
|
73
73
|
server.registerTool('submit_artifact', {
|
|
74
74
|
title: 'Submit an artifact',
|
|
75
|
-
description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the latest commit sha and every path changed on the task branch (including commits from earlier attempts on this branch). This tool pushes the task branch before submitting; if the push is refused (wrong branch, commit not on the task branch, remote has diverged) nothing is submitted and you get the reason. The server re-checks the paths against the ownership rules, so listing paths you did not change (or omitting ones you did) will be rejected or will fail verification later. If handover notes related to your task were published after you started, the submission is rejected with NOTES_UNACKNOWLEDGED and the notes are returned: read them, change and commit your work if they affect it, then submit again with their ids in acknowledged_note_ids. Notes that were already in your instructions are acknowledged automatically.',
|
|
75
|
+
description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the latest commit sha and every path changed on the task branch (including commits from earlier attempts on this branch). This tool pushes the task branch before submitting; if the push is refused (wrong branch, commit not on the task branch, remote has diverged) nothing is submitted and you get the reason. The server re-checks the paths against the ownership rules, so listing paths you did not change (or omitting ones you did) will be rejected or will fail verification later. If handover notes related to your task were published after you started, the submission is rejected with NOTES_UNACKNOWLEDGED and the notes are returned: read them, change and commit your work if they affect it, then submit again with their ids in acknowledged_note_ids. Notes that were already in your instructions are acknowledged automatically. Likewise, if decisions were made by answering other tasks\' questions after you started, the submission is rejected with DECISIONS_UNACKNOWLEDGED and the decisions are returned (with any new related notes): follow them, change and commit your work if it differs, then submit again with their ids in acknowledged_decision_ids (and the notes\' ids in acknowledged_note_ids). Decisions already in your instructions are acknowledged automatically.',
|
|
76
76
|
inputSchema: {
|
|
77
77
|
task_id: z.string().describe('The claimed task id'),
|
|
78
78
|
commit_sha: z.string().describe('Commit sha containing the work'),
|
|
@@ -81,8 +81,12 @@ server.registerTool('submit_artifact', {
|
|
|
81
81
|
.array(z.string())
|
|
82
82
|
.optional()
|
|
83
83
|
.describe('Ids of handover notes you read after a NOTES_UNACKNOWLEDGED rejection'),
|
|
84
|
+
acknowledged_decision_ids: z
|
|
85
|
+
.array(z.string())
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('Ids of decisions you read after a DECISIONS_UNACKNOWLEDGED rejection'),
|
|
84
88
|
},
|
|
85
|
-
}, async ({ task_id, commit_sha, changed_paths, acknowledged_note_ids }) => run(async () => {
|
|
89
|
+
}, async ({ task_id, commit_sha, changed_paths, acknowledged_note_ids, acknowledged_decision_ids }) => run(async () => {
|
|
86
90
|
// push할 브랜치와 막을 브랜치는 서버가 정한다. 모델이 넘긴 값으로 고르지 않는다.
|
|
87
91
|
const briefing = (await client.getBriefing(task_id));
|
|
88
92
|
const push = await pushTaskBranch({
|
|
@@ -95,13 +99,15 @@ server.registerTool('submit_artifact', {
|
|
|
95
99
|
process.stderr.write('[nomos-mcp] origin 없음 — push 건너뜀(서버가 mirror 모드로 로컬에서 읽는다)\n');
|
|
96
100
|
}
|
|
97
101
|
// push가 실패하면(PushRefused) 여기까지 오지 않는다 — 제출하지 않고 run()이 모델에게 사유를 돌려준다.
|
|
98
|
-
// 브리핑으로 프롬프트에 들어간
|
|
99
|
-
const
|
|
100
|
-
|
|
102
|
+
// 브리핑으로 프롬프트에 들어간 노트·결정은 이미 받은 것이다 — Executor가 작업공간에 남긴 id를 함께 보낸다.
|
|
103
|
+
const artifact = await client.submitArtifact(task_id, buildSubmitBody({
|
|
104
|
+
workspaceDir,
|
|
105
|
+
taskId: task_id,
|
|
101
106
|
commitSha: commit_sha,
|
|
102
107
|
changedPaths: changed_paths,
|
|
103
|
-
...(
|
|
104
|
-
|
|
108
|
+
...(acknowledged_note_ids === undefined ? {} : { acknowledgedNoteIds: acknowledged_note_ids }),
|
|
109
|
+
...(acknowledged_decision_ids === undefined ? {} : { acknowledgedDecisionIds: acknowledged_decision_ids }),
|
|
110
|
+
}));
|
|
105
111
|
return { ...artifact, push };
|
|
106
112
|
}, 'submit_artifact'));
|
|
107
113
|
server.registerTool('publish_note', {
|
|
@@ -29,6 +29,7 @@ export class NomosClient {
|
|
|
29
29
|
baseUrl;
|
|
30
30
|
fetchImpl;
|
|
31
31
|
onTokensChanged;
|
|
32
|
+
onApiError;
|
|
32
33
|
current;
|
|
33
34
|
// 진단용. 통합 테스트가 "정확히 한 번만 재발급했는가"를 확인한다.
|
|
34
35
|
refreshCount = 0;
|
|
@@ -36,6 +37,7 @@ export class NomosClient {
|
|
|
36
37
|
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
37
38
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
38
39
|
this.onTokensChanged = options.onTokensChanged;
|
|
40
|
+
this.onApiError = options.onApiError;
|
|
39
41
|
this.current = options.tokens;
|
|
40
42
|
}
|
|
41
43
|
get tokens() {
|
|
@@ -44,7 +46,9 @@ export class NomosClient {
|
|
|
44
46
|
async claimTask(taskId) {
|
|
45
47
|
return (await this.request('POST', `/api/tasks/${taskId}/claim`));
|
|
46
48
|
}
|
|
47
|
-
async submitArtifact(taskId,
|
|
49
|
+
async submitArtifact(taskId,
|
|
50
|
+
// acknowledgedDecisionIds: MCP submit_artifact는 비어 있어도 항상 보낸다(없으면 서버가 옛 CLI로 보고 결정 확인을 건너뛴다).
|
|
51
|
+
input) {
|
|
48
52
|
return (await this.request('POST', `/api/tasks/${taskId}/artifacts`, input));
|
|
49
53
|
}
|
|
50
54
|
// 브릿지 단계(V2·V4) 보고. 서버가 판정하는 V1A·V1B·V3는 여기로 보낼 수 없다 — 403이 돌아온다.
|
|
@@ -152,6 +156,22 @@ export class NomosClient {
|
|
|
152
156
|
await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
|
|
153
157
|
}
|
|
154
158
|
async request(method, path, body) {
|
|
159
|
+
try {
|
|
160
|
+
return await this.requestOnce(method, path, body);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (err instanceof NomosApiError) {
|
|
164
|
+
try {
|
|
165
|
+
this.onApiError?.(err);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// 알림이 실패해도 원래 에러를 그대로 던진다.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async requestOnce(method, path, body) {
|
|
155
175
|
const first = await this.send(method, path, body);
|
|
156
176
|
if (!needsRefresh(first))
|
|
157
177
|
return unwrap(first);
|
|
@@ -26,6 +26,11 @@ export function isAlive(pid) {
|
|
|
26
26
|
return err.code === 'EPERM';
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
// 이 프로세스가 백그라운드 connect 자신이면 daemon.json을 지운다 — 연결 해제로 스스로 끝날 때(cli.ts) status가 바로 "돌고 있지 않음"을 보이게.
|
|
30
|
+
export function clearDaemonIfSelf() {
|
|
31
|
+
if (readDaemon()?.pid === process.pid)
|
|
32
|
+
rmSync(daemonFile(), { force: true });
|
|
33
|
+
}
|
|
29
34
|
// 살아 있는 백그라운드 connect. 죽었는데 파일만 남았으면 지우고 null.
|
|
30
35
|
export function runningDaemon() {
|
|
31
36
|
const info = readDaemon();
|
package/dist/executor/cli.js
CHANGED
|
@@ -27,7 +27,8 @@ import { credentialsPath, readCredentials, updateAccessToken, writeCredentials,
|
|
|
27
27
|
import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
|
|
28
28
|
import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
|
|
29
29
|
import { accountLine, connect, DEFAULT_SERVER, serverCalls } from './connect.js';
|
|
30
|
-
import { runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
|
|
30
|
+
import { clearDaemonIfSelf, runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
|
|
31
|
+
import { agentStop, DISCONNECTED_EXIT_CODE, DISCONNECTED_MESSAGE } from './stop.js';
|
|
31
32
|
import { deviceLogin } from './device-login.js';
|
|
32
33
|
import { cliVersion, mcpServerPath } from './paths.js';
|
|
33
34
|
import { checkTools } from './preflight.js';
|
|
@@ -46,6 +47,24 @@ const POLL_INTERVAL_MS = 10_000;
|
|
|
46
47
|
function log(line) {
|
|
47
48
|
process.stdout.write(`[nomos] ${line}\n`);
|
|
48
49
|
}
|
|
50
|
+
// 돌고 있는 일(태스크 실행·상담·계약 초안) — 연결 해제로 멈출 때 다 끝나기를 기다린 뒤 프로세스를 끝낸다.
|
|
51
|
+
const running = new Set();
|
|
52
|
+
function track(work) {
|
|
53
|
+
const p = work().finally(() => running.delete(p));
|
|
54
|
+
running.add(p);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
// 연결 해제로 멈출 때, 이미 끝내라고 한(트리째 종료) 실행들이 정리되기를 잠시 기다린다. 오래 걸려도 프로세스는 끝낸다.
|
|
58
|
+
async function settleRunning(timeoutMs = 15_000) {
|
|
59
|
+
await Promise.race([Promise.allSettled([...running]), new Promise((r) => setTimeout(r, timeoutMs).unref())]);
|
|
60
|
+
}
|
|
61
|
+
// 연결 해제로 끝난다 — 0이 아닌 코드. 백그라운드(--background)로 돌던 것이면 daemon.json도 지워 status가 "돌고 있지 않음"을 보이게 한다.
|
|
62
|
+
// 남은 타이머(폴링 대기 등)가 프로세스를 붙잡지 않게 바로 끝낸다.
|
|
63
|
+
function exitDisconnected() {
|
|
64
|
+
log(DISCONNECTED_MESSAGE);
|
|
65
|
+
clearDaemonIfSelf();
|
|
66
|
+
process.exit(DISCONNECTED_EXIT_CODE);
|
|
67
|
+
}
|
|
49
68
|
function createClient() {
|
|
50
69
|
const credentials = readCredentials();
|
|
51
70
|
if (!credentials) {
|
|
@@ -56,6 +75,11 @@ function createClient() {
|
|
|
56
75
|
tokens: { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken },
|
|
57
76
|
// 재발급 결과를 파일에 되돌려 쓴다. 없으면 다음 실행이 또 401로 시작한다.
|
|
58
77
|
onTokensChanged: (tokens) => updateAccessToken(tokens.accessToken),
|
|
78
|
+
// 어느 요청에서든 AGENT_DISCONNECTED를 받으면 실행 중인 Claude를 모두 멈춘다(stop.ts).
|
|
79
|
+
onApiError: (err) => {
|
|
80
|
+
if (!agentStop.stopped && agentStop.noteError(err))
|
|
81
|
+
log(DISCONNECTED_MESSAGE);
|
|
82
|
+
},
|
|
59
83
|
});
|
|
60
84
|
}
|
|
61
85
|
// 지금 돌고 있는 태스크의 작업공간 — 상담 실행이 진행 중인 작업(커밋 안 된 파일 포함)을 읽으려고 둔다.
|
|
@@ -72,7 +96,7 @@ async function consultPending(client, role) {
|
|
|
72
96
|
if (!job)
|
|
73
97
|
return;
|
|
74
98
|
consulting.add(job.id);
|
|
75
|
-
|
|
99
|
+
track(async () => {
|
|
76
100
|
try {
|
|
77
101
|
const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
|
|
78
102
|
const repo = active ? { fullName: active.repo } : repos[0];
|
|
@@ -80,18 +104,20 @@ async function consultPending(client, role) {
|
|
|
80
104
|
throw new Error('이 역할이 소유한 레포가 프로젝트에 없다');
|
|
81
105
|
const dir = active ? active.dir : ensureRepo({ fullName: repo.fullName, cloneUrl: repos[0].cloneUrl }, { log }).path;
|
|
82
106
|
log(`상담 실행: ${job.askerRole}의 질문 ${job.questions.length}개 — ${repo.fullName}${active ? ' (진행 중인 작업공간)' : ''}`);
|
|
83
|
-
const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job });
|
|
107
|
+
const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job, signal: agentStop.signal });
|
|
84
108
|
const result = await client.submitDraft(job.id, draft);
|
|
85
109
|
log(result.status === 'agent_answered' ? ' 초안 올림 — 전부 코드에 정해져 있어 바로 답이 됐다(사람 확인 대기)' : ' 초안 올림 — 정해지지 않은 결정이 있어 담당자에게 넘어갔다');
|
|
86
110
|
}
|
|
87
111
|
catch (err) {
|
|
88
112
|
consultGaveUp.add(job.id);
|
|
89
|
-
|
|
113
|
+
// 연결 해제로 멈췄으면 사유는 이미 남겼다(그 뒤의 보고는 401이라 보내지 않는다).
|
|
114
|
+
if (!agentStop.stopped)
|
|
115
|
+
log(` 상담 실패 — 담당자가 직접 답한다: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
116
|
}
|
|
91
117
|
finally {
|
|
92
118
|
consulting.delete(job.id);
|
|
93
119
|
}
|
|
94
|
-
})
|
|
120
|
+
});
|
|
95
121
|
}
|
|
96
122
|
// 계약 작성(023) — BACKEND 노트북만. 기능 명세에 API가 필요하면 BE 레포를 읽기만 해서 계약 초안을 올린다(본인 구독).
|
|
97
123
|
// 한 번에 한 묶음씩. 첫 회차가 둘 이상이면 한 번의 실행으로 함께 쓰고(레포를 한 번만 읽는다 — contract.ts), 레포 최신화도 묶음당 한 번이다.
|
|
@@ -107,7 +133,7 @@ async function contractPending(client) {
|
|
|
107
133
|
if (jobs.length === 0)
|
|
108
134
|
return;
|
|
109
135
|
drafting = true;
|
|
110
|
-
|
|
136
|
+
track(async () => {
|
|
111
137
|
try {
|
|
112
138
|
const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
|
|
113
139
|
const repo = active ? repos.find((r) => r.fullName === active.repo) : repos[0];
|
|
@@ -124,7 +150,7 @@ async function contractPending(client) {
|
|
|
124
150
|
if (firsts.length >= 2) {
|
|
125
151
|
log(`계약 묶음 작성: ${firsts.map((j) => j.featureKey).join(', ')} (${firsts.length}개, 1회차) — ${repo.fullName}`);
|
|
126
152
|
try {
|
|
127
|
-
batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved });
|
|
153
|
+
batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved, signal: agentStop.signal });
|
|
128
154
|
const missing = firsts.filter((j) => !batched.has(j.featureKey)).map((j) => j.featureKey);
|
|
129
155
|
if (missing.length > 0)
|
|
130
156
|
log(` 묶음에서 빠진 기능 ${missing.join(', ')} — 하나씩 다시 쓴다`);
|
|
@@ -134,11 +160,13 @@ async function contractPending(client) {
|
|
|
134
160
|
}
|
|
135
161
|
}
|
|
136
162
|
for (const job of jobs) {
|
|
163
|
+
if (agentStop.stopped)
|
|
164
|
+
break;
|
|
137
165
|
try {
|
|
138
166
|
const ready = batched.get(job.featureKey);
|
|
139
167
|
if (!ready)
|
|
140
168
|
log(`계약 작성: ${job.featureKey} ${job.specTitle} (${job.attempt}회차) — ${repo.fullName}`);
|
|
141
|
-
let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved }));
|
|
169
|
+
let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved, signal: agentStop.signal }));
|
|
142
170
|
try {
|
|
143
171
|
await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
|
|
144
172
|
}
|
|
@@ -147,7 +175,7 @@ async function contractPending(client) {
|
|
|
147
175
|
throw err;
|
|
148
176
|
const problems = Array.isArray(err.details) ? err.details.map(String) : [err.message];
|
|
149
177
|
log(` ${job.featureKey} 형식 위반 ${problems.length}개 — 한 번 고쳐 쓴다`);
|
|
150
|
-
draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems });
|
|
178
|
+
draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems, signal: agentStop.signal });
|
|
151
179
|
await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
|
|
152
180
|
}
|
|
153
181
|
log(` ${job.featureKey} 초안 올림 — BE 담당이 웹에서 확정하거나 수정을 요청한다`);
|
|
@@ -160,10 +188,13 @@ async function contractPending(client) {
|
|
|
160
188
|
finally {
|
|
161
189
|
drafting = false;
|
|
162
190
|
}
|
|
163
|
-
})
|
|
191
|
+
});
|
|
164
192
|
}
|
|
165
193
|
async function reportContractFailure(client, job, err) {
|
|
166
194
|
contractGaveUp.add(`${job.id}:${job.attempt}`);
|
|
195
|
+
// 연결 해제로 멈췄다 — 실패 보고는 401이라 보내지 않는다. 서버에서는 BE 담당이 다시 요청할 수 있다.
|
|
196
|
+
if (agentStop.stopped)
|
|
197
|
+
return;
|
|
167
198
|
// 그 사이 수정 요청이 와서 회차가 바뀌었다 — 새 회차는 다음 확인에서 다시 쓴다.
|
|
168
199
|
if (err instanceof NomosApiError && err.code === 'CONTRACT_STALE') {
|
|
169
200
|
log(` ${job.featureKey} — 그 사이 계약이 바뀌었다, 이 회차는 버린다`);
|
|
@@ -190,8 +221,8 @@ async function handleTask(client, projectId, task) {
|
|
|
190
221
|
});
|
|
191
222
|
log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
|
|
192
223
|
activeWorkspaces.set(task.id, { dir: workspace.dir, repo: briefing.repo.fullName });
|
|
193
|
-
// 프롬프트에 넣는
|
|
194
|
-
writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id));
|
|
224
|
+
// 프롬프트에 넣는 노트·결정 = 제출 때 "받은 것"으로 함께 보낼 노트·결정. MCP 서버(submit_artifact)가 읽는다.
|
|
225
|
+
writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id), (briefing.decisions ?? []).map((d) => d.id));
|
|
195
226
|
if (workspace.createdBranch) {
|
|
196
227
|
await client.reportBranch(task.id, workspace.branch);
|
|
197
228
|
}
|
|
@@ -213,6 +244,9 @@ async function handleTask(client, projectId, task) {
|
|
|
213
244
|
const tracker = new ActivityTracker(workspace.dir);
|
|
214
245
|
// 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
|
|
215
246
|
const setup = await prepareDependencies(workspace.dir, log);
|
|
247
|
+
// 준비하는 사이 연결이 해제됐으면 Claude를 띄우지 않는다.
|
|
248
|
+
if (agentStop.stopped)
|
|
249
|
+
return;
|
|
216
250
|
const before = headSha(workspace.dir);
|
|
217
251
|
const result = await runClaude({
|
|
218
252
|
workspaceDir: workspace.dir,
|
|
@@ -225,7 +259,15 @@ async function handleTask(client, projectId, task) {
|
|
|
225
259
|
observer.observe(line);
|
|
226
260
|
reporter?.push(tracker.observe(line));
|
|
227
261
|
},
|
|
262
|
+
signal: agentStop.signal,
|
|
228
263
|
});
|
|
264
|
+
// 연결이 해제돼 멈췄다 — 서버가 열린 실행을 이미 닫았고(outcome disconnected) 종료 보고는 401이라 보내지 않는다.
|
|
265
|
+
// 작업공간은 지우지 않는다(남기는 규칙 그대로).
|
|
266
|
+
if (result.outcome === 'aborted') {
|
|
267
|
+
await reporter?.close().catch(() => { });
|
|
268
|
+
log(`태스크 ${task.id} 실행을 멈췄다(연결 해제). 작업공간은 남긴다: ${workspace.dir}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
229
271
|
const committed = headSha(workspace.dir) !== before;
|
|
230
272
|
if (reporter) {
|
|
231
273
|
reporter.push(tracker.finish());
|
|
@@ -320,19 +362,29 @@ async function start() {
|
|
|
320
362
|
},
|
|
321
363
|
fallback: () => pollingTaskSource(client).nextTasks(Number.MAX_SAFE_INTEGER),
|
|
322
364
|
log,
|
|
365
|
+
// 서버가 연결 해제로 스트림을 닫았다(4401 AGENT_DISCONNECTED) — 실행 중인 Claude를 멈춘다.
|
|
366
|
+
onAgentDisconnected: () => agentStop.stop(),
|
|
323
367
|
});
|
|
324
368
|
log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
|
|
325
369
|
const inFlight = new Set();
|
|
326
370
|
// 태스크 하나가 끝나면 자리가 난다 — 다음 푸시나 간격을 기다리지 않고 바로 다시 본다.
|
|
327
371
|
let finished = () => { };
|
|
372
|
+
// 연결 해제 — 실행 중인 것은 이미 트리째 끝내라고 했다(stop.ts). 정리를 기다린 뒤 멈춘다.
|
|
373
|
+
const stopDisconnected = async () => {
|
|
374
|
+
source.close();
|
|
375
|
+
await settleRunning();
|
|
376
|
+
return 'disconnected';
|
|
377
|
+
};
|
|
328
378
|
for (;;) {
|
|
379
|
+
if (agentStop.stopped)
|
|
380
|
+
return stopDisconnected();
|
|
329
381
|
try {
|
|
330
382
|
const capacity = self.maxConcurrent - inFlight.size;
|
|
331
383
|
if (capacity > 0) {
|
|
332
384
|
// 처리 중인 것도 목록에 남아 있을 수 있으니(수령 전) 그만큼 더 받아 거른다.
|
|
333
385
|
for (const task of (await source.nextTasks(capacity + inFlight.size)).filter((t) => !inFlight.has(t.id)).slice(0, capacity)) {
|
|
334
386
|
inFlight.add(task.id);
|
|
335
|
-
void handleTask(client, self.projectId, task)
|
|
387
|
+
void track(() => handleTask(client, self.projectId, task))
|
|
336
388
|
.catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
|
|
337
389
|
.finally(() => {
|
|
338
390
|
inFlight.delete(task.id);
|
|
@@ -345,9 +397,8 @@ async function start() {
|
|
|
345
397
|
catch (err) {
|
|
346
398
|
// 대표나 본인이 웹에서 이 에이전트의 연결을 해제했다(서버 021) — 계속 물어도 같은 답이니 이유를 알리고 멈춘다.
|
|
347
399
|
if (err instanceof NomosApiError && err.code === 'AGENT_DISCONNECTED') {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return 'disconnected';
|
|
400
|
+
agentStop.stop();
|
|
401
|
+
return stopDisconnected();
|
|
351
402
|
}
|
|
352
403
|
// 배정이 풀렸다(대표의 해제·프로젝트 중단, 서버 021). 같은 목록을 계속 물어도 거부된다.
|
|
353
404
|
if (err instanceof NomosApiError && err.code === 'NOT_PROJECT_MEMBER' && inFlight.size === 0) {
|
|
@@ -376,7 +427,7 @@ async function start() {
|
|
|
376
427
|
if (!(err instanceof NomosApiError && err.status === 404))
|
|
377
428
|
log(`계약 작업 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
|
|
378
429
|
}
|
|
379
|
-
await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve))]);
|
|
430
|
+
await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve)), agentStop.whenStopped()]);
|
|
380
431
|
}
|
|
381
432
|
}
|
|
382
433
|
// 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
|
|
@@ -522,8 +573,9 @@ async function connectCommand(argv) {
|
|
|
522
573
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
523
574
|
log,
|
|
524
575
|
});
|
|
525
|
-
|
|
526
|
-
|
|
576
|
+
const reason = await start();
|
|
577
|
+
if (reason === 'disconnected')
|
|
578
|
+
exitDisconnected();
|
|
527
579
|
log('다시 배정을 기다린다');
|
|
528
580
|
}
|
|
529
581
|
}
|
|
@@ -626,7 +678,8 @@ async function main() {
|
|
|
626
678
|
if (command === 'once')
|
|
627
679
|
return once();
|
|
628
680
|
if (command === 'start') {
|
|
629
|
-
await start()
|
|
681
|
+
if ((await start()) === 'disconnected')
|
|
682
|
+
exitDisconnected();
|
|
630
683
|
return;
|
|
631
684
|
}
|
|
632
685
|
if (command === 'pm-worker')
|
package/dist/executor/consult.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { killOnAbort } from './stop.js';
|
|
5
6
|
import { claudeEnv, resolveClaudeCommand } from './runner.js';
|
|
6
7
|
// 상담 응답의 형식(구조화 출력). 모든 객체는 additionalProperties: false + 전 필드 required.
|
|
7
8
|
export const CONSULT_JSON_SCHEMA = {
|
|
@@ -63,6 +64,7 @@ export function consultArgs(prompt, emptyMcpPath) {
|
|
|
63
64
|
return readOnlyClaudeArgs(prompt, emptyMcpPath, CONSULT_JSON_SCHEMA);
|
|
64
65
|
}
|
|
65
66
|
// 읽기 전용 Claude를 한 번 돌려 구조화 출력을 돌려준다(없으면 result 문자열).
|
|
67
|
+
// signal이 멈추면(연결 해제 — stop.ts) claude를 트리째 끝내고 그 사유로 실패한다.
|
|
66
68
|
export async function runReadOnlyClaude(input) {
|
|
67
69
|
const scratch = path.join(tmpdir(), 'nomos-consult');
|
|
68
70
|
mkdirSync(scratch, { recursive: true });
|
|
@@ -76,14 +78,20 @@ export async function runReadOnlyClaude(input) {
|
|
|
76
78
|
child.kill();
|
|
77
79
|
reject(new Error(`${input.label} 시간 초과`));
|
|
78
80
|
}, input.timeoutMs);
|
|
81
|
+
const unwatch = killOnAbort(child, input.signal);
|
|
79
82
|
child.stdout.on('data', (d) => (stdout += d));
|
|
80
83
|
child.once('error', (err) => {
|
|
81
84
|
clearTimeout(timer);
|
|
85
|
+
unwatch();
|
|
82
86
|
reject(err);
|
|
83
87
|
});
|
|
84
88
|
child.on('close', () => {
|
|
85
89
|
clearTimeout(timer);
|
|
86
|
-
|
|
90
|
+
unwatch();
|
|
91
|
+
if (input.signal?.aborted)
|
|
92
|
+
reject(input.signal.reason instanceof Error ? input.signal.reason : new Error(`${input.label} 중단`));
|
|
93
|
+
else
|
|
94
|
+
resolve(stdout);
|
|
87
95
|
});
|
|
88
96
|
child.stdin.end(input.prompt);
|
|
89
97
|
});
|
|
@@ -124,6 +132,7 @@ export async function runConsult(input) {
|
|
|
124
132
|
schema: CONSULT_JSON_SCHEMA,
|
|
125
133
|
timeoutMs: input.timeoutMs ?? 5 * 60_000,
|
|
126
134
|
label: '상담 실행',
|
|
135
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
127
136
|
});
|
|
128
137
|
return parseConsultDraft(output, input.question.questions.map((q) => q.question));
|
|
129
138
|
}
|
|
@@ -80,6 +80,7 @@ export async function runContractDraft(input) {
|
|
|
80
80
|
// 계약은 레포를 넓게 읽는다(라우트·에러 형식·인증) — 상담(5분)보다 길게 둔다.
|
|
81
81
|
timeoutMs: input.timeoutMs ?? 10 * 60_000,
|
|
82
82
|
label: '계약 작성',
|
|
83
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
83
84
|
});
|
|
84
85
|
return parseContractDraft(output);
|
|
85
86
|
}
|
|
@@ -170,6 +171,7 @@ export async function runContractBatch(input) {
|
|
|
170
171
|
// 레포는 한 번 읽지만 쓸 양은 기능 수만큼 는다. 기본 10분 + 기능당 2분, 최대 30분.
|
|
171
172
|
timeoutMs: input.timeoutMs ?? Math.min(30, 10 + 2 * input.jobs.length) * 60_000,
|
|
172
173
|
label: '계약 묶음 작성',
|
|
174
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
173
175
|
});
|
|
174
176
|
return parseContractBatch(output, input.jobs);
|
|
175
177
|
}
|
package/dist/executor/prompt.js
CHANGED
|
@@ -42,6 +42,16 @@ export function buildTaskPrompt(briefing, branch, environment) {
|
|
|
42
42
|
'이 브랜치에 이전 작업이 남아 있으면 이어서 진행한다. 같은 질문을 다시 묻지 않는다.',
|
|
43
43
|
].join('\n'));
|
|
44
44
|
}
|
|
45
|
+
// 다른 역할·PM이 질문에 답해 확정한 결정 — 이 태스크가 물은 것이 아니어도 프로젝트 전체가 따른다.
|
|
46
|
+
// 실행 중에는 끼워 넣지 않는다. 작업 중에 새로 정해진 결정은 제출 때 DECISIONS_UNACKNOWLEDGED로 온다.
|
|
47
|
+
if (briefing.decisions && briefing.decisions.length > 0) {
|
|
48
|
+
sections.push([
|
|
49
|
+
'# 질문으로 정해진 결정',
|
|
50
|
+
'다른 역할·PM이 질문에 답해 확정한 결정이다. 구현이 이 결정과 다르면 결정을 따른다.',
|
|
51
|
+
'결정 자체가 틀렸거나 바꿔야 한다고 보면 임의로 바꾸지 말고 AskUserQuestion으로 그 역할에 다시 묻는다.',
|
|
52
|
+
...briefing.decisions.map((d) => `- [물은 쪽 ${d.askerRole} · 정한 쪽 ${d.targetRole}${d.decidedByName ? ` ${d.decidedByName}` : ''}] Q: ${d.question}\n A: ${d.answer ?? ''}`),
|
|
53
|
+
].join('\n'));
|
|
54
|
+
}
|
|
45
55
|
// 사람(그 역할 담당·대표)이 룸에서 보낸 메시지 — 이번 실행이 시작되기 전에 온 것만 들어온다(실행 중에는 끼워 넣지 않는다).
|
|
46
56
|
if (briefing.humanMessages && briefing.humanMessages.length > 0) {
|
|
47
57
|
sections.push([
|
|
@@ -67,6 +77,7 @@ export function buildTaskPrompt(briefing, branch, environment) {
|
|
|
67
77
|
' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
|
|
68
78
|
'- 제출이 NOTES_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 올라온 인계 노트가 함께 온다. 읽고, 작업에 영향이 있으면 고쳐서 커밋한 뒤',
|
|
69
79
|
' submit_artifact를 다시 부르며 그 노트 id를 acknowledged_note_ids에 넣는다. 위 [인계 노트]의 노트는 따로 넣지 않아도 된다.',
|
|
80
|
+
'- 제출이 DECISIONS_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 정해진 결정이 함께 온다. 반영하고 그 id를 acknowledged_decision_ids에 넣어 다시 제출하라(함께 온 노트가 있으면 그 id는 acknowledged_note_ids에).',
|
|
70
81
|
'- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다. 다른 사람도 따라야 할 결정(계약·형식)은 DECIDED로 남긴다 — 프로젝트 전체에 전달된다.',
|
|
71
82
|
// 실험(E6): 이 줄이 없으면 FE 에이전트가 명세에 없는 BE 계약을 스스로 정해 DECIDED로 발행했다 — 그 노트가 BE 브리핑에 "결정"으로 들어간다.
|
|
72
83
|
`- 단, 다른 역할이 정해야 하는 계약(그 역할이 만드는 API의 응답 형식·상태 코드·필드 이름 등)은 DECIDED로 남기지 않는다 — 네 역할(${task.teamRole ?? '제한 없음'})이 정할 수 있는 것이 아니다.`,
|
package/dist/executor/runner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { buildClaudeArgs } from '../bridge/claude-args.js';
|
|
5
|
+
import { killOnAbort } from './stop.js';
|
|
5
6
|
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
6
7
|
// 모델 출력(stream-json)이 이만큼 없으면 응답이 끊긴 것으로 보고 끊는다. 30분을 다 기다리지 않고, 사유도 "오래 일함"과 구분된다.
|
|
7
8
|
// 실측: 도구 결과를 받은 뒤 다음 모델 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다(같은 조건 재실행은 정상).
|
|
@@ -111,14 +112,20 @@ export async function runClaude(input) {
|
|
|
111
112
|
child.kill();
|
|
112
113
|
}
|
|
113
114
|
}, Math.min(30_000, Math.max(50, Math.floor(idleMs / 4))));
|
|
115
|
+
const unwatch = killOnAbort(child, input.signal);
|
|
114
116
|
child.on('close', (code) => {
|
|
115
117
|
clearTimeout(timer);
|
|
116
118
|
clearInterval(idleCheck);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
unwatch();
|
|
120
|
+
const aborted = input.signal?.aborted === true;
|
|
121
|
+
const note = aborted
|
|
122
|
+
? '\n[executor] 에이전트 연결이 해제돼 실행을 멈췄다\n'
|
|
123
|
+
: stalled
|
|
124
|
+
? `\n[executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다\n`
|
|
125
|
+
: '';
|
|
126
|
+
writeFileSync(logPath, `${chunks.join('')}${note}`);
|
|
120
127
|
resolve({
|
|
121
|
-
outcome: stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
|
|
128
|
+
outcome: aborted ? 'aborted' : stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
|
|
122
129
|
exitCode: code,
|
|
123
130
|
durationMs: Date.now() - startedAt,
|
|
124
131
|
logPath,
|
|
@@ -127,6 +134,7 @@ export async function runClaude(input) {
|
|
|
127
134
|
child.on('error', (err) => {
|
|
128
135
|
clearTimeout(timer);
|
|
129
136
|
clearInterval(idleCheck);
|
|
137
|
+
unwatch();
|
|
130
138
|
writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
|
|
131
139
|
resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
|
|
132
140
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
// 이 에이전트의 연결이 해제됐다(서버 021 — 웹에서 본인·대표가 해제, 조직 탈퇴·해산). 알게 되는 곳이 여러 군데라 한 곳에 모은다:
|
|
3
|
+
// 폴링·브리핑·활동 보고 같은 HTTP 응답(NomosClient의 onApiError), 태스크 스트림의 4401 AGENT_DISCONNECTED 종료.
|
|
4
|
+
// 알게 되면 실행 중인 Claude(태스크 실행·상담·계약 초안)를 모두 멈춘다 — 해제 뒤에는 무엇을 해도 서버가 받지 않고(401),
|
|
5
|
+
// 실행이 끝까지 가면 룸에 "생각 중"만 남았다. 서버는 해제하면서 열린 실행을 이미 닫는다(outcome disconnected) — 그래서 종료 보고도 보내지 않는다.
|
|
6
|
+
export const AGENT_DISCONNECTED = 'AGENT_DISCONNECTED';
|
|
7
|
+
export const DISCONNECTED_MESSAGE = '이 에이전트의 연결이 해제돼 실행 중인 작업을 멈췄다 — 다시 쓰려면 connect를 실행하라';
|
|
8
|
+
// connect·start가 연결 해제로 끝날 때의 종료 코드(0이 아니다 — 스크립트·백그라운드 감시가 정상 종료와 가를 수 있게).
|
|
9
|
+
export const DISCONNECTED_EXIT_CODE = 3;
|
|
10
|
+
export function isAgentDisconnected(err) {
|
|
11
|
+
return typeof err === 'object' && err !== null && err.code === AGENT_DISCONNECTED;
|
|
12
|
+
}
|
|
13
|
+
// 프로세스 전체에서 하나 — 연결 해제는 에이전트 단위라 실행마다 따로 둘 이유가 없다.
|
|
14
|
+
export class AgentStop {
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
get signal() {
|
|
17
|
+
return this.controller.signal;
|
|
18
|
+
}
|
|
19
|
+
get stopped() {
|
|
20
|
+
return this.controller.signal.aborted;
|
|
21
|
+
}
|
|
22
|
+
// 에러가 연결 해제면 멈춘다. 멈췄으면 true(이미 멈춘 경우도).
|
|
23
|
+
noteError(err) {
|
|
24
|
+
if (isAgentDisconnected(err))
|
|
25
|
+
this.stop();
|
|
26
|
+
return this.stopped;
|
|
27
|
+
}
|
|
28
|
+
stop() {
|
|
29
|
+
if (!this.controller.signal.aborted)
|
|
30
|
+
this.controller.abort(new Error(DISCONNECTED_MESSAGE));
|
|
31
|
+
}
|
|
32
|
+
// 멈출 때 풀리는 약속 — 대기(waitForChange)와 경주시킨다.
|
|
33
|
+
whenStopped() {
|
|
34
|
+
if (this.stopped)
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
return new Promise((resolve) => this.controller.signal.addEventListener('abort', () => resolve(), { once: true }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const agentStop = new AgentStop();
|
|
40
|
+
// 자식 프로세스를 트리째 끝낸다. claude는 MCP 서버·Bash 도구(시험 실행 등)를 자식으로 띄운다.
|
|
41
|
+
// - Windows: claude를 node로 cli-wrapper를 거쳐 띄우므로(runner.ts의 resolveClaudeCommand) 그 node만 죽이면 실제 claude가 남는다 —
|
|
42
|
+
// taskkill /T(트리) /F로 끝낸다. taskkill을 못 띄우면 직접 kill로 내려간다.
|
|
43
|
+
// - 그 밖: SIGTERM을 claude에 보낸다. MCP 서버는 stdin(파이프)이 닫히면 함께 끝난다. 프로세스 그룹째(detached + kill(-pid)) 죽이지 않는 이유:
|
|
44
|
+
// detached로 띄우면 터미널의 Ctrl+C가 claude에 닿지 않아, connect를 끄면 claude가 고아로 남는다.
|
|
45
|
+
export function killProcessTree(child) {
|
|
46
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
47
|
+
return;
|
|
48
|
+
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
49
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
50
|
+
killer.once('error', () => child.kill());
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
child.kill('SIGTERM');
|
|
54
|
+
}
|
|
55
|
+
// signal이 멈추면 자식을 트리째 끝낸다. 돌려주는 함수로 구독을 푼다(자식이 먼저 끝나면 부른다).
|
|
56
|
+
export function killOnAbort(child, signal) {
|
|
57
|
+
if (!signal)
|
|
58
|
+
return () => { };
|
|
59
|
+
const onAbort = () => killProcessTree(child);
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
onAbort();
|
|
62
|
+
return () => { };
|
|
63
|
+
}
|
|
64
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
return () => signal.removeEventListener('abort', onAbort);
|
|
66
|
+
}
|
|
@@ -89,6 +89,12 @@ export function streamTaskSource(deps) {
|
|
|
89
89
|
wake();
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
+
if (ev.code === 4401 && ev.reason === 'AGENT_DISCONNECTED') {
|
|
93
|
+
deps.log('서버가 이 에이전트의 연결 해제로 푸시를 닫았다');
|
|
94
|
+
deps.onAgentDisconnected?.();
|
|
95
|
+
wake();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
if (ev.code === 4401 && !refreshedInARow) {
|
|
93
99
|
refreshedInARow = true;
|
|
94
100
|
deps
|