iop/agent-task/unbounded_cli_sessions/plan_0.log
toki 96f79fcd08 feat: unbounded CLI sessions support and related improvements
- Add unbounded CLI sessions agent-task with plan and code review logs
- Add edge console with tests
- Add node run manager for session lifecycle management
- Add router and store tests
- Update transport session handling
- Add runtime proto definitions
- Update configurations and packages
2026-05-03 20:07:09 +09:00

769 lines
31 KiB
Text

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