<!-- task=m-provider-availability-capacity-queue-foundation/04+03_fifo_admission_queue plan=0 tag=REFACTOR -->

# Plan - FIFO Admission Queue

## 이 파일을 읽는 구현 에이전트에게

구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 그대로 둔 채 리뷰 준비 완료를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope conflict 없이는 사용자에게 직접 묻지 말고, 차단 시 review stub의 `사용자 리뷰 요청` 섹션에 근거를 남긴다. `USER_REVIEW.md`, `complete.log`, log/archive 이동은 code-review 전용이다.

## 배경

현재 Node concurrency gate는 capacity 초과 요청을 즉시 reject한다. roadmap의 `admission-gate`, `queue-release`, `queue-reject`는 이 동작을 provider별 FIFO admission queue로 바꿔야 한다. 이 계획은 `03_queue_capability_contract` 완료 후 Node 실행 경로의 queue 본체를 구현한다.

## 사용자 리뷰 요청 흐름

구현 중 사용자-only blocker는 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 질문, 채팅 선택지 제시, `request_user_input` 호출을 하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/provider-availability-capacity-queue-foundation.md`
- Task ids:
  - `admission-gate`: Node provider executor가 `in_flight < capacity`일 때만 provider 호출을 시작하고, capacity가 찬 요청은 provider별 FIFO queue에 넣는다.
  - `queue-release`: 실행 중 요청이 응답, 실패, 취소, timeout으로 종료되면 `in_flight`를 줄이고 queue의 첫 요청을 실행 슬롯으로 승격한다.
  - `queue-reject`: `max_queue` 초과 또는 `queue_timeout` 초과 요청은 명확한 error/rejection reason으로 종료한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/edge-smoke.md`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/run_manager.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/node/node_concurrency_integration_test.go`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/adapters/ollama/ollama.go`
- `apps/node/internal/adapters/vllm/vllm.go`
- `apps/node/internal/adapters/ollama/ollama_test.go`
- `apps/node/internal/adapters/vllm/vllm_test.go`

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md` 존재 및 읽음.
- 적용 profile: `agent-test/local/node-smoke.md`.
- 필수 명령: 변경한 node package 또는 `go test ./apps/node/...`.
- 실행 pipeline 변경이므로 focused unit/integration 뒤 `./scripts/e2e-smoke.sh`를 보조 검증으로 실행한다. full-cycle 수동 검증을 수행하지 못하면 review stub에 생략 사유와 남은 위험을 기록한다.

### 테스트 커버리지 공백

- 기존 `TestConcurrencyLimit_*`는 immediate reject 정책을 검증한다. FIFO 대기/승격/timeout/reject 정책에 맞게 새 테스트로 교체해야 한다.
- `node_concurrency_integration_test.go`는 reject event 관측만 검증한다. queue overflow 또는 queue timeout error event 관측으로 갱신해야 한다.
- queue ordering, release after success/failure/cancel/timeout은 현재 커버리지 없음. 새 deterministic tests 필요.

### 심볼 참조

- renamed/removed symbols: none.
- changed symbols 후보: `permitManager.tryAcquire`, `permitManager.release`, `ErrConcurrencyLimitExceeded`.
- call sites: `apps/node/internal/node/node.go` lines 114, 123, 165-166; tests under `apps/node/internal/node/node_test.go` concurrency section; integration test `node_concurrency_integration_test.go`.

### 분할 판단

- split decision policy 평가 완료.
- 이 subtask는 `04+03_fifo_admission_queue`; predecessor `03_queue_capability_contract`가 active sibling으로 있어야 하며 implementation 시작 전 해당 subtask의 `complete.log`가 필요하다.
- 현재 확인 시 active predecessor complete.log는 없음. 구현 에이전트는 `agent-task/m-provider-availability-capacity-queue-foundation/03_queue_capability_contract/complete.log` 또는 archive의 같은 predecessor complete.log가 없으면 시작하지 말고 review stub에 dependency blocker를 기록한다.
- 관측 snapshot은 `05+04_queue_observe_snapshot`로 분리한다.

### 범위 결정 근거

- 포함: Node admission queue, queue release, queue overflow/timeout rejection, tests.
- 제외: Edge service/control-plane changes. Node가 `ProviderSnapshot.Queued` 값을 채우는 것은 후속 05에서 다룬다.
- 제외: priority queue, weighted queue, preemption. roadmap 범위 제외와 일치한다.

### 빌드 등급

- `cloud-G06`: concurrency behavior와 event/store side effects를 바꾸며 기존 reject 정책 테스트를 대체해야 한다.

## 구현 체크리스트

- [ ] predecessor `03_queue_capability_contract`의 `complete.log`가 active 또는 archive에 있는지 확인한다.
- [ ] `permitManager` 또는 새 admission manager를 FIFO queue semantics로 교체한다.
- [ ] `OnRunRequest`가 capacity 여유가 있으면 즉시 실행하고, capacity가 차면 provider별 FIFO queue에 요청을 대기시킨다.
- [ ] 실행 완료, 실패, 취소, timeout 시 permit을 release하고 queue head를 실행 슬롯으로 승격한다.
- [ ] `max_queue` 초과와 `queue_timeout_ms` 초과는 store status와 RunEvent error에 명확한 reason을 남긴다.
- [ ] foreground/background run 모두 같은 admission path를 사용한다.
- [ ] node unit/integration tests를 FIFO 정책에 맞게 추가/수정한다.
- [ ] 최종 검증 명령을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

- `04+03_fifo_admission_queue`는 디렉터리명 기준으로 `03_queue_capability_contract`의 PASS `complete.log`가 선행 조건이다.
- predecessor가 만족되면 queue manager test를 먼저 작성하고, 그 다음 `OnRunRequest`를 연결한다.

### [REFACTOR-1] FIFO Admission Manager

문제: [run_manager.go](/config/workspace/iop/apps/node/internal/node/run_manager.go:55)는 atomic counter 기반 immediate reject만 지원한다.

해결 방법:

Before:
```go
// apps/node/internal/node/run_manager.go:66
func (p *permitManager) tryAcquire() bool {
	if p.limit <= 0 {
		p.active.Add(1)
		return true
	}
	for {
		cur := p.active.Load()
		if cur >= p.limit {
			return false
		}
		if p.active.CompareAndSwap(cur, cur+1) {
			return true
		}
	}
}
```

After:
```go
type admissionManager struct {
	mu sync.Mutex
	capacity int
	maxQueue int
	queueTimeout time.Duration
	inFlight int
	queue []*admissionRequest
}
```

수정 파일 및 체크리스트:
- [ ] `apps/node/internal/node/run_manager.go`: queue manager 타입과 acquire/release/queuedCount API.
- [ ] context cancellation과 queue timeout이 goroutine leak 없이 제거되는지 보장한다.

테스트 작성:
- [ ] `apps/node/internal/node/node_test.go`: FIFO ordering, max_queue overflow, queue timeout, release after success/failure/cancel/timeout.

중간 검증:
```bash
go test -count=1 ./apps/node/internal/node -run 'Test.*Queue|TestConcurrency'
```
예상: PASS.

### [REFACTOR-2] OnRunRequest Admission Path

문제: [node.go](/config/workspace/iop/apps/node/internal/node/node.go:98)는 foreground/background 모두 capacity 초과 시 `rejectRun`을 호출해 반환한다. queue 대기 상태와 승격 실행 경로가 없다.

해결 방법:

Before:
```go
// apps/node/internal/node/node.go:114
if !n.globalPermit.tryAcquire() {
	n.rejectRun(ctx, sess, spec, limit)
	return fmt.Errorf("node: run %s: %w", spec.RunID, ErrConcurrencyLimitExceeded)
}
if !adapterPM.tryAcquire() {
	n.globalPermit.release()
	n.rejectRun(ctx, sess, spec, adapterCap)
	return fmt.Errorf("node: run %s: %w", spec.RunID, ErrConcurrencyLimitExceeded)
}
```

After:
```go
admission, err := n.admissionFor(spec.Adapter, caps).admit(ctx, request)
if err != nil {
	n.rejectRun(ctx, sess, spec, admissionRejectReason(err))
	return err
}
goOrRun(admission)
```

수정 파일 및 체크리스트:
- [ ] `apps/node/internal/node/node.go`: resolve 후 store insert 전 admission decision을 수행한다.
- [ ] queued foreground request는 queue admission이 완료될 때까지 기다리고 실행 완료까지 기존 foreground semantics를 유지한다.
- [ ] queued background request는 enqueue 성공 후 반환하고, 승격되면 goroutine에서 실행한다.
- [ ] store status는 queued/running/rejected/completed 흐름이 추적 가능해야 한다. store schema 변경 없이 status string 업데이트로 가능한지 우선 확인한다.

테스트 작성:
- [ ] foreground second run waits and executes after first release.
- [ ] background queued run returns after enqueue and starts after release.
- [ ] failed/cancelled/timeout run releases next queued request.

중간 검증:
```bash
go test -count=1 ./apps/node/internal/node
```
예상: PASS.

### [REFACTOR-3] Queue Rejection Events

문제: [rejectRun](/config/workspace/iop/apps/node/internal/node/node.go:203)는 concurrency limit exceeded 메시지만 만든다. `max_queue` 초과와 `queue_timeout_ms` 초과 reason이 구분되지 않는다.

해결 방법:

Before:
```go
// apps/node/internal/node/node.go:205
errMsg := fmt.Sprintf("concurrency limit exceeded (limit=%d)", limit)
```

After:
```go
errMsg := reason.message()
```

수정 파일 및 체크리스트:
- [ ] rejection reason type을 두고 `queue_full`, `queue_timeout`, `concurrency_unavailable`을 구분한다.
- [ ] RunEvent error와 store error에 같은 reason text가 들어가게 한다.
- [ ] 기존 `ErrConcurrencyLimitExceeded` compatibility가 필요한 tests는 새 sentinel 또는 wrapping 정책을 명확히 한다.

테스트 작성:
- [ ] `node_concurrency_integration_test.go`: queue overflow 또는 timeout error event가 Edge-side listener에 도착한다.

중간 검증:
```bash
go test -count=1 ./apps/node/internal/node -run 'Test.*Reject|Test.*Queue|TestConcurrencyLimit_.*Event'
```
예상: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/run_manager.go` | REFACTOR-1 |
| `apps/node/internal/node/node.go` | REFACTOR-2, REFACTOR-3 |
| `apps/node/internal/node/node_test.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
| `apps/node/internal/node/node_concurrency_integration_test.go` | REFACTOR-3 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/node
go test -count=1 ./apps/node/...
./scripts/e2e-smoke.sh
```

예상: PASS. Go test cache output은 허용하지 않는다. full-cycle 수동 검증을 수행하지 못하면 `CODE_REVIEW-*-G??.md`에 생략 사유와 남은 위험을 남긴다. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
