@capstone-nomos/cli 0.1.7 → 0.1.9
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/executor/activity.js +111 -25
- package/dist/executor/cli.js +19 -4
- package/dist/executor/consult.js +2 -2
- package/dist/executor/pm-worker.js +2 -2
- package/dist/executor/runner.js +53 -4
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
const MAX_TARGET = 300;
|
|
3
|
+
const MAX_SAY = 500;
|
|
3
4
|
function clip(text) {
|
|
4
5
|
const oneLine = text.split(/\r?\n/)[0].trim();
|
|
5
6
|
return oneLine.length > MAX_TARGET ? `${oneLine.slice(0, MAX_TARGET - 1)}…` : oneLine;
|
|
@@ -26,6 +27,38 @@ function hideWorkspace(command, cwd) {
|
|
|
26
27
|
}
|
|
27
28
|
return out;
|
|
28
29
|
}
|
|
30
|
+
function countLines(text) {
|
|
31
|
+
if (typeof text !== 'string')
|
|
32
|
+
return null;
|
|
33
|
+
const body = text.replace(/\r?\n$/, '');
|
|
34
|
+
return body === '' ? 0 : body.split(/\r?\n/).length;
|
|
35
|
+
}
|
|
36
|
+
// 수정·작성의 줄 수(내용은 보내지 않는다). Edit은 old/new, MultiEdit은 합, Write는 새 내용.
|
|
37
|
+
function lineCounts(tool, input) {
|
|
38
|
+
if (tool === 'Write') {
|
|
39
|
+
const added = countLines(input.content);
|
|
40
|
+
return added === null ? {} : { added };
|
|
41
|
+
}
|
|
42
|
+
const edits = tool === 'MultiEdit' && Array.isArray(input.edits) ? input.edits : [input];
|
|
43
|
+
let added = 0;
|
|
44
|
+
let removed = 0;
|
|
45
|
+
for (const e of edits) {
|
|
46
|
+
const a = countLines(e.new_string);
|
|
47
|
+
const r = countLines(e.old_string);
|
|
48
|
+
if (a === null || r === null)
|
|
49
|
+
return {};
|
|
50
|
+
added += a;
|
|
51
|
+
removed += r;
|
|
52
|
+
}
|
|
53
|
+
return { added, removed };
|
|
54
|
+
}
|
|
55
|
+
// 에이전트의 말 — 작업공간 경로를 지우고, 빈 줄을 줄이고, 500자에서 자른다.
|
|
56
|
+
export function toSay(text, cwd) {
|
|
57
|
+
const cleaned = hideWorkspace(text, cwd).replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
58
|
+
if (!cleaned)
|
|
59
|
+
return null;
|
|
60
|
+
return { kind: 'say', target: cleaned.length > MAX_SAY ? `${cleaned.slice(0, MAX_SAY - 1)}…` : cleaned };
|
|
61
|
+
}
|
|
29
62
|
// 도구 하나 → 활동 한 줄. 룸에 보일 필요가 없는 것(NOMOS 도구 — 제출·노트는 서버 이벤트로 이미 보인다, 할 일 목록)은 null.
|
|
30
63
|
export function toActivity(tool, input, cwd) {
|
|
31
64
|
// ToolSearch는 Claude Code가 도구 정의를 불러오는 내부 동작이다(실측: 실행마다 첫 줄로 찍혔다).
|
|
@@ -65,31 +98,64 @@ export function toActivity(tool, input, cwd) {
|
|
|
65
98
|
default:
|
|
66
99
|
item = { kind: 'other', target: tool };
|
|
67
100
|
}
|
|
68
|
-
|
|
101
|
+
if (!item)
|
|
102
|
+
return null;
|
|
103
|
+
const counts = item.kind === 'edit' || item.kind === 'write' ? lineCounts(tool, input) : {};
|
|
104
|
+
return { kind: item.kind, target: clip(item.target), ...counts };
|
|
69
105
|
}
|
|
70
|
-
// stream-json
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
// 실행 하나의 stream-json을 따라가며 룸에 보낼 줄을 낸다. 도구 줄은 **결과가 온 뒤** 성공 여부를 붙여 낸다(tool_use_id로 짝짓기).
|
|
107
|
+
// 결과를 못 받은 도구는 finish()에서 성공 여부 없이 낸다. 형식이 다르거나 깨진 줄은 조용히 건너뛴다(룸 표시가 실행을 막으면 안 된다).
|
|
108
|
+
export class ActivityTracker {
|
|
109
|
+
cwd;
|
|
110
|
+
pending = new Map();
|
|
111
|
+
constructor(cwd) {
|
|
112
|
+
this.cwd = cwd;
|
|
113
|
+
}
|
|
114
|
+
observe(line) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed.startsWith('{'))
|
|
117
|
+
return [];
|
|
118
|
+
let msg;
|
|
119
|
+
try {
|
|
120
|
+
msg = JSON.parse(trimmed);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(msg.message?.content))
|
|
126
|
+
return [];
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const block of msg.message.content) {
|
|
129
|
+
if (msg.type === 'assistant' && block.type === 'text' && typeof block.text === 'string') {
|
|
130
|
+
const say = toSay(block.text, this.cwd);
|
|
131
|
+
if (say)
|
|
132
|
+
out.push(say);
|
|
133
|
+
}
|
|
134
|
+
else if (msg.type === 'assistant' && block.type === 'tool_use' && typeof block.name === 'string') {
|
|
135
|
+
const item = toActivity(block.name, (block.input ?? {}), this.cwd);
|
|
136
|
+
if (!item)
|
|
137
|
+
continue;
|
|
138
|
+
if (typeof block.id === 'string')
|
|
139
|
+
this.pending.set(block.id, item);
|
|
140
|
+
else
|
|
141
|
+
out.push(item);
|
|
142
|
+
}
|
|
143
|
+
else if (msg.type === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
|
144
|
+
const item = this.pending.get(block.tool_use_id);
|
|
145
|
+
if (!item)
|
|
146
|
+
continue;
|
|
147
|
+
this.pending.delete(block.tool_use_id);
|
|
148
|
+
out.push({ ...item, ok: block.is_error !== true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
// 실행이 끝났다 — 결과를 못 받은 도구도 낸다.
|
|
154
|
+
finish() {
|
|
155
|
+
const rest = [...this.pending.values()];
|
|
156
|
+
this.pending.clear();
|
|
157
|
+
return rest;
|
|
158
|
+
}
|
|
93
159
|
}
|
|
94
160
|
// 활동을 모아 2초마다 한 번에 보낸다. 보내기 실패는 기록만 하고 넘어간다 — 룸 표시가 태스크 실행을 멈추면 안 된다.
|
|
95
161
|
export class ActivityReporter {
|
|
@@ -100,6 +166,8 @@ export class ActivityReporter {
|
|
|
100
166
|
timer = null;
|
|
101
167
|
sending = Promise.resolve();
|
|
102
168
|
warned = false;
|
|
169
|
+
// 옛 서버(020 전)는 say·성공 여부·줄 수를 400으로 거부한다 — 한 번 거부되면 그 실행 동안은 예전 형식(도구·대상만)으로 낮춰 보낸다.
|
|
170
|
+
legacy = false;
|
|
103
171
|
constructor(client, taskId, log, intervalMs = 2_000) {
|
|
104
172
|
this.client = client;
|
|
105
173
|
this.taskId = taskId;
|
|
@@ -115,7 +183,7 @@ export class ActivityReporter {
|
|
|
115
183
|
while (this.queue.length > 0) {
|
|
116
184
|
const batch = this.queue.splice(0, 50);
|
|
117
185
|
try {
|
|
118
|
-
await this.
|
|
186
|
+
await this.send(batch);
|
|
119
187
|
}
|
|
120
188
|
catch (err) {
|
|
121
189
|
if (!this.warned) {
|
|
@@ -127,6 +195,24 @@ export class ActivityReporter {
|
|
|
127
195
|
});
|
|
128
196
|
return this.sending;
|
|
129
197
|
}
|
|
198
|
+
async send(batch) {
|
|
199
|
+
if (this.legacy)
|
|
200
|
+
return this.sendLegacy(batch);
|
|
201
|
+
try {
|
|
202
|
+
await this.client.postActivity(this.taskId, batch);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (err.status !== 400)
|
|
206
|
+
throw err;
|
|
207
|
+
this.legacy = true;
|
|
208
|
+
await this.sendLegacy(batch);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async sendLegacy(batch) {
|
|
212
|
+
const items = batch.filter((i) => i.kind !== 'say').map((i) => ({ kind: i.kind, target: i.target }));
|
|
213
|
+
if (items.length > 0)
|
|
214
|
+
await this.client.postActivity(this.taskId, items);
|
|
215
|
+
}
|
|
130
216
|
async close() {
|
|
131
217
|
if (this.timer)
|
|
132
218
|
clearInterval(this.timer);
|
package/dist/executor/cli.js
CHANGED
|
@@ -25,14 +25,14 @@ import { ALLOWED_BASH_PREFIXES, buildMcpConfig } from '../bridge/claude-args.js'
|
|
|
25
25
|
import { prepareDependencies } from './setup.js';
|
|
26
26
|
import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
|
|
27
27
|
import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
|
|
28
|
-
import { ActivityReporter,
|
|
28
|
+
import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
|
|
29
29
|
import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
|
|
30
30
|
import { deviceLogin } from './device-login.js';
|
|
31
31
|
import { cliVersion, mcpServerPath } from './paths.js';
|
|
32
32
|
import { checkTools } from './preflight.js';
|
|
33
33
|
import { handleNextPmJob } from './pm-worker.js';
|
|
34
34
|
import { buildTaskPrompt } from './prompt.js';
|
|
35
|
-
import { runClaude } from './runner.js';
|
|
35
|
+
import { DEFAULT_IDLE_MS, runClaude } from './runner.js';
|
|
36
36
|
import { streamTaskSource } from './stream-source.js';
|
|
37
37
|
import { pollingTaskSource } from './task-source.js';
|
|
38
38
|
import { runLint, runSpecTests } from './verify.js';
|
|
@@ -128,6 +128,7 @@ async function handleTask(client, projectId, task) {
|
|
|
128
128
|
});
|
|
129
129
|
const reporter = runOpen ? new ActivityReporter(client, task.id, log) : null;
|
|
130
130
|
const observer = new RunObserver(workspace.dir);
|
|
131
|
+
const tracker = new ActivityTracker(workspace.dir);
|
|
131
132
|
// 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
|
|
132
133
|
const setup = await prepareDependencies(workspace.dir, log);
|
|
133
134
|
const before = headSha(workspace.dir);
|
|
@@ -140,15 +141,26 @@ async function handleTask(client, projectId, task) {
|
|
|
140
141
|
questionRelay: briefing.questionRelay === true,
|
|
141
142
|
onStdoutLine: (line) => {
|
|
142
143
|
observer.observe(line);
|
|
143
|
-
reporter?.push(
|
|
144
|
+
reporter?.push(tracker.observe(line));
|
|
144
145
|
},
|
|
145
146
|
});
|
|
146
147
|
const committed = headSha(workspace.dir) !== before;
|
|
147
148
|
if (reporter) {
|
|
149
|
+
reporter.push(tracker.finish());
|
|
148
150
|
await reporter.close();
|
|
149
151
|
// 제출했는지는 서버가 태스크 상태로 판단해 룸에 남긴다("끝났지만 제출하지 않았다"가 대표에게 보인다).
|
|
152
|
+
// 응답 없음(stalled)은 서버에 timeout으로 보고하고 사유 문장으로 구분한다 — 옛 서버는 outcome에 stalled를 모른다.
|
|
153
|
+
const summary = observer.summary();
|
|
154
|
+
const stalledNote = `[응답 없음] ${Math.round(DEFAULT_IDLE_MS / 60_000)}분 동안 모델 출력이 없어 Executor가 실행을 끊었다`;
|
|
150
155
|
await client
|
|
151
|
-
.endRun(task.id, {
|
|
156
|
+
.endRun(task.id, {
|
|
157
|
+
outcome: result.outcome === 'stalled' ? 'timeout' : result.outcome,
|
|
158
|
+
committed,
|
|
159
|
+
durationMs: result.durationMs,
|
|
160
|
+
exitCode: result.exitCode,
|
|
161
|
+
...summary,
|
|
162
|
+
...(result.outcome === 'stalled' ? { lastMessage: (summary.lastMessage ? `${stalledNote} — 마지막 말: ${summary.lastMessage}` : stalledNote).slice(0, 2000) } : {}),
|
|
163
|
+
})
|
|
152
164
|
.then((r) => {
|
|
153
165
|
if (r.blocked)
|
|
154
166
|
log('제출 없이 끝나 태스크가 멈춤(BLOCKED)으로 바뀌었다 — 대표가 원인을 해결하고 재개하면 다시 가져간다');
|
|
@@ -164,6 +176,9 @@ async function handleTask(client, projectId, task) {
|
|
|
164
176
|
else if (result.outcome === 'completed') {
|
|
165
177
|
log(`정상 종료했지만 커밋이 없다. 태스크는 그대로 둔다. 로그: ${result.logPath}`);
|
|
166
178
|
}
|
|
179
|
+
else if (result.outcome === 'stalled') {
|
|
180
|
+
log(`${Math.round(DEFAULT_IDLE_MS / 60_000)}분 동안 모델 출력이 없어 끊었다(응답 없음). 재시도하지 않는다. 로그: ${result.logPath}`);
|
|
181
|
+
}
|
|
167
182
|
else if (result.outcome === 'timeout') {
|
|
168
183
|
log(`30분 초과로 종료했다. 재시도하지 않는다. 로그: ${result.logPath}`);
|
|
169
184
|
}
|
package/dist/executor/consult.js
CHANGED
|
@@ -2,7 +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 { resolveClaudeCommand } from './runner.js';
|
|
5
|
+
import { claudeEnv, resolveClaudeCommand } from './runner.js';
|
|
6
6
|
// 상담 응답의 형식(구조화 출력). 모든 객체는 additionalProperties: false + 전 필드 required.
|
|
7
7
|
export const CONSULT_JSON_SCHEMA = {
|
|
8
8
|
type: 'object',
|
|
@@ -91,7 +91,7 @@ export async function runConsult(input) {
|
|
|
91
91
|
writeFileSync(emptyMcp, JSON.stringify({ mcpServers: {} }));
|
|
92
92
|
const { command, commandArgs } = resolveClaudeCommand(consultArgs(buildConsultPrompt(input), emptyMcp));
|
|
93
93
|
const out = await new Promise((resolve, reject) => {
|
|
94
|
-
const child = spawn(command, commandArgs, { cwd: input.dir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
94
|
+
const child = spawn(command, commandArgs, { cwd: input.dir, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
|
|
95
95
|
let stdout = '';
|
|
96
96
|
const timer = setTimeout(() => {
|
|
97
97
|
child.kill();
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { resolveClaudeCommand } from './runner.js';
|
|
5
|
+
import { claudeEnv, resolveClaudeCommand } from './runner.js';
|
|
6
6
|
import { removeQuietly } from './cleanup.js';
|
|
7
7
|
// 지침·프롬프트는 길어서(이전 초안이 붙는 수정 요청) 명령줄로 넘기지 않는다 — Windows 명령줄은 32k자에서 잘린다.
|
|
8
8
|
// 지침은 파일, 프롬프트는 stdin. 도구는 전부 끄고(--tools ""), 사용자 MCP·세션 기록도 쓰지 않는다.
|
|
@@ -54,7 +54,7 @@ const spawnClaude = (args, stdin, cwd) => {
|
|
|
54
54
|
const { command, commandArgs } = resolveClaudeCommand(args);
|
|
55
55
|
return new Promise((resolve, reject) => {
|
|
56
56
|
// shell을 쓰지 않는다 — 인자가 명령으로 해석되지 않게(runner.ts와 같은 이유).
|
|
57
|
-
const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
57
|
+
const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: claudeEnv() });
|
|
58
58
|
let stdout = '';
|
|
59
59
|
let stderr = '';
|
|
60
60
|
child.stdout.on('data', (c) => (stdout += c.toString()));
|
package/dist/executor/runner.js
CHANGED
|
@@ -3,6 +3,40 @@ import { existsSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { buildClaudeArgs } from '../bridge/claude-args.js';
|
|
5
5
|
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
6
|
+
// 모델 출력(stream-json)이 이만큼 없으면 응답이 끊긴 것으로 보고 끊는다. 30분을 다 기다리지 않고, 사유도 "오래 일함"과 구분된다.
|
|
7
|
+
// 실측: 도구 결과를 받은 뒤 다음 모델 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다(같은 조건 재실행은 정상).
|
|
8
|
+
// 정상적인 무출력 구간보다 길어야 한다 — 질문 답 대기(실행 안 3분)·시험 실행(Bash 기본 2분, 최대 10분)·긴 생각.
|
|
9
|
+
export const DEFAULT_IDLE_MS = 15 * 60 * 1000;
|
|
10
|
+
// Executor가 띄우는 claude에 넘기지 않는 환경변수 — 띄운 쪽 Claude Code 세션과 묶는 값들이다.
|
|
11
|
+
// Claude Code 안에서(예: "nomos connect 실행해 줘") CLI를 띄우면 이 값이 그대로 넘어가 태스크 실행이 그 대화 세션의
|
|
12
|
+
// 하위 세션이 된다(진입점이 claude-vscode로 기록되고, 부모 세션의 토큰 잔량 알림이 모델 맥락에 끼어들었다 — 실측).
|
|
13
|
+
// 인증·설정용 변수(CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_BEDROCK 등)는 건드리지 않는다.
|
|
14
|
+
export const SESSION_ENV_KEYS = [
|
|
15
|
+
'CLAUDECODE',
|
|
16
|
+
'CLAUDE_PID',
|
|
17
|
+
'CLAUDE_EFFORT',
|
|
18
|
+
'CLAUDE_AGENT_SDK_VERSION',
|
|
19
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
20
|
+
'CLAUDE_CODE_EXECPATH',
|
|
21
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
22
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
23
|
+
'CLAUDE_CODE_SESSION_ATTENDED',
|
|
24
|
+
'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
25
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
26
|
+
'CLAUDE_CODE_SSE_PORT',
|
|
27
|
+
'CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING',
|
|
28
|
+
'CLAUDE_CODE_ENABLE_TASKS',
|
|
29
|
+
'CLAUDE_CODE_EMIT_STARTUP_TIMING',
|
|
30
|
+
'CLAUDE_CODE_QUESTION_PREVIEW_FORMAT',
|
|
31
|
+
];
|
|
32
|
+
export function claudeEnv(base = process.env) {
|
|
33
|
+
const env = { ...base };
|
|
34
|
+
for (const key of Object.keys(env)) {
|
|
35
|
+
if (SESSION_ENV_KEYS.includes(key.toUpperCase()))
|
|
36
|
+
delete env[key];
|
|
37
|
+
}
|
|
38
|
+
return env;
|
|
39
|
+
}
|
|
6
40
|
// ⚠️ Windows에서 spawn('claude', args, { shell: true })를 쓰면 안 된다.
|
|
7
41
|
// shell:true는 인자를 이스케이프 없이 이어붙여서 따옴표·괄호가 든 프롬프트가 셸에 먹힌다
|
|
8
42
|
// (스파이크에서 도구 호출이 한 번도 안 일어났다). .cmd 셰이퍼는 shell 없이 못 돌리므로
|
|
@@ -28,7 +62,7 @@ export async function runClaude(input) {
|
|
|
28
62
|
...(input.model === undefined ? {} : { model: input.model }),
|
|
29
63
|
...(input.questionRelay === undefined ? {} : { questionRelay: input.questionRelay }),
|
|
30
64
|
});
|
|
31
|
-
const { command, commandArgs } = resolveClaudeCommand(args);
|
|
65
|
+
const { command, commandArgs } = input.command ?? resolveClaudeCommand(args);
|
|
32
66
|
const logPath = path.join(input.workspaceDir, '.nomos-run.log');
|
|
33
67
|
const startedAt = Date.now();
|
|
34
68
|
return new Promise((resolve) => {
|
|
@@ -36,7 +70,7 @@ export async function runClaude(input) {
|
|
|
36
70
|
const child = spawn(command, commandArgs, {
|
|
37
71
|
cwd: input.workspaceDir,
|
|
38
72
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
-
|
|
73
|
+
env: input.pathPrepend ? withPathPrepended(claudeEnv(), input.pathPrepend) : claudeEnv(),
|
|
40
74
|
});
|
|
41
75
|
const chunks = [];
|
|
42
76
|
const collect = (c) => chunks.push(c.toString());
|
|
@@ -56,7 +90,9 @@ export async function runClaude(input) {
|
|
|
56
90
|
}
|
|
57
91
|
}
|
|
58
92
|
};
|
|
93
|
+
let lastOutputAt = Date.now();
|
|
59
94
|
child.stdout.on('data', (c) => {
|
|
95
|
+
lastOutputAt = Date.now();
|
|
60
96
|
collect(c);
|
|
61
97
|
emitLines(c);
|
|
62
98
|
});
|
|
@@ -66,11 +102,23 @@ export async function runClaude(input) {
|
|
|
66
102
|
timedOut = true;
|
|
67
103
|
child.kill();
|
|
68
104
|
}, input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
105
|
+
// 출력 없는 시간 감지. 마지막 출력 시각만 보므로 도구가 오래 돌아도 그 사이 다른 출력이 있으면 끊지 않는다.
|
|
106
|
+
const idleMs = input.idleMs ?? DEFAULT_IDLE_MS;
|
|
107
|
+
let stalled = false;
|
|
108
|
+
const idleCheck = setInterval(() => {
|
|
109
|
+
if (Date.now() - lastOutputAt >= idleMs) {
|
|
110
|
+
stalled = true;
|
|
111
|
+
child.kill();
|
|
112
|
+
}
|
|
113
|
+
}, Math.min(30_000, Math.max(50, Math.floor(idleMs / 4))));
|
|
69
114
|
child.on('close', (code) => {
|
|
70
115
|
clearTimeout(timer);
|
|
71
|
-
|
|
116
|
+
clearInterval(idleCheck);
|
|
117
|
+
writeFileSync(logPath, stalled ? `${chunks.join('')}
|
|
118
|
+
[executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다
|
|
119
|
+
` : chunks.join(''));
|
|
72
120
|
resolve({
|
|
73
|
-
outcome: timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
|
|
121
|
+
outcome: stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
|
|
74
122
|
exitCode: code,
|
|
75
123
|
durationMs: Date.now() - startedAt,
|
|
76
124
|
logPath,
|
|
@@ -78,6 +126,7 @@ export async function runClaude(input) {
|
|
|
78
126
|
});
|
|
79
127
|
child.on('error', (err) => {
|
|
80
128
|
clearTimeout(timer);
|
|
129
|
+
clearInterval(idleCheck);
|
|
81
130
|
writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
|
|
82
131
|
resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
|
|
83
132
|
});
|