You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
+283
-33
@@ -17,6 +17,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"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"
|
||||
@@ -44,6 +46,25 @@ type SupervisorConfig struct {
|
||||
// AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/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 子进程总管。
|
||||
@@ -51,22 +72,87 @@ 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(部分测试)
|
||||
cfg SupervisorConfig
|
||||
log *slog.Logger
|
||||
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 <cwd>/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,
|
||||
@@ -114,6 +200,10 @@ type Process struct {
|
||||
// 返回后立即取消),故 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。
|
||||
@@ -163,14 +253,20 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
|
||||
// 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, kind)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -197,6 +293,28 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
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()
|
||||
@@ -243,6 +361,50 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
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 被丢、事件停止持久化、权限
|
||||
@@ -250,20 +412,21 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
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,
|
||||
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()
|
||||
@@ -272,14 +435,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
|
||||
go s.drainStderr(proc)
|
||||
go s.monitorProcess(proc)
|
||||
go relay.Run(relayCtx, handlers.SessionContext{
|
||||
SessionID: sess.ID,
|
||||
WorkspaceID: sess.WorkspaceID,
|
||||
UserID: sess.UserID,
|
||||
CwdPath: sess.CwdPath,
|
||||
AgentSessionID: sess.AgentSessionID,
|
||||
ToolAllowlist: kind.ToolAllowlist,
|
||||
})
|
||||
go relay.Run(relayCtx, sessCtx)
|
||||
|
||||
// 握手仍走调用方的 spawn ctx,保留 SpawnTimeout 截止语义。
|
||||
if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil {
|
||||
@@ -354,6 +510,30 @@ func (s *Supervisor) BuildSpawnEnv(kind *AgentKind, extraEnv map[string]string)
|
||||
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
|
||||
@@ -440,6 +620,12 @@ func (s *Supervisor) monitorProcess(proc *Process) {
|
||||
// 进程已 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
|
||||
@@ -451,6 +637,9 @@ func (s *Supervisor) monitorProcess(proc *Process) {
|
||||
// 不能再 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 != "" {
|
||||
@@ -460,6 +649,14 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
||||
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:<sid>",见 workspace.Caller.Holder),与启动 reaper
|
||||
// 一致,不需要知道 worktree ID。
|
||||
@@ -530,6 +727,17 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
||||
}
|
||||
}
|
||||
|
||||
// 回合检测:闭合任何开放的 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 +
|
||||
@@ -540,4 +748,46 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
||||
|
||||
// 最后关 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user