| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "os/exec" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/jobs" |
| 13 | "reasonix/internal/proc" |
| 14 | "reasonix/internal/sandbox" |
| 15 | "reasonix/internal/sessiontemp" |
| 16 | "reasonix/internal/shellrun" |
| 17 | "reasonix/internal/tool" |
| 18 | ) |
| 19 | |
| 20 | func (b bash) startBackground(ctx context.Context, p bashParams, sh sandbox.Shell, argv []string, wrapped bool, cmdEnv []string, lease *sessiontemp.Lease, releaseLease *bool, start time.Time, ex *tool.ShellExecution) (tool.DetailedResult, error) { |
| 21 | jm, ok := jobs.FromContext(ctx) |
| 22 | if !ok { |
| 23 | ex.State = tool.ShellStateNotRun |
| 24 | ex.FailurePhase = tool.ShellPhaseDependency |
| 25 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 26 | ex.DurationMs = time.Since(start).Milliseconds() |
| 27 | return tool.DetailedResult{Execution: ex}, fmt.Errorf("background execution is not available in this context") |
| 28 | } |
| 29 | |
| 30 | // The job closure owns the lease after StartForSession. It releases the |
| 31 | // generation after either process completion or a start failure. |
| 32 | jobLease := lease |
| 33 | *releaseLease = false |
| 34 | jobLabel := strings.TrimSpace(p.Description) |
| 35 | if jobLabel == "" { |
| 36 | jobLabel = commandPreview(p.Command) |
| 37 | } |
| 38 | permissionPreset := string(sandbox.PermissionPresetFrom(ctx)) |
| 39 | jobSpec := b.specForCall(ctx) |
| 40 | job := jm.StartForSession(jobs.SessionFromContext(ctx), b.Name(), jobLabel, func(jobCtx context.Context, out io.Writer) (string, error) { |
| 41 | if jobLease != nil { |
| 42 | defer jobLease.Release() |
| 43 | } |
| 44 | cmd := proc.CommandContext(jobCtx, argv[0], argv[1:]...) |
| 45 | cmd.Dir = b.workDir |
| 46 | cmd.Env = cmdEnv |
| 47 | cmd.WaitDelay = bashWaitDelay |
| 48 | capture := &boundedDiagnosticWriter{limit: 64 << 10} |
| 49 | combined := io.MultiWriter(out, capture) |
| 50 | cmd.Stdout = combined |
| 51 | cmd.Stderr = combined |
| 52 | started := time.Now() |
| 53 | tracked, runErr := runShellProcess(jobCtx, cmd, sh, p.Command, shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses)) |
| 54 | if shouldReapAfterRun(jobCtx, sh, p.Command, p.PreserveBackgroundProcesses) { |
| 55 | reapShellProcess(cmd, tracked) |
| 56 | } |
| 57 | runErr = normalizeBashRunError(jobCtx, runErr, p.PreserveBackgroundProcesses) |
| 58 | execution, classifiedErr := classifyBackgroundShellExecution(jobCtx, sh, argv, capture.String(), runErr, started) |
| 59 | if classifiedErr != nil && execution.FailurePhase == tool.ShellPhaseExecution && wrapped { |
| 60 | writeBackgroundSandboxHint(out, capture.String(), classifiedErr, p, jobSpec, permissionPreset) |
| 61 | } |
| 62 | jobs.SetExecution(jobCtx, execution) |
| 63 | return "", classifiedErr |
| 64 | }) |
| 65 | |
| 66 | msg := fmt.Sprintf("Started background job %q. It keeps running across turns; read it with job_output(job_id=%q) or stop it with job_kill(job_id=%q).", job.ID, job.ID, job.ID) |
| 67 | ex.State = tool.ShellStateBackgroundStarted |
| 68 | ex.MutationRisk = tool.ShellMutationUnknown |
| 69 | ex.DurationMs = time.Since(start).Milliseconds() |
| 70 | return tool.DetailedResult{ |
| 71 | Output: appendSessionDataHint(msg, b.guard.CommandHint(b.workDir, p.Command)), |
| 72 | Execution: ex, |
| 73 | }, nil |
| 74 | } |
| 75 | |
| 76 | func writeBackgroundSandboxHint(out io.Writer, captured string, runErr error, p bashParams, spec sandbox.Spec, permissionPreset string) { |
| 77 | hinted := appendSandboxWriteHint(captured, runErr, p, spec, permissionPreset) |
| 78 | if hint := strings.TrimPrefix(hinted, captured); hint != "" { |
| 79 | _, _ = io.WriteString(out, hint+"\n") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | type boundedDiagnosticWriter struct { |
| 84 | buf []byte |
| 85 | limit int |
| 86 | } |
| 87 | |
| 88 | func (w *boundedDiagnosticWriter) Write(p []byte) (int, error) { |
| 89 | n := len(p) |
| 90 | if w.limit <= 0 { |
| 91 | return n, nil |
| 92 | } |
| 93 | if len(p) >= w.limit { |
| 94 | w.buf = append(w.buf[:0], p[len(p)-w.limit:]...) |
| 95 | return n, nil |
| 96 | } |
| 97 | w.buf = append(w.buf, p...) |
| 98 | if overflow := len(w.buf) - w.limit; overflow > 0 { |
| 99 | copy(w.buf, w.buf[overflow:]) |
| 100 | w.buf = w.buf[:w.limit] |
| 101 | } |
| 102 | return n, nil |
| 103 | } |
| 104 | |
| 105 | func (w *boundedDiagnosticWriter) String() string { return string(w.buf) } |
| 106 | |
| 107 | func classifyBackgroundShellExecution(ctx context.Context, sh sandbox.Shell, argv []string, output string, runErr error, started time.Time) (*tool.ShellExecution, error) { |
| 108 | ex := shellrun.DescriptorFromShell(sh) |
| 109 | ex.DurationMs = time.Since(started).Milliseconds() |
| 110 | switch { |
| 111 | case ctx.Err() != nil: |
| 112 | ex.State = tool.ShellStateCancelled |
| 113 | ex.FailurePhase = tool.ShellPhaseCancellation |
| 114 | ex.MutationRisk = tool.ShellMutationMayBePartial |
| 115 | return ex, runErr |
| 116 | case runErr == nil: |
| 117 | ex.State = tool.ShellStateCompleted |
| 118 | ex.ExitCode = tool.IntPtr(0) |
| 119 | ex.MutationRisk = tool.ShellMutationMayHaveCompleted |
| 120 | return ex, nil |
| 121 | } |
| 122 | if exitErr := (*exec.ExitError)(nil); errors.As(runErr, &exitErr) { |
| 123 | ex.State = tool.ShellStateFailed |
| 124 | ex.FailurePhase = tool.ShellPhaseExecution |
| 125 | ex.ExitCode = tool.IntPtr(exitErr.ExitCode()) |
| 126 | ex.MutationRisk = tool.ShellMutationMayBePartial |
| 127 | return ex, runErr |
| 128 | } |
| 129 | ex.State = tool.ShellStateNotRun |
| 130 | ex.FailurePhase = tool.ShellPhaseLaunch |
| 131 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 132 | return ex, runErr |
| 133 | } |
| 134 |