@elyun/bylane 1.29.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 +32 -8
- package/commands/bylane-cleanup.md +5 -0
- package/commands/bylane-code-agent.md +18 -0
- package/commands/bylane-issue-agent.md +46 -1
- package/commands/bylane-orchestrator.md +43 -1
- package/commands/bylane-setup.md +23 -5
- 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 +4 -0
- 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/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
|
+
})
|