218 lines
8.6 KiB
Markdown
218 lines
8.6 KiB
Markdown
<!-- task=05_cli_persistent_explicit_complete plan=0 tag=REFACTOR -->
|
|
|
|
# Persistent CLI 세션의 명시적 종료 이벤트 도입
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/05_cli_persistent_explicit_complete/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다.
|
|
|
|
## 배경
|
|
|
|
`apps/node/internal/adapters/cli/persistent.go:104-114`의 persistent 분기는 출력이 `idleTimeout`(기본 1500ms) 동안 도착하지 않으면 응답이 끝났다고 판정해 `EventTypeComplete`를 emit합니다. 모델이 잠시 멈추는 경우 false-complete가 발생할 수 있고, 응답이 도중에 잘려도 사용자는 알 수 없습니다. 본 작업은 가능한 곳은 명시적 종료 마커를 사용해 응답 종료를 확실히 검출하고, 마커가 없는 CLI에 대해서만 idle-timeout fallback을 유지하는 구조로 바꿉니다.
|
|
|
|
이번 작업은 다음 두 가지를 포함합니다.
|
|
|
|
1. CLI 프로파일에 `completion_marker` 명세를 도입하고, persistent 어댑터가 그 마커를 보면 즉시 `complete`를 emit하도록 한다.
|
|
2. 마커가 정의되지 않은 프로파일은 기존 idle-timeout 동작을 유지하되, 발생 시 `EventTypeComplete`의 `Message`에 `"idle-timeout"` 사유를 명시해 운영 로그에서 구분 가능하게 한다.
|
|
|
|
> 정합성 메모: 현재 사용 중인 persistent 프로파일의 종료 마커를 직접 확인할 수 없는 경우, fallback 경로(idle-timeout + reason)를 항상 동작 가능하게 유지한다. 가능한 마커 후보는 `completion_marker.line`(정확 일치) 와 `completion_marker.regex`(정규식) 둘 다 지원한다.
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
1. `[REFACTOR-1]` `CLIProfileConf`에 `completion_marker` 구성 추가.
|
|
2. `[REFACTOR-2]` persistent 실행 루프에 마커 매칭 분기 도입.
|
|
3. `[REFACTOR-3]` idle-timeout fallback에서 사유를 명시.
|
|
|
|
### [REFACTOR-1] CLI 프로파일에 completion_marker 추가
|
|
|
|
#### 문제
|
|
|
|
`packages/config/config.go:106-119`의 `CLIProfileConf`에는 응답 종료를 식별할 명시적 필드가 없다. 현재 persistent 종료는 `ResponseIdleTimeoutMS` 단일 신호만 사용한다.
|
|
|
|
#### 해결 방법
|
|
|
|
```go
|
|
type CLIProfileConf struct {
|
|
// 기존 필드들...
|
|
CompletionMarker CompletionMarkerConf `mapstructure:"completion_marker" yaml:"completion_marker"`
|
|
}
|
|
|
|
type CompletionMarkerConf struct {
|
|
Line string `mapstructure:"line" yaml:"line"` // exact-match termination line
|
|
Regex string `mapstructure:"regex" yaml:"regex"` // regex termination match
|
|
}
|
|
|
|
func (m CompletionMarkerConf) Empty() bool { return m.Line == "" && m.Regex == "" }
|
|
```
|
|
|
|
YAML 예시(추가 필요할 때만 사용):
|
|
|
|
```yaml
|
|
my-agent:
|
|
completion_marker:
|
|
line: "<<END_OF_RESPONSE>>"
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `packages/config/config.go`에 `CompletionMarkerConf` 타입과 `CLIProfileConf.CompletionMarker` 필드를 추가한다.
|
|
- [ ] `packages/config/config_test.go`에 yaml 입력에서 `completion_marker.line` / `completion_marker.regex` 모두 정상 unmarshal되는 케이스를 추가한다.
|
|
- [ ] `cli_profile_proto_message` 작업이 머지된 후라면 `proto/iop/runtime.proto`의 `CLIProfileConfig`에도 동일 필드(중첩 메시지)를 추가하고 `make proto`.
|
|
|
|
#### 테스트 작성
|
|
|
|
`TestCompletionMarkerConf_RegexAndLine`: line/regex 둘 다 채워진 경우, 둘 다 비어있는 경우(`Empty() == true`)를 단언.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./packages/config/...
|
|
```
|
|
|
|
예상 결과: 신규 케이스 PASS.
|
|
|
|
### [REFACTOR-2] persistent 루프에서 명시적 마커 매칭
|
|
|
|
#### 문제
|
|
|
|
`apps/node/internal/adapters/cli/persistent.go:75-103` (`case out, ok := <-sess.output:`) 에서 매 출력마다 idle 타이머만 갱신한다. 마커 라인이 들어와도 인식하지 못하고, 다음 idle window가 도래해야만 `complete`를 emit한다.
|
|
|
|
#### 해결 방법
|
|
|
|
매 출력 라인을 emit한 직후 `profile.CompletionMarker`와 매칭해 명시적 종료를 결정한다. 매처는 한 번만 컴파일해 세션에 캐시한다.
|
|
|
|
```go
|
|
type completionMatcher struct {
|
|
line string
|
|
re *regexp.Regexp
|
|
}
|
|
|
|
func newCompletionMatcher(m config.CompletionMarkerConf) (completionMatcher, error) {
|
|
var cm completionMatcher
|
|
cm.line = m.Line
|
|
if m.Regex != "" {
|
|
re, err := regexp.Compile(m.Regex)
|
|
if err != nil {
|
|
return completionMatcher{}, fmt.Errorf("completion_marker regex: %w", err)
|
|
}
|
|
cm.re = re
|
|
}
|
|
return cm, nil
|
|
}
|
|
|
|
func (m completionMatcher) match(line string) bool {
|
|
if m.line != "" && line == m.line {
|
|
return true
|
|
}
|
|
if m.re != nil && m.re.MatchString(line) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
```
|
|
|
|
`executePersistent` 본체:
|
|
|
|
```go
|
|
matcher, err := newCompletionMatcher(profile.CompletionMarker)
|
|
if err != nil {
|
|
return emitRuntimeError(ctx, sink, spec.RunID, err.Error())
|
|
}
|
|
```
|
|
|
|
`case out, ok := <-sess.output:` 분기에서 emit 직후:
|
|
|
|
```go
|
|
if matcher.match(out.line) {
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "completion-marker",
|
|
Usage: &runtime.UsageStats{InputTokens: len(strings.Fields(prompt)), OutputTokens: outputTokens},
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
```
|
|
|
|
마커가 비어있는 프로파일(`matcher.line == "" && matcher.re == nil`)은 `match`가 항상 false를 반환하므로 기존 idle-timeout 경로가 그대로 동작한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/node/internal/adapters/cli/persistent.go`에 `completionMatcher`와 `newCompletionMatcher`를 추가한다.
|
|
- [ ] `executePersistent` 진입부에서 matcher를 한 번 빌드하고, 출력 분기에서 매칭한다.
|
|
- [ ] `regexp` import를 추가한다.
|
|
|
|
#### 테스트 작성
|
|
|
|
`apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` (현 위치 또는 layout cleanup 후 위치)에 다음 케이스 추가:
|
|
|
|
- `TestExecutePersistent_CompletionMarkerLineEndsRun`: stub CLI가 `"<<END>>"` 라인을 보내면 즉시 `EventTypeComplete{Message:"completion-marker"}`이 emit되는지 단언.
|
|
- `TestExecutePersistent_CompletionMarkerRegexEndsRun`: `^DONE \d+$` 정규식 기반 마커가 일치하면 종료되는지 단언.
|
|
- `TestExecutePersistent_NoMarkerFallsBackToIdleTimeout`: 마커가 비어있을 때 기존 idle-timeout 경로가 그대로 PASS인지 단언.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/node/internal/adapters/cli/...
|
|
```
|
|
|
|
예상 결과: 신규 케이스 포함 PASS.
|
|
|
|
### [REFACTOR-3] idle-timeout fallback의 사유 표시
|
|
|
|
#### 문제
|
|
|
|
`persistent.go:104-114`의 `<-idleC` 분기는 `Message: "cli execution complete"`로 emit하므로, 명시적 마커로 끝난 경우와 idle 추정으로 끝난 경우를 운영 로그에서 구분할 수 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
idle 분기의 `Message`를 `"idle-timeout"`으로 변경한다. 마커 매칭 분기는 `"completion-marker"`를 사용한다.
|
|
|
|
```go
|
|
case <-idleC:
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "idle-timeout",
|
|
Usage: &runtime.UsageStats{
|
|
InputTokens: len(strings.Fields(prompt)),
|
|
OutputTokens: outputTokens,
|
|
},
|
|
Timestamp: time.Now(),
|
|
})
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/node/internal/adapters/cli/persistent.go`의 idle 분기 `Message`를 `"idle-timeout"`으로 변경한다.
|
|
- [ ] 기존 단위 테스트 중 `Message`를 단언하던 케이스가 있다면 같이 갱신한다.
|
|
|
|
#### 테스트 작성
|
|
|
|
`TestExecutePersistent_IdleTimeoutMessageReason`: idle 분기로 종료될 때 `event.Message == "idle-timeout"`임을 단언.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/node/internal/adapters/cli/... -run Persistent
|
|
```
|
|
|
|
예상 결과: PASS.
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `packages/config/config.go` | REFACTOR-1 |
|
|
| `packages/config/config_test.go` | REFACTOR-1 |
|
|
| `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-2, REFACTOR-3 |
|
|
| `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` (또는 현 위치) | REFACTOR-2, REFACTOR-3 |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./...
|
|
```
|
|
|
|
예상 결과: 모든 테스트 PASS, persistent 종료 사유가 marker/idle-timeout으로 구분 가능.
|