# Follow-up Plan - Unbounded CLI Sessions Review Fixes ## 이 파일을 읽는 구현 에이전트에게 이 계획은 `plan_0.log` / `code_review_0.log` 리뷰에서 발견된 Required 이슈만 해결하기 위한 follow-up입니다. 각 체크리스트를 완료하고, 중간/최종 검증을 실행한 뒤 `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 전용 작업입니다. ## 배경 첫 구현은 unbounded CLI logical session의 큰 구조를 도입했지만, foreground adapter error 반환 계약과 기존 SQLite DB migration 안전성에서 Required 이슈가 남았습니다. 이 follow-up은 새 구조를 유지하면서 호출자 error 관측과 기존 workspace DB 호환성을 회복합니다. ## 의존 관계 및 구현 순서 1. `REVIEW_REFACTOR-1`에서 node foreground error 반환 계약을 복구한다. 2. `REVIEW_REFACTOR-2`에서 store migration을 idempotent하게 추가한다. 3. 두 항목의 중간 검증 후 최종 `go test ./...`를 실행한다. ### [REVIEW_REFACTOR-1] Restore Foreground OnRunRequest Error Return #### 문제 `OnRunRequest`의 foreground 경로가 adapter failure를 호출자에게 반환하지 않습니다. 현재 코드: ```go // apps/node/internal/node/node.go:120 run := func() { defer cancel() defer n.runs.deregister(spec.RunID) defer close(h.done) execErr := adapter.Execute(execCtx, spec, sink) n.completeRun(spec, execErr) } if spec.Background { go run() return nil } run() return nil ``` 이전 계약과 `plan_0.log`의 pseudocode는 synchronous run에서 `adapter.Execute` error를 반환해야 합니다. 지금 상태에서는 store에는 failed/cancelled가 기록되더라도 transport handler caller는 실패를 알 수 없습니다. #### 해결 방법 `run` closure가 `error`를 반환하도록 바꾸고, foreground 경로에서는 그 error를 그대로 반환한다. background 경로는 기존처럼 goroutine dispatch 후 `nil`을 반환한다. 계획 코드: ```go // apps/node/internal/node/node.go:120 run := func() error { defer cancel() defer n.runs.deregister(spec.RunID) defer close(h.done) execErr := adapter.Execute(execCtx, spec, sink) n.completeRun(spec, execErr) return execErr } if spec.Background { go func() { _ = run() }() return nil } return run() ``` `runtime.ErrRunCancelled`도 adapter error이므로 foreground caller에 반환한다. store status는 기존 `completeRun` 로직대로 `cancelled`를 유지한다. #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/node/node.go`: `run` closure return type을 `error`로 변경. - [ ] `apps/node/internal/node/node.go`: foreground path를 `return run()`으로 변경. - [ ] `apps/node/internal/node/node.go`: background path는 `go func() { _ = run() }()`로 error를 소비하되 store update는 유지. - [ ] `apps/node/internal/node/node_test.go`: adapter failure를 반환하는 test double 추가. - [ ] `apps/node/internal/node/node_test.go`: foreground adapter failure가 `OnRunRequest` return error와 store `failed` status에 모두 반영되는 regression test 추가. - [ ] `apps/node/internal/node/node_test.go`: foreground `runtime.ErrRunCancelled` 반환 시 `OnRunRequest`가 해당 sentinel error를 반환하고 store status가 `cancelled`가 되는지 필요하면 추가 test로 고정. #### 테스트 작성 작성한다. - `apps/node/internal/node/node_test.go` - `TestOnRunRequest_ForegroundAdapterErrorReturned`: fake adapter가 `errors.New("adapter boom")`을 반환하게 하고 `OnRunRequest`가 non-nil error를 반환하는지, `st.GetRun(...).Status == "failed"`인지, `Error`에 `"adapter boom"`이 들어가는지 검증한다. - `TestOnRunRequest_ForegroundCancelReturnedAndStored`: blocking adapter 또는 sentinel adapter를 사용해 foreground path에서 `runtime.ErrRunCancelled`가 반환되고 store status가 `cancelled`인지 검증한다. 구현이 복잡하면 첫 번째 test를 필수로 두고 cancelled path는 existing background cancel test로 충분한지 CODE_REVIEW에 근거를 남긴다. #### 중간 검증 ```bash go test ./apps/node/internal/node ``` 예상 결과: node package tests가 통과하고 새 regression test가 실패 계약을 방지한다. ### [REVIEW_REFACTOR-2] Add Idempotent Store Migration For Existing Runs Table #### 문제 store schema는 새 table 생성에는 `session_id`, `background` 컬럼을 포함하지만, 기존 DB에 이미 `runs` table이 있으면 `CREATE TABLE IF NOT EXISTS`는 아무 것도 바꾸지 않습니다. 현재 코드: ```go // apps/node/internal/store/store.go:47 func New(dsn string, logger *zap.Logger) (*Store, error) { db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("store: open %s: %w", dsn, err) } db.SetMaxOpenConns(1) // SQLite is single-writer if _, err := db.Exec(schema); err != nil { return nil, fmt.Errorf("store: migrate: %w", err) } logger.Info("store ready", zap.String("dsn", dsn)) return &Store{db: db, logger: logger}, nil } ``` 그런데 `InsertRun`과 `GetRun`은 항상 새 컬럼을 사용합니다. ```go // apps/node/internal/store/store.go:77 `INSERT INTO runs (run_id, adapter, model, session_id, background, status, created_at) VALUES (?,?,?,?,?,?,?)` // apps/node/internal/store/store.go:95 `SELECT run_id, adapter, model, session_id, background, status, created_at, completed_at, error FROM runs WHERE run_id=?` ``` 기존 workspace DB에서는 insert/select가 `no such column: session_id` 계열 오류로 깨집니다. #### 해결 방법 `schema` 실행 후 idempotent migration을 실행한다. SQLite는 `ADD COLUMN IF NOT EXISTS` 지원이 환경에 따라 애매할 수 있으므로, `PRAGMA table_info(runs)`로 컬럼 존재 여부를 확인한 뒤 없는 컬럼만 `ALTER TABLE` 한다. 계획 코드: ```go // apps/node/internal/store/store.go:54 if _, err := db.Exec(schema); err != nil { return nil, fmt.Errorf("store: migrate: %w", err) } if err := migrateRunsTable(db); err != nil { _ = db.Close() return nil, fmt.Errorf("store: migrate runs: %w", err) } ``` ```go // apps/node/internal/store/store.go:57 func migrateRunsTable(db *sql.DB) error { cols, err := tableColumns(db, "runs") if err != nil { return err } if !cols["session_id"] { if _, err := db.Exec(`ALTER TABLE runs ADD COLUMN session_id TEXT NOT NULL DEFAULT 'default'`); err != nil { return err } } if !cols["background"] { if _, err := db.Exec(`ALTER TABLE runs ADD COLUMN background INTEGER NOT NULL DEFAULT 0`); err != nil { return err } } return nil } ``` `tableColumns`는 `PRAGMA table_info(runs)`의 `name` column을 읽어 `map[string]bool`로 반환한다. #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/store/store.go`: `migrateRunsTable` 추가. - [ ] `apps/node/internal/store/store.go`: `tableColumns` helper 추가. - [ ] `apps/node/internal/store/store.go`: migration 실패 시 DB close 후 error 반환. - [ ] `apps/node/internal/store/store_test.go`: old schema fixture 생성 helper 추가. - [ ] `apps/node/internal/store/store_test.go`: old schema DB를 `Store.New`로 열고 `InsertRun`/`GetRun`이 새 columns와 함께 동작하는 regression test 추가. #### 테스트 작성 작성한다. - `apps/node/internal/store/store_test.go` - `TestStore_MigratesExistingRunsTable`: temp sqlite file에 구 schema(`run_id`, `adapter`, `model`, `status`, `created_at`, `completed_at`, `error`)만 가진 `runs` table을 먼저 만든다. 이후 `store.New(dsn, ...)`를 호출하고 `InsertRun` with `SessionID: "session-a", Background: true`가 성공하는지, `GetRun`이 해당 값을 읽는지 검증한다. - `TestStore_MigrationIsIdempotent`: 같은 DB에 `store.New`를 두 번 호출해 두 번째 호출도 실패하지 않는지 검증한다. #### 중간 검증 ```bash go test ./apps/node/internal/store ``` 예상 결과: store package tests가 통과하고 구 schema DB migration regression test가 추가된다. ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/node/internal/node/node.go` | REVIEW_REFACTOR-1 | | `apps/node/internal/node/node_test.go` | REVIEW_REFACTOR-1 | | `apps/node/internal/store/store.go` | REVIEW_REFACTOR-2 | | `apps/node/internal/store/store_test.go` | REVIEW_REFACTOR-2 | | `agent-task/unbounded_cli_sessions/CODE_REVIEW.md` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2 | ## 최종 검증 ```bash gofmt -w apps/node/internal/node/node.go apps/node/internal/node/node_test.go apps/node/internal/store/store.go apps/node/internal/store/store_test.go go test ./apps/node/internal/node go test ./apps/node/internal/store go test ./... ``` 예상 결과: 모든 명령이 성공한다.