Files
2026-08-14 23:41:57 +08:00

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()
}
}