<!-- task=unbounded_cli_sessions plan=0 tag=REFACTOR -->

# 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<string, string> 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<string, string> 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<string, string> 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 <id>         현재 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` 설치 여부를 확인하되, 생성 파일을 직접 수정하지 않는다.
