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