From 70c86a552bf4b7d1ca7fb73580700822632488ee Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 14:01:04 +0800 Subject: [PATCH] feat(acp): supervisor spawn / handshake / monitor / kill / onExit --- internal/acp/supervisor.go | 289 ++++++++++++++++++++++++++++- internal/acp/supervisor_unix.go | 23 +++ internal/acp/supervisor_windows.go | 14 ++ 3 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 internal/acp/supervisor_unix.go create mode 100644 internal/acp/supervisor_windows.go diff --git a/internal/acp/supervisor.go b/internal/acp/supervisor.go index 5071538..7a230c8 100644 --- a/internal/acp/supervisor.go +++ b/internal/acp/supervisor.go @@ -9,8 +9,13 @@ package acp import ( + "bufio" + "context" + "encoding/json" + "fmt" "io" "log/slog" + "os" "os/exec" "sync" "sync/atomic" @@ -18,6 +23,7 @@ import ( "github.com/google/uuid" + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" @@ -87,7 +93,7 @@ type Process struct { StderrBuf *stderrRing // 排障 ring buffer StartedAt time.Time - KilledByUs atomic.Bool // true → exited,false → crashed + KilledByUs atomic.Bool // true → exited,false → crashed done chan struct{} // monitor 退出时 close } @@ -134,4 +140,283 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay { return p.Relay } -// Spawn / Kill / ShutdownAll / monitorProcess / handshake are implemented in subsequent tasks (E2). +// Spawn 启动一个子进程并完成 ACP handshake。返回的 *Process 已注册到 procs map; +// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的 +// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。 +func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind) (*Process, error) { + envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv) + if err != nil { + return nil, err + } + + // strip env:只保留 PATH + 解密 overrides,避免泄漏 master key、git 凭据等。 + osEnv := []string{"PATH=" + os.Getenv("PATH")} + for k, v := range envMap { + osEnv = append(osEnv, fmt.Sprintf("%s=%s", k, v)) + } + + cmd := exec.Command(kind.BinaryPath, kind.Args...) + cmd.Dir = sess.CwdPath + cmd.Env = osEnv + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start: %w", err) + } + + relay := NewRelay( + sess.ID, + NewDecoder(stdout, s.cfg.EventMaxPayload), + NewEncoder(stdin), + s.repo, + handlers.NewFsHandler(), + handlers.NewPermissionHandler(), + RelayConfig{ + EventMaxPayload: s.cfg.EventMaxPayload, + EventTruncateField: s.cfg.EventTruncateField, + WSSendBuffer: 256, + }, + s.log, + ) + + proc := &Process{ + SessionID: sess.ID, + WorkspaceID: sess.WorkspaceID, + UserID: sess.UserID, + IsMain: sess.IsMainWorktree, + Cmd: cmd, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Relay: relay, + StderrBuf: newStderrRing(s.cfg.StderrBufferLines), + StartedAt: time.Now(), + done: make(chan struct{}), + } + + s.mu.Lock() + s.procs[sess.ID] = proc + s.mu.Unlock() + + go s.drainStderr(proc) + go s.monitorProcess(proc) + go relay.Run(ctx, handlers.SessionContext{ + SessionID: sess.ID, + WorkspaceID: sess.WorkspaceID, + UserID: sess.UserID, + CwdPath: sess.CwdPath, + AgentSessionID: sess.AgentSessionID, + }) + + if err := s.handshake(ctx, sess, relay, proc); err != nil { + s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error()) + s.Kill(ctx, sess.ID, s.cfg.KillGrace) + return nil, err + } + return proc, nil +} + +// handshake 发送 initialize → session/new,并把返回的 agent session id + pid +// 持久化到 acp_sessions 行(status → running)。SpawnTimeout 是整个握手的硬上限。 +func (s *Supervisor) handshake(ctx context.Context, sess *Session, relay *Relay, proc *Process) error { + hsCtx, cancel := context.WithTimeout(ctx, s.cfg.SpawnTimeout) + defer cancel() + + if _, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev")); err != nil { + return fmt.Errorf("initialize: %w", err) + } + resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath)) + if err != nil { + return fmt.Errorf("session/new: %w", err) + } + var snr handlers.SessionNewResult + if err := json.Unmarshal(resp.Result, &snr); err != nil { + return fmt.Errorf("parse session/new result: %w", err) + } + if snr.SessionID == "" { + return fmt.Errorf("agent did not return sessionId") + } + pid := int32(proc.Cmd.Process.Pid) + if err := s.repo.UpdateSessionRunning(ctx, sess.ID, snr.SessionID, pid); err != nil { + return fmt.Errorf("update session running: %w", err) + } + sess.AgentSessionID = &snr.SessionID + sess.PID = &pid + sess.Status = SessionRunning + return nil +} + +// Kill 终止指定 session 的子进程。在 Unix 平台上先发 SIGTERM 等待 grace 时间, +// 仍未退出再 Process.Kill;Windows 没有可靠 SIGTERM 实现,直接 Process.Kill。 +// 始终阻塞直到 monitorProcess 关闭 done channel。Kill 是幂等的——目标 session +// 不存在时立即返回。 +func (s *Supervisor) Kill(ctx context.Context, sid uuid.UUID, grace time.Duration) { + s.mu.RLock() + proc, ok := s.procs[sid] + s.mu.RUnlock() + if !ok { + return + } + + proc.KilledByUs.Store(true) + + // 平台差异:Unix 走 SIGTERM + grace + 强 Kill;Windows 直接 Kill。 + // 实现见 supervisor_unix.go / supervisor_windows.go。 + s.unixGracefulTerm(proc, grace) + + select { + case <-proc.done: + case <-ctx.Done(): + } +} + +// ShutdownAll 并行 Kill 所有正在运行的子进程。bounded by cfg.ShutdownGrace。 +func (s *Supervisor) ShutdownAll(ctx context.Context) { + s.mu.RLock() + ids := make([]uuid.UUID, 0, len(s.procs)) + for id := range s.procs { + ids = append(ids, id) + } + s.mu.RUnlock() + + shutdownCtx, cancel := context.WithTimeout(ctx, s.cfg.ShutdownGrace) + defer cancel() + + var wg sync.WaitGroup + for _, id := range ids { + wg.Add(1) + go func(sid uuid.UUID) { + defer wg.Done() + s.Kill(shutdownCtx, sid, s.cfg.KillGrace) + }(id) + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-shutdownCtx.Done(): + s.log.Warn("acp.shutdown_all.timeout") + } +} + +// drainStderr 持续读取子进程 stderr,按行追加到 ring buffer。EOF 或 read error +// 时安静退出(cmd.Wait() 已在 monitorProcess 处理)。 +func (s *Supervisor) drainStderr(proc *Process) { + scanner := bufio.NewScanner(proc.Stderr) + // 默认 token 大小 64KB,足够 ACP agent 单行 log。 + for scanner.Scan() { + proc.StderrBuf.Append(scanner.Text()) + } +} + +// monitorProcess 等待子进程退出,更新 procs map 并触发 onExit。这是子进程 +// 状态唯一的合法 close 路径(done channel)。 +func (s *Supervisor) monitorProcess(proc *Process) { + defer close(proc.done) + waitErr := proc.Cmd.Wait() + + exitCode := int32(0) + if exitErr, ok := waitErr.(*exec.ExitError); ok { + exitCode = int32(exitErr.ExitCode()) + } + + s.mu.Lock() + delete(s.procs, proc.SessionID) + s.mu.Unlock() + + status := SessionExited + if !proc.KilledByUs.Load() { + status = SessionCrashed + } + s.onExit(context.Background(), proc, status, exitCode, waitErr) +} + +// onExit 写终止状态、释放 worktree、触发通知与 audit、最后关闭 relay。 +// 不能再 acquire s.mu(monitorProcess 已 delete map 后调用本函数,但 Kill +// 也持锁等 done,会形成 monitor → Kill → onExit 的链式 deadlock)。 +func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionStatus, exitCode int32, waitErr error) { + stderrTail := proc.StderrBuf.Tail(s.cfg.StderrTailBytes) + var lastErr *string + if status == SessionCrashed && stderrTail != "" { + lastErr = &stderrTail + } + if err := s.repo.UpdateSessionFinished(ctx, proc.SessionID, status, &exitCode, lastErr); err != nil { + s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error()) + } + + // Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 走 workspace + // service 的 holder 校验。当前 wtSvc.Release 接受 workspace.Caller,子 worktree + // 释放需要绕过 holder 检查(spec 期望 holder = "session:",与 user holder + // 不同),用 IsAdmin: true 强制释放。F4 reaper 会替换为更严格的 byHolder 调用。 + if proc.IsMain { + if _, err := s.repo.ReleaseMainWorktree(ctx, proc.WorkspaceID, proc.SessionID); err != nil { + s.log.Error("acp.on_exit.release_main", + "session_id", proc.SessionID, "err", err.Error()) + } + } else if proc.WorktreeID != nil && s.wtSvc != nil { + if _, err := s.wtSvc.Release(ctx, workspace.Caller{IsAdmin: true}, *proc.WorktreeID); err != nil { + s.log.Error("acp.on_exit.release_worktree", + "session_id", proc.SessionID, "worktree_id", *proc.WorktreeID, "err", err.Error()) + } + } + + if status == SessionCrashed { + if s.notify != nil { + _ = s.notify.Dispatch(ctx, notify.Message{ + UserID: proc.UserID, + Topic: "acp.session_crashed", + Severity: notify.SeverityError, + Title: "ACP session crashed", + Body: fmt.Sprintf("Session %s exited with code %d.", proc.SessionID, exitCode), + Metadata: map[string]any{ + "session_id": proc.SessionID.String(), + "exit_code": exitCode, + }, + }) + } + if s.audit != nil { + uid := proc.UserID + _ = s.audit.Record(ctx, audit.Entry{ + UserID: &uid, + Action: "acp.session.crashed", + TargetType: "acp_session", + TargetID: proc.SessionID.String(), + Metadata: map[string]any{ + "exit_code": exitCode, + "last_error": stderrTail, + }, + }) + } + } else { + if s.audit != nil { + uid := proc.UserID + _ = s.audit.Record(ctx, audit.Entry{ + UserID: &uid, + Action: "acp.session.terminate", + TargetType: "acp_session", + TargetID: proc.SessionID.String(), + Metadata: map[string]any{"exit_code": exitCode}, + }) + } + } + _ = waitErr // 已通过 KilledByUs / ExitError 编码到 status / exitCode + + // 最后关 relay:广播 session_terminated 给 WS subscribers。 + proc.Relay.Close(string(status), &exitCode) +} diff --git a/internal/acp/supervisor_unix.go b/internal/acp/supervisor_unix.go new file mode 100644 index 0000000..6fb61d0 --- /dev/null +++ b/internal/acp/supervisor_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package acp + +import ( + "syscall" + "time" +) + +// unixGracefulTerm 在 Unix 平台上先发 SIGTERM,等待 grace 时间内退出; +// 否则 Process.Kill。done channel 由 monitorProcess 关闭。 +func (s *Supervisor) unixGracefulTerm(proc *Process, grace time.Duration) { + if proc.Cmd.Process == nil { + return + } + _ = proc.Cmd.Process.Signal(syscall.SIGTERM) + select { + case <-proc.done: + return + case <-time.After(grace): + _ = proc.Cmd.Process.Kill() + } +} diff --git a/internal/acp/supervisor_windows.go b/internal/acp/supervisor_windows.go new file mode 100644 index 0000000..32445d1 --- /dev/null +++ b/internal/acp/supervisor_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package acp + +import "time" + +// unixGracefulTerm 在 Windows 上没有可靠的 SIGTERM 等价信号(CTRL_BREAK 仅 +// 对 console 子进程生效)。直接 Process.Kill,grace 参数不参与,仅签名一致。 +func (s *Supervisor) unixGracefulTerm(proc *Process, grace time.Duration) { + if proc.Cmd.Process == nil { + return + } + _ = proc.Cmd.Process.Kill() +}