From ff161b9b1282b795aa0e7fad162250477f166540 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 21 Jun 2026 16:14:16 +0900 Subject: [PATCH] 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 --- agent-ops/rules/project/domain/edge/rules.md | 10 +- agent-ops/rules/project/domain/node/rules.md | 7 +- .../rules/project/domain/testing/rules.md | 11 +- agent-roadmap/ROADMAP.md | 4 +- .../PHASE.md | 4 + .../runtime-reconnect-config-refresh.md | 109 ++++++ .../runtime-reconnect-config-refresh/SDD.md | 147 +++++++ .../code_review_cloud_G07_0.log | 252 ++++++++++++ .../code_review_cloud_G07_1.log | 358 ++++++++++++++++++ .../code_review_cloud_G07_2.log | 290 ++++++++++++++ .../01_reconnect_supervisor/complete.log | 48 +++ .../plan_cloud_G07_0.log | 347 +++++++++++++++++ .../plan_cloud_G07_1.log | 203 ++++++++++ .../plan_cloud_G07_2.log | 103 +++++ apps/edge/README.md | 4 +- apps/edge/cmd/edge/main_test.go | 53 +++ apps/edge/internal/edgecmd/bootstrap.go | 81 +++- apps/edge/internal/edgecmd/edgecmd_test.go | 27 ++ apps/edge/internal/edgecmd/env.go | 4 + apps/edge/internal/edgecmd/node_register.go | 12 +- .../internal/transport/integration_test.go | 75 ++++ apps/node/cmd/node/main_test.go | 5 + apps/node/internal/bootstrap/module.go | 267 ++++++++++--- apps/node/internal/bootstrap/module_test.go | 264 +++++++++++++ .../internal/transport/integration_test.go | 92 +++++ apps/node/internal/transport/session.go | 31 +- configs/node.yaml | 4 + docs/edge-local-dev-guide.md | 40 +- packages/go/config/config.go | 9 + packages/go/config/config_test.go | 37 ++ 30 files changed, 2821 insertions(+), 77 deletions(-) create mode 100644 agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md create mode 100644 agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_2.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_2.log diff --git a/agent-ops/rules/project/domain/edge/rules.md b/agent-ops/rules/project/domain/edge/rules.md index e5c67bb..d50c0d2 100644 --- a/agent-ops/rules/project/domain/edge/rules.md +++ b/agent-ops/rules/project/domain/edge/rules.md @@ -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 | bash -s ` 한 줄을 출력하는 경로를 유지한다. 사용자가 기본 경로에서 `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` 생성 파일을 직접 수정하지 않는다. diff --git a/agent-ops/rules/project/domain/node/rules.md b/agent-ops/rules/project/domain/node/rules.md index 79b7d8b..8ea54f9 100644 --- a/agent-ops/rules/project/domain/node/rules.md +++ b/agent-ops/rules/project/domain/node/rules.md @@ -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로만 둔다. diff --git a/agent-ops/rules/project/domain/testing/rules.md b/agent-ops/rules/project/domain/testing/rules.md index 0caa369..68e7f29 100644 --- a/agent-ops/rules/project/domain/testing/rules.md +++ b/agent-ops/rules/project/domain/testing/rules.md @@ -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 ` 형태를 기준으로 한다. 다른 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` 같은 치환용 값을 넣지 않는다. 실제 값을 제공할 수 없으면 실행 명령을 제시하지 않고 필요한 값의 안전 전달이 먼저 필요하다고 보고한다. - 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다. diff --git a/agent-roadmap/ROADMAP.md b/agent-roadmap/ROADMAP.md index d6c07c5..6aef52b 100644 --- a/agent-roadmap/ROADMAP.md +++ b/agent-roadmap/ROADMAP.md @@ -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 전체에서 사용자만 결정할 항목이 더 이상 없고 에이전트가 표준선에 따라 실행하면 되는 상태라면 `구현 잠금` 상태를 `해제`로 둔다. diff --git a/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md b/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md index e2695d2..ae8f09e 100644 --- a/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md +++ b/agent-roadmap/phase/update-plane-self-update-foundation/PHASE.md @@ -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 최소 계약을 스케치한다. diff --git a/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md b/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md new file mode 100644 index 0000000..542a929 --- /dev/null +++ b/agent-roadmap/phase/update-plane-self-update-foundation/milestones/runtime-reconnect-config-refresh.md @@ -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` +- 확인 필요: 없음 diff --git a/agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md b/agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md new file mode 100644 index 0000000..bc5aaf6 --- /dev/null +++ b/agent-roadmap/sdd/update-plane-self-update-foundation/runtime-reconnect-config-refresh/SDD.md @@ -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: 없음 diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log new file mode 100644 index 0000000..fb918d3 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_0.log @@ -0,0 +1,252 @@ + + +# 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는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log new file mode 100644 index 0000000..c50feb6 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_1.log @@ -0,0 +1,358 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 , /session , /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는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_2.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_2.log new file mode 100644 index 0000000..dbef213 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/code_review_cloud_G07_2.log @@ -0,0 +1,290 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 , /session , /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로 이동한다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log new file mode 100644 index 0000000..bf4d0dd --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log new file mode 100644 index 0000000..d6f5e34 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_0.log @@ -0,0 +1,347 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log new file mode 100644 index 0000000..6255898 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_1.log @@ -0,0 +1,203 @@ + + +# 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`. diff --git a/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_2.log b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_2.log new file mode 100644 index 0000000..4158151 --- /dev/null +++ b/agent-task/archive/2026/06/m-runtime-reconnect-config-refresh/01_reconnect_supervisor/plan_cloud_G07_2.log @@ -0,0 +1,103 @@ + + +# 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`. diff --git a/apps/edge/README.md b/apps/edge/README.md index f8690ea..7126180 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -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는 `/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="-"`로 만들 수 있다. Edge만 갱신할 때는 `make build-edge && make pack-edge`, Node artifact만 갱신할 때는 `make pack-node-target NODE_TARGET=- && 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`를 지정해야 덮어쓴다. diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index 74291f1..7b4ea9e 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -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") diff --git a/apps/edge/internal/edgecmd/bootstrap.go b/apps/edge/internal/edgecmd/bootstrap.go index 5489a1a..fe0c939 100644 --- a/apps/edge/internal/edgecmd/bootstrap.go +++ b/apps/edge/internal/edgecmd/bootstrap.go @@ -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 /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 + } +} +` +} diff --git a/apps/edge/internal/edgecmd/edgecmd_test.go b/apps/edge/internal/edgecmd/edgecmd_test.go index 6b64111..efb2fd2 100644 --- a/apps/edge/internal/edgecmd/edgecmd_test.go +++ b/apps/edge/internal/edgecmd/edgecmd_test.go @@ -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") diff --git a/apps/edge/internal/edgecmd/env.go b/apps/edge/internal/edgecmd/env.go index affbf6c..6f43a2d 100644 --- a/apps/edge/internal/edgecmd/env.go +++ b/apps/edge/internal/edgecmd/env.go @@ -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" } diff --git a/apps/edge/internal/edgecmd/node_register.go b/apps/edge/internal/edgecmd/node_register.go index f025fb1..15b0ee3 100644 --- a/apps/edge/internal/edgecmd/node_register.go +++ b/apps/edge/internal/edgecmd/node_register.go @@ -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 }, diff --git a/apps/edge/internal/transport/integration_test.go b/apps/edge/internal/transport/integration_test.go index 12cacde..f8e9262 100644 --- a/apps/edge/internal/transport/integration_test.go +++ b/apps/edge/internal/transport/integration_test.go @@ -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() diff --git a/apps/node/cmd/node/main_test.go b/apps/node/cmd/node/main_test.go index 6316754..09979fd 100644 --- a/apps/node/cmd/node/main_test.go +++ b/apps/node/cmd/node/main_test.go @@ -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) { diff --git a/apps/node/internal/bootstrap/module.go b/apps/node/internal/bootstrap/module.go index 2ef2a48..d31a984 100644 --- a/apps/node/internal/bootstrap/module.go +++ b/apps/node/internal/bootstrap/module.go @@ -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 }, diff --git a/apps/node/internal/bootstrap/module_test.go b/apps/node/internal/bootstrap/module_test.go index e55507a..c891378 100644 --- a/apps/node/internal/bootstrap/module_test.go +++ b/apps/node/internal/bootstrap/module_test.go @@ -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) + } + } +} diff --git a/apps/node/internal/transport/integration_test.go b/apps/node/internal/transport/integration_test.go index 1c02b77..ee55ce2 100644 --- a/apps/node/internal/transport/integration_test.go +++ b/apps/node/internal/transport/integration_test.go @@ -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 } diff --git a/apps/node/internal/transport/session.go b/apps/node/internal/transport/session.go index 77ab6fd..2ff3a88 100644 --- a/apps/node/internal/transport/session.go +++ b/apps/node/internal/transport/session.go @@ -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) diff --git a/configs/node.yaml b/configs/node.yaml index a148e96..eca09b2 100644 --- a/configs/node.yaml +++ b/configs/node.yaml @@ -2,6 +2,10 @@ transport: edge_addr: "localhost:9090" token: "" +reconnect: + interval_sec: 10 + max_attempts: 10 + logging: level: "info" pretty: true diff --git a/docs/edge-local-dev-guide.md b/docs/edge-local-dev-guide.md index e2f4f43..47540b4 100644 --- a/docs/edge-local-dev-guide.md +++ b/docs/edge-local-dev-guide.md @@ -69,7 +69,7 @@ nodes: [] --ollama-base-url http://127.0.0.1:11434 ``` -출력된 `curl -fsSL ... | bash -s ` 명령을 보관한다. +출력된 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 -``` +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 diff --git a/packages/go/config/config.go b/packages/go/config/config.go index 378b0c2..b15d176 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -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) } diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 7cd9e78..8d008f7 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -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) + } +}