@elyun/bylane 1.30.0 → 1.31.0

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/CLAUDE.md CHANGED
@@ -11,7 +11,7 @@ byLane — Claude Code용 프론트엔드 개발 자동화 하네스.
11
11
 
12
12
  ```bash
13
13
  npm install # 의존성 설치 + pre-commit 훅 자동 등록 (prepare 스크립트)
14
- npm test # 테스트 실행 (19개)
14
+ npm test # 테스트 실행 (43개)
15
15
  npm run monitor # 모니터 대시보드
16
16
  npm version minor # 마이너 버전 올리기 (커밋 + 태그 자동 생성)
17
17
  npm run release # npm 배포 (커밋/푸시 완료 후 실행)
@@ -38,16 +38,19 @@ npx @elyun/bylane memory read 123
38
38
  - `src/config.js` — `.bylane/bylane.json` 로드/저장/검증 (loadConfig, saveConfig, validateConfig, getAgentModel, DEFAULT_CONFIG)
39
39
  - `src/branch.js` — 브랜치명 패턴 엔진 (buildBranchName, buildBranchNameFromConfig)
40
40
  - `src/memory.js` — 이슈별 컨텍스트 메모리 유틸 (readIssueMemory, appendIssueMemory, listIssueMemories)
41
- - `src/cli.js` — npx 설치 CLI (install, loop, --symlink 옵션, 기존 파일 .bak 백업)
41
+ - `src/cli.js` — npx 설치 CLI (install, loop, --symlink 옵션, 기존 파일 .bak 백업, Stop 훅 등록)
42
42
  - `src/loop-utils.js` — 루프 공통 유틸 (killExistingLoop, tmux 세션 관리, createAbsoluteTimer, resolveLoopMode)
43
- - `src/review-loop.js` — review 요청 PR 폴러 `.bylane/state/review-queue.json` (절대시간 기반 폴링)
44
- - `src/respond-loop.js` — PR 리뷰/코멘트 폴러 `.bylane/state/respond-queue.json` (절대시간 기반 폴링)
43
+ - `src/queue-utils.js` — 상태 관리 유틸 (reconcileQueue, expireStaleItems, gcQueue, maintainQueue)
44
+ - `src/pipeline.js` — 파이프라인 상태 추적 (startPipeline, updatePipelineStep, cancelStalePipeline, blockDownstreamOfFailed)
45
+ - `src/review-loop.js` — review 요청 PR 폴러 → `.bylane/state/review-queue.json` (절대시간 기반 폴링, 큐 reconcile/GC 포함)
46
+ - `src/respond-loop.js` — 내 PR 리뷰/코멘트 폴러 → `.bylane/state/respond-queue.json` (절대시간 기반 폴링, 큐 reconcile/GC 포함)
45
47
  - `src/monitor/` — blessed 기반 TUI 대시보드 (2열 그리드, 1초 폴링, fullUnicode)
46
48
  - `skills/` — Claude Code 에이전트 skill 파일들
47
49
  - `hooks/` — 외부 이벤트 자동 감지 훅
48
50
  - `commands/` — `/bylane` 슬래시 커맨드 정의
49
51
  - `templates/review-template.md` — 리뷰 코멘트 기본 템플릿
50
52
  - `scripts/release.sh` — npm 배포 스크립트 (dirty 체크 + 테스트 + publish)
53
+ - `hooks/bylane-session-cleanup.js` — Stop 훅 (세션 종료 시 in_progress→cancelled, 루프/큐 상태 정리)
51
54
  - `.githooks/pre-commit` — 보안 검사 훅 (시크릿/민감파일/console.log)
52
55
 
53
56
  ## 에이전트 파이프라인
@@ -87,7 +90,7 @@ respond-loop (독립: 설정 주기로 리뷰 코멘트 감지, 절대시간
87
90
  ```json
88
91
  {
89
92
  "agent": "code-agent",
90
- "status": "in_progress | completed | failed | idle",
93
+ "status": "in_progress | completed | failed | cancelled | blocked | idle",
91
94
  "startedAt": "ISO8601",
92
95
  "progress": 0,
93
96
  "retries": 0,
@@ -95,6 +98,25 @@ respond-loop (독립: 설정 주기로 리뷰 코멘트 감지, 절대시간
95
98
  }
96
99
  ```
97
100
 
101
+ ### 큐 항목 상태 값
102
+
103
+ `pending` → `reviewing`/`responding` → `resolved` | `expired` (GC 후 제거)
104
+
105
+ 큐 reconcile: 매 poll마다 GitHub 상태와 대조하여 불필요한 pending 항목을 resolved로 전환.
106
+ TTL: 24시간 초과 pending → expired. GC: resolved/expired 후 1시간 경과 시 큐에서 제거.
107
+
108
+ ### 파이프라인 상태 (`pipeline.json`)
109
+
110
+ ```json
111
+ {
112
+ "agent": "pipeline",
113
+ "status": "in_progress | completed | failed | cancelled",
114
+ "pipelineType": "A | B | C_review | C_respond | D",
115
+ "currentStep": "code-agent",
116
+ "steps": [{ "agent": "code-agent", "status": "completed" }, ...]
117
+ }
118
+ ```
119
+
98
120
  ## GitHub 접근 방법
99
121
 
100
122
  `github.method`: `"auto"` (기본, MCP→CLI→API 순) | `"mcp"` | `"cli"` | `"api"`
@@ -114,9 +136,11 @@ respond-loop (독립: 설정 주기로 리뷰 코멘트 감지, 절대시간
114
136
 
115
137
  ```
116
138
  tests/
117
- ├── state.test.js — writeState, readState, clearState, listStates, appendLog
118
- ├── config.test.js — loadConfig, saveConfig, validateConfig, DEFAULT_CONFIG
119
- └── branch.test.js — buildBranchName (6개 패턴 케이스, 슬래시 엣지케이스 포함)
139
+ ├── state.test.js — writeState, readState, clearState, listStates, appendLog (6개)
140
+ ├── config.test.js — loadConfig, saveConfig, validateConfig, DEFAULT_CONFIG (7개)
141
+ ├── branch.test.js — buildBranchName (6개 패턴 케이스, 슬래시 엣지케이스 포함)
142
+ ├── queue-utils.test.js — reconcileQueue, expireStaleItems, gcQueue, maintainQueue (10개)
143
+ └── pipeline.test.js — startPipeline, updatePipelineStep, cancelStalePipeline, blockDownstreamOfFailed (14개)
120
144
  ```
121
145
 
122
146
  ## 주의사항
@@ -25,11 +25,16 @@ npx @elyun/bylane cleanup
25
25
 
26
26
  | 항목 | 동작 |
27
27
  |------|------|
28
+ | stale 파이프라인 | 30분 초과 파이프라인 cascade cancel (하위 에이전트 일괄 취소) |
29
+ | upstream 실패 | 파이프라인 내 실패 에이전트 하류 → `blocked` 처리 |
28
30
  | `.bylane/state/` 권한 | 디렉토리 755, 파일 644로 수정 |
29
31
  | 죽은 루프 프로세스 | PID 확인 → 없으면 `stopped`로 전환 |
30
32
  | 30분 초과 `in_progress` | `failed`로 초기화 |
31
33
  | `subagents.json` active | PID 없는 항목 제거 |
32
34
  | 큐의 `reviewing`/`responding` | `pending`으로 복구 (재처리 대기) |
35
+ | 큐 TTL 초과 pending | 24시간 초과 → `expired` 전환 |
36
+ | 큐 GC | resolved/expired 후 1시간 경과 항목 제거 |
37
+ | 루프-큐 상태 동기화 | 루프 미실행 시 큐 `stopped` 전환 |
33
38
 
34
39
  ## 완료
35
40
 
@@ -31,6 +31,41 @@ npx @elyun/bylane models
31
31
 
32
32
  ---
33
33
 
34
+ ## 파이프라인 상태 추적
35
+
36
+ 각 에이전트 호출 전후에 파이프라인 상태를 기록하여, 세션 종료나 에이전트 실패 시 자동 정리가 가능하게 한다.
37
+
38
+ ### 파이프라인 시작 (에이전트 호출 전)
39
+
40
+ ```bash
41
+ npx @elyun/bylane state write pipeline '{"status":"in_progress","pipelineType":"A","issueNumber":null,"startedAt":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","currentStep":"issue-agent","steps":[{"agent":"issue-agent","status":"pending"},{"agent":"code-agent","status":"pending"},{"agent":"test-agent","status":"pending"},{"agent":"commit-agent","status":"pending"},{"agent":"pr-agent","status":"pending"},{"agent":"review-agent","status":"pending"},{"agent":"notify-agent","status":"pending"}]}'
42
+ ```
43
+
44
+ ### 각 에이전트 시작/완료 시
45
+
46
+ ```bash
47
+ # 에이전트 시작 시 — 해당 step을 in_progress로
48
+ npx @elyun/bylane state write pipeline '현재 pipeline state에서 해당 step.status를 "in_progress"로 변경'
49
+
50
+ # 에이전트 완료 시 — 해당 step을 completed로, currentStep을 다음 단계로
51
+ npx @elyun/bylane state write pipeline '현재 pipeline state에서 해당 step.status를 "completed"로 변경, currentStep 갱신'
52
+ ```
53
+
54
+ ### 파이프라인 완료/실패 시
55
+
56
+ ```bash
57
+ # 성공
58
+ npx @elyun/bylane state write pipeline '{"status":"completed","completedAt":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'",...}'
59
+
60
+ # 실패 (maxRetries 초과 등)
61
+ npx @elyun/bylane state write pipeline '{"status":"failed","failedAt":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","reason":"...",...}'
62
+ ```
63
+
64
+ > **중요**: 파이프라인 상태가 30분간 업데이트되지 않으면 cleanup이 자동으로 cascade cancel한다.
65
+ > 세션 종료 시 Stop 훅이 in_progress 파이프라인과 하위 에이전트를 즉시 cancelled로 전환한다.
66
+
67
+ ---
68
+
34
69
  ## 의도 파싱 규칙
35
70
 
36
71
  ### 파이프라인 A — 새 기능 / 이슈 없는 구현 요청
@@ -94,9 +129,13 @@ npx @elyun/bylane models
94
129
 
95
130
  ### 각 에이전트 호출 방법
96
131
 
97
- Agent 도구에 아래 형식으로 전달한다:
132
+ Agent 도구에 아래 형식으로 전달한다. **호출 전후로 파이프라인 상태를 갱신**한다:
98
133
 
99
134
  ```
135
+ # 1. 호출 전: 파이프라인 step을 in_progress로 갱신
136
+ npx @elyun/bylane state write pipeline '{...현재 state에서 해당 step.status → "in_progress"}'
137
+
138
+ # 2. Agent 도구 호출
100
139
  subagent_type: "general-purpose"
101
140
  model: {models 명령으로 확인한 해당 에이전트 모델}
102
141
  prompt: |
@@ -109,6 +148,9 @@ prompt: |
109
148
 
110
149
  스킬 완료 후 .bylane/state/{에이전트명}.json 에 결과가 기록된다.
111
150
  결과 파일의 status, 핵심 출력 필드만 응답으로 돌려줘.
151
+
152
+ # 3. 호출 후: 파이프라인 step을 completed/failed로 갱신, currentStep을 다음 에이전트로
153
+ npx @elyun/bylane state write pipeline '{...현재 state에서 해당 step.status → "completed", currentStep → 다음}'
112
154
  ```
113
155
 
114
156
  ---
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bylane-session-cleanup.js
4
+ * Stop 훅 — Claude Code 세션 종료 시 상태 파일 자동 정리
5
+ *
6
+ * 처리 항목:
7
+ * - in_progress 에이전트 → cancelled (세션 종료)
8
+ * - running 루프 (PID 죽은 경우) → stopped
9
+ * - running 큐 (루프 죽은 경우) → stopped
10
+ * - pipeline in_progress → cancelled
11
+ */
12
+ import { readFileSync, readdirSync, writeFileSync, existsSync } from 'fs'
13
+
14
+ // stdin 읽기 (훅 프로토콜)
15
+ try { readFileSync('/dev/stdin', 'utf8') } catch {}
16
+
17
+ const STATE_DIR = '.bylane/state'
18
+ if (!existsSync(STATE_DIR)) process.exit(0)
19
+
20
+ const now = new Date().toISOString()
21
+
22
+ function isPidAlive(pid) {
23
+ try { process.kill(Number(pid), 0); return true } catch { return false }
24
+ }
25
+
26
+ function readJson(path) {
27
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
28
+ }
29
+
30
+ function writeJson(path, data) {
31
+ writeFileSync(path, JSON.stringify({ ...data, updatedAt: now }, null, 2))
32
+ }
33
+
34
+ try {
35
+ const files = readdirSync(STATE_DIR).filter(f => f.endsWith('.json'))
36
+ const { join } = await import('path')
37
+
38
+ for (const file of files) {
39
+ const name = file.replace('.json', '')
40
+ if (name === 'cancel' || name === 'subagents') continue
41
+
42
+ const path = join(STATE_DIR, file)
43
+ const state = readJson(path)
44
+ if (!state) continue
45
+
46
+ // 파이프라인: in_progress → cancelled + 하위 step도 cancelled
47
+ if (name === 'pipeline' && state.status === 'in_progress') {
48
+ writeJson(path, {
49
+ ...state,
50
+ status: 'cancelled',
51
+ cancelledAt: now,
52
+ reason: 'session_ended',
53
+ steps: (state.steps ?? []).map(step =>
54
+ step.status === 'in_progress' || step.status === 'pending'
55
+ ? { ...step, status: 'cancelled', cancelledAt: now }
56
+ : step
57
+ )
58
+ })
59
+ continue
60
+ }
61
+
62
+ // in_progress 에이전트 → cancelled
63
+ if (state.status === 'in_progress' && !name.endsWith('-loop') && !name.endsWith('-queue')) {
64
+ writeJson(path, {
65
+ ...state,
66
+ status: 'cancelled',
67
+ cancelledAt: now,
68
+ reason: 'session_ended'
69
+ })
70
+ continue
71
+ }
72
+
73
+ // running 루프: PID 죽었으면 stopped
74
+ if (name.endsWith('-loop') && state.status === 'running' && state.pid) {
75
+ if (!isPidAlive(state.pid)) {
76
+ writeJson(path, { ...state, status: 'stopped', stoppedAt: now })
77
+ }
78
+ continue
79
+ }
80
+
81
+ // running 큐: 대응 루프가 죽었으면 stopped
82
+ if (name.endsWith('-queue') && state.status === 'running') {
83
+ const loopName = name.replace('-queue', '-loop')
84
+ const loopPath = join(STATE_DIR, `${loopName}.json`)
85
+ const loopState = readJson(loopPath)
86
+ const loopAlive = loopState?.status === 'running' && loopState?.pid && isPidAlive(loopState.pid)
87
+ if (!loopAlive) {
88
+ writeJson(path, { ...state, status: 'stopped' })
89
+ }
90
+ }
91
+ }
92
+ } catch {}
93
+
94
+ process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyun/bylane",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Frontend development harness for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cleanup.js CHANGED
@@ -6,6 +6,8 @@
6
6
  import { readdirSync, chmodSync, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
7
7
  import { join } from 'path'
8
8
  import { readState, writeState } from './state.js'
9
+ import { gcQueue, expireStaleItems } from './queue-utils.js'
10
+ import { cancelStalePipeline, blockDownstreamOfFailed } from './pipeline.js'
9
11
 
10
12
  const STATE_DIR = '.bylane/state'
11
13
  const STALE_MS = 30 * 60 * 1000 // 30분 이상 in_progress → 초기화
@@ -73,6 +75,18 @@ export function runCleanup() {
73
75
  return result
74
76
  }
75
77
 
78
+ // 0. 파이프라인 cascade cancel — stale 파이프라인 감지 시 하위 에이전트 일괄 취소
79
+ const stalePipeline = cancelStalePipeline()
80
+ if (stalePipeline.pipelineCancelled) {
81
+ result.reset.push(`pipeline: stale → cancelled (${stalePipeline.cancelled.length}개 에이전트 취소: ${stalePipeline.cancelled.join(', ')})`)
82
+ }
83
+
84
+ // 0-1. 파이프라인 내 실패 에이전트 하류 → blocked
85
+ const downstream = blockDownstreamOfFailed()
86
+ if (downstream.blocked.length > 0) {
87
+ result.reset.push(`pipeline: upstream 실패 → ${downstream.blocked.length}개 에이전트 blocked (${downstream.blocked.join(', ')})`)
88
+ }
89
+
76
90
  // 1. 파일 권한 수정
77
91
  result.fixed.push(...fixPermissions())
78
92
 
@@ -82,8 +96,8 @@ export function runCleanup() {
82
96
  for (const file of files) {
83
97
  const name = file.replace('.json', '')
84
98
 
85
- // 특수 파일 건너뜀
86
- if (name === 'cancel') continue
99
+ // 특수 파일 건너뜀 (pipeline은 pipeline.js에서 별도 처리)
100
+ if (name === 'cancel' || name === 'pipeline') continue
87
101
 
88
102
  // subagents 별도 처리
89
103
  if (name === 'subagents') {
@@ -115,17 +129,53 @@ export function runCleanup() {
115
129
  }
116
130
  }
117
131
 
118
- // 큐 파일: responding/reviewing 상태가 남아있으면 pending으로 복구
132
+ // 큐 파일 종합 정리
119
133
  if ((name === 'review-queue' || name === 'respond-queue') && Array.isArray(state.queue)) {
120
- const fixed = state.queue.map(item =>
134
+ let queue = state.queue
135
+ let changed = false
136
+
137
+ // 1) responding/reviewing 상태 → pending 복구
138
+ const recovered = queue.map(item =>
121
139
  item.status === 'reviewing' || item.status === 'responding'
122
140
  ? { ...item, status: 'pending', recoveredAt: new Date().toISOString() }
123
141
  : item
124
142
  )
125
- const changedCount = fixed.filter((item, i) => item.status !== state.queue[i].status).length
126
- if (changedCount > 0) {
127
- writeState(name, { ...state, queue: fixed }, STATE_DIR)
128
- result.reset.push(`${name}: ${changedCount}개 진행중 항목 → pending 복구`)
143
+ const recoveredCount = recovered.filter((item, i) => item.status !== queue[i].status).length
144
+ if (recoveredCount > 0) {
145
+ queue = recovered
146
+ changed = true
147
+ result.reset.push(`${name}: ${recoveredCount}개 진행중 항목 → pending 복구`)
148
+ }
149
+
150
+ // 2) stale pending → expired (TTL 초과)
151
+ const expired = expireStaleItems(queue)
152
+ if (expired.expiredCount > 0) {
153
+ queue = expired.queue
154
+ changed = true
155
+ result.reset.push(`${name}: ${expired.expiredCount}개 pending 항목 → expired (TTL 초과)`)
156
+ }
157
+
158
+ // 3) resolved/expired 항목 GC (1시간 경과)
159
+ const gc = gcQueue(queue)
160
+ if (gc.removedCount > 0) {
161
+ queue = gc.queue
162
+ changed = true
163
+ result.cleared.push(`${name}: ${gc.removedCount}개 완료/만료 항목 GC 제거`)
164
+ }
165
+
166
+ // 4) 루프-큐 상태 동기화: 루프가 stopped인데 큐가 running이면 stopped로
167
+ const loopName = name.replace('-queue', '-loop')
168
+ const loopState = readState(loopName, STATE_DIR)
169
+ const loopDead = !loopState || loopState.status !== 'running' ||
170
+ (loopState.pid && !isPidAlive(loopState.pid))
171
+ if (state.status === 'running' && loopDead) {
172
+ changed = true
173
+ result.reset.push(`${name}: 루프 미실행 → 큐 상태 stopped`)
174
+ }
175
+
176
+ if (changed) {
177
+ const newStatus = (state.status === 'running' && loopDead) ? 'stopped' : state.status
178
+ writeState(name, { ...state, status: newStatus, queue }, STATE_DIR)
129
179
  }
130
180
  }
131
181
  }
package/src/cli.js CHANGED
@@ -49,11 +49,14 @@ function registerHooks() {
49
49
  }
50
50
 
51
51
  const hookScript = join(CLAUDE_DIR, 'hooks', 'bylane-agent-tracker.js')
52
+ const cleanupHookScript = join(CLAUDE_DIR, 'hooks', 'bylane-session-cleanup.js')
52
53
  settings.hooks = settings.hooks ?? {}
53
54
 
54
55
  // 기존 bylane 훅 제거 후 재등록 (버전 업 시 경로 변경 대응)
55
56
  const stripBylane = (arr) =>
56
- (arr ?? []).filter(h => !h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker')))
57
+ (arr ?? []).filter(h => !h.hooks?.some(hh =>
58
+ hh.command?.includes('bylane-agent-tracker') || hh.command?.includes('bylane-session-cleanup')
59
+ ))
57
60
 
58
61
  settings.hooks.PreToolUse = [
59
62
  ...stripBylane(settings.hooks.PreToolUse),
@@ -63,9 +66,14 @@ function registerHooks() {
63
66
  ...stripBylane(settings.hooks.PostToolUse),
64
67
  { matcher: 'Agent', hooks: [{ type: 'command', command: `node ${hookScript} post` }] }
65
68
  ]
69
+ settings.hooks.Stop = [
70
+ ...stripBylane(settings.hooks.Stop),
71
+ { matcher: '', hooks: [{ type: 'command', command: `node ${cleanupHookScript}` }] }
72
+ ]
66
73
 
67
74
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
68
- console.log(' ~ Hook: bylane-agent-tracker 등록 (최신 경로로 갱신)')
75
+ console.log(' ~ Hook: bylane-agent-tracker 등록 (PreToolUse + PostToolUse)')
76
+ console.log(' ~ Hook: bylane-session-cleanup 등록 (Stop — 세션 종료 시 상태 정리)')
69
77
  }
70
78
 
71
79
  function preservedConfigs() {
@@ -158,10 +166,13 @@ function uninstall() {
158
166
  try {
159
167
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
160
168
  const stripBylane = (arr) =>
161
- (arr ?? []).filter(h => !h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker')))
169
+ (arr ?? []).filter(h => !h.hooks?.some(hh =>
170
+ hh.command?.includes('bylane-agent-tracker') || hh.command?.includes('bylane-session-cleanup')
171
+ ))
162
172
  settings.hooks = settings.hooks ?? {}
163
173
  settings.hooks.PreToolUse = stripBylane(settings.hooks.PreToolUse)
164
174
  settings.hooks.PostToolUse = stripBylane(settings.hooks.PostToolUse)
175
+ settings.hooks.Stop = stripBylane(settings.hooks.Stop)
165
176
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
166
177
  console.log(' - Hook: bylane-agent-tracker 제거')
167
178
  } catch {}
@@ -267,6 +278,10 @@ if (command === 'install') {
267
278
  const sessionName = config.loop?.sessionName ?? 'bylane-loops'
268
279
 
269
280
  if (subCmd === 'start') {
281
+ // 시작 전 상태 정리
282
+ const { runCleanup } = await import('./cleanup.js')
283
+ runCleanup()
284
+
270
285
  const mode = resolveLoopMode()
271
286
 
272
287
  if (mode === 'tmux') {
@@ -330,6 +345,10 @@ if (command === 'install') {
330
345
  }
331
346
  }
332
347
 
348
+ // 종료 후 상태 정리 (큐 상태 동기화 포함)
349
+ const { runCleanup } = await import('./cleanup.js')
350
+ runCleanup()
351
+
333
352
  if (!stopped) {
334
353
  console.log(' 실행 중인 루프가 없습니다.')
335
354
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * pipeline.js
3
+ * 에이전트 파이프라인 상태 추적 및 cascade cancel
4
+ */
5
+ import { readState, writeState } from './state.js'
6
+
7
+ const STATE_DIR = '.bylane/state'
8
+
9
+ /** 파이프라인별 에이전트 실행 순서 */
10
+ export const PIPELINES = {
11
+ A: ['issue-agent', 'code-agent', 'test-agent', 'commit-agent', 'pr-agent', 'review-agent', 'notify-agent'],
12
+ B: ['code-agent', 'test-agent', 'commit-agent', 'pr-agent', 'review-agent', 'notify-agent'],
13
+ C_review: ['review-agent'],
14
+ C_respond: ['respond-agent'],
15
+ D: [] // 단일 에이전트, 동적 지정
16
+ }
17
+
18
+ /** 파이프라인 상태 기록 기본 TTL (30분) */
19
+ export const PIPELINE_STALE_MS = 30 * 60 * 1000
20
+
21
+ /**
22
+ * 파이프라인 시작 기록
23
+ * @param {{ type: string, issueNumber?: number, steps: string[] }} opts
24
+ */
25
+ export function startPipeline({ type, issueNumber, steps }) {
26
+ const now = new Date().toISOString()
27
+ writeState('pipeline', {
28
+ status: 'in_progress',
29
+ pipelineType: type,
30
+ issueNumber: issueNumber ?? null,
31
+ startedAt: now,
32
+ currentStep: steps[0] ?? null,
33
+ steps: steps.map(agent => ({ agent, status: 'pending' }))
34
+ }, STATE_DIR)
35
+ }
36
+
37
+ /**
38
+ * 현재 파이프라인 단계 업데이트
39
+ * @param {string} agentName
40
+ * @param {'in_progress'|'completed'|'failed'} status
41
+ */
42
+ export function updatePipelineStep(agentName, status) {
43
+ const pipeline = readState('pipeline', STATE_DIR)
44
+ if (!pipeline || pipeline.status !== 'in_progress') return
45
+
46
+ const steps = (pipeline.steps ?? []).map(step =>
47
+ step.agent === agentName
48
+ ? { ...step, status, [`${status}At`]: new Date().toISOString() }
49
+ : step
50
+ )
51
+
52
+ // 다음 단계 결정
53
+ const currentIdx = steps.findIndex(s => s.agent === agentName)
54
+ const nextStep = (status === 'completed' && currentIdx < steps.length - 1)
55
+ ? steps[currentIdx + 1].agent
56
+ : null
57
+
58
+ // 전체 완료 여부
59
+ const allDone = steps.every(s => s.status === 'completed')
60
+ const anyFailed = steps.some(s => s.status === 'failed')
61
+
62
+ writeState('pipeline', {
63
+ ...pipeline,
64
+ status: allDone ? 'completed' : anyFailed ? 'failed' : 'in_progress',
65
+ currentStep: nextStep ?? pipeline.currentStep,
66
+ steps
67
+ }, STATE_DIR)
68
+ }
69
+
70
+ /**
71
+ * 파이프라인 완료 처리
72
+ */
73
+ export function completePipeline() {
74
+ const pipeline = readState('pipeline', STATE_DIR)
75
+ if (!pipeline) return
76
+ writeState('pipeline', {
77
+ ...pipeline,
78
+ status: 'completed',
79
+ completedAt: new Date().toISOString()
80
+ }, STATE_DIR)
81
+ }
82
+
83
+ /**
84
+ * 활성 파이프라인 조회
85
+ * @returns {Object|null}
86
+ */
87
+ export function getActivePipeline() {
88
+ const pipeline = readState('pipeline', STATE_DIR)
89
+ if (!pipeline || pipeline.status !== 'in_progress') return null
90
+ return pipeline
91
+ }
92
+
93
+ /**
94
+ * stale 파이프라인 감지 및 cascade cancel.
95
+ * 파이프라인이 staleMs 동안 업데이트 없으면 하위 에이전트를 모두 cancelled 처리한다.
96
+ *
97
+ * @param {number} [staleMs=PIPELINE_STALE_MS]
98
+ * @returns {{ cancelled: string[], pipelineCancelled: boolean }}
99
+ */
100
+ export function cancelStalePipeline(staleMs = PIPELINE_STALE_MS) {
101
+ const pipeline = readState('pipeline', STATE_DIR)
102
+ if (!pipeline || pipeline.status !== 'in_progress') {
103
+ return { cancelled: [], pipelineCancelled: false }
104
+ }
105
+
106
+ const lastUpdate = pipeline.updatedAt || pipeline.startedAt
107
+ const age = Date.now() - new Date(lastUpdate).getTime()
108
+ if (age < staleMs) {
109
+ return { cancelled: [], pipelineCancelled: false }
110
+ }
111
+
112
+ const now = new Date().toISOString()
113
+ const cancelled = []
114
+
115
+ // 파이프라인 소속 에이전트 중 in_progress/pending → cancelled
116
+ for (const step of pipeline.steps ?? []) {
117
+ if (step.status === 'in_progress' || step.status === 'pending') {
118
+ const agentState = readState(step.agent, STATE_DIR)
119
+ if (agentState && (agentState.status === 'in_progress' || agentState.status === 'idle')) {
120
+ writeState(step.agent, {
121
+ ...agentState,
122
+ status: 'cancelled',
123
+ cancelledAt: now,
124
+ reason: 'pipeline_stale'
125
+ }, STATE_DIR)
126
+ cancelled.push(step.agent)
127
+ }
128
+ }
129
+ }
130
+
131
+ // 파이프라인 자체도 cancelled
132
+ writeState('pipeline', {
133
+ ...pipeline,
134
+ status: 'cancelled',
135
+ cancelledAt: now,
136
+ reason: `${Math.floor(age / 60000)}분간 업데이트 없음`,
137
+ steps: (pipeline.steps ?? []).map(step =>
138
+ step.status === 'in_progress' || step.status === 'pending'
139
+ ? { ...step, status: 'cancelled', cancelledAt: now }
140
+ : step
141
+ )
142
+ }, STATE_DIR)
143
+
144
+ return { cancelled, pipelineCancelled: true }
145
+ }
146
+
147
+ /**
148
+ * 파이프라인에서 실패한 에이전트의 하류 에이전트를 blocked 처리한다.
149
+ * (cleanup에서 호출)
150
+ *
151
+ * @returns {{ blocked: string[] }}
152
+ */
153
+ export function blockDownstreamOfFailed() {
154
+ const pipeline = readState('pipeline', STATE_DIR)
155
+ if (!pipeline || pipeline.status !== 'in_progress') {
156
+ return { blocked: [] }
157
+ }
158
+
159
+ const steps = pipeline.steps ?? []
160
+ const blocked = []
161
+ let foundFailed = false
162
+
163
+ for (const step of steps) {
164
+ if (step.status === 'failed') {
165
+ foundFailed = true
166
+ continue
167
+ }
168
+ // 실패 에이전트 이후의 pending 단계 → blocked
169
+ if (foundFailed && step.status === 'pending') {
170
+ const agentState = readState(step.agent, STATE_DIR)
171
+ if (!agentState || agentState.status === 'idle' || !agentState.status) {
172
+ writeState(step.agent, {
173
+ agent: step.agent,
174
+ status: 'blocked',
175
+ blockedAt: new Date().toISOString(),
176
+ reason: 'upstream_failed'
177
+ }, STATE_DIR)
178
+ }
179
+ blocked.push(step.agent)
180
+ }
181
+ }
182
+
183
+ if (blocked.length > 0) {
184
+ writeState('pipeline', {
185
+ ...pipeline,
186
+ steps: steps.map(step =>
187
+ blocked.includes(step.agent)
188
+ ? { ...step, status: 'blocked', blockedAt: new Date().toISOString() }
189
+ : step
190
+ )
191
+ }, STATE_DIR)
192
+ }
193
+
194
+ return { blocked }
195
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * queue-utils.js
3
+ * 큐 상태 관리 공용 유틸 — reconcile, expire, GC
4
+ */
5
+
6
+ /** 큐 항목 TTL: 24시간 이상 pending이면 expired */
7
+ export const QUEUE_TTL_MS = 24 * 60 * 60 * 1000
8
+
9
+ /** GC 대상: resolved/expired 후 1시간 경과 시 제거 */
10
+ export const GC_AGE_MS = 60 * 60 * 1000
11
+
12
+ /**
13
+ * 현재 GitHub에서 가져온 PR 번호 목록과 큐를 대조하여
14
+ * 더 이상 액션이 필요 없는 항목을 resolved로 전환한다.
15
+ *
16
+ * @param {Array} queue 기존 큐 배열
17
+ * @param {Set<number>} activePrNumbers 현재 액션 필요한 PR 번호 Set
18
+ * @returns {{ queue: Array, resolvedCount: number }}
19
+ */
20
+ export function reconcileQueue(queue, activePrNumbers) {
21
+ let resolvedCount = 0
22
+ const now = new Date().toISOString()
23
+
24
+ const result = queue.map(item => {
25
+ // pending인데 현재 GitHub에서 더 이상 해당 PR이 액션 필요 목록에 없음
26
+ if (item.status === 'pending' && !activePrNumbers.has(item.number)) {
27
+ resolvedCount++
28
+ return { ...item, status: 'resolved', resolvedAt: now, reason: 'no_longer_actionable' }
29
+ }
30
+ return item
31
+ })
32
+
33
+ return { queue: result, resolvedCount }
34
+ }
35
+
36
+ /**
37
+ * TTL 초과된 pending 항목을 expired로 전환한다.
38
+ *
39
+ * @param {Array} queue 큐 배열
40
+ * @param {number} [ttlMs=QUEUE_TTL_MS] TTL (밀리초)
41
+ * @returns {{ queue: Array, expiredCount: number }}
42
+ */
43
+ export function expireStaleItems(queue, ttlMs = QUEUE_TTL_MS) {
44
+ const now = Date.now()
45
+ let expiredCount = 0
46
+
47
+ const result = queue.map(item => {
48
+ if (item.status === 'pending' && item.detectedAt) {
49
+ const age = now - new Date(item.detectedAt).getTime()
50
+ if (age > ttlMs) {
51
+ expiredCount++
52
+ return { ...item, status: 'expired', expiredAt: new Date().toISOString() }
53
+ }
54
+ }
55
+ return item
56
+ })
57
+
58
+ return { queue: result, expiredCount }
59
+ }
60
+
61
+ /**
62
+ * resolved/expired 항목 중 일정 시간이 지난 것을 큐에서 제거한다.
63
+ *
64
+ * @param {Array} queue 큐 배열
65
+ * @param {number} [gcAgeMs=GC_AGE_MS] GC 기준 시간 (밀리초)
66
+ * @returns {{ queue: Array, removedCount: number }}
67
+ */
68
+ export function gcQueue(queue, gcAgeMs = GC_AGE_MS) {
69
+ const now = Date.now()
70
+ const before = queue.length
71
+
72
+ const result = queue.filter(item => {
73
+ if (item.status === 'resolved' || item.status === 'expired') {
74
+ const ts = item.resolvedAt || item.expiredAt
75
+ if (ts && now - new Date(ts).getTime() > gcAgeMs) {
76
+ return false
77
+ }
78
+ }
79
+ return true
80
+ })
81
+
82
+ return { queue: result, removedCount: before - result.length }
83
+ }
84
+
85
+ /**
86
+ * reconcile + expire + GC 를 한 번에 실행한다.
87
+ *
88
+ * @param {Array} queue 기존 큐 배열
89
+ * @param {Set<number>|null} activePrNumbers 현재 액션 필요한 PR 번호 Set (null이면 reconcile 생략)
90
+ * @param {{ ttlMs?: number, gcAgeMs?: number }} [opts]
91
+ * @returns {{ queue: Array, resolvedCount: number, expiredCount: number, removedCount: number }}
92
+ */
93
+ export function maintainQueue(queue, activePrNumbers, opts = {}) {
94
+ const { ttlMs = QUEUE_TTL_MS, gcAgeMs = GC_AGE_MS } = opts
95
+
96
+ let resolvedCount = 0
97
+ let expiredCount = 0
98
+ let removedCount = 0
99
+ let current = queue
100
+
101
+ // 1. Reconcile (activePrNumbers가 있을 때만)
102
+ if (activePrNumbers) {
103
+ const r = reconcileQueue(current, activePrNumbers)
104
+ current = r.queue
105
+ resolvedCount = r.resolvedCount
106
+ }
107
+
108
+ // 2. Expire stale pending items
109
+ const e = expireStaleItems(current, ttlMs)
110
+ current = e.queue
111
+ expiredCount = e.expiredCount
112
+
113
+ // 3. GC resolved/expired items
114
+ const g = gcQueue(current, gcAgeMs)
115
+ current = g.queue
116
+ removedCount = g.removedCount
117
+
118
+ return { queue: current, resolvedCount, expiredCount, removedCount }
119
+ }
@@ -9,6 +9,7 @@ import { mkdirSync } from 'fs'
9
9
  import { writeState, readState, appendLog } from './state.js'
10
10
  import { loadConfig } from './config.js'
11
11
  import { killExistingLoop, createAbsoluteTimer } from './loop-utils.js'
12
+ import { maintainQueue } from './queue-utils.js'
12
13
 
13
14
  const config = loadConfig()
14
15
  const INTERVAL_MS = config.loop?.intervalMs ?? 300000
@@ -162,7 +163,21 @@ async function poll() {
162
163
  }
163
164
  }
164
165
 
165
- saveQueue(Object.values(queueMap))
166
+ // 큐 유지보수: reconcile + expire + GC
167
+ const activePrNumbers = new Set(prs.map(pr => pr.number))
168
+ const maintained = maintainQueue(Object.values(queueMap), activePrNumbers)
169
+
170
+ if (maintained.resolvedCount > 0) {
171
+ appendLog('respond-loop', `${maintained.resolvedCount}개 항목 resolved (더 이상 액션 불필요)`, STATE_DIR)
172
+ }
173
+ if (maintained.expiredCount > 0) {
174
+ appendLog('respond-loop', `${maintained.expiredCount}개 항목 expired (TTL 초과)`, STATE_DIR)
175
+ }
176
+ if (maintained.removedCount > 0) {
177
+ appendLog('respond-loop', `${maintained.removedCount}개 완료 항목 GC 제거`, STATE_DIR)
178
+ }
179
+
180
+ saveQueue(maintained.queue)
166
181
 
167
182
  if (newCount > 0) {
168
183
  appendLog('respond-loop', `${newCount}개 PR이 큐에 추가됨`, STATE_DIR)
@@ -180,7 +195,14 @@ const { stop } = createAbsoluteTimer(poll, INTERVAL_MS)
180
195
  // 종료 처리
181
196
  function shutdown() {
182
197
  stop()
183
- writeState('respond-loop', { status: 'stopped' }, STATE_DIR)
198
+ writeState('respond-loop', { status: 'stopped', stoppedAt: new Date().toISOString() }, STATE_DIR)
199
+
200
+ // 큐 상태도 stopped로 전환
201
+ const queueState = readState('respond-queue', STATE_DIR)
202
+ if (queueState) {
203
+ writeState('respond-queue', { ...queueState, status: 'stopped' }, STATE_DIR)
204
+ }
205
+
184
206
  process.exit(0)
185
207
  }
186
208
  process.on('SIGINT', shutdown)
@@ -8,6 +8,7 @@ import { mkdirSync } from 'fs'
8
8
  import { writeState, readState, appendLog } from './state.js'
9
9
  import { loadConfig } from './config.js'
10
10
  import { killExistingLoop, createAbsoluteTimer } from './loop-utils.js'
11
+ import { maintainQueue } from './queue-utils.js'
11
12
 
12
13
  const config = loadConfig()
13
14
  const INTERVAL_MS = config.loop?.intervalMs ?? 300000
@@ -109,7 +110,21 @@ async function poll() {
109
110
  }
110
111
  }
111
112
 
112
- saveQueue(Object.values(queueMap))
113
+ // 큐 유지보수: reconcile + expire + GC
114
+ const activePrNumbers = new Set(prs.map(pr => pr.number))
115
+ const maintained = maintainQueue(Object.values(queueMap), activePrNumbers)
116
+
117
+ if (maintained.resolvedCount > 0) {
118
+ appendLog('review-loop', `${maintained.resolvedCount}개 항목 resolved (더 이상 리뷰 요청 없음)`, STATE_DIR)
119
+ }
120
+ if (maintained.expiredCount > 0) {
121
+ appendLog('review-loop', `${maintained.expiredCount}개 항목 expired (TTL 초과)`, STATE_DIR)
122
+ }
123
+ if (maintained.removedCount > 0) {
124
+ appendLog('review-loop', `${maintained.removedCount}개 완료 항목 GC 제거`, STATE_DIR)
125
+ }
126
+
127
+ saveQueue(maintained.queue)
113
128
 
114
129
  if (newCount > 0) {
115
130
  appendLog('review-loop', `${newCount}개 PR이 큐에 추가됨`, STATE_DIR)
@@ -127,7 +142,14 @@ const { stop } = createAbsoluteTimer(poll, INTERVAL_MS)
127
142
  // 종료 처리
128
143
  function shutdown() {
129
144
  stop()
130
- writeState('review-loop', { status: 'stopped' }, STATE_DIR)
145
+ writeState('review-loop', { status: 'stopped', stoppedAt: new Date().toISOString() }, STATE_DIR)
146
+
147
+ // 큐 상태도 stopped로 전환
148
+ const queueState = readState('review-queue', STATE_DIR)
149
+ if (queueState) {
150
+ writeState('review-queue', { ...queueState, status: 'stopped' }, STATE_DIR)
151
+ }
152
+
131
153
  process.exit(0)
132
154
  }
133
155
  process.on('SIGINT', shutdown)
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdirSync, rmSync, writeFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { writeState, readState } from '../src/state.js'
5
+ import {
6
+ startPipeline, updatePipelineStep, completePipeline,
7
+ getActivePipeline, cancelStalePipeline, blockDownstreamOfFailed,
8
+ PIPELINES
9
+ } from '../src/pipeline.js'
10
+
11
+ const STATE_DIR = '.bylane/state'
12
+
13
+ beforeEach(() => mkdirSync(STATE_DIR, { recursive: true }))
14
+ afterEach(() => rmSync('.bylane', { recursive: true, force: true }))
15
+
16
+ describe('startPipeline', () => {
17
+ it('파이프라인 상태를 기록한다', () => {
18
+ startPipeline({ type: 'A', issueNumber: 42, steps: PIPELINES.A })
19
+ const state = readState('pipeline', STATE_DIR)
20
+
21
+ expect(state.status).toBe('in_progress')
22
+ expect(state.pipelineType).toBe('A')
23
+ expect(state.issueNumber).toBe(42)
24
+ expect(state.currentStep).toBe('issue-agent')
25
+ expect(state.steps).toHaveLength(7)
26
+ expect(state.steps[0]).toMatchObject({ agent: 'issue-agent', status: 'pending' })
27
+ })
28
+ })
29
+
30
+ describe('updatePipelineStep', () => {
31
+ it('에이전트 상태를 업데이트하고 다음 단계로 이동한다', () => {
32
+ startPipeline({ type: 'B', steps: ['code-agent', 'test-agent', 'commit-agent'] })
33
+
34
+ updatePipelineStep('code-agent', 'in_progress')
35
+ let state = readState('pipeline', STATE_DIR)
36
+ expect(state.steps[0].status).toBe('in_progress')
37
+
38
+ updatePipelineStep('code-agent', 'completed')
39
+ state = readState('pipeline', STATE_DIR)
40
+ expect(state.steps[0].status).toBe('completed')
41
+ expect(state.currentStep).toBe('test-agent')
42
+ expect(state.status).toBe('in_progress')
43
+ })
44
+
45
+ it('마지막 에이전트 완료 시 파이프라인도 completed', () => {
46
+ startPipeline({ type: 'D', steps: ['review-agent'] })
47
+ updatePipelineStep('review-agent', 'completed')
48
+ const state = readState('pipeline', STATE_DIR)
49
+ expect(state.status).toBe('completed')
50
+ })
51
+
52
+ it('에이전트 실패 시 파이프라인 상태가 failed', () => {
53
+ startPipeline({ type: 'B', steps: ['code-agent', 'test-agent'] })
54
+ updatePipelineStep('code-agent', 'failed')
55
+ const state = readState('pipeline', STATE_DIR)
56
+ expect(state.status).toBe('failed')
57
+ })
58
+
59
+ it('파이프라인이 없으면 무시한다', () => {
60
+ // 에러 없이 실행되어야 함
61
+ updatePipelineStep('code-agent', 'completed')
62
+ })
63
+ })
64
+
65
+ describe('completePipeline', () => {
66
+ it('파이프라인을 완료 처리한다', () => {
67
+ startPipeline({ type: 'A', steps: ['issue-agent'] })
68
+ completePipeline()
69
+ const state = readState('pipeline', STATE_DIR)
70
+ expect(state.status).toBe('completed')
71
+ expect(state.completedAt).toBeTruthy()
72
+ })
73
+ })
74
+
75
+ describe('getActivePipeline', () => {
76
+ it('in_progress 파이프라인을 반환한다', () => {
77
+ startPipeline({ type: 'A', steps: ['issue-agent'] })
78
+ expect(getActivePipeline()).toBeTruthy()
79
+ })
80
+
81
+ it('completed 파이프라인은 null', () => {
82
+ startPipeline({ type: 'A', steps: ['issue-agent'] })
83
+ completePipeline()
84
+ expect(getActivePipeline()).toBeNull()
85
+ })
86
+
87
+ it('파이프라인 없으면 null', () => {
88
+ expect(getActivePipeline()).toBeNull()
89
+ })
90
+ })
91
+
92
+ describe('cancelStalePipeline', () => {
93
+ it('stale 파이프라인의 에이전트를 cascade cancel한다', () => {
94
+ // 30분 전에 시작된 파이프라인 시뮬레이션 — writeState는 updatedAt을 현재로 덮어쓰므로 직접 작성
95
+ const oldTime = new Date(Date.now() - 31 * 60 * 1000).toISOString()
96
+ writeFileSync(join(STATE_DIR, 'pipeline.json'), JSON.stringify({
97
+ agent: 'pipeline',
98
+ status: 'in_progress',
99
+ pipelineType: 'B',
100
+ startedAt: oldTime,
101
+ updatedAt: oldTime,
102
+ currentStep: 'code-agent',
103
+ steps: [
104
+ { agent: 'code-agent', status: 'in_progress' },
105
+ { agent: 'test-agent', status: 'pending' }
106
+ ],
107
+ log: []
108
+ }, null, 2))
109
+ // 에이전트 상태도 생성
110
+ writeState('code-agent', { status: 'in_progress', startedAt: oldTime }, STATE_DIR)
111
+
112
+ const result = cancelStalePipeline()
113
+
114
+ expect(result.pipelineCancelled).toBe(true)
115
+ expect(result.cancelled).toContain('code-agent')
116
+
117
+ const codeState = readState('code-agent', STATE_DIR)
118
+ expect(codeState.status).toBe('cancelled')
119
+ expect(codeState.reason).toBe('pipeline_stale')
120
+
121
+ const pipelineState = readState('pipeline', STATE_DIR)
122
+ expect(pipelineState.status).toBe('cancelled')
123
+ })
124
+
125
+ it('fresh 파이프라인은 건드리지 않는다', () => {
126
+ startPipeline({ type: 'A', steps: ['issue-agent'] })
127
+ const result = cancelStalePipeline()
128
+ expect(result.pipelineCancelled).toBe(false)
129
+ expect(readState('pipeline', STATE_DIR).status).toBe('in_progress')
130
+ })
131
+
132
+ it('파이프라인이 없으면 아무것도 안 한다', () => {
133
+ const result = cancelStalePipeline()
134
+ expect(result.cancelled).toEqual([])
135
+ })
136
+ })
137
+
138
+ describe('blockDownstreamOfFailed', () => {
139
+ it('실패 에이전트 하류를 blocked 처리한다', () => {
140
+ writeState('pipeline', {
141
+ status: 'in_progress',
142
+ steps: [
143
+ { agent: 'code-agent', status: 'completed' },
144
+ { agent: 'test-agent', status: 'failed' },
145
+ { agent: 'commit-agent', status: 'pending' },
146
+ { agent: 'pr-agent', status: 'pending' }
147
+ ]
148
+ }, STATE_DIR)
149
+
150
+ const result = blockDownstreamOfFailed()
151
+
152
+ expect(result.blocked).toEqual(['commit-agent', 'pr-agent'])
153
+
154
+ const commitState = readState('commit-agent', STATE_DIR)
155
+ expect(commitState.status).toBe('blocked')
156
+ expect(commitState.reason).toBe('upstream_failed')
157
+ })
158
+
159
+ it('실패 없으면 아무것도 안 한다', () => {
160
+ writeState('pipeline', {
161
+ status: 'in_progress',
162
+ steps: [
163
+ { agent: 'code-agent', status: 'completed' },
164
+ { agent: 'test-agent', status: 'in_progress' }
165
+ ]
166
+ }, STATE_DIR)
167
+
168
+ const result = blockDownstreamOfFailed()
169
+ expect(result.blocked).toEqual([])
170
+ })
171
+ })
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { reconcileQueue, expireStaleItems, gcQueue, maintainQueue, QUEUE_TTL_MS, GC_AGE_MS } from '../src/queue-utils.js'
3
+
4
+ describe('reconcileQueue', () => {
5
+ it('활성 PR 목록에 없는 pending 항목을 resolved로 전환한다', () => {
6
+ const queue = [
7
+ { number: 1, status: 'pending', detectedAt: new Date().toISOString() },
8
+ { number: 2, status: 'pending', detectedAt: new Date().toISOString() },
9
+ { number: 3, status: 'responded', detectedAt: new Date().toISOString() }
10
+ ]
11
+ const activePrs = new Set([2])
12
+ const { queue: result, resolvedCount } = reconcileQueue(queue, activePrs)
13
+
14
+ expect(resolvedCount).toBe(1)
15
+ expect(result[0].status).toBe('resolved')
16
+ expect(result[0].reason).toBe('no_longer_actionable')
17
+ expect(result[0].resolvedAt).toBeTruthy()
18
+ expect(result[1].status).toBe('pending')
19
+ // responded 항목은 건드리지 않음
20
+ expect(result[2].status).toBe('responded')
21
+ })
22
+
23
+ it('모든 항목이 활성이면 변경 없음', () => {
24
+ const queue = [
25
+ { number: 1, status: 'pending' },
26
+ { number: 2, status: 'pending' }
27
+ ]
28
+ const { queue: result, resolvedCount } = reconcileQueue(queue, new Set([1, 2]))
29
+ expect(resolvedCount).toBe(0)
30
+ expect(result[0].status).toBe('pending')
31
+ expect(result[1].status).toBe('pending')
32
+ })
33
+
34
+ it('빈 큐에서 동작한다', () => {
35
+ const { queue, resolvedCount } = reconcileQueue([], new Set([1]))
36
+ expect(queue).toEqual([])
37
+ expect(resolvedCount).toBe(0)
38
+ })
39
+ })
40
+
41
+ describe('expireStaleItems', () => {
42
+ it('TTL 초과 pending 항목을 expired로 전환한다', () => {
43
+ const old = new Date(Date.now() - QUEUE_TTL_MS - 1000).toISOString()
44
+ const fresh = new Date().toISOString()
45
+ const queue = [
46
+ { number: 1, status: 'pending', detectedAt: old },
47
+ { number: 2, status: 'pending', detectedAt: fresh },
48
+ { number: 3, status: 'resolved', detectedAt: old }
49
+ ]
50
+ const { queue: result, expiredCount } = expireStaleItems(queue)
51
+
52
+ expect(expiredCount).toBe(1)
53
+ expect(result[0].status).toBe('expired')
54
+ expect(result[0].expiredAt).toBeTruthy()
55
+ expect(result[1].status).toBe('pending')
56
+ // resolved는 건드리지 않음
57
+ expect(result[2].status).toBe('resolved')
58
+ })
59
+
60
+ it('커스텀 TTL을 적용할 수 있다', () => {
61
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()
62
+ const queue = [{ number: 1, status: 'pending', detectedAt: twoHoursAgo }]
63
+ const { expiredCount } = expireStaleItems(queue, 1 * 60 * 60 * 1000)
64
+ expect(expiredCount).toBe(1)
65
+ })
66
+
67
+ it('detectedAt 없는 항목은 건너뛴다', () => {
68
+ const queue = [{ number: 1, status: 'pending' }]
69
+ const { expiredCount } = expireStaleItems(queue)
70
+ expect(expiredCount).toBe(0)
71
+ })
72
+ })
73
+
74
+ describe('gcQueue', () => {
75
+ it('GC 기준 초과한 resolved/expired 항목을 제거한다', () => {
76
+ const oldTs = new Date(Date.now() - GC_AGE_MS - 1000).toISOString()
77
+ const freshTs = new Date().toISOString()
78
+ const queue = [
79
+ { number: 1, status: 'resolved', resolvedAt: oldTs },
80
+ { number: 2, status: 'expired', expiredAt: oldTs },
81
+ { number: 3, status: 'resolved', resolvedAt: freshTs },
82
+ { number: 4, status: 'pending', detectedAt: oldTs }
83
+ ]
84
+ const { queue: result, removedCount } = gcQueue(queue)
85
+
86
+ expect(removedCount).toBe(2)
87
+ expect(result).toHaveLength(2)
88
+ expect(result[0].number).toBe(3)
89
+ expect(result[1].number).toBe(4)
90
+ })
91
+
92
+ it('GC 기준 미달이면 유지한다', () => {
93
+ const freshTs = new Date().toISOString()
94
+ const queue = [
95
+ { number: 1, status: 'resolved', resolvedAt: freshTs },
96
+ { number: 2, status: 'expired', expiredAt: freshTs }
97
+ ]
98
+ const { removedCount } = gcQueue(queue)
99
+ expect(removedCount).toBe(0)
100
+ })
101
+ })
102
+
103
+ describe('maintainQueue', () => {
104
+ it('reconcile + expire + GC를 한 번에 실행한다', () => {
105
+ const oldTs = new Date(Date.now() - GC_AGE_MS - 1000).toISOString()
106
+ const staleTs = new Date(Date.now() - QUEUE_TTL_MS - 1000).toISOString()
107
+ const freshTs = new Date().toISOString()
108
+
109
+ const queue = [
110
+ { number: 1, status: 'pending', detectedAt: freshTs }, // active → stays
111
+ { number: 2, status: 'pending', detectedAt: freshTs }, // not active → resolved
112
+ { number: 3, status: 'pending', detectedAt: staleTs }, // TTL 초과 → expired
113
+ { number: 4, status: 'resolved', resolvedAt: oldTs }, // GC 대상
114
+ { number: 5, status: 'responded', detectedAt: freshTs } // 유지
115
+ ]
116
+ const activePrs = new Set([1])
117
+
118
+ const result = maintainQueue(queue, activePrs)
119
+
120
+ // #2, #3 모두 active에 없으므로 reconcile에서 resolved (expire보다 먼저 실행)
121
+ expect(result.resolvedCount).toBe(2) // #2, #3
122
+ expect(result.expiredCount).toBe(0) // reconcile이 먼저 resolved 처리
123
+ expect(result.removedCount).toBe(1) // #4
124
+
125
+ // 남은 항목 확인
126
+ expect(result.queue).toHaveLength(4)
127
+ expect(result.queue.find(q => q.number === 1).status).toBe('pending')
128
+ expect(result.queue.find(q => q.number === 2).status).toBe('resolved')
129
+ expect(result.queue.find(q => q.number === 3).status).toBe('resolved')
130
+ expect(result.queue.find(q => q.number === 5).status).toBe('responded')
131
+ })
132
+
133
+ it('activePrNumbers가 null이면 reconcile을 생략한다', () => {
134
+ const queue = [
135
+ { number: 1, status: 'pending', detectedAt: new Date().toISOString() }
136
+ ]
137
+ const result = maintainQueue(queue, null)
138
+ expect(result.resolvedCount).toBe(0)
139
+ expect(result.queue[0].status).toBe('pending')
140
+ })
141
+ })
@@ -1,37 +0,0 @@
1
- {
2
- "trackers": {
3
- "primary": "github",
4
- "linear": {
5
- "enabled": false
6
- }
7
- },
8
- "notifications": {
9
- "telegram": {
10
- "enabled": true,
11
- "chatIdEnv": "TELEGRAM_CHAT_ID",
12
- "botTokenEnv": "TELEGRAM_BOT_TOKEN"
13
- },
14
- "slack": {
15
- "enabled": false
16
- }
17
- },
18
- "team": {
19
- "enabled": true,
20
- "members": [],
21
- "reviewAssignment": "round-robin"
22
- },
23
- "permissions": {
24
- "scope": "write"
25
- },
26
- "maxRetries": 3,
27
- "loopTimeoutMinutes": 30,
28
- "figma": {
29
- "enabled": false
30
- },
31
- "branch": {
32
- "pattern": "{tracker}-{issue-number}",
33
- "defaults": {
34
- "tracker": "issues"
35
- }
36
- }
37
- }