- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리 - 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16) - finite 재연결 시도 고갈 시 노드 종료 - CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
189 lines
5.7 KiB
Go
189 lines
5.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
|
|
prompt := extractPrompt(spec.Input)
|
|
args := append([]string{}, profile.Args...)
|
|
if prompt != "" {
|
|
args = append(args, prompt)
|
|
}
|
|
_, err := c.executeCommand(ctx, spec, profile, args, prompt, sink)
|
|
return err
|
|
}
|
|
|
|
func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, args []string, prompt string, sink runtime.EventSink) (string, error) {
|
|
cmd, err := buildCmd(ctx, profile.Command, args, profile.Env, spec.Workspace)
|
|
if err != nil {
|
|
return "", emitReturnedError(ctx, sink, spec.RunID, err)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return "", emitReturnedError(ctx, sink, spec.RunID, fmt.Errorf("cli adapter: stdout pipe: %w", err))
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return "", emitReturnedError(ctx, sink, spec.RunID, fmt.Errorf("cli adapter: stderr pipe: %w", err))
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
// The context can be cancelled or time out before the process starts,
|
|
// which surfaces as a start error. Honor the cancellation contract with a
|
|
// single cancelled event and ErrRunCancelled instead of a start error.
|
|
if ctx.Err() != nil {
|
|
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeCancelled,
|
|
Message: cancelEventForContext(ctx.Err()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return "", runtime.ErrRunCancelled
|
|
}
|
|
return "", emitReturnedError(ctx, sink, spec.RunID, fmt.Errorf("cli adapter: start %q: %w", profile.Command, err))
|
|
}
|
|
|
|
// Closing the stdout/stderr pipes on cancellation unblocks pending reads
|
|
// promptly. CommandContext only signals the direct child, so a forked
|
|
// grandchild that keeps the pipes open would otherwise stall the read until
|
|
// it exits on its own; closing the reader ends the read-vs-wait race.
|
|
readsDone := make(chan struct{})
|
|
defer close(readsDone)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = stdout.Close()
|
|
_ = stderr.Close()
|
|
case <-readsDone:
|
|
}
|
|
}()
|
|
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeStart,
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
var errBuf strings.Builder
|
|
var outBuf strings.Builder
|
|
combinedOutput := func() string {
|
|
return outBuf.String() + errBuf.String()
|
|
}
|
|
stderrDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.Copy(&errBuf, stderr)
|
|
stderrDone <- err
|
|
}()
|
|
|
|
var (
|
|
outputTokens int
|
|
readErr error
|
|
)
|
|
if reg, ok := jsonEmitters[profile.OutputFormat]; ok {
|
|
outputTokens, readErr = driveJSONLines(ctx, stdout, sink, spec.RunID, &outBuf, reg.emitter, reg.scanBufMax)
|
|
} else {
|
|
outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
|
|
}
|
|
if readErr != nil {
|
|
_ = <-stderrDone
|
|
_ = cmd.Wait()
|
|
// A context cancellation can close the stdout pipe before cmd.Wait()
|
|
// observes the process exit, surfacing as a read error. Treat that as a
|
|
// cancellation so the user-cancel/timeout contract wins over the generic
|
|
// read-stdout error, emitting exactly one cancelled event.
|
|
if ctx.Err() != nil {
|
|
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeCancelled,
|
|
Message: cancelEventForContext(ctx.Err()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), runtime.ErrRunCancelled
|
|
}
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("read stdout: %v", readErr),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: read stdout: %w", readErr)
|
|
}
|
|
|
|
waitErr := cmd.Wait()
|
|
stderrErr := <-stderrDone
|
|
if waitErr != nil {
|
|
if ctx.Err() != nil {
|
|
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeCancelled,
|
|
Message: cancelEventForContext(ctx.Err()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), runtime.ErrRunCancelled
|
|
}
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("command failed: %v — %s", waitErr, errBuf.String()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: command exited with error: %w", waitErr)
|
|
}
|
|
if stderrErr != nil && !errors.Is(stderrErr, os.ErrClosed) {
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("read stderr: %v", stderrErr),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return combinedOutput(), fmt.Errorf("cli adapter: read stderr: %w", stderrErr)
|
|
}
|
|
|
|
return combinedOutput(), 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 emitStdoutChunks(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
|
buf := make([]byte, 4096)
|
|
outputTokens := 0
|
|
|
|
for {
|
|
n, err := stdout.Read(buf)
|
|
if n > 0 {
|
|
delta := string(buf[:n])
|
|
outBuf.WriteString(delta)
|
|
outputTokens += len(strings.Fields(delta))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: delta,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
return outputTokens, nil
|
|
}
|
|
if err != nil {
|
|
return outputTokens, err
|
|
}
|
|
}
|
|
}
|