Files
cc-connect/agent/codex/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

315 lines
7.1 KiB
Go

package codex
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// codexSession manages a multi-turn Codex conversation.
// First Send() uses `codex exec`, subsequent ones use `codex exec resume <threadID>`.
type codexSession struct {
workDir string
model string
mode string
extraEnv []string
events chan core.Event
threadID atomic.Value // stores string — Codex thread_id
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
}
func newCodexSession(ctx context.Context, workDir, model, mode, resumeID string, extraEnv []string) (*codexSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
cs := &codexSession{
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
cs.alive.Store(true)
if resumeID != "" {
cs.threadID.Store(resumeID)
}
return cs, nil
}
// Send launches a codex subprocess.
// If a threadID exists (from a prior turn or resume), uses `codex exec resume <id> <prompt>`.
// Otherwise uses `codex exec <prompt>` to start a new conversation.
func (cs *codexSession) Send(prompt string, images []core.ImageAttachment) error {
if len(images) > 0 {
slog.Warn("codexSession: images not supported by Codex, ignoring")
}
if !cs.alive.Load() {
return fmt.Errorf("session is closed")
}
tid := cs.CurrentSessionID()
isResume := tid != ""
var args []string
if isResume {
args = []string{"exec", "resume", "--json", "--skip-git-repo-check"}
} else {
args = []string{"exec", "--json", "--skip-git-repo-check"}
}
switch cs.mode {
case "auto-edit", "full-auto":
args = append(args, "--full-auto")
case "yolo":
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
}
if cs.model != "" {
args = append(args, "--model", cs.model)
}
if isResume {
args = append(args, tid, prompt)
} else {
args = append(args, "--cd", cs.workDir, prompt)
}
slog.Debug("codexSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(cs.ctx, "codex", args...)
cmd.Dir = cs.workDir
if len(cs.extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), cs.extraEnv)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("codexSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("codexSession: start: %w", err)
}
cs.wg.Add(1)
go cs.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (cs *codexSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer cs.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("codexSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.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("codexSession: non-JSON line", "line", line)
continue
}
cs.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("codexSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *codexSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "thread.started":
if tid, ok := raw["thread_id"].(string); ok {
cs.threadID.Store(tid)
slog.Debug("codexSession: thread started", "thread_id", tid)
}
case "turn.started":
slog.Debug("codexSession: turn started")
case "item.started":
cs.handleItemStarted(raw)
case "item.completed":
cs.handleItemCompleted(raw)
case "turn.completed":
evt := core.Event{Type: core.EventResult, SessionID: cs.CurrentSessionID(), Done: true}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "error":
msg, _ := raw["message"].(string)
if strings.Contains(msg, "Reconnecting") || strings.Contains(msg, "Falling back") {
slog.Debug("codexSession: transient error", "message", msg)
} else {
slog.Warn("codexSession: error event", "message", msg)
}
}
}
func (cs *codexSession) handleItemStarted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
return
}
itemType, _ := item["type"].(string)
if itemType == "command_execution" {
command, _ := item["command"].(string)
evt := core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *codexSession) handleItemCompleted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
return
}
itemType, _ := item["type"].(string)
switch itemType {
case "reasoning":
text, _ := item["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "agent_message":
text, _ := item["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "command_execution":
command, _ := item["command"].(string)
status, _ := item["status"].(string)
output, _ := item["aggregated_output"].(string)
exitCode, _ := item["exit_code"].(float64)
slog.Debug("codexSession: command completed",
"command", truncate(command, 100),
"status", status,
"exit_code", int(exitCode),
"output_len", len(output),
)
case "error":
msg, _ := item["message"].(string)
if msg != "" && !strings.Contains(msg, "Falling back") {
slog.Warn("codexSession: item error", "message", msg)
}
}
}
// RespondPermission is a no-op for Codex — permissions are handled via CLI flags.
func (cs *codexSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (cs *codexSession) Events() <-chan core.Event {
return cs.events
}
func (cs *codexSession) CurrentSessionID() string {
v, _ := cs.threadID.Load().(string)
return v
}
func (cs *codexSession) Alive() bool {
return cs.alive.Load()
}
func (cs *codexSession) Close() error {
cs.alive.Store(false)
cs.cancel()
done := make(chan struct{})
go func() {
cs.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(8 * time.Second):
slog.Warn("codexSession: close timed out, abandoning wg.Wait")
}
close(cs.events)
return nil
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}