# Unbounded CLI Logical Sessions Plan ## 이 파일을 읽는 구현 에이전트에게 이 계획은 "edge-node transport 연결은 호스트당 하나, node 내부 logical CLI session은 제한 없이 여러 개"라는 전제를 구현하기 위한 작업 지시서입니다. 각 항목의 체크리스트를 실제로 완료하면서 진행하고, 항목별 중간 검증과 최종 검증을 실행한 뒤 `agent-task/unbounded_cli_sessions/CODE_REVIEW.md`의 모든 섹션에 실제 구현 메모와 명령 출력 결과를 채우세요. 구현 에이전트는 `CODE_REVIEW.md`의 "이 파일을 읽는 리뷰 에이전트에게" 섹션에 적힌 아카이브 지시를 실행하면 안 됩니다. `CODE_REVIEW.md` -> `code_review_*.log`, `PLAN.md` -> `plan_*.log`, `complete.log` 작성은 code-review skill 전용 작업입니다. ## 배경 현재 CLI adapter는 profile 이름당 persistent process 하나만 유지합니다. 이 구조에서는 같은 `codex` profile에 대해 `session-a`, `session-b`, `session-c`처럼 여러 장수 worker session을 만들고 유지할 수 없습니다. 요구사항은 edge와 node 사이 TCP/protobuf transport 연결은 하나로 유지하되, 그 연결 안에서 edge가 logical session을 선택하거나 생성하고 node가 해당 worker를 명시 종료 전까지 계속 살려두는 모델입니다. ## 핵심 전제 - edge-node transport 연결은 호스트당 하나를 유지한다. - edge registry의 "node id당 연결 1개" 제약은 유지한다. - node 내부 CLI logical session은 hard limit, `pool_size`, `max_sessions`를 두지 않는다. - 같은 adapter/model/profile이라도 `session_id`가 다르면 서로 다른 장수 worker process로 관리한다. - session은 명시적 terminate, node shutdown, process death, optional lifecycle condition이 발생하기 전까지 유지한다. - `cancel run`과 `terminate session`은 서로 다른 동작이다. ## 의존 관계 및 구현 순서 1. `REFACTOR-1`에서 protobuf/runtime 계약을 먼저 확장하고 `make proto`로 generated code를 갱신한다. 2. `REFACTOR-2`에서 node 실행 생명주기를 transport session에서 분리한다. 3. `REFACTOR-3`에서 CLI adapter를 unbounded logical session manager로 바꾼다. 4. `REFACTOR-4`에서 edge console/config payload와 문서를 새 계약에 맞춘다. 5. 각 단계 중간 검증을 통과한 뒤 최종 `go test ./...`를 실행한다. ### [REFACTOR-1] Protocol And Runtime Contract #### 문제 - `RunRequest`는 `run_id`, `adapter`, `model`까지만 표현하고 logical worker session을 고를 필드가 없습니다. `proto/iop/runtime.proto:10` - `CancelRequest`는 `run_id`만 있어서 run cancel과 session terminate를 구분할 수 없습니다. `proto/iop/runtime.proto:43` - node domain 타입도 같은 제약을 갖습니다. `apps/node/internal/runtime/types.go:9`, `apps/node/internal/runtime/types.go:56` - edge console은 `RunRequest` 생성 시 adapter/model만 채우므로 session 선택을 할 수 없습니다. `apps/edge/cmd/edge/console.go:115` 현재 코드: ```proto // proto/iop/runtime.proto:10 message RunRequest { string run_id = 1; string adapter = 2; string model = 3; string workspace = 4; google.protobuf.Struct policy = 5; google.protobuf.Struct input = 6; int32 timeout_sec = 7; map metadata = 8; } // proto/iop/runtime.proto:43 message CancelRequest { string run_id = 1; } ``` #### 해결 방법 `runtime.proto`에 logical session 선택, background 실행, cancel action을 명시한다. 새 field 번호는 기존 1-8을 보존해 wire compatibility를 유지한다. 계획된 계약: ```proto // proto/iop/runtime.proto:9 enum RunSessionMode { RUN_SESSION_MODE_UNSPECIFIED = 0; // default: create if missing RUN_SESSION_MODE_CREATE_IF_MISSING = 1; RUN_SESSION_MODE_REQUIRE_EXISTING = 2; } enum CancelAction { CANCEL_ACTION_UNSPECIFIED = 0; // default: cancel run only CANCEL_ACTION_CANCEL_RUN = 1; CANCEL_ACTION_TERMINATE_SESSION = 2; } message RunRequest { string run_id = 1; string adapter = 2; string model = 3; string workspace = 4; google.protobuf.Struct policy = 5; google.protobuf.Struct input = 6; int32 timeout_sec = 7; map metadata = 8; string session_id = 9; RunSessionMode session_mode = 10; bool background = 11; } 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; } message CancelRequest { string run_id = 1; string adapter = 2; string model = 3; string session_id = 4; CancelAction action = 5; } ``` `apps/node/internal/runtime/types.go`에는 protobuf enum을 직접 끌어오지 않고 node-domain 타입을 둔다. ```go // apps/node/internal/runtime/types.go:9 type SessionMode string const ( SessionModeCreateIfMissing SessionMode = "create_if_missing" SessionModeRequireExisting SessionMode = "require_existing" ) type CancelAction string const ( CancelActionCancelRun CancelAction = "cancel_run" CancelActionTerminateSession CancelAction = "terminate_session" ) const DefaultSessionID = "default" type ExecutionSpec struct { RunID string Adapter string Model string SessionID string SessionMode SessionMode Background bool Workspace string Policy map[string]any Input map[string]any TimeoutSec int Metadata map[string]string } ``` `RunRequest`에도 같은 필드를 추가하고, `EventTypeCancelled` 및 optional adapter control interface를 추가한다. ```go // apps/node/internal/runtime/types.go:24 const ( EventTypeStart EventType = "start" EventTypeDelta EventType = "delta" EventTypeComplete EventType = "complete" EventTypeError EventType = "error" EventTypeCancelled EventType = "cancelled" ) type SessionTerminator interface { TerminateSession(ctx context.Context, model, sessionID string) error } ``` #### 수정 파일 및 체크리스트 - [ ] `proto/iop/runtime.proto`: `RunSessionMode`, `CancelAction`, `session_id`, `background` fields 추가. - [ ] `proto/gen/iop/runtime.pb.go`: 직접 수정하지 말고 `make proto`로 갱신. - [ ] `apps/node/internal/runtime/types.go`: `SessionID`, `SessionMode`, `Background`, `CancelAction`, `DefaultSessionID`, `EventTypeCancelled`, `SessionTerminator` 추가. - [ ] `apps/node/internal/router/router.go`: `runtime.RunRequest` -> `runtime.ExecutionSpec`로 새 필드 전달. 현재 복사 지점은 `apps/node/internal/router/router.go:36`. - [ ] `apps/node/internal/node/node.go`: protobuf `RunRequest`에서 새 field를 node-domain request로 변환. 현재 변환 지점은 `apps/node/internal/node/node.go:58`. - [ ] `apps/node/internal/transport/parser_test.go`: `RunRequest` marshal/unmarshal에서 `session_id`, `background`, `session_mode` 보존 확인. #### 테스트 작성 작성한다. - `apps/node/internal/transport/parser_test.go` - `TestNodeParserMap_RunRequest`: 기존 test를 확장해 `SessionId`, `Background`, `SessionMode`가 roundtrip 되는지 검증한다. - `TestNodeParserMap_CancelRequest`: 기존 test를 확장해 `Action`, `SessionId`, `Adapter`, `Model`이 roundtrip 되는지 검증한다. - `apps/node/internal/router/router_test.go`가 없으므로 새 파일 생성 또는 기존 router test가 발견되면 갱신한다. - `TestResolve_PreservesSessionFields`: router가 `SessionID`, `SessionMode`, `Background`를 그대로 전달하는지 검증한다. #### 중간 검증 ```bash make proto go test ./apps/node/internal/runtime ./apps/node/internal/router ./apps/node/internal/transport ``` 예상 결과: protobuf generated code가 갱신되고 세 패키지 테스트가 통과한다. ### [REFACTOR-2] Node Run Manager And Background Execution #### 문제 - `OnRunRequest`는 요청을 받자마자 `adapter.Execute`를 직접 호출하고 완료까지 기다립니다. `apps/node/internal/node/node.go:99` - timeout cancel 함수가 `transport.Session`에 등록되어 run lifecycle이 edge transport session에 묶여 있습니다. `apps/node/internal/node/node.go:90` - transport session 자체가 cancel registry를 들고 있어 node 내부 worker/session lifecycle과 transport channel이 섞여 있습니다. `apps/node/internal/transport/session.go:20` - `OnCancel`은 run ID cancel만 가능하고 session terminate를 표현하지 못합니다. `apps/node/internal/node/node.go:115` 현재 코드: ```go // apps/node/internal/node/node.go:90 execCtx := ctx if spec.TimeoutSec > 0 { var cancel context.CancelFunc execCtx, cancel = context.WithTimeout(ctx, time.Duration(spec.TimeoutSec)*time.Second) defer cancel() sess.RegisterCancel(spec.RunID, cancel) defer sess.DeregisterCancel(spec.RunID) } sink := &sessionSink{sess: sess, out: os.Stdout} execErr := adapter.Execute(execCtx, spec, sink) ``` #### 해결 방법 node domain 안에 run manager를 추가해 active run registry를 transport에서 분리한다. transport `Session`은 "하나의 통로" 역할만 유지하고, run cancel/terminate routing은 node가 담당한다. 계획된 구조: ```go // apps/node/internal/node/run_manager.go package node import ( "context" "sync" ) type runHandle struct { runID string adapter string model string sessionID string cancel context.CancelFunc done chan struct{} } type runManager struct { mu sync.RWMutex runs map[string]*runHandle } func newRunManager() *runManager { return &runManager{runs: make(map[string]*runHandle)} } func (m *runManager) register(h *runHandle) { m.mu.Lock() defer m.mu.Unlock() m.runs[h.runID] = h } func (m *runManager) cancelRun(runID string) bool { m.mu.RLock() h, ok := m.runs[runID] m.mu.RUnlock() if !ok { return false } h.cancel() return true } ``` `Node`에는 manager를 추가하되 constructor signature는 가능하면 유지한다. ```go // apps/node/internal/node/node.go:24 type Node struct { nodeID string router runtime.Router registry *adapters.Registry store *store.Store runs *runManager logger *zap.Logger } ``` `OnRunRequest`는 공통 실행 함수를 만들고, `spec.Background`가 true면 goroutine으로 실행 후 즉시 반환한다. background라도 event는 같은 transport connection의 `sessionSink`로 계속 전송한다. ```go // apps/node/internal/node/node.go:99 sink := &sessionSink{ sess: sess, out: os.Stdout, sessionID: spec.SessionID, background: spec.Background, } run := func() { execErr := adapter.Execute(execCtx, spec, sink) n.completeRun(context.Background(), spec, execErr) } if spec.Background { go run() return nil } run() return execErr ``` `OnCancel`은 action을 분기한다. ```go // apps/node/internal/node/node.go:115 switch cancelActionFromProto(req.GetAction()) { case runtime.CancelActionTerminateSession: adapter, ok := n.registry.Get(req.GetAdapter()) if !ok { return fmt.Errorf("node: adapter %q not found", req.GetAdapter()) } terminator, ok := adapter.(runtime.SessionTerminator) if !ok { return fmt.Errorf("node: adapter %q does not support session termination", req.GetAdapter()) } return terminator.TerminateSession(ctx, req.GetModel(), normalizeSessionID(req.GetSessionId())) default: n.runs.cancelRun(req.GetRunId()) return nil } ``` `sessionSink.Emit`는 RunEvent에 session context를 포함한다. ```go // apps/node/internal/node/node.go:131 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, } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/node/run_manager.go`: active run registry 추가. - [ ] `apps/node/internal/node/node.go`: `runManager` 필드, background 실행, cancel/terminate 분기, cancelled status 처리 추가. - [ ] `apps/node/internal/transport/session.go`: `cancelFns` registry와 `RegisterCancel`/`DeregisterCancel`/`CancelRun` 제거 또는 deprecated no-op으로 축소. 최종적으로 node가 cancel ownership을 가져야 한다. - [ ] `apps/node/internal/transport/session_test.go`: transport cancel registry 테스트 제거/대체. transport는 parser/send/connection 책임만 검증한다. - [ ] `apps/node/internal/node/node_test.go`: fixed router와 test adapter가 새 `ExecutionSpec` fields를 보존하도록 갱신. - [ ] `apps/node/internal/store/store.go`: `session_id`, `background`, `cancelled` status를 저장할 수 있도록 schema migration 추가. 현재 schema는 `run_id`, `adapter`, `model`, `status`만 갖는다. `apps/node/internal/store/store.go:14` #### 테스트 작성 작성한다. - `apps/node/internal/node/node_test.go` - `TestOnRunRequest_BackgroundReturnsBeforeAdapterCompletes`: blocking adapter를 사용해 `Background=true`일 때 `OnRunRequest`가 adapter 완료 전에 반환하고, 이후 store status가 completed로 바뀌는지 검증한다. - `TestOnRunRequest_EventIncludesSessionContext`: fake session 또는 sink seam을 통해 emitted `RunEvent.SessionId`와 `Background`가 채워지는지 검증한다. fake session 작성이 과도하면 `sessionSink` 단위 테스트로 분리한다. - `TestOnCancel_CancelsRunViaRunManager`: 더 이상 `transport.Session` cancel registry에 의존하지 않고 node manager가 cancel을 호출하는지 검증한다. - `TestOnCancel_TerminatesAdapterSession`: `runtime.SessionTerminator`를 구현한 fake adapter로 `CancelActionTerminateSession`이 adapter의 `TerminateSession`으로 라우팅되는지 검증한다. - `apps/node/internal/store/store_test.go` - `TestStore_RunSessionFields`: `session_id`, `background`, cancelled status 저장/조회 검증. #### 중간 검증 ```bash go test ./apps/node/internal/node ./apps/node/internal/transport ./apps/node/internal/store ``` 예상 결과: node run lifecycle, transport parser/session, store tests가 통과한다. ### [REFACTOR-3] CLI Adapter Unbounded Logical Sessions #### 문제 - `CLI.sessions`가 `map[string]*profileSession`이라 profile/model당 session 하나만 가질 수 있습니다. `apps/node/internal/adapters/cli/cli.go:42` - `Start`도 persistent profile마다 session 하나만 시작하고 `sessions[name] = sess`로 덮어씁니다. `apps/node/internal/adapters/cli/cli.go:79` - `executePersistent`는 `c.sessions[spec.Model]`로만 session을 찾습니다. `apps/node/internal/adapters/cli/persistent.go:29` - context cancel 시 session process를 닫고 삭제합니다. 하지만 요구사항은 명시 terminate 전까지 worker를 살리는 것입니다. `apps/node/internal/adapters/cli/persistent.go:67` 현재 코드: ```go // apps/node/internal/adapters/cli/cli.go:42 type CLI struct { mu sync.Mutex profiles map[string]config.CLIProfileConf sessions map[string]*profileSession logger *zap.Logger } // apps/node/internal/adapters/cli/persistent.go:29 func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { c.mu.Lock() sess, ok := c.sessions[spec.Model] c.mu.Unlock() if !ok { return fmt.Errorf("cli adapter: no persistent session for profile %q", spec.Model) } ``` #### 해결 방법 profile은 worker 생성 template로만 사용하고, 실제 장수 process는 `(model, session_id)` key로 관리한다. hard limit, pool size, max sessions를 넣지 않는다. 계획된 구조: ```go // apps/node/internal/adapters/cli/cli.go:31 type sessionKey struct { model string sessionID string } type profileSession struct { key sessionKey name string profile config.CLIProfileConf cmd *exec.Cmd input io.Writer output <-chan cliOutput done <-chan error closeFn func() error mu sync.Mutex } type CLI struct { mu sync.Mutex profiles map[string]config.CLIProfileConf sessions map[sessionKey]*profileSession logger *zap.Logger } ``` `Start`는 backward compatibility를 위해 persistent profile의 `default` session만 prestart한다. 추가 session은 `RunRequest.SessionID`가 들어왔을 때 lazy-create 한다. ```go // apps/node/internal/adapters/cli/cli.go:79 for _, name := range names { profile := c.profiles[name] key := sessionKey{model: name, sessionID: runtime.DefaultSessionID} sess, err := startProfileSession(ctx, key, profile, c.logger) // ... c.sessions[key] = sess } ``` `executePersistent`는 session mode에 따라 lookup/create를 수행한다. ```go // apps/node/internal/adapters/cli/persistent.go:29 func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { sess, err := c.resolveSession(ctx, spec, profile) if err != nil { return err } sess.mu.Lock() defer sess.mu.Unlock() // send prompt and stream response } func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) { key := sessionKey{model: spec.Model, sessionID: normalizeSessionID(spec.SessionID)} c.mu.Lock() defer c.mu.Unlock() if sess, ok := c.sessions[key]; ok { return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { return nil, fmt.Errorf("cli adapter: no persistent session for profile %q session %q", spec.Model, key.sessionID) } sess, err := startProfileSession(ctx, key, profile, c.logger) if err != nil { return nil, err } c.sessions[key] = sess return sess, nil } ``` run cancel은 session terminate가 아니다. Persistent CLI는 단일 interactive stream 특성상 prompt를 이미 보낸 뒤의 output을 완전히 되돌릴 수 없으므로, run cancel 시 process를 죽이지 않고 current run을 `cancelled`로 끝낸 뒤 idle timeout까지 output을 drain하여 다음 run 오염을 줄인다. session 자체를 내려야 할 때만 `TerminateSession`을 사용한다. ```go // apps/node/internal/adapters/cli/persistent.go:65 case <-ctx.Done(): _ = drainSessionUntilIdle(sess.output, idleTimeout, c.logger, sess.key) _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: "cli execution cancelled", Timestamp: time.Now(), }) return runtime.ErrRunCancelled ``` CLI adapter는 `runtime.SessionTerminator`를 구현한다. ```go // apps/node/internal/adapters/cli/cli.go:150 func (c *CLI) TerminateSession(ctx context.Context, model, sessionID string) error { key := sessionKey{model: model, sessionID: normalizeSessionID(sessionID)} c.mu.Lock() sess, ok := c.sessions[key] if ok { delete(c.sessions, key) } c.mu.Unlock() if !ok { return fmt.Errorf("cli adapter: no session %q for profile %q", key.sessionID, model) } return closeProfileSession(ctx, sess) } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/cli.go`: `sessionKey`, `sessions map[sessionKey]*profileSession`, default session prestart, stop-all iteration, `TerminateSession` 구현. - [ ] `apps/node/internal/adapters/cli/persistent.go`: `resolveSession`, lazy session creation, require-existing behavior, cancel-with-drain, process-exit cleanup을 session key 기준으로 변경. - [ ] `apps/node/internal/adapters/cli/oneshot.go`: one-shot은 session을 유지하지 않지만 `RuntimeEvent.Metadata` 또는 event context에 `session_id`를 반영할지 결정한다. 최소한 compile 영향 확인. - [ ] `apps/node/internal/adapters/cli/internal/testutil/testutil.go`: 필요 시 event assertion helper 추가. - [ ] `apps/node/internal/adapters/cli/persistent/execute_test.go`: persistent behavior 테스트 갱신. - [ ] `apps/node/internal/adapters/cli/lifecycle/cli_test.go`: Start/Stop rollback 테스트를 `default` session 기준으로 갱신. #### 테스트 작성 작성한다. - `apps/node/internal/adapters/cli/persistent/execute_test.go` - `TestCLIExecutePersistentCreatesIndependentSessionsForSameProfile`: 같은 model에 `SessionID=session-a`, `SessionID=session-b`를 지정하고 각 session이 자기 prompt에 대한 응답만 반환하는지 검증한다. - `TestCLIExecutePersistentRequireExistingSessionFailsWhenMissing`: `SessionModeRequireExisting`과 없는 `session_id`를 보내면 새 process를 만들지 않고 에러를 반환하는지 검증한다. - `TestCLIExecutePersistentMaintainsHundredLogicalSessions`: `Terminal=false`인 가벼운 shell loop로 같은 profile에 100개 session_id를 생성하고 모두 응답한 뒤, 두 번째 요청에서도 session들이 유지되는지 검증한다. 이 테스트는 OS process를 많이 만들므로 `testing.Short()`에서는 skip한다. - `TestCLIExecutePersistentCancelDoesNotTerminateSession`: timeout/cancel 후 같은 `session_id`로 다시 Execute 했을 때 no-session error가 아니라 정상 응답하는지 검증한다. 기존 `TestCLIExecutePersistentTimeoutCleansSession`은 반대 기대값을 갖고 있으므로 새 요구에 맞게 수정한다. 현재 반대 기대값 위치는 `apps/node/internal/adapters/cli/persistent/execute_test.go:167`. - `apps/node/internal/adapters/cli/lifecycle/cli_test.go` - `TestCLITerminateSessionStopsOnlyTargetSession`: 같은 profile의 `session-a`, `session-b`를 만든 뒤 `session-a`만 terminate하고 `session-b`는 계속 실행되는지 검증한다. - `TestCLIStopStopsAllLogicalSessions`: 생성된 모든 logical sessions가 `Stop`에서 정리되는지 검증한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/... ``` 예상 결과: CLI root, internal/testutil, lifecycle, oneshot, persistent 테스트가 통과한다. ### [REFACTOR-4] Edge Console, Config Payload, Docs #### 문제 - edge console은 session을 고르지 않고 매번 adapter/model만 보냅니다. `apps/edge/cmd/edge/console.go:99` - console config에는 session/background 설정이 없습니다. `packages/config/config.go:39` - edge config payload는 CLI profile 설정을 전달하지만 optional lifecycle condition이나 session default를 전달하지 않습니다. `apps/edge/internal/transport/server.go:190` - `configs/edge.yaml`의 codex profile은 현재 one-shot `persistent: false`라 logical worker session 요구를 예제로 보여주지 못합니다. `configs/edge.yaml:49` 현재 코드: ```go // apps/edge/cmd/edge/console.go:115 req := &iop.RunRequest{ RunId: runID, Adapter: adapter, Model: model, Input: input, TimeoutSec: int32(timeoutSec), Metadata: map[string]string{ "source": "edge-console", }, } ``` #### 해결 방법 edge-node transport registry는 그대로 node id당 하나의 connection만 유지한다. edge가 logical session을 선택할 수 있도록 console config와 request 생성부만 확장한다. 계획된 config: ```go // packages/config/config.go:39 type EdgeConsoleConf struct { Adapter string `mapstructure:"adapter" yaml:"adapter"` Model string `mapstructure:"model" yaml:"model"` SessionID string `mapstructure:"session_id" yaml:"session_id"` Background bool `mapstructure:"background" yaml:"background"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` } ``` 계획된 console request: ```go // apps/edge/cmd/edge/console.go:115 req := &iop.RunRequest{ RunId: runID, Adapter: adapter, Model: model, SessionId: normalizeConsoleSessionID(sessionID), SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING, Background: background, Input: input, TimeoutSec: int32(timeoutSec), Metadata: map[string]string{ "source": "edge-console", }, } ``` console 명령은 구현 범위를 작게 유지하되 manual testing을 위해 아래만 추가한다. ```text /session 현재 console target logical session 변경 /background on|off 현재 console request background mode 변경 /terminate-session 현재 adapter/model/session_id worker 종료 ``` `configs/edge.yaml` 예시는 `codex`를 persistent worker로 보여준다. 단, 사용자가 one-shot으로 계속 쓸 수 있도록 주석 또는 README에 `persistent: false` 선택지를 남긴다. ```yaml # configs/edge.yaml:14 console: adapter: "cli" model: "codex" session_id: "default" background: false timeout_sec: 120 # configs/edge.yaml:49 codex: command: "codex" args: - "exec" - "--dangerously-bypass-approvals-and-sandbox" - "--color" - "never" - "--skip-git-repo-check" env: [] persistent: true terminal: false ``` #### 수정 파일 및 체크리스트 - [ ] `packages/config/config.go`: `EdgeConsoleConf.SessionID`, `EdgeConsoleConf.Background` 추가. CLI profile lifecycle field가 필요하면 zero default disabled 형태로 추가한다. `pool_size` 또는 hard limit field는 추가하지 않는다. - [ ] `packages/config/config_test.go`: console defaults/override 테스트 갱신. - [ ] `apps/edge/cmd/edge/console.go`: `/session`, `/background`, `/terminate-session` 명령과 `RunRequest` field 설정 추가. - [ ] `apps/edge/internal/transport/server.go`: CLI settings payload에 새 config fields가 생기면 누락 없이 전달. node id당 one connection 거절 로직 `apps/edge/internal/transport/server.go:109`는 유지한다. - [ ] `apps/edge/internal/transport/server_test.go`: 새 CLI profile fields roundtrip 검증. - [ ] `configs/edge.yaml`: console session/background 예시와 codex persistent 예시 반영. - [ ] `apps/node/README.md`, `apps/edge/README.md`: "transport connection 1개, logical session 여러 개" 사용법과 terminate/cancel 의미 차이를 문서화. #### 테스트 작성 작성한다. - `packages/config/config_test.go` - `TestLoadEdge_ConsoleSessionDefaults`: default `session_id="default"`, `background=false` 확인. - `TestLoadEdge_ConsoleSessionOverride`: YAML override 확인. - `apps/edge/internal/transport/server_test.go` - `TestBuildConfigPayload_CLISessionLifecycleRoundtrip`: 추가 config field가 structpb payload에 보존되는지 검증한다. - `apps/edge/cmd/edge/console_test.go` - 현재 없음. 새로 만들고 `sendConsoleRun`에 session/background 인자를 넣었을 때 `RunRequest`가 올바르게 만들어지는지 테스트한다. 직접 TCP client를 세우기 어렵다면 request-building helper를 추출해 단위 테스트한다. #### 중간 검증 ```bash go test ./packages/config ./apps/edge/internal/transport ./apps/edge/cmd/edge ``` 예상 결과: config loading, edge config payload, edge console tests가 통과한다. ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `proto/iop/runtime.proto` | REFACTOR-1 | | `proto/gen/iop/runtime.pb.go` | REFACTOR-1 | | `apps/node/internal/runtime/types.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 | | `apps/node/internal/router/router.go` | REFACTOR-1 | | `apps/node/internal/router/router_test.go` | REFACTOR-1 | | `apps/node/internal/node/node.go` | REFACTOR-1, REFACTOR-2 | | `apps/node/internal/node/run_manager.go` | REFACTOR-2 | | `apps/node/internal/node/node_test.go` | REFACTOR-2 | | `apps/node/internal/transport/session.go` | REFACTOR-2 | | `apps/node/internal/transport/session_test.go` | REFACTOR-2 | | `apps/node/internal/transport/parser_test.go` | REFACTOR-1 | | `apps/node/internal/store/store.go` | REFACTOR-2 | | `apps/node/internal/store/store_test.go` | REFACTOR-2 | | `apps/node/internal/adapters/cli/cli.go` | REFACTOR-3 | | `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-3 | | `apps/node/internal/adapters/cli/oneshot.go` | REFACTOR-3 | | `apps/node/internal/adapters/cli/internal/testutil/testutil.go` | REFACTOR-3 | | `apps/node/internal/adapters/cli/persistent/execute_test.go` | REFACTOR-3 | | `apps/node/internal/adapters/cli/lifecycle/cli_test.go` | REFACTOR-3 | | `packages/config/config.go` | REFACTOR-4 | | `packages/config/config_test.go` | REFACTOR-4 | | `apps/edge/cmd/edge/console.go` | REFACTOR-4 | | `apps/edge/cmd/edge/console_test.go` | REFACTOR-4 | | `apps/edge/internal/transport/server.go` | REFACTOR-4 | | `apps/edge/internal/transport/server_test.go` | REFACTOR-4 | | `configs/edge.yaml` | REFACTOR-4 | | `apps/node/README.md` | REFACTOR-4 | | `apps/edge/README.md` | REFACTOR-4 | ## 콜사이트 및 호환성 체크 - `runtime.ExecutionSpec` 생성 콜사이트: - `apps/node/internal/router/router.go:36` - `apps/node/internal/node/node_test.go:43` - `apps/node/internal/adapters/cli/persistent/execute_test.go:45` - `apps/node/internal/adapters/cli/lifecycle/cli_test.go:75` - `iop.RunRequest` 생성 콜사이트: - `apps/edge/cmd/edge/console.go:115` - `apps/node/internal/transport/parser_test.go:14` - `apps/node/internal/transport/integration_test.go:139` - `apps/node/internal/node/node_test.go:112` - `iop.CancelRequest` 생성 콜사이트: - `apps/node/internal/transport/parser_test.go:40` - `apps/node/internal/node/node_test.go:143` - 제거/이동 후보 `transport.Session` cancel methods 콜사이트: - `apps/node/internal/node/node.go:95` - `apps/node/internal/node/node.go:96` - `apps/node/internal/node/node.go:118` - `apps/node/internal/node/node_test.go:141` - `apps/node/internal/transport/session_test.go:15` ## 최종 검증 아래 명령을 순서대로 실행한다. ```bash make proto gofmt -w \ apps/node/internal/runtime/types.go \ apps/node/internal/router/router.go \ apps/node/internal/router/router_test.go \ apps/node/internal/node/node.go \ apps/node/internal/node/run_manager.go \ apps/node/internal/node/node_test.go \ apps/node/internal/transport/session.go \ apps/node/internal/transport/session_test.go \ apps/node/internal/transport/parser_test.go \ apps/node/internal/store/store.go \ apps/node/internal/store/store_test.go \ apps/node/internal/adapters/cli/cli.go \ apps/node/internal/adapters/cli/persistent.go \ apps/node/internal/adapters/cli/oneshot.go \ apps/node/internal/adapters/cli/internal/testutil/testutil.go \ apps/node/internal/adapters/cli/persistent/execute_test.go \ apps/node/internal/adapters/cli/lifecycle/cli_test.go \ packages/config/config.go \ packages/config/config_test.go \ apps/edge/cmd/edge/console.go \ apps/edge/cmd/edge/console_test.go \ apps/edge/internal/transport/server.go \ apps/edge/internal/transport/server_test.go go test ./apps/node/internal/adapters/cli/... go test ./apps/node/internal/runtime ./apps/node/internal/router ./apps/node/internal/node ./apps/node/internal/transport ./apps/node/internal/store go test ./packages/config ./apps/edge/internal/transport ./apps/edge/cmd/edge go test ./... ``` 예상 결과: 모든 명령이 성공한다. `make proto`가 실패하면 `protoc` 또는 `protoc-gen-go` 설치 여부를 확인하되, 생성 파일을 직접 수정하지 않는다.