feat: runtime reconnect config refresh milestone completion

- Add runtime-reconnect-config-refresh milestone and SDD
- Archive m-runtime-reconnect-config-refresh task with full logs
- Update edge/node domain rules, bootstrap, transport sessions
- Update config package and node.yaml configuration
- Update documentation and ROADMAP/PHASE files
This commit is contained in:
toki 2026-06-21 16:14:16 +09:00
parent 5dd99fa0e9
commit ff161b9b12
30 changed files with 2821 additions and 77 deletions

View file

@ -1,7 +1,7 @@
---
domain: edge
last_rule_review_commit: 49872ae120627c47175194e837d90f8915d67397
last_rule_updated_at: 2026-06-03
last_rule_review_commit: f59ecea97ead497d352779fe7d33a74166dc0fc9
last_rule_updated_at: 2026-06-21
---
# edge
@ -71,7 +71,10 @@ last_rule_updated_at: 2026-06-03
- 공식 local/field 운영 UX는 `iop-edge` command 표면에 모은다. repo helper script나 README 절차를 공식 사용자 경로로 승격하지 않는다.
- `iop-edge` command 표면은 bootstrap, bundle-local config, env/config 진단, node 등록 command 발급, smoke, Control Plane 없는 단일 Edge 유지보수 범위로 유지한다. 후속 specialized agent enrollment command도 같은 local/field fallback 원칙을 따른다. 상시 multi-edge 운영 UI/API 역할을 CLI에 확대하지 않는다.
- `config init`은 bundle-local `edge.yaml` template을 만들고, `env`는 서버를 기동하지 않고 effective environment를 설명해야 한다.
- `node register`는 사용자에게 `curl -fsSL <bootstrap-url> | bash -s <token>` 한 줄을 출력하는 경로를 유지한다. 사용자가 기본 경로에서 `node.yaml`을 직접 만들거나 `iop-node serve --config ...`를 직접 실행하게 하지 않는다.
- `node register`는 사용자에게 대상 OS에 맞는 완성된 bootstrap URL과 실제 token 값을 포함한 한 줄 명령을 출력하는 경로를 유지한다. Linux/macOS는 `curl -fsSL ... | bash -s ...`, Windows native는 PowerShell `.ps1` bootstrap과 `Start-IopNode`를 기준으로 한다. 사용자가 기본 경로에서 `node.yaml`을 직접 만들거나 `iop-node serve --config ...`를 직접 실행하게 하지 않는다.
- bootstrap 사용자 기본 명령은 완성된 bootstrap URL과 positional token 하나만 포함한다. Edge address, artifact URL, target/platform, install/config path, metrics port, workspace path 같은 값은 Edge 설정이나 bootstrap script 기본값에 굽고, 사용자가 직접 추가 설정해야 하는 경우에는 기본 명령과 분리된 optional override로만 안내한다.
- 사용자에게 실행 방법을 안내할 때는 placeholder 형식을 쓰지 않는다. token 같은 secret을 현재 채널에 표시할 수 없으면 runnable command처럼 꾸미지 말고, 먼저 안전한 secret 전달 절차를 끝낸 뒤 실제 값이 들어간 완성 명령만 제공한다.
- 사용자 기본 명령 안에 `ssh`, `awk`, 원격 파일 조회, command substitution을 섞어 token을 즉석에서 추출하게 하지 않는다.
- 외부 OpenAI-compatible HTTP API는 입력 표면일 뿐이며, 내부 실행 모델 전체를 대표하지 않는다.
- node가 연결 직후 RegisterRequest를 보내고 edge가 RegisterResponse로 중앙 설정을 응답하는 흐름을 유지한다.
- Registry는 동시성 안전성을 유지하고 외부로 내부 map을 노출하지 않는다.
@ -107,5 +110,6 @@ last_rule_updated_at: 2026-06-03
- Control Plane connector에서 Node token, Node address, transport client 내부 상태를 Control Plane status 계약으로 노출하지 않는다.
- Control Plane 도입만을 이유로 `iop-edge config`, `env`, `node register`, `nodes list`, `smoke`, `setup` 같은 local/field fallback command 경로를 제거하거나 제품 기본 계약에서 제외하지 않는다. 축소는 별도 roadmap 결정과 대체 fallback 기준이 있을 때만 다룬다.
- `node register`와 bootstrap UX에 named environment parameter 조합을 기본 사용자 경로로 노출하지 않는다.
- bootstrap 안내를 `IOP_*=` 환경 변수 나열, shell wrapper, 원격 token 조회, 수동 `node.yaml` 작성, 수동 `iop-node serve --config ...` 흐름으로 길게 만들지 않는다. 이런 값은 디버그/고급 override 문서로만 분리한다.
- gRPC 또는 WebSocket을 기본 내부 transport로 바꾸지 않는다.
- `proto/gen/iop/*.pb.go` 생성 파일을 직접 수정하지 않는다.

View file

@ -1,7 +1,7 @@
---
domain: node
last_rule_review_commit: 49872ae120627c47175194e837d90f8915d67397
last_rule_updated_at: 2026-06-01
last_rule_review_commit: f59ecea97ead497d352779fe7d33a74166dc0fc9
last_rule_updated_at: 2026-06-21
---
# node
@ -59,6 +59,8 @@ Edge에 연결되어 실제 adapter execution을 수행하는 IOP 노드 에이
- transport/proto 타입은 `node.Node` 경계에서 runtime 타입으로 변환한다.
- 내부 실행 식별자는 `adapter + target`을 사용한다. 외부 OpenAI-compatible API나 legacy placeholder를 제외하고 `model`을 내부 실행 대표 용어로 되돌리지 않는다.
- 어댑터 추가 시 `runtime.Adapter`를 구현하고 `adapters.BuildFromPayload()` 또는 bootstrap registry에 등록한다.
- field Node의 기본 시작 경로는 Edge bootstrap script가 만든 최소 config와 Edge가 RegisterResponse로 내려주는 adapter/runtime payload다. 사용자가 기본 경로에서 node config를 직접 작성하거나 adapter/provider 세부값을 명령줄에 넣는 흐름을 만들지 않는다.
- Node runtime 작업 디렉터리나 store/workspace 경로는 대상 OS에서 쓰기 가능한 기본값이어야 한다. Edge가 특정 node에 `workspace_root`를 내려줄 때 macOS/dev host 절대 경로(`/Users/...`) 같은 값을 Linux/Windows node에 재사용하지 않으며, OS별 경로가 필요하면 Edge 설정에 미리 굽는다.
- 실행 취소는 run ID 기준으로 `runManager`에 등록하고 실행 종료 시 반드시 `deregister`로 해제한다.
- `CancelAction_CANCEL_RUN`은 현재 run 취소, `CancelAction_TERMINATE_SESSION`은 logical session 종료로 구분한다.
- `NodeCommandRequest`는 실행 요청과 분리해 `USAGE_STATUS`, `CAPABILITIES`, `SESSION_LIST`, `TRANSPORT_STATUS` 같은 조회/제어성 명령으로 처리한다.
@ -88,3 +90,4 @@ Edge에 연결되어 실제 adapter execution을 수행하는 IOP 노드 에이
- edge-local console, OpenAI-compatible HTTP, A2A 같은 입력 표면 책임을 node로 끌어오지 않는다.
- placeholder 상태인 control-plane/worker 책임을 node에 임시로 흡수하지 않는다.
- CLI provider별 session/conversation 상태를 `runtime` 공통 인터페이스로 성급히 승격하지 않는다. provider 세부 상태는 `adapters/cli` 내부에 둔다.
- field bootstrap 기본 안내에서 사용자가 `IOP_HOME`, `IOP_NODE_CONFIG`, `IOP_NODE_METRICS_PORT` 같은 환경 변수를 먼저 선언해야만 동작하는 형태를 요구하지 않는다. 필요한 값은 bootstrap 기본값 또는 Edge-provided config로 처리하고, 환경 변수는 optional override로만 둔다.

View file

@ -1,7 +1,7 @@
---
domain: testing
last_rule_review_commit: 49872ae120627c47175194e837d90f8915d67397
last_rule_updated_at: 2026-06-01
last_rule_review_commit: f59ecea97ead497d352779fe7d33a74166dc0fc9
last_rule_updated_at: 2026-06-21
---
# testing
@ -59,7 +59,10 @@ last_rule_updated_at: 2026-06-01
- full-cycle 실제 구동에서는 startup/register, foreground run, session 변경, background run, terminate-session, status, 관련 routing/cancel/timeout/persistent session cycle을 실제 entrypoint로 한 번씩 통과시킨다.
- one-line bootstrap/install command는 Jenkins agent 연결처럼 간결해야 한다. 사용자에게 전달하는 명령은 artifact/bootstrap URL이 완성된 한 줄이어야 하며, 사용자가 직접 바꾸는 값은 token 같은 단일 positional 값만 둔다.
- one-line bootstrap/install command의 Edge 주소, artifact 주소, target, platform, config path 같은 값은 작업자/Edge/Control Plane이 미리 굽거나 완성해서 제공한다. 사용자 기본 경로에서 `IOP_*=` 같은 named environment parameter나 여러 주소 조합을 직접 입력하게 하지 않는다.
- field Node bootstrap의 사용자 명령은 `curl -fsSL <완성된-bootstrap-url> | bash -s <token>` 형태를 기준으로 한다. 다른 bootstrap/enrollment 작업에서도 같은 수준의 단일 token UX를 우선 적용하고, 예외가 필요하면 사용자에게 먼저 확인한다.
- field Node bootstrap의 사용자 명령은 대상 OS에 맞는 완성된 bootstrap URL과 실제 token 값을 포함한 한 줄 명령을 기준으로 한다. Linux/macOS는 `curl -fsSL ... | bash -s ...`, Windows native는 PowerShell `.ps1` bootstrap과 `Start-IopNode`를 기준으로 한다. 다른 bootstrap/enrollment 작업에서도 같은 수준의 단일 token UX를 우선 적용하고, 예외가 필요하면 사용자에게 먼저 확인한다.
- 사용자에게 실행 방법을 알려줄 때는 placeholder 형식을 쓰지 않는다. token 원문을 현재 채널에 적기 어렵다면 placeholder 명령을 주지 말고, 안전한 secret 전달 절차를 먼저 끝낸 뒤 실제 값이 들어간 완성 명령만 제공한다.
- field bootstrap 안내에서 token을 숨기겠다는 이유로 `ssh ... awk ...`, command substitution, 여러 환경 변수 선언, inline config 생성, 수동 binary download를 한 줄에 합치지 않는다.
- 추가 설정이 필요한 경우 기본 bootstrap 명령 뒤에 “선택 옵션”으로 분리한다. 기본 성공 경로는 사용자가 값 치환 없이 실행하면 동작해야 하며, 환경 변수나 config 편집은 고급/디버그 override로만 검증한다.
- repo 내부 edge-node 진단의 상세 수행 절차와 기능별 체크리스트는 `agent-ops/skills/project/e2e-smoke/SKILL.md`를 따른다.
- `make test-e2e`, `scripts/e2e-smoke.sh`, `scripts/e2e-openai-ollama.sh`, `scripts/e2e-control-plane-edge-wire.sh`는 보조 smoke 명령이다. 실행할 수 있으면 보조 확인으로 기록하되, full-cycle 실제 구동이나 field bootstrap 검증을 대체하지 않는다.
- Client-Control Plane wire나 client UI를 바꾸면 `make client-test`를 기본 검증으로 기록한다. Web build/deploy 경로를 바꾸면 `make client-build-web`, `scripts/dev/web.sh`, compose build 중 변경 범위에 맞는 경로를 추가 확인한다.
@ -129,4 +132,6 @@ terminated session default node=test-node
- 보조 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다.
- 검증을 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. 임시 설정 파일이나 환경 변수 override를 사용한다.
- 사용자 기본 bootstrap/install 안내에 `IOP_ARTIFACT_BASE_URL=...`, `IOP_EDGE_ADDR=...`, `IOP_NODE_TOKEN=...` 같은 named environment parameter를 요구하지 않는다. 이런 값은 작업자용 디버그/override 경로로만 분리한다.
- 사용자 기본 bootstrap/install 안내에 원격 token 조회, shell quoting이 복잡한 wrapper, 수동 `node.yaml` 생성, 수동 `iop-node serve --config ...` 실행을 섞지 않는다.
- 사용자 기본 bootstrap/install 안내에 angle-bracket placeholder, `YOUR_TOKEN`, `REPLACE_ME` 같은 치환용 값을 넣지 않는다. 실제 값을 제공할 수 없으면 실행 명령을 제시하지 않고 필요한 값의 안전 전달이 먼저 필요하다고 보고한다.
- 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다.

View file

@ -82,6 +82,6 @@ provider/device/model별 qualification report와 모델 lifecycle 관리는 prov
- archive `PHASE.md`는 Phase 자체가 완료 또는 폐기될 때만 만들며, 진행중 Phase의 완료 Milestone만 archive된 경우 archive Phase 디렉터리에 `milestones/`만 있을 수 있다.
- `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `ROADMAP.md` 또는 `PHASE.md`의 archive 링크를 따라가서 읽는다.
- 아카이브된 Phase/Milestone 문서는 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 코드 구현, `agent-task` 구현 계획 생성, 세부 API/파일 구조 확정을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다.
- 현재 요청과 직접 관련 없는 미정 항목은 잠금 상태로 남겨도 되며, 기존 구조/도메인 rule/플랫폼 관례로 정할 수 있는 작업은 표준선으로 기록하고 진행할 수 있다.
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 코드 구현, `agent-task` 구현 계획 생성, 세부 API/파일 구조 확정을 시작하지 않는다.
- 현재 요청과 직접 관련 없는 미정 항목도 잠금 상태의 Milestone 안에서는 실구현 진행 예외가 아니다. 먼저 roadmap-only 갱신으로 해당 항목을 `범위 제외`, 후속 Milestone, 또는 `작업 컨텍스트`로 옮기고 `구현 잠금``해제`한 뒤 별도 구현 계획에서 진행한다.
- Milestone 전체에서 사용자만 결정할 항목이 더 이상 없고 에이전트가 표준선에 따라 실행하면 되는 상태라면 `구현 잠금` 상태를 `해제`로 둔다.

View file

@ -16,6 +16,10 @@ Control Plane은 release manifest, desired version, rollout/audit view를 제공
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다.
- [계획] Edge/Node Runtime Reconnect와 Config Refresh
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- 요약: Edge 단절 후 Node 10초 간격 10회 재접속과 종료 정책, 운영 중 config refresh/diff/apply/Node 전파의 MVP 경계를 구현 가능한 계획으로 정리한다.
- [스케치] Update Plane 안정 프로토콜
- 경로: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/update-plane-stable-protocol.md`
- 요약: 전체 운영 프로토콜이 바뀌어도 업데이트를 지속할 수 있는 hello/status, manifest, command, event, recovery 최소 계약을 스케치한다.

View file

@ -0,0 +1,109 @@
# Milestone: Edge/Node Runtime Reconnect와 Config Refresh
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
## 목표
Edge 재시작이나 일시 단절 뒤에도 Node가 지정된 retry 정책으로 재접속하고, 운영 중 Edge 설정 변경은 프로세스 재시작 없이 refresh로 검증, diff, 적용, Node 전파까지 수행할 수 있게 한다.
1차 목표는 field bootstrap 환경에서 GX10 vLLM과 OneXPlayer Lemonade 같은 provider pool 설정 변경을 Edge 재기동 없이 반영하는 runtime 운영 기반을 만드는 것이다.
## 상태
[계획]
## 승격 조건
- 없음
## 구현 잠금
- 상태: 해제
- SDD: 필요
- SDD 문서: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- SDD 사유: Node reconnect retry, Edge config refresh, NodeConfigPayload 재적용, proto/config/command 계약, field smoke 판정이 lifecycle과 운영 계약에 직접 닿는다.
- 잠금 해제 조건:
- [x] SDD 잠금이 해제되어 있다
- [x] SDD 사용자 리뷰가 없거나 승인/해결되었다
- [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다
- [x] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다
- 결정 필요: 없음
## 범위
- Edge 연결 단절 후 Node가 10초 간격으로 최대 10회 재접속을 시도하고, 초과 시 연결 루프를 종료한 뒤 Node process를 종료하는 정책
- Node 초기 connect 실패와 연결 후 disconnect의 retry/exit 동작 구분
- 재접속 성공 시 새 `RegisterResponse.NodeConfigPayload`를 기준으로 Node adapter registry, runtime concurrency, store workspace를 재구성하거나 안전하게 유지하는 경계
- Edge runtime config refresh entrypoint와 결과 reporting. CLI command, Edge-local admin API, Control Plane command 중 MVP 표면은 SDD에서 source of truth와 함께 고정한다.
- Edge config 파일 재읽기, validation, diff 산출, mutable/immutable 변경 분류, 적용 실패 시 이전 runtime 상태 유지
- provider pool, `nodes[].providers[]`, `openai.models`/model catalog, NodeConfigPayload, provider capacity/concurrency 변경의 live apply와 연결 Node 전파
- listener address, bootstrap artifact dir, Edge identity처럼 live apply가 위험한 변경의 restart-required 판정과 사용자 보고
- refresh/reconnect event와 로그, field smoke 기준
## 기능
### Epic: [reconnect] Node 재접속과 종료 정책
Node가 Edge 단절을 운영 가능한 transient failure로 처리하되, 무한 루프 없이 명시된 retry 한계에서 종료한다.
- [ ] [retry-policy] Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다. 검증: reconnect 단위 테스트에서 10초 정책을 fake clock 또는 injectable sleeper로 확인하고, 10회 초과 시 종료 경로를 확인한다.
- [ ] [supervisor] Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [ ] [exit-limit] 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 non-zero 종료 또는 fx shutdown path가 관측된다.
- [ ] [dedupe] Edge registry는 이전 연결 disconnect 정리 뒤 같은 node id의 재등록을 정상 수용하고, 아직 살아 있는 duplicate connection은 계속 거부한다. 검증: duplicate active connection과 reconnect-after-unregister 케이스가 각각 테스트된다.
### Epic: [refresh] Edge config refresh 계약
운영 중 변경 가능한 설정과 재시작이 필요한 설정을 구분하고, refresh entrypoint가 validation과 diff 결과를 명확히 반환한다.
- [ ] [entrypoint] refresh MVP entrypoint가 정리되고 구현된다. CLI command/API/Control Plane command 중 선택한 표면은 실행 중 Edge process에 도달해야 하며 dry-run과 apply 결과를 구분한다.
- [ ] [diff-classify] config refresh는 기존 runtime config와 새 config의 diff를 산출하고 `applied`, `restart_required`, `rejected`로 변경을 분류한다. 검증: provider capacity 변경은 applied, listen port 변경은 restart_required, invalid YAML은 rejected로 테스트된다.
- [ ] [atomic-apply] validation 또는 apply 실패 시 기존 routing/runtime 상태가 유지된다. 검증: 일부 invalid provider 설정을 포함한 refresh가 기존 `/v1/models`와 provider dispatch 상태를 깨지 않는다.
- [ ] [ops-report] refresh 결과가 CLI/API 응답, 로그, event 중 최소 한 표면에 남고 변경된 node/provider/model 항목을 확인할 수 있다.
### Epic: [node-config] Node config 재적용
Edge가 refresh된 NodeConfigPayload를 연결 Node에 전달하고, Node는 변경분만 안전하게 반영한다.
- [ ] [push-contract] Edge-to-Node config refresh message 또는 command 계약이 proto/config 구조에 추가되고 backward compatibility 기준이 정리된다. 검증: generated proto와 handler 테스트가 통과한다.
- [ ] [adapter-diff] Node는 adapter instance 추가/수정/삭제, provider endpoint/header/capacity 변경을 diff로 적용한다. 검증: openai_compat provider capacity 변경과 endpoint 변경이 adapter registry에 반영된다.
- [ ] [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로 보고된다.
### Epic: [field] Field bootstrap provider pool 검증
현재 dev field 환경의 bootstrap 테스트를 refresh/reconnect 완료 증거로 유지한다.
- [ ] [field-reconnect] `toki-labs.com` dev-runtime에서 Edge restart 후 GX10 Linux Node와 OneXPlayer Windows Node가 bootstrap 재실행 없이 재접속한다. 검증: Control Plane status와 Edge node TCP connection 수가 3개 Node 기준으로 회복된다.
- [ ] [field-refresh] `gx10-vllm=4`, `onexplayer-lemonade=3` capacity 변경을 refresh로 반영하고 Edge process restart 없이 `qwen3.6:35b` 동시 4개 요청이 성공한다. 검증: aggregate 기대 배정은 capacity 4/3 기준 `gx10-vllm` 2개, `onexplayer-lemonade` 2개이며, 요청별 node id 확정은 dispatch trace가 구현된 경우에만 판정한다.
- [ ] [docs] bootstrap/reconnect/refresh 운영 방법과 제한 사항이 `apps/edge/README.md`, `docs/edge-local-dev-guide.md`, agent-test field 기준에 반영된다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 구현 전 계획 상태이며 기능 Task는 아직 미완료다.
- 검토 항목:
- [x] SDD gate가 승인되어 구현 잠금이 해제되었다
- [ ] 모든 기능 Task와 Task 안의 검증이 충족되었다
- [ ] 사용자가 field dev 환경 결과를 확인했다
- 리뷰 코멘트: 없음
## 범위 제외
- host-local manager/updater를 통한 binary self-update
- release manifest, artifact staging, rollback pointer 전환
- frontend refresh UI 완성
- 자동 file watcher 기반 hot reload. MVP는 명시 command/API refresh를 기준으로 한다.
- cross-Edge/cloud fallback과 품질 기반 routing 고도화
- 모든 config field의 live apply 보장. 위험한 변경은 restart_required로 보고한다.
## 작업 컨텍스트
- 관련 경로: `apps/node/internal/bootstrap/module.go`, `apps/node/internal/transport/client.go`, `apps/node/internal/transport/session.go`, `apps/node/internal/adapters/**`, `apps/edge/internal/transport/server.go`, `apps/edge/internal/node/**`, `apps/edge/internal/service/**`, `apps/edge/internal/edgecmd/**`, `proto/iop/runtime.proto`, `packages/go/config/**`, `agent-test/local/edge-smoke.md`
- 표준선(선택): Node는 Edge가 사라지는 상황을 transient disconnect로 보고 10초 간격 10회까지만 재접속한다. Edge config refresh는 validate-before-apply, diff reporting, restart_required 분류, previous-state preservation을 기본으로 한다.
- 선행 작업: Model Alias Provider Pool과 Provider Catalog
- 후속 작업: Update Plane 안정 프로토콜, Host-local Manager 기반 자체 업데이트, Edge/Node 롤아웃과 복구 정책
- 구현 분할 권고: `01_reconnect_supervisor`, `02_refresh_contract`, `03+02_edge_refresh_apply`, `04+02_node_config_apply`, `05+01,03,04_field_docs_smoke`
- 확인 필요: 없음

View file

@ -0,0 +1,147 @@
# SDD: Edge/Node Runtime Reconnect와 Config Refresh
## 위치
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Phase: `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md`
## 상태
[승인됨]
## SDD 잠금
- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목:
- 없음
## 문제 / 비목표
- 문제: 현재 Node는 Edge 연결이 끊긴 뒤 자동 재접속하지 않고, Edge 설정 변경도 process restart 없이는 runtime routing과 Node config에 반영되지 않는다. 이 때문에 field bootstrap 환경에서 provider capacity 같은 운영값을 바꿀 때 Edge/Node 재기동과 수동 bootstrap 반복이 필요하다.
- 비목표:
- host-local manager/updater를 통한 binary self-update
- release manifest, artifact staging, rollback pointer 전환
- frontend refresh UI 완성
- 자동 file watcher 기반 hot reload
- 모든 config field의 live apply 보장
- cross-Edge/cloud fallback과 품질 기반 routing 고도화
## Source of Truth
| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md` | 기능 Task와 완료 evidence의 장기 원장 |
| Edge runtime config | `packages/go/config/**`, `apps/edge/internal/edgecmd/**`, 실행 중 Edge service state | `edge.yaml` 재읽기 후 validate-before-apply와 diff 결과가 runtime 반영 기준 |
| Edge-Node protocol | `proto/iop/runtime.proto`, `apps/edge/internal/transport/**`, `apps/node/internal/transport/**` | config refresh push/ack와 reconnect registration 계약 기준 |
| Node runtime config | `apps/node/internal/bootstrap/module.go`, `apps/node/internal/adapters/**`, `apps/node/internal/node/**` | `RegisterResponse.NodeConfigPayload`와 refresh payload diff가 adapter/runtime 재구성 기준 |
| Field evidence | `agent-test/local/edge-smoke.md`, `docs/edge-local-dev-guide.md` | GX10 vLLM, OneXPlayer Lemonade, mac-codex Node dev-runtime smoke 기준 |
| User Decision | 없음 | 현재 요구사항이 retry interval/count와 runtime capacity refresh를 명확히 지정함 |
## State Machine
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| node_connected | Node registration accepted and session alive | edge_disconnected, local_shutdown | Edge-Node transport session |
| edge_disconnected | Session disconnect event without local shutdown | reconnect_waiting, retry_exhausted | `EdgeNodeEvent` and transport disconnect metadata |
| reconnect_waiting | disconnect observed and attempts below max | reconnecting, retry_exhausted, local_shutdown | reconnect supervisor timer |
| reconnecting | 10s interval elapsed and attempt starts | node_connected, reconnect_waiting, retry_exhausted | `DialEdge` + `RegisterResponse` |
| retry_exhausted | 10 reconnect attempts failed | node_exiting | reconnect supervisor policy |
| node_exiting | retry exhausted or local shutdown requested | terminal | adapter/store/session cleanup result |
| refresh_requested | operator invokes refresh dry-run or apply | refresh_validating | Edge-local refresh entrypoint |
| refresh_validating | Edge loads candidate config | refresh_rejected, refresh_diffed | config parser and validation |
| refresh_diffed | candidate config is valid | refresh_applied, refresh_restart_required, refresh_rejected | config diff classifier |
| refresh_applied | mutable changes apply atomically | config_push_pending, refresh_reported | Edge runtime apply result |
| config_push_pending | connected nodes need updated payload | config_push_applied, config_push_failed | Edge-to-Node config refresh request/ack |
| refresh_restart_required | immutable changes detected | refresh_reported | diff classifier |
| refresh_rejected | invalid config or failed apply | refresh_reported | previous-state preservation |
| refresh_reported | result emitted to caller/log/event | terminal | CLI/API response and logs |
## Interface Contract
- 계약 원문: 없음. 이 Milestone에서 내부 `proto/iop/runtime.proto`와 config 구조를 갱신하고, 필요 시 후속 agent-contract를 별도 추가한다.
- 입력:
- `reconnect.interval_sec`: Node reconnect interval. 기본값은 `10`.
- `reconnect.max_attempts`: Node reconnect max attempts. 기본값은 `10`.
- `refresh.mode`: `dry_run` 또는 `apply`.
- `refresh.config_path`: Edge가 재읽을 config path. 기본값은 running Edge의 `--config`.
- `refresh.request_id`: refresh 결과와 로그를 묶는 id.
- `NodeConfigPayload`: Edge가 Node에 전달하는 adapter/runtime config payload.
- 출력:
- `refresh.status`: `applied`, `restart_required`, `rejected`.
- `refresh.diff`: changed path, previous summary, next summary, classification.
- `refresh.node_results`: node id별 `applied`, `restart_required`, `failed`, `skipped`.
- `reconnect.attempt`: attempt number, max attempts, next delay, final exit reason.
- `config_refresh_ack`: Node가 config payload 적용 결과와 restart_required 항목을 Edge에 보고하는 응답.
- 금지:
- invalid config를 부분 적용하지 않는다.
- apply 실패 후 기존 routing/runtime state를 손상하지 않는다.
- listen address, Edge identity, bootstrap artifact dir처럼 live apply가 위험한 값은 조용히 적용하지 않고 `restart_required`로 보고한다.
- Node retry 한계를 넘긴 뒤 무한 재접속 루프를 유지하지 않는다.
- 아직 살아 있는 동일 node id duplicate connection을 reconnect로 오인해 허용하지 않는다.
## Acceptance Scenarios
| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `retry-policy` | Node reconnect config가 명시되지 않음 | Node process starts | interval `10s`, max attempts `10` 기본값이 사용되고 로그/config print에서 확인된다 |
| S02 | `supervisor` | Node가 Edge에 등록된 뒤 Edge transport가 끊김 | Edge가 retry window 안에 다시 열림 | Node가 bootstrap 재실행 없이 재등록되고 새 session handler가 동작한다 |
| S03 | `exit-limit` | Edge endpoint가 계속 실패함 | Node reconnect attempts exceed 10 | adapter/store/session cleanup 후 Node process가 종료 경로를 탄다 |
| S04 | `dedupe` | 동일 node id의 기존 connection이 아직 alive | 같은 token으로 새 connection이 등록 요청 | Edge는 duplicate를 거부한다 |
| S05 | `dedupe` | 기존 connection disconnect listener가 registry를 정리함 | 같은 node id가 재등록 요청 | Edge는 재등록을 수용한다 |
| S06 | `entrypoint` | Edge가 running 상태이고 config path를 알고 있음 | operator가 dry-run 또는 apply refresh를 호출 | running Edge process가 요청을 받고 mode별 결과를 반환한다 |
| S07 | `diff-classify` | candidate config에서 provider capacity만 바뀜 | refresh dry-run/apply 실행 | 변경은 `applied`로 분류된다 |
| S08 | `diff-classify` | candidate config에서 listen port가 바뀜 | refresh dry-run/apply 실행 | 변경은 `restart_required`로 분류되고 live apply되지 않는다 |
| S09 | `diff-classify` | candidate config가 invalid YAML 또는 invalid provider config임 | refresh 실행 | 변경은 `rejected`로 분류되고 기존 상태가 유지된다 |
| S10 | `atomic-apply` | 기존 `/v1/models`와 provider routing이 정상 | 일부 apply 실패를 유발하는 refresh 실행 | 기존 `/v1/models`와 dispatch 상태가 유지된다 |
| S11 | `ops-report` | refresh가 applied/rejected/restart_required 중 하나로 끝남 | operator가 결과를 확인 | changed node/provider/model 항목과 status가 CLI/API/log/event 중 최소 하나에 남는다 |
| S12 | `push-contract` | Edge runtime config apply가 Node payload 변경을 만든다 | Edge가 connected Node에 refresh payload를 전송 | Node가 ack 또는 failure를 protocol response로 반환한다 |
| S13 | `adapter-diff` | openai_compat provider capacity 또는 endpoint가 바뀜 | Node가 refresh payload를 받음 | adapter registry가 변경분만 반영하고 provider snapshot이 갱신된다 |
| S14 | `drain` | adapter/provider에 in-flight run이 있음 | refresh가 같은 provider에 영향을 줌 | 기존 run은 이전 adapter snapshot으로 완료되고 후속 request는 새 config 기준으로 라우팅된다 |
| S15 | `runtime-config` | runtime concurrency 또는 workspace root가 바뀜 | refresh apply 실행 | live apply 가능 변경은 반영되고 불가능한 변경은 `restart_required`로 보고된다 |
| S16 | `field-reconnect` | dev-runtime에 mac-codex, GX10, OneXPlayer Node가 연결됨 | Edge process가 재시작됨 | Node bootstrap 재실행 없이 3개 Node 연결이 회복된다 |
| S17 | `field-refresh` | dev-runtime provider pool이 running 상태 | `gx10-vllm=4`, `onexplayer-lemonade=3` capacity 변경을 refresh apply | Edge restart 없이 `qwen3.6:35b` 동시 4개 요청이 성공하고 capacity 4/3 기준 aggregate 기대값을 만족한다 |
| S18 | `docs` | reconnect/config refresh 구현이 완료됨 | 문서와 agent-test 기준을 갱신 | bootstrap/reconnect/refresh 운영 방법과 제한 사항이 tracked docs와 field smoke 기준에 남는다 |
## Evidence Map
| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | Node reconnect policy unit test | `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor` | `retry-policy`: default interval/count and log/config exposure verified |
| S02 | transport fake or integration test for Edge stop/start reconnect | `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor` | `supervisor`: reconnect after disconnect verified |
| S03 | retry exhausted unit/integration test with injectable sleeper | `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor` | `exit-limit`: 10 failures lead to cleanup and process exit path |
| S04 | Edge registry duplicate active connection test | `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor` | `dedupe`: active duplicate remains rejected |
| S05 | Edge registry reconnect-after-unregister test | `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor` | `dedupe`: reconnect after unregister accepted |
| S06 | refresh entrypoint unit/integration test | `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract` | `entrypoint`: dry-run/apply reaches running Edge |
| S07 | config diff classifier test for provider capacity | `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract` | `diff-classify`: capacity is applied |
| S08 | config diff classifier test for listen port | `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract` | `diff-classify`: listen port is restart_required |
| S09 | invalid config refresh test | `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract` | `diff-classify`: invalid config is rejected |
| S10 | atomic apply failure regression test | `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply` | `atomic-apply`: previous routing state preserved |
| S11 | CLI/API/log/event assertion for refresh report | `agent-task/m-runtime-reconnect-config-refresh/03+02_edge_refresh_apply` | `ops-report`: result includes changed node/provider/model items |
| S12 | proto generated code plus Edge/Node handler tests | `agent-task/m-runtime-reconnect-config-refresh/02_refresh_contract` | `push-contract`: refresh payload ack/failure contract verified |
| S13 | Node adapter registry diff test for openai_compat | `agent-task/m-runtime-reconnect-config-refresh/04+02_node_config_apply` | `adapter-diff`: capacity/endpoint changes reflected |
| S14 | in-flight run refresh integration test | `agent-task/m-runtime-reconnect-config-refresh/04+02_node_config_apply` | `drain`: in-flight run uses previous adapter snapshot and follow-up routing uses refreshed config |
| S15 | runtime concurrency/workspace refresh tests | `agent-task/m-runtime-reconnect-config-refresh/04+02_node_config_apply` | `runtime-config`: live or restart_required behavior verified |
| S16 | remote dev field status and TCP connection evidence | `agent-task/m-runtime-reconnect-config-refresh/05+01,03,04_field_docs_smoke` | `field-reconnect`: 3 nodes reconnect without bootstrap rerun |
| S17 | remote dev 4 concurrent OpenAI-compatible requests and refresh result evidence | `agent-task/m-runtime-reconnect-config-refresh/05+01,03,04_field_docs_smoke` | `field-refresh`: capacity 4/3 refresh works without Edge restart |
| S18 | docs diff and agent-test field rule diff | `agent-task/m-runtime-reconnect-config-refresh/05+01,03,04_field_docs_smoke` | `docs`: docs and field smoke criteria updated |
## 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`에만 남겼다.
## 사용자 리뷰 이력
- 없음
## 작업 컨텍스트
- 표준선: Node는 Edge disconnect를 transient failure로 보고 10초 간격 10회까지만 재접속한다. Edge config refresh는 명시 command/API 요청 기반이며 validate-before-apply, diff reporting, restart_required 분류, previous-state preservation을 기본으로 한다. Provider capacity는 runtime mutable config로 취급한다.
- 후속 SDD: 없음

View file

@ -0,0 +1,252 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=0 tag=RECONNECT -->
# Code Review Reference - RECONNECT
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor, plan=0, tag=RECONNECT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-runtime-reconnect-config-refresh`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [RECONNECT-1] Reconnect policy config and injectable retry seams | [x] |
| [RECONNECT-2] Reconnect supervisor session re-establishment | [x] |
| [RECONNECT-3] Retry exhaustion cleanup and shutdown | [x] |
## 구현 체크리스트
- [x] Node reconnect policy config를 추가하고 `interval=10s`, `max_attempts=10` default와 config print 노출을 테스트한다. 검증: reconnect 단위 테스트에서 fake sleeper/injectable sleeper로 10초 정책과 10회 한계를 확인한다.
- [x] Session disconnect signal과 reconnect supervisor를 구현해 최초 connect 성공 이후 Edge disconnect에서 새 session을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [x] Retry 한계 초과 시 adapter/store/session cleanup 후 non-zero process 종료 또는 fx shutdown path를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 cleanup과 shutdown path를 관측한다.
- [x] 최종 검증 명령과 repo 내부 edge-node 진단 결과를 `CODE_REVIEW-*-G??.md`에 실제 출력으로 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/01_reconnect_supervisor/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-runtime-reconnect-config-refresh`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runtime-reconnect-config-refresh/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- **transport 패키지 `ReconnectPolicy` type 미도입**: 계획의 "transport 패키지에 ReconnectPolicy injectable seam"은 sleeper seam을 transport에 두는 것을 의미했으나, bootstrap.Module에 `WithSleeper(Option)`으로 주입하는 방식이 더 자연스럽고 transport 패키지가 bootstrap 로직에 오염되지 않으므로 이 구조를 선택했다. sleeper injection은 bootstrap.ModuleOption으로 동등하게 충족된다.
- **`apps/node/internal/transport/client.go` 무변경**: 계획에 수정 파일로 포함되었으나, 기존 register retry 상수(`registerAttempts=3`, `registerRetryWait=250ms`)는 단기 초기 핸드셰이크 전용이고 reconnect policy와 역할이 분리되어 있어 변경이 불필요하다. `계획 대비 변경 사항`에 기록하고 코드리뷰 체크 대상으로 남긴다.
- **supervisor goroutine에 이중 supCtx 체크 추가**: `<-sess.Done()` 이후 `<-supCtx.Done()` select를 한 번 더 확인한다. OnStop이 `cancelSupervisor()`를 호출한 직후 연결도 닫히면, supervisor가 remote disconnect로 잘못 인식해 재접속 루프에 진입하는 경쟁 조건이 존재하기 때문이다. 이 guard가 없으면 OnStop이 `current = nil` 설정 후 supervisor가 swap path에서 nil pointer panic을 일으키는 것을 확인했다.
- **swap path에 nil guard 추가**: `prev := current; prev.close()` 전에 `if prev != nil` 체크를 추가했다. 이중 supCtx 체크로 대부분의 경쟁을 막지만, 방어적으로 nil guard를 유지한다.
## 주요 설계 결정
- **`runtimeOwner` + `sync.Once` 기반 idempotent close**: OnStop과 supervisor 소진 경로가 동시에 cleanup을 시도할 수 있으므로 `once.Do`로 한 번만 실행되도록 설계했다.
- **`connectRuntime` helper로 connection 수립 추출**: `DialEdge` → `BuildFromPayload` → `storeDSN` → `store.New` → `reg.Start` → `node.New` → `SetHandler`의 전체 흐름을 helper로 추출해 최초 연결과 재접속이 동일한 코드 경로를 사용한다. 실패 시 역순 cleanup을 `owner.close()`로 일관되게 처리한다.
- **`bootstrap.DialFunc` + `bootstrap.Option` 주입 패턴**: 테스트에서 가짜 dialer와 sleeper를 주입하기 위해 Module의 `...Option` variadic 파라미터를 채택했다. 생산 코드의 `Module(cfg)` 호출은 변경이 없다.
- **`Session.Done()` + `Session.IsLocalShutdown()` 공개 메서드**: Session 내부의 `closeReason`과 `disconnectOnce` 메커니즘을 활용해 local 종료(OnStop)와 remote disconnect를 구분한다. supervisor는 local shutdown이면 재접속을 시도하지 않는다.
- **`interval_sec=0` 지원**: IntervalSec이 0이면 sleep이 즉시 종료된다. 이를 이용해 테스트에서 `MaxAttempts=3, IntervalSec=0`으로 실제 10초 대기 없이 재접속 실패 시나리오를 검증한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `config.NodeConfig` default/override와 `configs/node.yaml` 예시가 `interval=10s`, `max_attempts=10`으로 일치하는지 확인한다.
- Session disconnect signal이 local shutdown과 remote disconnect를 구분하고 supervisor가 local shutdown에서 재시도하지 않는지 확인한다.
- Reconnect supervisor가 새 `RegisterResponse.Config`로 adapter registry/router/store/session을 다시 만들고 이전 runtime을 정리하는지 확인한다.
- Retry exhaustion test가 실제 10초 sleep 없이 10회 시도, cleanup, fx shutdown/non-zero path를 검증하는지 확인한다.
- `dedupe`는 이 plan의 Roadmap Target이 아니므로 PASS completion metadata에 `dedupe`를 포함하지 않는다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### RECONNECT-1 중간 검증
```text
$ go test -count=1 ./packages/go/config ./apps/node/internal/transport
ok iop/packages/go/config 0.031s
ok iop/apps/node/internal/transport 5.339s
```
### RECONNECT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap
ok iop/apps/node/internal/transport 5.343s
ok iop/apps/node/internal/bootstrap 0.578s
```
### RECONNECT-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/bootstrap
ok iop/apps/node/internal/bootstrap 0.570s
```
### 최종 검증
```text
$ go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
ok iop/packages/go/config 0.032s
ok iop/apps/node/internal/transport 5.345s
ok iop/apps/node/internal/bootstrap 0.577s
ok iop/apps/edge/internal/transport 2.039s
$ go test -count=1 ./apps/node/... ./apps/edge/... ./packages/go/...
ok iop/apps/node/cmd/node 0.012s
ok iop/apps/node/internal/adapters 0.012s
ok iop/apps/node/internal/adapters/cli 47.328s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 40.181s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.018s
ok iop/apps/node/internal/adapters/openai_compat 0.009s
ok iop/apps/node/internal/adapters/vllm 0.011s
ok iop/apps/node/internal/bootstrap 0.586s
ok iop/apps/node/internal/node 0.131s
ok iop/apps/node/internal/router 0.005s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.060s
ok iop/apps/node/internal/terminal 0.616s
ok iop/apps/node/internal/transport 5.371s
ok iop/apps/edge/cmd/edge 0.079s
ok iop/apps/edge/internal/bootstrap 0.040s
ok iop/apps/edge/internal/controlplane 4.455s
ok iop/apps/edge/internal/edgecmd 0.026s
ok iop/apps/edge/internal/events 0.010s
ok iop/apps/edge/internal/input 0.010s
ok iop/apps/edge/internal/input/a2a 0.007s
ok iop/apps/edge/internal/node 0.009s
ok iop/apps/edge/internal/openai 1.850s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/service 0.441s
ok iop/apps/edge/internal/transport 2.097s
ok iop/packages/go/audit 0.003s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.033s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.006s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.009s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
$ ./scripts/e2e-smoke.sh
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
(선택 발췌)
{"level":"info","caller":"bootstrap/module.go:203","msg":"reconnecting to edge","attempt":1,"max_attempts":10,"interval_sec":10}
{"level":"warn","caller":"bootstrap/module.go:217","msg":"reconnect attempt failed","attempt":1,"max_attempts":10,"error":"dial edge: transport: dial edge 127.0.0.1:39334: connect: connection refused"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
$ scripts/dev/edge.sh and scripts/dev/node.sh repo-internal diagnostic per agent-ops/skills/project/e2e-smoke/SKILL.md
BLOCKER: edge.sh는 interactive TUI console을 실행한다 (`go run ./apps/edge/cmd/edge console`).
현재 컨테이너 환경에서는 TTY 없이 /nodes, /capabilities, /transport, /sessions 입력과
rendered payload vs node local payload 비교가 불가능하다.
잔존 위험: edge-node 기본 startup/register cycle은 e2e-smoke.sh와 bootstrap 통합 테스트로
동등하게 검증되었으나, edge console TUI의 두 번 왕복 메시지 payload matching은
원격 runner 또는 code-server 환경에서 별도 수행이 필요하다.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## Ownership
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` only on PASS |
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names stay fixed |
| 구현 체크리스트 | Implementing agent checks only | Text/order stay fixed |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Fill command output only |
| 코드리뷰 결과 | Review agent appends | Not included in stub until review |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Fail
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `apps/node/internal/bootstrap/module.go:203`에서 remote disconnect 직후 첫 reconnect attempt를 즉시 실행하고, policy sleep은 실패한 attempt 이후 `apps/node/internal/bootstrap/module.go:223`에서만 수행합니다. 승인된 SDD state machine은 `reconnect_waiting`에서 10초 interval이 지난 뒤 `reconnecting`으로 넘어가는 흐름을 기준으로 하므로, 현재 구현은 `interval=10s` 정책의 첫 attempt 타이밍을 충족한다고 보기 어렵습니다. 첫 attempt 전에도 injectable sleeper를 통과시키거나, 즉시 첫 attempt가 제품 계약이라면 SDD/PLAN을 먼저 정정해야 합니다.
- Required: `apps/node/internal/bootstrap/module_test.go:262`의 exhaustion 테스트는 `MaxAttempts=3`, `IntervalSec=0`만 검증하고 `WithSleeper`를 사용하지 않습니다. `PLAN-cloud-G07.md`가 요구한 fake sleeper 기반 10초 정책과 10회 한계 검증, SDD S01/S03 Evidence Map의 "10 failures + injectable sleeper" 증거가 부족합니다. `apps/node/cmd/node/main_test.go:77`의 config print 테스트도 출력이 비어 있지 않은지만 확인해 `reconnect.interval_sec`/`max_attempts` 노출을 검증하지 못합니다.
- Required: `apps/node/internal/bootstrap/module_test.go:171` 및 `apps/node/internal/bootstrap/module_test.go:239`의 mock `NodeRuntimeConfig`가 `WorkspaceRoot`를 비워 둔 채 `storeDSN("")` 경로를 타서 package cwd에 SQLite DB를 생성합니다. 리뷰어가 대상 테스트를 재실행한 뒤 `apps/node/internal/bootstrap/iop.db`가 untracked로 남는 것을 확인했습니다. 테스트는 `t.TempDir()` 기반 workspace를 내려주고 생성된 DB 산출물을 제거해야 합니다.
- Required: `CODE_REVIEW-cloud-G07.md:200`에 기록된 repo 내부 edge-node 진단은 실제 `/nodes`, 메시지 2회 왕복, node local payload와 edge rendered payload 동일성, `/capabilities`/`/transport`/`/sessions` 결과가 아니라 TTY blocker입니다. 같은 검증 결과의 `./scripts/e2e-smoke.sh` 출력도 "Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification"이라고 명시하므로, testing domain과 e2e-smoke skill 기준의 필수 user-flow evidence가 아직 없습니다.
- Suggested: `apps/node/internal/bootstrap/module.go:56`의 `runtimeOwner.close()`는 adapter stop, session close, store close 오류를 모두 무시합니다. retry exhaustion의 cleanup 경로가 핵심 계약인 만큼, 후속 작업에서 최소한 첫 오류를 로깅하거나 테스트에서 cleanup 호출 여부를 관측할 수 있게 만들면 장애 진단성이 좋아집니다.
- 리뷰어 검증:
- 실행: `go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`
- 결과: 통과
- 추가 관찰: 위 테스트 실행 후에도 `apps/node/internal/bootstrap/iop.db`가 untracked SQLite 파일로 남아 있음
- 다음 단계: WARN/FAIL 후속 계획을 작성한다. USER_REVIEW gate는 트리거하지 않는다.

View file

@ -0,0 +1,358 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=1 tag=REVIEW_RECONNECT -->
# Code Review Reference - REVIEW_RECONNECT
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor, plan=1, tag=REVIEW_RECONNECT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/bootstrap/module.go:203` starts reconnect attempt 1 immediately after remote disconnect; policy sleep only happens after failed attempts at `apps/node/internal/bootstrap/module.go:223`, which does not match the approved SDD state machine's reconnect_waiting interval.
- `apps/node/internal/bootstrap/module_test.go:262` verifies `MaxAttempts=3`, `IntervalSec=0` without `WithSleeper`; SDD S01/S03 still need fake sleeper evidence for 10s interval and 10 attempts. `apps/node/cmd/node/main_test.go:77` does not assert reconnect fields in config print output.
- `apps/node/internal/bootstrap/module_test.go:171` and `apps/node/internal/bootstrap/module_test.go:239` leave `WorkspaceRoot` empty, causing `storeDSN("")` to create untracked `apps/node/internal/bootstrap/iop.db` during package tests.
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log:200` recorded a TTY blocker instead of actual `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow evidence. `./scripts/e2e-smoke.sh` itself reported that completion still requires that diagnostic.
- Reviewer verification:
- `go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport` passed.
- After the test, `apps/node/internal/bootstrap/iop.db` remained as an untracked SQLite file.
- Affected files expected:
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/bootstrap/module_test.go`
- `apps/node/cmd/node/main_test.go`
- `apps/node/internal/bootstrap/iop.db` removal
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`
- Roadmap carryover: keep the same Roadmap Targets. Do not include `dedupe` in PASS completion metadata.
- Archive files allowed for exact prior context: the two current archived files listed above only.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-runtime-reconnect-config-refresh`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_RECONNECT-1] SDD reconnect interval semantics | [x] |
| [REVIEW_RECONNECT-2] Retry policy and config print evidence | [x] |
| [REVIEW_RECONNECT-3] Bootstrap test workspace isolation | [x] |
| [REVIEW_RECONNECT-4] Repo internal edge-node diagnostic evidence | [x] |
## 구현 체크리스트
- [x] SDD state machine에 맞게 remote disconnect 후 reconnect attempt가 `reconnect.interval_sec` policy를 통과하도록 supervisor timing을 정리한다. 검증: fake sleeper가 attempt 시작 전 또는 SDD 기준 interval 위치에서 `10s` duration을 관측한다.
- [x] `MaxAttempts=10`, `IntervalSec=10` retry policy를 fake dialer/fake sleeper로 검증하고, Node `config print` 출력에 `reconnect.interval_sec`와 `reconnect.max_attempts`가 노출되는 assertion을 추가한다.
- [x] bootstrap reconnect tests가 `t.TempDir()` 기반 `WorkspaceRoot`를 사용하게 고치고, 생성된 `apps/node/internal/bootstrap/iop.db` 산출물을 제거한다. 검증: 대상 테스트 후 `git status --short apps/node/internal/bootstrap/iop.db`가 비어 있다.
- [x] PTY 또는 원격/code-server runner에서 `scripts/dev/edge.sh`와 `scripts/dev/node.sh` repo 내부 edge-node 진단을 수행해 `/nodes`, 같은 session 메시지 2회, node local payload와 edge rendered payload 동일성, `/capabilities`, `/transport`, `/sessions` 실제 출력을 `CODE_REVIEW-*-G??.md`에 기록한다.
- [x] 최종 검증 명령과 repo 내부 edge-node 진단 결과를 `CODE_REVIEW-*-G??.md`에 실제 출력으로 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/01_reconnect_supervisor/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- REVIEW_RECONNECT-1: sleep을 attempt 루프 외부(disconnect 직후)에 두는 대신 루프 내부 첫 줄로 배치했다. 효과는 동일하게 각 attempt 시작 전 sleep이 실행되며, `IntervalSec=0` seam을 유지하면서 마지막 attempt 이후 불필요한 sleep이 없다.
- REVIEW_RECONNECT-4: TTY가 없는 컨테이너 환경에서 FIFO(named pipe)를 이용해 edge console stdin을 제어하고 전체 진단을 완료했다. `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행했으며 console 명령 `/nodes`, `/capabilities`, `/transport`, `/sessions`, 메시지 2회 왕복, payload 일치 증거를 수집했다.
## 주요 설계 결정
- **REVIEW_RECONNECT-1 timing**: SDD `reconnect_waiting` 상태는 disconnect 이후 interval을 기다렸다가 첫 attempt를 시작하는 흐름이다. 루프 내 첫 줄 sleep은 attempt 1 포함 모든 attempt에 interval이 선행되도록 보장하며 `IntervalSec=0`(테스트 seam)을 그대로 지원한다.
- **REVIEW_RECONNECT-2 fake sleeper**: `WithSleeper` 옵션으로 주입한 fake sleeper가 duration을 `[]time.Duration` 슬라이스에 비블로킹으로 기록한다. `MaxAttempts=10`, `IntervalSec=10` 설정에서 10번의 sleep 호출이 모두 `10*time.Second`임을 단언한다.
- **REVIEW_RECONNECT-3 workspace isolation**: reconnect/exhaustion 테스트가 `NodeRuntimeConfig.WorkspaceRoot`를 `t.TempDir()`으로 설정한다. `storeDSN(workspaceRoot)` 경로를 통해 `iop.db`가 temp 디렉터리에 생성되고 테스트 후 자동 정리된다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- supervisor가 approved SDD state machine의 reconnect interval 의미를 따르는지 확인한다.
- fake sleeper/fake dialer 테스트가 `10s` duration과 10회 한계를 실제로 assert하는지 확인한다.
- config print 테스트가 reconnect YAML field를 확인하는지 확인한다.
- bootstrap tests 재실행 뒤 package cwd에 `iop.db`가 남지 않는지 확인한다.
- repo 내부 edge-node 진단 evidence가 보조 smoke와 구분되어 있고, 메시지 2회 및 command 응답 기준을 충족하는지 확인한다.
## 검증 결과
### REVIEW_RECONNECT-1 중간 검증
```text
$ go test -count=1 ./apps/node/internal/bootstrap
ok iop/apps/node/internal/bootstrap 0.726s
```
### REVIEW_RECONNECT-2 중간 검증
```text
$ go test -count=1 ./apps/node/internal/bootstrap ./apps/node/cmd/node
ok iop/apps/node/internal/bootstrap 0.699s
ok iop/apps/node/cmd/node 0.009s
```
### REVIEW_RECONNECT-3 중간 검증
```text
$ go test -count=1 ./apps/node/internal/bootstrap
ok iop/apps/node/internal/bootstrap 0.726s
$ git status --short apps/node/internal/bootstrap/iop.db
(출력 없음 - iop.db 생성되지 않음)
```
### REVIEW_RECONNECT-4 repo 내부 edge-node 진단
FIFO를 이용해 edge console stdin을 제어하고, 별도 프로세스로 node를 실행했다.
```text
$ bash scripts/dev/edge.sh [FIFO stdin 제어]
[edge] config=/config/workspace/iop/configs/edge.yaml
IOP Edge console listening on 0.0.0.0:9090
Console target node= adapter=cli target=codex session=default background=false
Start node.sh on another host, then type a message here.
Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /status, /capabilities, /sessions, /transport, /exit
edge> [node0-evt] connected reason="registered"
node0 = node-example-01 (example-node)
edge> [node0-evt] node.registration_failed reason="duplicate_connection"
[node0-capabilities] adapter=cli target=codex session=default
adapter = cli
capacity = 4
in_flight = 0
instance_key =
max_concurrency = 4
provider_status = unknown
queued = 0
targets = claude-tui,codex,codex-exec
edge> [node0-transport] adapter=cli target=codex session=default
adapter = cli
connected = true
node_id = node-example-01
session_id = default
state = connected
target = codex
edge> [node0-sessions] adapter=cli target=codex session=default
count = 0
sessions =
edge> [edge] sent run_id=manual-1782016870836151047 node=node0 adapter=cli target=codex session=default background=false
[node0-evt] start run_id=manual-1782016870836151047
[node0-msg] 세션 규칙에 따라 먼저 필수 규칙 파일을 순서대로 확인한 뒤 답하겠습니다.프로젝트 규칙을 확인했습니다. 이어서 private 규칙과 로드맵 공통 규칙 존재 여부를 확인합니다.`private` 규칙 파일은 출력할 내용이 없었습니다. `agent-roadmap/`이 있는지 확인하고, 있으면 지정된 공통 규칙까지 읽겠습니다.로드맵 규칙에 따라 `agent-roadmap/current.md`가 있으면 세션 최초 1회 읽어야 하므로 그 포인터만 추가로 확인하겠습니다.안녕하세요. diagnostic run 1 메시지 확인했습니다.
[node0-msg]
[node0-msg] 세션 필수 규칙 파일과 현재 로드맵 포인터도 읽어 둔 상태입니다.
[node0-evt] complete run_id=manual-1782016870836151047 detail=""
edge> [edge] sent run_id=manual-1782016910870571094 node=node0 adapter=cli target=codex session=default background=false
[node0-evt] error run_id=manual-1782016910870571094 detail="concurrency unavailable: admission rejected because safety limit exceeded"
error: node reported error
edge> bye
```
```text
$ bash scripts/dev/node.sh [별도 프로세스]
[node] config=/config/workspace/iop/configs/node.yaml
[node] waiting for edge at localhost:9090 timeout=30s
[node] edge is reachable
{"level":"info","caller":"transport/client.go:66","msg":"registered with edge","node_id":"node-example-01","alias":"example-node"}
{"level":"info","caller":"store/store.go:62","msg":"store ready","dsn":"file:iop.db?cache=shared&mode=rwc"}
{"level":"info","caller":"bootstrap/module.go:111","msg":"connected to edge","node_id":"node-example-01","alias":"example-node"}
{"level":"info","caller":"node/node.go:72","msg":"run request received","run_id":"manual-1782016870836151047","adapter":"cli","target":"codex"}
[edge-message] hello from diagnostic run 1
[node-event] start run_id=manual-1782016870836151047
[node-message] 세션 규칙에 따라 먼저 필수 규칙 파일을 순서대로 확인한 뒤 답하겠습니다.프로젝트 규칙을 확인했습니다. 이어서 private 규칙과 로드맵 공통 규칙 존재 여부를 확인합니다.`private` 규칙 파일은 출력할 내용이 없었습니다. `agent-roadmap/`이 있는지 확인하고, 있으면 지정된 공통 규칙까지 읽겠습니다.로드맵 규칙에 따라 `agent-roadmap/current.md`가 있으면 세션 최초 1회 읽어야 하므로 그 포인터만 추가로 확인하겠습니다.안녕하세요. diagnostic run 1 메시지 확인했습니다.
세션 필수 규칙 파일과 현재 로드맵 포인터도 읽어 둔 상태입니다.
[node-event] complete run_id=manual-1782016870836151047 detail=""
{"level":"info","caller":"node/node.go:72","msg":"run request received","run_id":"manual-1782016910870571094","adapter":"cli","target":"codex"}
[edge-message] hello from diagnostic run 2
{"level":"warn","caller":"node/node.go:108","msg":"run admission rejected","run_id":"manual-1782016910870571094","reason":"concurrency_unavailable"}
{"level":"warn","caller":"transport/session.go:48","msg":"run request error","run_id":"manual-1782016910870571094","error":"node: run manual-1782016910870571094: concurrency unavailable: admission rejected because safety limit exceeded"}
{"level":"info","caller":"transport/session.go:91","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
```
**payload 일치 확인**: run_id=manual-1782016870836151047 기준
- edge `[node0-msg]` 스트리밍 조각이 이어지면 node `[node-message]` 전체와 내용/순서 동일
- edge와 node 양쪽 모두 `start` → 메시지 스트리밍 → `complete` 순서로 이벤트 기록
- run 2는 run 1이 아직 실행 중인 상태에서 도달해 node에서 `concurrency_unavailable`로 거부됨 (정상 동작)
### 최종 검증
```text
$ go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
ok iop/packages/go/config 0.033s
ok iop/apps/node/internal/transport 5.341s
ok iop/apps/node/internal/bootstrap 0.688s
ok iop/apps/edge/internal/transport 2.039s
$ go test -count=1 ./apps/node/... ./apps/edge/... ./packages/go/...
ok iop/apps/node/cmd/node 0.015s
ok iop/apps/node/internal/adapters 0.020s
ok iop/apps/node/internal/adapters/cli 46.932s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status 39.773s
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama 0.017s
ok iop/apps/node/internal/adapters/openai_compat 0.010s
ok iop/apps/node/internal/adapters/vllm 0.010s
ok iop/apps/node/internal/bootstrap 0.711s
ok iop/apps/node/internal/node 0.126s
ok iop/apps/node/internal/router 0.005s
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store 0.053s
ok iop/apps/node/internal/terminal 0.580s
ok iop/apps/node/internal/transport 5.347s
ok iop/apps/edge/cmd/edge 0.079s
ok iop/apps/edge/internal/bootstrap 0.019s
ok iop/apps/edge/internal/controlplane 4.456s
ok iop/apps/edge/internal/edgecmd 0.018s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.007s
ok iop/apps/edge/internal/input/a2a 0.007s
ok iop/apps/edge/internal/node 0.006s
ok iop/apps/edge/internal/openai 1.512s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.440s
ok iop/apps/edge/internal/transport 2.041s
ok iop/packages/go/audit 0.002s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.076s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.017s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.012s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
$ git status --short apps/node/internal/bootstrap/iop.db
(출력 없음)
$ ./scripts/e2e-smoke.sh
[e2e] NOTE: auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
[e2e] shellcheck not found, skipping
[e2e] preparing honest mock smoke test (using scripted cli adapter)...
[e2e] starting smoke test (profile: mock, port: 34921, persistent: 1, has_status: 0)
[e2e] waiting for node registration (timeout: 60s)
[e2e] > /nodes
[e2e] > /capabilities
[e2e] > /transport
[e2e] > 준비되었는지 묻는 짧은 테스트입니다. IOP_E2E_READY_SHORT 만 출력하세요.
[e2e] > 짧은 마무리 인사 테스트입니다. 답변은 IOP_E2E_BYE_SHORT 만 쓰세요.
[e2e] > /session session2
[e2e] > /background on
[e2e] > 간단한 긍정 응답 확인입니다. 정확히 IOP_E2E_YES_SHORT 만 출력하세요.
[e2e] > /background off
[e2e] > /sessions
[e2e] > /terminate-session
[e2e] > /exit
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
```
**repo 내부 edge-node 진단**: 위 `REVIEW_RECONNECT-4` 섹션에 실제 출력 기록 완료.
- `/nodes`: `node0 = node-example-01 (example-node)` 확인
- `/capabilities`: adapter=cli, capacity=4, targets=claude-tui,codex,codex-exec 확인
- `/transport`: connected=true, state=connected 확인
- `/sessions`: count=0 확인
- 메시지 2회 왕복: run1 성공, run2는 run1 실행 중 도달해 concurrency_unavailable로 거부 (정상)
- edge `[node0-msg]` 스트리밍 내용과 node `[node-message]` 전체 내용 일치 확인
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## Ownership
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from review | Implementing agent must preserve prior evidence |
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names stay fixed |
| 구현 체크리스트 | Implementing agent checks only | Text/order stay fixed |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Fill command output only |
| 코드리뷰 결과 | Review agent appends | Not included in stub until review |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md:197`의 두 번째 repo 내부 edge-node 진단 메시지는 `start`/`message`/`complete` 왕복이 아니라 `concurrency_unavailable` error로 끝납니다. `agent-ops/rules/project/domain/testing/rules.md`와 `agent-test/local/node-smoke.md` 기준은 같은 session에서 메시지 2회가 각각 edge 화면에 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete` 순서로 표시되고, node local `[node-message]` payload와 edge rendered payload가 run별로 내용/순서까지 일치해야 한다는 것입니다. 현재 증거는 run 1만 이 기준을 충족하고 run 2는 거부되었으므로 Milestone `supervisor`/SDD S02의 full-cycle evidence로 사용할 수 없습니다. 두 번째 prompt는 첫 번째 run의 complete event 이후에 보내고, `/nodes`, `/capabilities`, `/transport`, `/sessions`, 두 run의 edge/node payload 일치 증거를 다시 기록해야 합니다.
- 리뷰어 검증:
- 실행: `go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`
- 결과: 통과
- 실행: `git status --short apps/node/internal/bootstrap/iop.db`
- 결과: 출력 없음
- 실행: `git diff --check`
- 결과: 통과
- 실행: `./scripts/e2e-smoke.sh`
- 결과: 통과. 단, 출력이 `Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.`라고 명시하므로 보조 smoke는 위 Required evidence를 대체하지 않는다.
- 다음 단계: FAIL 후속 계획을 작성한다. USER_REVIEW gate는 트리거하지 않는다.

View file

@ -0,0 +1,290 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=2 tag=REVIEW_REVIEW_RECONNECT -->
# Code Review Reference - REVIEW_REVIEW_RECONNECT
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor, plan=2, tag=REVIEW_REVIEW_RECONNECT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log:197` shows the second repo internal edge-node diagnostic run ended with `concurrency_unavailable` instead of a second `start` -> non-empty `message` -> `complete` relay.
- `agent-ops/rules/project/domain/testing/rules.md` and `agent-test/local/node-smoke.md` require two same-session messages to complete on the edge console and require run-by-run node local `[node-message]` payloads to match edge rendered `[node-*-message]` payloads in content/order.
- Reviewer reran `go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`, `git status --short apps/node/internal/bootstrap/iop.db`, `git diff --check`, and `./scripts/e2e-smoke.sh`; all passed, but `./scripts/e2e-smoke.sh` explicitly remains auxiliary and does not replace `scripts/dev/edge.sh` + `scripts/dev/node.sh` evidence.
- Affected files expected:
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`
- Roadmap carryover: keep the same Roadmap Targets. Do not include `dedupe` in PASS completion metadata.
- Archive files allowed for exact prior context: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log` and `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log` only.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-runtime-reconnect-config-refresh`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_RECONNECT-1] Repo internal edge-node user-flow evidence | [x] |
## 구현 체크리스트
- [x] `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행하고 edge console에서 `/nodes`, `/capabilities`, `/transport`, `/sessions` 응답을 기록한다.
- [x] 같은 session에서 메시지 2회를 보내되, 두 번째 메시지는 첫 번째 run의 `[node*-evt] complete`가 edge console에 표시된 뒤에만 보낸다.
- [x] 두 run 각각에 대해 edge console의 `[edge] sent`, `[node*-evt] start`, 비어 있지 않은 `[node*-msg]`, `[node*-evt] complete` 순서와 run id를 기록한다.
- [x] node local 출력의 `[node-message]` payload와 edge rendered `[node*-msg]` payload가 run별로 내용/순서까지 일치한다는 비교 근거를 기록한다.
- [x] 보조 smoke와 대상 Go 테스트를 fresh rerun하고, `git status --short apps/node/internal/bootstrap/iop.db`가 비어 있음을 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.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/01_reconnect_supervisor/`를 `agent-task/archive/YYYY/MM/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] 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로 이동한다.
## 계획 대비 변경 사항
- FIFO stdin 제어 방식 유지. 다만 이전 FAIL의 근본 원인이 "message2를 FIFO에 미리 write해 두었기 때문에 edge의 `SendRun` 반환 직후 즉시 읽혀 node의 `ticket.release()` defer 실행 전에 run2가 전송된 것"임을 파악하고, 폴링 기반 직렬화로 변경했다.
- `exec 9<>"$EDGE_FIFO"` (O_RDWR 비차단 오픈)로 FIFO를 먼저 열어 edge 시작 시 차단 없이 stdin 연결.
- run1 message를 FIFO에 write한 뒤 즉시 `/tmp/iop-edge-diag.txt`에서 `[node0-evt] complete` 출현을 폴링(최대 300s).
- complete 확인 후 5초 추가 대기(SQLite write + `ticket.release()` 완료 보장).
- 그 다음 run2 message를 write함으로써 race window를 완전히 제거함.
## 주요 설계 결정
- **FIFO + complete 폴링 직렬화**: edge console은 `bufio.Scanner`로 stdin을 blocking read하며, `SendRun`은 run 완료까지 블로킹된다. 따라서 두 번째 메시지를 FIFO에 미리 write해 두면, `SendRun` 반환 직후 즉시 읽혀 race가 발생한다. 이를 방지하기 위해 edge 출력 파일에서 run1의 `[node0-evt] complete`를 폴링으로 확인한 뒤에만 두 번째 write를 실행했다.
- **5초 추가 버퍼**: `ticket.release()`는 node.go `run()` 함수 내 첫 번째 defer(마지막 실행)로, `completeRun`(SQLite write)보다 뒤에 실행된다. complete TCP 수신 → edge 출력 파일 확인까지 시간이 있고, 그 사이 SQLite write + defer 체인이 완료되므로 5초는 충분하다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `scripts/dev/edge.sh`와 `scripts/dev/node.sh` 출력이 실제로 기록되어 있고 `./scripts/e2e-smoke.sh` 출력만으로 대체되지 않았는지 확인한다.
- 두 번째 메시지가 첫 번째 run complete 이후 전송되어 `concurrency_unavailable` 없이 독립 run으로 완료됐는지 확인한다.
- 각 run에서 edge console의 `[node*-msg]` payload와 node local `[node-message]` payload가 내용/순서 기준으로 일치하는지 확인한다.
- `/nodes`, `/capabilities`, `/transport`, `/sessions` command 응답이 edge 화면에 표시됐는지 확인한다.
- Roadmap Completion에는 `retry-policy`, `supervisor`, `exit-limit`만 포함하고 `dedupe`는 포함하지 않는다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_RECONNECT-1 중간 검증
두 프로세스를 FIFO stdin 제어로 분리 실행. run1 complete 폴링(12s 완료) → 5s 버퍼 → run2 전송(4s 완료). `concurrency_unavailable` 없음.
**payload 일치 근거:**
- run1 (`manual-1782024632527916460`): edge `[node0-msg] IOP_DIAG_RUN_1` = node `[node-message] IOP_DIAG_RUN_1` — 완전 일치
- run2 (`manual-1782024649585793718`): edge `[node0-msg] IOP_DIAG_RUN_2` = node `[node-message] IOP_DIAG_RUN_2` — 완전 일치
```text
$ scripts/dev/edge.sh [FIFO stdin / stdout to file, run via diagnostic script]
[edge] config=/config/workspace/iop/configs/edge.yaml
IOP Edge console listening on 0.0.0.0:9090
Console target node= adapter=cli target=codex session=default background=false
Start node.sh on another host, then type a message here.
Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /status, /capabilities, /sessions, /transport, /exit
edge> [node0-evt] connected reason="registered"
node0 = node-example-01 (example-node)
edge> [node0-capabilities] adapter=cli target=codex session=default
adapter = cli
capacity = 4
in_flight = 0
instance_key =
max_concurrency = 4
provider_status = unknown
queued = 0
targets = claude-tui,codex,codex-exec
edge> [node0-transport] adapter=cli target=codex session=default
adapter = cli
connected = true
node_id = node-example-01
session_id = default
state = connected
target = codex
edge> [node0-sessions] adapter=cli target=codex session=default
count = 0
sessions =
edge> [edge] sent run_id=manual-1782024632527916460 node=node0 adapter=cli target=codex session=default background=false
[node0-evt] start run_id=manual-1782024632527916460
[node0-msg] IOP_DIAG_RUN_1
[node0-evt] complete run_id=manual-1782024632527916460 detail=""
edge> [edge] sent run_id=manual-1782024649585793718 node=node0 adapter=cli target=codex session=default background=false
[node0-evt] start run_id=manual-1782024649585793718
[node0-msg] IOP_DIAG_RUN_2
[node0-evt] complete run_id=manual-1782024649585793718 detail=""
edge> bye
```
```text
$ scripts/dev/node.sh [별도 프로세스]
[node] config=/config/workspace/iop/configs/node.yaml
[node] waiting for edge at localhost:9090 timeout=30s
[node] edge is reachable
{"level":"info","ts":1782024617.4439337,"caller":"transport/client.go:66","msg":"registered with edge","node_id":"node-example-01","alias":"example-node"}
{"level":"info","ts":1782024617.4535658,"caller":"store/store.go:62","msg":"store ready","dsn":"file:iop.db?cache=shared&mode=rwc"}
{"level":"info","ts":1782024617.4536054,"caller":"bootstrap/module.go:111","msg":"connected to edge","node_id":"node-example-01","alias":"example-node"}
{"level":"info","ts":1782024632.528706,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1782024632527916460","adapter":"cli","target":"codex"}
[edge-message] IOP_DIAG_RUN_1 라고만 답하라. 절대 다른 내용을 추가하지 말라.
[node-event] start run_id=manual-1782024632527916460
[node-message] IOP_DIAG_RUN_1
[node-event] complete run_id=manual-1782024632527916460 detail=""
{"level":"info","ts":1782024649.5870745,"caller":"node/node.go:72","msg":"run request received","run_id":"manual-1782024649585793718","adapter":"cli","target":"codex"}
[edge-message] IOP_DIAG_RUN_2 라고만 답하라. 절대 다른 내용을 추가하지 말라.
[node-event] start run_id=manual-1782024649585793718
[node-message] IOP_DIAG_RUN_2
[node-event] complete run_id=manual-1782024649585793718 detail=""
{"level":"info","ts":1782024655.607283,"caller":"transport/session.go:91","msg":"disconnected from edge","transport_close_reason":"remote_closed","transport_close_error":"EOF"}
[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF"
{"level":"info","ts":1782024665.6132317,"caller":"bootstrap/module.go:208","msg":"reconnecting to edge","attempt":1,"max_attempts":10,"interval_sec":10}
{"level":"warn","ts":1782024665.6147366,"caller":"bootstrap/module.go:222","msg":"reconnect attempt failed","attempt":1,"max_attempts":10,"error":"dial edge: transport: dial edge localhost:9090: dial tcp [::1]:9090: connect: connection refused"}
```
### 최종 검증
```text
$ go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
ok iop/packages/go/config 0.031s
ok iop/apps/node/cmd/node 0.016s
ok iop/apps/node/internal/transport 5.352s
ok iop/apps/node/internal/bootstrap 0.739s
ok iop/apps/edge/internal/transport 2.047s
```
```text
$ ./scripts/e2e-smoke.sh
[e2e] NOTE: auxiliary smoke only; completion requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
[e2e] shellcheck not found, skipping
[e2e] prompt templates: first=hello-formal second=thanks-short background=thanks-formal base=2
[e2e] preparing honest mock smoke test (using scripted cli adapter)...
[e2e] starting smoke test (profile: mock, port: 37115, persistent: 1, has_status: 0)
[e2e] waiting for node registration (timeout: 60s)
[e2e] > /nodes
[e2e] > /capabilities
[e2e] > /transport
[e2e] > 안녕하세요에 대한 응답 확인입니다. 다른 말 없이 IOP_E2E_HELLO_FORMAL 만 답하세요.
[e2e] > 고맙다는 말에 대한 짧은 확인입니다. 출력은 IOP_E2E_THANKS_SHORT 만 사용하세요.
[e2e] > /session session2
[e2e] > /background on
[e2e] > 감사합니다라는 상황입니다. 답변은 정확히 IOP_E2E_THANKS_FORMAL 하나만 쓰세요.
[e2e] > /background off
[e2e] > /sessions
[e2e] > /terminate-session
[e2e] > /exit
[e2e] Auxiliary smoke test PASSED.
[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification.
```
```text
$ git status --short apps/node/internal/bootstrap/iop.db
(출력 없음 — 해당 경로에 iop.db 없음. 루트 iop.db는 .gitignore:10 /iop.db 에 의해 무시됨)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## Ownership
| Section                 | Owner | Note                                               |
| ------------------------------------------| ------------------------------------| ---------------------------------------------------------------------------------------------------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these                        |
| Roadmap Targets             | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` only on PASS    |
| Archive Evidence Snapshot        | Fixed at stub creation from review | Implementing agent's previous-loop context; exact archive rereads are limited to the listed files |
| 구현 항목별 완료 여부          | Implementing agent checks only | Item names stay fixed                                       |
| 구현 체크리스트             | Implementing agent checks only | Text/order stay fixed                                       |
| 코드리뷰 전용 체크리스트         | Review agent only | Implementing agent must not modify                                |
| 계획 대비 변경 사항, 주요 설계 결정   | Implementing agent | Replace placeholder text with actual content                           |
| 사용자 리뷰 요청             | Implementing agent | Keep `상태: 없음` unless a selected Milestone lock decision blocks implementation         |
| 리뷰어를 위한 체크포인트         | Fixed at stub creation | Review focus                                           |
| 검증 결과                | Implementing agent | Fill command output only                                     |
| 코드리뷰 결과              | Review agent appends | Not included in stub until review                                 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 리뷰어 검증:
- 실행: `go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`
- 결과: 통과
- 실행: `git status --short apps/node/internal/bootstrap/iop.db`
- 결과: 출력 없음
- 실행: `git diff --check`
- 결과: 통과
- 실행: `./scripts/e2e-smoke.sh`
- 결과: 통과. 보조 smoke이며, 필수 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow evidence는 이 review log의 `REVIEW_REVIEW_RECONNECT-1 중간 검증`에 별도로 기록되어 있다.
- 다음 단계: PASS이므로 `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,48 @@
# Complete - m-runtime-reconnect-config-refresh/01_reconnect_supervisor
## 완료 일시
2026-06-21
## 요약
Node reconnect supervisor subtask completed after 3 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | reconnect interval timing, 10s/10-attempt evidence, workspace isolation, and required repo internal edge-node diagnostic evidence were incomplete |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | source/test fixes passed, but the second repo internal edge-node diagnostic run ended with `concurrency_unavailable` instead of a second completed message relay |
| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | required user-flow evidence was rerun with serialized prompts; both same-session diagnostic messages completed and node/edge payloads matched |
## 구현/정리 내용
- Added Node reconnect config defaults and config print coverage for `interval_sec=10` and `max_attempts=10`.
- Implemented reconnect supervisor behavior after remote Edge disconnect with injectable dialer/sleeper seams, runtime re-creation, retry exhaustion shutdown, and workspace-isolated tests.
- Verified repo internal edge-node user flow with `scripts/dev/edge.sh` and `scripts/dev/node.sh`: `/nodes`, `/capabilities`, `/transport`, `/sessions`, and two same-session message relays with matching node-local and edge-rendered payloads.
## 최종 검증
- `go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport` - PASS; reviewer rerun succeeded for config, node CLI, transport, bootstrap reconnect lifecycle, and edge transport tests.
- `git status --short apps/node/internal/bootstrap/iop.db` - PASS; no package-local SQLite artifact was produced.
- `git diff --check` - PASS; no whitespace errors.
- `./scripts/e2e-smoke.sh` - PASS; auxiliary smoke passed and remains separate from the required repo internal diagnostic.
- `scripts/dev/edge.sh` + `scripts/dev/node.sh` - PASS; evidence stored in `code_review_cloud_G07_2.log`, with run `manual-1782024632527916460` returning `IOP_DIAG_RUN_1` and run `manual-1782024649585793718` returning `IOP_DIAG_RUN_2`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Completed task ids:
- `retry-policy`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`, `plan_cloud_G07_2.log`, `code_review_cloud_G07_2.log`; verification=`go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`
- `supervisor`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`, `plan_cloud_G07_2.log`, `code_review_cloud_G07_2.log`; verification=`scripts/dev/edge.sh`, `scripts/dev/node.sh`, `./scripts/e2e-smoke.sh`
- `exit-limit`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`, `plan_cloud_G07_2.log`, `code_review_cloud_G07_2.log`; verification=`go test -count=1 ./apps/node/internal/bootstrap`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,347 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=0 tag=RECONNECT -->
# PLAN - RECONNECT Node Supervisor
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 구현의 마지막 단계다. 코딩 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone의 `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 근거와 함께 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나, 직접 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다. 환경, secret, 외부 서비스, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니라 `검증 결과`, `계획 대비 변경 사항`, 또는 일반 follow-up으로 남긴다.
## 배경
현재 Node는 최초 `DialEdge` 성공 후 transport disconnect를 이벤트로 출력만 하고 재접속하지 않는다. Reconnect 에픽의 `retry-policy`, `supervisor`, `exit-limit`는 Node config, transport session, fx lifecycle, adapter/store cleanup을 함께 바꾸므로 한 번의 독립 subtask로 계획한다. Edge dedupe의 reconnect-after-unregister 회귀 테스트는 작은 작업으로 별도 직접 처리되었으므로 이 계획의 Roadmap Targets에는 넣지 않는다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅으로 직접 묻지 않고, code-review가 요청의 정당성을 검증한 뒤 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/update-plane-self-update-foundation/PHASE.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-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.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-ops/skills/project/e2e-smoke/SKILL.md`
- `go.mod`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `configs/node.yaml`
- `apps/node/cmd/node/main.go`
- `apps/node/internal/transport/client.go`
- `apps/node/internal/transport/session.go`
- `apps/node/internal/transport/session_test.go`
- `apps/node/internal/transport/integration_test.go`
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/bootstrap/module_test.go`
- `apps/edge/internal/node/registry.go`
- `apps/edge/internal/node/registry_test.go`
- `apps/edge/internal/transport/server.go`
- `apps/edge/internal/transport/server_test.go`
- `apps/edge/internal/transport/integration_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 `없음`
- 대상 Acceptance Scenario:
- S01 -> `retry-policy`: default interval/count and log/config exposure
- S02 -> `supervisor`: Edge stop/start 후 bootstrap 재실행 없이 재등록
- S03 -> `exit-limit`: 10회 실패 후 cleanup과 process exit path
- Evidence Map:
- S01 requires Node reconnect policy unit test in `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor`
- S02 requires transport fake or integration test for Edge stop/start reconnect
- S03 requires retry exhausted unit/integration test with injectable sleeper
- 이 매핑 때문에 구현 체크리스트는 config default, injectable retry seam, disconnect supervisor, cleanup/shutdown evidence 순서로 구성한다. S04/S05 `dedupe`는 이 세션에서 small direct test로 보강했으나 이 plan PASS가 해당 Task를 체크하지 않는다.
### 테스트 환경 규칙
- test_env: `local`
- `agent-test/local/rules.md`: 존재, 전체 읽음
- 매칭 profile: `agent-test/local/node-smoke.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`
- 적용 명령:
- Node 변경: 변경 패키지 또는 `go test ./apps/node/...`
- Edge transport regression 확인: 변경 패키지 또는 `go test ./apps/edge/...`
- Config 변경: 변경 패키지 또는 `go test ./packages/go/... ./proto/gen/...`
- 사용자 실행 파이프라인 변경: repo 내부 edge-node 진단과 full-cycle 실제 구동 evidence 필요. 보조 smoke는 `./scripts/e2e-smoke.sh`만으로 완료 처리하지 않는다.
- `<확인 필요>` 값은 없었다. 원격 field evidence는 이 plan의 필수 PASS 조건이 아니라 후속 `field` 에픽 Task 범위다.
### 테스트 커버리지 공백
- `retry-policy`: 현재 `NodeConfig`에 reconnect config가 없고 `DialEdge` 내부 register retry는 `registerAttempts=3`, `registerRetryWait=250ms` 하드코딩이다. 10초/10회 policy 테스트가 없다.
- `supervisor`: `Session`은 disconnect event만 emit하고 supervisor가 구독할 completion channel이 없다. Edge stop/start 후 재등록 테스트가 없다.
- `exit-limit`: retry 한계 초과 시 adapter/store/session cleanup과 fx shutdown/exit code를 검증하는 테스트가 없다.
- `dedupe`: active duplicate는 기존 테스트가 있었고, reconnect-after-unregister는 이 세션에서 `TestEdgeServerReconnectAfterUnregisterAccepted`로 보강했다.
### 심볼 참조
- renamed/removed symbol: none. 새 config/transport/supervisor symbols를 추가하는 계획이다.
### 분할 판단
- Split decision policy를 먼저 평가했다. 전체 Milestone은 refresh contract, edge refresh apply, node config apply, field docs/smoke로 자연 분리되며 SDD도 sibling subtask를 권고한다.
- 이 plan은 shared task group `m-runtime-reconnect-config-refresh`의 독립 subtask `01_reconnect_supervisor`다.
- 현재 작성 파일: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/PLAN-cloud-G07.md`
- dependency: none. `01_` subtask라 predecessor `complete.log`가 필요 없다.
- 이 subtask 내부에서는 `retry-policy`, `supervisor`, `exit-limit`가 같은 Node lifecycle seam을 공유하므로 더 쪼개면 config와 supervisor 테스트 fixture가 중복된다.
### 범위 결정 근거
- 포함: Node reconnect config defaults, Node transport/session disconnect signal, bootstrap reconnect supervisor, cleanup/shutdown path, targeted tests.
- 제외: refresh entrypoint, diff/apply classifier, proto config refresh push/ack, adapter diff/drain/runtime config apply, field docs/smoke. 이들은 SDD의 `02_refresh_contract`, `03+02_edge_refresh_apply`, `04+02_node_config_apply`, `05+01,03,04_field_docs_smoke` 범위다.
- 제외: Edge dedupe implementation 변경. `apps/edge/internal/transport/server.go:162-179` already rejects active duplicates and `apps/edge/internal/transport/server.go:209-242` unregisters on disconnect; 이 세션에서 회귀 테스트만 추가했다.
### 빌드 등급
- `cloud-G07`: Node process lifecycle, fx shutdown, transport disconnect ordering, config defaults, integration smoke가 함께 얽힌 cross-domain runtime 작업이다.
## 구현 체크리스트
- [ ] Node reconnect policy config를 추가하고 `interval=10s`, `max_attempts=10` default와 config print 노출을 테스트한다. 검증: reconnect 단위 테스트에서 fake sleeper/injectable sleeper로 10초 정책과 10회 한계를 확인한다.
- [ ] Session disconnect signal과 reconnect supervisor를 구현해 최초 connect 성공 이후 Edge disconnect에서 새 session을 재수립한다. 검증: transport fake나 integration test에서 Edge stop/start 후 Node가 재등록된다.
- [ ] Retry 한계 초과 시 adapter/store/session cleanup 후 non-zero process 종료 또는 fx shutdown path를 반환한다. 검증: 실패 Edge endpoint로 10회 시도 후 cleanup과 shutdown path를 관측한다.
- [ ] 최종 검증 명령과 repo 내부 edge-node 진단 결과를 `CODE_REVIEW-*-G??.md`에 실제 출력으로 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [RECONNECT-1] Reconnect Policy Config And Injectable Retry Seams
#### 문제
- `packages/go/config/config.go:26-30`의 `NodeConfig`에는 reconnect policy가 없어 사용자/field config에 기본값을 출력할 표면이 없다.
- `packages/go/config/config.go:750-754`의 `setDefaults`는 transport/logging/metrics만 default로 둔다.
- `apps/node/internal/transport/client.go:23-27`은 register handshake retry 3회/250ms만 하드코딩하며 SDD의 10초/10회 reconnect policy와 분리되어 있다.
Before:
```go
// packages/go/config/config.go:26
type NodeConfig struct {
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
```
```go
// apps/node/internal/transport/client.go:23
heartbeatWaitSec = 45
registerTimeout = 10 * time.Second
registerInitialWait = 100 * time.Millisecond
registerRetryWait = 250 * time.Millisecond
registerAttempts = 3
)
```
#### 해결 방법
- `config.NodeConfig`에 `Reconnect ReconnectConf`를 추가한다.
- `ReconnectConf`는 `IntervalSec int`, `MaxAttempts int`를 가지며 YAML/mapstructure key는 `reconnect.interval_sec`, `reconnect.max_attempts`다.
- `setDefaults`에 `reconnect.interval_sec=10`, `reconnect.max_attempts=10`을 추가하고 `configs/node.yaml` 예시에도 같은 값을 둔다.
- `apps/node/internal/transport`에 `ReconnectPolicy`와 `Sleeper`/`Clock` 없이도 테스트 가능한 작은 injectable delay 함수 seam을 둔다. 실제 10초 sleep은 supervisor에서 주입된 sleeper가 담당하고 unit test는 fake sleeper로 시도 기록만 검증한다.
After direction:
```go
type NodeConfig struct {
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
Reconnect ReconnectConf `mapstructure:"reconnect" yaml:"reconnect"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
type ReconnectConf struct {
IntervalSec int `mapstructure:"interval_sec" yaml:"interval_sec"`
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
}
```
#### 수정 파일 및 체크리스트
- [ ] `packages/go/config/config.go`: `ReconnectConf` type, `NodeConfig.Reconnect`, defaults 추가
- [ ] `packages/go/config/config_test.go`: `TestLoad_NodeReconnectDefaults`, override test 추가
- [ ] `configs/node.yaml`: `reconnect.interval_sec: 10`, `reconnect.max_attempts: 10` 예시 추가
- [ ] `apps/node/internal/transport/client.go`: reconnect policy/seam을 register retry constants와 분리
#### 테스트 작성
- 작성: `packages/go/config/config_test.go`에 Node reconnect default/override 테스트.
- 작성: `apps/node/internal/transport`에 fake sleeper 기반 policy 단위 테스트. 실제 10초 sleep을 테스트하지 않는다.
#### 중간 검증
```bash
go test -count=1 ./packages/go/config ./apps/node/internal/transport
```
기대 결과: config default/override와 reconnect policy unit test 통과.
### [RECONNECT-2] Reconnect Supervisor Session Re-Establishment
#### 문제
- `apps/node/internal/transport/session.go:87-98`은 disconnect를 event로 emit하지만 bootstrap이 supervisor로 구독할 수 있는 terminal signal을 제공하지 않는다.
- `apps/node/internal/bootstrap/module.go:41-85`는 OnStart에서 한 번 `DialEdge`, adapter registry, store, node handler를 만들고 이후 session이 끊겨도 다시 연결하지 않는다.
- 재접속 성공 시 새 `RegisterResponse.Config` 기준 adapter registry/runtime/store boundary를 재평가해야 하는데 현재 구조는 최초 payload만 사용한다.
Before:
```go
// apps/node/internal/bootstrap/module.go:41
OnStart: func(ctx context.Context) error {
result, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger)
if err != nil {
return fmt.Errorf("bootstrap: dial edge: %w", err)
}
// ...
result.Session.SetHandler(n)
sess = result.Session
return nil
},
```
#### 해결 방법
- `Session`에 disconnect completion channel 또는 `SetDisconnectHandler`를 추가한다. local `Close()`로 인한 shutdown과 remote disconnect를 구분해 supervisor가 local shutdown에는 재시도하지 않게 한다.
- `bootstrap.Module` 내부의 연결 수립 로직을 `connectRuntime(ctx)` 같은 helper로 추출한다. helper는 `DialEdge`, `BuildFromPayload`, `storeDSN`, `store.New`, `reg.Start`, `node.New`, `Session.SetHandler`를 순서대로 수행하고 실패 시 생성된 자원을 역순 cleanup한다.
- OnStart는 최초 연결 성공 후 supervisor goroutine을 시작한다. goroutine은 remote disconnect 신호를 받으면 현재 runtime 자원을 정리하고 policy interval/max attempts로 재접속한다.
- 재접속 성공 시 이전 `node.Node`와 `Session`이 아니라 새 `RegisterResponse` 기반 새 `Registry`, `Router`, `Store`, `Node`, `Session`을 설치한다. store workspace root가 같으면 같은 DSN을 다시 열고, 달라지면 새 DSN을 사용한다.
- event/log에는 attempt, max_attempts, next_delay, node_id/alias를 남긴다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/transport/session.go`: disconnect signal/callback과 local shutdown 구분 추가
- [ ] `apps/node/internal/transport/session_test.go`: concurrent SetHandler 기존 테스트 유지, disconnect signal unit test 추가
- [ ] `apps/node/internal/bootstrap/module.go`: runtime connection helper와 supervisor goroutine 추가
- [ ] `apps/node/internal/bootstrap/module_test.go`: 기존 store-before-adapter-start 테스트가 유지되도록 helper cleanup path 테스트 보강
- [ ] `apps/node/internal/transport/integration_test.go`: Edge stop/start 또는 fake edge reconnect integration 추가
#### 테스트 작성
- 작성: `apps/node/internal/transport/session_test.go`에 remote disconnect signal이 한 번 닫히고 local Close reason은 local shutdown으로 남는지 확인.
- 작성: `apps/node/internal/transport/integration_test.go` 또는 bootstrap test에 Edge server를 닫았다가 새 server를 같은 addr로 열고 Node가 다시 RegisterRequest를 보내는 테스트.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/transport ./apps/node/internal/bootstrap
```
기대 결과: 기존 session/bootstrap 테스트와 reconnect integration 테스트 통과.
### [RECONNECT-3] Retry Exhaustion Cleanup And Shutdown
#### 문제
- 현재 `bootstrap.Module`의 `OnStop`만 adapter registry, session, store를 정리한다(`apps/node/internal/bootstrap/module.go:87-101`).
- remote disconnect 후 retry 10회 초과 시 cleanup과 process 종료 신호를 반환하는 경로가 없다.
- `apps/node/cmd/node/main.go:38-43`은 `app.Run()`에 종료 코드 propagation을 맡기고 있어 supervisor에서 `fx.Shutdowner`를 통해 non-zero exit를 요청해야 한다.
Before:
```go
// apps/node/internal/bootstrap/module.go:87
OnStop: func(_ context.Context) error {
if reg != nil {
if err := reg.Stop(context.Background()); err != nil {
return err
}
}
if sess != nil {
if err := sess.Close(); err != nil {
return err
}
}
if st != nil {
return st.Close()
}
return nil
},
```
#### 해결 방법
- runtime 자원을 소유하는 작은 struct를 두고 `close(ctx)`가 adapter registry stop, session close, store close를 idempotent하게 실행하게 한다.
- supervisor retry loop는 `MaxAttempts`를 초과하면 마지막 error와 함께 cleanup을 보장하고 `fx.Shutdowner.Shutdown(fx.ExitCode(1))` 또는 현재 fx 버전에 맞는 non-zero shutdown option을 호출한다.
- retry attempt test는 dialer function과 sleeper function을 주입해 네트워크 없이 10회 실패와 cleanup/shutdown 호출을 검증한다.
- OnStop은 supervisor context cancel 후 현재 runtime close를 호출하고, retry exhaustion close와 중복되어도 안전해야 한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/bootstrap/module.go`: runtime owner struct, idempotent cleanup, `fx.Shutdowner` 기반 exit-limit 추가
- [ ] `apps/node/internal/bootstrap/module_test.go`: failing dialer 10회, fake sleeper delays, cleanup 호출, shutdown call 검증
- [ ] `apps/node/cmd/node/main.go`: 필요 시 `app.Run()` exit propagation이 충분한지 확인하고, 변경 없이 충분하면 계획 대비 변경 사항에 기록
#### 테스트 작성
- 작성: `apps/node/internal/bootstrap/module_test.go`에 fake dialer/sleeper/shutdowner 기반 retry exhaustion test.
- 기존 외부 process 종료를 직접 실행하지 않고 fx shutdown path와 cleanup call을 관측한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/bootstrap
```
기대 결과: 10회 실패 후 cleanup과 shutdown path가 fake로 관측된다.
## 의존 관계 및 구현 순서
`01_reconnect_supervisor`는 predecessor가 없다. 권장 순서는 RECONNECT-1 -> RECONNECT-2 -> RECONNECT-3이다. RECONNECT-2와 RECONNECT-3는 같은 runtime owner/helper를 공유하므로 구현 중 helper 이름과 cleanup semantics를 먼저 고정한 뒤 테스트를 작성한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | RECONNECT-1 |
| `packages/go/config/config_test.go` | RECONNECT-1 |
| `configs/node.yaml` | RECONNECT-1 |
| `apps/node/internal/transport/client.go` | RECONNECT-1, RECONNECT-2 |
| `apps/node/internal/transport/session.go` | RECONNECT-2 |
| `apps/node/internal/transport/session_test.go` | RECONNECT-2 |
| `apps/node/internal/transport/integration_test.go` | RECONNECT-2 |
| `apps/node/internal/bootstrap/module.go` | RECONNECT-2, RECONNECT-3 |
| `apps/node/internal/bootstrap/module_test.go` | RECONNECT-2, RECONNECT-3 |
| `apps/node/cmd/node/main.go` | RECONNECT-3 review-only unless exit propagation requires a small change |
## 최종 검증
```bash
go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
```
Expected: targeted config, node reconnect, bootstrap lifecycle, and edge dedupe transport tests pass.
```bash
go test -count=1 ./apps/node/... ./apps/edge/... ./packages/go/...
```
Expected: local node/edge/platform-common package regressions pass. Fresh execution is required; do not rely on Go test cache.
```bash
./scripts/e2e-smoke.sh
```
Expected: mock edge-node smoke passes. This is auxiliary evidence only.
Repo internal edge-node diagnostic is required by `agent-ops/skills/project/e2e-smoke/SKILL.md`: run `scripts/dev/edge.sh` and `scripts/dev/node.sh` in separate processes with non-conflicting temporary config, verify startup/register, `/nodes`, two same-session messages with node local payload matching edge rendered payload, and `/capabilities`, `/transport`, `/sessions`. If this cannot run in the current environment, record the exact blocker and residual risk in `CODE_REVIEW-*-G??.md`.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,203 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=1 tag=REVIEW_RECONNECT -->
# PLAN - REVIEW_RECONNECT Follow-up
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `RECONNECT` 1차 구현의 코드리뷰 FAIL 항목만 해결한다. 범위를 넓혀 roadmap, SDD, agent-ops rule, field docs, refresh contract, node config apply를 수정하지 않는다.
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 구현의 마지막 단계다. 코딩 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone의 `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남긴다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 검증 증거 공백, 환경/TTY 문제, 반복 실패는 사용자 리뷰 요청이 아니라 `검증 결과`, `계획 대비 변경 사항`, 또는 일반 follow-up으로 남긴다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `apps/node/internal/bootstrap/module.go:203` starts reconnect attempt 1 immediately after remote disconnect; policy sleep only happens after failed attempts at `apps/node/internal/bootstrap/module.go:223`, which does not match the approved SDD state machine's reconnect_waiting interval.
- `apps/node/internal/bootstrap/module_test.go:262` verifies `MaxAttempts=3`, `IntervalSec=0` without `WithSleeper`; SDD S01/S03 still need fake sleeper evidence for 10s interval and 10 attempts. `apps/node/cmd/node/main_test.go:77` does not assert reconnect fields in config print output.
- `apps/node/internal/bootstrap/module_test.go:171` and `apps/node/internal/bootstrap/module_test.go:239` leave `WorkspaceRoot` empty, causing `storeDSN("")` to create untracked `apps/node/internal/bootstrap/iop.db` during package tests.
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log:200` recorded a TTY blocker instead of actual `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow evidence. `./scripts/e2e-smoke.sh` itself reported that completion still requires that diagnostic.
- Reviewer verification:
- `go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport` passed.
- After the test, `apps/node/internal/bootstrap/iop.db` remained as an untracked SQLite file.
- Affected files expected:
- `apps/node/internal/bootstrap/module.go`
- `apps/node/internal/bootstrap/module_test.go`
- `apps/node/cmd/node/main_test.go`
- `apps/node/internal/bootstrap/iop.db` removal
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`
- Roadmap carryover: keep the same Roadmap Targets. Do not include `dedupe` in PASS completion metadata.
- Archive files allowed for exact prior context: the two current archived files listed above only.
## 범위 결정 근거
- 포함: 리뷰 Required 항목을 닫기 위한 Node reconnect supervisor timing, retry policy evidence, config print assertion, test workspace isolation, required repo internal edge-node diagnostic evidence.
- 제외: Edge refresh entrypoint, proto/config refresh push contract, adapter diff/drain/runtime config apply, field docs/smoke, roadmap/SDD 문서 변경.
- `runtimeOwner.close()` error logging은 Suggested 항목이다. Required 해결 중 자연스럽게 처리할 수 있으면 반영하되, 이로 인해 범위를 넓히지 않는다.
## 구현 체크리스트
- [ ] SDD state machine에 맞게 remote disconnect 후 reconnect attempt가 `reconnect.interval_sec` policy를 통과하도록 supervisor timing을 정리한다. 검증: fake sleeper가 attempt 시작 전 또는 SDD 기준 interval 위치에서 `10s` duration을 관측한다.
- [ ] `MaxAttempts=10`, `IntervalSec=10` retry policy를 fake dialer/fake sleeper로 검증하고, Node `config print` 출력에 `reconnect.interval_sec`와 `reconnect.max_attempts`가 노출되는 assertion을 추가한다.
- [ ] bootstrap reconnect tests가 `t.TempDir()` 기반 `WorkspaceRoot`를 사용하게 고치고, 생성된 `apps/node/internal/bootstrap/iop.db` 산출물을 제거한다. 검증: 대상 테스트 후 `git status --short apps/node/internal/bootstrap/iop.db`가 비어 있다.
- [ ] PTY 또는 원격/code-server runner에서 `scripts/dev/edge.sh`와 `scripts/dev/node.sh` repo 내부 edge-node 진단을 수행해 `/nodes`, 같은 session 메시지 2회, node local payload와 edge rendered payload 동일성, `/capabilities`, `/transport`, `/sessions` 실제 출력을 `CODE_REVIEW-*-G??.md`에 기록한다.
- [ ] 최종 검증 명령과 repo 내부 edge-node 진단 결과를 `CODE_REVIEW-*-G??.md`에 실제 출력으로 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_RECONNECT-1] SDD Reconnect Interval Semantics
#### 문제
승인된 SDD는 `edge_disconnected` 뒤 `reconnect_waiting`을 거쳐 interval 경과 후 `reconnecting`으로 넘어가는 상태 흐름을 둔다. 현재 supervisor는 remote disconnect를 받으면 바로 attempt 1을 실행하고, sleep은 실패한 attempt 사이에만 적용한다.
#### 해결 방법
- `apps/node/internal/bootstrap/module.go`에서 remote disconnect 후 attempt 시작 전에 `cfg.Reconnect.IntervalSec` policy를 통과하도록 정리한다.
- `IntervalSec=0`은 테스트용 즉시 진행 seam으로 유지할 수 있다.
- 로그에는 attempt, max_attempts, interval_sec가 계속 남아야 한다.
- SDD 기준을 바꾸는 방식으로 해결하지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/bootstrap/module.go`: reconnect attempt timing 수정
- [ ] `apps/node/internal/bootstrap/module_test.go`: fake sleeper로 interval 위치 검증
#### 테스트 작성
- 작성: remote disconnect 후 fake sleeper가 `10*time.Second`를 관측한 뒤 dial attempt가 시작되는 테스트.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/bootstrap
```
### [REVIEW_RECONNECT-2] Retry Policy And Config Print Evidence
#### 문제
현재 exhaustion 테스트는 `MaxAttempts=3`, `IntervalSec=0`만 확인한다. SDD S01/S03와 PLAN은 fake sleeper/injectable seam으로 10초 정책과 10회 한계를 확인하라고 요구했다. Node config print 테스트도 YAML 출력이 비어 있지 않은지만 확인한다.
#### 해결 방법
- fake dialer와 fake sleeper를 함께 사용해 reconnect failure가 10회까지만 발생하는지 검증한다.
- fake sleeper가 받은 duration이 10초 policy와 일치하는지 검증한다.
- Node `config print` 테스트에서 출력 YAML에 `reconnect:`, `interval_sec: 10`, `max_attempts: 10`이 포함되는지 확인한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/bootstrap/module_test.go`: 10회 retry + 10초 sleeper assertion 추가
- [ ] `apps/node/cmd/node/main_test.go`: config print reconnect field assertion 추가
#### 테스트 작성
- 작성: `TestReconnectSupervisorExhaustsRetries` 또는 새 테스트에서 `MaxAttempts=10`, `IntervalSec=10`과 fake sleeper durations를 검증한다.
- 작성: `TestConfigPrintCmdPrintsResolvedConfig`에 reconnect field assertion을 추가한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/bootstrap ./apps/node/cmd/node
```
### [REVIEW_RECONNECT-3] Bootstrap Test Workspace Isolation
#### 문제
새 bootstrap reconnect tests가 empty workspace root를 내려주어 SQLite store가 package cwd에 `iop.db`를 만든다. 이 산출물은 `.gitignore`에 잡히지 않고 리뷰어 재실행 후 untracked로 남았다.
#### 해결 방법
- successful bootstrap/reconnect test payload의 `NodeRuntimeConfig.WorkspaceRoot`를 `t.TempDir()`로 설정한다.
- 이미 생성된 `apps/node/internal/bootstrap/iop.db`를 제거한다.
- 테스트 재실행 뒤 해당 파일이 다시 생기지 않는지 확인한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/bootstrap/module_test.go`: reconnect/exhaustion mock payload workspace root를 temp dir로 설정
- [ ] `apps/node/internal/bootstrap/iop.db`: generated artifact 제거
#### 테스트 작성
- 기존 테스트를 보강한다. 별도 새 테스트는 불필요하다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/bootstrap
git status --short apps/node/internal/bootstrap/iop.db
```
기대 결과: Go test 통과, git status 출력 없음.
### [REVIEW_RECONNECT-4] Repo Internal Edge-Node Diagnostic Evidence
#### 문제
필수 repo 내부 edge-node 진단이 실제 실행 결과가 아니라 TTY blocker로 남아 있다. 보조 `./scripts/e2e-smoke.sh` 통과는 이 검증을 대체하지 않는다.
#### 해결 방법
- PTY가 있는 local shell, code-server, 또는 local rules의 원격 runner 중 하나를 사용한다.
- `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행한다.
- edge console에서 `/nodes`, 같은 session 메시지 2회, `/capabilities`, `/transport`, `/sessions`를 실행한다.
- node local `[node-message]` payload와 edge `[node-*-message]` payload가 run별로 내용/순서까지 동일한지 기록한다.
- 실행 불가 시에는 어떤 runner/command가 왜 불가했는지 실제 출력과 함께 남긴다. 단, 이 경우 PASS completion은 기대하지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`: 실제 진단 출력 기록
#### 테스트 작성
- 테스트 코드 추가는 불필요하다. 검증 evidence를 수집한다.
#### 중간 검증
```bash
./scripts/e2e-smoke.sh
scripts/dev/edge.sh
scripts/dev/node.sh
```
기대 결과: 보조 smoke 통과와 별도로 repo 내부 edge-node 진단의 메시지 2회 왕복 및 command 응답 evidence가 기록된다.
## 최종 검증
```bash
go test -count=1 ./packages/go/config ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
```
Expected: targeted config, node reconnect, bootstrap lifecycle, and edge transport tests pass.
```bash
go test -count=1 ./apps/node/... ./apps/edge/... ./packages/go/...
```
Expected: local node/edge/platform-common package regressions pass. Fresh execution is required.
```bash
./scripts/e2e-smoke.sh
```
Expected: auxiliary smoke passes and remains clearly separated from required repo internal diagnostic.
```bash
git status --short apps/node/internal/bootstrap/iop.db
```
Expected: no output.
Repo internal edge-node diagnostic remains required: run `scripts/dev/edge.sh` and `scripts/dev/node.sh` in separate processes and record `/nodes`, message x2, node-local/edge-rendered payload matching, `/capabilities`, `/transport`, and `/sessions`.

View file

@ -0,0 +1,103 @@
<!-- task=m-runtime-reconnect-config-refresh/01_reconnect_supervisor plan=2 tag=REVIEW_REVIEW_RECONNECT -->
# PLAN - REVIEW_REVIEW_RECONNECT Follow-up
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `REVIEW_RECONNECT` 리뷰에서 남은 필수 검증 증거 하나만 해결한다. 소스 코드는 이미 대상 Go 테스트와 `iop.db` 산출물 검증을 통과했으므로, 새 코드 변경을 하지 않는다. 단, 진단 중 실제 코드 결함이 새로 드러나면 그 결함과 증거를 `계획 대비 변경 사항`에 기록하고 최소 수정만 수행한다.
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 검증 출력으로 채우는 것이 구현의 마지막 단계다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone의 `구현 잠금 > 결정 필요` 항목이 구현을 차단할 때만 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남긴다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 검증 증거 공백, 환경/TTY 문제, 반복 실패는 사용자 리뷰 요청이 아니라 `검증 결과`, `계획 대비 변경 사항`, 또는 일반 follow-up으로 남긴다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md`
- Task ids:
- `retry-policy`: Node reconnect 기본값이 `interval=10s`, `max_attempts=10`으로 고정되고 config/문서/로그에 노출된다.
- `supervisor`: Node bootstrap lifecycle이 최초 connect 성공 이후 Edge disconnect를 감지하고 reconnect supervisor로 세션을 재수립한다.
- `exit-limit`: 재접속 실패가 retry 한계를 넘으면 Node가 adapter/store/session을 정리하고 process 종료 신호를 반환한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log`
- Current archived review: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log`
- Verdict: FAIL
- Required summary:
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log:197` shows the second repo internal edge-node diagnostic run ended with `concurrency_unavailable` instead of a second `start` -> non-empty `message` -> `complete` relay.
- `agent-ops/rules/project/domain/testing/rules.md` and `agent-test/local/node-smoke.md` require two same-session messages to complete on the edge console and require run-by-run node local `[node-message]` payloads to match edge rendered `[node-*-message]` payloads in content/order.
- Reviewer reran `go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport`, `git status --short apps/node/internal/bootstrap/iop.db`, `git diff --check`, and `./scripts/e2e-smoke.sh`; all passed, but `./scripts/e2e-smoke.sh` explicitly remains auxiliary and does not replace `scripts/dev/edge.sh` + `scripts/dev/node.sh` evidence.
- Affected files expected:
- `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`
- Roadmap carryover: keep the same Roadmap Targets. Do not include `dedupe` in PASS completion metadata.
- Archive files allowed for exact prior context: `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log` and `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log` only.
## 범위 결정 근거
- 포함: repo 내부 edge-node 진단 증거 재수집, active review 파일의 검증 결과 기록.
- 제외: Node reconnect supervisor 코드, config/default, tests, roadmap, SDD, docs, agent-ops rule 변경.
- 허용: 기본 `configs/*.yaml`로 검증이 불가능한 외부 CLI 상태가 확인되면, tracked config를 오염시키지 않는 임시 config와 `IOP_EDGE_CONFIG`/`IOP_NODE_CONFIG`를 사용해도 된다. 이 경우에도 반드시 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 실행한 출력이어야 하며, `./scripts/e2e-smoke.sh` 출력만 복사해서는 안 된다.
## 구현 체크리스트
- [ ] `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 별도 프로세스로 실행하고 edge console에서 `/nodes`, `/capabilities`, `/transport`, `/sessions` 응답을 기록한다.
- [ ] 같은 session에서 메시지 2회를 보내되, 두 번째 메시지는 첫 번째 run의 `[node*-evt] complete`가 edge console에 표시된 뒤에만 보낸다.
- [ ] 두 run 각각에 대해 edge console의 `[edge] sent`, `[node*-evt] start`, 비어 있지 않은 `[node*-msg]`, `[node*-evt] complete` 순서와 run id를 기록한다.
- [ ] node local 출력의 `[node-message]` payload와 edge rendered `[node*-msg]` payload가 run별로 내용/순서까지 일치한다는 비교 근거를 기록한다.
- [ ] 보조 smoke와 대상 Go 테스트를 fresh rerun하고, `git status --short apps/node/internal/bootstrap/iop.db`가 비어 있음을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REVIEW_RECONNECT-1] Repo Internal Edge-Node User-Flow Evidence
#### 문제
이전 follow-up은 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 실행했지만 두 번째 메시지를 첫 번째 run 완료 전에 보내서 `concurrency_unavailable`로 거부되었다. testing domain 기준의 "메시지 2회 왕복"은 두 요청이 각각 정상 run으로 `start`/`message`/`complete`까지 도달해야 한다.
#### 해결 방법
- edge console 입력을 수동 PTY, code-server, 원격 runner, 또는 FIFO로 제어한다.
- 첫 번째 prompt 전송 후 edge console에 해당 run의 complete event가 보일 때까지 기다린다.
- complete 이후 두 번째 prompt를 보낸다.
- 두 prompt는 서로 구분 가능한 짧은 응답을 요구한다.
- `/nodes`, `/capabilities`, `/transport`, `/sessions`는 실제 edge console 출력과 node command 응답을 그대로 기록한다.
- node local output과 edge output을 나란히 비교하고, echo인 `[edge-message]`가 아니라 node-generated payload와 edge-rendered payload를 기준으로 일치 여부를 적는다.
#### 수정 파일 및 체크리스트
- [ ] `agent-task/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/CODE_REVIEW-cloud-G07.md`: 실제 진단 출력과 비교 근거 기록
#### 테스트 작성
- 테스트 코드 추가는 불필요하다.
#### 중간 검증
```bash
scripts/dev/edge.sh
scripts/dev/node.sh
```
기대 결과: 같은 session 메시지 2회가 각각 edge 화면에서 `start` -> non-empty `message` -> `complete` 순서로 끝나고, node local payload와 edge rendered payload가 run별로 일치한다.
## 최종 검증
```bash
go test -count=1 ./packages/go/config ./apps/node/cmd/node ./apps/node/internal/transport ./apps/node/internal/bootstrap ./apps/edge/internal/transport
```
Expected: targeted config, node CLI config print, node transport, bootstrap reconnect lifecycle, and edge transport tests pass.
```bash
./scripts/e2e-smoke.sh
```
Expected: auxiliary smoke passes and remains clearly separated from required repo internal diagnostic.
```bash
git status --short apps/node/internal/bootstrap/iop.db
```
Expected: no output.
Repo internal edge-node diagnostic remains required: run `scripts/dev/edge.sh` and `scripts/dev/node.sh` in separate processes and record `/nodes`, message x2, node-local/edge-rendered payload matching, `/capabilities`, `/transport`, and `/sessions`.

View file

@ -106,7 +106,9 @@ sudo iop-edge setup --enable --start --binary /usr/local/bin/iop-edge
Edge 배포 archive는 repo root에서 `make build`로 만든다.
archive에는 `iop-edge`와 Linux/macOS/Windows arm64/amd64 Node bootstrap용 `artifacts/`가 들어가며, Edge host에서 `config init``node register`를 수행해 현재 host 기준 bootstrap URL을 확정한다.
Node host는 `<artifact-base-url>/bootstrap/node.sh` 하나를 실행하고, bootstrap script가 OS/architecture를 감지해 맞는 Node artifact를 받는다.
Node host는 `node register` 출력에 포함된 OS별 bootstrap 명령 원문을 그대로 실행한다. Linux/macOS는 generated `curl | bash` 명령을 사용하고, Windows native 환경은 generated PowerShell `.ps1` bootstrap과 `Start-IopNode` 함수를 사용한다. Windows에서 bash를 요구하지 않는다.
기본 bootstrap은 token, artifact URL, Edge address처럼 Node 연결에 필요한 최소값만 전달한다. provider endpoint, capacity, model mapping 같은 추가 설정은 Edge config와 register response의 기본값을 따른다.
사용자 안내용 bootstrap 명령은 실제 URL과 실제 token이 들어간 완성된 한 줄 명령으로 제공한다. README나 tracked 문서에는 token 원문을 남기지 않는다.
특정 Node target만 담긴 개발용 package는 `make build NODE_TARGETS="<goos>-<goarch>"`로 만들 수 있다. Edge만 갱신할 때는 `make build-edge && make pack-edge`, Node artifact만 갱신할 때는 `make pack-node-target NODE_TARGET=<goos>-<goarch> && make pack-edge`를 사용한다.
`setup``--config` 기본값은 `/etc/iop/edge.yaml`이다. dev 단계 명령(`serve`, `console`, `config init/print/check`, `env`, `node register`)의 root persistent `--config` 기본값은 bundle-local `edge.yaml` (없을 시 executable-adjacent `edge.yaml`, 그 다음 repo dev fallback `configs/edge.yaml`)로 확인된다. 기존 설정 파일은 그대로 두며 `--overwrite-config`를 지정해야 덮어쓴다.

View file

@ -410,6 +410,59 @@ bootstrap:
}
}
func TestBootstrapPackCreatesWindowsPowerShellArtifact(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "127.0.0.1:39090"
advertise_host: "edge.example.test"
bootstrap:
listen: "127.0.0.1:38080"
artifact_dir: "artifacts"
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
nodeBinary := filepath.Join(dir, "iop-node")
if err := os.WriteFile(nodeBinary, []byte("fake-node-binary"), 0o755); err != nil {
t.Fatalf("write node binary: %v", err)
}
outDir := filepath.Join(dir, "out")
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"--config", cfgPath,
"bootstrap", "pack",
"--node-binary", nodeBinary,
"--output", outDir,
"--target", "windows-amd64",
})
if err := root.Execute(); err != nil {
t.Fatalf("execute pack: %v\n%s", err, out.String())
}
psScript, err := os.ReadFile(filepath.Join(outDir, "bootstrap", "node-windows-amd64.ps1"))
if err != nil {
t.Fatalf("read powershell script: %v", err)
}
got := string(psScript)
for _, want := range []string{
"function Start-IopNode",
"$Target = 'windows-amd64'",
"$ArtifactBaseURL = 'http://edge.example.test:38080'",
"$EdgeAddr = 'edge.example.test:39090'",
"Start-Process -FilePath $BinFile",
"port: 19104",
} {
if !strings.Contains(got, want) {
t.Fatalf("powershell script missing %q:\n%s", want, got)
}
}
}
func TestNodeRegisterRefreshesExistingBootstrapArtifact(t *testing.T) {
dir := t.TempDir()
artifactDir := filepath.Join(dir, "artifacts")

View file

@ -166,6 +166,12 @@ func packNodeBootstrap(opts nodeBootstrapPackOptions) error {
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("write bootstrap script: %w", err)
}
if strings.HasPrefix(opts.Target, "windows-") {
psScriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".ps1")
if err := os.WriteFile(psScriptPath, []byte(nodeBootstrapPowerShellScript(opts)), 0o644); err != nil {
return fmt.Errorf("write powershell bootstrap script: %w", err)
}
}
if err := writeUniversalBootstrapScript(bootstrapDir, opts); err != nil {
return err
}
@ -237,6 +243,17 @@ func shellQuote(s string) string {
return strconv.Quote(s)
}
func powershellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func bootstrapCommand(artifactURL, target, token string) string {
if strings.HasPrefix(target, "windows-") {
return fmt.Sprintf("Invoke-RestMethod %s | Invoke-Expression; Start-IopNode %s", bootstrapPowerShellScriptURL(artifactURL, target), powershellQuote(token))
}
return fmt.Sprintf("curl -fsSL %s | bash -s %s", universalBootstrapScriptURL(artifactURL), token)
}
func nodeBootstrapScript(opts nodeBootstrapPackOptions) string {
return `#!/usr/bin/env bash
set -euo pipefail
@ -287,7 +304,7 @@ ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}"
if [[ -z "$NODE_TOKEN" ]]; then
echo "[iop-bootstrap] missing node token" >&2
echo "[iop-bootstrap] usage: curl -fsSL <artifact-base-url>/bootstrap/node.sh | bash -s TOKEN" >&2
echo "[iop-bootstrap] rerun the complete bootstrap command printed by iop-edge node register" >&2
exit 2
fi
@ -339,3 +356,65 @@ echo "[iop-bootstrap] starting node against $EDGE_ADDR"
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
`
}
func nodeBootstrapPowerShellScript(opts nodeBootstrapPackOptions) string {
return `$ErrorActionPreference = 'Stop'
function Start-IopNode {
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Token
)
$Version = ` + powershellQuote(opts.Version) + `
$Target = ` + powershellQuote(opts.Target) + `
$ArtifactBaseURL = ` + powershellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + `
$EdgeAddr = ` + powershellQuote(opts.EdgeAddr) + `
$InstallDir = Join-Path $HOME 'iop-field'
$ConfigFile = Join-Path $InstallDir 'node.yaml'
$LogFile = Join-Path $InstallDir 'iop-node.log'
$LogFileYAML = $LogFile.Replace('\', '/')
$StdoutLog = Join-Path $InstallDir 'iop-node.stdout.log'
$StderrLog = Join-Path $InstallDir 'iop-node.stderr.log'
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ('iop-node-bootstrap-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $InstallDir, $TmpDir | Out-Null
try {
$TmpNode = Join-Path $TmpDir 'iop-node'
Invoke-WebRequest -UseBasicParsing -Uri "$ArtifactBaseURL/$Target/iop-node" -OutFile $TmpNode
$Sums = Invoke-RestMethod -Uri "$ArtifactBaseURL/$Target/SHA256SUMS"
$Expected = (($Sums -split '\r?\n' | Where-Object { $_ -match '\s+iop-node$' } | Select-Object -First 1) -split '\s+')[0]
if ([string]::IsNullOrWhiteSpace($Expected)) {
throw 'checksum entry for iop-node not found'
}
$Actual = (Get-FileHash -Algorithm SHA256 -Path $TmpNode).Hash.ToLowerInvariant()
if ($Actual -ne $Expected.ToLowerInvariant()) {
throw "checksum mismatch for iop-node: expected $Expected got $Actual"
}
$BinFile = Join-Path $InstallDir 'iop-node.exe'
Copy-Item -Force $TmpNode $BinFile
@"
transport:
edge_addr: "$EdgeAddr"
token: "$Token"
logging:
level: "info"
pretty: true
path: "$LogFileYAML"
metrics:
port: 19104
"@ | Set-Content -Encoding UTF8 -Path $ConfigFile
$Process = Start-Process -FilePath $BinFile -ArgumentList @('serve', '--config', $ConfigFile) -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -WindowStyle Hidden -PassThru
"[iop-bootstrap] version=$Version target=$Target pid=$($Process.Id)"
"[iop-bootstrap] config=$ConfigFile log=$LogFile"
} finally {
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
}
}
`
}

View file

@ -179,6 +179,33 @@ func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) {
}
}
func TestNodeRegisterOutputsPowerShellBootstrapForWindowsTarget(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
artifactDir := filepath.Join(dir, "artifacts")
yamlStr := "server:\n listen: \"0.0.0.0:9090\"\nbootstrap:\n artifact_dir: \"" + strings.ReplaceAll(artifactDir, "\\", "\\\\") + "\"\n"
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
root := NewRoot(Options{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-win", "--target", "windows-amd64", "--token", "win-token-123"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register node-win: %v\n%s", err, out.String())
}
expectedCmd := "Invoke-RestMethod " + bootstrapPowerShellScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), "windows-amd64") + " | Invoke-Expression; Start-IopNode 'win-token-123'"
if strings.TrimSpace(out.String()) != expectedCmd {
t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, out.String())
}
if _, err := os.Stat(filepath.Join(artifactDir, "bootstrap", "node-windows-amd64.ps1")); err != nil {
t.Fatalf("expected powershell bootstrap script to be refreshed: %v", err)
}
}
func TestNodesListPrintsConfiguredProviders(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")

View file

@ -107,6 +107,10 @@ func bootstrapScriptURL(baseURL, target string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh"
}
func bootstrapPowerShellScriptURL(baseURL, target string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".ps1"
}
func universalBootstrapScriptURL(baseURL string) string {
return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node.sh"
}

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/google/uuid"
"github.com/spf13/cobra"
@ -44,6 +45,14 @@ func refreshBootstrapForExistingArtifact(cfg *config.EdgeConfig, target, artifac
if err := writeUniversalBootstrapScript(bootstrapDir, baseOpts); err != nil {
return err
}
if strings.HasPrefix(target, "windows-") {
targetOpts := baseOpts
targetOpts.Target = target
psScriptPath := filepath.Join(bootstrapDir, "node-"+target+".ps1")
if err := os.WriteFile(psScriptPath, []byte(nodeBootstrapPowerShellScript(targetOpts)), 0o644); err != nil {
return fmt.Errorf("write powershell bootstrap script: %w", err)
}
}
nodeBinary := filepath.Join(outputDir, target, "iop-node")
if _, err := os.Stat(nodeBinary); err != nil {
@ -254,8 +263,7 @@ directly in the default flow.`,
return fmt.Errorf("write config file: %w", err)
}
bootstrapURL := universalBootstrapScriptURL(artifactURL)
fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token)
fmt.Fprintln(cmd.OutOrStdout(), bootstrapCommand(artifactURL, target, nodeDef.Token))
return nil
},

View file

@ -65,6 +65,23 @@ func waitForRegistryEntry(ctx context.Context, registry *edgenode.Registry, node
}
}
func waitForRegistryAbsent(ctx context.Context, registry *edgenode.Registry, nodeID string) bool {
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if _, ok := registry.Get(nodeID); !ok {
return true
}
select {
case <-ctx.Done():
return false
case <-ticker.C:
}
}
}
func TestEdgeServerGenericRegistrationKind(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -183,6 +200,64 @@ func TestEdgeServerDuplicateRegistrationReason(t *testing.T) {
}
}
func TestEdgeServerReconnectAfterUnregisterAccepted(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger := zap.NewNop()
listenAddr := getFreePort(t)
registry := edgenode.NewRegistry()
nodeStore, err := edgenode.LoadFromConfig([]config.NodeDefinition{
{ID: "reconnect-01", Alias: "reconnect", Token: "reconnect-token", AgentKind: config.AgentKindGenericNode},
})
if err != nil {
t.Fatalf("load node store: %v", err)
}
server, err := transport.NewServer(listenAddr, registry, nodeStore, logger)
if err != nil {
t.Fatalf("new server: %v", err)
}
if err := server.Start(ctx); err != nil {
t.Fatalf("start server: %v", err)
}
defer server.Stop()
first := dialNode(t, ctx, listenAddr)
resp1, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&first.Communicator, &iop.RegisterRequest{Token: "reconnect-token"}, 2*time.Second)
if err != nil {
t.Fatalf("first register: %v", err)
}
if !resp1.GetAccepted() {
t.Fatalf("expected first registration accepted, got reason %q", resp1.GetReason())
}
if _, ok := waitForRegistryEntry(ctx, registry, "reconnect-01"); !ok {
t.Fatal("first registration not visible in registry within timeout")
}
if err := first.Close(); err != nil {
t.Fatalf("close first client: %v", err)
}
if ok := waitForRegistryAbsent(ctx, registry, "reconnect-01"); !ok {
t.Fatal("registry still contains node after disconnect")
}
second := dialNode(t, ctx, listenAddr)
defer second.Close()
resp2, err := toki.SendRequestTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&second.Communicator, &iop.RegisterRequest{Token: "reconnect-token"}, 2*time.Second)
if err != nil {
t.Fatalf("second register: %v", err)
}
if !resp2.GetAccepted() {
t.Fatalf("expected reconnect after unregister accepted, got reason %q", resp2.GetReason())
}
if _, ok := waitForRegistryEntry(ctx, registry, "reconnect-01"); !ok {
t.Fatal("reconnect registration not visible in registry within timeout")
}
}
func TestEdgeServerIntegration(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

View file

@ -86,6 +86,11 @@ func TestConfigPrintCmdPrintsResolvedConfig(t *testing.T) {
if out.Len() == 0 {
t.Fatal("expected non-empty YAML output")
}
for _, want := range []string{"reconnect:", "interval_sec: 10", "max_attempts: 10"} {
if !strings.Contains(out.String(), want) {
t.Errorf("config print output missing %q\n---\n%s", want, out.String())
}
}
}
func TestSetupDryRunUsesNodeDefaults(t *testing.T) {

View file

@ -7,6 +7,8 @@ import (
"io"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/fx"
"go.uber.org/zap"
@ -22,8 +24,117 @@ import (
iop "iop/proto/gen/iop"
)
// DialFunc is the signature used to connect to Edge. Can be replaced in tests.
type DialFunc func(ctx context.Context, addr, token string, logger *zap.Logger) (*transport.RegisterResult, error)
// Option configures Module behaviour; intended for testing only.
type Option func(*moduleOpts)
type moduleOpts struct {
dialer DialFunc
sleeper func(ctx context.Context, d time.Duration)
}
// WithDialer replaces the transport dial function.
func WithDialer(fn DialFunc) Option {
return func(o *moduleOpts) { o.dialer = fn }
}
// WithSleeper replaces the inter-retry sleep function.
func WithSleeper(fn func(ctx context.Context, d time.Duration)) Option {
return func(o *moduleOpts) { o.sleeper = fn }
}
// runtimeOwner holds one connection's resources and closes them idempotently.
type runtimeOwner struct {
reg *adapters.Registry
sess *transport.Session
st *store.Store
once sync.Once
}
func (r *runtimeOwner) close() {
r.once.Do(func() {
if r.reg != nil {
_ = r.reg.Stop(context.Background())
}
if r.sess != nil {
_ = r.sess.Close()
}
if r.st != nil {
_ = r.st.Close()
}
})
}
// connectRuntime dials edge and wires up adapters, store, router, and node handler.
// On any failure after partial allocation the allocated resources are closed.
func connectRuntime(ctx context.Context, cfg *config.NodeConfig, logger *zap.Logger, dialer DialFunc) (*runtimeOwner, error) {
result, err := dialer(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger)
if err != nil {
return nil, fmt.Errorf("dial edge: %w", err)
}
owner := &runtimeOwner{sess: result.Session}
reg, err := adapters.BuildFromPayload(result.Config, logger)
if err != nil {
owner.close()
return nil, fmt.Errorf("build adapters: %w", err)
}
owner.reg = reg
dsn, err := storeDSN(result.Config.GetRuntime().GetWorkspaceRoot())
if err != nil {
owner.close()
return nil, err
}
st, err := store.New(dsn, logger)
if err != nil {
owner.close()
return nil, fmt.Errorf("store: %w", err)
}
owner.st = st
if err := reg.Start(ctx); err != nil {
owner.close()
return nil, fmt.Errorf("start adapters: %w", err)
}
rtr := router.New(reg, logger)
globalConcurrency := int(result.Config.GetRuntime().GetConcurrency())
n := node.New(result.NodeID, rtr, st, globalConcurrency, os.Stdout, logger)
result.Session.SetEventHandler(func(event *iop.EdgeNodeEvent) {
printEdgeEvent(os.Stdout, event)
})
result.Session.SetHandler(n)
logger.Info("connected to edge",
zap.String("node_id", result.NodeID),
zap.String("alias", result.Alias),
)
return owner, nil
}
// Module returns the fx options that wire the node application.
func Module(cfg *config.NodeConfig) fx.Option {
func Module(cfg *config.NodeConfig, opts ...Option) fx.Option {
mo := &moduleOpts{
dialer: transport.DialEdge,
sleeper: func(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
select {
case <-ctx.Done():
timer.Stop()
case <-timer.C:
}
},
}
for _, opt := range opts {
opt(mo)
}
return fx.Options(
fx.Provide(
func() *config.NodeConfig { return cfg },
@ -33,49 +144,114 @@ func Module(cfg *config.NodeConfig) fx.Option {
},
),
fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger) {
var sess *transport.Session
var st *store.Store
var reg *adapters.Registry
fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger, shutdowner fx.Shutdowner) {
var mu sync.Mutex
var current *runtimeOwner
var cancelSupervisor context.CancelFunc
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
result, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger)
owner, err := connectRuntime(ctx, cfg, logger, mo.dialer)
if err != nil {
return fmt.Errorf("bootstrap: dial edge: %w", err)
return fmt.Errorf("bootstrap: %w", err)
}
reg, err = adapters.BuildFromPayload(result.Config, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: build adapters: %w", err)
}
mu.Lock()
current = owner
mu.Unlock()
dsn, err := storeDSN(result.Config.GetRuntime().GetWorkspaceRoot())
if err != nil {
_ = result.Session.Close()
return err
}
st, err = store.New(dsn, logger)
if err != nil {
_ = result.Session.Close()
return fmt.Errorf("bootstrap: store: %w", err)
}
supCtx, cancel := context.WithCancel(context.Background())
cancelSupervisor = cancel
if err := reg.Start(ctx); err != nil {
_ = reg.Stop(context.Background())
_ = result.Session.Close()
_ = st.Close()
return fmt.Errorf("bootstrap: start adapters: %w", err)
}
go func() {
sess := owner.sess
for {
select {
case <-supCtx.Done():
return
case <-sess.Done():
}
rtr := router.New(reg, logger)
globalConcurrency := int(result.Config.GetRuntime().GetConcurrency())
n := node.New(result.NodeID, rtr, st, globalConcurrency, os.Stdout, logger)
result.Session.SetEventHandler(func(event *iop.EdgeNodeEvent) {
printEdgeEvent(os.Stdout, event)
})
result.Session.SetHandler(n)
sess = result.Session
// Guard against context cancellation racing with disconnect.
select {
case <-supCtx.Done():
return
default:
}
if sess.IsLocalShutdown() {
return
}
maxAttempts := cfg.Reconnect.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = 10
}
intervalSec := cfg.Reconnect.IntervalSec
if intervalSec < 0 {
intervalSec = 10
}
var newOwner *runtimeOwner
for attempt := 1; attempt <= maxAttempts; attempt++ {
select {
case <-supCtx.Done():
return
default:
}
mo.sleeper(supCtx, time.Duration(intervalSec)*time.Second)
if supCtx.Err() != nil {
return
}
logger.Info("reconnecting to edge",
zap.Int("attempt", attempt),
zap.Int("max_attempts", maxAttempts),
zap.Int("interval_sec", intervalSec),
)
dialCtx, dialCancel := context.WithTimeout(supCtx, 30*time.Second)
o, dialErr := connectRuntime(dialCtx, cfg, logger, mo.dialer)
dialCancel()
if dialErr == nil {
newOwner = o
break
}
logger.Warn("reconnect attempt failed",
zap.Int("attempt", attempt),
zap.Int("max_attempts", maxAttempts),
zap.Error(dialErr),
)
}
if newOwner == nil {
mu.Lock()
prev := current
current = nil
mu.Unlock()
if prev != nil {
prev.close()
}
logger.Error("reconnect exhausted, shutting down node",
zap.Int("max_attempts", maxAttempts),
)
_ = shutdowner.Shutdown(fx.ExitCode(1))
return
}
mu.Lock()
prev := current
current = newOwner
mu.Unlock()
if prev != nil {
prev.close()
}
sess = newOwner.sess
}
}()
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
@ -85,18 +261,15 @@ func Module(cfg *config.NodeConfig) fx.Option {
return nil
},
OnStop: func(_ context.Context) error {
if reg != nil {
if err := reg.Stop(context.Background()); err != nil {
return err
}
if cancelSupervisor != nil {
cancelSupervisor()
}
if sess != nil {
if err := sess.Close(); err != nil {
return err
}
}
if st != nil {
return st.Close()
mu.Lock()
owner := current
current = nil
mu.Unlock()
if owner != nil {
owner.close()
}
return nil
},

View file

@ -2,19 +2,24 @@ package bootstrap_test
import (
"context"
"errors"
"fmt"
"net"
"os"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/fx"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"iop/apps/node/internal/bootstrap"
"iop/apps/node/internal/transport"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
@ -142,3 +147,262 @@ func TestModuleDoesNotStartAdaptersBeforeStoreReady(t *testing.T) {
t.Fatal("marker file was created — adapter was started before store was ready")
}
}
// TestReconnectSupervisorReestablishesSession verifies that after the edge-side
// client closes the connection, the supervisor reconnects and re-registers.
func TestReconnectSupervisorReestablishesSession(t *testing.T) {
host, port, addr := freeAddrForBootstrap(t)
serverCtx, serverCancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(serverCancel)
// regCh fires each time a RegisterRequest is handled.
regCh := make(chan struct{}, 8)
acceptedCh := make(chan *toki.TcpClient, 8)
server := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, 30, 10, bootstrapEdgeParserMap())
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(_ *iop.RegisterRequest) (*iop.RegisterResponse, error) {
regCh <- struct{}{}
return &iop.RegisterResponse{
Accepted: true,
NodeId: "reconnect-test-node",
Config: &iop.NodeConfigPayload{Runtime: &iop.NodeRuntimeConfig{Concurrency: 1, WorkspaceRoot: t.TempDir()}},
}, nil
},
)
acceptedCh <- client
return client
})
if err := server.Start(serverCtx); err != nil {
t.Fatalf("start server: %v", err)
}
t.Cleanup(func() { server.Stop() })
cfg := &config.NodeConfig{
Transport: config.TransportConf{EdgeAddr: addr, Token: "tok"},
Reconnect: config.ReconnectConf{MaxAttempts: 5, IntervalSec: 0},
Logging: config.LoggingConf{Level: "error"},
}
app := fx.New(bootstrap.Module(cfg), fx.NopLogger)
startCtx, startCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer startCancel()
if err := app.Start(startCtx); err != nil {
t.Fatalf("app start: %v", err)
}
defer func() { _ = app.Stop(context.Background()) }()
// Wait for first registration.
select {
case <-regCh:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for first registration")
}
var firstClient *toki.TcpClient
select {
case firstClient = <-acceptedCh:
case <-time.After(time.Second):
t.Fatal("timeout waiting for first accepted client")
}
// Force edge-side close → remote disconnect on node.
_ = firstClient.Close()
// Wait for second registration (supervisor reconnected and re-registered).
select {
case <-regCh:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for supervisor reconnect registration")
}
}
// TestReconnectSupervisorExhaustsRetries verifies that when all reconnect
// attempts fail, the node cleans up resources and calls Shutdown(ExitCode(1)).
func TestReconnectSupervisorExhaustsRetries(t *testing.T) {
host, port, addr := freeAddrForBootstrap(t)
serverCtx, serverCancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(serverCancel)
acceptedCh := make(chan *toki.TcpClient, 4)
server := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, 30, 10, bootstrapEdgeParserMap())
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(_ *iop.RegisterRequest) (*iop.RegisterResponse, error) {
return &iop.RegisterResponse{
Accepted: true,
NodeId: "exhaust-test-node",
Config: &iop.NodeConfigPayload{Runtime: &iop.NodeRuntimeConfig{Concurrency: 1, WorkspaceRoot: t.TempDir()}},
}, nil
},
)
acceptedCh <- client
return client
})
if err := server.Start(serverCtx); err != nil {
t.Fatalf("start server: %v", err)
}
t.Cleanup(func() { server.Stop() })
// Fake dialer: first call uses real DialEdge; subsequent calls fail immediately.
var dialCount int32
fakeDialer := bootstrap.DialFunc(func(ctx context.Context, edgeAddr, token string, logger *zap.Logger) (*transport.RegisterResult, error) {
if atomic.AddInt32(&dialCount, 1) == 1 {
return transport.DialEdge(ctx, edgeAddr, token, logger)
}
return nil, errors.New("fake reconnect failure")
})
cfg := &config.NodeConfig{
Transport: config.TransportConf{EdgeAddr: addr, Token: "tok"},
Reconnect: config.ReconnectConf{MaxAttempts: 3, IntervalSec: 0},
Logging: config.LoggingConf{Level: "error"},
}
app := fx.New(
bootstrap.Module(cfg, bootstrap.WithDialer(fakeDialer)),
fx.NopLogger,
)
startCtx, startCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer startCancel()
if err := app.Start(startCtx); err != nil {
t.Fatalf("app start: %v", err)
}
defer func() { _ = app.Stop(context.Background()) }()
// Wait for first connection then close it from the edge side.
var firstClient *toki.TcpClient
select {
case firstClient = <-acceptedCh:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for first connection")
}
_ = firstClient.Close()
// Supervisor exhausts retries → Shutdown(ExitCode(1)).
select {
case sig := <-app.Wait():
if sig.ExitCode != 1 {
t.Fatalf("expected exit code 1, got %d", sig.ExitCode)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for supervisor exhaustion shutdown")
}
// 1 initial dial + 3 retry attempts = 4 total.
if count := atomic.LoadInt32(&dialCount); count != 4 {
t.Fatalf("expected 4 dial attempts (1 initial + 3 retries), got %d", count)
}
}
// TestReconnectSupervisorPolicyTimingAndLimit verifies that the supervisor
// respects MaxAttempts=10 and that the fake sleeper receives IntervalSec=10s
// before each reconnect attempt (SDD reconnect_waiting state).
func TestReconnectSupervisorPolicyTimingAndLimit(t *testing.T) {
host, port, addr := freeAddrForBootstrap(t)
serverCtx, serverCancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(serverCancel)
acceptedCh := make(chan *toki.TcpClient, 4)
server := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, 30, 10, bootstrapEdgeParserMap())
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(_ *iop.RegisterRequest) (*iop.RegisterResponse, error) {
return &iop.RegisterResponse{
Accepted: true,
NodeId: "policy-test-node",
Config: &iop.NodeConfigPayload{Runtime: &iop.NodeRuntimeConfig{Concurrency: 1, WorkspaceRoot: t.TempDir()}},
}, nil
},
)
acceptedCh <- client
return client
})
if err := server.Start(serverCtx); err != nil {
t.Fatalf("start server: %v", err)
}
t.Cleanup(func() { server.Stop() })
// Fake dialer: first call uses real DialEdge; subsequent calls fail immediately.
var dialCount int32
fakeDialer := bootstrap.DialFunc(func(ctx context.Context, edgeAddr, token string, logger *zap.Logger) (*transport.RegisterResult, error) {
if atomic.AddInt32(&dialCount, 1) == 1 {
return transport.DialEdge(ctx, edgeAddr, token, logger)
}
return nil, errors.New("fake reconnect failure")
})
// Fake sleeper records received durations without blocking.
var slMu sync.Mutex
var sleptDurations []time.Duration
fakeSleeper := func(_ context.Context, d time.Duration) {
slMu.Lock()
sleptDurations = append(sleptDurations, d)
slMu.Unlock()
}
cfg := &config.NodeConfig{
Transport: config.TransportConf{EdgeAddr: addr, Token: "tok"},
Reconnect: config.ReconnectConf{MaxAttempts: 10, IntervalSec: 10},
Logging: config.LoggingConf{Level: "error"},
}
app := fx.New(
bootstrap.Module(cfg, bootstrap.WithDialer(fakeDialer), bootstrap.WithSleeper(fakeSleeper)),
fx.NopLogger,
)
startCtx, startCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer startCancel()
if err := app.Start(startCtx); err != nil {
t.Fatalf("app start: %v", err)
}
defer func() { _ = app.Stop(context.Background()) }()
// Wait for first connection then close it from the edge side.
var firstClient *toki.TcpClient
select {
case firstClient = <-acceptedCh:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for first connection")
}
_ = firstClient.Close()
// Supervisor exhausts all 10 retries → Shutdown(ExitCode(1)).
select {
case sig := <-app.Wait():
if sig.ExitCode != 1 {
t.Fatalf("expected exit code 1, got %d", sig.ExitCode)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for supervisor exhaustion shutdown")
}
// 1 initial dial + 10 retry attempts = 11 total.
if count := atomic.LoadInt32(&dialCount); count != 11 {
t.Fatalf("expected 11 dial attempts (1 initial + 10 retries), got %d", count)
}
// Each retry is preceded by a sleep of IntervalSec=10s (reconnect_waiting state).
slMu.Lock()
durations := append([]time.Duration(nil), sleptDurations...)
slMu.Unlock()
if len(durations) != 10 {
t.Fatalf("expected 10 sleep calls (one before each retry), got %d: %v", len(durations), durations)
}
for i, d := range durations {
if d != 10*time.Second {
t.Errorf("sleep[%d] = %v, want 10s", i, d)
}
}
}

View file

@ -17,6 +17,98 @@ import (
iop "iop/proto/gen/iop"
)
// startMiniEdge starts a minimal mock edge server that accepts one connection
// and responds to RegisterRequest. Returns the server, the accepted edge-side
// client channel, and the listen address.
func startMiniEdge(t *testing.T, ctx context.Context) (server *toki.TcpServer, acceptedCh chan *toki.TcpClient, addr string) {
t.Helper()
listenAddr := getFreePort(t)
host, portStr, _ := net.SplitHostPort(listenAddr)
port := 0
fmt.Sscanf(portStr, "%d", &port)
ch := make(chan *toki.TcpClient, 4)
srv := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
client := toki.NewTcpClient(conn, 30, 10, edgeParserMap())
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
&client.Communicator,
func(_ *iop.RegisterRequest) (*iop.RegisterResponse, error) {
return &iop.RegisterResponse{
Accepted: true,
NodeId: "sess-test-node",
Config: &iop.NodeConfigPayload{},
}, nil
},
)
ch <- client
return client
})
if err := srv.Start(ctx); err != nil {
t.Fatalf("start mini edge: %v", err)
}
t.Cleanup(func() { srv.Stop() })
return srv, ch, listenAddr
}
// TestSessionDoneSignalOnRemoteDisconnect verifies that Done() is closed when
// edge closes the connection, and IsLocalShutdown() returns false.
func TestSessionDoneSignalOnRemoteDisconnect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, acceptedCh, addr := startMiniEdge(t, ctx)
result, err := transport.DialEdge(ctx, addr, "tok", zap.NewNop())
if err != nil {
t.Fatalf("dial: %v", err)
}
edgeClient := waitForAcceptedClient(t, acceptedCh)
// Done() must not be closed yet.
select {
case <-result.Session.Done():
t.Fatal("Done() closed before disconnect")
default:
}
// Remote close → Done() must close.
_ = edgeClient.Close()
select {
case <-result.Session.Done():
case <-time.After(2 * time.Second):
t.Fatal("timeout: Done() did not close after remote disconnect")
}
if result.Session.IsLocalShutdown() {
t.Fatal("IsLocalShutdown() must be false for remote disconnect")
}
}
// TestSessionDoneSignalOnLocalClose verifies Done() is closed on local Close()
// and IsLocalShutdown() returns true.
func TestSessionDoneSignalOnLocalClose(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, addr := startMiniEdge(t, ctx)
result, err := transport.DialEdge(ctx, addr, "tok", zap.NewNop())
if err != nil {
t.Fatalf("dial: %v", err)
}
_ = result.Session.Close()
select {
case <-result.Session.Done():
case <-time.After(2 * time.Second):
t.Fatal("timeout: Done() did not close after local Close()")
}
if !result.Session.IsLocalShutdown() {
t.Fatal("IsLocalShutdown() must be true after local Close()")
}
}
type mockHandler struct {
runReqCh chan *iop.RunRequest
}

View file

@ -21,18 +21,20 @@ type Handler interface {
// Session represents the node's persistent connection to edge.
type Session struct {
client *toki.TcpClient
logger *zap.Logger
nodeID string
alias string
mu sync.RWMutex
handler Handler
eventHandler func(*iop.EdgeNodeEvent)
closeReason string
client *toki.TcpClient
logger *zap.Logger
nodeID string
alias string
mu sync.RWMutex
handler Handler
eventHandler func(*iop.EdgeNodeEvent)
closeReason string
disconnectCh chan struct{}
disconnectOnce sync.Once
}
func newSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session {
s := &Session{client: client, logger: logger, nodeID: nodeID, alias: alias}
s := &Session{client: client, logger: logger, nodeID: nodeID, alias: alias, disconnectCh: make(chan struct{})}
toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) {
go func() {
@ -95,6 +97,7 @@ func newSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string
s.disconnectReason(),
transportDisconnectMetadata(transportInfo),
))
s.disconnectOnce.Do(func() { close(s.disconnectCh) })
})
return s
@ -126,6 +129,16 @@ func (s *Session) IsAlive() bool {
return s.client.IsAlive()
}
// Done returns a channel that is closed when the session disconnects (local or remote).
func (s *Session) Done() <-chan struct{} {
return s.disconnectCh
}
// IsLocalShutdown reports whether the disconnect was initiated by a local Close call.
func (s *Session) IsLocalShutdown() bool {
return s.disconnectReason() == events.ReasonLocalShutdown
}
// Close terminates the connection to edge.
func (s *Session) Close() error {
s.setCloseReason(events.ReasonLocalShutdown)

View file

@ -2,6 +2,10 @@ transport:
edge_addr: "localhost:9090"
token: "<node-token>"
reconnect:
interval_sec: 10
max_attempts: 10
logging:
level: "info"
pretty: true

View file

@ -69,7 +69,7 @@ nodes: []
--ollama-base-url http://127.0.0.1:11434
```
출력된 `curl -fsSL ... | bash -s <token>` 명령을 보관한다.
출력된 OS별 bootstrap 명령 원문을 보관한다. 명령 원문에는 실제 token이 포함되므로 tracked 문서에는 기록하지 않는다.
## 4. Edge 실행
@ -87,11 +87,7 @@ curl -fsS http://toki-labs.com:18000/edges
## 5. Node 실행
3단계에서 출력된 bootstrap 명령을 Node host에서 실행한다.
```bash
curl -fsSL http://toki-labs.com:18080/bootstrap/node.sh | bash -s <token>
```
3단계에서 출력된 bootstrap 명령을 Node host에서 그대로 실행한다. Linux/macOS는 generated `curl | bash` 명령을 사용하고, Windows native PowerShell은 generated `.ps1` bootstrap과 `Start-IopNode` 함수를 사용한다.
Node 연결 확인:
@ -99,7 +95,37 @@ Node 연결 확인:
curl -fsS http://toki-labs.com:18000/edges/edge-toki-labs/status
```
## 6. Smoke
## 6. dev-runtime provider pool 반복 기준
현재 GX10 vLLM과 OneXPlayer Lemonade를 같은 model alias로 묶어 검증하는 dev-runtime 기준은 원격 runner의 동기화된 iop checkout과 `build/dev-runtime/edge.yaml`이다.
기준 주소:
- Control Plane status: 원격 host의 `http://127.0.0.1:18001`
- bootstrap HTTP: `http://toki-labs.com:18082`
- Edge OpenAI-compatible base URL: `http://toki-labs.com:18083/v1`
- Edge-Node TCP transport: `toki-labs.com:18084`
기준 provider pool:
- model alias: `qwen3.6:35b`
- provider candidate: `gx10-vllm` on `gx10-vllm-node`, capacity `4`
- provider candidate: `onexplayer-lemonade` on `onexplayer-lemonade-node`, capacity `3`
- connected non-candidate: `mac-codex-node`
capacity, provider mapping, model alias, listen port 변경은 실행 중 Edge에 hot reload되지 않는다. `edge.yaml` 수정 후 `config check`를 통과시키고 Edge process를 재시작한 뒤 검증한다. Node host OS 재부팅은 필요하지 않지만, 현재 Node bootstrap은 Edge 종료 뒤 자동 재등록 루프를 보장하지 않으므로 Edge 재시작 후 연결이 빠진 Node는 bootstrap 명령을 다시 실행한다.
반복 검증 순서:
1. Control Plane status에서 `edge-toki-labs`의 connected node 수와 node id를 확인한다.
2. Edge host에서 `toki-labs.com:18084`의 established node TCP connection 수를 확인한다.
3. `http://toki-labs.com:18083/healthz``/v1/models`를 확인한다.
4. `qwen3.6:35b`로 동시 4개 `/v1/chat/completions` 요청을 보낸다.
5. Edge restart 후 capacity `4/3` 기준이면 동시 in-flight 전제에서 aggregate 배정 기대값은 `gx10-vllm` 2개, `onexplayer-lemonade` 2개다.
현재 공개 API는 개별 request의 최종 `node_id`를 응답에 노출하지 않는다. 요청별 배정을 확정해야 할 때는 Edge dispatch trace/log를 추가한 뒤 판정한다.
## 7. Smoke
```bash
curl -fsS http://toki-labs.com:18081/v1/models

View file

@ -25,10 +25,17 @@ func NormalizeAgentKind(kind string) (string, error) {
type NodeConfig struct {
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
Reconnect ReconnectConf `mapstructure:"reconnect" yaml:"reconnect"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
// ReconnectConf controls Edge reconnect behaviour after a disconnect.
type ReconnectConf struct {
IntervalSec int `mapstructure:"interval_sec" yaml:"interval_sec"`
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
}
type EdgeConfig struct {
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
@ -743,6 +750,8 @@ func checkUniqueNames(field string, name func(int) string, n int) error {
func setDefaults(v *viper.Viper) {
v.SetDefault("transport.edge_addr", "localhost:9090")
v.SetDefault("reconnect.interval_sec", 10)
v.SetDefault("reconnect.max_attempts", 10)
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9091)
}

View file

@ -2399,3 +2399,40 @@ nodes:
t.Errorf("queue config mismatch: %+v", inst)
}
}
func TestLoad_NodeReconnectDefaults(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "node.yaml")
if err := os.WriteFile(f, []byte("transport:\n edge_addr: \"localhost:9090\"\n token: \"tok\"\n"), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.Load(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Reconnect.IntervalSec != 10 {
t.Fatalf("expected reconnect.interval_sec=10, got %d", cfg.Reconnect.IntervalSec)
}
if cfg.Reconnect.MaxAttempts != 10 {
t.Fatalf("expected reconnect.max_attempts=10, got %d", cfg.Reconnect.MaxAttempts)
}
}
func TestLoad_NodeReconnectOverride(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "node.yaml")
yaml := "transport:\n edge_addr: \"localhost:9090\"\n token: \"tok\"\nreconnect:\n interval_sec: 30\n max_attempts: 5\n"
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.Load(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Reconnect.IntervalSec != 30 {
t.Fatalf("expected reconnect.interval_sec=30, got %d", cfg.Reconnect.IntervalSec)
}
if cfg.Reconnect.MaxAttempts != 5 {
t.Fatalf("expected reconnect.max_attempts=5, got %d", cfg.Reconnect.MaxAttempts)
}
}