Files
agentic-coding-workflow/internal/acp/turn.go
T
2026-06-22 08:55:57 +08:00

243 lines
7.6 KiB
Go

// turn.go 实现 TurnTracker:单 relay 维度的回合状态机,外加 stopReason 解析辅助。
//
// 回合(turn)定义:一次 session/prompt 请求到其响应之间的窗口。relay reader 在解析到
// - session/update 通知 → OnUpdate(累加 update_count,内存计数,完成时一次性写库)
// - 该 prompt 的字符串-id 响应 → OnPromptResponse(标完成 + 写 last_stop_reason + 发事件)
//
// MVP 假设:同一 session 同时只有一个开放回合。若在已有开放回合时收到新的 StartTurn,
// 自动中止前一个(auto-abort),避免悬挂。所有状态由 mu 保护;Abort 只触碰 repo+bus,
// 不持有 supervisor 锁,故可安全地从 onExit 调用(不违反 onExit-must-not-relock 不变量)。
package acp
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
// TurnRepo 是 TurnTracker 需要的最小 repo 子集,由 acp.Repository 满足。
type TurnRepo interface {
InsertTurn(ctx context.Context, t *Turn) (*Turn, error)
MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error
MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error
GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error)
UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error
}
// TurnTracker 是单 relay 的回合状态机。
type TurnTracker struct {
repo TurnRepo
bus TurnBus
sess handlers.SessionContext
log *slog.Logger
mu sync.Mutex
open bool // 是否有开放回合
turnIndex int // 当前/最近回合的 index
promptID string // 当前开放回合的 prompt 请求 id(用于响应相关联)
updateCount int // 当前回合的 session/update 计数(内存累计,完成时落库)
nextIndex int // 下一个回合的 index(构造时由 GetLatestTurnBySession 播种)
seeded bool // nextIndex 是否已播种
}
// NewTurnTracker 构造一个 TurnTracker。bus / repo 为 nil 时方法均为安全 no-op
// (部分测试可不注入)。
func NewTurnTracker(repo TurnRepo, bus TurnBus, sess handlers.SessionContext, log *slog.Logger) *TurnTracker {
if log == nil {
log = slog.Default()
}
return &TurnTracker{repo: repo, bus: bus, sess: sess, log: log}
}
// seedLocked 懒播种 nextIndex:从 DB 取最近回合 index+1。失败时从 0 开始(best-effort)。
// 调用方必须已持有 t.mu。
func (t *TurnTracker) seedLocked(ctx context.Context) {
if t.seeded {
return
}
t.seeded = true
if t.repo == nil {
return
}
latest, err := t.repo.GetLatestTurnBySession(ctx, t.sess.SessionID)
if err != nil {
t.log.Warn("acp.turn.seed_index", "session_id", t.sess.SessionID, "err", err.Error())
return
}
if latest != nil {
t.nextIndex = latest.TurnIndex + 1
}
}
// StartTurn 在发送 session/prompt 之前调用,登记一个 in_progress 回合并把 promptRequestID
// 与之关联。若已有开放回合,先 auto-abort 之(MVP 单回合假设)。返回新回合的 index。
func (t *TurnTracker) StartTurn(ctx context.Context, promptRequestID string) (int, error) {
if t == nil {
return 0, nil
}
t.mu.Lock()
defer t.mu.Unlock()
t.seedLocked(ctx)
// 已有开放回合 → auto-abort,避免悬挂。
if t.open {
t.abortLocked(ctx, string(StopCancelled))
}
idx := t.nextIndex
if t.repo != nil {
var pid *string
if promptRequestID != "" {
p := promptRequestID
pid = &p
}
if _, err := t.repo.InsertTurn(ctx, &Turn{
SessionID: t.sess.SessionID,
TurnIndex: idx,
PromptRequestID: pid,
Status: TurnInProgress,
}); err != nil {
return 0, err
}
}
t.open = true
t.turnIndex = idx
t.promptID = promptRequestID
t.updateCount = 0
t.nextIndex = idx + 1
return idx, nil
}
// OnUpdate 在收到一条 session/update 通知时调用,累加当前开放回合的 update_count
// (内存计数,避免每 chunk 一次 UPDATE;完成时一次性落库)。无开放回合时 no-op。
func (t *TurnTracker) OnUpdate(_ context.Context) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.open {
t.updateCount++
}
}
// IsTrackedPrompt 报告 id 是否为当前开放回合关联的 prompt 请求 id。relay reader 用它
// 判断一条字符串-id 响应是否应触发 OnPromptResponse(而非沿用既有 fanout 路径)。
func (t *TurnTracker) IsTrackedPrompt(id string) bool {
if t == nil || id == "" {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
return t.open && t.promptID == id
}
// OnPromptResponse 在收到匹配当前回合的 session/prompt 响应时调用:标完成、写
// session.last_stop_reason、发布 turn_completed 与 session_idle 事件。id 不匹配时 no-op。
func (t *TurnTracker) OnPromptResponse(ctx context.Context, promptRequestID string, rawStopReason string) {
if t == nil {
return
}
t.mu.Lock()
if !t.open || t.promptID != promptRequestID {
t.mu.Unlock()
return
}
idx := t.turnIndex
updateCount := t.updateCount
t.open = false
t.promptID = ""
t.mu.Unlock()
norm := NormalizeStopReason(rawStopReason)
completedAt := time.Now()
if t.repo != nil {
if err := t.repo.MarkTurnCompleted(ctx, t.sess.SessionID, idx, rawStopReason, updateCount); err != nil {
t.log.Error("acp.turn.mark_completed", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error())
}
if err := t.repo.UpdateSessionLastStopReason(ctx, t.sess.SessionID, rawStopReason); err != nil {
t.log.Error("acp.turn.update_last_stop_reason", "session_id", t.sess.SessionID, "err", err.Error())
}
}
if t.bus != nil {
base := TurnEvent{
SessionID: t.sess.SessionID,
UserID: t.sess.UserID,
WorkspaceID: t.sess.WorkspaceID,
TurnIndex: idx,
StopReason: norm,
RawStopReason: rawStopReason,
CompletedAt: completedAt,
}
completed := base
completed.Kind = TurnCompletedEvent
t.bus.Publish(ctx, completed)
idle := base
idle.Kind = SessionIdleEvent
t.bus.Publish(ctx, idle)
}
}
// Abort 中止当前开放回合(如果有)并发布 session_idle 事件。从 supervisor.onExit
// 在子进程退出时调用,确保崩溃/被杀时也能闭合悬挂回合。无开放回合时 no-op。
func (t *TurnTracker) Abort(ctx context.Context, reason string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if !t.open {
return
}
t.abortLocked(ctx, reason)
}
// abortLocked 闭合当前开放回合并发布 session_idle。调用方必须已持有 t.mu。
func (t *TurnTracker) abortLocked(ctx context.Context, reason string) {
idx := t.turnIndex
t.open = false
t.promptID = ""
if t.repo != nil {
if err := t.repo.MarkTurnAborted(ctx, t.sess.SessionID, idx); err != nil {
t.log.Error("acp.turn.mark_aborted", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error())
}
}
if t.bus != nil {
norm := NormalizeStopReason(reason)
t.bus.Publish(ctx, TurnEvent{
SessionID: t.sess.SessionID,
UserID: t.sess.UserID,
WorkspaceID: t.sess.WorkspaceID,
TurnIndex: idx,
Kind: SessionIdleEvent,
StopReason: norm,
RawStopReason: reason,
CompletedAt: time.Now(),
})
}
}
// ParseStopReason 从 session/prompt 响应的 Result JSON 中提取 stopReason 字段。
// 字段缺失或解析失败时返回空串(调用方归一化为 StopOther)。
func ParseStopReason(result json.RawMessage) string {
if len(result) == 0 {
return ""
}
var r struct {
StopReason string `json:"stopReason"`
}
if err := json.Unmarshal(result, &r); err != nil {
return ""
}
return r.StopReason
}