# Edge Multi-Point Routing Plan ## 이 파일을 읽는 구현 에이전트에게 아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/edge_multi_point_routing/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다. ## 배경 현재 edge console은 연결된 node가 하나라는 전제에서만 자연스럽게 동작합니다. 실제 dispatch는 `Registry.Pick()`의 임의 선택에 의존하고, background 이벤트는 어느 node에서 온 것인지 proto 수준에서 식별할 수 없습니다. 멀티포인트 운영으로 가려면 지금 단계에서 스케줄러까지 도입할 필요는 없지만, 최소한 edge가 명시적으로 대상 node를 선택하고 이벤트 출처를 안정적으로 표시할 수 있어야 합니다. ## 의존 관계 및 구현 순서 1. `API-1`에서 registry에 명시적 node 조회 API를 추가한다. 2. `API-2`에서 console이 `Pick()` 대신 명시적 target node를 사용하도록 바꾼다. 3. `API-3`에서 `RunEvent`에 node 출처를 실어 async/foreground 출력이 모두 node-aware 하게 만든다. ### [API-1] Registry를 임의 Pick에서 명시적 조회 API 중심으로 전환 #### 문제 현재 registry는 `node_id` 기반 map 하나만 유지하고, 실제 선택 API는 `apps/edge/internal/node/registry.go:62-68`의 `Pick()`뿐입니다. 이 함수는 map 순회 첫 값을 반환하므로 멀티 node 환경에서 대상 선택이 비결정적이고, alias 기반 선택도 할 수 없습니다. existing test도 `apps/edge/internal/node/registry_test.go:24-28`에서 빈 registry의 `Pick()` 에러만 확인할 뿐, 멀티포인트 선택 규칙은 전혀 고정하지 못합니다. Before (`apps/edge/internal/node/registry.go:17-68`): ```go // Registry manages all nodes connected to edge. type Registry struct { mu sync.RWMutex nodes map[string]*NodeEntry } func NewRegistry() *Registry { return &Registry{nodes: make(map[string]*NodeEntry)} } func (r *Registry) Register(entry *NodeEntry) { r.mu.Lock() defer r.mu.Unlock() r.nodes[entry.NodeID] = entry } func (r *Registry) Unregister(nodeID string) { r.mu.Lock() defer r.mu.Unlock() delete(r.nodes, nodeID) } func (r *Registry) Get(nodeID string) (*NodeEntry, bool) { r.mu.RLock() defer r.mu.RUnlock() e, ok := r.nodes[nodeID] return e, ok } func (r *Registry) Pick() (*NodeEntry, error) { r.mu.RLock() defer r.mu.RUnlock() for _, e := range r.nodes { return e, nil } return nil, fmt.Errorf("no nodes connected") } ``` #### 해결 방법 registry를 `byID`와 `byAlias` 조회가 가능한 구조로 확장하고, console 쪽에서 사용할 `Resolve(ref string)` 또는 동등한 명시 API를 제공합니다. fallback은 “연결 node가 정확히 1개일 때만 자동 선택”으로 제한하고, 2개 이상이면 caller가 명시적으로 alias/id를 지정해야 에러 없이 진행되도록 합니다. `Pick()`은 제거하거나 deprecated helper로 남기더라도 console 경로에서 더 이상 사용하지 않게 합니다. After: ```go type Registry struct { mu sync.RWMutex byID map[string]*NodeEntry byAlias map[string]*NodeEntry } func NewRegistry() *Registry { return &Registry{ byID: make(map[string]*NodeEntry), byAlias: make(map[string]*NodeEntry), } } func (r *Registry) Register(entry *NodeEntry) { r.mu.Lock() defer r.mu.Unlock() r.byID[entry.NodeID] = entry if entry.Alias != "" { r.byAlias[entry.Alias] = entry } } func (r *Registry) Resolve(ref string) (*NodeEntry, error) { r.mu.RLock() defer r.mu.RUnlock() if ref != "" { if entry, ok := r.byID[ref]; ok { return entry, nil } if entry, ok := r.byAlias[ref]; ok { return entry, nil } return nil, fmt.Errorf("node %q not found", ref) } if len(r.byID) == 1 { for _, entry := range r.byID { return entry, nil } } if len(r.byID) == 0 { return nil, fmt.Errorf("no nodes connected") } return nil, fmt.Errorf("multiple nodes connected; select one with /node ") } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/edge/internal/node/registry.go` - `byAlias` 저장과 `Resolve`/동등 helper 추가, `Pick()` 의존 경로 제거 또는 deprecated 처리 - [ ] `apps/edge/internal/node/registry_test.go` - alias 조회, single-node fallback, multi-node ambiguous error, unregister cleanup 테스트 추가 - [ ] `apps/edge/internal/transport/integration_test.go` - registry에 alias가 채워진 entry가 실제 등록되는 현재 동작이 유지되는지 확인하고 필요한 assertion 보강 - [ ] renamed/removed symbol call site 점검: `rg -n "\.Pick\(|Resolve\(" apps/edge`로 console 외에 남은 임의 선택 경로가 없는지 확인 #### 테스트 작성 테스트를 작성합니다. - `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_ByAliasOrID`: alias와 node_id 모두 같은 entry로 해석되는지 검증 - `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_ImplicitSingleNodeOnly`: node가 1개일 때만 빈 ref가 성공하고, 2개 이상이면 명시적 선택 에러가 나는지 검증 - `apps/edge/internal/node/registry_test.go` - `TestRegistryResolve_UnregisterRemovesAlias`: unregister 후 alias 경로도 함께 제거되는지 검증 #### 중간 검증 ```bash go test ./apps/edge/internal/node/... ./apps/edge/internal/transport/... ``` 기대 결과: registry lookup 규칙과 edge transport integration 테스트가 모두 통과합니다. ### [API-2] Console 실행/세션 종료를 target node 명시 모델로 변경 #### 문제 console state는 `apps/edge/cmd/edge/console.go:52-62`에서 `adapter/agent/session/background`만 들고 있고, 명령 목록에도 node 선택 개념이 없습니다. 실제 실행과 세션 종료는 `apps/edge/cmd/edge/console.go:159-239`에서 모두 `registry.Pick()`에 의존하므로, node가 여러 대 연결된 순간 동일 `agent/session_id` 요청이 어느 node로 갈지 사용자가 제어할 수 없습니다. 현재 테스트도 `apps/edge/cmd/edge/console_test.go:11-54`에서 `buildRunRequest`의 session/background만 검증하고 있어 target selection 회귀를 막지 못합니다. Before (`apps/edge/cmd/edge/console.go:52-62,159-239`): ```go // Console state: adapter, agent, session, background mode. adapter := cfg.Console.Adapter agent := cfg.Console.ResolveAgent() sessionID := normalizeConsoleSessionID(cfg.Console.SessionID) background := cfg.Console.Background timeoutSec := cfg.Console.TimeoutSec fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen) fmt.Fprintf(out, "Console target adapter=%s agent=%s session=%s background=%v\n", adapter, agent, sessionID, background) fmt.Fprintln(out, "Start node.sh on another host, then type a message here.") fmt.Fprintln(out, "Commands: /nodes, /session , /background on|off, /terminate-session, /exit") func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, agent, sessionID string, background bool, timeoutSec int, message string) error { entry, err := registry.Pick() if err != nil { return err } ... } func sendTerminateSession(ctx context.Context, registry *edgenode.Registry, adapter, model, sessionID string) error { entry, err := registry.Pick() if err != nil { return err } ... } ``` #### 해결 방법 console state에 `targetNodeRef`를 추가하고 `/node ` 명령을 도입합니다. dispatch helper는 `registry.Resolve(targetNodeRef)`를 호출하고, 빈 target은 single-node일 때만 암묵 선택되게 합니다. `/nodes` 출력은 현재 선택된 node를 함께 보여주고, startup banner와 `/terminate-session` 결과도 `node=`를 포함하게 바꿔 사용자가 현재 작업 컨텍스트를 잃지 않게 합니다. After: ```go type consoleTarget struct { NodeRef string Adapter string Agent string SessionID string Background bool TimeoutSec int } func resolveConsoleNode(registry *edgenode.Registry, nodeRef string) (*edgenode.NodeEntry, error) { return registry.Resolve(nodeRef) } // Commands: /nodes, /node , /session , /background on|off, /terminate-session, /exit case strings.HasPrefix(lower, "/node "): parts := strings.Fields(message) if len(parts) < 2 || parts[1] == "" { fmt.Fprintln(out, "usage: /node ") continue } if _, err := resolveConsoleNode(registry, parts[1]); err != nil { fmt.Fprintf(out, "error: %v\n", err) continue } target.NodeRef = parts[1] fmt.Fprintf(out, "node → %s\n", target.NodeRef) ``` #### 수정 파일 및 체크리스트 - [ ] `apps/edge/cmd/edge/console.go` - console state에 `target node` 축 추가, `/node ` 명령 추가, startup/help 문구 갱신 - [ ] `apps/edge/cmd/edge/console.go` - `sendConsoleRun`/`sendTerminateSession`가 `registry.Resolve(...)`를 사용하도록 변경 - [ ] `apps/edge/cmd/edge/console.go` - `/nodes` 출력이 현재 선택 상태를 확인하기 쉽게 보강 - [ ] `apps/edge/cmd/edge/console_test.go` - target node helper와 `/node` command 회귀 테스트 추가 - [ ] renamed/removed symbol call site 점검: `rg -n "sendConsoleRun|sendTerminateSession|/nodes|/node " apps/edge/cmd/edge` #### 테스트 작성 테스트를 작성합니다. - `apps/edge/cmd/edge/console_test.go` - `TestResolveConsoleNode_RequiresExplicitSelectionForMultipleNodes`: 빈 target으로 multi-node registry를 조회하면 에러가 나는지 검증 - `apps/edge/cmd/edge/console_test.go` - `TestResolveConsoleNode_AllowsSingleNodeFallback`: node 하나일 때만 빈 target이 허용되는지 검증 - `apps/edge/cmd/edge/console_test.go` - `TestPrintNodes_ShowsSelectedNode`: `/nodes` 출력에 selected marker 또는 동등한 상태 표시가 포함되는지 검증 - `apps/edge/cmd/edge/console_test.go` - `TestSendTerminateSession_UsesResolvedNode`: 선택된 alias/id가 올바른 node entry로 해석되는 helper 경로를 검증 #### 중간 검증 ```bash go test ./apps/edge/cmd/edge/... ./apps/edge/internal/node/... ``` 기대 결과: console helper와 node selection 관련 테스트가 통과하고, multi-node ambiguous 상황이 의도한 에러로 고정됩니다. ### [API-3] RunEvent에 node 출처를 추가해 foreground/background 출력 모두 node-aware 하게 만들기 #### 문제 현재 `proto/iop/runtime.proto:36-48`의 `RunEvent`에는 `session_id`와 `background`만 있고 `node_id`가 없습니다. node 쪽 `apps/node/internal/node/node.go:175-203`의 `sessionSink.Emit`도 출처 node 정보를 채우지 않고, edge 쪽 `apps/edge/cmd/edge/console_events.go:66-97`는 async event를 `[node-event]`, `[node-message]`로만 출력합니다. 그래서 background 실행이나 추후 비동기 fan-in 상황에서 어떤 node가 어떤 이벤트를 보냈는지 proto와 출력 양쪽 모두에서 잃어버립니다. Before (`proto/iop/runtime.proto:36-48`): ```proto // RunEvent is a streaming execution event. message RunEvent { string run_id = 1; string type = 2; // start | delta | complete | error | cancelled string delta = 3; string message = 4; string error = 5; Usage usage = 6; map metadata = 7; int64 timestamp = 8; // unix nano string session_id = 9; bool background = 10; } ``` Before (`apps/node/internal/node/node.go:175-203`): ```go type sessionSink struct { sess *transport.Session out io.Writer sessionID string background bool response strings.Builder } func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error { s.printEvent(event) re := &iop.RunEvent{ RunId: event.RunID, Type: string(event.Type), Delta: event.Delta, Message: event.Message, Error: event.Error, Metadata: event.Metadata, Timestamp: event.Timestamp.UnixNano(), SessionId: s.sessionID, Background: s.background, } ... } ``` Before (`apps/edge/cmd/edge/console_events.go:66-97`): ```go func (r *consoleEventRouter) printAsyncLocked(event *iop.RunEvent) { ... switch event.GetType() { case "start": fmt.Fprintf(r.out, "[node-event] start run_id=%s session=%s background=%v\n", runID, event.GetSessionId(), event.GetBackground()) case "delta": r.responseStream(runID).Write(event.GetDelta()) case "complete": r.finishStream(runID) fmt.Fprintf(r.out, "[node-event] complete run_id=%s detail=%q\n", runID, event.GetMessage()) ... } ``` #### 해결 방법 `RunEvent`에 `node_id`를 추가하고, `sessionSink`가 `Node.nodeID`를 채워 보내게 합니다. edge console event router는 registry를 참조해 `node_id -> alias`를 해석하고, foreground와 async 출력 모두 `[node-{alias}-event]`, `[node-{alias}-message]` 형태를 동일하게 사용합니다. registry에서 alias를 찾지 못하더라도 fallback으로 `node_id`를 그대로 보여주게 해 event provenance를 잃지 않게 합니다. After: ```proto message RunEvent { string run_id = 1; string type = 2; string delta = 3; string message = 4; string error = 5; Usage usage = 6; map metadata = 7; int64 timestamp = 8; string session_id = 9; bool background = 10; string node_id = 11; } ``` ```go type sessionSink struct { sess *transport.Session out io.Writer nodeID string sessionID string background bool response strings.Builder } re := &iop.RunEvent{ RunId: event.RunID, Type: string(event.Type), ... SessionId: s.sessionID, Background: s.background, NodeId: s.nodeID, } ``` ```go func (r *consoleEventRouter) nodeLabel(event *iop.RunEvent) string { if event.GetNodeId() == "" { return "unknown" } if entry, ok := r.registry.Get(event.GetNodeId()); ok && entry.Alias != "" { return entry.Alias } return event.GetNodeId() } ``` #### 수정 파일 및 체크리스트 - [ ] `proto/iop/runtime.proto` - `RunEvent.node_id` 추가 - [ ] `apps/node/internal/node/node.go` - `sessionSink`가 `nodeID`를 보관하고 `RunEvent.NodeId`를 채우도록 변경 - [ ] `apps/edge/cmd/edge/console_events.go` - registry 주입, node label helper, async 출력 prefix를 node-aware 하게 변경 - [ ] `apps/edge/cmd/edge/console.go` - `newConsoleEventRouter(...)` 생성 시 registry 전달, foreground 출력도 event의 node label과 일관된 prefix 사용 - [ ] `apps/edge/cmd/edge/console_test.go` - async event 출력에 node label이 포함되는지 검증 - [ ] `apps/edge/README.md` - `/node` 명령, multi-node 명시 선택 규칙, node-aware event 출력 예시 반영 - [ ] `proto/gen/iop/*.pb.go` - `make proto`로 regenerated output 반영 - [ ] renamed/removed symbol call site 점검: `rg -n "NodeId|node_id|newConsoleEventRouter|RunEvent{" apps proto` #### 테스트 작성 테스트를 작성합니다. - `apps/edge/cmd/edge/console_test.go` - `TestConsoleEventRouterPrintsNodeScopedAsyncRun`: `RunEvent{NodeId: "node-local", ...}`를 넣었을 때 `[node-local-node-message]` 또는 최종 선택한 포맷에 맞는 node-scoped 출력이 나오는지 검증 - `apps/edge/cmd/edge/console_test.go` - `TestConsoleEventRouterFallsBackToNodeID`: alias를 해석하지 못해도 `node_id` 자체가 출력에 포함되는지 검증 - `apps/node/internal/node/node_test.go` - `TestSessionSinkEmitIncludesNodeID`: emitted `RunEvent`에 `NodeId`가 채워지는지 검증 #### 중간 검증 ```bash make proto go test ./apps/node/internal/node/... ./apps/edge/cmd/edge/... ``` 기대 결과: protobuf 생성이 성공하고, node event emission 및 edge console event formatting 테스트가 모두 통과합니다. 실행 환경에 `make`가 없으면 Makefile의 `proto` 타깃과 동일한 `protoc` 명령으로 대체하고 그 사실을 `CODE_REVIEW.md`에 기록합니다. ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/edge/internal/node/registry.go` | API-1 | | `apps/edge/internal/node/registry_test.go` | API-1 | | `apps/edge/internal/transport/integration_test.go` | API-1 | | `apps/edge/cmd/edge/console.go` | API-2, API-3 | | `apps/edge/cmd/edge/console_events.go` | API-3 | | `apps/edge/cmd/edge/console_test.go` | API-2, API-3 | | `proto/iop/runtime.proto` | API-3 | | `proto/gen/iop/*.pb.go` | API-3 | | `apps/node/internal/node/node.go` | API-3 | | `apps/node/internal/node/node_test.go` | API-3 | | `apps/edge/README.md` | API-3 | ## 최종 검증 ```bash make proto go test ./apps/edge/... ./apps/node/internal/node/... go test ./... ``` 기대 결과: proto 재생성과 edge/node 관련 테스트가 모두 통과하고, 전체 저장소 테스트도 회귀 없이 PASS 합니다. `make`가 없으면 동등 `protoc` 명령으로 대체한 뒤 `go test ./...` 결과를 기록합니다.