You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user