@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.
@@ -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
+ })