Files
agentic-coding-workflow/internal/acp/session_service.go
T

337 lines
10 KiB
Go

// session_service.go 实现 SessionService。Create 走完整事务(worktree
// acquire + supervisor.Spawn);Get/List/Terminate 是配套的简单读写。
package acp
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// SessionServiceConfig 是 service 的限流配置(从 AcpConfig 派生)。
type SessionServiceConfig struct {
MaxActiveGlobal int
MaxActivePerUser int
}
// ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。
type ProjectAccess interface {
GetProjectByWorkspace(ctx context.Context, wsID uuid.UUID) (*project.Project, error)
GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Issue, error)
GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error)
}
type sessionService struct {
repo Repository
wsSvc workspace.WorkspaceService
wtSvc workspace.WorktreeService
wsRepo workspace.Repository
pa ProjectAccess
sup *Supervisor
audit audit.Recorder
cfg SessionServiceConfig
}
// NewSessionService 构造 SessionService。
func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService,
wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
pa ProjectAccess, sup *Supervisor, rec audit.Recorder,
cfg SessionServiceConfig) SessionService {
return &sessionService{
repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo,
pa: pa, sup: sup, audit: rec, cfg: cfg,
}
}
// ResolveBranch 是 spec §8.1 的纯函数实现:根据 CreateSessionInput 决定
// (branch, isMainWorktree)。允许独立单测,不依赖 service 状态。
//
// 规则(按优先级):
// 1. in.Branch 显式:直接用;isMain = (== ws.DefaultBranch)
// 2. in.IssueNumber:拉 issue,校验 workspace 关联,命名 "issue/<slug>-<num>"
// 3. in.RequirementNumber:拉 requirement,校验 workspace,命名 "req/<slug>-<num>"
// 4. 都没给:fallback "acp-auto/<8-hex>",isMain = false
func ResolveBranch(ctx context.Context, ws *workspace.Workspace, sessionID uuid.UUID,
in CreateSessionInput, projectSlug string, pa ProjectAccess) (string, bool, error) {
if in.Branch != nil && *in.Branch != "" {
br := *in.Branch
return br, br == ws.DefaultBranch, nil
}
if in.IssueNumber != nil {
iss, err := pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber)
if err != nil {
return "", false, err
}
if iss.WorkspaceID != nil && *iss.WorkspaceID != ws.ID {
return "", false, errs.New(errs.CodeInvalidInput,
fmt.Sprintf("issue #%d already linked to other workspace", *in.IssueNumber))
}
return fmt.Sprintf("issue/%s-%d", projectSlug, iss.Number), false, nil
}
if in.RequirementNumber != nil {
req, err := pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber)
if err != nil {
return "", false, err
}
if req.WorkspaceID != nil && *req.WorkspaceID != ws.ID {
return "", false, errs.New(errs.CodeInvalidInput,
fmt.Sprintf("requirement #%d already linked to other workspace", *in.RequirementNumber))
}
return fmt.Sprintf("req/%s-%d", projectSlug, req.Number), false, nil
}
short := strings.ReplaceAll(sessionID.String(), "-", "")
if len(short) > 8 {
short = short[:8]
}
return fmt.Sprintf("acp-auto/%s", short), false, nil
}
// Create 执行完整 session 创建事务:
// 1. workspace 鉴权(wsSvc.Get)
// 2. agent kind 校验(enabled?)
// 3. 全局 + 单用户限流
// 4. 分支解析(ResolveBranch + project 查询)
// 5. worktree 占用(main → repo.AcquireMainWorktree;
// 子分支 → wtSvc.List 找已存在或 Create + Acquire,holder = "session:<sid>")
// 6. issueID/requirementID 反查(best-effort)
// 7. INSERT acp_sessions
// 8. audit.Record
// 9. async supervisor.Spawn(失败回滚 worktree)
// 10. async initial_prompt 转发(等握手完成)
func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) {
// 1. workspace + authz
wsCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}
ws, err := s.wsSvc.Get(ctx, wsCaller, in.WorkspaceID)
if err != nil {
return nil, err
}
// 2. agent kind
kind, err := s.repo.GetAgentKindByID(ctx, in.AgentKindID)
if err != nil {
return nil, err
}
if !kind.Enabled {
return nil, errs.New(errs.CodeAcpAgentKindDisabled, "agent kind disabled")
}
// 3. 限流
nGlobal, _ := s.repo.CountActiveSessions(ctx)
if int(nGlobal) >= s.cfg.MaxActiveGlobal {
return nil, errs.New(errs.CodeAcpSessionQuotaExceeded, "global session quota exceeded")
}
nUser, _ := s.repo.CountActiveSessionsByUser(ctx, c.UserID)
if int(nUser) >= s.cfg.MaxActivePerUser {
return nil, errs.New(errs.CodeAcpSessionQuotaExceeded, "per-user session quota exceeded")
}
// 4. 分支解析
pj, err := s.pa.GetProjectByWorkspace(ctx, ws.ID)
if err != nil {
return nil, err
}
sessionID := uuid.New()
branch, isMain, err := ResolveBranch(ctx, ws, sessionID, in, pj.Slug, s.pa)
if err != nil {
return nil, err
}
// 5. worktree 占用
var (
cwdPath string
worktreeID *uuid.UUID
)
sessCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin, SessionID: &sessionID}
if isMain {
ok, err := s.repo.AcquireMainWorktree(ctx, sessionID, ws.ID)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.New(errs.CodeAcpSessionBranchConflict, "main worktree already in use")
}
cwdPath = ws.MainPath
} else {
wt, err := s.findOrCreateWorktree(ctx, sessCaller, ws.ID, branch)
if err != nil {
return nil, err
}
if _, err := s.wtSvc.Acquire(ctx, sessCaller, wt.ID); err != nil {
return nil, err
}
cwdPath = wt.Path
wid := wt.ID
worktreeID = &wid
}
// 6. PM 关联(best-effort:拿不到 ID 不阻塞 session 创建)
var issueID, reqID *uuid.UUID
if in.IssueNumber != nil {
if iss, _ := s.pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber); iss != nil {
id := iss.ID
issueID = &id
}
}
if in.RequirementNumber != nil {
if req, _ := s.pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber); req != nil {
id := req.ID
reqID = &id
}
}
// 7. INSERT
sess := &Session{
ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID,
AgentKindID: kind.ID, UserID: c.UserID,
IssueID: issueID, RequirementID: reqID,
Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain,
Status: SessionStarting,
}
out, err := s.repo.InsertSession(ctx, sess)
if err != nil {
s.releaseOnFailure(ctx, sess, sessCaller, worktreeID)
return nil, err
}
// 8. audit
s.recordAudit(ctx, c, "acp.session.create", out.ID.String(), map[string]any{
"agent_kind": kind.Name,
"branch": branch,
"cwd_path": cwdPath,
"issue_id": issueID,
"requirement_id": reqID,
})
// 9. async spawn(失败时回滚 worktree)
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := s.sup.Spawn(spawnCtx, out, kind); err != nil {
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
}
}()
// 10. initial prompt:握手成功后异步转发
if in.InitialPrompt != nil && *in.InitialPrompt != "" {
go s.sendInitialPrompt(out.ID, *in.InitialPrompt)
}
return out, nil
}
// findOrCreateWorktree 在指定 workspace 内查找 branch 同名 worktree,
// 不存在则创建。caller 已通过 wsSvc.Get 完成鉴权。
func (s *sessionService) findOrCreateWorktree(ctx context.Context, c workspace.Caller, wsID uuid.UUID, branch string) (*workspace.Worktree, error) {
wts, err := s.wtSvc.List(ctx, c, wsID)
if err != nil {
return nil, err
}
for _, wt := range wts {
if wt.Branch == branch {
return wt, nil
}
}
return s.wtSvc.Create(ctx, c, wsID, workspace.CreateWorktreeInput{Branch: branch})
}
// releaseOnFailure 在 INSERT / Spawn 失败时回滚 worktree 占用。幂等且静默。
func (s *sessionService) releaseOnFailure(ctx context.Context, sess *Session, c workspace.Caller, worktreeID *uuid.UUID) {
if sess.IsMainWorktree {
_, _ = s.repo.ReleaseMainWorktree(ctx, sess.WorkspaceID, sess.ID)
return
}
if worktreeID != nil {
_, _ = s.wtSvc.Release(ctx, c, *worktreeID)
}
}
// sendInitialPrompt 等待 supervisor 完成 handshake(最多 30s),随后转发一条
// session/prompt 给 agent。轮询 200ms 一次拉 relay + agent_session_id;任一缺失
// 继续等待。超时后静默放弃。
func (s *sessionService) sendInitialPrompt(sid uuid.UUID, text string) {
deadline := time.After(30 * time.Second)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-deadline:
return
case <-tick.C:
}
relay := s.sup.GetRelay(sid)
if relay == nil {
continue
}
sess, _ := s.repo.GetSessionByID(context.Background(), sid)
if sess == nil || sess.AgentSessionID == nil {
continue
}
idJSON, _ := json.Marshal("client-init")
params, _ := json.Marshal(map[string]any{
"sessionId": *sess.AgentSessionID,
"prompt": []map[string]any{{"type": "text", "text": text}},
})
msg := &Message{
JSONRPC: "2.0", ID: idJSON, Method: "session/prompt", Params: params,
}
_ = relay.SendClient(msg)
return
}
}
func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) {
sess, err := s.repo.GetSessionByID(ctx, id)
if err != nil {
return nil, err
}
if !c.IsAdmin && sess.UserID != c.UserID {
return nil, errs.New(errs.CodeAcpSessionNotFound, "session not found")
}
return sess, nil
}
func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) {
if all && !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if all {
return s.repo.ListAllSessions(ctx)
}
return s.repo.ListSessionsByUser(ctx, c.UserID)
}
func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error {
sess, err := s.Get(ctx, c, id)
if err != nil {
return err
}
if !sess.Status.IsActive() {
return nil
}
s.sup.Kill(ctx, id, 5*time.Second)
return nil
}
func (s *sessionService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
if s.audit == nil {
return
}
uid := c.UserID
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
UserID: &uid, Action: action, TargetType: "acp_session", TargetID: targetID, Metadata: meta,
})
}