feat: runtime reconnect config refresh - node drain config refresh
- Add PHASE.md for cli-agent-group-grade-routing milestone - Add SDD for automation-runtime-bridge - Update edge configrefresh classify logic and tests - Update node config_set, node.go, and run_manager for runtime config refresh - Archive old plan/review docs for 06+05_node_drain_runtime_config
This commit is contained in:
parent
bf6b7de354
commit
695df72eac
16 changed files with 1562 additions and 40 deletions
|
|
@ -76,6 +76,10 @@ NomadCode가 IOP를 실행 백엔드로 사용할 수 있도록 하는 Responses
|
|||
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
|
||||
- 요약: NomadCode가 IOP CLI를 직접 실행하지 않고 IOP Edge OpenAI-compatible HTTP 호출의 `metadata.workspace`와 task/source metadata만으로 내부 workspace-bound agent target이 해당 checkout에서 산출물을 만들 수 있게 하는 최우선 contract/serving hardening 작업이다.
|
||||
|
||||
- [계획] CLI Agent Group Grade Routing
|
||||
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md`
|
||||
- 요약: `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G07.md` 같은 예약어/lane/grade 파일명을 기준으로 CLI provider agent를 목적별 agent group에 라우팅하고, 수동/자동 grade range assignment와 OpenAI-compatible `metadata.agent_group.task_file` 계약을 정리한다.
|
||||
|
||||
- [스케치] CLI Agent 사용량 알림과 자동 이어받기 MVP
|
||||
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-usage-notification-continuation.md`
|
||||
- 요약: CLI Agent limit 도달 감지, 사용자 알림, 작업 자동 이어받기의 MVP 경계를 스케치한다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
# Milestone: CLI Agent Group Grade Routing
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
CLI provider에 등록된 agent를 목적별 agent group으로 묶고, `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G07.md`, `DOC-local-G04.md` 같은 예약어/lane/grade 파일명을 기준으로 실행 agent를 결정한다.
|
||||
예약어 설정은 기본 실행자를 직접 agent id 또는 `auto`로 고를 수 있고, `auto`일 때만 선택된 agent group의 수동/자동 grade routing을 사용한다.
|
||||
OpenAI-compatible 호출은 파일 내용을 prompt 본문에 섞지 않고 `metadata.agent_group.task_file`로 절대 또는 workspace-relative task file 경로를 전달한다.
|
||||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
- 없음
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 잠금
|
||||
- SDD: 필요
|
||||
- SDD 문서: `agent-roadmap/sdd/automation-runtime-bridge/cli-agent-group-grade-routing/SDD.md`
|
||||
- SDD 사유: CLI provider agent catalog, agent group grade assignment, OpenAI-compatible metadata schema, config schema, request routing, usage/resource based selection, route audit log가 runtime/API 계약에 직접 닿는다.
|
||||
- 잠금 해제 조건:
|
||||
- [ ] SDD 잠금이 해제되어 있다
|
||||
- [ ] SDD 사용자 리뷰가 없거나 승인/해결되었다
|
||||
- [ ] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다
|
||||
- [ ] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다
|
||||
- 결정 필요: 아래 체크리스트
|
||||
- [ ] 선택된 agent 실행 실패, provider quota 소진, 실행 중단, 중복 실행 위험이 발생했을 때 자동 재라우팅/재시도/중단 중 어떤 후속 정책을 적용할지 결정한다. 이번 Milestone의 schema와 routing 구현은 이 정책이 결정되기 전까지 자동 retry/fallback 동작을 확정하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
- CLI provider에 나열된 agent를 routing 가능한 실행 단위로 보고, 각 agent가 참조하는 adapter/target, `native_lane`, 처리 가능한 lane 목록, local/cloud provider 성격, subscription/quota/resource metric source를 config로 표현한다.
|
||||
- agent group은 목적별 그룹이다. 기본 agent group은 `coding`이며, `docs`는 문서/테스트용 추가 기본 후보로 둔다. 그 밖의 목적 그룹도 확장 가능하게 둔다.
|
||||
- agent group은 `assignment_mode=manual|auto`, `purpose`, benchmark profile, agent id 목록, lane별 grade coverage, overlap 정책, 수동 range 또는 자동 산출 range를 가진다.
|
||||
- agent group 안에서도 local lane과 cloud lane assignment table은 분리한다. cloud-capable agent는 local lane coverage에 포함될 수 있지만, local-only agent는 cloud lane coverage에 포함될 수 없다.
|
||||
- agent group의 agent id 목록 변경 감지는 순서가 아니라 contain 기준이다. 신규 auto group은 최초 자동 설정 대상이며, 기존 group은 같은 agent id 집합을 재정렬한 변경을 자동 재정렬 트리거로 보지 않고 agent id가 추가/삭제/교체된 경우에만 자동 설정 재계산 대상으로 본다. 이 비교는 group 추가/수정 저장 시점에 이전 agent id set과 현재 agent id set을 메모리에서 비교하는 방식이면 충분하며, hash/cache 기반 변경 감지를 요구하지 않는다.
|
||||
- 수동 설정에서는 lane별로 `G01`~`G10` coverage가 모두 채워져야 하며, grade range overlap을 허용한다. overlap 후보는 local device resource 여유와 cloud subscription/quota 잔여량을 포함한 route score로 선택한다.
|
||||
- cloud agent는 local lane 작업 후보가 될 수 있지만, local-only agent는 cloud lane 작업 후보가 될 수 없다. 따라서 local lane coverage는 local agent와 cloud-capable agent가 함께 채울 수 있고, cloud lane coverage는 cloud-capable agent만 채운다.
|
||||
- grade overlap은 group 설정으로 표현한다. 예를 들어 overlap 폭이 `2`이면 각 grade의 상위/하위 2개 grade까지 인접 후보가 겹쳐 처리 가능하도록 range assignment를 산출하거나 검증한다.
|
||||
- 자동 설정에서는 group의 purpose에 맞는 benchmark profile, auto assignment evaluator, 사용자 설정 benchmark sorting prompt를 사용해 group 안의 agent를 정렬하고 grade range assignment schema를 생성한다. coding group은 coding benchmark, docs group은 documentation benchmark를 기준으로 삼고, evaluator가 반환한 자동 assignment 결과는 output schema 검증을 통과한 뒤 `auto_ranges`로 저장한다.
|
||||
- 예약어 설정은 사용자 추가가 가능하며, 예약어별 `default_agent`, `prompt_template`, `user_params_schema`, `file_payload_policy=path`를 가진다. `agent_group`은 `default_agent=auto`일 때만 노출/필수인 하위 설정이다.
|
||||
- 예약어의 `default_agent=auto`는 해당 예약어가 참조하는 `agent_group` routing을 사용한다는 뜻이다. `default_agent`가 특정 cli provider agent id이면 agent group routing을 타지 않고 해당 agent를 기본 실행자로 사용하되, 파일명 lane/grade와 agent capability가 맞지 않으면 실패한다.
|
||||
- agent group routing이 걸린 요청은 task file basename에서 예약어, lane, grade를 파싱한다. 예약어 prefix는 basename에서 마지막 `-{lane}-GNN.md` suffix를 제거한 왼쪽 전체 값이며, 형식 불일치, 미등록 예약어, lane/grade 범위 불일치, group coverage gap은 실패로 반환한다.
|
||||
- OpenAI-compatible 호출은 `metadata.agent_group.task_file`과 `metadata.agent_group.params`를 사용한다. `agent_route_prefix` 같은 중복 필드는 만들지 않고, 예약어/lane/grade는 task file basename에서만 얻는다.
|
||||
- task file 경로는 절대 경로와 상대 경로를 모두 지원한다. 상대 경로는 OpenAI-compatible CLI route에서 `metadata.workspace` 기준으로 해석하고, 상대 경로인데 workspace가 없으면 routing input error로 실패한다.
|
||||
- 예약어 prompt는 task file 내용을 inline으로 붙이지 않고 task file 경로와 사용자 parameter만 전달한다. provider/adapter별 renderer는 같은 의미를 유지한 채 CLI별 인자나 prompt 형태로 변환할 수 있다.
|
||||
- route request log는 request/execution id, task file, parsed prefix/lane/grade, 예약어 설정, group 또는 default agent, 후보 agent, 탈락 사유, 선택 agent, resource/quota snapshot, 실패 원인을 남긴다.
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [config] Agent Group 설정 계약
|
||||
|
||||
CLI provider agent와 목적별 agent group, 예약어 설정을 runtime이 해석 가능한 config 계약으로 고정한다.
|
||||
|
||||
- [ ] [provider-agent] CLI provider agent catalog가 agent id, adapter, target, local/cloud provider 성격, `native_lane`, 처리 가능 lane 목록, subscription/quota/resource metric source를 표현한다. 검증: local-only agent가 cloud lane 후보로 들어가면 config validation이 실패하고, cloud-capable agent가 local lane 후보로 들어가는 설정은 통과한다.
|
||||
- [ ] [group-schema] agent group schema가 `purpose`, `assignment_mode`, benchmark profile, auto assignment evaluator, benchmark sorting prompt, auto assignment output schema, agent id 목록, lane별 grade coverage, overlap 폭, 수동/자동 assignment 결과를 표현한다. 검증: lane별 `G01`~`G10` coverage gap은 실패하고, 같은 agent id 집합의 순서만 바뀐 저장은 자동 재계산 트리거로 기록되지 않으며, overlap 폭이 산출 range에 반영된다.
|
||||
- [ ] [prefix-schema] 예약어 설정이 사용자 추가 가능한 `prefix`, `default_agent=auto|<agent-id>`, 조건부 `agent_group`, `prompt_template`, `user_params_schema`, `file_payload_policy=path`를 표현한다. 검증: `default_agent=auto`인데 `agent_group`이 없으면 실패하고, direct `default_agent`가 cli provider agent catalog에 없으면 실패하며, direct mode에서는 `agent_group`이 필수가 아니다.
|
||||
- [ ] [metadata-contract] OpenAI-compatible 계약이 `metadata.agent_group.task_file`과 `metadata.agent_group.params`를 지원하고, `agent_route_prefix` 없이 task file basename에서 예약어/lane/grade를 파싱한다. 검증: 절대 경로와 `metadata.workspace` 기준 상대 경로가 통과하고, 상대 경로인데 workspace가 없으면 실패하며, prompt 본문에만 파일명을 넣은 요청은 agent group routing 입력으로 사용되지 않는다.
|
||||
|
||||
### Epic: [routing] Grade 기반 실행 라우팅
|
||||
|
||||
예약어, lane, grade, agent group assignment, resource/quota 상태를 조합해 실행 agent를 결정한다.
|
||||
|
||||
- [ ] [filename-parse] agent group routing 요청은 task file basename의 예약어, lane, grade 형식을 엄격히 검증한다. 검증: `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G07.md`, `DOC-local-G04.md`는 통과하고, lane 누락, `G11`, 미등록 prefix, 잘못된 grade 표기는 실패하며, prefix는 마지막 `-{lane}-GNN.md` suffix 왼쪽 전체로 해석된다.
|
||||
- [ ] [default-agent] 예약어의 `default_agent`가 `auto`이면 agent group routing을 사용하고, 특정 cli provider agent id이면 그 agent를 직접 선택한다. 검증: direct default agent가 파일명의 lane/grade를 처리할 수 없으면 routing error를 반환한다.
|
||||
- [ ] [manual-routing] 수동 assignment는 lane별 grade range와 overlap을 허용하고, 겹치는 후보는 local resource 여유 또는 cloud subscription/quota 잔여량을 포함한 route score로 선택한다. 검증: 같은 grade를 처리하는 agent가 둘 이상일 때 resource/quota 상태가 더 좋은 후보가 선택된다.
|
||||
- [ ] [auto-routing] 자동 assignment는 group purpose에 맞는 benchmark profile, auto assignment evaluator, 사용자가 설정한 benchmark sorting prompt를 사용해 agent 순위와 lane별 grade range를 산출한다. 검증: coding group은 coding benchmark profile, docs group은 documentation benchmark profile을 사용하고, evaluator 응답이 output schema와 lane별 coverage, cloud/local capability 규칙을 통과한 경우에만 저장된다.
|
||||
- [ ] [route-log] routing 요청마다 request/execution id, task file, parsed prefix/lane/grade, 예약어, group/default agent, 후보/탈락/선택 agent, resource/quota snapshot, 실패 원인이 로그에 남는다. 검증: 성공과 routing error 모두 route log에서 같은 request id로 추적된다.
|
||||
|
||||
### Epic: [docs-tests] 문서와 검증
|
||||
|
||||
구현자가 라우팅 규칙을 재현하고 운영자가 설정 오류를 고칠 수 있도록 문서와 테스트 근거를 남긴다.
|
||||
|
||||
- [ ] [contract-docs] OpenAI-compatible 계약 문서와 Edge 운영 문서가 `metadata.agent_group.task_file`, `metadata.agent_group.params`, 예약어 파일명 계약, path-only payload 정책을 설명한다.
|
||||
- [ ] [config-examples] 기본 `coding` agent group, 문서/테스트용 `docs` agent group, manual assignment, auto assignment, `PLAN`, `CODE_REVIEW`, `DOC` 예약어 설정 예시가 config sample에 추가된다.
|
||||
- [ ] [routing-tests] config validation, filename parsing, manual routing, auto assignment result validation, direct default agent, metadata path error, route log 테스트가 추가된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 기능 Task가 아직 충족되지 않았고 SDD 사용자 리뷰가 남아 있어 구현 잠금이 유지된다.
|
||||
- 검토 항목:
|
||||
- [ ] SDD 사용자 리뷰가 해결되고 SDD 잠금이 해제되었다
|
||||
- [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다
|
||||
- [ ] OpenAI-compatible 계약 문서와 config 예시가 실제 구현과 일치한다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- 선택된 agent 실행 실패 후 자동 재시도, 다른 agent fallback, 중복 실행 방지 정책의 최종 동작. 이번 Milestone에서는 잠금 항목으로 남기고, 결정 전에는 자동 retry/fallback을 구현하지 않는다.
|
||||
- agent benchmark를 실제로 실행해 점수를 산출하는 benchmark runner 구축. 이번 범위는 benchmark profile/prompt와 LLM 기반 assignment 결과 schema/validation이다.
|
||||
- 모든 cloud provider의 subscription API 연동. MVP는 사용 가능 quota/remaining metric source가 있으면 route score에 사용하고, 없으면 설정된 fallback weight를 사용한다.
|
||||
- GUI/Client 설정 화면 완성. config/API 계약과 운영 문서 예시를 우선한다.
|
||||
- 원격 터미널/CLI 터널링, oto scheduler/CI-CD 자동화, 장기 기억/RAG/tool policy routing.
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `agent-contract/provided/openai-compatible-api.md`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/node/internal/adapters/cli`, `packages/go/config`, `configs`, `apps/edge/README.md`
|
||||
- 표준선(선택): 내부 실행 개념은 `adapter + target`을 유지하고, OpenAI-compatible 경계의 agent group routing 문맥은 `metadata.agent_group` 아래에 둔다. 파일명은 routing 계약의 source of truth이며 prefix/lane/grade 중복 metadata는 만들지 않는다.
|
||||
- 선행 작업: CLI Automation Runtime 안정화, OpenAI Responses Input Surface, OpenAI Workspace Agent Execution Contract
|
||||
- 후속 작업: CLI Agent 사용량 알림과 자동 이어받기 MVP, 운영 관측과 Provider 관리의 provider quota/usage 고도화
|
||||
- 확인 필요: 선택된 agent 실패/중단/실행 중 quota 소진 시 자동 retry/fallback/중단 정책은 `구현 잠금 > 결정 필요`와 SDD `USER_REVIEW.md`로 분리한다.
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
# SDD: CLI Agent Group Grade Routing
|
||||
|
||||
## 위치
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md`
|
||||
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
|
||||
|
||||
## 상태
|
||||
|
||||
[검토중]
|
||||
|
||||
## SDD 잠금
|
||||
|
||||
- 상태: 잠금
|
||||
- 사용자 리뷰: `USER_REVIEW.md`
|
||||
- 잠금 항목:
|
||||
- [ ] [D01] 선택된 agent 실행 실패, provider quota 소진, 실행 중단, 중복 실행 위험이 발생했을 때 자동 재라우팅/재시도/중단 중 어떤 후속 정책을 적용할지 결정한다.
|
||||
|
||||
## 문제 / 비목표
|
||||
|
||||
- 문제: plan/code-review/doc 같은 파일 기반 agent-task는 이미 `PLAN-{lane}-GNN.md`, `CODE_REVIEW-{lane}-GNN.md` naming contract로 lane과 grade를 표현하지만, runtime이 이 정보를 CLI provider agent, 목적별 agent group, local/cloud capability, resource/quota 상태에 연결하는 계약이 없다. 이 SDD는 예약어 설정, agent group assignment, OpenAI-compatible metadata 입력, route log, validation 경계를 고정한다.
|
||||
- 비목표:
|
||||
- 선택된 agent 실패 뒤 자동 retry/fallback 정책을 이번 문서에서 확정하지 않는다. D01 사용자 리뷰가 해결되기 전에는 fail-fast routing error 또는 실행 실패 reporting까지만 표준선으로 둔다.
|
||||
- benchmark runner 자체를 구현하지 않는다. 자동 설정은 benchmark profile, prompt, LLM 산출 schema, validation 경계까지만 다룬다.
|
||||
- 원격 터미널/CLI 터널링, oto scheduler/CI-CD, RAG/tool policy routing은 다루지 않는다.
|
||||
- 파일 내용을 OpenAI-compatible prompt 본문에 inline으로 넣는 입력 방식은 채택하지 않는다.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
| 영역 | 기준 | 메모 |
|
||||
|------|------|------|
|
||||
| Roadmap | `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md` | 목표, 기능 Task, 잠금 항목, 범위 제외 기준 |
|
||||
| Contract | `agent-contract/provided/openai-compatible-api.md` | OpenAI-compatible `metadata.agent_group.task_file`와 `metadata.agent_group.params` 계약을 추가할 원문 |
|
||||
| Code | `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/node/internal/adapters/cli`, `configs` | config schema, OpenAI metadata parsing, routing decision, CLI adapter execution 연결 기준 |
|
||||
| External Provider | CLI provider agent catalog | opencode/codex/claude/gemini 등 provider-specific 실행 대상은 cli provider agent id로 참조한다 |
|
||||
| User Decision | D01 | 실행 실패/중단/quota 소진 시 자동 retry/fallback/중단 정책은 사용자 결정 전까지 잠금 |
|
||||
|
||||
## State Machine
|
||||
|
||||
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|
||||
|------|-----------|-----------|------|
|
||||
| `prefix-configured` | 예약어 config가 `prefix`, `default_agent`, `prompt_template`, `file_payload_policy=path`를 가진다 | `request-received` | Edge config 또는 config refresh 결과 |
|
||||
| `group-configured` | agent group이 `purpose`, `assignment_mode`, agent id set, lane별 coverage 또는 auto assignment profile을 가진다 | `request-received` | Edge config 또는 config refresh 결과 |
|
||||
| `auto-assignment-needed` | 신규 auto group이 저장되었거나 기존 group이 수정되고 agent id contain set이 이전 저장값과 다르며 `assignment_mode=auto`다 | `auto-assignment-validated` 또는 `routing-config-error` | group edit/save event의 이전/현재 agent id set 비교 |
|
||||
| `auto-assignment-validated` | auto assignment evaluator가 benchmark profile, benchmark sorting prompt, agent catalog를 입력으로 만든 assignment 결과가 output schema, lane coverage, local/cloud capability validator를 통과한다 | `group-configured` | auto assignment output schema |
|
||||
| `request-received` | OpenAI-compatible 또는 native run request가 `metadata.agent_group.task_file` 또는 동등한 native field를 가진다 | `filename-parsed` 또는 `routing-input-error` | request metadata |
|
||||
| `filename-parsed` | task file basename에서 등록 prefix, `local|cloud`, `G01`~`G10`을 파싱했다. prefix는 마지막 `-{lane}-GNN.md` suffix 왼쪽 전체다 | `direct-agent-selected` 또는 `group-routing` | filename contract |
|
||||
| `direct-agent-selected` | 예약어 `default_agent`가 cli provider agent id이고 lane/grade capability가 맞다 | `execution-dispatched` | route prefix config |
|
||||
| `group-routing` | 예약어 `default_agent=auto`이고 참조 agent group이 존재한다 | `candidate-selected` 또는 `routing-config-error` | route prefix config와 group assignment |
|
||||
| `candidate-selected` | lane/grade 후보 중 route score가 가장 높은 agent를 골랐다 | `execution-dispatched` | resource/quota snapshot, coverage table |
|
||||
| `execution-dispatched` | prompt template과 task file path를 CLI adapter/provider에 전달했다 | `execution-complete` 또는 `execution-failed-locked` | RunRequest/execution id |
|
||||
| `execution-failed-locked` | 선택된 agent 실행 실패, quota 소진, 중단, 중복 실행 위험이 발생했다 | `blocked-on-D01` | D01 결정 전 자동 retry/fallback 금지 |
|
||||
| `routing-input-error` | task file, filename, prefix, lane/grade가 유효하지 않다 | terminal error | OpenAI-compatible error 또는 native error |
|
||||
| `routing-config-error` | group coverage gap, capability mismatch, missing default agent/group이 있다 | terminal error | config validation 또는 route validation |
|
||||
|
||||
## Interface Contract
|
||||
|
||||
- 계약 원문: `agent-contract/provided/openai-compatible-api.md`
|
||||
- 입력:
|
||||
- `metadata.agent_group.task_file`: agent group routing에 사용할 task file 경로다. 절대 경로와 상대 경로를 모두 허용한다. OpenAI-compatible CLI route에서 상대 경로는 `metadata.workspace` 기준으로 해석하고, 상대 경로인데 `metadata.workspace`가 없으면 실패한다.
|
||||
- `metadata.agent_group.params`: 예약어 `user_params_schema`로 검증한 사용자 parameter 객체다. 예약어 prompt renderer가 CLI별 prompt 또는 argument로 변환할 수 있다.
|
||||
- `route_prefixes[].prefix`: 사용자 추가 가능한 예약어다. task file basename에서 마지막 `-{lane}-GNN.md` suffix를 제거한 왼쪽 전체 값과 일치해야 한다.
|
||||
- `route_prefixes[].default_agent`: `auto` 또는 cli provider agent id다. `auto`면 `agent_group`을 사용하고, 특정 id면 agent group routing 없이 그 agent를 직접 선택한다.
|
||||
- `route_prefixes[].agent_group`: `default_agent=auto`일 때만 노출/필수인 목적별 agent group id다. `default_agent`가 특정 cli provider agent id이면 필수가 아니며 direct mode routing에 사용하지 않는다.
|
||||
- `route_prefixes[].prompt_template`: agent에게 전달할 최초 prompt template이다. 구현자/리뷰어/문서 작성자 같은 역할 지시를 여기에 둔다.
|
||||
- `route_prefixes[].user_params_schema`: `metadata.agent_group.params` 검증 schema다.
|
||||
- `route_prefixes[].file_payload_policy`: 이번 Milestone에서는 `path`만 표준선이다. 파일 내용 inline 전달은 금지한다.
|
||||
- `cli_provider_agents[].id`: 예약어 direct default agent와 agent group `agents[]`가 참조하는 실행 agent id다.
|
||||
- `cli_provider_agents[].adapter` / `target`: 내부 실행 기준인 `adapter + target`을 가리킨다.
|
||||
- `cli_provider_agents[].native_lane`: `local` 또는 `cloud`다.
|
||||
- `cli_provider_agents[].serves_lanes`: agent가 처리 가능한 lane 목록이다. cloud-capable agent는 `local`과 `cloud`를 모두 가질 수 있지만 local-only agent는 `cloud`를 가질 수 없다.
|
||||
- `agent_groups[].purpose`: `coding`, `docs` 같은 목적이다. 기본 agent group은 `coding`이며, `docs`는 문서/테스트용 추가 기본 후보로 둔다. 이 값은 자동 assignment의 benchmark profile 선택 기준이다.
|
||||
- `agent_groups[].assignment_mode`: `manual` 또는 `auto`다.
|
||||
- `agent_groups[].agents`: agent id 목록이다. 신규 auto group은 최초 자동 설정 대상이며, 기존 group의 변경 감지는 contain set 기준이다. 순서 변경만으로는 자동 assignment 재계산을 트리거하지 않는다. group 저장 시점의 이전/현재 agent id set 비교로 충분하며 hash/cache 기반 변경 감지는 요구하지 않는다.
|
||||
- `agent_groups[].benchmark_profile`: 자동 assignment에 사용할 benchmark category와 weight를 가리킨다.
|
||||
- `agent_groups[].auto_assignment_evaluator`: benchmark sorting prompt를 실행해 agent 순위와 lane별 grade range 초안을 반환할 LLM/evaluator target이다. group에 값이 없으면 system default evaluator를 사용할 수 있지만, 실행 전 어떤 evaluator를 썼는지 route/config log에 남겨야 한다.
|
||||
- `agent_groups[].benchmark_sorting_prompt`: 자동 assignment 때 agent 순위와 grade range 산출을 요청할 사용자 설정 prompt template이다.
|
||||
- `agent_groups[].auto_assignment_output_schema`: 자동 assignment 결과가 따라야 할 schema다. 최소한 lane별 range, agent id, `G01`~`G10` coverage, overlap 반영 여부, capability validation 근거를 표현해야 한다.
|
||||
- `agent_groups[].grade_overlap`: grade range overlap 폭이다. 예를 들어 `2`이면 각 grade의 상위/하위 2개 grade까지 인접 후보가 겹쳐 처리 가능하도록 assignment를 산출하거나 검증한다.
|
||||
- `agent_groups[].manual_ranges` / `auto_ranges`: lane별 `G01`~`G10` coverage를 표현한다. local lane과 cloud lane assignment table은 분리하며, cloud-capable agent는 local lane coverage에 포함될 수 있지만 local-only agent는 cloud lane coverage에 포함될 수 없다.
|
||||
- 출력:
|
||||
- route decision: request/execution id, task file, parsed prefix/lane/grade, route prefix config id, group id 또는 direct default agent id, 후보 agent, 탈락 사유, 선택 agent, route score 입력 metric snapshot.
|
||||
- routing error: invalid task file, invalid filename, unknown prefix, missing group/default agent, lane/grade coverage gap, capability mismatch, malformed auto assignment result.
|
||||
- execution dispatch: 선택된 cli provider agent id와 해당 adapter/target에 전달한 path-only task file prompt.
|
||||
- 금지:
|
||||
- `metadata.agent_route_prefix` 같은 중복 field를 만들지 않는다. prefix/lane/grade는 task file basename에서만 얻는다.
|
||||
- `metadata.cli` wrapper를 만들지 않는다.
|
||||
- task file 경로나 workspace를 prompt 본문에만 섞어 routing source로 사용하지 않는다.
|
||||
- agent group routing이 걸린 요청에서 filename 형식 오류를 best-effort로 추정하지 않는다.
|
||||
- agent id 목록의 순서만 바뀐 경우 자동 assignment를 재계산하지 않는다.
|
||||
- D01이 해결되기 전에는 실행 실패 뒤 자동 retry/fallback 정책을 구현하지 않는다.
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
| ID | Milestone Task | Given | When | Then |
|
||||
|----|----------------|-------|------|------|
|
||||
| S01 | `provider-agent` | local-only agent와 cloud-capable agent가 cli provider agent catalog에 있다 | config validation을 실행한다 | local-only agent는 cloud lane 후보가 될 수 없고, cloud-capable agent는 local lane 후보가 될 수 있다 |
|
||||
| S02 | `group-schema` | manual coding group이 lane별 grade range를 가진다 | 한 lane의 `G01`~`G10` coverage 중 일부가 비어 있다 | config validation이 coverage gap을 routing-config-error로 보고한다 |
|
||||
| S03 | `group-schema` | 신규 auto group이 저장되거나 기존 auto group의 `agents[]`가 같은 id set을 다른 순서로 저장된다 | group 저장을 처리한다 | 신규 auto group은 최초 assignment 대상으로 처리되고, 기존 group은 contain set이 같으면 auto assignment 재계산을 트리거하지 않는다 |
|
||||
| S04 | `prefix-schema` | `PLAN` 예약어가 `default_agent=auto`로 설정되어 있다 | `agent_group` 없이 저장하거나 존재하지 않는 group을 참조한다 | config validation이 실패한다 |
|
||||
| S05 | `metadata-contract` | OpenAI-compatible request가 absolute `metadata.agent_group.task_file=/repo/agent-task/x/PLAN-local-G08.md` 또는 `metadata.workspace=/repo`와 relative `metadata.agent_group.task_file=agent-task/x/PLAN-local-G08.md`를 가진다 | request metadata를 파싱한다 | 두 입력 모두 `/repo/agent-task/x/PLAN-local-G08.md`로 해석되고, prefix/lane/grade는 basename에서만 파싱된다 |
|
||||
| S06 | `filename-parse` | agent group routing 요청이 `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G07.md`, `DOC-local-G04.md`를 가리킨다 | filename parser가 실행된다 | 마지막 `-{lane}-GNN.md` suffix 왼쪽 전체가 prefix로 해석되고, 등록 prefix, lane, grade가 정확히 추출된다 |
|
||||
| S07 | `filename-parse` | 요청 task file basename이 `PLAN-G08.md`, `PLAN-cloud-8.md`, `PLAN-local-G11.md`, `UNKNOWN-local-G02.md` 중 하나다 | filename parser가 실행된다 | 추정 없이 routing-input-error를 반환한다 |
|
||||
| S08 | `default-agent` | `DOC` 예약어가 특정 cli provider agent id를 `default_agent`로 가진다 | `DOC-cloud-G08.md` 요청이 local-only direct agent로 들어온다 | agent group routing으로 우회하지 않고 capability mismatch error를 반환한다 |
|
||||
| S09 | `manual-routing` | 같은 local grade를 처리할 수 있는 agent가 둘 이상이고 resource/quota metric이 다르다 | route score를 계산한다 | local resource 여유 또는 cloud subscription/quota 잔여량이 더 좋은 후보가 선택된다 |
|
||||
| S10 | `auto-routing` | coding group과 docs group이 각각 `assignment_mode=auto`이며 auto assignment evaluator, benchmark sorting prompt, output schema를 가진다 | 자동 assignment prompt를 실행하고 schema를 검증한다 | coding group은 coding benchmark profile, docs group은 documentation benchmark profile을 사용하며 evaluator 응답이 output schema, lane coverage, capability 규칙을 통과한 경우에만 저장된다 |
|
||||
| S11 | `route-log` | routing 성공 또는 routing error가 발생한다 | request/execution이 종료된다 | route log에서 같은 request id로 입력, 후보, 선택/실패 사유, resource/quota snapshot을 추적할 수 있다 |
|
||||
| S12 | `contract-docs` | 구현이 `metadata.agent_group.task_file`을 지원한다 | 계약 문서와 README/config examples를 확인한다 | task file, params, filename contract, path-only payload policy가 문서화되어 있다 |
|
||||
| S13 | `config-examples` | 기본 `coding` group, 문서/테스트용 `docs` group, `PLAN`, `CODE_REVIEW`, `DOC` 예약어를 설정하려는 운영자가 있다 | config sample을 확인한다 | manual assignment, auto assignment, direct default agent, `default_agent=auto` group routing 예시가 재현 가능하게 제공된다 |
|
||||
| S14 | `routing-tests` | config validation, filename parsing, routing scorer, auto assignment result validation이 구현되어 있다 | targeted test suite를 실행한다 | S01~S13, S15~S17의 핵심 routing 계약이 자동 테스트로 검증된다 |
|
||||
| S15 | `group-schema` | agent group의 `grade_overlap`이 `2`로 설정되어 있다 | manual/auto assignment 결과를 검증한다 | 각 grade의 상위/하위 2개 grade까지 인접 후보 overlap이 range에 반영되며 lane별 coverage가 유지된다 |
|
||||
| S16 | `metadata-contract` | OpenAI-compatible request가 relative `metadata.agent_group.task_file`을 갖지만 `metadata.workspace`가 없다 | request metadata를 파싱한다 | 상대 task file을 해석하지 않고 routing-input-error를 반환한다 |
|
||||
| S17 | `prefix-schema` | 예약어가 `default_agent=<agent-id>` direct mode로 설정되어 있다 | `<agent-id>`가 cli provider agent catalog에 없는 상태로 config validation을 실행한다 | `agent_group`으로 우회하지 않고 missing direct default agent error를 반환한다 |
|
||||
|
||||
## Evidence Map
|
||||
|
||||
| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|
||||
|----------|-------------------|------------------|---------------------------|
|
||||
| S01 | config validation unit test | `agent-task/m-cli-agent-group-grade-routing/...` | `provider-agent` Roadmap Completion과 local/cloud capability test output |
|
||||
| S02 | lane coverage validation test | `agent-task/m-cli-agent-group-grade-routing/...` | `group-schema` Roadmap Completion과 coverage gap test output |
|
||||
| S03 | contain-set change detection test | `agent-task/m-cli-agent-group-grade-routing/...` | `group-schema` Roadmap Completion과 reorder-no-recompute test output |
|
||||
| S04 | route prefix validation test | `agent-task/m-cli-agent-group-grade-routing/...` | `prefix-schema` Roadmap Completion과 missing group/default agent test output |
|
||||
| S05 | OpenAI metadata parser test for absolute and relative task file paths | `agent-task/m-cli-agent-group-grade-routing/...` | `metadata-contract` Roadmap Completion과 parser test output |
|
||||
| S06 | filename parser positive table test | `agent-task/m-cli-agent-group-grade-routing/...` | `filename-parse` Roadmap Completion과 positive filename test output |
|
||||
| S07 | filename parser negative table test | `agent-task/m-cli-agent-group-grade-routing/...` | `filename-parse` Roadmap Completion과 routing-input-error test output |
|
||||
| S08 | direct default agent capability mismatch test | `agent-task/m-cli-agent-group-grade-routing/...` | `default-agent` Roadmap Completion과 direct-agent error test output |
|
||||
| S09 | manual routing scorer test with resource/quota metric fixtures | `agent-task/m-cli-agent-group-grade-routing/...` | `manual-routing` Roadmap Completion과 selected candidate evidence |
|
||||
| S10 | auto assignment prompt/schema validation test or golden fixture | `agent-task/m-cli-agent-group-grade-routing/...` | `auto-routing` Roadmap Completion과 benchmark profile selection evidence |
|
||||
| S11 | route log unit/integration test | `agent-task/m-cli-agent-group-grade-routing/...` | `route-log` Roadmap Completion과 request id trace evidence |
|
||||
| S12 | docs/contract diff plus doc link validation | `agent-task/m-cli-agent-group-grade-routing/...` | `contract-docs` Roadmap Completion과 OpenAI-compatible/README documentation evidence |
|
||||
| S13 | config sample diff plus validation output | `agent-task/m-cli-agent-group-grade-routing/...` | `config-examples` Roadmap Completion과 example validation evidence |
|
||||
| S14 | targeted routing/config test suite output | `agent-task/m-cli-agent-group-grade-routing/...` | `routing-tests` Roadmap Completion과 final verification output |
|
||||
| S15 | grade overlap validation test | `agent-task/m-cli-agent-group-grade-routing/...` | `group-schema` Roadmap Completion과 overlap width test output |
|
||||
| S16 | OpenAI metadata parser negative test for relative path without workspace | `agent-task/m-cli-agent-group-grade-routing/...` | `metadata-contract` Roadmap Completion과 missing workspace error evidence |
|
||||
| S17 | direct default agent missing validation test | `agent-task/m-cli-agent-group-grade-routing/...` | `prefix-schema` Roadmap Completion과 missing direct agent evidence |
|
||||
|
||||
## Cross-repo Dependencies
|
||||
|
||||
- 없음
|
||||
|
||||
## Drift Check
|
||||
|
||||
- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
|
||||
- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다.
|
||||
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
|
||||
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.
|
||||
|
||||
## 사용자 리뷰 이력
|
||||
|
||||
- 없음
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 표준선: 내부 실행은 `adapter + target` 기준을 유지한다. OpenAI-compatible agent group routing 문맥은 `metadata.agent_group` 아래에 둔다. task file 경로는 path-only payload로 전달하고 prefix/lane/grade는 basename에서만 파싱한다. agent group의 agent list 변경 감지는 순서가 아니라 contain set 기준이다.
|
||||
- agent-ops 결합 기준: `plan`/`code-review` 스킬은 `PLAN-*`/`CODE_REVIEW-*` 파일과 각 루프의 lifecycle을 소유하고, CLI provider agent 선택은 Edge/runtime routing 책임으로 둔다. 스킬이 provider/agent를 직접 고르거나 다른 스킬 그룹 절차를 자동 호출하지 않는다.
|
||||
- `metadata.agent_group`은 OpenAI-compatible 요청의 라우팅 metadata 컨테이너다. 실제 목적별 agent group assignment는 예약어 `default_agent=auto`일 때만 사용하며, direct `default_agent=<agent-id>` 요청은 같은 task file path와 filename validation을 사용하되 group 후보 산출로 넘어가지 않는다.
|
||||
- `DOC-*` 같은 추가 prefix는 이 Milestone에서는 route prefix 계약으로만 다룬다. 별도 문서 작성 skill lifecycle이 필요하면 후속 Milestone/SDD에서 추가하고, 이번 라우팅 계약은 prefix config와 runtime dispatch 경계만 고정한다.
|
||||
- 후속 SDD: D01이 자동 retry/fallback/중복 실행 방지의 별도 상태 전이를 크게 확장하면 CLI Agent 사용량 알림과 자동 이어받기 MVP SDD 또는 별도 follow-up SDD에서 다룬다.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# SDD User Review
|
||||
|
||||
## 상태
|
||||
|
||||
요청됨
|
||||
|
||||
## 검토 대상
|
||||
|
||||
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/cli-agent-group-grade-routing/SDD.md`
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md`
|
||||
|
||||
## 사용자 결정 항목
|
||||
|
||||
### [D01] 선택된 agent 실패 후 후속 정책
|
||||
|
||||
- 결정 필요: 선택된 agent 실행 실패, provider quota 소진, 실행 중단, 중복 실행 위험이 발생했을 때 MVP에서 자동 재라우팅/재시도/중단 중 어떤 정책을 적용할지 결정한다.
|
||||
- 추천안: 이번 Milestone에서는 fail-fast로 두고 route log와 execution error만 남긴다. 자동 재시도나 다른 agent fallback은 CLI Agent 사용량 알림과 자동 이어받기 MVP 또는 후속 Milestone에서 중복 실행 방지, partial output, 비용/limit 소진 정책과 함께 다룬다.
|
||||
- 대안: 같은 lane/grade 후보 중 다음 route score 후보로 1회 fallback한다. 이 경우 동일 task file 중복 실행 방지, 이전 실행 산출물 정리, quota/cost 상한, fallback 로그, 사용자 승인 조건을 같은 Milestone에서 추가로 확정해야 한다.
|
||||
- 영향: 실패 처리 정책은 상태 전이, 비용/limit 소진, 중복 실행, route log 의미, code-review/plan loop 재시작 방식에 영향을 준다. 결정 전에는 자동 retry/fallback 구현을 시작하지 않는다.
|
||||
- 적용 위치:
|
||||
- SDD: `SDD 잠금`, `State Machine`, `Interface Contract`, `문제 / 비목표`
|
||||
- Milestone: `구현 잠금`, `범위 제외`, `작업 컨텍스트`
|
||||
|
||||
## 승인 항목
|
||||
|
||||
- [ ] 위 결정 항목을 승인했다.
|
||||
- [ ] SDD 잠금 해제를 승인했다.
|
||||
|
||||
## 답변 기록
|
||||
|
||||
- 없음
|
||||
|
||||
## 해결 조건
|
||||
|
||||
- 모든 사용자 결정 항목의 답변이 SDD에 반영되어 있다.
|
||||
- `USER_REVIEW.md`가 `user_review_N.log`로 이동되어 있다.
|
||||
- 남은 잠금 항목이 없으면 SDD 상태가 `[승인됨]`이고 `SDD 잠금` 상태가 `해제`다.
|
||||
|
|
@ -43,45 +43,52 @@ task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config, plan=0,
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [NCDRAIN-1] Adapter Drain And Deferred Stop | [ ] |
|
||||
| [NCDRAIN-2] Runtime Concurrency Live Apply | [ ] |
|
||||
| [NCDRAIN-3] Workspace Restart Required And Edge Classification | [ ] |
|
||||
| [NCDRAIN-1] Adapter Drain And Deferred Stop | [x] |
|
||||
| [NCDRAIN-2] Runtime Concurrency Live Apply | [x] |
|
||||
| [NCDRAIN-3] Workspace Restart Required And Edge Classification | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `05+04_node_adapter_diff`의 `complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다.
|
||||
- [ ] `runManager`에 adapter별 active run snapshot/wait 기능을 추가하고 changed/removed adapter stop을 run completion 이후로 지연한다. 검증: in-flight run 중 refresh해도 기존 run은 old adapter로 complete된다.
|
||||
- [ ] 후속 request가 refresh 후 새 registry/adapter snapshot으로 라우팅되는 integration test를 추가한다. 검증: old adapter call count와 new adapter call count가 기대값과 일치한다.
|
||||
- [ ] runtime concurrency live apply를 구현하고 `workspace_root` 변경은 restart_required로 보고한다. 검증: concurrency 변경 후 admission limit이 바뀌고 workspace root 변경은 live store를 바꾸지 않는다.
|
||||
- [ ] Edge config refresh classifier/result가 node runtime concurrency는 applied, workspace root는 restart_required/node result로 보고하게 조정한다. 검증: Edge classifier/runtime tests가 S15 경계를 확인한다.
|
||||
- [ ] 전체 Edge/Node 회귀로 `go test -count=1 ./apps/node/...`와 `go test -count=1 ./apps/edge/...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] `05+04_node_adapter_diff`의 `complete.log`가 active/archive 중 하나에 있는지 확인하고, 없으면 구현하지 말고 review stub `검증 결과`에 dependency blocker로 기록한다. → `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log` 존재(최종 verdict PASS), dependency 충족.
|
||||
- [x] `runManager`에 adapter별 active run snapshot/wait 기능을 추가하고 changed/removed adapter stop을 run completion 이후로 지연한다. 검증: in-flight run 중 refresh해도 기존 run은 old adapter로 complete된다.
|
||||
- [x] 후속 request가 refresh 후 새 registry/adapter snapshot으로 라우팅되는 integration test를 추가한다. 검증: old adapter call count와 new adapter call count가 기대값과 일치한다.
|
||||
- [x] runtime concurrency live apply를 구현하고 `workspace_root` 변경은 restart_required로 보고한다. 검증: concurrency 변경 후 admission limit이 바뀌고 workspace root 변경은 live store를 바꾸지 않는다.
|
||||
- [x] Edge config refresh classifier/result가 node runtime concurrency는 applied, workspace root는 restart_required/node result로 보고하게 조정한다. 검증: Edge classifier/runtime tests가 S15 경계를 확인한다.
|
||||
- [x] 전체 Edge/Node 회귀로 `go test -count=1 ./apps/node/...`와 `go test -count=1 ./apps/edge/...`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- NCDRAIN-1 drain: 계획의 `activeByAdapter(adapterKey)` per-adapter wait 대신 `runManager.snapshotActive()`로 pre-swap active handle 전체 스냅샷을 잡고 패키지 함수 `waitHandles(ctx, handles)`로 drain한다. 이유: 기존 `OnConfigRefresh`/`Registry.Stop`이 old registry를 통째로 stop하는 구조(05+04에서 확정)라, stop 단위가 adapter가 아니라 registry다. per-adapter wait는 registry 통째 stop과 맞지 않아 over-engineering이 되므로, "pre-swap run이 모두 drain된 뒤 old registry stop"이라는 동일 보장을 전체 스냅샷 wait로 구현했다. 이전 구현은 active run이 있으면 stop을 영구 skip(누수)했는데, 이번에 실제 deferred stop goroutine으로 교체했다.
|
||||
- NCDRAIN-2: 계획의 `setCapacity`는 05+04에서 이미 추가된 `fifoGate.updateCapacity`로 존재한다. 추가로 change 감지를 위한 getter `fifoGate.currentCapacity()`만 신설했다(`capacity` 필드명과 충돌해 method명은 `currentCapacity`). global concurrency apply는 adapter diff 유무와 무관하게 동작해야 하므로 "no adapter changes" early-return 이전으로 runtime 처리를 끌어올렸다.
|
||||
- NCDRAIN-3 workspace 추적: Node가 현재 workspace_root를 알 수 있도록 `node.New` 시그니처를 바꾸는 대신(호출부 11곳 영향) `adapters.ConfigSet`에 `Runtime{Concurrency, WorkspaceRoot}` 필드를 추가하고 `BuildConfigSet`이 payload runtime에서 채우게 했다. Node는 `currentConfigSet.Runtime.WorkspaceRoot`로 변경을 감지한다. module.go는 이미 `BuildConfigSet`으로 만든 set을 `New`에 넘기므로 초기값이 자동 반영되고 New 시그니처/storeDSN은 불변이다(계획의 module.go 불변 요구 충족).
|
||||
- 최종 검증 명령은 계획/스텁의 고정 계약 그대로 실행했고 대체는 없다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
- Deferred stop은 swap(router.SetRegistry) 이후 pre-swap handle 스냅샷을 잡고 goroutine에서 `waitHandles` → `oldSet.Registry.Stop` 순으로 수행한다. 후속 request는 swap 직후 새 registry로 라우팅되고, 진행 중 run은 captured `oldSet`(reassign 전 값)으로 완료 후 stop된다. `configSetMu.Lock` 보유 중 `oldSet`을 캡처하므로 reassignment race가 없다.
|
||||
- workspace_root 변경은 store DB path가 bootstrap에 고정되어 live reopen이 위험하므로 Node가 store를 건드리지 않고 `RESTART_REQUIRED` + `restart_required_paths=["runtime.workspace_root"]`로 보고하고 early-return한다. Edge 분류는 `nodes[%q].runtime`을 통째 restart_required로 묶던 것을 `runtime.concurrency`(applied)와 `runtime.workspace_root`(restart_required) field별로 분리했다. `RuntimeConf`는 이 두 필드만 가지므로 분리가 전체를 커버한다.
|
||||
- Edge `RefreshConfig`는 overall status가 applied일 때만 node push를 수행하므로, workspace_root 변경(restart_required)은 자연히 node push가 일어나지 않는다(runtime_test로 확인). concurrency 변경은 applied로 분류되어 node로 push되고 node가 live apply 후 node result에 보고된다.
|
||||
- concurrency 감소는 `updateCapacity`만 호출하고 이미 admit된 run을 취소하지 않는다. `fifoGate.acquire`는 후속 acquire부터 새 capacity를 적용하므로 in-flight run은 영향받지 않는다(`TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight`로 확인).
|
||||
- runtime 블록이 없는(`GetRuntime()==nil`) payload는 concurrency/workspace 처리를 전부 skip해 기존 adapter-only refresh 동작과 호환을 유지한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -115,34 +122,81 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
Dependency 확인: `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log` 존재(최종 verdict PASS). predecessor 충족 → 구현 진행.
|
||||
|
||||
추가/관련 테스트:
|
||||
- NCDRAIN-1: `TestNodeConfigRefreshDeferredStopRunsAfterActiveRunCompletes`(신규, deferred stop이 active run drain 후 실제 실행됨 확인). 기존 `TestNodeConfigRefreshDoesNotStopOldRegistryWithActiveRun`(active 중 stop 미호출 + 후속 request 새 registry 라우팅)와 `...WaitsForResolvedRunRegistrationBeforeStoppingOldRegistry`는 회귀로 유지.
|
||||
- NCDRAIN-2: `TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns`, `TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight`(신규).
|
||||
- NCDRAIN-3: `TestConfigRefreshWorkspaceRootReportsRestartRequired`(node, 신규), `TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired`(edge classify, 신규), `TestRefreshConfigNodeRuntimeConcurrencyAppliedAndWorkspaceRestartRequired`(edge runtime, 신규).
|
||||
|
||||
### NCDRAIN-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
|
||||
(output)
|
||||
ok iop/apps/node/internal/node 0.290s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
```
|
||||
|
||||
### NCDRAIN-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/node/internal/node
|
||||
(output)
|
||||
ok iop/apps/node/internal/node 0.295s
|
||||
```
|
||||
|
||||
### NCDRAIN-3 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
|
||||
(output)
|
||||
ok iop/apps/edge/internal/configrefresh 0.015s
|
||||
ok iop/apps/edge/internal/bootstrap 0.265s
|
||||
ok iop/apps/node/internal/node 0.291s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters
|
||||
(output)
|
||||
ok iop/apps/node/internal/node 0.290s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
|
||||
$ go test -count=1 ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap ./apps/node/internal/node
|
||||
(output)
|
||||
ok iop/apps/edge/internal/configrefresh 0.015s
|
||||
ok iop/apps/edge/internal/bootstrap 0.265s
|
||||
ok iop/apps/node/internal/node 0.291s
|
||||
|
||||
$ go test -count=1 ./apps/node/...
|
||||
(output)
|
||||
ok iop/apps/node/cmd/node 0.014s
|
||||
ok iop/apps/node/internal/adapters 0.010s
|
||||
ok iop/apps/node/internal/adapters/cli 46.750s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.846s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.014s
|
||||
ok iop/apps/node/internal/adapters/openai_compat 0.010s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.009s
|
||||
ok iop/apps/node/internal/bootstrap 0.701s
|
||||
ok iop/apps/node/internal/node 0.288s
|
||||
ok iop/apps/node/internal/router 0.505s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.036s
|
||||
ok iop/apps/node/internal/terminal 0.553s
|
||||
ok iop/apps/node/internal/transport 5.339s
|
||||
|
||||
$ go test -count=1 ./apps/edge/...
|
||||
(output)
|
||||
ok iop/apps/edge/cmd/edge 0.045s
|
||||
ok iop/apps/edge/internal/bootstrap 0.274s
|
||||
ok iop/apps/edge/internal/configrefresh 0.011s
|
||||
ok iop/apps/edge/internal/controlplane 4.451s
|
||||
ok iop/apps/edge/internal/edgecmd 0.021s
|
||||
? iop/apps/edge/internal/edgevalidate [no test files]
|
||||
ok iop/apps/edge/internal/events 0.003s
|
||||
ok iop/apps/edge/internal/input 0.006s
|
||||
ok iop/apps/edge/internal/input/a2a 0.006s
|
||||
ok iop/apps/edge/internal/node 0.008s
|
||||
ok iop/apps/edge/internal/openai 1.510s
|
||||
ok iop/apps/edge/internal/opsconsole 0.006s
|
||||
ok iop/apps/edge/internal/service 0.445s
|
||||
ok iop/apps/edge/internal/transport 2.038s
|
||||
|
||||
$ git diff --check
|
||||
(no whitespace errors)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -163,3 +217,19 @@ $ go test -count=1 ./apps/edge/...
|
|||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation. |
|
||||
| 검증 결과 | Implementing agent | Paste actual command output. |
|
||||
| 코드리뷰 결과, 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit. |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Fail
|
||||
- verification trust: Pass
|
||||
- spec conformance: Fail
|
||||
- 발견된 문제:
|
||||
- Required - `apps/node/internal/node/node.go:345`: `OnConfigRefresh`가 `runtime.concurrency`를 `globalGate`에 먼저 적용한 뒤, 이후 `nextSet.Registry.Start(ctx)`가 실패하면 `FAILED`를 반환합니다(`apps/node/internal/node/node.go:377`). 이 경우 adapter refresh는 실패했지만 Node admission limit만 새 값으로 남아 SDD의 previous-state preservation 및 `runtime-config` 경계를 깨뜨립니다. fix: concurrency 적용을 adapter registry start/swap 성공 이후로 늦추거나, 실패 경로에서 이전 capacity로 rollback하세요. regression test는 현재 concurrency=1에서 refresh payload가 concurrency=2와 `Start` 실패 lifecycle adapter를 함께 포함할 때 `FAILED` 후 두 번째 concurrent run이 계속 reject되는지, 또는 gate capacity가 1로 유지되는지 검증해야 합니다.
|
||||
- 다음 단계: FAIL follow-up plan/review를 작성한다.
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<!-- task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config code_review=G06 tag=REVIEW_NCDRAIN -->
|
||||
|
||||
# Code Review - REVIEW_NCDRAIN (G06)
|
||||
|
||||
## 구현 에이전트 소유 섹션
|
||||
|
||||
### 구현 내용
|
||||
|
||||
REVIEW_NCDRAIN-1: `OnConfigRefresh`에서 `runtime.concurrency` mutation을 adapter registry start/swap 성공 이후로 지연시켰다.
|
||||
|
||||
**변경된 파일:**
|
||||
- `apps/node/internal/node/node.go`: runtime concurrency commit ordering 수정
|
||||
- `apps/node/internal/node/node_test.go`: `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency` regression test 추가
|
||||
|
||||
**상세 변경 (node.go):**
|
||||
1. **라인 327~348**: runtime config 읽을 때 `nextConcurrency`와 `hasRuntimeConcurrencyChange`만 계산하고 `globalGate.updateCapacity()`를 호출하지 않는다.
|
||||
2. **라인 353~367**: adapter diff가 없으면 (no-op 경로) `currentConfigSet.Runtime`을 갱신하고 `hasRuntimeConcurrencyChange`가 true면 즉시 concurrency 적용.
|
||||
3. **라인 376~384**: `nextSet.Registry.Start(ctx)` 실패 시 `FAILED` 반환. 이 시점에 global gate는 mutation되지 않았으므로 이전 상태 유지.
|
||||
4. **라인 386~411**: adapter registry start 성공 → router swap → adapter gate update → **그 후에** `n.globalGate.updateCapacity(nextConcurrency)` 적용.
|
||||
5. **라인 438**: `n.currentConfigSet = nextSet` 갱신.
|
||||
|
||||
**상세 변경 (node_test.go):**
|
||||
- `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency` 추가 (라인 2717~2830)
|
||||
- `blockingAdapterWithStart` test double 추가 (라인 2832~2865): `MaxConcurrency: 0` (무제한 per-adapter), global gate만으로 결합됨.
|
||||
- Fixture: concurrency=1, active run 유지 중 `Runtime{Concurrency:2}` + failing CLI persistent profile (`/nonexistent/path/to/binary`) 으로 `Registry.Start` 실패 시나리오 재현.
|
||||
- Assertion: refresh response `FAILED` + 두 번째 run이 `ErrConcurrencyLimitExceeded`로 reject되어 이전 concurrency=1이 보존됨.
|
||||
|
||||
### 검증 stdout/stderr
|
||||
|
||||
```
|
||||
=== RUN TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns
|
||||
--- PASS: TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns (0.00s)
|
||||
=== RUN TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight
|
||||
--- PASS: TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight (0.00s)
|
||||
=== RUN TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency
|
||||
--- PASS: TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency (0.00s)
|
||||
=== RUN TestConfigRefreshWorkspaceRootReportsRestartRequired
|
||||
--- PASS: TestConfigRefreshWorkspaceRootReportsRestartRequired (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/node 0.011s
|
||||
|
||||
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
|
||||
ok iop/apps/node/internal/node 0.299s
|
||||
ok iop/apps/node/internal/adapters 0.010s
|
||||
ok iop/apps/edge/internal/configrefresh 0.011s
|
||||
ok iop/apps/edge/internal/bootstrap 0.264s
|
||||
|
||||
git diff --check
|
||||
(no output - whitespace check passed)
|
||||
```
|
||||
|
||||
### 구현 검증 checklist
|
||||
|
||||
- [x] `OnConfigRefresh`에서 `runtime.concurrency`를 adapter registry start/swap 성공 이후에 commit한다. 실패 경로는 mutation 없이 이전 상태 유지.
|
||||
- [x] `apps/node/internal/node/node_test.go`에 `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency`를 추가했다.
|
||||
- Fixture: concurrency=1, active run 중 `Runtime{Concurrency:2}` + failing CLI persistent profile (`/nonexistent/path/to/binary`) → `FAILED`.
|
||||
- Assertion: 두 번째 concurrent run은 계속 `ErrConcurrencyLimitExceeded`로 reject.
|
||||
- [x] 대상 회귀: `TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)` PASS (v4 재검증).
|
||||
- [x] 최종 회귀: 4패키지 PASS, `git diff --check` PASS (v4 재검증).
|
||||
- [x] Go test cache output 없음 (`-count=1` 사용).
|
||||
|
||||
### 사용자 리뷰 요청
|
||||
|
||||
해당 없음. 구현이 막히지 않았고 사용자 리뷰가 필요한 환경/secret/service 차단, 일반 범위 조정, evidence 공백이 없다.
|
||||
|
||||
## Reviewer 검증 섹션
|
||||
|
||||
- [x] SDD S15 `runtime-config` 실패 원상유지: `runtime.concurrency` live apply가 실패 refresh에서 이전 admission state를 보존함을 regression test로 검증했다.
|
||||
- [x] SDD S14 `drain` 회귀: 기존 deferred stop, in-flight run completion, follow-up routing tests가 통과했다.
|
||||
- [x] 빌드/포맷: `git diff --check` PASS.
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Pass
|
||||
- verification trust: Pass
|
||||
- spec conformance: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS 완료 처리로 `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.
|
||||
|
||||
## 리뷰 중 정리
|
||||
|
||||
- `gofmt -w apps/node/internal/node/node.go apps/node/internal/node/node_test.go apps/node/internal/node/run_manager.go apps/node/internal/adapters/config_set.go`로 포맷 drift를 정리했다.
|
||||
- 포맷 정리 후 `gofmt -l` 재확인 결과 출력 없음.
|
||||
- 포맷 정리 후 대상 회귀 재실행: `go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node` PASS (`ok iop/apps/node/internal/node 0.008s`).
|
||||
- 포맷 정리 후 최종 4패키지 재실행: `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap` PASS (`node 0.292s`, `adapters 0.009s`, `configrefresh 0.013s`, `bootstrap 0.266s`).
|
||||
- 포맷 정리 후 `git diff --check` PASS.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-local-G06.md`를 `code_review_local_G06_1.log`로 아카이브한다.
|
||||
- [x] active `PLAN-local-G06.md`를 `plan_local_G06_1.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이므로 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이므로 active task 디렉터리 `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/`를 `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-runtime-reconnect-config-refresh`이므로 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이므로 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거했다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Complete - m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-23
|
||||
|
||||
## 요약
|
||||
|
||||
Edge/Node runtime reconnect config refresh의 node drain/runtime config subtask를 2개 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | adapter start 실패 refresh에서 `runtime.concurrency`가 먼저 적용되어 previous-state preservation을 깨는 Required 1건 확인 |
|
||||
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | concurrency commit을 adapter start/swap 성공 이후로 지연하고 실패 경로 regression test로 확인 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `OnConfigRefresh`의 runtime concurrency mutation을 adapter registry start/swap 성공 이후로 지연해 failed refresh가 이전 global admission state를 유지하게 했다.
|
||||
- `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency`로 concurrency=1 active run 중 concurrency=2와 failing CLI persistent profile이 함께 온 refresh가 `FAILED` 후에도 두 번째 run을 계속 reject하는지 검증했다.
|
||||
- 기존 drain/deferred stop, runtime concurrency live apply, workspace restart_required, Edge classifier/runtime refresh 회귀를 유지했다.
|
||||
- 리뷰 중 `gofmt` 포맷 drift를 정리하고 동일 검증을 재실행했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `gofmt -l apps/node/internal/node/node.go apps/node/internal/node/node_test.go apps/node/internal/node/run_manager.go apps/node/internal/adapters/config_set.go apps/edge/internal/configrefresh/classify.go apps/edge/internal/configrefresh/classify_test.go apps/edge/internal/bootstrap/runtime_test.go` - PASS; no output
|
||||
- `go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node` - PASS; `ok iop/apps/node/internal/node 0.008s`
|
||||
- `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap` - PASS; `node 0.292s`, `adapters 0.009s`, `configrefresh 0.013s`, `bootstrap 0.266s`
|
||||
- `git diff --check` - PASS; no output
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
|
||||
- Completed task ids:
|
||||
- `drain`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_cloud_G08_0.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_cloud_G08_0.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_local_G06_1.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_local_G06_1.log`; verification=`go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`
|
||||
- `runtime-config`: PASS; evidence=`agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_cloud_G08_0.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_cloud_G08_0.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_local_G06_1.log`, `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_local_G06_1.log`; verification=`go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
<!-- task=m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config plan=1 tag=REVIEW_NCDRAIN -->
|
||||
|
||||
# Plan - REVIEW_NCDRAIN
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고, 실제 구현 내용과 실제 stdout/stderr를 리뷰 stub에 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 archive, `complete.log`, roadmap 완료 처리는 code-review/runtime 책임이다. 선택된 Milestone의 `구현 잠금 > 결정 필요` 항목 때문에 구현이 막힐 때만 리뷰 stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`/`complete.log`를 직접 만들지 않는다. 환경/secret/service 차단, 일반 범위 조정, 후속 에이전트가 닫을 수 있는 evidence 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
이 후속 루프는 `code_review_cloud_G08_0.log`의 Required 1건만 닫는다. 현재 구현은 `runtime.concurrency`를 먼저 live apply한 뒤 adapter registry start를 수행하므로, adapter apply 실패 시 Node admission limit만 새 값으로 남을 수 있다. SDD의 previous-state preservation과 `runtime-config` 완료 evidence를 만족하려면 refresh 실패 경로에서 runtime gate도 이전 상태를 유지해야 한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 사용자 리뷰 요청은 선택된 Milestone lock decision이 실제 구현을 막을 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 템플릿을 따른다. 구현 에이전트는 직접 사용자에게 질문하지 않으며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다.
|
||||
|
||||
## Archive Evidence Snapshot
|
||||
|
||||
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_cloud_G08_0.log`
|
||||
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_cloud_G08_0.log`
|
||||
- Verdict: FAIL
|
||||
- Required summary:
|
||||
- `apps/node/internal/node/node.go:345`: `OnConfigRefresh` applies `runtime.concurrency` to `globalGate` before `nextSet.Registry.Start(ctx)` can fail at `apps/node/internal/node/node.go:377`, leaving admission capacity changed after a failed refresh.
|
||||
- Suggested issues: 없음
|
||||
- Nit issues: 없음
|
||||
- Affected files:
|
||||
- `apps/node/internal/node/node.go`
|
||||
- `apps/node/internal/node/node_test.go`
|
||||
- Prior verification evidence:
|
||||
- Reviewer rerun: `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap` PASS.
|
||||
- Reviewer rerun: `git diff --check` PASS.
|
||||
- Implementer evidence in archived review includes `go test -count=1 ./apps/node/...`, `go test -count=1 ./apps/edge/...`, and `git diff --check` PASS.
|
||||
- Roadmap carryover:
|
||||
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
|
||||
- Task ids remain `drain`, `runtime-config`.
|
||||
- The follow-up must close SDD S15 `runtime-config` without regressing S14 `drain`.
|
||||
- Narrow reread allowed:
|
||||
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_cloud_G08_0.log`
|
||||
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_cloud_G08_0.log`
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
|
||||
- Task ids:
|
||||
- `drain`: 진행 중 run이 있는 adapter/provider 변경은 drop하지 않고 기존 run은 이전 adapter snapshot으로 완료시키며, 후속 request는 새 config 기준으로 라우팅한다. 검증: in-flight run 중 refresh 시 run completion과 후속 request routing이 기대대로 동작한다.
|
||||
- `runtime-config`: runtime concurrency와 workspace root 변경의 live apply 가능 여부가 구현되고, 불가능한 변경은 restart_required로 보고된다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/common/rules-roadmap.md`
|
||||
- `agent-ops/skills/common/router.md`
|
||||
- `agent-ops/skills/common/code-review/SKILL.md`
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-ops/rules/project/domain/node/rules.md`
|
||||
- `agent-ops/rules/project/domain/edge/rules.md`
|
||||
- `agent-ops/rules/project/domain/platform-common/rules.md`
|
||||
- `agent-ops/rules/project/domain/testing/rules.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/node-smoke.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-test/local/platform-common-smoke.md`
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
|
||||
- `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
|
||||
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/plan_cloud_G08_0.log`
|
||||
- `agent-task/m-runtime-reconnect-config-refresh/06+05_node_drain_runtime_config/code_review_cloud_G08_0.log`
|
||||
- `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log`
|
||||
- `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/adapters/config_set.go`
|
||||
- `apps/node/internal/adapters/registry.go`
|
||||
- `apps/node/internal/adapters/factory.go`
|
||||
- `apps/node/internal/adapters/cli/cli.go`
|
||||
- `apps/edge/internal/bootstrap/runtime.go`
|
||||
- `apps/edge/internal/bootstrap/runtime_test.go`
|
||||
- `apps/edge/internal/configrefresh/classify.go`
|
||||
- `apps/edge/internal/configrefresh/classify_test.go`
|
||||
- `apps/edge/internal/node/mapper.go`
|
||||
- `apps/edge/internal/node/store.go`
|
||||
- `apps/edge/internal/transport/server.go`
|
||||
|
||||
### SDD 기준
|
||||
|
||||
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
|
||||
- 상태: 승인됨, SDD 잠금 해제.
|
||||
- 대상 Acceptance Scenario: S14 / `drain`, S15 / `runtime-config`.
|
||||
- Evidence Map: S14는 in-flight run refresh integration test와 previous adapter snapshot completion plus follow-up routing을 요구한다. S15는 runtime concurrency/workspace refresh tests와 live or restart_required behavior verified를 요구한다.
|
||||
- 이 follow-up은 S15의 실패 원상유지 경계를 보강한다. `runtime.concurrency` live apply가 성공 refresh에서만 commit되고 실패 refresh에서는 이전 admission state를 보존해야 S15 evidence가 완결된다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- `agent-test/local/rules.md`는 local checkout의 Go quick check를 우선하고, 실행 경로 변경 시 필수 검증 누락을 차단 사유로 기록하라고 한다.
|
||||
- `agent-test/local/node-smoke.md`는 `apps/node/**` 변경 시 변경 패키지 또는 `go test ./apps/node/...`를 요구한다.
|
||||
- `agent-test/local/edge-smoke.md`는 Edge 변경 시 `go test ./apps/edge/...`를 요구한다. 이 follow-up은 Edge 소스 변경을 의도하지 않지만 기존 archived scope가 Edge 결과와 연결되어 있어 최종 회귀에 포함한다.
|
||||
- `agent-test/local/platform-common-smoke.md`는 config/proto 변경 시 적용된다. 이 follow-up은 config/proto 원본 변경을 포함하지 않는다.
|
||||
- Go test cache output은 허용하지 않는다. fresh run을 위해 `-count=1`을 사용한다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- 기존 구현은 concurrency 증가/감소 성공 경로, workspace restart_required, drain/deferred stop, Edge classifier를 테스트한다.
|
||||
- 공백: `runtime.concurrency`와 adapter diff가 같은 refresh payload에 있고, adapter registry start가 실패할 때 concurrency gate가 이전 값으로 유지되는 regression test가 없다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- rename/remove 없음.
|
||||
- 새 exported API 추가는 불필요하다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- 기존 split subtask `06+05_node_drain_runtime_config` 안의 후속 루프다.
|
||||
- predecessor `05`는 `agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/05+04_node_adapter_diff/complete.log`로 충족되었다.
|
||||
- Required가 하나의 Node atomicity bug로 좁혀져 추가 split은 불필요하다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- 포함: `OnConfigRefresh`의 runtime concurrency commit/rollback ordering, 실패 경로 regression test.
|
||||
- 제외: Edge classifier/result 변경, workspace root 정책 변경, deferred stop 구조 변경, field smoke, docs, roadmap 문서 갱신.
|
||||
- unrelated working tree의 `agent-roadmap/phase/automation-runtime-bridge/**` 변경은 이 review follow-up 범위가 아니다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- lane/GNN: `local-G06`.
|
||||
- 이유: 단일 Required issue이며 bounded Node shared-state fix와 deterministic Go regression test로 판단 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `OnConfigRefresh`에서 `runtime.concurrency`를 adapter registry start/swap 성공 이후에 commit하거나, 실패 경로에서 이전 `globalGate` capacity로 rollback한다. 검증: adapter start 실패 refresh 후 admission limit이 이전 값으로 유지된다.
|
||||
- [ ] `apps/node/internal/node/node_test.go`에 `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency`를 추가한다. 검증: concurrency=1 상태에서 active run 중 concurrency=2 + failing CLI persistent profile refresh가 `FAILED`를 반환하고, 두 번째 concurrent run은 계속 `ErrConcurrencyLimitExceeded`로 reject된다.
|
||||
- [ ] 대상 회귀로 `go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node`를 실행한다.
|
||||
- [ ] 최종 회귀로 `go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap`와 `git diff --check`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
1. Regression test를 먼저 추가해 현재 실패를 확인한다.
|
||||
2. `OnConfigRefresh` ordering/rollback을 수정한다.
|
||||
3. 대상 회귀와 최종 회귀를 fresh run으로 실행한다.
|
||||
|
||||
### [REVIEW_NCDRAIN-1] Runtime Concurrency Rollback On Failed Refresh
|
||||
|
||||
#### 문제
|
||||
|
||||
`apps/node/internal/node/node.go:345`에서 `nextConcurrency`가 기존 capacity와 다르면 즉시 `n.globalGate.updateCapacity(nextConcurrency)`가 실행된다. 이후 `apps/node/internal/node/node.go:377`의 `nextSet.Registry.Start(ctx)`가 실패하면 response는 `FAILED`지만, global admission gate는 이미 새 concurrency를 들고 있다.
|
||||
|
||||
Before `apps/node/internal/node/node.go:345`:
|
||||
|
||||
```go
|
||||
nextConcurrency := int(rc.GetConcurrency())
|
||||
if prev := n.globalGate.currentCapacity(); nextConcurrency != prev {
|
||||
n.logger.Info("config refresh: applying runtime concurrency",
|
||||
zap.Int("previous", prev),
|
||||
zap.Int("next", nextConcurrency),
|
||||
)
|
||||
n.globalGate.updateCapacity(nextConcurrency)
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Runtime diff는 먼저 계산만 하고 mutation은 성공 commit 지점으로 늦춘다. Adapter diff가 없으면 기존처럼 바로 applied로 반환하기 전에 concurrency를 적용하고 `currentConfigSet.Runtime`을 갱신한다. Adapter diff가 있으면 `nextSet.Registry.Start(ctx)` 성공, router swap, adapter gate update 직후 `n.globalGate.updateCapacity(nextConcurrency)`를 적용하고 `n.currentConfigSet = nextSet`까지 같은 성공 경로에서 완료한다. 대안으로 선적용을 유지한다면 `prevConcurrency`를 저장하고 `nextSet.Registry.Start(ctx)` 실패 전후의 모든 실패 return에서 rollback해야 한다.
|
||||
|
||||
After example:
|
||||
|
||||
```go
|
||||
nextConcurrency, hasRuntimeConcurrencyChange := 0, false
|
||||
if rc := req.GetConfig().GetRuntime(); rc != nil {
|
||||
nextConcurrency = int(rc.GetConcurrency())
|
||||
hasRuntimeConcurrencyChange = nextConcurrency != n.globalGate.currentCapacity()
|
||||
}
|
||||
|
||||
// ... after nextSet.Registry.Start(ctx) succeeds and before returning APPLIED:
|
||||
if hasRuntimeConcurrencyChange {
|
||||
n.globalGate.updateCapacity(nextConcurrency)
|
||||
}
|
||||
n.currentConfigSet = nextSet
|
||||
```
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/node/internal/node/node.go`: runtime concurrency mutation을 failed refresh path 밖으로 이동하거나 rollback 처리.
|
||||
- [ ] `apps/node/internal/node/node_test.go`: failing CLI persistent profile로 `Registry.Start` 실패를 재현하는 regression test 추가.
|
||||
- [ ] 기존 `TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns`, `TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight`, `TestConfigRefreshWorkspaceRootReportsRestartRequired`를 유지한다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 추가: `TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency`.
|
||||
- Fixture: `newQueuedSlowAdapter("slow", 0, 0, 0)`으로 active run을 잡고 global concurrency=1을 채운다. Refresh payload는 `Runtime{Concurrency:2}`와 autostart persistent CLI profile을 포함하되 command는 존재하지 않는 path로 둬 `Registry.Start` 실패를 만든다.
|
||||
- Assertion: refresh response는 `FAILED`; active run이 유지되는 동안 두 번째 run은 `ErrConcurrencyLimitExceeded`로 reject되어 이전 global concurrency=1이 보존됨을 증명한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node
|
||||
```
|
||||
|
||||
기대 결과: 새 regression과 기존 runtime config tests PASS.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/node/internal/node/node.go` | REVIEW_NCDRAIN-1 |
|
||||
| `apps/node/internal/node/node_test.go` | REVIEW_NCDRAIN-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 -run 'TestConfigRefresh(FailedAdapterStartDoesNotApplyRuntimeConcurrency|UpdatesGlobalConcurrencyForFutureRuns|ConcurrencyDecreaseDoesNotCancelInFlight|WorkspaceRootReportsRestartRequired)' ./apps/node/internal/node
|
||||
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters ./apps/edge/internal/configrefresh ./apps/edge/internal/bootstrap
|
||||
git diff --check
|
||||
```
|
||||
|
||||
기대 결과: Node failed-refresh rollback regression, runtime config refresh tests, targeted Node/Edge packages, whitespace check가 모두 PASS. Go test cache output은 허용하지 않는다.
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -1643,3 +1643,180 @@ func TestRefreshRejectedPreservesProviderDispatch(t *testing.T) {
|
|||
t.Errorf("after rejected refresh target: got %q, want %q", afterReq.GetTarget(), "served-qwen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshConfigNodeRuntimeConcurrencyAppliedAndWorkspaceRestartRequired
|
||||
// verifies the S15 boundary at the running-edge level: a node runtime.concurrency
|
||||
// change is applied live and pushed to the connected node (node result reported),
|
||||
// while a runtime.workspace_root change is classified restart_required and is NOT
|
||||
// pushed to nodes (no node results) nor committed to the runtime snapshot.
|
||||
func TestRefreshConfigNodeRuntimeConcurrencyAppliedAndWorkspaceRestartRequired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
serverAddr := freeTCPAddr(t)
|
||||
|
||||
writeCfg := func(name string, concurrency int, workspaceRoot string) string {
|
||||
path := filepath.Join(dir, name)
|
||||
yaml := fmt.Sprintf(`
|
||||
server:
|
||||
listen: %q
|
||||
bootstrap:
|
||||
listen: "0.0.0.0:18190"
|
||||
artifact_dir: "artifacts"
|
||||
logging:
|
||||
level: "error"
|
||||
refresh:
|
||||
enabled: false
|
||||
listen: "127.0.0.1:0"
|
||||
openai:
|
||||
listen: "0.0.0.0:18191"
|
||||
a2a:
|
||||
listen: "0.0.0.0:8182"
|
||||
metrics:
|
||||
port: 0
|
||||
nodes:
|
||||
- id: "node-rt-1"
|
||||
alias: "rt-node"
|
||||
token: "tok-rt"
|
||||
adapters:
|
||||
cli:
|
||||
enabled: true
|
||||
runtime:
|
||||
concurrency: %d
|
||||
workspace_root: %q
|
||||
`, serverAddr, concurrency, workspaceRoot)
|
||||
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
basePath := writeCfg("base.yaml", 2, "/var/lib/iop/ws-a")
|
||||
concurrencyPath := writeCfg("concurrency.yaml", 4, "/var/lib/iop/ws-a")
|
||||
workspacePath := writeCfg("workspace.yaml", 2, "/var/lib/iop/ws-b")
|
||||
|
||||
rt, err := NewRuntime(loadServeNormalizedConfig(t, basePath))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
if err := rt.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := rt.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
nodeParserMap := toki.ParserMap{
|
||||
toki.TypeNameOf(&iop.RegisterResponse{}): func(b []byte) (proto.Message, error) {
|
||||
m := &iop.RegisterResponse{}
|
||||
return m, proto.Unmarshal(b, m)
|
||||
},
|
||||
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
|
||||
m := &iop.NodeConfigRefreshRequest{}
|
||||
return m, proto.Unmarshal(b, m)
|
||||
},
|
||||
}
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(serverAddr)
|
||||
port := 0
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fakeNode, err := toki.DialTcp(ctx, host, port, 30, 10, nodeParserMap)
|
||||
if err != nil {
|
||||
t.Fatalf("dial edge: %v", err)
|
||||
}
|
||||
defer fakeNode.Close()
|
||||
|
||||
var capturedConcurrency int32 = -1
|
||||
var capturedMu sync.Mutex
|
||||
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
|
||||
&fakeNode.Communicator,
|
||||
func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
|
||||
capturedMu.Lock()
|
||||
capturedConcurrency = req.GetConfig().GetRuntime().GetConcurrency()
|
||||
capturedMu.Unlock()
|
||||
return &iop.NodeConfigRefreshResponse{
|
||||
RequestId: req.GetRequestId(),
|
||||
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
regResp, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
||||
&fakeNode.Communicator,
|
||||
&iop.RegisterRequest{Token: "tok-rt"},
|
||||
2*time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if !regResp.GetAccepted() {
|
||||
t.Fatalf("expected accepted, got reason %q", regResp.GetReason())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := rt.Registry.Get("node-rt-1"); ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if _, ok := rt.Registry.Get("node-rt-1"); !ok {
|
||||
t.Fatal("fake node not registered in edge registry")
|
||||
}
|
||||
|
||||
// workspace_root change → restart_required, NOT pushed, snapshot unchanged.
|
||||
wsResult, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
|
||||
Mode: configrefresh.ModeApply,
|
||||
ConfigPath: workspacePath,
|
||||
RequestID: "rt-ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshConfig workspace: %v", err)
|
||||
}
|
||||
if wsResult.Status != configrefresh.StatusRestartRequired {
|
||||
t.Fatalf("expected workspace change status=restart_required, got %q", wsResult.Status)
|
||||
}
|
||||
if len(wsResult.NodeResults) != 0 {
|
||||
t.Errorf("workspace restart_required must not push to nodes, got node_results=%+v", wsResult.NodeResults)
|
||||
}
|
||||
capturedMu.Lock()
|
||||
if capturedConcurrency != -1 {
|
||||
t.Errorf("node must not receive a push for a restart_required workspace change, captured concurrency=%d", capturedConcurrency)
|
||||
}
|
||||
capturedMu.Unlock()
|
||||
|
||||
// concurrency change → applied, pushed to node, node result reported.
|
||||
concResult, err := rt.RefreshConfig(context.Background(), configrefresh.Request{
|
||||
Mode: configrefresh.ModeApply,
|
||||
ConfigPath: concurrencyPath,
|
||||
RequestID: "rt-conc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshConfig concurrency: %v", err)
|
||||
}
|
||||
if concResult.Status != configrefresh.StatusApplied {
|
||||
t.Fatalf("expected concurrency change status=applied, got %q (summary=%s)", concResult.Status, concResult.Summary)
|
||||
}
|
||||
found := false
|
||||
for _, nr := range concResult.NodeResults {
|
||||
if nr.NodeID == "node-rt-1" {
|
||||
found = true
|
||||
if nr.Status != "applied" {
|
||||
t.Errorf("expected node status=applied, got %q (error=%q)", nr.Status, nr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("node-rt-1 not found in node_results: %+v", concResult.NodeResults)
|
||||
}
|
||||
|
||||
capturedMu.Lock()
|
||||
got := capturedConcurrency
|
||||
capturedMu.Unlock()
|
||||
if got != 4 {
|
||||
t.Errorf("pushed runtime concurrency: got %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,7 +217,11 @@ func Classify(current, candidate *config.EdgeConfig) Result {
|
|||
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token)
|
||||
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].agent_kind", key), StatusRestartRequired, cur.AgentKind, next.AgentKind)
|
||||
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters)
|
||||
appendDeepIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime", key), StatusRestartRequired, cur.Runtime, next.Runtime)
|
||||
// Runtime tuning is classified per field: node-wide concurrency is applied
|
||||
// live, while workspace_root requires a node restart because the store DB
|
||||
// path is fixed at bootstrap.
|
||||
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime.concurrency", key), StatusApplied, cur.Runtime.Concurrency, next.Runtime.Concurrency)
|
||||
appendIfChanged(&changes, fmt.Sprintf("nodes[%q].runtime.workspace_root", key), StatusRestartRequired, cur.Runtime.WorkspaceRoot, next.Runtime.WorkspaceRoot)
|
||||
}
|
||||
for key := range candidateNodes {
|
||||
if _, exists := currentNodes[key]; !exists {
|
||||
|
|
|
|||
|
|
@ -710,3 +710,122 @@ nodes:
|
|||
t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired verifies
|
||||
// S15: node runtime tuning is classified per field. concurrency is applied live
|
||||
// while workspace_root requires a node restart.
|
||||
func TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
currentYAML := `
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
nodes:
|
||||
- id: "node-1"
|
||||
alias: "n1"
|
||||
token: "tok-1"
|
||||
adapters:
|
||||
cli:
|
||||
enabled: true
|
||||
runtime:
|
||||
concurrency: 2
|
||||
workspace_root: "/var/lib/iop/ws-a"
|
||||
`
|
||||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||||
|
||||
concurrencyOnlyYAML := `
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
nodes:
|
||||
- id: "node-1"
|
||||
alias: "n1"
|
||||
token: "tok-1"
|
||||
adapters:
|
||||
cli:
|
||||
enabled: true
|
||||
runtime:
|
||||
concurrency: 4
|
||||
workspace_root: "/var/lib/iop/ws-a"
|
||||
`
|
||||
concurrencyOnlyPath := writeYAML(t, dir, "concurrency.yaml", concurrencyOnlyYAML)
|
||||
|
||||
current := buildNormalizedCurrent(t, currentPath)
|
||||
ctx := context.Background()
|
||||
|
||||
// concurrency-only change → applied.
|
||||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||||
Mode: configrefresh.ModeDryRun,
|
||||
ConfigPath: concurrencyOnlyPath,
|
||||
RequestID: "test-s15-conc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate concurrency: %v", err)
|
||||
}
|
||||
if result.Status != configrefresh.StatusApplied {
|
||||
t.Fatalf("expected concurrency change status=applied, got %q (summary: %s)", result.Status, result.Summary)
|
||||
}
|
||||
foundConc := false
|
||||
for _, c := range result.Changes {
|
||||
if c.Path == `nodes["node-1"].runtime.concurrency` {
|
||||
foundConc = true
|
||||
if c.Class != configrefresh.StatusApplied {
|
||||
t.Errorf("expected concurrency class=applied, got %q", c.Class)
|
||||
}
|
||||
}
|
||||
if c.Class == configrefresh.StatusRestartRequired {
|
||||
t.Errorf("unexpected restart_required change for concurrency-only diff: %s", c.Path)
|
||||
}
|
||||
}
|
||||
if !foundConc {
|
||||
t.Errorf("concurrency change path not found in: %+v", result.Changes)
|
||||
}
|
||||
|
||||
// workspace_root change → restart_required.
|
||||
workspaceYAML := `
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
nodes:
|
||||
- id: "node-1"
|
||||
alias: "n1"
|
||||
token: "tok-1"
|
||||
adapters:
|
||||
cli:
|
||||
enabled: true
|
||||
runtime:
|
||||
concurrency: 2
|
||||
workspace_root: "/var/lib/iop/ws-b"
|
||||
`
|
||||
workspacePath := writeYAML(t, dir, "workspace.yaml", workspaceYAML)
|
||||
wsResult, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||||
Mode: configrefresh.ModeDryRun,
|
||||
ConfigPath: workspacePath,
|
||||
RequestID: "test-s15-ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate workspace: %v", err)
|
||||
}
|
||||
if wsResult.Status != configrefresh.StatusRestartRequired {
|
||||
t.Fatalf("expected workspace_root change status=restart_required, got %q", wsResult.Status)
|
||||
}
|
||||
foundWS := false
|
||||
for _, c := range wsResult.Changes {
|
||||
if c.Path == `nodes["node-1"].runtime.workspace_root` {
|
||||
foundWS = true
|
||||
if c.Class != configrefresh.StatusRestartRequired {
|
||||
t.Errorf("expected workspace_root class=restart_required, got %q", c.Class)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundWS {
|
||||
t.Errorf("workspace_root change path not found in: %+v", wsResult.Changes)
|
||||
}
|
||||
foundRestartPath := false
|
||||
for _, p := range wsResult.RestartRequiredPaths {
|
||||
if p == `nodes["node-1"].runtime.workspace_root` {
|
||||
foundRestartPath = true
|
||||
}
|
||||
}
|
||||
if !foundRestartPath {
|
||||
t.Errorf("workspace_root not in RestartRequiredPaths: %v", wsResult.RestartRequiredPaths)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ import (
|
|||
type ConfigSet struct {
|
||||
Registry *Registry
|
||||
Items map[string]ConfigItem
|
||||
Runtime RuntimeConfig
|
||||
}
|
||||
|
||||
// RuntimeConfig captures the node-wide runtime tuning carried alongside the
|
||||
// adapter set so config refresh can detect concurrency and workspace changes.
|
||||
type RuntimeConfig struct {
|
||||
Concurrency int
|
||||
WorkspaceRoot string
|
||||
}
|
||||
|
||||
type ConfigItem struct {
|
||||
|
|
@ -65,7 +73,7 @@ func BuildConfigSet(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Config
|
|||
}
|
||||
a := ollama.New(cfg, logger, instanceKey)
|
||||
reg.RegisterKeyed(instanceKey, typeName, a)
|
||||
|
||||
|
||||
bs, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal ollama config: %w", err)
|
||||
|
|
@ -134,6 +142,10 @@ func BuildConfigSet(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Config
|
|||
return &ConfigSet{
|
||||
Registry: reg,
|
||||
Items: items,
|
||||
Runtime: RuntimeConfig{
|
||||
Concurrency: int(payload.GetRuntime().GetConcurrency()),
|
||||
WorkspaceRoot: payload.GetRuntime().GetWorkspaceRoot(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,9 +300,9 @@ func (n *Node) OnConfigRefresh(ctx context.Context, _ *transport.Session, req *i
|
|||
}, nil
|
||||
}
|
||||
return &iop.NodeConfigRefreshResponse{
|
||||
RequestId: req.GetRequestId(),
|
||||
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
|
||||
RestartRequiredPaths: req.GetChangedPaths(),
|
||||
RequestId: req.GetRequestId(),
|
||||
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
|
||||
RestartRequiredPaths: req.GetChangedPaths(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -319,10 +319,48 @@ func (n *Node) OnConfigRefresh(ctx context.Context, _ *transport.Session, req *i
|
|||
n.configSetMu.Lock()
|
||||
defer n.configSetMu.Unlock()
|
||||
|
||||
// Runtime tuning. workspace_root cannot be applied live because the store DB
|
||||
// path is fixed at bootstrap, so a change is reported as restart_required
|
||||
// without touching the running store. Concurrency is applied live but
|
||||
// deferred until after adapter registry start/swap succeeds so that a
|
||||
// failed refresh preserves the previous admission state.
|
||||
var nextConcurrency int
|
||||
var hasRuntimeConcurrencyChange bool
|
||||
if rc := req.GetConfig().GetRuntime(); rc != nil {
|
||||
currentWorkspaceRoot := ""
|
||||
if n.currentConfigSet != nil {
|
||||
currentWorkspaceRoot = n.currentConfigSet.Runtime.WorkspaceRoot
|
||||
}
|
||||
if rc.GetWorkspaceRoot() != currentWorkspaceRoot {
|
||||
n.logger.Info("config refresh: workspace_root change requires restart",
|
||||
zap.String("current", currentWorkspaceRoot),
|
||||
zap.String("next", rc.GetWorkspaceRoot()),
|
||||
)
|
||||
return &iop.NodeConfigRefreshResponse{
|
||||
RequestId: req.GetRequestId(),
|
||||
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED,
|
||||
RestartRequiredPaths: []string{"runtime.workspace_root"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pre-compute the concurrency change without mutating the gate yet.
|
||||
nextConcurrency = int(rc.GetConcurrency())
|
||||
hasRuntimeConcurrencyChange = nextConcurrency != n.globalGate.currentCapacity()
|
||||
}
|
||||
|
||||
diff := adapters.DiffConfigSets(n.currentConfigSet, nextSet)
|
||||
|
||||
if len(diff.Added) == 0 && len(diff.Updated) == 0 && len(diff.Removed) == 0 {
|
||||
n.logger.Info("config refresh: no adapter changes detected")
|
||||
// Persist the latest runtime snapshot even when adapters are unchanged so
|
||||
// subsequent refreshes diff workspace_root against the applied value.
|
||||
if n.currentConfigSet != nil {
|
||||
n.currentConfigSet.Runtime = nextSet.Runtime
|
||||
}
|
||||
// Apply runtime concurrency since there are no adapter changes to fail.
|
||||
if hasRuntimeConcurrencyChange {
|
||||
n.globalGate.updateCapacity(nextConcurrency)
|
||||
}
|
||||
return &iop.NodeConfigRefreshResponse{
|
||||
RequestId: req.GetRequestId(),
|
||||
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
|
||||
|
|
@ -363,18 +401,38 @@ func (n *Node) OnConfigRefresh(ctx context.Context, _ *transport.Session, req *i
|
|||
}
|
||||
n.adapterGatesMu.Unlock()
|
||||
|
||||
// Registry.Stop stops every lifecycle adapter in the old registry, so defer
|
||||
// the whole old registry while any pre-swap run is active.
|
||||
hasActiveRuns := n.runs.hasAnyActiveRuns()
|
||||
// Only apply runtime concurrency after all adapter operations succeed.
|
||||
if hasRuntimeConcurrencyChange {
|
||||
n.logger.Info("config refresh: applying runtime concurrency after successful adapter swap",
|
||||
zap.Int("previous", n.globalGate.currentCapacity()),
|
||||
zap.Int("next", nextConcurrency),
|
||||
)
|
||||
n.globalGate.updateCapacity(nextConcurrency)
|
||||
}
|
||||
|
||||
if !hasActiveRuns {
|
||||
if n.currentConfigSet != nil && n.currentConfigSet.Registry != nil {
|
||||
if err := n.currentConfigSet.Registry.Stop(context.Background()); err != nil {
|
||||
// Registry.Stop stops every lifecycle adapter in the old registry. Pre-swap
|
||||
// runs still hold old adapter instances, so the old registry stop is deferred
|
||||
// until those runs drain; in-flight executions then finish on their original
|
||||
// adapter snapshot while new requests already route to nextSet.
|
||||
oldSet := n.currentConfigSet
|
||||
pendingRuns := n.runs.snapshotActive()
|
||||
|
||||
if len(pendingRuns) == 0 {
|
||||
if oldSet != nil && oldSet.Registry != nil {
|
||||
if err := oldSet.Registry.Stop(context.Background()); err != nil {
|
||||
n.logger.Warn("config refresh: failed to stop old adapters", zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
n.logger.Info("config refresh: deferring old registry stop due to active runs on updated/removed adapters")
|
||||
} else if oldSet != nil && oldSet.Registry != nil {
|
||||
n.logger.Info("config refresh: deferring old registry stop until active runs drain",
|
||||
zap.Int("pending_runs", len(pendingRuns)),
|
||||
)
|
||||
go func() {
|
||||
waitHandles(context.Background(), pendingRuns)
|
||||
if err := oldSet.Registry.Stop(context.Background()); err != nil {
|
||||
n.logger.Warn("config refresh: failed to stop old adapters after drain", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
n.currentConfigSet = nextSet
|
||||
|
|
|
|||
|
|
@ -2320,7 +2320,7 @@ func (a *blockingCapsAdapter) Execute(ctx context.Context, _ runtime.ExecutionSp
|
|||
}
|
||||
|
||||
func (a *blockingCapsAdapter) Start(_ context.Context) error { return nil }
|
||||
func (a *blockingCapsAdapter) Stop(_ context.Context) error { return nil }
|
||||
func (a *blockingCapsAdapter) Stop(_ context.Context) error { return nil }
|
||||
|
||||
func TestNodeConfigRefreshWaitsForResolvedRunRegistrationBeforeStoppingOldRegistry(t *testing.T) {
|
||||
reg1 := adapters.NewRegistry()
|
||||
|
|
@ -2537,3 +2537,370 @@ func TestNodeConfigRefreshDecreasesExistingAdapterGateCapacity(t *testing.T) {
|
|||
t.Fatalf("run-1 failed: %v", err1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NCDRAIN-1: deferred old-registry stop drains after active runs finish ---
|
||||
|
||||
func TestNodeConfigRefreshDeferredStopRunsAfterActiveRunCompletes(t *testing.T) {
|
||||
reg1 := adapters.NewRegistry()
|
||||
oldAdapter := &lifecycleTestAdapter{
|
||||
name: "my-adapter",
|
||||
started: make(chan struct{}),
|
||||
blockChan: make(chan struct{}),
|
||||
}
|
||||
reg1.RegisterKeyed("my-adapter", "lifecycle", oldAdapter)
|
||||
|
||||
initialConfigSet := &adapters.ConfigSet{
|
||||
Registry: reg1,
|
||||
Items: map[string]adapters.ConfigItem{
|
||||
"my-adapter": {Key: "my-adapter", Type: "lifecycle", Fingerprint: "fingerprint-1"},
|
||||
},
|
||||
}
|
||||
|
||||
rtr := router.New(reg1, zap.NewNop())
|
||||
st, err := store.New(":memory:", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
n := node.New("test-node", rtr, st, 0, io.Discard, zap.NewNop(), initialConfigSet)
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
||||
RunId: "run-1",
|
||||
Adapter: "my-adapter",
|
||||
})
|
||||
}()
|
||||
<-oldAdapter.started
|
||||
|
||||
refreshResp, refreshErr := n.OnConfigRefresh(context.Background(), nil, &iop.NodeConfigRefreshRequest{
|
||||
RequestId: "refresh-1",
|
||||
Config: &iop.NodeConfigPayload{
|
||||
Adapters: []*iop.AdapterConfig{{Name: "new-mock", Type: "mock", Enabled: true}},
|
||||
},
|
||||
})
|
||||
if refreshErr != nil {
|
||||
t.Fatalf("OnConfigRefresh failed: %v", refreshErr)
|
||||
}
|
||||
if refreshResp.Status != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED {
|
||||
t.Fatalf("expected refresh status APPLIED, got %v", refreshResp.Status)
|
||||
}
|
||||
|
||||
// Old registry stop must be deferred while the run is still active.
|
||||
if calls := atomic.LoadInt32(&oldAdapter.stopCalls); calls != 0 {
|
||||
t.Fatalf("expected old adapter Stop to be deferred (0 calls), got %d", calls)
|
||||
}
|
||||
|
||||
// Drain the active run; the deferred stop should fire afterwards.
|
||||
close(oldAdapter.blockChan)
|
||||
if err := <-errChan; err != nil {
|
||||
t.Fatalf("active run failed: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for atomic.LoadInt32(&oldAdapter.stopCalls) == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("deferred old registry stop never ran after the active run completed")
|
||||
default:
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- NCDRAIN-2: runtime concurrency live apply on the global gate ---
|
||||
|
||||
func TestConfigRefreshUpdatesGlobalConcurrencyForFutureRuns(t *testing.T) {
|
||||
sa := newQueuedSlowAdapter("slow", 0, 0, 0) // unlimited per-adapter so the global gate binds
|
||||
rtr := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
||||
st, err := store.New(":memory:", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
n := node.New("test-node", rtr, st, 1, io.Discard, zap.NewNop(), nil)
|
||||
|
||||
errCh1 := make(chan error, 1)
|
||||
go func() {
|
||||
errCh1 <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1", Adapter: "slow"})
|
||||
}()
|
||||
waitStarted(t, sa, "run-1")
|
||||
|
||||
// Second run rejected: global concurrency = 1 is full.
|
||||
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-2", Adapter: "slow"}); !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
||||
t.Fatalf("expected run-2 concurrency rejection, got %v", err)
|
||||
}
|
||||
|
||||
resp, refreshErr := n.OnConfigRefresh(context.Background(), nil, &iop.NodeConfigRefreshRequest{
|
||||
RequestId: "refresh-conc",
|
||||
Config: &iop.NodeConfigPayload{Runtime: &iop.NodeRuntimeConfig{Concurrency: 2}},
|
||||
})
|
||||
if refreshErr != nil {
|
||||
t.Fatalf("OnConfigRefresh failed: %v", refreshErr)
|
||||
}
|
||||
if resp.Status != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED {
|
||||
t.Fatalf("expected refresh status APPLIED, got %v", resp.Status)
|
||||
}
|
||||
|
||||
// A second concurrent run is now admitted under the raised limit.
|
||||
errCh3 := make(chan error, 1)
|
||||
go func() {
|
||||
errCh3 <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-3", Adapter: "slow"})
|
||||
}()
|
||||
waitStarted(t, sa, "run-3")
|
||||
|
||||
sa.releaseRun("run-1")
|
||||
sa.releaseRun("run-3")
|
||||
if err := <-errCh1; err != nil {
|
||||
t.Fatalf("run-1 failed: %v", err)
|
||||
}
|
||||
if err := <-errCh3; err != nil {
|
||||
t.Fatalf("run-3 failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRefreshConcurrencyDecreaseDoesNotCancelInFlight(t *testing.T) {
|
||||
sa := newQueuedSlowAdapter("slow", 0, 0, 0)
|
||||
rtr := &fixedRouter{adapterName: "slow", adapters: map[string]runtime.Adapter{"slow": sa}}
|
||||
st, err := store.New(":memory:", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
n := node.New("test-node", rtr, st, 2, io.Discard, zap.NewNop(), nil)
|
||||
|
||||
errCh1 := make(chan error, 1)
|
||||
go func() {
|
||||
errCh1 <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1", Adapter: "slow"})
|
||||
}()
|
||||
waitStarted(t, sa, "run-1")
|
||||
|
||||
errCh2 := make(chan error, 1)
|
||||
go func() {
|
||||
errCh2 <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-2", Adapter: "slow"})
|
||||
}()
|
||||
waitStarted(t, sa, "run-2")
|
||||
|
||||
resp, refreshErr := n.OnConfigRefresh(context.Background(), nil, &iop.NodeConfigRefreshRequest{
|
||||
RequestId: "refresh-dec",
|
||||
Config: &iop.NodeConfigPayload{Runtime: &iop.NodeRuntimeConfig{Concurrency: 1}},
|
||||
})
|
||||
if refreshErr != nil {
|
||||
t.Fatalf("OnConfigRefresh failed: %v", refreshErr)
|
||||
}
|
||||
if resp.Status != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED {
|
||||
t.Fatalf("expected refresh status APPLIED, got %v", resp.Status)
|
||||
}
|
||||
|
||||
// New limit (1) rejects a third run while the two in-flight runs remain active.
|
||||
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-3", Adapter: "slow"}); !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
||||
t.Fatalf("expected run-3 concurrency rejection after decrease, got %v", err)
|
||||
}
|
||||
|
||||
// The two pre-existing runs are not cancelled and complete normally.
|
||||
sa.releaseRun("run-1")
|
||||
sa.releaseRun("run-2")
|
||||
if err := <-errCh1; err != nil {
|
||||
t.Fatalf("run-1 should complete normally, got %v", err)
|
||||
}
|
||||
if err := <-errCh2; err != nil {
|
||||
t.Fatalf("run-2 should complete normally, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NCDRAIN-3: TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency -
|
||||
// runtime concurrency rollback on failed adapter registry start ---
|
||||
|
||||
// TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency verifies that when
|
||||
// adapter registry Start fails during a config refresh, the previously applied
|
||||
// runtime.concurrency is NOT committed and the global gate retains its prior capacity.
|
||||
func TestConfigRefreshFailedAdapterStartDoesNotApplyRuntimeConcurrency(t *testing.T) {
|
||||
// Start with concurrency=1 and an active run to occupy the slot.
|
||||
initialPayload := &iop.NodeConfigPayload{
|
||||
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1},
|
||||
Adapters: []*iop.AdapterConfig{
|
||||
{
|
||||
Name: "slow",
|
||||
Type: "mock",
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
set, err := adapters.BuildConfigSet(initialPayload, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigSet: %v", err)
|
||||
}
|
||||
|
||||
rtr := router.New(set.Registry, zap.NewNop())
|
||||
st, err := store.New(":memory:", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
n := node.New("test-node", rtr, st, 1, io.Discard, zap.NewNop(), set)
|
||||
|
||||
// Add a blocking adapter that occupies the concurrency slot.
|
||||
blockingAdapt := newBlockingAdapterWithStart()
|
||||
set.Registry.RegisterKeyed("blocking", "mock", blockingAdapt)
|
||||
|
||||
// Start an active run on the blocking adapter.
|
||||
errCh1 := make(chan error, 1)
|
||||
go func() {
|
||||
errCh1 <- n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
||||
RunId: "run-hold",
|
||||
Adapter: "blocking",
|
||||
Background: true,
|
||||
})
|
||||
}()
|
||||
|
||||
// Wait for the run to start.
|
||||
select {
|
||||
case <-blockingAdapt.started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("blocking adapter never started")
|
||||
}
|
||||
|
||||
// Verify concurrency=1 is in effect: a second run should be rejected.
|
||||
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
||||
RunId: "run-rejected",
|
||||
Adapter: "blocking",
|
||||
}); !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
||||
t.Fatalf("expected concurrency rejection with concurrency=1, got %v", err)
|
||||
}
|
||||
|
||||
// Now send a refresh that includes Runtime{Concurrency: 2} AND a failing CLI adapter.
|
||||
// The CLI adapter with a non-existent command path will cause Registry.Start to fail.
|
||||
refreshPayload := &iop.NodeConfigPayload{
|
||||
Runtime: &iop.NodeRuntimeConfig{Concurrency: 2},
|
||||
Adapters: []*iop.AdapterConfig{
|
||||
{
|
||||
Name: "slow",
|
||||
Type: "mock",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "cli",
|
||||
Type: "cli",
|
||||
Enabled: true,
|
||||
Config: &iop.AdapterConfig_Cli{
|
||||
Cli: &iop.CLIAdapterConfig{
|
||||
Profiles: map[string]*iop.CLIProfileConfig{
|
||||
"failprofile": {
|
||||
Command: "/nonexistent/path/to/binary",
|
||||
Args: []string{"--fail"},
|
||||
Persistent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, refreshErr := n.OnConfigRefresh(context.Background(), nil, &iop.NodeConfigRefreshRequest{
|
||||
RequestId: "refresh-fail",
|
||||
Config: refreshPayload,
|
||||
})
|
||||
if refreshErr != nil {
|
||||
t.Fatalf("OnConfigRefresh failed: %v", refreshErr)
|
||||
}
|
||||
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED {
|
||||
t.Fatalf("expected FAILED status, got %v", resp.GetStatus())
|
||||
}
|
||||
|
||||
// The key assertion: concurrency should still be 1 (not 2).
|
||||
// Verify by attempting another run: with concurrency=1, a second concurrent
|
||||
// run should still be rejected even after the failed refresh.
|
||||
if err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
|
||||
RunId: "run-verify",
|
||||
Adapter: "blocking",
|
||||
}); !errors.Is(err, node.ErrConcurrencyLimitExceeded) {
|
||||
t.Fatalf("expected concurrency=1 to be preserved after failed refresh (got %v), but a second run was admitted", err)
|
||||
}
|
||||
|
||||
// Clean up: unblock the held run.
|
||||
close(blockingAdapt.blockChan)
|
||||
if err := <-errCh1; err != nil && !errors.Is(err, runtime.ErrRunCancelled) {
|
||||
t.Fatalf("run-hold failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingAdapterWithStart struct {
|
||||
started chan struct{}
|
||||
blockChan chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingAdapterWithStart() *blockingAdapterWithStart {
|
||||
return &blockingAdapterWithStart{
|
||||
started: make(chan struct{}, 1),
|
||||
blockChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *blockingAdapterWithStart) Name() string { return "blocking" }
|
||||
|
||||
func (a *blockingAdapterWithStart) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
||||
return runtime.Capabilities{
|
||||
AdapterName: "blocking",
|
||||
MaxConcurrency: 0, // unlimited per-adapter; only the global gate binds
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *blockingAdapterWithStart) Execute(ctx context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
|
||||
close(a.started)
|
||||
<-a.blockChan
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *blockingAdapterWithStart) Start(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *blockingAdapterWithStart) Stop(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigRefreshWorkspaceRootReportsRestartRequired(t *testing.T) {
|
||||
initialPayload := &iop.NodeConfigPayload{
|
||||
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1, WorkspaceRoot: "/tmp/ws-a"},
|
||||
}
|
||||
set, err := adapters.BuildConfigSet(initialPayload, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigSet: %v", err)
|
||||
}
|
||||
|
||||
rtr := router.New(set.Registry, zap.NewNop())
|
||||
st, err := store.New(":memory:", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
n := node.New("test-node", rtr, st, 1, io.Discard, zap.NewNop(), set)
|
||||
|
||||
resp, refreshErr := n.OnConfigRefresh(context.Background(), nil, &iop.NodeConfigRefreshRequest{
|
||||
RequestId: "refresh-ws",
|
||||
Config: &iop.NodeConfigPayload{
|
||||
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1, WorkspaceRoot: "/tmp/ws-b"},
|
||||
},
|
||||
})
|
||||
if refreshErr != nil {
|
||||
t.Fatalf("OnConfigRefresh failed: %v", refreshErr)
|
||||
}
|
||||
if resp.Status != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_RESTART_REQUIRED {
|
||||
t.Fatalf("expected RESTART_REQUIRED, got %v", resp.Status)
|
||||
}
|
||||
foundWS := false
|
||||
for _, p := range resp.GetRestartRequiredPaths() {
|
||||
if p == "runtime.workspace_root" {
|
||||
foundWS = true
|
||||
}
|
||||
}
|
||||
if !foundWS {
|
||||
t.Fatalf("expected runtime.workspace_root in restart_required paths, got %v", resp.GetRestartRequiredPaths())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,29 @@ func (m *runManager) hasAnyActiveRuns() bool {
|
|||
return len(m.runs) > 0
|
||||
}
|
||||
|
||||
// snapshotActive returns a copy of all currently active run handles so callers
|
||||
// can wait for pre-swap runs to drain without holding the manager lock.
|
||||
func (m *runManager) snapshotActive() []*runHandle {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*runHandle, 0, len(m.runs))
|
||||
for _, h := range m.runs {
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitHandles blocks until every handle's run has finished or ctx is cancelled.
|
||||
func waitHandles(ctx context.Context, handles []*runHandle) {
|
||||
for _, h := range handles {
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rejectReason classifies why a run could not be admitted.
|
||||
type rejectReason string
|
||||
|
||||
|
|
@ -158,6 +181,13 @@ func (g *fifoGate) updateCapacity(capacity int) {
|
|||
g.capacity = capacity
|
||||
}
|
||||
|
||||
// currentCapacity returns the current concurrency capacity (0 means unlimited).
|
||||
func (g *fifoGate) currentCapacity() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.capacity
|
||||
}
|
||||
|
||||
func (g *fifoGate) queuedCount() int {
|
||||
return 0
|
||||
}
|
||||
|
|
@ -205,4 +235,3 @@ func (t *admissionTicket) release() {
|
|||
t.adapterHeld = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue