yao/agent/sandbox/v2/opencode/session.go
Max f230f1e90c feat(workspace): enhance attachment handling and execution context
- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management.
- Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments.
- Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking.
- Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency.
- Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
2026-05-07 19:32:48 +08:00

265 lines
7.3 KiB
Go

package opencode
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// session encapsulates a single OpenCode CLI execution lifecycle:
// process start, stderr collection, kill on cancel, Wait with timeout.
type session struct {
ctx context.Context
computer infra.Computer
plat platform
exec *infra.ExecStream
stderr strings.Builder
stderrMu sync.Mutex
logger *agentContext.RequestLogger
chatID string
}
func startSession(ctx context.Context, computer infra.Computer, p platform, cmd command, chatID string, logger *agentContext.RequestLogger) (*session, error) {
opts := []infra.ExecOption{infra.WithWorkDir(cmd.workDir), infra.WithEnv(cmd.env)}
logger.Info("opencode session starting: cmd=%v workDir=%s platform=%s chatID=%s",
cmd.shell, cmd.workDir, p.OS(), chatID)
execStream, err := computer.Stream(ctx, cmd.shell, opts...)
if err != nil {
return nil, fmt.Errorf("computer.Stream: %w", err)
}
// Write user message to stdin, then close. OpenCode reads the prompt
// from stdin when no positional message is given (same as Claude runner).
// Closing after write signals EOF so OpenCode begins processing.
if execStream.Stdin != nil {
if cmd.stdin != "" {
if _, err := io.WriteString(execStream.Stdin, cmd.stdin); err != nil {
logger.Warn("failed to write stdin: %v", err)
}
}
execStream.Stdin.Close()
}
return &session{
ctx: ctx,
computer: computer,
plat: p,
exec: execStream,
logger: logger,
chatID: chatID,
}, nil
}
// runStream executes the main stream processing loop.
// Returns (completed, error) where completed=true means OpenCode CLI sent
// a step_finish with reason=stop and the stream finished normally.
func (s *session) runStream(handler message.StreamFunc) (completed bool, err error) {
s.collectStderr()
cleanup := s.watchCancel()
defer cleanup()
// Tee stdout to a debug log so we can inspect raw JSONL timing.
stdout := s.teeStdout()
parser := newStreamParser(handler)
parseErr := parser.parse(s.ctx, stdout)
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if !parser.completed && parseErr != nil {
s.exec.Cancel()
}
if parser.completed {
s.logger.Info("opencode stream completed normally")
return true, nil
}
exitErr := s.waitForExit(parseErr)
if exitErr != nil {
if handler != nil {
handler(message.ChunkError, []byte(exitErr.Error()))
}
return false, exitErr
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if stderrStr != "" {
s.logger.Warn("opencode exited with code 0 but stream incomplete and stderr present: %s", stderrStr)
errMsg := fmt.Errorf("opencode CLI setup failed: %s", stderrStr)
if handler != nil {
handler(message.ChunkError, []byte(errMsg.Error()))
}
return false, errMsg
}
return false, nil
}
// teeStdout wraps exec.Stdout with a TeeReader that writes a copy to a
// timestamped log file. Returns the original Stdout if tee setup fails.
func (s *session) teeStdout() io.ReadCloser {
logDir := os.Getenv("YAO_LOG_PATH")
if logDir == "" {
logDir = "/tmp"
}
logFile := filepath.Join(logDir, fmt.Sprintf("opencode-stream-%s-%d.jsonl", s.chatID, time.Now().Unix()))
f, err := os.Create(logFile)
if err != nil {
s.logger.Debug("teeStdout: cannot create %s: %v", logFile, err)
return s.exec.Stdout
}
s.logger.Info("teeStdout: raw JSONL -> %s", logFile)
tee := io.TeeReader(s.exec.Stdout, f)
return &teeReadCloser{Reader: tee, closers: []io.Closer{s.exec.Stdout, f}}
}
type teeReadCloser struct {
io.Reader
closers []io.Closer
}
func (t *teeReadCloser) Close() error {
var firstErr error
for _, c := range t.closers {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *session) collectStderr() {
go func() {
buf := make([]byte, 4096)
for {
n, err := s.exec.Stderr.Read(buf)
if n > 0 {
chunk := string(buf[:n])
s.stderrMu.Lock()
s.stderr.WriteString(chunk)
s.stderrMu.Unlock()
s.logger.Debug("opencode stderr: %s", chunk)
}
if err != nil {
return
}
}
}()
}
// killProcess terminates the OpenCode CLI process (Node.js).
func (s *session) killProcess(ctx context.Context) {
if s.chatID != "" {
name := sanitizeSessionName(s.chatID)
result, err := s.computer.Exec(ctx, s.plat.KillSessionCmd(name))
s.logger.Debug("killProcess: KillSessionCmd(%s) exitCode=%d err=%v", name, result.ExitCode, err)
return
}
// OpenCode is a Node.js process; match both "opencode" and "node.*opencode"
result, err := s.computer.Exec(ctx, s.plat.KillCmd("opencode"))
s.logger.Debug("killProcess: KillCmd(opencode) exitCode=%d err=%v", result.ExitCode, err)
}
func (s *session) watchCancel() func() {
done := make(chan struct{})
go func() {
select {
case <-s.ctx.Done():
s.logger.Info("context cancelled, killing opencode: %v", s.ctx.Err())
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
case <-done:
}
}()
return func() { close(done) }
}
// shutdown cleans up after normal stream completion.
//
// OpenCode exits cleanly after step_finish(stop), but we still need to
// release the Docker exec connection. Like Claude runner, we first kill
// only the opencode process with SIGKILL (which cannot be caught, so
// OpenCode has no chance to propagate signals to child processes), then
// close the exec connection. Children (browsers, servers, etc.) that were
// launched via nohup/setsid survive because they are in separate sessions.
func (s *session) shutdown() {
s.logger.Info("shutting down completed opencode exec session: chatID=%s", s.chatID)
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
}
func (s *session) waitForExit(parseErr error) error {
s.logger.Info("opencode stream did not complete normally, waiting for exit")
type waitResult struct {
exitCode int
err error
}
ch := make(chan waitResult, 1)
go func() {
code, err := s.exec.Wait()
ch <- waitResult{code, err}
}()
var exitCode int
var waitErr error
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-s.ctx.Done():
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-time.After(10 * time.Second):
s.exec.Cancel()
s.logger.Error("opencode did not exit after kill, timeout")
return fmt.Errorf("opencode did not exit after kill (timeout)")
}
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if parseErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", parseErr, stderrStr)
}
return parseErr
}
if waitErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", waitErr, stderrStr)
}
return waitErr
}
if exitCode != 0 {
s.logger.Warn("opencode exited with non-zero code: exitCode=%d stderr=%s", exitCode, stderrStr)
if stderrStr != "" {
return fmt.Errorf("opencode CLI exited with code %d: %s", exitCode, stderrStr)
}
return fmt.Errorf("opencode CLI exited with code %d", exitCode)
}
return nil
}