iop/apps/node/internal/adapters/cli/cli.go
toki 7b16b3243c 수정: CLI timeout/cancel 후 session 오염과 Registry stop error 처리 개선
- CLI persistent 실행 중 ctx.Done() 발생 시 session을 정리하여 늦은 출력이
  다음 run에 섞이는 문제 해결
- CLI Execute에서 mutex lock/unlock 구조를 개선하여 race condition 방지
- Registry.Stop이 모든 lifecycle adapter의 Stop을 시도하고, errors.Join 대신
  first error 패턴으로 변경하여 한 adapter의 실패가 다른 adapter 정리를 막지 않도록 수정
- timeout 후 session 오염 방지 회귀 테스트 추가
- registry stop failure 후에도 preceding adapter stop이 호출되는 테스트 추가
- CLI partial profile rollback 테스트 강화
2026-05-03 14:45:18 +09:00

417 lines
10 KiB
Go

// Package cli provides an Adapter that runs external CLI tools as inference
// backends. Profiles (claude, gemini, codex, opencode) are configured in
// configs/node.yaml and select the command + args to execute.
package cli
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"time"
"github.com/creack/pty"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
"iop/packages/config"
)
const Name = "cli"
// known profiles — must match CLI tool names
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
type cliOutput struct {
line string
}
type profileSession struct {
name string
profile config.CLIProfileConf
cmd *exec.Cmd
input io.Writer
output <-chan cliOutput
done <-chan error
closeFn func() error
mu sync.Mutex
}
type CLI struct {
mu sync.Mutex
profiles map[string]config.CLIProfileConf
sessions map[string]*profileSession
logger *zap.Logger
}
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
return &CLI{
profiles: cfg.Profiles,
sessions: make(map[string]*profileSession),
logger: logger,
}
}
func (c *CLI) Name() string { return Name }
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
models := make([]string, 0, len(c.profiles))
for name := range c.profiles {
models = append(models, name)
}
// Always advertise known profiles even if not in config yet.
for _, p := range knownProfiles {
if _, ok := c.profiles[p]; !ok {
models = append(models, p)
}
}
return runtime.Capabilities{
AdapterName: Name,
Models: models,
MaxConcurrency: 4,
}, nil
}
func (c *CLI) Start(ctx context.Context) error {
for name, profile := range c.profiles {
if !profile.Persistent {
continue
}
sess, err := startProfileSession(ctx, name, profile, c.logger)
if err != nil {
// roll back already-started sessions before returning
_ = c.Stop(context.Background())
c.sessions = make(map[string]*profileSession)
return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
}
c.sessions[name] = sess
c.logger.Info("cli adapter: persistent session started", zap.String("profile", name))
}
return nil
}
func (c *CLI) Stop(_ context.Context) error {
var firstErr error
for name, sess := range c.sessions {
if err := sess.closeFn(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q: %w", name, err)
}
if sess.cmd.Process != nil {
_ = sess.cmd.Process.Kill()
}
}
c.sessions = make(map[string]*profileSession)
return firstErr
}
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
profile, ok := c.profiles[spec.Model]
if !ok {
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
}
if profile.Persistent {
return c.executePersistent(ctx, spec, profile, sink)
}
return c.executeOneShot(ctx, spec, profile, sink)
}
func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
prompt := extractPrompt(spec.Input)
args := append(append([]string{}, profile.Args...), prompt)
cmd := exec.CommandContext(ctx, profile.Command, args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("cli adapter: stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("cli adapter: stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
scanner := bufio.NewScanner(stdout)
outputTokens := 0
for scanner.Scan() {
line := scanner.Text()
outputTokens += len(strings.Fields(line))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: line + "\n",
Timestamp: time.Now(),
})
}
var errBuf strings.Builder
errScanner := bufio.NewScanner(stderr)
for errScanner.Scan() {
errBuf.WriteString(errScanner.Text())
errBuf.WriteByte('\n')
}
if err := cmd.Wait(); err != nil {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeError,
Error: fmt.Sprintf("command failed: %v — %s", err, errBuf.String()),
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: command exited with error: %w", err)
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "cli execution complete",
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
}
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("cli adapter: %s", msg)
}
func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
sess, ok := c.sessions[spec.Model]
if !ok {
return fmt.Errorf("cli adapter: no persistent session for profile %q", spec.Model)
}
sess.mu.Lock()
prompt := extractPrompt(spec.Input)
idleTimeout := time.Duration(profile.ResponseIdleTimeoutMS) * time.Millisecond
if idleTimeout <= 0 {
idleTimeout = 1500 * time.Millisecond
}
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
if _, err := fmt.Fprintf(sess.input, "%s\n", prompt); err != nil {
sess.mu.Unlock()
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
}
// idle timer is nil until first output arrives (REVIEW_API-1)
var idleTimer *time.Timer
var idleC <-chan time.Time
defer func() {
if idleTimer != nil {
idleTimer.Stop()
}
}()
outputTokens := 0
for {
select {
case <-ctx.Done():
sess.mu.Unlock()
c.mu.Lock()
_ = c.Stop(context.Background())
c.mu.Unlock()
return ctx.Err()
case out, ok := <-sess.output:
if !ok {
// output channel closed means process exited — treat as failure (REVIEW_API-2)
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
}
outputTokens += len(strings.Fields(out.line))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: out.line + "\n",
Timestamp: time.Now(),
})
// arm timer on first output; reset on subsequent outputs (REVIEW_API-1)
if idleTimer == nil {
idleTimer = time.NewTimer(idleTimeout)
idleC = idleTimer.C
} else {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(idleTimeout)
}
case <-idleC:
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "cli execution complete",
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
case err := <-sess.done:
// persistent session process exit is always a failure (REVIEW_API-2)
msg := "persistent session process exited"
if err != nil {
msg = fmt.Sprintf("persistent session process exited: %v", err)
}
return emitRuntimeError(ctx, sink, spec.RunID, msg)
}
}
}
func startProfileSession(_ context.Context, name string, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) {
if profile.Command == "" {
return nil, fmt.Errorf("profile %q has no command", name)
}
outputCh := make(chan cliOutput, 1024)
doneCh := make(chan error, 1)
var input io.Writer
var closeFn func() error
var cmd *exec.Cmd
if profile.Terminal {
cmd = exec.Command(profile.Command, profile.Args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
input = ptmx
closeFn = ptmx.Close
go func() {
scanner := bufio.NewScanner(ptmx)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
outputCh <- cliOutput{line: line}
}
// send to buffered doneCh before closing outputCh so the startup
// drain's non-blocking doneCh check is race-free
doneCh <- cmd.Wait()
close(outputCh)
}()
} else {
cmd = exec.Command(profile.Command, profile.Args...)
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = stdin.Close()
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return nil, fmt.Errorf("start: %w", err)
}
input = stdin
closeFn = stdin.Close
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
outputCh <- cliOutput{line: scanner.Text()}
}
doneCh <- cmd.Wait()
close(outputCh)
}()
}
if profile.StartupIdleTimeoutMS > 0 {
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, name)
}
// check if process already exited during startup drain (REVIEW_API-2)
select {
case err := <-doneCh:
_ = closeFn()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
if err == nil {
return nil, fmt.Errorf("process exited during startup")
}
return nil, fmt.Errorf("process exited during startup: %w", err)
default:
}
return &profileSession{
name: name,
profile: profile,
cmd: cmd,
input: input,
output: outputCh,
done: doneCh,
closeFn: closeFn,
}, nil
}
func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, name string) {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case _, ok := <-outputCh:
if !ok {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
logger.Debug("cli adapter: startup drain", zap.String("profile", name))
case <-timer.C:
return
}
}
}
func extractPrompt(input map[string]any) string {
if input == nil {
return ""
}
if v, ok := input["prompt"]; ok {
if s, ok := v.(string); ok {
return s
}
}
return fmt.Sprintf("%v", input)
}