返回 DeepSeek-Reasonix
runner.go
根目录 / internal / shellrun / runner.go
1 // Package shellrun provides a shared foreground shell runner used by the model
2 // bash tool and the user !command path. It classifies exits, collects a bounded
3 // output tail, and keeps combined stdout/stderr model-visible output intact.
4 package shellrun
5
6 import (
7 "bytes"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os/exec"
13 "sync"
14 "time"
15
16 "reasonix/internal/proc"
17 "reasonix/internal/tool"
18 )
19
20 // DefaultWaitDelay mirrors the bash tool's child-process wait grace.
21 const DefaultWaitDelay = 5 * time.Second
22
23 var errForegroundTimeout = errors.New("shell foreground timeout")
24
25 // Request describes one foreground shell launch. Argv must already include the
26 // interpreter and any sandbox wrapping; Command is only for diagnostics.
27 type Request struct {
28 Argv []string
29 Dir string
30 Env []string
31 Timeout time.Duration
32 WaitDelay time.Duration
33 CommandPreview string
34 ShellKind string
35 ShellPath string
36 Source string
37 Track bool
38 PreserveWaitDelay bool
39 // Progress receives live combined output chunks (optional).
40 Progress func(chunk string)
41 // Run is optional; tests inject a process runner. When nil, proc.RunCommand.
42 Run func(ctx context.Context, cmd *exec.Cmd, opts proc.RunOptions) (*proc.TrackedCommand, error)
43 }
44
45 // Result is the structured outcome of a foreground run.
46 type Result struct {
47 Combined string
48 // OutputTail is the bounded tail of combined output, populated only when the
49 // run did not complete successfully. Stdout and stderr share one pipe so the
50 // model-visible ordering is preserved, which makes a stderr-only tail
51 // impossible; in practice the last bytes before a failure are the diagnosis.
52 OutputTail string
53 ExitCode *int
54 Started bool
55 State string
56 FailurePhase string
57 Err error
58 Tracked *proc.TrackedCommand
59 Cmd *exec.Cmd
60 }
61
62 // RunForeground starts the process, captures combined stdout/stderr with a
63 // lock-safe collector, and classifies timeout / cancel / launch / execution
64 // failures. Combined output is always returned so callers can feed the model.
65 func RunForeground(ctx context.Context, req Request) Result {
66 if len(req.Argv) == 0 {
67 return Result{
68 State: tool.ShellStateFailed,
69 FailurePhase: tool.ShellPhaseLaunch,
70 Err: fmt.Errorf("empty argv"),
71 }
72 }
73 waitDelay := req.WaitDelay
74 if waitDelay <= 0 {
75 waitDelay = DefaultWaitDelay
76 }
77 runCtx := ctx
78 var cancel context.CancelFunc
79 if req.Timeout > 0 {
80 runCtx, cancel = context.WithTimeoutCause(ctx, req.Timeout, errForegroundTimeout)
81 defer cancel()
82 }
83
84 cmd := exec.CommandContext(runCtx, req.Argv[0], req.Argv[1:]...)
85 cmd.Dir = req.Dir
86 cmd.Env = req.Env
87 cmd.WaitDelay = waitDelay
88
89 collector := newOutputCollector(tool.OutputTailMaxBytes)
90 var writers []io.Writer
91 writers = append(writers, collector.combined, collector.tail)
92 if req.Progress != nil {
93 writers = append(writers, &progressWriter{emit: req.Progress})
94 }
95 // Stdout and Stderr must stay the *same* writer value: os/exec then hands the
96 // child a single pipe, so the two streams interleave in the order the child
97 // wrote them and only one copy goroutine calls Progress. Two MultiWriters
98 // would mean two pipes, and combined output would be reordered per stream.
99 // The bounded tail therefore covers combined output rather than stderr only;
100 // failing commands routinely report on stdout, so the tail stays useful.
101 w := io.MultiWriter(writers...)
102 cmd.Stdout = w
103 cmd.Stderr = w
104
105 run := req.Run
106 if run == nil {
107 run = proc.RunCommand
108 }
109 source := req.Source
110 if source == "" {
111 source = "shellrun"
112 }
113 tracked, err := run(runCtx, cmd, proc.RunOptions{
114 Track: req.Track,
115 CancelWaitGrace: waitDelay + time.Second,
116 Source: source,
117 ShellKind: req.ShellKind,
118 ShellPath: req.ShellPath,
119 CommandPreview: req.CommandPreview,
120 })
121
122 out := Result{
123 Combined: collector.combined.String(),
124 OutputTail: collector.tailString(),
125 Started: processStarted(cmd, err),
126 Tracked: tracked,
127 Cmd: cmd,
128 }
129
130 if req.PreserveWaitDelay && runCtx.Err() == nil && errors.Is(err, exec.ErrWaitDelay) {
131 err = nil
132 }
133
134 // Timeout takes precedence when the tool-local deadline fired.
135 if errors.Is(context.Cause(runCtx), errForegroundTimeout) {
136 out.State = tool.ShellStateTimedOut
137 out.FailurePhase = tool.ShellPhaseTimeout
138 out.ExitCode = exitCodeFromErr(err)
139 out.Err = fmt.Errorf("command timed out (> %s)", req.Timeout)
140 return out
141 }
142 // Parent cancellation (user stop / session cancel).
143 if err != nil && (errors.Is(err, context.Canceled) || errors.Is(runCtx.Err(), context.Canceled) || isCanceledWait(err)) {
144 out.State = tool.ShellStateCancelled
145 out.FailurePhase = tool.ShellPhaseCancellation
146 out.ExitCode = exitCodeFromErr(err)
147 if cause := context.Cause(runCtx); cause != nil {
148 out.Err = cause
149 } else {
150 out.Err = err
151 }
152 return out
153 }
154 if err == nil {
155 code := 0
156 out.ExitCode = &code
157 out.State = tool.ShellStateCompleted
158 // The tail exists to explain a failure. Dropping it on success keeps
159 // successful runs from persisting up to 16 KiB of ordinary stdout into
160 // every session record and tool card.
161 out.OutputTail = ""
162 return out
163 }
164 if code := exitCodeFromErr(err); code != nil {
165 out.ExitCode = code
166 out.Started = true
167 out.State = tool.ShellStateFailed
168 out.FailurePhase = tool.ShellPhaseExecution
169 out.Err = fmt.Errorf("command exited: %w", err)
170 return out
171 }
172 // Process never produced an exit status — launch / dependency style failure.
173 out.State = tool.ShellStateFailed
174 if out.Started {
175 out.FailurePhase = tool.ShellPhaseExecution
176 } else {
177 out.FailurePhase = tool.ShellPhaseLaunch
178 }
179 out.Err = err
180 return out
181 }
182
183 func processStarted(cmd *exec.Cmd, err error) bool {
184 if cmd != nil && cmd.Process != nil {
185 return true
186 }
187 // ExitError means the process ran.
188 var ee *exec.ExitError
189 return errors.As(err, &ee)
190 }
191
192 func exitCodeFromErr(err error) *int {
193 if err == nil {
194 code := 0
195 return &code
196 }
197 var ee *exec.ExitError
198 if errors.As(err, &ee) {
199 code := ee.ExitCode()
200 return &code
201 }
202 return nil
203 }
204
205 func isCanceledWait(err error) bool {
206 var c proc.CanceledWaitError
207 return errors.As(err, &c)
208 }
209
210 // outputCollector owns the combined buffer and a bounded tail ring. Writes stay
211 // serialized behind one mutex so a caller that does wire two pipes cannot race
212 // on the Buffer.
213 type outputCollector struct {
214 mu sync.Mutex
215 combined *lockedBuffer
216 tail *tailWriter
217 }
218
219 func newOutputCollector(tailLimit int) *outputCollector {
220 c := &outputCollector{}
221 c.combined = &lockedBuffer{mu: &c.mu}
222 c.tail = &tailWriter{mu: &c.mu, limit: tailLimit}
223 return c
224 }
225
226 func (c *outputCollector) tailString() string {
227 c.mu.Lock()
228 defer c.mu.Unlock()
229 return string(c.tail.buf)
230 }
231
232 // lockedBuffer is a bytes.Buffer guarded by an external mutex so MultiWriter
233 // concurrent writes from stdout and stderr stay race-free.
234 type lockedBuffer struct {
235 mu *sync.Mutex
236 buf bytes.Buffer
237 }
238
239 func (b *lockedBuffer) Write(p []byte) (int, error) {
240 b.mu.Lock()
241 defer b.mu.Unlock()
242 return b.buf.Write(p)
243 }
244
245 func (b *lockedBuffer) String() string {
246 b.mu.Lock()
247 defer b.mu.Unlock()
248 return b.buf.String()
249 }
250
251 type tailWriter struct {
252 mu *sync.Mutex
253 limit int
254 buf []byte
255 }
256
257 func (w *tailWriter) Write(p []byte) (int, error) {
258 w.mu.Lock()
259 defer w.mu.Unlock()
260 w.buf = append(w.buf, p...)
261 if w.limit > 0 && len(w.buf) > w.limit {
262 w.buf = append([]byte(nil), w.buf[len(w.buf)-w.limit:]...)
263 }
264 return len(p), nil
265 }
266
267 type progressWriter struct{ emit func(string) }
268
269 func (w *progressWriter) Write(p []byte) (int, error) {
270 if w.emit != nil && len(p) > 0 {
271 w.emit(string(p))
272 }
273 return len(p), nil
274 }
275
275 lines GO