// supervisor.go 管理 ACP agent 子进程池。每 session 一个 Process,spawn / kill / // monitor 三类操作通过 mu 保护 procs map。 // // 生命周期 invariants(spec §6.8): // - procs 写锁仅在 spawn / Kill / monitorProcess delete 时持有 // - done channel 严格 close-once(monitor 唯一持有) // - KilledByUs atomic 防 race(Kill / monitor 并发) // - onExit 不能再 Lock supervisor.mu(避免死锁) package acp import ( "bufio" "context" "encoding/json" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/acp/sandbox" "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" procgrp "github.com/yan1h/agent-coding-workflow/internal/infra/proc" "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // SupervisorConfig 是从 config.AcpConfig 派生的 supervisor 子集。 type SupervisorConfig struct { SpawnTimeout time.Duration KillGrace time.Duration StderrBufferLines int StderrTailBytes int EventMaxPayload int EventTruncateField int ShutdownGrace time.Duration // AgentHomesDir 是 agent CLI 受管 home 的根目录(/agent-homes)。 // 空串时跳过配置物化与重定向 env 注入(部分测试)。 AgentHomesDir string // DefaultProjectBudgetUSD 是 per-project ACP 累计花费的全局软上限(0 = 不限)。 // 当 session 与 agent-kind 均未设置成本上限时,用作 fallback 的预算判定基线。 DefaultProjectBudgetUSD float64 // ===== 沙箱(per-session 隔离)===== // Sandbox 是 pluggable 沙箱实现(none|uid|container),在 cmd/env 构建后、 // proc.Group.Prepare 之前 Apply。为 nil 时等同 mode=none(无隔离)。 Sandbox sandbox.Sandbox // SandboxMode 记录当前 mode,用于落库 acp_sessions.sandbox_mode(审计/取证)。 SandboxMode sandbox.Mode // SandboxBaseUID 是 per-session 低权 UID 的基址:实际 uid = BaseUID + offset, // offset 由 session 派生(保证不同活跃 session 不复用同一 uid)。0 = 不分配。 SandboxBaseUID int // DataRoot 是要被屏蔽的 /data 根(除本 session worktree + per-user home 外)。 DataRoot string // EgressProxyURL 是注入子进程 env 的 HTTP(S)_PROXY(egress 白名单代理)。 EgressProxyURL string // SandboxRlimits 是套用到子进程(及其进程树)的 OS 资源上限。 SandboxRlimits sandbox.Limits } // Supervisor 是 ACP 子进程总管。 type Supervisor struct { mu sync.RWMutex procs map[uuid.UUID]*Process repo Repository audit audit.Recorder notify *notify.Dispatcher wtSvc workspace.WorktreeService wsRepo workspace.Repository mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token crypto *crypto.Encryptor permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试) turnBus TurnBus // 回合完成事件总线;可为 nil(部分测试) orchRedrive OrchestratorRedrive // 崩溃的编排器 session 再驱动回调;可为 nil prices ModelPriceLookup // model 价格查询;可为 nil(无 model 价格时成本=0/agent 自报) agentsMD AgentsMDRenderer // AGENTS.md 渲染回调(project memory);可为 nil metrics SessionExitRecorder // 会话退出计数;可为 nil(metrics 关闭) cfg SupervisorConfig log *slog.Logger } // SessionExitRecorder 是 Supervisor 上报会话退出指标所需的窄接口。 // metrics.Metrics 满足它;nil 时不记录。 type SessionExitRecorder interface { RecordSessionExit(status string) } // SetMetrics 注入会话退出计数器(装配期)。 func (s *Supervisor) SetMetrics(m SessionExitRecorder) { s.metrics = m } // AgentsMDRenderer renders the AGENTS.md content for a (project, workspace) from // project memory. Injected via SetAgentsMDRenderer to avoid an acp → // projectmemory import cycle. May be nil (no seeding). type AgentsMDRenderer func(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) // SetAgentsMDRenderer 注入 AGENTS.md 渲染回调(装配期)。 func (s *Supervisor) SetAgentsMDRenderer(fn AgentsMDRenderer) { s.agentsMD = fn } // agentsMDFileName is the generated file written into the worktree root. const agentsMDFileName = "AGENTS.md" // seedAgentsMD renders project memory into /AGENTS.md before spawn. Skipped // when no renderer is configured, CwdPath is empty, or ProjectID is nil. Failures // are logged, never fatal — the agent still spawns without seeded knowledge. func (s *Supervisor) seedAgentsMD(ctx context.Context, sess *Session) { if s.agentsMD == nil || sess.CwdPath == "" || sess.ProjectID == uuid.Nil { return } var wsID *uuid.UUID if sess.WorkspaceID != uuid.Nil { id := sess.WorkspaceID wsID = &id } content, err := s.agentsMD(ctx, sess.ProjectID, wsID) if err != nil { s.log.Warn("acp.agents_md.render_failed", "session_id", sess.ID, "err", err.Error()) return } dst := filepath.Join(sess.CwdPath, agentsMDFileName) if err := os.WriteFile(dst, []byte(content), 0o644); err != nil { s.log.Warn("acp.agents_md.write_failed", "session_id", sess.ID, "path", dst, "err", err.Error()) } } // OrchestratorRedrive 在一个由编排器 step 驱动的 session 崩溃退出时被调用,由编排器 // 据此把对应 step 重新入队(带 backoff),让回合在同一 worktree 上恢复。注入为 func // 以避免 acp → orchestrator 的 import 循环(与 notify dispatcher / permission service // 的注入方式一致)。实现必须是非阻塞、不持 supervisor 锁的纯 DB 写(jobs Enqueue)。 type OrchestratorRedrive func(ctx context.Context, stepID uuid.UUID, reason string) // SetOrchestratorRedrive 回填编排器再驱动回调(装配期)。 func (s *Supervisor) SetOrchestratorRedrive(fn OrchestratorRedrive) { s.orchRedrive = fn } // SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填 // (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。 func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p } // SetTurnBus 注入回合事件总线。Spawn 时每个 relay 构造一个 TurnTracker 复用它。 // 可为 nil——此时回合仍落库(若 repo 可用),但不发布内部事件。 func (s *Supervisor) SetTurnBus(b TurnBus) { s.turnBus = b } // SetModelPriceLookup 注入 model 价格查询(成本核算用)。装配期回填,避免 acp → // chat 的构造期依赖。可为 nil——此时无 model 价格的会话成本为 0 或取 agent 自报。 func (s *Supervisor) SetModelPriceLookup(p ModelPriceLookup) { s.prices = p } // NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时 // 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。 func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher, wtSvc workspace.WorktreeService, wsRepo workspace.Repository, mcpTokens mcp.TokenService, enc *crypto.Encryptor, cfg SupervisorConfig, log *slog.Logger) *Supervisor { if log == nil { log = slog.Default() } return &Supervisor{ procs: map[uuid.UUID]*Process{}, repo: repo, audit: rec, notify: disp, wtSvc: wtSvc, wsRepo: wsRepo, mcpTokens: mcpTokens, crypto: enc, cfg: cfg, log: log, } } // Process 是单个 agent 子进程的运行时句柄。 type Process struct { SessionID uuid.UUID WorkspaceID uuid.UUID UserID uuid.UUID IsMain bool // 主 worktree 占用 → release 走不同路径 Cmd *exec.Cmd group procgrp.Group // 进程树句柄(整树终止),Spawn 时 Prepare+Adopt Stdin io.WriteCloser Stdout io.ReadCloser Stderr io.ReadCloser Relay *Relay StderrBuf *stderrRing // 排障 ring buffer StartedAt time.Time KilledByUs atomic.Bool // true → exited,false → crashed done chan struct{} // monitor 退出时 close // relayCancel 取消 relay 的会话级 context。该 context 由 Spawn 用 // context.Background() 派生,独立于调用方的 spawn ctx(spawn ctx 在 Spawn // 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个 // 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。 relayCancel context.CancelFunc // sandboxCleanup 拆除沙箱临时资源(卸载 bind mount、停止容器、删临时目录)。 // 由 monitorProcess 在 group.Close() 之后调用,仅调用一次。可为 nil(mode=none)。 sandboxCleanup func() } // stderrRing 是固定行数 ring buffer,monitor 退出时取 tail 写 last_error。 type stderrRing struct { mu sync.Mutex lines []string cap int } func newStderrRing(cap int) *stderrRing { return &stderrRing{cap: cap} } func (r *stderrRing) Append(line string) { r.mu.Lock() defer r.mu.Unlock() if len(r.lines) >= r.cap { r.lines = r.lines[1:] } r.lines = append(r.lines, line) } func (r *stderrRing) Tail(maxBytes int) string { r.mu.Lock() defer r.mu.Unlock() out := "" for i := len(r.lines) - 1; i >= 0 && len(out) < maxBytes; i-- { out = r.lines[i] + "\n" + out } if len(out) > maxBytes { out = out[len(out)-maxBytes:] } return out } // GetRelay returns the relay for an active session, or nil if not running. // WS handler 用此判断是否进 live 模式。 func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay { s.mu.RLock() defer s.mu.RUnlock() p, ok := s.procs[sid] if !ok { return nil } return p.Relay } // 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, extraEnv map[string]string) (*Process, error) { // AGENTS.md seeding:在 spawn 前把 project memory 渲染成 worktree 根的 // AGENTS.md,让 agent 启动即有结构化项目知识。best-effort,失败仅告警不阻断。 s.seedAgentsMD(ctx, sess) // 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被 // AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。 envMap := map[string]string{} var agentHome string if s.cfg.AgentHomesDir != "" { home, err := s.materializeAgentHome(ctx, sess, kind) if err != nil { return nil, fmt.Errorf("materialize agent home: %w", err) } agentHome = home envMap = agentHomeEnv(home, kind.ClientType) } kindEnv, err := decryptEnv(s.crypto, kind.EncryptedEnv) if err != nil { return nil, err } for k, v := range kindEnv { envMap[k] = v } // System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11) for k, v := range extraEnv { envMap[k] = v } // 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 // 沙箱:在 cmd/env 构建完成后、proc.Group.Prepare(Setpgid)之前 Apply。 // Apply 只 ADD 到 cmd.SysProcAttr(Credential / namespace),绝不替换, // 故不破坏后续 group.Prepare 的 Setpgid 与负 pgid 整树终止。返回的 cleanup // 在 monitorProcess 的 group.Close() 之后调用一次。mode=none 时为 no-op。 sandboxCleanup := func() {} if s.cfg.Sandbox != nil { spec := s.buildSandboxSpec(sess, agentHome) cleanup, serr := s.cfg.Sandbox.Apply(cmd, spec) if serr != nil { return nil, fmt.Errorf("sandbox apply: %w", serr) } if cleanup != nil { sandboxCleanup = cleanup } // 记录本 session 运行所用的 sandbox mode(审计/取证)。best-effort。 if s.cfg.SandboxMode != "" { if err := s.repo.UpdateSessionSandboxMode(ctx, sess.ID, string(s.cfg.SandboxMode)); err != nil { s.log.Warn("acp.sandbox.record_mode", "session_id", sess.ID, "err", err.Error()) } } } // 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后 // (Windows Job Object)。终止时整树清理,避免 agent 派生的子孙进程残留。 group := procgrp.NewGroup() group.Prepare(cmd) 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) } if err := group.Adopt(cmd); err != nil { _ = cmd.Process.Kill() _ = cmd.Wait() // 回收进程资源,避免 Unix 僵尸进程 return nil, fmt.Errorf("adopt process group: %w", err) } var decider handlers.PermissionDecider if s.permSvc != nil { decider = s.permSvc } relay := NewRelay( sess.ID, NewDecoder(stdout, s.cfg.EventMaxPayload), NewEncoder(stdin), s.repo, handlers.NewFsHandler(), handlers.NewPermissionHandler(decider), RelayConfig{ EventMaxPayload: s.cfg.EventMaxPayload, EventTruncateField: s.cfg.EventTruncateField, WSSendBuffer: 256, }, s.log, ) // 回合追踪器:被动解析 session/update + session/prompt 响应。复用同一 acpRepo // 与共享 TurnBus,无需额外连接池。onExit 通过 relay.Tracker().Abort 闭合悬挂回合。 sessCtx := handlers.SessionContext{ SessionID: sess.ID, WorkspaceID: sess.WorkspaceID, UserID: sess.UserID, CwdPath: sess.CwdPath, AgentSessionID: sess.AgentSessionID, ToolAllowlist: kind.ToolAllowlist, } relay.SetTracker(NewTurnTracker(s.repo, s.turnBus, sessCtx, s.log)) // 成本核算:构造 per-session 累加器 + usage sink,seeded with 会话快照的有效 // 预算上限与 agent-kind 的 model 价格来源。预算突破时以 detached goroutine 标记 // terminated_reason 后 Kill(绝不阻塞 relay.reader,且 Kill 幂等)。 caps := BudgetCaps{ MaxCostUSD: sess.BudgetMaxCostUSD, MaxTokens: sess.BudgetMaxTokens, MaxWallClockSeconds: sess.BudgetMaxWallClockSeconds, } acc := newUsageAccumulator(AccumulatorParams{ Repo: s.repo, Prices: s.prices, Log: s.log, SessionID: sess.ID, UserID: sess.UserID, ProjectID: sess.ProjectID, AgentKindID: sess.AgentKindID, ModelID: kind.ModelID, StartedAt: time.Now(), Caps: caps, ProjectBudgetUSD: s.cfg.DefaultProjectBudgetUSD, }) sid := sess.ID killForBudget := func(reason BreachReason) { bgCtx := context.Background() if err := s.repo.MarkSessionTerminatedReason(bgCtx, sid, string(reason)); err != nil { s.log.Error("acp.budget.mark_terminated", "session_id", sid, "err", err.Error()) } s.log.Warn("acp.budget.breach_kill", "session_id", sid, "reason", string(reason)) s.Kill(bgCtx, sid, s.cfg.KillGrace) } relay.SetUsageSink(newAccumulatorSink(acc, killForBudget, s.log)) // relay 的会话级 context:独立于调用方的 spawn ctx(生产调用方在 Spawn 返回后 // 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须 // 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限 // 审批被强制拒绝。teardown 由 onExit 调 relayCancel + Relay.Close(关 r.done)完成。 relayCtx, relayCancel := context.WithCancel(context.Background()) proc := &Process{ SessionID: sess.ID, WorkspaceID: sess.WorkspaceID, UserID: sess.UserID, IsMain: sess.IsMainWorktree, Cmd: cmd, group: group, Stdin: stdin, Stdout: stdout, Stderr: stderr, Relay: relay, StderrBuf: newStderrRing(s.cfg.StderrBufferLines), StartedAt: time.Now(), done: make(chan struct{}), relayCancel: relayCancel, sandboxCleanup: sandboxCleanup, } s.mu.Lock() s.procs[sess.ID] = proc s.mu.Unlock() go s.drainStderr(proc) go s.monitorProcess(proc) go relay.Run(relayCtx, sessCtx) // 握手仍走调用方的 spawn ctx,保留 SpawnTimeout 截止语义。 if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); 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 是整个握手的硬上限。 // session/new 同时下发 mcpServers:ACW 自家 MCP(extraEnv 中的 per-session // token)+ AgentKind 第三方 MCP,按 initialize 声明的 mcpCapabilities 过滤。 func (s *Supervisor) handshake(ctx context.Context, sess *Session, kind *AgentKind, relay *Relay, proc *Process, extraEnv map[string]string) error { hsCtx, cancel := context.WithTimeout(ctx, s.cfg.SpawnTimeout) defer cancel() initResp, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev")) if err != nil { return fmt.Errorf("initialize: %w", err) } var ir handlers.InitializeResult if err := json.Unmarshal(initResp.Result, &ir); err != nil { // 能力解析失败不阻断握手:按无 http/sse 能力处理(仅基线 stdio 可下发)。 s.log.Warn("acp.handshake.parse_initialize_result", "session_id", sess.ID, "err", err.Error()) } httpOK, sseOK := ir.McpCapabilities() specs, err := decryptMCPServers(s.crypto, kind.EncryptedMCPServers) if err != nil { return err } mcpServers := buildMcpServers(specs, extraEnv[EnvMCPURL], extraEnv[EnvMCPToken], httpOK, sseOK, s.log, sess.ID) resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath, mcpServers)) 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 } // BuildSpawnEnv exposes env merge logic for testing. func (s *Supervisor) BuildSpawnEnv(kind *AgentKind, extraEnv map[string]string) ([]string, error) { envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv) if err != nil { return nil, err } for k, v := range extraEnv { envMap[k] = v } out := []string{"PATH=" + os.Getenv("PATH")} for k, v := range envMap { out = append(out, fmt.Sprintf("%s=%s", k, v)) } return out, nil } // buildSandboxSpec 从 session + 受管 home 派生本次 spawn 的沙箱 Spec。 // per-session UID = BaseUID + offset,offset 由 session ID 派生(低 16 位), // 保证不同 session 大概率落在不同 uid(碰撞仅影响隔离粒度,不影响正确性)。 // HomeDir = 受管 home(per-user),WorktreeDir = session cwd,二者为唯一可写路径。 func (s *Supervisor) buildSandboxSpec(sess *Session, agentHome string) sandbox.Spec { uid := 0 if s.cfg.SandboxBaseUID > 0 { // 取 session ID 的低 16 位作为 offset,限制 uid 漂移范围。 b := sess.ID offset := int(b[14])<<8 | int(b[15]) uid = s.cfg.SandboxBaseUID + offset } return sandbox.Spec{ Mode: s.cfg.SandboxMode, UID: uid, GID: uid, HomeDir: agentHome, WorktreeDir: sess.CwdPath, DataRoot: s.cfg.DataRoot, Rlimits: s.cfg.SandboxRlimits, ProxyURL: s.cfg.EgressProxyURL, } } // 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(-pgid) + grace + SIGKILL(-pgid);Windows 关闭 // Job 句柄触发 KILL_ON_JOB_CLOSE。实现下沉至 internal/infra/proc。 proc.group.TerminateGroup(proc.done, 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() // 释放进程树句柄(Windows 关闭 Job handle;Unix no-op)。done 已 close、 // 进程已 Wait 返回,此处 Close 不影响 onExit-must-not-relock 不变量。 proc.group.Close() // 沙箱清理:在 group.Close() 之后调用一次(卸载 bind mount、停容器、删临时目录)。 // 不持 supervisor.mu,遵守 onExit-must-not-relock 不变量。mode=none 时为 no-op。 if proc.sandboxCleanup != nil { proc.sandboxCleanup() } 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) { if s.metrics != nil { s.metrics.RecordSessionExit(string(status)) } 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()) } // 成本/预算终止:若 terminated_reason 已被预算门或 reaper 写入,发 audit + notify。 // 读 DB(UpdateSessionFinished 之后)拿最新 terminated_reason;不覆盖既有值。 if finished, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil && finished != nil && finished.TerminatedReason != nil && *finished.TerminatedReason != "" { s.recordBudgetTerminated(ctx, proc.UserID, proc.SessionID, *finished.TerminatedReason, finished.TotalCostUSD, finished.PromptTokens+finished.CompletionTokens+finished.ThinkingTokens) } // Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 按 holder // 释放(holder = "session:",见 workspace.Caller.Holder),与启动 reaper // 一致,不需要知道 worktree ID。 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 s.wtSvc != nil { if _, err := s.wtSvc.ReleaseByHolder(ctx, "session:"+proc.SessionID.String()); err != nil { s.log.Error("acp.on_exit.release_worktree", "session_id", proc.SessionID, "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 // 终止会话的全部挂起权限请求:标 expired 并唤醒阻塞的 Decide goroutine, // 避免 agent 端永久挂起。须在 Relay.Close 之前(Close 后 FanoutControl 静默丢弃)。 if s.permSvc != nil { s.permSvc.CancelSession(ctx, proc.SessionID) } // Revoke session's system MCP tokens (spec §6.3) if s.mcpTokens != nil { if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil { s.log.Error("acp.on_exit.mcp_revoke", "session_id", proc.SessionID, "err", err.Error()) } } // 回合检测:闭合任何开放的 in_progress 回合并发布 session_idle,确保崩溃/被杀时 // 编排层也能收到空闲信号。Abort 只触碰 repo+bus(不持 supervisor.mu),且必须在 // Relay.Close 之前——Close 后 tracker 仍可用,但语义上回合应在 teardown 前闭合。 if tracker := proc.Relay.Tracker(); tracker != nil { reason := string(StopCancelled) if status == SessionCrashed { reason = "crashed" } tracker.Abort(ctx, reason) } // 取消 relay 的会话级 context:停止 reader/writer,并让仍阻塞在 Decide / 已派生 // 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调, // 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done + // 广播 session_terminated)。relayCancel 在早期 spawn 失败路径也可能为 nil。 if proc.relayCancel != nil { proc.relayCancel() } // 最后关 relay:广播 session_terminated 给 WS subscribers。 proc.Relay.Close(string(status), &exitCode) // 编排器再驱动:若该 session 由编排器某 step 驱动且本次为崩溃退出,把对应 step // 重新入队(带 backoff),让回合在同一 worktree 上恢复。只做 DB 读 + jobs Enqueue, // 不持 supervisor 锁(保持 onExit-must-not-relock 不变量)。 if status == SessionCrashed && s.orchRedrive != nil { if sess, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil && sess != nil && sess.OrchestratorStepID != nil { s.orchRedrive(ctx, *sess.OrchestratorStepID, "session_crashed") } } } // recordBudgetTerminated 在会话因预算/reaper 被强制终止时发 audit + owner notify。 func (s *Supervisor) recordBudgetTerminated(ctx context.Context, userID, sessionID uuid.UUID, reason string, totalCost float64, totalTokens int64) { if s.audit != nil { uid := userID _ = s.audit.Record(ctx, audit.Entry{ UserID: &uid, Action: "acp.session.budget_exceeded", TargetType: "acp_session", TargetID: sessionID.String(), Metadata: map[string]any{ "reason": reason, "total_cost_usd": totalCost, "total_tokens": totalTokens, }, }) } if s.notify != nil { _ = s.notify.Dispatch(ctx, notify.Message{ UserID: userID, Topic: "acp.session_budget_exceeded", Severity: notify.SeverityWarning, Title: "ACP session terminated (budget/reaper)", Body: fmt.Sprintf("Session %s was terminated: %s.", sessionID, reason), Metadata: map[string]any{ "session_id": sessionID.String(), "reason": reason, "total_cost_usd": totalCost, }, }) } }