iop/apps/node/internal/adapters/cli/persistent.go
toki f01c7e5ecd refactor(node): CLI 실행 경계를 정리한다
터미널 세션 core와 CLI mode executor 경계를 분리해 후속 remote terminal bridge가 재사용할 내부 실행 기반을 만든다.

adapter 기본값과 vLLM experimental surface도 명시해 production 실행 경로가 mock fallback이나 미구현 실행으로 숨지 않게 한다.
2026-06-06 14:57:49 +09:00

655 lines
18 KiB
Go

package cli
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"regexp"
"strings"
"time"
"unicode"
"go.uber.org/zap"
"iop/apps/node/internal/adapters/cli/status"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
const (
terminalInputDelay = 2 * time.Millisecond
terminalRows = 80
terminalCols = 240
)
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)
}
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
}
func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
sess, err := e.resolveSession(ctx, spec, profile)
if err != nil {
return err
}
matcher, err := newCompletionMatcher(profile.CompletionMarker)
if err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, err.Error())
}
sess.mu.Lock()
defer sess.mu.Unlock()
prompt := extractPrompt(spec.Input)
idleTimeout := time.Duration(profile.ResponseIdleTimeoutMS) * time.Millisecond
if idleTimeout <= 0 {
idleTimeout = 1500 * time.Millisecond
}
targetName := cliTargetName(spec)
waitForFilteredMessage := profile.Terminal && isClaudeTerminalProfile(targetName, profile)
baselineAssistant := ""
if waitForFilteredMessage {
baselineAssistant, _ = latestClaudeAssistantMessageFromCleanOutput(cleanClaudeTerminalOutput(sess.getTail()))
}
outputFilter := newPersistentOutputFilter(targetName, profile, prompt, baselineAssistant)
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
})
if sess.core != nil {
if err := sess.core.WritePrompt(ctx, prompt); err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
}
} else {
if err := writePrompt(ctx, sess.input, prompt, profile); err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
}
}
var idleTimer *time.Timer
var idleC <-chan time.Time
defer func() {
if idleTimer != nil {
idleTimer.Stop()
}
}()
outputTokens := 0
var markerBuf strings.Builder
claudePromptReplayCount := 0
for {
select {
case <-ctx.Done():
// Drain output so the process remains usable for the next run.
drainSessionUntilIdle(sess.output, idleTimeout, e.cli.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
case out, ok := <-sess.output:
if !ok {
err := drainPersistentDone(sess)
return e.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err)
}
if waitForFilteredMessage {
if msg, cancelled := claudeTerminalCancelMessage(sess.getTail()); cancelled {
e.removePersistentSession(sess)
_ = closeProfileSession(context.Background(), sess)
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeCancelled,
Message: msg,
Timestamp: time.Now(),
})
return fmt.Errorf("%w: %s", runtime.ErrRunCancelled, msg)
}
}
if delta := outputFilter.Filter(out.text); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
markerLines := []string(nil)
if out.markerLine != "" {
markerLines = append(markerLines, out.markerLine)
} else {
markerLines = consumeCompleteLines(&markerBuf, out.text)
}
if matcher.matchAny(markerLines) {
if delta := outputFilter.Flush(); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
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(),
})
}
if idleTimer == nil {
idleTimer = time.NewTimer(idleTimeout)
idleC = idleTimer.C
} else {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(idleTimeout)
}
case <-idleC:
if delta := outputFilter.Flush(); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
} else if waitForFilteredMessage && !outputFilter.HasOutput() {
if claudePromptReplayCount == 0 && claudeTerminalReadyForInput(sess.getTail()) {
claudePromptReplayCount++
e.cli.logger.Info("cli adapter: replaying claude prompt after ready screen", zap.String("target", targetName), zap.String("session", sess.key.sessionID))
var err error
if sess.core != nil {
err = sess.core.WritePrompt(ctx, prompt)
} else {
err = writePrompt(ctx, sess.input, prompt, profile)
}
if err != nil {
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("replay prompt: %v", err))
}
}
idleTimer.Reset(idleTimeout)
continue
}
completeMessage := "idle-timeout"
if msg := outputFilter.CompletionMessage(); msg != "" {
completeMessage = msg
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: completeMessage,
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,
},
Timestamp: time.Now(),
})
case err := <-sess.done:
return e.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err)
}
}
}
func (m completionMatcher) matchAny(lines []string) bool {
for _, line := range lines {
if m.match(line) {
return true
}
}
return false
}
func consumeCompleteLines(buf *strings.Builder, text string) []string {
if text == "" {
return nil
}
buf.WriteString(text)
raw := buf.String()
start := 0
var lines []string
for i, r := range raw {
if r != '\n' {
continue
}
lines = append(lines, strings.TrimRight(raw[start:i], "\r"))
start = i + 1
}
if start > 0 {
buf.Reset()
buf.WriteString(raw[start:])
} else if len(raw) > 8192 {
buf.Reset()
buf.WriteString(raw[len(raw)-8192:])
}
return lines
}
func promptTerminator(profile config.CLIProfileConf) string {
if profile.Terminal {
return "\r"
}
return "\n"
}
func writePrompt(ctx context.Context, input io.Writer, prompt string, profile config.CLIProfileConf) error {
if !profile.Terminal {
_, err := io.WriteString(input, prompt+promptTerminator(profile))
return err
}
for _, r := range prompt {
if _, err := io.WriteString(input, string(r)); err != nil {
return err
}
timer := time.NewTimer(terminalInputDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
_, err := io.WriteString(input, promptTerminator(profile))
return err
}
// resolveSession returns an existing session for the given key or creates one
// when SessionMode allows it.
func (e *persistentExecutor) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) {
target := cliTargetName(spec)
key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)}
e.mu.Lock()
defer e.mu.Unlock()
if sess, ok := e.sessions[key]; ok {
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID)
}
sess, err := startProfileSession(ctx, key, profile, e.cli.logger)
if err != nil {
return nil, err
}
e.sessions[key] = sess
return sess, nil
}
func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) {
if profile.Command == "" {
return nil, fmt.Errorf("target %q has no command", key.target)
}
outputCh := make(chan cliOutput, 1024)
doneCh := make(chan error, 1)
sess := &profileSession{
key: key,
name: key.target,
profile: profile,
output: outputCh,
done: doneCh,
}
var cmd *exec.Cmd
if profile.Terminal {
opts := terminalSessionOptions{
Command: profile.Command,
Args: profile.Args,
Env: profile.Env,
}
core, err := startTerminalSessionCore(context.Background(), opts)
if err != nil {
return nil, err
}
sess.core = core
sess.cmd = core.cmd
sess.input = core.input
sess.output = core.outputCh
sess.done = core.doneCh
sess.closeFn = core.Close
} 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)
}
sess.cmd = cmd
sess.input = stdin
sess.closeFn = stdin.Close
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
text := line + "\n"
sess.appendTail(text)
outputCh <- cliOutput{text: text, markerLine: line}
}
doneCh <- cmd.Wait()
close(outputCh)
}()
}
if profile.StartupIdleTimeoutMS > 0 {
drainUntilIdle(sess.output, sess.input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile)
}
select {
case err := <-sess.done:
_ = sess.closeFn()
if sess.cmd != nil && sess.cmd.Process != nil {
_ = sess.cmd.Process.Kill()
}
tail := sess.getTail()
if err == nil {
if tail != "" {
return nil, fmt.Errorf("process exited during startup: %s", tail)
}
return nil, fmt.Errorf("process exited during startup")
}
if tail != "" {
return nil, fmt.Errorf("process exited during startup: %w (recent output: %q)", err, tail)
}
return nil, fmt.Errorf("process exited during startup: %w", err)
default:
}
return sess, nil
}
func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) {
timer := time.NewTimer(timeout)
defer timer.Stop()
var startupBuf strings.Builder
acceptedClaudeWorkspaceTrust := false
acceptedClaudeBypassWarning := false
for {
select {
case out, ok := <-outputCh:
if !ok {
return
}
if profile.Terminal && out.text != "" {
appendBounded(&startupBuf, out.text, 8192)
rawStr := startupBuf.String()
if !acceptedClaudeWorkspaceTrust && shouldAcceptClaudeWorkspaceTrust(rawStr) {
if _, err := io.WriteString(input, "\r"); err != nil {
logger.Warn("cli adapter: accept claude workspace trust", zap.String("target", key.target), zap.Error(err))
} else {
acceptedClaudeWorkspaceTrust = true
logger.Info("cli adapter: accepted claude workspace trust", zap.String("target", key.target))
startupBuf.Reset()
}
} else if !acceptedClaudeBypassWarning && shouldAcceptClaudeBypassWarning(rawStr) {
if _, err := io.WriteString(input, "\x1b[B\r"); err != nil {
logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err))
} else {
acceptedClaudeBypassWarning = true
logger.Info("cli adapter: accepted claude bypass warning", zap.String("target", key.target))
}
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
logger.Debug("cli adapter: startup drain", zap.String("target", key.target), zap.String("session", key.sessionID))
case <-timer.C:
return
}
}
}
func appendBounded(buf *strings.Builder, s string, max int) {
buf.WriteString(s)
raw := buf.String()
if len(raw) <= max {
return
}
buf.Reset()
buf.WriteString(raw[len(raw)-max:])
}
func shouldAcceptClaudeWorkspaceTrust(raw string) bool {
compact := compactTerminalText(raw)
return strings.Contains(compact, "quicksafetycheckisthisaproject") &&
strings.Contains(compact, "yesitrustthisfolder")
}
func shouldAcceptClaudeBypassWarning(raw string) bool {
compact := compactTerminalText(raw)
return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") &&
strings.Contains(compact, "yesiaccept")
}
func claudeTerminalCancelMessage(raw string) (string, bool) {
compact := compactTerminalText(raw)
switch {
case strings.Contains(compact, "youvehityoursessionlimit"):
if line := findClaudeTerminalLine(raw, "session limit"); line != "" {
return line, true
}
return "Claude session limit reached", true
case strings.Contains(compact, "stopandwaitforlimittoreset") && strings.Contains(compact, "upgradeyourplan"):
return "Claude session limit reached; upgrade was not selected", true
default:
return "", false
}
}
func claudeTerminalReadyForInput(raw string) bool {
return claudeScreenHasInputPrompt(status.RenderVisibleScreen(raw, terminalRows, terminalCols))
}
func findClaudeTerminalLine(raw, needle string) string {
needle = strings.ToLower(needle)
for _, line := range strings.Split(cleanClaudeTerminalOutput(raw), "\n") {
line = strings.Join(strings.Fields(line), " ")
if strings.Contains(strings.ToLower(line), needle) {
return line
}
}
return ""
}
func compactTerminalText(s string) string {
s = cleanClaudeTerminalOutput(s)
var b strings.Builder
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
continue
}
if inEscape {
if r >= '@' && r <= '~' {
inEscape = false
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, key sessionKey) {
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: cancel drain", zap.String("target", key.target), zap.String("session", key.sessionID))
case <-timer.C:
return
}
}
}
func drainPersistentDone(sess *profileSession) error {
select {
case err := <-sess.done:
return err
default:
return nil
}
}
func (e *persistentExecutor) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error {
e.removePersistentSession(sess)
cmdSummary := fmt.Sprintf("%s %s", profile.Command, strings.Join(profile.Args, " "))
tail := sess.getTail()
var msg string
if err != nil {
msg = fmt.Sprintf("persistent session process exited: %s, target=%s, session=%s, command=%q", err.Error(), targetName, sess.key.sessionID, cmdSummary)
} else {
msg = fmt.Sprintf("persistent session process exited unexpectedly: target=%s, session=%s, command=%q", targetName, sess.key.sessionID, cmdSummary)
}
if tail != "" {
msg = fmt.Sprintf("%s, recent output: %q", msg, tail)
}
return emitRuntimeError(ctx, sink, runID, msg)
}
func (e *persistentExecutor) removePersistentSession(sess *profileSession) {
e.mu.Lock()
if s, found := e.sessions[sess.key]; found && s == sess {
delete(e.sessions, sess.key)
}
e.mu.Unlock()
}
func (e *persistentExecutor) stopAllSessions(_ context.Context) error {
var firstErr error
for key, sess := range e.sessions {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err)
}
}
e.sessions = make(map[sessionKey]*profileSession)
return firstErr
}
func (e *persistentExecutor) Sessions() []sessionListEntry {
e.mu.Lock()
defer e.mu.Unlock()
snaps := make([]sessionListEntry, 0, len(e.sessions))
for k := range e.sessions {
snaps = append(snaps, sessionListEntry{"persistent", k.target, k.sessionID})
}
return snaps
}
func (e *persistentExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) {
key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)}
e.mu.Lock()
sess, ok := e.sessions[key]
if ok {
delete(e.sessions, key)
}
e.mu.Unlock()
if !ok {
return false, nil
}
err := closeProfileSession(ctx, sess)
return true, err
}
func (e *persistentExecutor) Stop(ctx context.Context) error {
e.mu.Lock()
sessionsCopy := make(map[sessionKey]*profileSession, len(e.sessions))
for key, sess := range e.sessions {
sessionsCopy[key] = sess
}
e.sessions = make(map[sessionKey]*profileSession)
e.mu.Unlock()
var firstErr error
for key, sess := range sessionsCopy {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err)
}
}
return firstErr
}