You've already forked agentic-coding-workflow
698 lines
23 KiB
Go
698 lines
23 KiB
Go
// session_service.go 实现 SessionService。Create 走完整事务(worktree
|
|
// acquire + supervisor.Spawn);Get/List/Terminate 是配套的简单读写。
|
|
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/brief"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
|
)
|
|
|
|
// SessionServiceConfig 是 service 的限流 + MCP 配置(从 AcpConfig / MCPConfig 派生)。
|
|
type SessionServiceConfig struct {
|
|
MaxActiveGlobal int
|
|
MaxActivePerUser int
|
|
SystemTokenTTL time.Duration
|
|
MCPPublicURL string
|
|
BriefTokenBudget int // 初始 prompt 简报的 token 硬上限(<=0 用 brief 默认值)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ArtifactAccess 是 acp 模块拉取某 requirement 各阶段最新产物的窄接口;adapter 在
|
|
// app.go 实现(基于 project.Repository.ListArtifactsByRequirement,按阶段取最大版本)。
|
|
type ArtifactAccess interface {
|
|
ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error)
|
|
}
|
|
|
|
type sessionService struct {
|
|
repo Repository
|
|
wsSvc workspace.WorkspaceService
|
|
wtSvc workspace.WorktreeService
|
|
wsRepo workspace.Repository
|
|
pa ProjectAccess
|
|
aa ArtifactAccess // 可为 nil(无产物注入)
|
|
composer brief.Composer // 可为 nil(退回 raw passthrough)
|
|
sup *Supervisor
|
|
audit audit.Recorder
|
|
cfg SessionServiceConfig
|
|
mcpTokens mcp.TokenService // spec §6.1 — system token issuance
|
|
}
|
|
|
|
// NewSessionService 构造 SessionService。aa / composer 可为 nil:缺任一时
|
|
// initial prompt 退回原始透传(不组装简报),保持旧行为。
|
|
func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService,
|
|
wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
|
|
pa ProjectAccess, aa ArtifactAccess, composer brief.Composer,
|
|
sup *Supervisor, rec audit.Recorder,
|
|
cfg SessionServiceConfig, mcpTokens mcp.TokenService) SessionService {
|
|
return &sessionService{
|
|
repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo,
|
|
pa: pa, aa: aa, composer: composer, sup: sup, audit: rec,
|
|
cfg: cfg, mcpTokens: mcpTokens,
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// 0. 互斥校验:branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
|
|
// service 层统一拦截,HTTP / MCP 入口共用。
|
|
specified := 0
|
|
if in.Branch != nil && *in.Branch != "" {
|
|
specified++
|
|
}
|
|
if in.IssueNumber != nil {
|
|
specified++
|
|
}
|
|
if in.RequirementNumber != nil {
|
|
specified++
|
|
}
|
|
if specified > 1 {
|
|
return nil, errs.New(errs.CodeInvalidInput,
|
|
"branch / issue_number / requirement_number are mutually exclusive; specify at most one")
|
|
}
|
|
|
|
// 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 创建)。
|
|
// 同时保留解析出的 issue/requirement 对象,供初始 prompt 组装简报复用。
|
|
var (
|
|
issueID, reqID *uuid.UUID
|
|
pmIssue *project.Issue
|
|
pmReq *project.Requirement
|
|
)
|
|
if in.IssueNumber != nil {
|
|
if iss, _ := s.pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber); iss != nil {
|
|
id := iss.ID
|
|
issueID = &id
|
|
pmIssue = iss
|
|
}
|
|
}
|
|
if in.RequirementNumber != nil {
|
|
if req, _ := s.pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber); req != nil {
|
|
id := req.ID
|
|
reqID = &id
|
|
pmReq = req
|
|
}
|
|
}
|
|
|
|
// 7. INSERT acp_sessions + system MCP token(spec §6.1 atomic guarantee)
|
|
// 预算快照:当前无 session 级覆盖入口,故直接继承 agent-kind 默认上限。快照到
|
|
// session 行使 supervisor / reaper 读取自洽,不必每次回查 agent-kind。
|
|
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,
|
|
OrchestratorStepID: in.OrchestratorStepID,
|
|
BudgetMaxCostUSD: kind.MaxCostUSD,
|
|
BudgetMaxTokens: kind.MaxTokens,
|
|
BudgetMaxWallClockSeconds: kind.MaxWallClockSeconds,
|
|
}
|
|
var (
|
|
out *Session
|
|
tokenRes mcp.IssueResult
|
|
)
|
|
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
|
|
inserted, err := s.repo.WithTx(tx).InsertSession(txCtx, sess)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = inserted
|
|
|
|
res, err := s.mcpTokens.IssueSystemTokenWithTx(txCtx, tx, mcp.IssueSystemTokenInput{
|
|
UserID: c.UserID,
|
|
ACPSessionID: inserted.ID,
|
|
ProjectIDs: []uuid.UUID{ws.ProjectID},
|
|
ExpiresIn: s.cfg.SystemTokenTTL,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tokenRes = res
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
s.releaseOnFailure(ctx, sess, sessCaller, worktreeID)
|
|
return nil, txErr
|
|
}
|
|
|
|
// 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 + revoke MCP token)
|
|
extraEnv := map[string]string{
|
|
EnvMCPToken: tokenRes.Plaintext,
|
|
EnvMCPURL: s.cfg.MCPPublicURL,
|
|
}
|
|
go func() {
|
|
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
if _, err := s.sup.Spawn(spawnCtx, out, kind, extraEnv); err != nil {
|
|
// 守卫式标记:Spawn 早期失败(进程未注册、onExit 不会触发)时把
|
|
// starting 收尾为 crashed;handshake 失败路径 onExit 已写终态,此处 no-op。
|
|
if _, merr := s.repo.MarkSessionFailedIfActive(spawnCtx, out.ID, err.Error()); merr != nil {
|
|
s.sup.log.Error("acp.spawn_failed.mark_session",
|
|
"session_id", out.ID, "err", merr.Error())
|
|
}
|
|
_ = s.mcpTokens.RevokeBySession(spawnCtx, out.ID)
|
|
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
|
|
}
|
|
}()
|
|
|
|
// 10. initial prompt:握手成功后异步转发。先在请求路径同步组装服务端简报
|
|
// (需求/Issue + 阶段产物 + 仓库定向 + 操作者自由文本),失败/无 PM 上下文时
|
|
// 退回原始透传,保持旧行为。
|
|
if in.InitialPrompt != nil && *in.InitialPrompt != "" {
|
|
text := s.composeInitialPrompt(ctx, c, out, pj, pmReq, pmIssue, *in.InitialPrompt)
|
|
go s.sendInitialPrompt(out.ID, text)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// composeInitialPrompt 组装 ACP session 的初始 prompt 简报。无 composer 或无 PM 上下文
|
|
// (既无 requirement 也无 issue)时返回原始 userPrompt(raw passthrough)。组装失败也
|
|
// 静默退回原始文本,绝不阻塞 session。成功组装后写一条 audit。
|
|
func (s *sessionService) composeInitialPrompt(ctx context.Context, c Caller, sess *Session,
|
|
pj *project.Project, req *project.Requirement, iss *project.Issue, userPrompt string) string {
|
|
|
|
if s.composer == nil || (req == nil && iss == nil) {
|
|
return userPrompt
|
|
}
|
|
|
|
var artifacts []*project.Artifact
|
|
if req != nil && s.aa != nil {
|
|
if arts, err := s.aa.ListLatestArtifacts(ctx, req.ID); err == nil {
|
|
artifacts = arts
|
|
}
|
|
}
|
|
|
|
res, err := s.composer.ComposeTaskBrief(ctx, brief.BriefInput{
|
|
Project: pj,
|
|
Requirement: req,
|
|
Issue: iss,
|
|
Artifacts: artifacts,
|
|
RepoCwd: sess.CwdPath,
|
|
Branch: sess.Branch,
|
|
UserPrompt: userPrompt,
|
|
TokenBudget: s.cfg.BriefTokenBudget,
|
|
Kind: brief.KindACPSession,
|
|
})
|
|
if err != nil || res.Text == "" {
|
|
return userPrompt
|
|
}
|
|
|
|
s.recordAudit(ctx, c, "acp.session.brief_composed", sess.ID.String(), map[string]any{
|
|
"requirement_id": idOrNil(req),
|
|
"issue_id": issueIDOrNil(iss),
|
|
"artifact_versions": artifactVersions(artifacts),
|
|
"brief_tokens": res.Tokens,
|
|
"truncated": res.Truncated,
|
|
"sections": res.Sections,
|
|
})
|
|
return res.Text
|
|
}
|
|
|
|
func idOrNil(req *project.Requirement) *uuid.UUID {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
id := req.ID
|
|
return &id
|
|
}
|
|
|
|
func issueIDOrNil(iss *project.Issue) *uuid.UUID {
|
|
if iss == nil {
|
|
return nil
|
|
}
|
|
id := iss.ID
|
|
return &id
|
|
}
|
|
|
|
// artifactVersions 把产物列表归约为 {phase: version} 映射(每阶段取出现的最大版本),
|
|
// 仅用于 audit 元数据。
|
|
func artifactVersions(arts []*project.Artifact) map[string]int {
|
|
out := make(map[string]int, len(arts))
|
|
for _, a := range arts {
|
|
if a == nil {
|
|
continue
|
|
}
|
|
if v, ok := out[string(a.Phase)]; !ok || a.Version > v {
|
|
out[string(a.Phase)] = a.Version
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
// 回合相关联:在发送前登记回合 id("client-init"),使响应的 stopReason 可被
|
|
// 相关联到这个回合。tracker 可能为 nil(部分测试)——StartTurn 已做 nil 守卫。
|
|
if tr := relay.Tracker(); tr != nil {
|
|
if _, err := tr.StartTurn(context.Background(), "client-init"); err != nil {
|
|
slog.Warn("acp.session.start_turn", "session_id", sid, "err", err.Error())
|
|
}
|
|
}
|
|
_ = relay.SendClient(msg)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 编译期断言:sessionService 同时实现 SessionService 与 TurnRunner(编排器依赖后者)。
|
|
var _ TurnRunner = (*sessionService)(nil)
|
|
|
|
// promptResult 是 session/prompt 响应 Result 中我们关心的字段。
|
|
type promptResult struct {
|
|
StopReason string `json:"stopReason"`
|
|
}
|
|
|
|
// SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回 agent 上报的原始
|
|
// stopReason。这是编排器驱动一个阻塞回合的核心原语:先轮询等待 supervisor handshake
|
|
// 完成(relay + agent_session_id 就绪,复用 sendInitialPrompt 的就绪检测),随后走
|
|
// server-initiated relay.Call('session/prompt') 取响应里的 stopReason。
|
|
//
|
|
// ctx 控制整体超时(编排器以 cfg.TurnTimeout 约束,须 < jobReaper LeaseTimeout)。
|
|
func (s *sessionService) SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (string, error) {
|
|
relay, agentSessionID, err := s.waitForRelayReady(ctx, sessionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
params := map[string]any{
|
|
"sessionId": agentSessionID,
|
|
"prompt": []map[string]any{{"type": "text", "text": prompt}},
|
|
}
|
|
resp, err := relay.Call(ctx, "session/prompt", params)
|
|
if err != nil {
|
|
return "", errs.Wrap(err, errs.CodeInternal, "session/prompt call")
|
|
}
|
|
var pr promptResult
|
|
if len(resp.Result) > 0 {
|
|
if uerr := json.Unmarshal(resp.Result, &pr); uerr != nil {
|
|
return "", errs.Wrap(uerr, errs.CodeInternal, "parse session/prompt result")
|
|
}
|
|
}
|
|
if pr.StopReason != "" {
|
|
// 落库最近一次 stopReason(best-effort),供观测/网关复用。
|
|
_ = s.repo.UpdateSessionLastStopReason(context.WithoutCancel(ctx), sessionID, pr.StopReason)
|
|
}
|
|
return pr.StopReason, nil
|
|
}
|
|
|
|
// waitForRelayReady 轮询直到 relay 与 agent_session_id 同时就绪或 ctx 截止。
|
|
// 200ms 一次,与 sendInitialPrompt 一致。
|
|
func (s *sessionService) waitForRelayReady(ctx context.Context, sessionID uuid.UUID) (*Relay, string, error) {
|
|
tick := time.NewTicker(200 * time.Millisecond)
|
|
defer tick.Stop()
|
|
for {
|
|
relay := s.sup.GetRelay(sessionID)
|
|
if relay != nil {
|
|
sess, _ := s.repo.GetSessionByID(ctx, sessionID)
|
|
if sess != nil && sess.AgentSessionID != nil {
|
|
return relay, *sess.AgentSessionID, nil
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, "", ctx.Err()
|
|
case <-tick.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ResumeSession 在崩溃/退出 session 的同一 cwd/worktree 上重新拉起 agent。复用原
|
|
// session 的 workspace/branch/cwd/main 标记与编排器 step 关联:重新占用 worktree
|
|
// (main → AcquireMainWorktree CAS;子 worktree → 按 holder Acquire),签发新 system
|
|
// token,再 sup.Spawn。Spawn 失败时回滚 worktree(releaseOnFailure)。
|
|
//
|
|
// 返回的是被复用并重置为 starting 状态的同一行 session(同 ID),供编排器随后
|
|
// SendPromptAndWait。仅供编排器内部调用(caller 为 run.owner)。
|
|
func (s *sessionService) ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error) {
|
|
sess, err := s.repo.GetSessionByID(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sess.Status.IsActive() {
|
|
// 已经活跃:无需 resume,直接返回。
|
|
return sess, nil
|
|
}
|
|
|
|
kind, err := s.repo.GetAgentKindByID(ctx, sess.AgentKindID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sessCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin, SessionID: &sess.ID}
|
|
if sess.IsMainWorktree {
|
|
ok, aerr := s.repo.AcquireMainWorktree(ctx, sess.ID, sess.WorkspaceID)
|
|
if aerr != nil {
|
|
return nil, aerr
|
|
}
|
|
if !ok {
|
|
return nil, errs.New(errs.CodeAcpSessionBranchConflict, "main worktree in use; cannot resume")
|
|
}
|
|
} else {
|
|
wt, ferr := s.findOrCreateWorktree(ctx, sessCaller, sess.WorkspaceID, sess.Branch)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
if _, aerr := s.wtSvc.Acquire(ctx, sessCaller, wt.ID); aerr != nil {
|
|
return nil, aerr
|
|
}
|
|
}
|
|
|
|
// 重新签发 system token + 标记 session 回到 starting(清空上次终态字段)。
|
|
var (
|
|
tokenRes mcp.IssueResult
|
|
)
|
|
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
|
|
if rerr := s.repo.WithTx(tx).ResetSessionForResume(txCtx, sess.ID); rerr != nil {
|
|
return rerr
|
|
}
|
|
res, ierr := s.mcpTokens.IssueSystemTokenWithTx(txCtx, tx, mcp.IssueSystemTokenInput{
|
|
UserID: c.UserID,
|
|
ACPSessionID: sess.ID,
|
|
ProjectIDs: []uuid.UUID{sess.ProjectID},
|
|
ExpiresIn: s.cfg.SystemTokenTTL,
|
|
})
|
|
if ierr != nil {
|
|
return ierr
|
|
}
|
|
tokenRes = res
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
s.releaseOnFailure(ctx, sess, sessCaller, nil)
|
|
return nil, txErr
|
|
}
|
|
sess.Status = SessionStarting
|
|
|
|
extraEnv := map[string]string{
|
|
EnvMCPToken: tokenRes.Plaintext,
|
|
EnvMCPURL: s.cfg.MCPPublicURL,
|
|
}
|
|
if _, serr := s.sup.Spawn(ctx, sess, kind, extraEnv); serr != nil {
|
|
if _, merr := s.repo.MarkSessionFailedIfActive(ctx, sess.ID, serr.Error()); merr != nil {
|
|
s.sup.log.Error("acp.resume.mark_session", "session_id", sess.ID, "err", merr.Error())
|
|
}
|
|
_ = s.mcpTokens.RevokeBySession(ctx, sess.ID)
|
|
s.releaseOnFailure(ctx, sess, sessCaller, nil)
|
|
return nil, serr
|
|
}
|
|
|
|
s.recordAudit(ctx, c, "acp.session.resume", sess.ID.String(), map[string]any{
|
|
"branch": sess.Branch,
|
|
"cwd_path": sess.CwdPath,
|
|
})
|
|
return sess, nil
|
|
}
|
|
|
|
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, f SessionListFilter) ([]*Session, error) {
|
|
if f.All && !c.IsAdmin {
|
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
|
}
|
|
if f.All {
|
|
return s.repo.ListAllSessions(ctx, f.RequirementID)
|
|
}
|
|
return s.repo.ListSessionsByUser(ctx, c.UserID, f.RequirementID)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// defaultDashboardWindow 是 run-history 汇总在调用方未指定时间窗时的默认回溯。
|
|
const defaultDashboardWindow = 30 * 24 * time.Hour
|
|
|
|
// Dashboard 返回 run-history 汇总 + 按天序列。非 admin 强制按 caller scope。
|
|
func (s *sessionService) Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error) {
|
|
to := in.To
|
|
if to.IsZero() {
|
|
to = time.Now().UTC()
|
|
}
|
|
from := in.From
|
|
if from.IsZero() {
|
|
from = to.Add(-defaultDashboardWindow)
|
|
}
|
|
if !from.Before(to) {
|
|
return nil, errs.New(errs.CodeInvalidInput, "from must be before to")
|
|
}
|
|
f := DashboardFilter{
|
|
ProjectID: in.ProjectID,
|
|
RequirementID: in.RequirementID,
|
|
From: from,
|
|
To: to,
|
|
}
|
|
// 非 admin 只能看自己的会话:UserID 固定为 caller。admin 留空(全量)。
|
|
if !c.IsAdmin {
|
|
uid := c.UserID
|
|
f.UserID = &uid
|
|
}
|
|
rollup, err := s.repo.SessionRollup(ctx, f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byDay, err := s.repo.SessionsByDay(ctx, f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DashboardResult{Rollup: rollup, ByDay: byDay, From: from, To: to}, nil
|
|
}
|
|
|
|
// Timeline 返回单会话事件时间线(先经 Get 做 owner/admin 访问校验)。
|
|
func (s *sessionService) Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error) {
|
|
if _, err := s.Get(ctx, c, sessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.SessionTimeline(ctx, sessionID)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|