@suseona/runner 1.0.1 → 1.0.2
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/api.mjs +11 -7
- package/claude.mjs +17 -8
- package/git.mjs +19 -0
- package/package.json +1 -1
- package/runner.mjs +40 -8
package/api.mjs
CHANGED
|
@@ -37,10 +37,11 @@ export class ApiError extends Error {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** POST 래퍼 — 네트워크 오류·5xx 만 2회 재시도(4xx 는 즉시 throw). */
|
|
40
|
+
/** POST 래퍼 — 네트워크 오류·5xx 만 2회 재시도(4xx 는 즉시 throw). 재시도 전 백오프. */
|
|
41
41
|
export async function api(path, body = {}, { retries = 2 } = {}) {
|
|
42
42
|
let lastErr;
|
|
43
43
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
44
|
+
let retryable = false;
|
|
44
45
|
try {
|
|
45
46
|
const res = await fetch(`${base()}${path}`, {
|
|
46
47
|
method: 'POST',
|
|
@@ -52,17 +53,20 @@ export async function api(path, body = {}, { retries = 2 } = {}) {
|
|
|
52
53
|
if (!res.ok) {
|
|
53
54
|
const err = new ApiError(res.status, data.detail ?? data.error, path);
|
|
54
55
|
if (res.status >= 500 && attempt < retries) {
|
|
55
|
-
lastErr = err;
|
|
56
|
-
|
|
56
|
+
lastErr = err; // 5xx — 백오프 후 재시도
|
|
57
|
+
retryable = true;
|
|
58
|
+
} else {
|
|
59
|
+
throw err;
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
} else {
|
|
62
|
+
return data;
|
|
59
63
|
}
|
|
60
|
-
return data;
|
|
61
64
|
} catch (e) {
|
|
62
65
|
if (e instanceof ApiError) throw e;
|
|
63
|
-
lastErr = e; // 네트워크 오류 — 재시도
|
|
66
|
+
lastErr = e; // 네트워크 오류 — 백오프 후 재시도
|
|
67
|
+
retryable = true;
|
|
64
68
|
}
|
|
65
|
-
await sleep(500 * (attempt + 1));
|
|
69
|
+
if (retryable && attempt < retries) await sleep(500 * (attempt + 1));
|
|
66
70
|
}
|
|
67
71
|
throw lastErr;
|
|
68
72
|
}
|
package/claude.mjs
CHANGED
|
@@ -58,7 +58,11 @@ export function runClaude(opts) {
|
|
|
58
58
|
'--max-turns', String(maxTurns),
|
|
59
59
|
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
|
60
60
|
];
|
|
61
|
-
|
|
61
|
+
// detached(POSIX)=신규 프로세스 그룹 — 취소·타임아웃이 claude 가 띄운 도구 자식(bash 등)까지
|
|
62
|
+
// 종료시킨다. detached 로 파이프(stdio)는 그대로 동작한다.
|
|
63
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
64
|
+
cwd, env: process.env, detached: process.platform !== 'win32',
|
|
65
|
+
});
|
|
62
66
|
let settled = false;
|
|
63
67
|
let cancelled = false;
|
|
64
68
|
let timedOut = false;
|
|
@@ -170,14 +174,19 @@ export function runClaude(opts) {
|
|
|
170
174
|
});
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
/** 프로세스 그룹 종료(SIGTERM
|
|
177
|
+
/** 프로세스 그룹 종료(SIGTERM → 5s → SIGKILL) — claude 세션이 띄운 자식 프로세스까지. */
|
|
174
178
|
function killTree(child) {
|
|
179
|
+
const killGroup = (signal) => {
|
|
180
|
+
try {
|
|
181
|
+
if (process.platform === 'win32') {
|
|
182
|
+
if (child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
183
|
+
} else if (child.pid) {
|
|
184
|
+
process.kill(-child.pid, signal); // detached 그룹 전체
|
|
185
|
+
}
|
|
186
|
+
} catch { /* already dead */ }
|
|
187
|
+
};
|
|
175
188
|
try {
|
|
176
|
-
|
|
177
|
-
setTimeout(() =>
|
|
178
|
-
try {
|
|
179
|
-
child.kill('SIGKILL');
|
|
180
|
-
} catch { /* already dead */ }
|
|
181
|
-
}, 5000).unref?.();
|
|
189
|
+
killGroup('SIGTERM');
|
|
190
|
+
setTimeout(() => killGroup('SIGKILL'), 5000).unref?.();
|
|
182
191
|
} catch { /* already dead */ }
|
|
183
192
|
}
|
package/git.mjs
CHANGED
|
@@ -54,6 +54,25 @@ export async function currentBranch(cwd) {
|
|
|
54
54
|
return run(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** 이전 작업이 남긴 e2e-task/* 브랜치 위에서 시작했다면 저장소 기본 브랜치로 복귀한 뒤 그 이름 반환.
|
|
58
|
+
* 실패·취소된 작업은 HEAD 를 작업 브랜치에 남기는데, 그대로 두면 다음 작업의 base 가
|
|
59
|
+
* 검토되지 않은 이전 작업 코드 위에 쌓인다 — implement 전 반드시 탈출시킨다.
|
|
60
|
+
* 기본 브랜치는 origin/HEAD(symbolic-ref)에서 유도, 실패 시 'main' 폴백. */
|
|
61
|
+
export async function escapeTaskBranch(cwd) {
|
|
62
|
+
const branch = await currentBranch(cwd);
|
|
63
|
+
if (!branch.startsWith('e2e-task/')) return branch;
|
|
64
|
+
let base = 'main';
|
|
65
|
+
try {
|
|
66
|
+
const ref = await run(cwd, 'symbolic-ref', 'refs/remotes/origin/HEAD');
|
|
67
|
+
const derived = ref.replace(/^refs\/remotes\/origin\//, '');
|
|
68
|
+
if (derived && !derived.startsWith('e2e-task/')) base = derived;
|
|
69
|
+
} catch {
|
|
70
|
+
/* origin/HEAD 미설정 — main 폴백 */
|
|
71
|
+
}
|
|
72
|
+
await run(cwd, 'checkout', base);
|
|
73
|
+
return base;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export function taskBranchName(taskId) {
|
|
58
77
|
return `e2e-task/${String(taskId).slice(0, 8)}`;
|
|
59
78
|
}
|
package/package.json
CHANGED
package/runner.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { dirname, join } from 'node:path';
|
|
|
18
18
|
import { api, runnerIdFromToken, sleep } from './api.mjs';
|
|
19
19
|
import { parsePhaseMarker, probeClaude, runClaude } from './claude.mjs';
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
ensureTaskBranch, escapeTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
|
|
22
22
|
} from './git.mjs';
|
|
23
23
|
|
|
24
24
|
// runner/.env.local 자동 로드 — 이미 설정된 env 는 덮지 않는다(셀 export 우선).
|
|
@@ -108,7 +108,14 @@ class EventBuffer {
|
|
|
108
108
|
|
|
109
109
|
async stop() {
|
|
110
110
|
clearInterval(this.timer);
|
|
111
|
-
|
|
111
|
+
// 종결 보고 직전 최종 플러시 — 실패해도 버퍼가 남으므로 짧은 백오프로 몇 번 더 시도.
|
|
112
|
+
for (let i = 0; i < 3 && (this.events.length || this.taskMeta); i++) {
|
|
113
|
+
await this.flush();
|
|
114
|
+
if (this.events.length || this.taskMeta) await sleep(1000 * (i + 1));
|
|
115
|
+
}
|
|
116
|
+
if (this.events.length || this.taskMeta) {
|
|
117
|
+
log(`최종 flush 실패 — ${this.events.length}건·meta 유실(단계는 계속 진행)`);
|
|
118
|
+
}
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
push(stepId, kind, data) {
|
|
@@ -127,7 +134,12 @@ class EventBuffer {
|
|
|
127
134
|
});
|
|
128
135
|
mergeCancels(res);
|
|
129
136
|
} catch (e) {
|
|
130
|
-
|
|
137
|
+
// 전송 실패 — 버퍼 선두로 되돌려 다음 플러시(1s)가 재전송한다(재시도 백오프는 api.mjs).
|
|
138
|
+
// base_branch 등 meta 유실은 잘못된 브랜치 병합으로 이어지므로 절대 버리지 않는다.
|
|
139
|
+
this.events.unshift(...events);
|
|
140
|
+
if (taskMeta) this.taskMeta = taskMeta;
|
|
141
|
+
if (this.events.length > 2000) this.events.splice(0, this.events.length - 2000); // 무한 증식 방지
|
|
142
|
+
log(`event flush 실패(${e.message}) — ${this.events.length}건 재시도 대기`);
|
|
131
143
|
}
|
|
132
144
|
}
|
|
133
145
|
}
|
|
@@ -181,10 +193,11 @@ async function handleStep({ step, task, project }) {
|
|
|
181
193
|
}
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
/** implement — git 프리플라이트 → 작업 브랜치 → claude(신규 세션).
|
|
196
|
+
/** implement — git 프리플라이트 → (이전 작업 브랜치 탈출) → 작업 브랜치 → claude(신규 세션).
|
|
197
|
+
* base_branch 를 서버에 보고(유실 방지: EventBuffer 가 재전송한다). */
|
|
185
198
|
async function runImplement({ step, task, project, buf, cancelled }) {
|
|
186
199
|
await preflight(project.folder_path);
|
|
187
|
-
const base = await
|
|
200
|
+
const base = await escapeTaskBranch(project.folder_path);
|
|
188
201
|
const branch = await ensureTaskBranch(project.folder_path, task.id);
|
|
189
202
|
buf.taskMeta = { base_branch: base, branch };
|
|
190
203
|
log(`브랜치 준비: ${branch} (base ${base})`);
|
|
@@ -219,8 +232,13 @@ async function runDeploy({ step, task, project, buf, cancelled }) {
|
|
|
219
232
|
|
|
220
233
|
if (project.deploy_command) {
|
|
221
234
|
const output = await new Promise((resolve) => {
|
|
235
|
+
// detached(POSIX)=신규 프로세스 그룹 — 타임아웃·취소가 셸뿐 아니라 그 자식(wrangler 등)
|
|
236
|
+
// 까지 종료시킨다. 취소한 작업의 배포가 고아 프로세스로 완료되는 것을 막는다.
|
|
222
237
|
const child = spawn(project.deploy_command, {
|
|
223
|
-
cwd: project.folder_path,
|
|
238
|
+
cwd: project.folder_path,
|
|
239
|
+
shell: true,
|
|
240
|
+
env: process.env,
|
|
241
|
+
detached: process.platform !== 'win32',
|
|
224
242
|
});
|
|
225
243
|
let out = '';
|
|
226
244
|
const onData = (c) => {
|
|
@@ -229,9 +247,9 @@ async function runDeploy({ step, task, project, buf, cancelled }) {
|
|
|
229
247
|
child.stdout.on('data', onData);
|
|
230
248
|
child.stderr.on('data', onData);
|
|
231
249
|
const cancelTimer = setInterval(() => {
|
|
232
|
-
if (cancelled()) child
|
|
250
|
+
if (cancelled()) killGroup(child, 'SIGTERM');
|
|
233
251
|
}, 1000);
|
|
234
|
-
const killer = setTimeout(() => child
|
|
252
|
+
const killer = setTimeout(() => killGroup(child, 'SIGKILL'), DEPLOY_TIMEOUT_S * 1000);
|
|
235
253
|
child.on('close', (code) => {
|
|
236
254
|
clearInterval(cancelTimer);
|
|
237
255
|
clearTimeout(killer);
|
|
@@ -256,6 +274,20 @@ async function runDeploy({ step, task, project, buf, cancelled }) {
|
|
|
256
274
|
|
|
257
275
|
// ─── 메인 루프 ────────────────────────────────────────────────────────────────
|
|
258
276
|
|
|
277
|
+
/** 프로세스 그룹 종료 — detached 스폰한 자식의 그룹(pid 음수) 전체에 시그널.
|
|
278
|
+
* 셸(spawn shell:true)을 죽여도 실제 커맨드가 살아남는 것을 방지한다. */
|
|
279
|
+
function killGroup(child, signal) {
|
|
280
|
+
try {
|
|
281
|
+
if (process.platform === 'win32') {
|
|
282
|
+
if (child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
283
|
+
} else if (child.pid) {
|
|
284
|
+
process.kill(-child.pid, signal);
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
/* already dead */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
259
291
|
async function heartbeat() {
|
|
260
292
|
try {
|
|
261
293
|
const res = await api('/ai/e2e/agent/runner/heartbeat', {
|