@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 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
- continue;
56
+ lastErr = err; // 5xx — 백오프 후 재시도
57
+ retryable = true;
58
+ } else {
59
+ throw err;
57
60
  }
58
- throw err;
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
- const child = spawn(CLAUDE_BIN, args, { cwd, env: process.env });
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) — 자식 셸까지 남지 않게 detached 옵션은 쓰지 않고 프로세스만. */
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
- child.kill('SIGTERM');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suseona/runner",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포를 자동 수행",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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
- currentBranch, ensureTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
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
- await this.flush();
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
- log(`event flush 실패(${e.message}) — ${events.length}건 유실`);
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(신규 세션). base_branch 를 서버에 보고. */
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 currentBranch(project.folder_path);
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, shell: true, env: process.env,
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.kill('SIGTERM');
250
+ if (cancelled()) killGroup(child, 'SIGTERM');
233
251
  }, 1000);
234
- const killer = setTimeout(() => child.kill('SIGKILL'), DEPLOY_TIMEOUT_S * 1000);
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', {