<!-- task=unbounded_cli_sessions plan=1 tag=REVIEW_REFACTOR -->

# 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 ./...
```

예상 결과: 모든 명령이 성공한다.
