Files
cc-connect/agent/opencode/session.go
T
chenhg5andClaude f2920739d8 feat: add timeout handling for agent session closure
Added timeout mechanisms with logging for graceful session closure across multiple agent types (claudecode, codex, cursor, gemini, opencode, qoder). Modified engine cleanup to handle timeouts and improve logging. Sessions now timeout after 8-10 seconds with appropriate warnings/errors.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 22:02:54 +08:00

329 lines
7.1 KiB
Go

package opencode
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// opencodeSession manages multi-turn conversations with the OpenCode CLI.
// Each Send() launches a new `opencode run --format json` process
// with --session for conversation continuity.
type opencodeSession struct {
cmd string
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — OpenCode session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
}
func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*opencodeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &opencodeSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
s.alive.Store(true)
if resumeID != "" {
s.chatID.Store(resumeID)
}
return s, nil
}
func (s *opencodeSession) Send(prompt string, images []core.ImageAttachment) error {
if !s.alive.Load() {
return fmt.Errorf("session is closed")
}
chatID := s.CurrentSessionID()
isResume := chatID != ""
args := []string{"run", "--format", "json"}
if isResume {
args = append(args, "--session", chatID)
}
if s.model != "" {
args = append(args, "--model", s.model)
}
if s.workDir != "" {
args = append(args, "--dir", s.workDir)
}
// Enable thinking blocks
args = append(args, "--thinking")
// Append prompt as positional arg
args = append(args, prompt)
slog.Debug("opencodeSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(s.ctx, s.cmd, args...)
cmd.Dir = s.workDir
env := os.Environ()
if len(s.extraEnv) > 0 {
env = core.MergeEnv(env, s.extraEnv)
}
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("opencodeSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("opencodeSession: start: %w", err)
}
s.wg.Add(1)
go s.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer s.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := stderrBuf.String()
if stderrMsg != "" {
slog.Error("opencodeSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("opencodeSession: non-JSON line", "line", line)
continue
}
s.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("opencodeSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
// OpenCode NDJSON event structure:
//
// { "type": "text|tool_use|reasoning|step_start|step_finish",
// "part": { "type": "text|tool|reasoning|step-start|step-finish", ... } }
func (s *opencodeSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "text":
s.handleText(raw)
case "tool_use":
s.handleToolUse(raw)
case "reasoning":
s.handleReasoning(raw)
case "step_start":
s.handleStepStart(raw)
case "step_finish":
s.handleStepFinish(raw)
default:
slog.Debug("opencodeSession: unhandled event", "type", eventType)
}
}
func (s *opencodeSession) handleText(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleToolUse(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
toolName, _ := part["tool"].(string)
// Check state for status
state, _ := part["state"].(map[string]any)
status := ""
if state != nil {
status, _ = state["status"].(string)
}
if status == "completed" {
// Tool result
output := ""
if state != nil {
output, _ = state["output"].(string)
}
evt := core.Event{Type: core.EventToolResult, ToolName: toolName, Content: truncate(output, 500)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
} else {
// Tool use (running or starting)
input := ""
if state != nil {
inputVal, _ := state["input"].(string)
if inputVal != "" {
input = inputVal
} else {
// Try to marshal input as JSON summary
if inputMap, ok := state["input"].(map[string]any); ok {
b, _ := json.Marshal(inputMap)
input = truncate(string(b), 200)
}
}
}
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleReasoning(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleStepStart(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
sessionID, _ := part["sessionID"].(string)
if sessionID != "" {
s.chatID.Store(sessionID)
slog.Debug("opencodeSession: session started", "session_id", sessionID)
}
}
func (s *opencodeSession) handleStepFinish(_ map[string]any) {
sid := s.CurrentSessionID()
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
// RespondPermission is a no-op — OpenCode handles permissions internally.
func (s *opencodeSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (s *opencodeSession) Events() <-chan core.Event {
return s.events
}
func (s *opencodeSession) CurrentSessionID() string {
v, _ := s.chatID.Load().(string)
return v
}
func (s *opencodeSession) Alive() bool {
return s.alive.Load()
}
func (s *opencodeSession) Close() error {
s.alive.Store(false)
s.cancel()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(8 * time.Second):
slog.Warn("opencodeSession: close timed out, abandoning wg.Wait")
}
close(s.events)
return nil
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}