@capstone-nomos/cli 0.1.15 → 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/nomos-client.js +18 -0
- package/dist/executor/background.js +5 -0
- package/dist/executor/cli.js +71 -18
- package/dist/executor/consult.js +10 -1
- package/dist/executor/contract.js +2 -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
|
@@ -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() {
|
|
@@ -154,6 +156,22 @@ export class NomosClient {
|
|
|
154
156
|
await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
|
|
155
157
|
}
|
|
156
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) {
|
|
157
175
|
const first = await this.send(method, path, body);
|
|
158
176
|
if (!needsRefresh(first))
|
|
159
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} — 그 사이 계약이 바뀌었다, 이 회차는 버린다`);
|
|
@@ -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/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
|