## 2026-05-27 — 세션 워크트리 핸들 보존 + cleanup 부분실패 보고 + TUI 대체 버퍼 fit 우회

### 배경

세션을 만들고 워크트리에서 한 번 돌린 다음 stop 하면, 같은 row의 **워크트리/브랜치 cleanup 버튼이 사라지는 현상**이 있었다. 원인 추적 결과 세 가지 문제가 얽혀 있었다:

1. **WS 이벤트 누락**: 세션 spawn 후 서버가 `session:status-changed: running`을 broadcast 할 때 `worktree_path`/`branch_name`을 보내지 않아, 클라이언트의 `setSessions` 핸들러는 optimistic add 시점의 `null/null`을 영영 갱신 못함. `todo:status-changed`는 이미 두 필드를 같이 보내는데 session 쪽만 빠져 있었다.
2. **`createWorktree`의 suffix-bumped 브랜치명 미반환**: dir + branch 이름이 모두 unique해질 때까지 suffix를 올리면서도 `worktreePath`만 리턴 → 호출자는 입력 시점의 원본 `branchName`을 DB에 박음. 디스크에는 `feature/session-7`이 만들어졌는데 DB는 `feature/session`을 들고 있게 되어, deleteBranch=true cleanup이 엉뚱한 브랜치를 지울 수 있는 잠재 위험.
3. **`worktree remove` 실패 시 핸들 분실**: 기존 cleanup은 git이 실패해도 DB의 `worktree_path`/`branch_name`을 null로 박아버려, 사용자가 UI에서 재시도할 수단을 잃었다. Windows에서 프로세스가 잠근 파일 때문에 `worktree remove --force`가 실패하는 케이스가 흔함.

추가로 어제 stash된 TUI alternate buffer 이슈도 같이 처리: Claude/Codex/Gemini 같은 TUI가 alternate screen으로 진입한 동안 폰트 사이즈 변경이 일어나면 `fitAddon.fit()`이 cols/rows를 다시 잡으면서 TUI 스크롤백 상단이 잘리는 문제.

### 주요 변경

#### 1. WS broadcast에 worktree/branch 동봉 (`490bf7e`)

- **`src/server/websocket/events.ts`**: `session:status-changed` 이벤트 타입에 `worktree_path?: string | null`, `branch_name?: string | null` 옵셔널 필드 추가.
- **`src/server/services/session-manager.ts`**: `running` 상태 broadcast 시 `worktreePath`/`branchName`을 같이 실어 보냄.
- **`src/server/services/worktree-manager.ts`**: `createWorktree`의 리턴 시그니처를 `Promise<string>` → `Promise<{ worktreePath: string; branchName: string }>`으로 변경. 호출자가 suffix-bumped 실제 브랜치명을 받을 수 있게 됨.
- **`src/server/services/orchestrator.ts`, `discussion-orchestrator.ts`**: 새 리턴 셰이프에 맞춰 분해 할당으로 업데이트.
- **`src/client/src/components/ProjectDetail.tsx`, `popout/PopoutPage.tsx`**: WS 이벤트의 `worktree_path`/`branch_name` 필드를 session row에 머지. 페이지 새로고침이나 refetch 없이도 cleanup 버튼이 즉시 나타남.

#### 2. cleanup 부분실패 리포팅 + 폴백 (`0b96f70`)

- **`src/server/services/worktree-manager.ts:cleanupWorktree`**: per-step 성공/실패 객체 `{ worktreeRemoved, branchDeleted, worktreeError?, branchError? }`를 리턴. `worktree remove --force` 실패 시 `rmSync` + `worktree prune` 폴백으로 한 번 더 시도하고, `branch -D`가 "checked out at" 으로 거부되면 prune 후 재시도.
- **`src/server/routes/sessions.ts`**: 실제로 git이 성공한 필드만 DB에 null 박음 — 워크트리가 살아있으면 `worktree_path`를 보존해 UI에서 재시도 가능하도록.
- **`src/server/services/worktree-manager.ts:createWorktree`**: dir 충돌만 보던 suffix bump 루프를 **dir + branch 둘 다 unique해질 때까지** 돌도록 강화. cleanup 부분실패로 dangling 브랜치만 남아도 새 세션이 silent하게 그 브랜치를 reuse하지 않음.
- **`src/client/src/api/sessions.ts`, `ProjectDetail.tsx`**: cleanup 응답에서 어느 단계가 실패했는지 `alert()`로 사용자에게 노출 + 성공한 필드만 client state에 patch.

#### 3. TUI 대체 버퍼에서 fit 우회 (`6d9363f`)

- **`src/client/src/components/SessionTerminal.tsx`**: 폰트 사이즈 변경 effect에서 `term.buffer.active.type === 'alternate'` 감지. alternate 버퍼이면 `fit()`/`refresh()`/SIGWINCH를 건너뛰고 글리프 크기만 바꿔, cols/rows를 핀 상태로 유지 → Claude/Codex/Gemini 스크롤백이 상단부터 잘리지 않음. normal 버퍼에서는 기존 reflow 경로 유지 (스크롤백 보존되므로 OK). dev-only 디버그 로그 추가.

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/server/websocket/events.ts` | `session:status-changed`에 worktree_path/branch_name 옵셔널 필드 |
| `src/server/services/session-manager.ts` | broadcast에 두 필드 동봉 + createWorktree 새 셰이프 분해 |
| `src/server/services/worktree-manager.ts` | createWorktree 리턴 셰이프 변경 + cleanup per-step 에러 리포팅 + rm/prune 폴백 |
| `src/server/services/orchestrator.ts`, `discussion-orchestrator.ts` | 새 createWorktree 리턴 사용 |
| `src/server/routes/sessions.ts` | git 성공한 필드만 DB null 박음 |
| `src/client/src/components/ProjectDetail.tsx`, `popout/PopoutPage.tsx` | WS 이벤트의 worktree/branch를 session row에 머지 |
| `src/client/src/components/SessionTerminal.tsx` | alternate buffer에서 fit/refresh/SIGWINCH 우회 |

### 아키텍처 결정

1. **cleanup은 핸들을 보존한다**: 과거에는 cleanup 호출이 무조건 DB 컬럼을 null로 박았다. 그러면 git 단계가 부분 실패했을 때 사용자가 UI에서 재시도할 핸들 자체를 잃는다. 새 방식은 git이 *확실히* 성공한 필드만 null 박고, 실패한 부분은 alert로 알려 사용자가 같은 row에서 retry 할 수 있게 한다. "낙관적 client-side 클리어 + 서버 정합성" 트레이드오프에서 정합성 쪽으로 이동.
2. **createWorktree가 실제 브랜치명을 반환**: suffix 로직이 함수 내부에 있고 결과는 호출자가 알아야 DB에 정확히 박을 수 있다. 단일 리턴값(`worktreePath`)을 객체로 확장해 BC를 깨는 대신 한 번에 정합화 — 호출 사이트가 세 군데(`session-manager`, `orchestrator`, `discussion-orchestrator`)뿐이라 일괄 수정 비용이 작음.
3. **alternate buffer는 reflow에서 제외**: xterm.js의 `fit()` + `refresh()`는 normal 버퍼에서는 스크롤백을 보존하지만 alternate 버퍼는 화면 크기가 바뀌면 위에서부터 truncate된다(터미널 표준 동작). TUI 앱이 alternate에 진입한 상태에서 폰트 사이즈를 바꿀 때 PTY에 SIGWINCH를 보내지 않고 글리프만 키우면, cols/rows는 그대로라 TUI 내부 출력은 손상 없이 유지된다. 사용자 입장에서는 "TUI 안에서 줌만 부드럽게" 됨.
