# Persistent 실행 cancel 사유(timeout vs user-cancel) 구분 ## 이 파일을 읽는 구현 에이전트에게 아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/04_cli_persistent_cancel_reason/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다. ## 배경 `apps/node/internal/adapters/cli/persistent.go:65-74`의 `<-ctx.Done()` 분기와 `apps/node/internal/adapters/cli/oneshot.go:96-104`의 동일 분기는 timeout으로 인한 컨텍스트 종료와 사용자 cancel을 구분하지 않고 모두 `EventTypeCancelled` + `Message: "cli execution cancelled"`로 보고합니다. 운영 시 두 사유는 대응이 다르므로 (timeout은 설정 조정, user-cancel은 워크플로 변경) `Message`/`Error` 필드에 사유를 명시해 구분 가능하게 합니다. ### [REFACTOR-1] context 종료 사유를 cancel 이벤트에 반영 #### 문제 - `persistent.go:65-74`: `case <-ctx.Done():`에서 항상 `Message: "cli execution cancelled"`로 emit. - `oneshot.go:96-103`: 동일하게 `Message: "cli execution cancelled"`. `context.Canceled`와 `context.DeadlineExceeded`를 구분하지 않는다. #### 해결 방법 각 분기에서 `ctx.Err()`를 보고 사유를 분기한다. ```go func cancelEventForContext(err error) (msg string) { switch { case errors.Is(err, context.DeadlineExceeded): return "timeout" case errors.Is(err, context.Canceled): return "user-cancel" default: return "context-done" } } ``` `persistent.go`: ```go case <-ctx.Done(): drainSessionUntilIdle(sess.output, idleTimeout, c.logger, sess.key) _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(), }) return runtime.ErrRunCancelled ``` `oneshot.go`의 `cmd.Wait()` 실패 후 `if ctx.Err() != nil` 분기도 동일하게: ```go _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: cancelEventForContext(ctx.Err()), Timestamp: time.Now(), }) return combinedOutput(), runtime.ErrRunCancelled ``` `cancelEventForContext` 헬퍼는 `apps/node/internal/adapters/cli/cli.go` 또는 새 파일 `apps/node/internal/adapters/cli/cancel.go`에 둔다. 본 계획은 `cli.go`에 둔다. > 호환성: edge console (`apps/edge/cmd/edge/console.go:212-215`)이 cancel 이벤트의 `Message`를 인쇄에만 사용하므로 동작 깨짐 없음. 다만 노출되는 텍스트가 바뀌므로 사용자 안내 문서/리드미는 따로 갱신하지 않는다(현재 README가 해당 문구를 명시하지 않음). #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/cli.go`에 `cancelEventForContext` 헬퍼를 추가한다. - [ ] `apps/node/internal/adapters/cli/persistent.go`의 `ctx.Done()` 분기 `Message`를 헬퍼 호출로 교체한다. - [ ] `apps/node/internal/adapters/cli/oneshot.go`의 cancel emit `Message`를 헬퍼 호출로 교체한다. - [ ] `errors`/`context` import가 두 파일에 모두 있는지 확인한다. #### 테스트 작성 - `TestCancelEventForContext_DeadlineMapsToTimeout`: `cancelEventForContext(context.DeadlineExceeded) == "timeout"` 단언. - `TestCancelEventForContext_CanceledMapsToUserCancel`: `context.Canceled` → `"user-cancel"` 단언. - `TestExecutePersistent_TimeoutEmitsTimeoutMessage`: 짧은 deadline ctx로 persistent 실행 → 마지막 cancel 이벤트의 `Message == "timeout"` 단언. - `TestExecuteOneShot_UserCancelEmitsUserCancelMessage`: 명시적 `cancel()` 호출 후 cancel 이벤트 `Message == "user-cancel"` 단언. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/... ``` 예상 결과: 신규 케이스 PASS. ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/node/internal/adapters/cli/cli.go` | REFACTOR-1 | | `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-1 | | `apps/node/internal/adapters/cli/oneshot.go` | REFACTOR-1 | | `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` 또는 적절한 위치 | REFACTOR-1 | ## 최종 검증 ```bash go build ./... go test ./apps/node/... ``` 예상 결과: 모든 테스트 PASS, cancel 이벤트의 `Message`가 `timeout` / `user-cancel` / `context-done` 셋 중 하나로 emit됨.