@elyun/bylane 1.30.0 → 1.32.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 +32 -8
- package/README.md +22 -2
- package/commands/bylane-cleanup.md +5 -0
- package/commands/bylane-notify-agent.md +15 -12
- package/commands/bylane-orchestrator.md +43 -1
- package/hooks/bylane-session-cleanup.js +94 -0
- package/package.json +1 -1
- package/src/cleanup.js +58 -8
- package/src/cli.js +22 -3
- package/src/config.js +1 -1
- package/src/pipeline.js +195 -0
- package/src/queue-utils.js +119 -0
- package/src/respond-loop.js +24 -2
- package/src/review-loop.js +24 -2
- package/tests/pipeline.test.js +171 -0
- package/tests/queue-utils.test.js +141 -0
- package/.bylane/bylane.json +0 -37
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 # 테스트 실행 (
|
|
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/
|
|
44
|
-
- `src/
|
|
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
|
|
118
|
-
├── config.test.js
|
|
119
|
-
|
|
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
|
## 주의사항
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ GitHub Issues에서 시작해 코드 구현, 테스트, 커밋, PR 생성, 리
|
|
|
28
28
|
- [Claude Code](https://claude.ai/code) CLI
|
|
29
29
|
- Node.js 20+
|
|
30
30
|
- GitHub MCP (Claude Code 기본 제공) / `gh` CLI / `GITHUB_TOKEN` 중 하나
|
|
31
|
-
- Slack
|
|
31
|
+
- Slack Incoming Webhook URL 또는 Telegram Bot Token (알림 사용 시, 선택사항)
|
|
32
32
|
- Figma MCP (디자인 연동 사용 시, 선택사항)
|
|
33
33
|
|
|
34
34
|
---
|
|
@@ -443,7 +443,7 @@ respond-loop 독립: 5분 주기 리뷰 코멘트 감지
|
|
|
443
443
|
"linear": { "enabled": false, "apiKey": "" }
|
|
444
444
|
},
|
|
445
445
|
"notifications": {
|
|
446
|
-
"slack": { "enabled": true, "
|
|
446
|
+
"slack": { "enabled": true, "webhookUrl": "https://hooks.slack.com/services/T.../B.../..." },
|
|
447
447
|
"telegram": { "enabled": false, "chatId": "" }
|
|
448
448
|
},
|
|
449
449
|
"workflow": {
|
|
@@ -486,6 +486,26 @@ respond-loop 독립: 5분 주기 리뷰 코멘트 감지
|
|
|
486
486
|
}
|
|
487
487
|
```
|
|
488
488
|
|
|
489
|
+
### Slack 웹훅 설정
|
|
490
|
+
|
|
491
|
+
1. [Slack API](https://api.slack.com/apps) → **Create New App** → **Incoming Webhooks** 활성화
|
|
492
|
+
2. **Add New Webhook to Workspace** → 채널 선택 → Webhook URL 복사
|
|
493
|
+
3. `.bylane/bylane.json`에 입력:
|
|
494
|
+
|
|
495
|
+
```json
|
|
496
|
+
"notifications": {
|
|
497
|
+
"slack": {
|
|
498
|
+
"enabled": true,
|
|
499
|
+
"webhookUrl": "https://hooks.slack.com/services/T.../B.../..."
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
알림이 발송되는 시점:
|
|
505
|
+
- ✅ PR 생성/머지 완료
|
|
506
|
+
- ⚠️ 테스트 실패로 개입 필요
|
|
507
|
+
- ❌ 파이프라인 오류
|
|
508
|
+
|
|
489
509
|
### 브랜치 네이밍 토큰
|
|
490
510
|
|
|
491
511
|
| 토큰 | 설명 |
|
|
@@ -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
|
|
|
@@ -38,18 +38,21 @@ PR: PR_URL
|
|
|
38
38
|
|
|
39
39
|
### Slack 알림 (notifications.slack.enabled: true)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
`config.notifications.slack.webhookUrl`로 Incoming Webhook POST:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# 완료 메시지
|
|
45
|
+
curl -s -X POST "$SLACK_WEBHOOK_URL" \
|
|
46
|
+
-H "Content-Type: application/json" \
|
|
47
|
+
-d '{"text":"[byLane] ✅ 완료: TITLE\nPR: PR_URL | 소요 시간: ELAPSED"}'
|
|
48
|
+
|
|
49
|
+
# 개입 필요 메시지
|
|
50
|
+
curl -s -X POST "$SLACK_WEBHOOK_URL" \
|
|
51
|
+
-H "Content-Type: application/json" \
|
|
52
|
+
-d '{"text":"[byLane] ⚠️ 개입 필요: TITLE\n이유: REASON | 확인: PR_URL"}'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`webhookUrl`이 비어 있으면 Slack 알림을 건너뜬다.
|
|
53
56
|
|
|
54
57
|
### Telegram 알림 (notifications.telegram.enabled: true)
|
|
55
58
|
|
|
@@ -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
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
|
-
// 큐
|
|
132
|
+
// 큐 파일 종합 정리
|
|
119
133
|
if ((name === 'review-queue' || name === 'respond-queue') && Array.isArray(state.queue)) {
|
|
120
|
-
|
|
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
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
}
|
package/src/config.js
CHANGED
package/src/pipeline.js
ADDED
|
@@ -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
|
+
}
|
package/src/respond-loop.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/review-loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/.bylane/bylane.json
DELETED
|
@@ -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
|
-
}
|