168 lines
4.8 KiB
Go
168 lines
4.8 KiB
Go
// This file implements background bash execution: a BashJobStore holding the
|
|
// commands launched with run_in_background, and the BashJob state each one
|
|
// carries. A background job is detached from the turn context (which is canceled
|
|
// when the turn ends) and runs under its own cancelable context until it exits or
|
|
// kill_bash stops it. Its combined stdout/stderr accumulates in a buffer that
|
|
// bash_output drains incrementally, mirroring Claude Code's background shells +
|
|
// BashOutput/KillShell.
|
|
package agenttool
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// BashJobStatus is the lifecycle state of a background bash job.
|
|
type BashJobStatus string
|
|
|
|
const (
|
|
// BashRunning means the command is still executing.
|
|
BashRunning BashJobStatus = "running"
|
|
// BashExited means the command finished (successfully or not) or was killed.
|
|
BashExited BashJobStatus = "exited"
|
|
)
|
|
|
|
// BashJob is a single background command: its identity, growing combined output,
|
|
// and terminal status. All fields are guarded by mu so the running command's
|
|
// writer, bash_output reads, and kill_bash can touch it concurrently.
|
|
type BashJob struct {
|
|
// ID is the stable handle (e.g. "bash_1") bash_output/kill_bash address.
|
|
ID string
|
|
// Command is the shell command line, kept for listing/display.
|
|
Command string
|
|
// StartedAt is when the command was launched.
|
|
StartedAt time.Time
|
|
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
cursor int // bytes of buf already returned by bash_output
|
|
status BashJobStatus
|
|
exitCode int
|
|
errMsg string
|
|
finished time.Time
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// jobWriter adapts a BashJob to io.Writer so it can be a command's Stdout/Stderr;
|
|
// each write appends to the job's combined buffer under its lock.
|
|
type jobWriter struct{ job *BashJob }
|
|
|
|
func (w jobWriter) Write(p []byte) (int, error) {
|
|
w.job.mu.Lock()
|
|
w.job.buf.Write(p)
|
|
w.job.mu.Unlock()
|
|
return len(p), nil
|
|
}
|
|
|
|
// writer returns an io.Writer that appends to the job's output buffer.
|
|
func (j *BashJob) writer() jobWriter { return jobWriter{job: j} }
|
|
|
|
// finish records the command's terminal state from the error returned by
|
|
// cmd.Wait (nil = success). It is idempotent-safe to call once per job.
|
|
func (j *BashJob) finish(exitCode int, errMsg string) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
j.status = BashExited
|
|
j.exitCode = exitCode
|
|
j.errMsg = errMsg
|
|
j.finished = time.Now()
|
|
}
|
|
|
|
// kill cancels the job's context (terminating the process) and marks it exited if
|
|
// it was still running. It reports whether the job was running when called.
|
|
func (j *BashJob) kill() bool {
|
|
j.mu.Lock()
|
|
running := j.status == BashRunning
|
|
j.mu.Unlock()
|
|
if j.cancel != nil {
|
|
j.cancel()
|
|
}
|
|
return running
|
|
}
|
|
|
|
// readNew returns the output accumulated since the last read and advances the
|
|
// cursor, so successive bash_output calls stream the command's output without
|
|
// repeating what was already seen.
|
|
func (j *BashJob) readNew() string {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
all := j.buf.Bytes()
|
|
if j.cursor > len(all) {
|
|
j.cursor = len(all)
|
|
}
|
|
out := string(all[j.cursor:])
|
|
j.cursor = len(all)
|
|
return out
|
|
}
|
|
|
|
// snapshot returns the job's current status fields for reporting without exposing
|
|
// the mutex-guarded internals.
|
|
func (j *BashJob) snapshot() (status BashJobStatus, exitCode int, errMsg string) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
return j.status, j.exitCode, j.errMsg
|
|
}
|
|
|
|
// BashJobStore holds a session's background bash jobs. A single store is shared by
|
|
// the bash, bash_output and kill_bash tools so a job launched by one is visible to
|
|
// the others. It is safe for concurrent use.
|
|
type BashJobStore struct {
|
|
mu sync.Mutex
|
|
jobs map[string]*BashJob
|
|
seq int
|
|
}
|
|
|
|
// NewBashJobStore returns an empty store.
|
|
func NewBashJobStore() *BashJobStore {
|
|
return &BashJobStore{jobs: map[string]*BashJob{}}
|
|
}
|
|
|
|
// create registers a new running job for command with its cancel func, assigning
|
|
// a readable sequential id.
|
|
func (s *BashJobStore) create(command string, cancel context.CancelFunc) *BashJob {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.seq++
|
|
job := &BashJob{
|
|
ID: fmt.Sprintf("bash_%d", s.seq),
|
|
Command: command,
|
|
StartedAt: time.Now(),
|
|
status: BashRunning,
|
|
cancel: cancel,
|
|
}
|
|
s.jobs[job.ID] = job
|
|
return job
|
|
}
|
|
|
|
// Get returns the job with the given id, or (nil, false).
|
|
func (s *BashJobStore) Get(id string) (*BashJob, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
j, ok := s.jobs[id]
|
|
return j, ok
|
|
}
|
|
|
|
// List returns the jobs in creation order.
|
|
func (s *BashJobStore) List() []*BashJob {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]*BashJob, 0, len(s.jobs))
|
|
for i := 1; i <= s.seq; i++ {
|
|
if j, ok := s.jobs[fmt.Sprintf("bash_%d", i)]; ok {
|
|
out = append(out, j)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// KillAll cancels every still-running job. It is intended for session shutdown so
|
|
// background processes are not orphaned.
|
|
func (s *BashJobStore) KillAll() {
|
|
for _, j := range s.List() {
|
|
j.kill()
|
|
}
|
|
}
|