// Package app 是应用的装配根:把 config、logger、DB 池、各业务模块、HTTP // 路由按依赖顺序拼起来,并暴露 Run 给入口程序。本包只做组合,不引入新业务。 // // 启动顺序: // 1. 用 config.Env 派生 logger(FromEnv 在生产环境默认开启 source)。 // 2. 跑 migrations(先 migrate 再开池,避免应用启动时表还没建好;migrate // 失败直接返错由调用方记录、退出)。 // 3. 创建 *db.Pool(实际是 *pgxpool.Pool 别名),失败时不再继续。 // 4. 模块装配:audit recorder -> user repo+service(注入 audit)-> notify // repo+dispatcher(注入 dummy notifier)-> notify handler(复用 userSvc // 作为 SessionResolver,user.Service 接口本身满足该契约)。 // 5. 路由:httpx.NewRouter 返回 chi.Router 接口,直接挂 RequestID/Recover/ // Logger 三个全局中间件,再让模块 Mount 自己的子路由。 // 6. 可选 bootstrap:仅当配置里同时给了 admin 邮箱/密码时尝试。失败仅 // Warn 不阻塞启动。 // 7. 把所有依赖封装到 *App,由 Run 负责启动 http server 与优雅关停。 package app import ( "context" "embed" "errors" "fmt" "io/fs" "log/slog" "net/http" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/acp/sandbox" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/brief" "github.com/yan1h/agent-coding-workflow/internal/changerequest" "github.com/yan1h/agent-coding-workflow/internal/chat" "github.com/yan1h/agent-coding-workflow/internal/codeindex" "github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/docs" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "github.com/yan1h/agent-coding-workflow/internal/infra/git" "github.com/yan1h/agent-coding-workflow/internal/infra/llm" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/metrics" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/storage" "github.com/yan1h/agent-coding-workflow/internal/infra/vcs" "github.com/yan1h/agent-coding-workflow/internal/jobs" "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" mcp "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/orchestrator" "github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/projectmemory" runmod "github.com/yan1h/agent-coding-workflow/internal/run" "github.com/yan1h/agent-coding-workflow/internal/security" "github.com/yan1h/agent-coding-workflow/internal/terminal" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" httpmw "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。 type App struct { cfg *config.Config log *slog.Logger pool *db.Pool server *http.Server dispatcher *notify.Dispatcher jobs *jobs.Module jobsCancel context.CancelFunc acpSup *acp.Supervisor // ACP supervisor — ShutdownAll on Run shutdown runSup *runmod.RunSupervisor // run profiles supervisor — ShutdownAll on Run shutdown termSup *terminal.Supervisor // admin terminal supervisor — CloseAll on shutdown } // New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。 // dist 是 cmd/server 用 //go:embed 注入的前端构建产物,根目录为 web/dist; // fs.Sub 之后交给 SPA handler 提供静态文件 + index.html 回退。 func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { log := logger.FromEnv(cfg.Env, nil) // 跑 migrations 必须在开池前;否则首次启动连不存在的表会让 ping 失败。 if err := db.Migrate(ctx, cfg.DB.DSN, "file://migrations"); err != nil { return nil, fmt.Errorf("migrate: %w", err) } pool, err := db.NewPool(ctx, cfg.DB.DSN) if err != nil { return nil, err } auditRec := audit.NewPostgresRecorder(pool) wrappedAudit := mcp.AuditWrap(auditRec) // 给业务 service 用,自动注入 via_mcp metadata auditReader := audit.NewPostgresReader(pool) // ===== observability:Prometheus 指标注册中心 ===== // GaugeFunc 数据源用独立 pool 查询,避免与模块构造顺序耦合(acpRepo / chatRepo // 此时尚未构造)。查询失败时回退 0,不阻断 /metrics scrape。 var appMetrics *metrics.Metrics if cfg.Metrics.Enabled { activeSessionsFn := func() float64 { qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() var n int64 if err := pool.QueryRow(qctx, `SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting','running')`).Scan(&n); err != nil { return 0 } return float64(n) } llmCostTotalFn := func() float64 { qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() var cost float64 if err := pool.QueryRow(qctx, `SELECT COALESCE(SUM(cost_usd),0)::float8 FROM llm_usage`).Scan(&cost); err != nil { return 0 } return cost } appMetrics = metrics.New(activeSessionsFn, llmCostTotalFn) } // ===== secret 加密:versioned KeyedEncryptor + Postgres key store ===== // provider 据 cfg.Crypto.Provider 选取(env|vault|kms);vault/kms 客户端尚未 // 接入,选中时退回 env provider(version 1 = APP_MASTER_KEY),保持启动不破。 cryptoProvider := buildCryptoProvider(cfg, log) cryptoKeyStore := crypto.NewPostgresKeyStore(pool) keyedEnc := crypto.NewKeyedEncryptor(cryptoProvider, cryptoKeyStore) // enc 是 v1 shim,背靠同一个 KeyedEncryptor:~30 个既有调用点(workspace/chat/ // acp/run 的 *crypto.Encryptor 参数)无需改动。需要按版本持久化新密文的少数 // 调用点(re-encrypt job / rotate-key)直接用 keyedEnc。 // 注意:shim 的写版本固定为 1(与历史密文格式一致);rotate 后由 re-encrypt job // 把旧行迁到新版本,shim 仍能解 v1(DB key_version 列携带版本)。 enc := crypto.NewEncryptorFromKeyed(keyedEnc, 1) userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) // 登录暴力破解节流:装配期回填启用的实例(cfg-gated)。 if setter, ok := userSvc.(user.LoginThrottleSetter); ok { setter.SetLoginThrottle(user.NewLoginThrottle(user.LoginThrottleConfig{ Enabled: cfg.User.LoginThrottle.Enabled, MaxFailures: cfg.User.LoginThrottle.MaxFailures, Window: cfg.User.LoginThrottle.Window, }, nil)) } // notifyDispatcher 先于 project 服务构造:phase-change 领域事件经其 fan-out。 notifyRepo := notify.NewPostgresRepository(pool) notifyPrefsRepo := notify.NewPostgresPrefsRepository(pool) notifyDispatcher := notify.NewDispatcher(notifyRepo, log) notifyDispatcher.Register(notify.NewDummyNotifier(log)) // inbox 实时推送:per-user SSE 扇出中心。Dispatch 落库成功后 Publish。 notifyStream := notify.NewStreamHub() notifyDispatcher.SetStreamHub(notifyStream) // email / im 外发通道:仅在配置启用时注册。二者均消费 notification_prefs // 做 per-user 开关 + min_severity 过滤;in-app inbox 始终投递不受影响。 if cfg.Notify.Email.Enabled { notifyDispatcher.Register(notify.NewEmailNotifier(notify.EmailConfig{ Enabled: cfg.Notify.Email.Enabled, SMTPHost: cfg.Notify.Email.SMTPHost, Port: cfg.Notify.Email.Port, From: cfg.Notify.Email.From, Username: cfg.Notify.Email.Username, Password: cfg.Notify.Email.Password, }, notifyPrefsRepo, emailLookupAdapter{svc: userSvc}, nil, log)) } if cfg.Notify.IM.Enabled { notifyDispatcher.Register(notify.NewIMNotifier(notify.IMConfig{ Enabled: cfg.Notify.IM.Enabled, }, notifyPrefsRepo, enc, nil, log)) } projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, wrappedAudit) // crRepo 在此提前构造(仅需 pool),供 Done 网关查询 workspace PR 合并状态; // 同一实例在下方 change-request 装配处复用(无状态,复用安全)。 crRepo := changerequest.NewPostgresRepository(pool) // 阶段网关引擎:Repo 查阶段指针 / 产物 verdict;Workspace 查 PR 合并状态(Done 网关)。 phaseGate := project.NewGateRunner(project.GateDeps{ Repo: projectRepo, Workspace: workspaceStateAdapter{crRepo: crRepo}, }, nil) phaseEmitter := phaseEventEmitterAdapter{d: notifyDispatcher} requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit, phaseEmitter, phaseGate) issueSvc := project.NewIssueService(projectRepo, wrappedAudit) artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit) spaSub, err := fs.Sub(dist, "web/dist") if err != nil { return nil, fmt.Errorf("sub fs: %w", err) } spaHandler := httpx.NewSPAHandler(spaSub) r := httpx.NewRouter(httpx.RouterDeps{ HealthCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) }, ReadyCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) }, SPAHandler: spaHandler, Logger: log, Security: httpmw.SecurityHeadersConfig{ Enabled: cfg.HTTP.Security.Enabled, TLSEnabled: cfg.HTTP.TLS.Enabled, HSTSMaxAgeSeconds: cfg.HTTP.Security.HSTSMaxAgeSeconds, FrameOptions: cfg.HTTP.Security.FrameOptions, ReferrerPolicy: cfg.HTTP.Security.ReferrerPolicy, ContentSecurityPolicy: cfg.HTTP.Security.ContentSecurityPolicy, }, CORS: httpmw.CORSConfig{ Enabled: cfg.HTTP.CORS.Enabled, AllowedOrigins: cfg.HTTP.CORS.AllowedOrigins, AllowedMethods: cfg.HTTP.CORS.AllowedMethods, AllowedHeaders: cfg.HTTP.CORS.AllowedHeaders, AllowCredentials: cfg.HTTP.CORS.AllowCredentials, MaxAgeSeconds: cfg.HTTP.CORS.MaxAgeSeconds, }, MetricsHandler: func() http.Handler { if appMetrics == nil { return nil } return appMetrics.Handler() }(), MetricsAuthToken: cfg.Metrics.AuthToken, }) // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。 user.NewHandler(userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).WithStreamHub(notifyStream).WithPrefs(notifyPrefsRepo, enc).Mount(r) // admin-only 审计日志查看器(GET /api/v1/admin/audit-logs)。 audit.NewHandler(auditReader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== workspace 模块装配 ===== // 依赖图: // git.Runner (cfg) ─┐ // crypto.Encryptor ─┼─ workspaceService (clones=nil) ─┐ // workspace.Repo ───┘ ├─ CloneRunner ─┐ // │ │ // SetScheduler ───┴───────────────┘ // │ // WorktreeService / GitOpsService // wsDataDir 是数据根;pathing.MainPath/WorktreePath 内部会再 join "workspaces" 子目录。 // 这里显式 mkdir 出 ${dataDir}/workspaces 让首个 clone 的父目录已经存在。 wsDataDir := cfg.Workspace.DataDir if err := os.MkdirAll(filepath.Join(wsDataDir, "workspaces"), 0o755); err != nil { return nil, fmt.Errorf("mkdir workspace data dir: %w", err) } tmpDir := filepath.Join(wsDataDir, "tmp") // 启动期清掉残留 tmp(可能含上次崩溃留下的 askpass 脚本与 SSH 私钥) _ = os.RemoveAll(tmpDir) if err := os.MkdirAll(tmpDir, 0o700); err != nil { return nil, fmt.Errorf("mkdir workspace tmp: %w", err) } gitr := git.NewDefaultRunner(git.Config{ Binary: cfg.Git.Binary, TmpDir: tmpDir, CloneTimeout: cfg.Workspace.CloneTimeout, FetchTimeout: cfg.Workspace.FetchTimeout, PushTimeout: cfg.Workspace.PushTimeout, OpsTimeout: cfg.Workspace.OpsTimeout, }) // 服务端上下文组装器:把 (requirement|issue + 阶段产物 + 仓库定向 + 操作者文本) // 组装成 token 受限的任务简报。注入 ACP session 初始 prompt 与 chat 的 FK 上下文。 // orienter 走同一个 git runner(仅 ACP 场景且有 cwd 时才做仓库定向)。 briefComposer := brief.NewComposer(brief.NewRepoOrienter(gitr), brief.Config{ DefaultTokenBudget: cfg.Acp.BriefTokenBudget, }) wsRepo := workspace.NewPostgresRepository(pool) // 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住; // 每个被复位的工作区写一条 workspace.sync_aborted_on_restart audit。 if abortedIDs, err := wsRepo.ResetStuckSyncStatuses(ctx); err != nil { log.Warn("reset stuck workspace sync statuses", "err", err.Error()) } else { for _, wsID := range abortedIDs { if rerr := auditRec.Record(ctx, audit.Entry{ Action: "workspace.sync_aborted_on_restart", TargetType: "workspace", TargetID: wsID.String(), }); rerr != nil { log.Warn("workspace.sync_aborted_on_restart audit failed", "workspace_id", wsID, "err", rerr.Error()) } } } pa := projectAccessAdapter{p: projectSvc} // 先以 nil scheduler 构造 service,cloneRunner 再回填(环依赖:runner 需要凭据访问器,访问器在 service 上) wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, wsDataDir) // 共享凭据加载器:CloneRunner 与 GitOpsService 都需要。 // 通过 CredentialAccessor 接口避免直接持有 *workspaceService 私有类型。 credAccessor, ok := wsSvc.(workspace.CredentialAccessor) if !ok { return nil, fmt.Errorf("workspace service does not implement CredentialAccessor") } credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { return credAccessor.LoadDecryptedCredential(ctx, wsID) } cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, cfg.Workspace.CloneTimeout) if setter, ok := wsSvc.(workspace.SchedulerSetter); ok { setter.SetScheduler(cloneRunner) } wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, wsDataDir) gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader) workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== change-request (PR) + VCS provider 装配 ===== // vcs.Provider 是 git-host REST 客户端(v1 仅 Gitea);ParseRepoRef 用 Host() // 做主机白名单校验。crSvc 复用 pa(owner+admin 写鉴权)、wsRepo(GitRemoteURL // -> RepoRef);用 wrappedAudit 让经 MCP 调用时自动打 via_mcp 标记(与 PM // service 一致)。合并网关:review_verdict 必须为 approved(审批仅 Web 暴露, // agent 无法自批自合)。 vcsProvider := vcs.NewGiteaProvider(cfg.VCS.Gitea.BaseURL, cfg.VCS.Gitea.Token, cfg.VCS.Gitea.Timeout) // crRepo 已在上方 project 装配处提前构造(供 Done 网关复用),此处直接复用。 crSvc := changerequest.NewService(crRepo, changeRequestWorkspaceAdapter{repo: wsRepo}, wrappedAudit, vcsProvider, pa, changerequest.Config{ RequireCIPass: cfg.VCS.RequireCIPass, MergeMethod: cfg.VCS.Gitea.MergeMethod, Host: vcsProvider.Host(), }) changerequest.NewHandler(crSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== chat 模块装配 ===== // 依赖图: // pgx.Pool ─ chat.Repository ─┐ // ├─ EndpointLoader ─ llm.Registry ─┐ // crypto.Encryptor ───────────┘ │ // │ // storage.LocalFS ─ Storage ──┐ │ // ├─ MessageService ─ Handler ──────┤ // notify.Dispatcher ─ StreamerHub ─ ConversationService ─ Hub ───┘ // // 启动期把卡死的 pending 消息复位为 error,避免行永远卡住。 chatRepo := chat.NewRepository(pool) if err := chatRepo.MarkPendingAsErrorOnStartup(ctx); err != nil { log.Warn("mark chat pending messages as error on startup", "err", err.Error()) } endpointLoader := chat.NewEndpointLoader(chatRepo, enc) llmRegistry := llm.NewRegistry(endpointLoader) var attachStorage storage.Storage switch cfg.Storage.Driver { case "localfs", "": s, err := storage.NewLocalFS(cfg.Storage.LocalFS.BasePath) if err != nil { return nil, fmt.Errorf("storage localfs: %w", err) } attachStorage = s default: return nil, fmt.Errorf("unsupported storage driver %q", cfg.Storage.Driver) } // safety_margin_pct 配置为百分比(5.0 表示 5%),构造器期望小数(0.05),故 /100。 safetyFraction := cfg.Chat.History.SafetyMarginPct / 100 retention := time.Duration(cfg.Chat.Stream.RingBufferRetentionSeconds) * time.Second chatEpSvc := chat.NewEndpointService(chatRepo, enc, llmRegistry, auditRec, log) chatTplSvc := chat.NewTemplateService(chatRepo, auditRec, log) chatConvSvc := chat.NewConversationService(chatRepo, llmRegistry, auditRec, log) chatHub := chat.NewStreamerHub(chatRepo, llmRegistry, notifyDispatcher, auditRec, chatConvSvc, log, retention, safetyFraction) chatMsgSvc := chat.NewMessageService(chatRepo, attachStorage, chatHub, auditRec, log, chat.MessageServiceConfig{ MimeWhitelist: cfg.Chat.Attachment.MimeWhitelist, MaxFileBytes: cfg.Chat.Attachment.MaxFileSizeMB << 20, MaxMsgBytes: cfg.Chat.Attachment.MaxMessageSizeMB << 20, SafetyPct: safetyFraction, }, chatContextReaderAdapter{projectRepo: projectRepo, composer: briefComposer}) chatUsageSvc := chat.NewUsageService(chatRepo) chatUploader := chat.NewUploader(chatRepo, attachStorage, cfg.Chat.Attachment.MimeWhitelist, cfg.Chat.Attachment.MaxFileSizeMB<<20, ) chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc, chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== MCP 模块装配(第一段:token/限流)===== // Server 本体的装配在 ACP / run 模块之后(ServerDeps 依赖 acp bridge 与 runSvc), // 这里只构造 ACP reaper / supervisor 需要的 token service。 mcpRepo := mcp.NewPostgresRepository(pool) mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // mcp 自身 audit 不需要 via_mcp 标记 mcpRateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{ PerTokenPerMinute: cfg.MCP.RateLimit.PerTokenPerMinute, GlobalPerMinute: cfg.MCP.RateLimit.GlobalPerMinute, }, nil) // ===== ACP module wiring (Part 2: full SessionService + Supervisor) ===== acpRepo := acp.NewPostgresRepository(pool) akSvc := acp.NewAgentKindService(acpRepo, enc, auditRec) // Phase 1: startup reaper — recover sessions interrupted by previous crash. // ResetStuckSessionsOnRestart marks all 'starting'/'running' sessions as 'crashed' // and returns them; we release their worktrees and notify owners. abortedSessions, rerr := acpRepo.ResetStuckSessionsOnRestart(ctx) if rerr != nil { log.Warn("acp.startup_reaper.reset_failed", "err", rerr.Error()) } else { for _, sess := range abortedSessions { if sess.IsMainWorktree { if _, rerr := acpRepo.ReleaseMainWorktree(ctx, sess.WorkspaceID, sess.ID); rerr != nil { log.Warn("acp.startup_reaper.release_main_failed", "session_id", sess.ID, "err", rerr.Error()) } } else { if _, rerr := wtSvc.ReleaseByHolder(ctx, "session:"+sess.ID.String()); rerr != nil { log.Warn("acp.startup_reaper.release_subwt_failed", "session_id", sess.ID, "err", rerr.Error()) } } // 该会话已被标 crashed,其挂起的权限请求也应一并标 expired,避免悬挂。 if _, rerr := acpRepo.ExpirePendingPermissionRequestsBySession(ctx, sess.ID); rerr != nil { log.Warn("acp.startup_reaper.expire_permissions_failed", "session_id", sess.ID, "err", rerr.Error()) } uid := sess.UserID if rerr := auditRec.Record(ctx, audit.Entry{ UserID: &uid, Action: "acp.session.aborted_on_restart", TargetType: "acp_session", TargetID: sess.ID.String(), Metadata: map[string]any{ "agent_kind_id": sess.AgentKindID.String(), "branch": sess.Branch, }, }); rerr != nil { log.Warn("acp.startup_reaper.audit_failed", "session_id", sess.ID, "err", rerr.Error()) } if derr := notifyDispatcher.Dispatch(ctx, notify.Message{ UserID: sess.UserID, Topic: "acp.session_crashed", Severity: notify.SeverityWarning, Title: "ACP session terminated by server restart", Body: "Your session was interrupted while the server restarted.", Metadata: map[string]any{ "session_id": sess.ID.String(), "branch": sess.Branch, }, }); derr != nil { log.Warn("acp.startup_reaper.notify_failed", "session_id", sess.ID, "err", derr.Error()) } } } // Phase 1.5 (post-ACP-reaper): revoke MCP system tokens still bound to crashed/exited // sessions. spec §6.4 — must run AFTER ACP reaper so sessions are already marked crashed. revokedTokenIDs, rerr := mcpRepo.ReapStaleSystemTokens(ctx) if rerr != nil { log.Warn("mcp.startup_reaper.failed", "err", rerr.Error()) } else if len(revokedTokenIDs) > 0 { log.Info("mcp.startup_reaper.revoked", "count", len(revokedTokenIDs)) _ = auditRec.Record(ctx, audit.Entry{ Action: "mcp.token.expire_on_restart", TargetType: "mcp_token", TargetID: "(batch)", Metadata: map[string]any{ "count": len(revokedTokenIDs), "token_ids": func() []string { out := make([]string, 0, len(revokedTokenIDs)) for _, id := range revokedTokenIDs { out = append(out, id.String()) } return out }(), }, }) } // Phase 2: build supervisor + session service. acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo} acpSup := acp.NewSupervisor( acpRepo, auditRec, notifyDispatcher, wtSvc, wsRepo, mcpTokenSvc, enc, acp.SupervisorConfig{ SpawnTimeout: cfg.Acp.SpawnTimeout, KillGrace: cfg.Acp.KillGrace, StderrBufferLines: cfg.Acp.StderrBufferLines, StderrTailBytes: cfg.Acp.StderrTailBytes, EventMaxPayload: cfg.Acp.EventMaxPayload, EventTruncateField: cfg.Acp.EventTruncateField, ShutdownGrace: cfg.Acp.ShutdownGrace, AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"), DefaultProjectBudgetUSD: cfg.Acp.DefaultProjectBudgetUSD, // per-session 沙箱:factory 据 mode 选实现(非 Linux / none 时为 no-op)。 Sandbox: sandbox.New(sandbox.Mode(cfg.Acp.Sandbox.Mode)), SandboxMode: sandbox.Mode(cfg.Acp.Sandbox.Mode), SandboxBaseUID: cfg.Acp.Sandbox.BaseUID, DataRoot: cfg.DataDir, EgressProxyURL: cfg.Acp.Sandbox.ProxyURL, SandboxRlimits: sandbox.Limits{ AddressSpaceBytes: cfg.Acp.Sandbox.AddressSpaceBytes, NProc: cfg.Acp.Sandbox.NProc, CPUSeconds: cfg.Acp.Sandbox.CPUSeconds, PIDs: cfg.Acp.Sandbox.PIDs, DiskBytes: cfg.Acp.Sandbox.DiskBytes, }, }, log, ) // 成本核算:注入 model 价格查询(acp → chat.ComputeCost),并给 agent-kind // 服务注入 model 校验器。两者均装配期回填,避免 acp 构造期依赖 chat。 acpSup.SetModelPriceLookup(acpModelPriceAdapter{repo: chatRepo}) if appMetrics != nil { acpSup.SetMetrics(appMetrics) } if akSetter, ok := akSvc.(interface { SetModelValidator(acp.ModelValidator) }); ok { akSetter.SetModelValidator(acpModelValidatorAdapter{repo: chatRepo}) } // 回合完成检测事件总线:进程内 pub-sub,供未来编排层订阅。注册一个日志订阅者 // 把总线端到端跑通(编排层尚未实现)。 turnBus := acp.NewTurnBus(log) turnBus.Subscribe("log", func(_ context.Context, ev acp.TurnEvent) { log.Info("acp.turn_event", "kind", string(ev.Kind), "session_id", ev.SessionID, "turn_index", ev.TurnIndex, "stop_reason", string(ev.StopReason), "raw_stop_reason", ev.RawStopReason) }) acpSup.SetTurnBus(turnBus) // 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService / // SetRelayLocator),再注入 handler。 acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log) // HITL:待审权限通知(关闭页面的用户也能被提醒去 /approvals)+ 决策计数。 acpPermSvc.SetNotifier(notifyDispatcher) if appMetrics != nil { acpPermSvc.SetMetrics(appMetrics) } acpSup.SetPermissionService(acpPermSvc) acpPermSvc.SetRelayLocator(acpSup) // HITL:允许任意 project maintainer(非仅会话 owner)处理待审权限请求。 // 复用成员感知的 projectAccessAdapter(与 project_service ACL 一致)。 acpPermSvc.SetProjectMaintainer(projectMaintainerAdapter{pa: pa}) sessSvc := acp.NewSessionService( acpRepo, wsSvc, wtSvc, wsRepo, acpProjAccess, acpArtifactAccessAdapter{projectRepo: projectRepo}, briefComposer, acpSup, auditRec, acp.SessionServiceConfig{ MaxActiveGlobal: cfg.Acp.MaxActiveGlobal, MaxActivePerUser: cfg.Acp.MaxActivePerUser, SystemTokenTTL: cfg.MCP.SystemTokenTTL, MCPPublicURL: cfg.MCP.PublicURL, BriefTokenBudget: cfg.Acp.BriefTokenBudget, }, mcpTokenSvc, ) acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, acpPermSvc, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, PongTimeout: cfg.Acp.WSPongTimeout, SendBuffer: cfg.Acp.WSSendBuffer, }).Mount(r) // ===== orchestrator/planner module wiring ===== // jobsRepo 提前构造(无 ACP 依赖):编排器 StartRun/redrive 用它入队 orchestrator.step。 jobsRepo := jobs.NewPostgresRepository(pool) // ===== code index (pgvector RAG) + project memory + auto-docs wiring ===== // EmbeddingSelector 解析配置的 embedding endpoint+model(OpenAI/Gemini)。无 // endpoint 配置时返回 ErrEmbeddingDisabled:search_code/reindex 报 typed 错误, // memory_search 降级关键字回退。Anthropic-only 部署只有关键字能力。 embedSel := &embeddingSelector{registry: llmRegistry, cfg: cfg.CodeIndex} wsLocator := &workspaceLocatorAdapter{repo: wsRepo} codeIndexRepo := codeindex.NewPostgresRepository(pool) projectMemoryRepo := projectmemory.NewPostgresRepository(pool) docsRepo := docs.NewPostgresRepository(pool) chunker := codeindex.NewChunker() if cfg.CodeIndex.ChunkWindowLines > 0 { chunker.WindowLines = cfg.CodeIndex.ChunkWindowLines } if cfg.CodeIndex.ChunkOverlapLines > 0 { chunker.OverlapLines = cfg.CodeIndex.ChunkOverlapLines } if cfg.CodeIndex.MaxFileBytes > 0 { chunker.MaxFileBytes = cfg.CodeIndex.MaxFileBytes } var codeIndexSvc codeindex.Service var projectMemorySvc projectmemory.Service if cfg.CodeIndex.Enabled { codeIndexSvc = codeindex.NewService(codeIndexRepo, wsLocator, gitr, embedSel, jobsRepo, cfg.CodeIndex.SearchTopKCap) } // project memory 始终可用(embedder 缺失时只是无向量、走关键字回退)。 projectMemorySvc = projectmemory.NewService(projectMemoryRepo, embedSel, log) // codeindex.build 处理器(失败时广播给 admin)。 codeIndexFailureNotify := func(nctx context.Context, run *codeindex.Run, cause error) { admins, lerr := userSvc.ListAdmins(nctx) if lerr != nil { return } for _, a := range admins { _ = notifyDispatcher.Dispatch(nctx, notify.Message{ UserID: a.ID, Topic: "codeindex.build_failed", Severity: notify.SeverityError, Title: "Code index build failed", Body: fmt.Sprintf("workspace=%s commit=%s: %v", run.WorkspaceID, run.CommitSHA, cause), }) } } codeIndexBuildRunner := codeindex.NewBuildRunner(codeIndexRepo, wsLocator, gitr, embedSel, chunker, codeIndexFailureNotify, log) // docs.commit_summary 处理器 + service。Summarizer 复用 chat 的 title-generator // model 走 llm.Stream 收集 markdown。docs 默认关闭以省 LLM 成本。 docsSummarizer := &commitSummarizer{registry: llmRegistry, chatRepo: chatRepo, log: log} docsLocator := &docsLocatorAdapter{repo: wsRepo} docsSummaryRunner := docs.NewSummaryRunner(docsRepo, docsLocator, gitr, docsSummarizer, log) docsSvc := docs.NewService(jobsRepo, cfg.Docs.Enabled) // Hooks(functional setters,避免 workspace/acp → codeindex/docs 的 import 环)。 if codeIndexSvc != nil { indexHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) error { // Sync 路径(worktreeID==nil)先把旧 run 标 stale,再按新 HEAD 入队。 if worktreeID == nil { if serr := codeIndexSvc.MarkStale(hctx, hwsID); serr != nil { log.Warn("codeindex.mark_stale_failed", "workspace_id", hwsID, "err", serr.Error()) } } _, err := codeIndexSvc.EnqueueBuild(hctx, hwsID, worktreeID, commitSHA) if errors.Is(err, codeindex.ErrEmbeddingDisabled) { return nil // feature off; not an error } return err } if setter, ok := wtSvc.(interface { SetIndexBuildHook(workspace.IndexBuildHook) }); ok { setter.SetIndexBuildHook(indexHook) } if setter, ok := wsSvc.(interface { SetIndexBuildHook(workspace.IndexBuildHook) }); ok { setter.SetIndexBuildHook(indexHook) } } if cfg.Docs.Enabled { summaryHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error { return docsSvc.EnqueueCommitSummary(hctx, hwsID, worktreeID, branch, commitSHA) } if setter, ok := gopSvc.(interface { SetCommitSummaryHook(workspace.CommitSummaryHook) }); ok { setter.SetCommitSummaryHook(summaryHook) } } // AGENTS.md seeding:把 project memory 渲染进 worktree(acpSup 在下方构造,故此处 // 仅保留 renderer 闭包,acpSup 构造后再注入)。 agentsMDRenderer := func(rctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) { return projectMemorySvc.RenderAgentsMD(rctx, projectID, wsID) } acpSup.SetAgentsMDRenderer(agentsMDRenderer) // ===== 安全:master-key 轮换 + re-encrypt job + admin 端点 ===== // re-encrypt store 扫描每张加密列表、逐行重封到新版本;handler 注册进 jobs map。 // rotate-key 端点 bump crypto_keys active 版本并入队 secret.reencrypt job。 reencryptStore := handlers.NewPostgresReencryptStore(pool) secretReencryptHandler := handlers.NewSecretReencryptHandler(reencryptStore, keyedEnc, log) security.NewHandler(cryptoKeyStore, jobsRepo, reencryptStore, userSvc, userAdminAdapter{svc: userSvc}, auditRec).Mount(r) orchRepo := orchestrator.NewPostgresRepository(pool) // 阶段→默认 agent-kind:把 config 里的"阶段名→agent-kind 名"映射按名解析为 UUID。 orchDefaults := map[project.Phase]uuid.UUID{} for phaseName, kindName := range cfg.Orchestrator.PhaseAgentKinds { if k, kerr := acpRepo.GetAgentKindByName(ctx, kindName); kerr == nil && k != nil { orchDefaults[project.Phase(phaseName)] = k.ID } else { log.Warn("orchestrator.default_agent_kind_unresolved", "phase", phaseName, "agent_kind", kindName) } } // 不变量:编排器步骤跑在 jobs worker 上,单个 handler 超时 = lease_timeout*9/10。 // turn_timeout 必须严格小于它,否则一次完整 agent 回合会被中途 reap 并重复执行。 // 启动期快速失败,把配置错误暴露在部署时而非运行时。 if cfg.Orchestrator.Enabled && cfg.Jobs.JobReaper.LeaseTimeout > 0 { jobHandlerTimeout := cfg.Jobs.JobReaper.LeaseTimeout - cfg.Jobs.JobReaper.LeaseTimeout/10 if cfg.Orchestrator.TurnTimeout >= jobHandlerTimeout { return nil, fmt.Errorf("orchestrator.turn_timeout (%s) must be < jobs handler timeout (%s = job_reaper.lease_timeout*9/10); raise jobs.job_reaper.lease_timeout", cfg.Orchestrator.TurnTimeout, jobHandlerTimeout) } } orchCfg := orchestrator.Config{ MaxAttemptsPerPhase: cfg.Orchestrator.MaxAttemptsPerPhase, TurnTimeout: cfg.Orchestrator.TurnTimeout, MaxDepth: cfg.Orchestrator.MaxDepth, FanOutLimit: cfg.Orchestrator.FanOutLimit, } orchPM := orchPMAdapter{repo: projectRepo, reqs: requirementSvc, adminLookup: userAdminAdapter{svc: userSvc}} orchSvc := orchestrator.NewService(orchestrator.ServiceDeps{ Repo: orchRepo, PM: orchPM, ACL: orchACLAdapter{pa: projectAccessAdapter{p: projectSvc}}, Jobs: jobsRepo, Sessions: orchSessionTermAdapter{sess: sessSvc}, Defaults: orchDefaults, Config: orchCfg, Audit: orchAuditAdapter{rec: auditRec}, Log: log, }) // sessSvc 同时实现 acp.TurnRunner(SendPromptAndWait/ResumeSession)。 orchTurnRunner, _ := sessSvc.(acp.TurnRunner) orchStepHandler := orchestrator.NewStepHandler(orchestrator.StepHandlerDeps{ Repo: orchRepo, Runner: orchestrator.NewRunner(sessSvc, orchTurnRunner, orchACPLookupAdapter{repo: acpRepo}, cfg.Orchestrator.TurnTimeout), Gate: orchestrator.NewV1Gate(orchGateArtifactAdapter{repo: projectRepo}, orchGateIssueAdapter{repo: projectRepo}), PM: orchPM, Enqueue: orchSvc.(orchestrator.StepEnqueuer), DefaultAgentKind: orchDefaults, Config: orchCfg, Audit: orchAuditAdapter{rec: auditRec}, Log: log, }) // 崩溃的编排 session → 重新入队对应 step(带短延迟)。注入为 func 避免 acp→orchestrator 循环。 acpSup.SetOrchestratorRedrive(func(rctx context.Context, stepID uuid.UUID, reason string) { orchSvc.EnqueueStepRedrive(rctx, stepID, reason) }) // 启动恢复:把活跃 run 下卡住的 step 重置为 pending 并重新入队(须在 ACP reaper 之后, // 使 resume 看到已 crashed 的 session 与已释放的 worktree)。 if stuckSteps, serr := orchRepo.ResetStuckStepsOnRestart(ctx); serr != nil { log.Warn("orchestrator.startup_reaper.reset_failed", "err", serr.Error()) } else { for _, st := range stuckSteps { if eerr := orchSvc.(orchestrator.StepEnqueuer).EnqueueStep(ctx, st.RunID, st.ID, time.Now().UTC()); eerr != nil { log.Warn("orchestrator.startup_reaper.enqueue_failed", "step_id", st.ID, "err", eerr.Error()) } } } if cfg.Orchestrator.Enabled { orchestrator.NewHandler(orchSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) } // ===== task-decomposition parallel scheduler(autonomy roadmap §10)===== // 服务端调度器:拓扑选取就绪(未阻塞/open/叶子/无活跃 session)子任务,扇出到独立 // worktree 上的并行 ACP session。以项目 owner 特权身份调用 SessionService.Create, // 故不受 checkNotSystemToken 限制。注册为 jobs 周期 RunnerFn(单实例、单 tick,跨重启存活)。 orchScheduler := orchestrator.NewScheduler(orchestrator.SchedulerDeps{ Projects: schedulerReadySourceAdapter{repo: projectRepo}, Sessions: schedulerSessionStarterAdapter{ sess: sessSvc, projectRepo: projectRepo, wsRepo: wsRepo, defaultAgentKind: func() (uuid.UUID, bool) { if id, ok := orchDefaults[project.PhaseImplementing]; ok && id != uuid.Nil { return id, true } return uuid.Nil, false }, }, Notify: schedulerNotifyAdapter{dispatcher: notifyDispatcher, projectRepo: projectRepo}, Log: log, MaxFanoutPerTick: cfg.Orchestrator.Scheduler.MaxFanoutPerTick, MaxConcurrentPerProject: cfg.Orchestrator.Scheduler.MaxConcurrentPerProject, }) // ===== run profiles module wiring ===== // 进程托管:每个 workspace 的 run profile 作为长驻进程在 main_path 拉起。 // 复用 workspace.ProjectAccess(pa) 做 owner+admin 写鉴权。WS 心跳沿用 acp 的 // 全局默认值(同类参数,避免新增配置面)。 runRepo := runmod.NewPostgresRepository(pool) runSup := runmod.NewSupervisor(runRepo, runmod.SupervisorConfig{ KillGrace: cfg.Run.KillGrace, OutBufferLines: cfg.Run.OutBufferLines, OutTailBytes: cfg.Run.OutTailBytes, WSSendBuffer: cfg.Run.WSSendBuffer, ShutdownGrace: cfg.Run.ShutdownGrace, }, log) runSvc := runmod.NewService(runRepo, wsRepo, runSup, pa, auditRec, enc, runmod.ServiceConfig{ EnvWhitelist: cfg.Run.EnvWhitelist, KillGrace: cfg.Run.KillGrace, OutTailBytes: cfg.Run.OutTailBytes, }, log) // 一次性 exec(run_command / run_tests):agent 自验证闭环。无状态同步,无需 // ShutdownAll 注册——RunOnce 受请求 ctx + 自身超时约束。 execSvc := runmod.NewExecService(wsRepo, wtSvc, pa, auditRec, enc, runmod.ExecConfig{ EnvWhitelist: cfg.Run.EnvWhitelist, DefaultTimeout: cfg.Run.Exec.DefaultTimeout, MaxTimeout: cfg.Run.Exec.MaxTimeout, MaxOutputBytes: cfg.Run.Exec.MaxOutputBytes, KillGrace: cfg.Run.KillGrace, AllowedCommands: cfg.Run.Exec.AllowedCommands, }, log) if cfg.Run.Enabled { runmod.NewHandler(runSvc, userSvc, userAdminAdapter{svc: userSvc}, runmod.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, PongTimeout: cfg.Acp.WSPongTimeout, SendBuffer: cfg.Run.WSSendBuffer, }).Mount(r) } // ===== admin terminal module wiring ===== // 仅限管理员的网页端交互式终端:在宿主进程(部署时即 runtime 容器)内开 shell, // 主要用于运维(容器内 npm i -g 安装 ACP 客户端 CLI)。会话纯内存、不落库。 // WS 心跳沿用 acp 全局默认值(同类参数,避免新增配置面)。 termSup := terminal.NewSupervisor(terminal.SupervisorConfig{ Shell: cfg.Terminal.Shell, MaxSessions: cfg.Terminal.MaxSessions, KillGrace: cfg.Terminal.KillGrace, EnvWhitelist: cfg.Terminal.EnvWhitelist, }, log) if cfg.Terminal.Enabled { terminal.NewHandler(termSup, userSvc, userAdminAdapter{svc: userSvc}, auditRec, cfg.Terminal.Shell, terminal.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, PongTimeout: cfg.Acp.WSPongTimeout, }).Mount(r) } // ===== MCP 模块装配(第二段:Server 本体 + transports + admin CRUD)===== mcpDeps := mcp.ServerDeps{ Caller: mcp.NewCallerResolver(userSvc), Projects: projectSvc, Reqs: requirementSvc, Issues: issueSvc, Artifacts: artifactSvc, Workspaces: wsSvc, Worktrees: wtSvc, GitOps: gopSvc, ChangeReqs: crSvc, Templates: chatTplSvc, Messages: chatMsgSvc, Conversations: chatConvSvc, Endpoints: chatEpSvc, ACP: acp.NewMCPBridge(akSvc, sessSvc, acpPermSvc, acpRepo), Runs: runSvc, Execs: execSvc, Orchestrator: orchestrator.NewMCPBridge(orchSvc), CodeIndex: codeIndexSvc, // 可为 nil(code_index.enabled=false) Memory: projectMemorySvc, // 始终非 nil(无 embedder 时关键字回退) } mcpServer := mcp.NewMCPServer(mcpDeps) mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter) mcp.MountAdmin(r, mcp.AdminHandlerDeps{ Tokens: mcpTokenSvc, Users: userSvc, }) // ===== jobs module wiring ===== // jobsRepo 已在 orchestrator 装配块提前构造(见上)。 webhookBackoff := []time.Duration{ 30 * time.Second, 2 * time.Minute, 8 * time.Minute, 30 * time.Minute, 2 * time.Hour, } webhookHandler := handlers.NewWebhookHandler(handlers.WebhookConfig{ URL: cfg.Notify.Webhook.URL, Secret: cfg.Notify.Webhook.Secret, Timeout: cfg.Notify.Webhook.Timeout, }, clock.Real()) webhookNotifier := jobs.NewWebhookNotifier(jobsRepo, jobs.WebhookNotifierConfig{ Enabled: cfg.Notify.Webhook.Enabled, Topics: cfg.Notify.Webhook.Topics, Backoff: webhookBackoff, }, clock.Real(), auditRec, log) notifyDispatcher.Register(webhookNotifier) dispAdapter := dispatcherAdapter{d: notifyDispatcher} wsFetchRunner := runners.NewWorkspaceFetch( workspaceFetchAdapter{repo: wsRepo}, workspaceFetcherAdapter{gitr: gitr, credLoader: credLoader}, dispAdapter, log) wtPruneRunner := runners.NewWorktreePrune( worktreePruneAdapter{wsRepo: wsRepo, pLookup: projectRepo, log: log}, gitr, dispAdapter, runners.WorktreePruneConfig{ IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold, WarningLead: cfg.Jobs.WorktreePrune.WarningLead, }, log, auditRec) attCleanupRunner := runners.NewAttachmentCleanup( chatAttachmentAdapter{repo: chatRepo}, attachStorage, runners.AttachmentCleanupConfig{Retention: cfg.Jobs.AttachmentCleanup.Retention}, log) jobReaperRunner := runners.NewJobReaper(jobsRepo, cfg.Jobs.JobReaper.LeaseTimeout, log) jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log) acpEventsPurgeRunner := runners.NewAcpEventsPurge( acpEventsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log) acpTurnsPurgeRunner := runners.NewAcpTurnsPurge( acpTurnsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log) mcpTokensPurgeRunner := runners.NewMCPTokensPurge( mcpTokensPurgeAdapter{repo: mcpRepo}, cfg.Jobs.MCPTokensPurge.Retention, log) acpSessionReaperRunner := runners.NewAcpSessionReaper( acpSessionReaperAdapter{repo: acpRepo}, acpSup, notifyDispatcher, runners.AcpSessionReaperConfig{ WallClockTimeout: cfg.Jobs.AcpSessionReaper.WallClockTimeout, IdleTimeout: cfg.Jobs.AcpSessionReaper.IdleTimeout, KillGrace: cfg.Acp.KillGrace, }, log) auditRetentionRunner := runners.NewAuditRetention(auditReader, cfg.Jobs.AuditRetention.Retention, log) deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) { if appMetrics != nil { appMetrics.RecordDeadLetter(string(job.Type)) } errMsg := herr.Error() if len(errMsg) > 500 { errMsg = errMsg[:500] } if rerr := auditRec.Record(ctx, audit.Entry{ Action: "job.dead_letter", TargetType: "job", TargetID: job.ID.String(), Metadata: map[string]any{ "type": string(job.Type), "attempts": job.Attempts, "err": errMsg, }, }); rerr != nil { log.Warn("dead_letter.audit_failed", "err", rerr.Error()) } admins, err := userSvc.ListAdmins(ctx) if err != nil { log.Error("dead_letter.list_admins_failed", "err", err.Error()) return } for _, a := range admins { if derr := notifyDispatcher.Dispatch(ctx, notify.Message{ UserID: a.ID, Topic: "webhook.dead_letter", Severity: notify.SeverityError, Title: "Webhook delivery failed permanently", Body: errMsg, Metadata: map[string]any{ "job_id": job.ID.String(), "job_type": string(job.Type), }, CreatedAt: clock.Real().Now().UTC(), }); derr != nil { log.Warn("dead_letter.dispatch_failed", "admin_id", a.ID, "err", derr.Error()) } } } jobsModule := jobs.NewModule(jobs.Config{ Enabled: cfg.Jobs.Enabled, Workers: cfg.Jobs.Workers, ShutdownGrace: cfg.Jobs.ShutdownGrace, WorkspaceFetch: jobs.RunnerConfig{ Enabled: cfg.Jobs.WorkspaceFetch.Enabled, Interval: cfg.Jobs.WorkspaceFetch.Interval, }, WorktreePrune: jobs.WorktreePruneConfig{ Enabled: cfg.Jobs.WorktreePrune.Enabled, Interval: cfg.Jobs.WorktreePrune.Interval, IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold, WarningLead: cfg.Jobs.WorktreePrune.WarningLead, }, AttachmentCleanup: jobs.AttachmentCleanupConfig{ Enabled: cfg.Jobs.AttachmentCleanup.Enabled, Interval: cfg.Jobs.AttachmentCleanup.Interval, Retention: cfg.Jobs.AttachmentCleanup.Retention, }, JobReaper: jobs.JobReaperConfig{ Enabled: cfg.Jobs.JobReaper.Enabled, Interval: cfg.Jobs.JobReaper.Interval, LeaseTimeout: cfg.Jobs.JobReaper.LeaseTimeout, }, JobPurge: jobs.JobPurgeConfig{ Enabled: cfg.Jobs.JobPurge.Enabled, Interval: cfg.Jobs.JobPurge.Interval, CompletedRetention: cfg.Jobs.JobPurge.CompletedRetention, }, AcpEventsPurge: jobs.AcpEventsPurgeConfig{ Enabled: cfg.Jobs.AcpEventsPurge.Enabled, Interval: cfg.Jobs.AcpEventsPurge.Interval, Retention: cfg.Jobs.AcpEventsPurge.Retention, }, MCPTokensPurge: jobs.MCPTokensPurgeConfig{ Enabled: cfg.Jobs.MCPTokensPurge.Enabled, Interval: cfg.Jobs.MCPTokensPurge.Interval, Retention: cfg.Jobs.MCPTokensPurge.Retention, }, AcpSessionReaper: jobs.AcpSessionReaperConfig{ Enabled: cfg.Jobs.AcpSessionReaper.Enabled, Interval: cfg.Jobs.AcpSessionReaper.Interval, }, // 任务分解并行调度器的周期 tick(autonomy roadmap §10)。 OrchestratorSchedule: jobs.RunnerConfig{ Enabled: cfg.Orchestrator.Scheduler.Enabled, Interval: cfg.Orchestrator.Scheduler.Interval, }, // 审计日志保留期清理(autonomy roadmap §11)。 AuditRetention: jobs.RunnerConfig{ Enabled: cfg.Jobs.AuditRetention.Enabled, Interval: cfg.Jobs.AuditRetention.Interval, }, }, jobs.ModuleDeps{ Repo: jobsRepo, Handlers: map[jobs.JobType]jobs.Handler{ jobs.TypeWebhookDeliver: webhookHandler, orchestrator.JobTypeOrchestratorStep: orchStepHandler, jobs.TypeSecretReencrypt: secretReencryptHandler, codeindex.JobTypeBuild: codeIndexBuildRunner, docs.JobTypeCommitSummary: docsSummaryRunner, }, RunnerFns: map[string]jobs.RunnerFunc{ "workspace_fetch": wsFetchRunner.Run, "worktree_prune": wtPruneRunner.Run, "attachment_cleanup": attCleanupRunner.Run, "job_reaper": jobReaperRunner.Run, "job_purge": jobPurgeRunner.Run, "acp_events_purge": acpEventsPurgeRunner.Run, "acp_turns_purge": acpTurnsPurgeRunner.Run, "mcp_tokens_purge": mcpTokensPurgeRunner.Run, "acp_session_reaper": acpSessionReaperRunner.Run, "orchestrator_schedule": orchScheduler.Run, "audit_retention": auditRetentionRunner.Run, }, Clock: clock.Real(), Log: log, WorkerBackoff: webhookBackoff, OnDeadLetter: deadLetterFn, }) // Start the module with a background context; cancellation is wired into // App.Run's shutdown path via jobsCancel. jobsCtx, jobsCancel := context.WithCancel(context.Background()) jobsModule.Start(jobsCtx) if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { log.Warn("bootstrap admin failed", "err", err.Error()) } else if u != nil { log.Info("bootstrap admin created", "email", u.Email) } } // 注册 system 作用域的 prompt 模板为 MCP prompts(spec)。需在 bootstrap 之后 // 执行以保证存在 admin;失败仅 warn,不阻断启动。以第一个 admin 的视角加载模板。 if admins, err := userSvc.ListAdmins(ctx); err != nil { log.Warn("mcp register_system_prompts: list admins failed", "err", err.Error()) } else if len(admins) > 0 { if err := mcp.RegisterSystemPrompts(mcpServer, mcpDeps, admins[0].ID); err != nil { log.Warn("mcp register_system_prompts failed", "err", err.Error()) } } srv := &http.Server{ Addr: cfg.HTTP.Addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } return &App{ cfg: cfg, log: log, pool: pool, server: srv, dispatcher: notifyDispatcher, jobs: jobsModule, jobsCancel: jobsCancel, acpSup: acpSup, runSup: runSup, termSup: termSup, }, nil } // Run 启动 HTTP server 并阻塞直到 ctx 取消(Shutdown)或服务器报错。 // 关停路径上保证关闭 db pool;返回 nil 表示由 ctx 取消触发的正常关停。 func (a *App) Run(ctx context.Context) error { a.log.Info("server starting", "addr", a.cfg.HTTP.Addr, "env", a.cfg.Env) errCh := make(chan error, 1) go func() { var serveErr error if a.cfg.HTTP.TLS.Enabled { a.log.Info("server serving https", "cert", a.cfg.HTTP.TLS.CertFile) serveErr = a.server.ListenAndServeTLS(a.cfg.HTTP.TLS.CertFile, a.cfg.HTTP.TLS.KeyFile) } else { serveErr = a.server.ListenAndServe() } if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { errCh <- serveErr } }() select { case <-ctx.Done(): a.log.Info("shutdown signal received, draining...") shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() shutErr := a.server.Shutdown(shutCtx) if shutErr != nil { a.log.Error("server shutdown", "err", shutErr.Error()) } // Stop ACP subprocesses (kill child agents) BEFORE jobs/dispatcher drain so // any final crash notifications dispatched by onExit can flow through. if a.acpSup != nil { a.acpSup.ShutdownAll(shutCtx) } // Stop run-profile subprocesses (整树 kill) on shutdown. if a.runSup != nil { a.runSup.ShutdownAll(shutCtx) } // Close admin terminal sessions (整树 kill shell + 子孙) on shutdown. if a.termSup != nil { a.termSup.CloseAll(shutCtx) } // Stop jobs module first so no in-flight worker can dispatch new // notifications after we begin to drain the dispatcher. if a.jobs != nil && a.cfg.Jobs.Enabled { a.jobsCancel() stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second) a.jobs.Stop(stopCtx) stopCancel() } // server 已停止接收新请求;等异步通知投递结束再关 pool。 // 用独立的 drainCtx:shutCtx 的 30s 预算已被 server.Shutdown 与 // jobs.Stop 的实际墙钟时间消耗,复用可能让 dispatcher 拿到已死 ctx // 而立即返回,丢失在途的 webhook enqueue(DB 写)。 drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second) defer drainCancel() if err := a.dispatcher.Shutdown(drainCtx); err != nil { a.log.Warn("notify dispatcher drain", "err", err.Error()) } a.pool.Close() return shutErr case err := <-errCh: drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if a.acpSup != nil { a.acpSup.ShutdownAll(drainCtx) } if a.runSup != nil { a.runSup.ShutdownAll(drainCtx) } if a.termSup != nil { a.termSup.CloseAll(drainCtx) } if a.jobs != nil && a.cfg.Jobs.Enabled { a.jobsCancel() stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second) a.jobs.Stop(stopCtx) stopCancel() } _ = a.dispatcher.Shutdown(drainCtx) a.pool.Close() return err } } // userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。 // 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。 // workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名), // 因此本类型同时满足两者,可在两个 handler 复用。 // buildCryptoProvider 据 cfg.Crypto.Provider 选取 secret key provider。 // env:APP_MASTER_KEY 为 version 1(轮换时 APP_MASTER_KEY_V 暂存新版本)。 // vault/kms:客户端尚未接入,退回 env provider 并 warn,保持启动不破(接入真实 // 客户端时把 DelegatingProvider 替换为对应实现即可)。 func buildCryptoProvider(cfg *config.Config, log *slog.Logger) crypto.Provider { env := crypto.NewEnvProvider(cfg.MasterKey) switch cfg.Crypto.Provider { case "vault", "kms": log.Warn("crypto.provider_not_implemented_fallback_env", "provider", cfg.Crypto.Provider) return crypto.NewDelegatingProvider(env) default: return env } } type userAdminAdapter struct{ svc user.Service } func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) { u, err := a.svc.Get(ctx, id) if err != nil { return false, err } return u.IsAdmin, nil } // changeRequestWorkspaceAdapter 把 workspace.Repository 桥接成 // changerequest.WorkspaceLookup(窄接口),让 change-request service 不依赖 // workspace service 全量,只取 project_id / remote_url / default_branch。 type changeRequestWorkspaceAdapter struct { repo workspace.Repository } func (a changeRequestWorkspaceAdapter) GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (changerequest.WorkspaceMeta, error) { ws, err := a.repo.GetWorkspaceByID(ctx, wsID) if err != nil { return changerequest.WorkspaceMeta{}, err } return changerequest.WorkspaceMeta{ ProjectID: ws.ProjectID, GitRemoteURL: ws.GitRemoteURL, DefaultBranch: ws.DefaultBranch, }, nil } // projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。 // - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径 // - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限 // // ACL 语义复刻 project 包内部 assertReadable / assertWritable: // // read private: owner + admin // read internal: 任意已登录 // write *: owner + admin // // 注意:project.assertReadable 在 visibility=private 且非 owner+admin 时返回 NotFound // 防探测;这里 ProjectService.Get/GetByID 已替我们兜住,到本适配层时 pj 一定可读。 type projectAccessAdapter struct { p project.ProjectService } func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) { pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug) if err != nil { return uuid.Nil, false, false, err } role := a.roleFor(ctx, pj, callerID, isAdmin) canRead, canWrite := projectACL(pj, callerID, isAdmin, role) return pj.ID, canRead, canWrite, nil } func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) { pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID) if err != nil { return false, false, err } role := a.roleFor(ctx, pj, callerID, isAdmin) canRead, canWrite := projectACL(pj, callerID, isAdmin, role) return canRead, canWrite, nil } // roleFor 查 caller 在 pj 的成员角色,供 projectACL 叠加授权。owner/global-admin // 隐式 RoleAdmin(与 project_service.memberRoleFor 保持一致);查询失败回退 RoleNone, // 既有 owner/admin/internal 规则仍兜底。 func (a projectAccessAdapter) roleFor(ctx context.Context, pj *project.Project, callerID uuid.UUID, isAdmin bool) project.Role { if isAdmin || pj.OwnerID == callerID { return project.RoleAdmin } role, err := a.p.MemberRole(ctx, pj.ID, callerID) if err != nil { return project.RoleNone } return role } // projectACL 复刻 project 包内部 assertReadable/assertWritable 的语义,并叠加成员/ // 角色 ACL(autonomy roadmap §11)——两侧必须一致,否则 workspace/ACP/chat 作用域 // 会与 project service 鉴权分叉: // - global-admin 永远可读可写 // - owner 可读可写(经迁移回填为 project-admin 行) // - project 角色 admin/member:可写(叠加);viewer:仅可读(叠加) // - internal 可见性:所有登录用户可读 // // 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess // 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。 func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool, role project.Role) (canRead, canWrite bool) { owned := pj.OwnerID == callerID canWrite = isAdmin || owned || role.CanWrite() canRead = canWrite || pj.Visibility == project.VisibilityInternal || role.CanRead() return canRead, canWrite } // projectMaintainerAdapter satisfies acp.ProjectMaintainer. It reuses the // member-aware projectAccessAdapter so "who can resolve a permission request" // stays consistent with project_service write ACL (owner/admin/member roles). type projectMaintainerAdapter struct { pa projectAccessAdapter } func (a projectMaintainerAdapter) CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { _, canWrite, err := a.pa.ResolveByID(ctx, callerID, isAdmin, projectID) if err != nil { return false, err } return canWrite, nil } // acpProjectAccessAdapter satisfies acp.ProjectAccess. It uses workspace.Repository // to resolve workspace → project, then project.Repository for issue/requirement. type acpProjectAccessAdapter struct { wsRepo workspace.Repository projectRepo project.Repository } func (a acpProjectAccessAdapter) GetProjectByWorkspace(ctx context.Context, wsID uuid.UUID) (*project.Project, error) { ws, err := a.wsRepo.GetWorkspaceByID(ctx, wsID) if err != nil { return nil, err } return a.projectRepo.GetProjectByID(ctx, ws.ProjectID) } func (a acpProjectAccessAdapter) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Issue, error) { return a.projectRepo.GetIssueByNumber(ctx, projectID, number) } func (a acpProjectAccessAdapter) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) { return a.projectRepo.GetRequirementByNumber(ctx, projectID, number) } // acpArtifactAccessAdapter satisfies acp.ArtifactAccess. It loads all artifacts of a // requirement and reduces to the latest version per phase (max version), so the // brief composer receives at most one artifact per phase. type acpArtifactAccessAdapter struct { projectRepo project.Repository } func (a acpArtifactAccessAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) { all, err := a.projectRepo.ListArtifactsByRequirement(ctx, requirementID, "") if err != nil { return nil, err } return latestArtifactsPerPhase(all), nil } // latestArtifactsPerPhase keeps the max-version artifact per phase. Deterministic: // later versions strictly override earlier ones regardless of input order. func latestArtifactsPerPhase(all []*project.Artifact) []*project.Artifact { byPhase := make(map[project.Phase]*project.Artifact, len(all)) for _, art := range all { if art == nil { continue } cur, ok := byPhase[art.Phase] if !ok || art.Version > cur.Version { byPhase[art.Phase] = art } } out := make([]*project.Artifact, 0, len(byPhase)) for _, art := range byPhase { out = append(out, art) } return out } // chatContextReaderAdapter satisfies chat.ContextReader. It resolves a conversation's // FK mounts (requirement / issue / project) into domain objects + latest artifacts, // then delegates to brief.Composer. Repo orientation is intentionally skipped for chat // (RepoCwd left empty) to keep the request-path latency bounded — git shell-outs only // run for ACP sessions. type chatContextReaderAdapter struct { projectRepo project.Repository composer brief.Composer } func (a chatContextReaderAdapter) BriefForConversation(ctx context.Context, conv *chat.Conversation) (string, error) { if conv == nil || a.composer == nil { return "", nil } var ( req *project.Requirement iss *project.Issue pj *project.Project artifacts []*project.Artifact kind = brief.KindChatRequirement ) if conv.RequirementID != nil { if r, err := a.projectRepo.GetRequirementByID(ctx, *conv.RequirementID); err == nil { req = r if arts, aerr := a.projectRepo.ListArtifactsByRequirement(ctx, r.ID, ""); aerr == nil { artifacts = latestArtifactsPerPhase(arts) } } } if conv.IssueID != nil { if i, err := a.projectRepo.GetIssueByID(ctx, *conv.IssueID); err == nil { iss = i kind = brief.KindChatIssue } } if conv.ProjectID != nil { if p, err := a.projectRepo.GetProjectByID(ctx, *conv.ProjectID); err == nil { pj = p } } // No PM context resolved → no FK brief. if req == nil && iss == nil { return "", nil } res, err := a.composer.ComposeTaskBrief(ctx, brief.BriefInput{ Project: pj, Requirement: req, Issue: iss, Artifacts: artifacts, UserPrompt: "", // chat 的用户输入走消息历史,不进 FK 简报 Kind: kind, }) if err != nil { return "", err } return res.Text, nil } // ===== Adapters: bridge cross-module types into the narrow interfaces the // jobs/runners package expects, so the runners package never imports // workspace/chat/notify. // workspaceFetchAdapter implements runners.WorkspaceFetchRepo backed by // workspace.Repository. Translates workspace.FetchTarget into // runners.WorkspaceFetchTarget (same shape). type workspaceFetchAdapter struct{ repo workspace.Repository } func (a workspaceFetchAdapter) ListFetchTargets(ctx context.Context) ([]runners.WorkspaceFetchTarget, error) { targets, err := a.repo.ListFetchTargets(ctx) if err != nil { return nil, err } out := make([]runners.WorkspaceFetchTarget, 0, len(targets)) for _, t := range targets { out = append(out, runners.WorkspaceFetchTarget{ ID: t.ID, OwnerID: t.OwnerID, Name: t.Name, MainPath: t.MainPath, }) } return out, nil } func (a workspaceFetchAdapter) MarkSyncing(ctx context.Context, id uuid.UUID) (bool, error) { return a.repo.MarkSyncing(ctx, id) } func (a workspaceFetchAdapter) MarkFetchResult(ctx context.Context, id uuid.UUID, success bool, errMsg string) error { return a.repo.MarkFetchResult(ctx, id, success, errMsg) } // workspaceFetcherAdapter implements runners.WorkspaceFetcher by loading the // workspace credential and calling git.Runner.Fetch directly. type workspaceFetcherAdapter struct { gitr git.Runner credLoader func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) } func (a workspaceFetcherAdapter) Fetch(ctx context.Context, mainPath string, wsID uuid.UUID) error { cred, err := a.credLoader(ctx, wsID) if err != nil { return err } // 后台 fetch 成功后与手动 Sync 一致地把 main 工作区快进到远端 if err := a.gitr.Fetch(ctx, mainPath, cred); err != nil { return err } return a.gitr.MergeFFOnly(ctx, mainPath) } // projectLookup is the minimal interface needed by worktreePruneAdapter to // look up a project by ID without ACL enforcement (background system job path). type projectLookup interface { GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error) } // worktreePruneAdapter implements runners.WorktreePruneRepo by composing the // workspace.Repository's worktree-prune methods with per-worktree workspace+ // project lookups (to populate OwnerID and MainPath that aren't on the // workspace_worktrees row). N+1 acceptable: prune lists are small per tick. type worktreePruneAdapter struct { wsRepo workspace.Repository pLookup projectLookup log *slog.Logger } func (a worktreePruneAdapter) ListWorktreesNeedingPruneWarning(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) { rows, err := a.wsRepo.ListWorktreesNeedingPruneWarning(ctx, lastUsedBefore) if err != nil { return nil, err } return a.enrichWorktrees(ctx, rows), nil } func (a worktreePruneAdapter) ListWorktreesNeedingPrune(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) { rows, err := a.wsRepo.ListWorktreesNeedingPrune(ctx, lastUsedBefore) if err != nil { return nil, err } return a.enrichWorktrees(ctx, rows), nil } func (a worktreePruneAdapter) MarkPruneWarning(ctx context.Context, id uuid.UUID) error { return a.wsRepo.MarkPruneWarning(ctx, id) } func (a worktreePruneAdapter) TryStartPrune(ctx context.Context, id uuid.UUID) (runners.WorktreeRow, error) { wt, err := a.wsRepo.TryStartPrune(ctx, id) if err != nil { return runners.WorktreeRow{}, err } return a.enrichOne(ctx, wt), nil } func (a worktreePruneAdapter) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error { return a.wsRepo.FinishPruneSuccess(ctx, id) } func (a worktreePruneAdapter) FinishPruneRollback(ctx context.Context, id uuid.UUID) error { return a.wsRepo.FinishPruneRollback(ctx, id) } // enrichWorktrees converts []*workspace.Worktree to []runners.WorktreeRow. func (a worktreePruneAdapter) enrichWorktrees(ctx context.Context, rows []*workspace.Worktree) []runners.WorktreeRow { out := make([]runners.WorktreeRow, 0, len(rows)) for _, w := range rows { out = append(out, a.enrichOne(ctx, w)) } return out } func (a worktreePruneAdapter) enrichOne(ctx context.Context, w *workspace.Worktree) runners.WorktreeRow { row := runners.WorktreeRow{ID: w.ID, WorkspaceID: w.WorkspaceID, Branch: w.Branch, Path: w.Path} ws, err := a.wsRepo.GetWorkspaceByID(ctx, w.WorkspaceID) if err != nil { a.log.Warn("worktree_prune.adapter.lookup_workspace_failed", "worktree_id", w.ID, "workspace_id", w.WorkspaceID, "err", err.Error()) return row } row.MainPath = ws.MainPath pj, err := a.pLookup.GetProjectByID(ctx, ws.ProjectID) if err != nil { a.log.Warn("worktree_prune.adapter.lookup_project_failed", "worktree_id", w.ID, "project_id", ws.ProjectID, "err", err.Error()) return row } row.OwnerID = pj.OwnerID return row } // chatAttachmentAdapter implements runners.AttachmentCleanupRepo backed by // chat.Repository. Translates chat.AttachmentRef → runners.AttachmentRow. type chatAttachmentAdapter struct{ repo chat.Repository } func (a chatAttachmentAdapter) ListAttachmentsForSoftDeletedConversations(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) { refs, err := a.repo.ListAttachmentsForSoftDeletedConversations(ctx, olderThan) if err != nil { return nil, err } return refsToRows(refs), nil } func (a chatAttachmentAdapter) ListExpiredOrphans(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) { refs, err := a.repo.ListExpiredOrphans(ctx, olderThan) if err != nil { return nil, err } return refsToRows(refs), nil } func (a chatAttachmentAdapter) DeleteAttachments(ctx context.Context, ids []uuid.UUID) error { return a.repo.DeleteAttachments(ctx, ids) } func refsToRows(refs []chat.AttachmentRef) []runners.AttachmentRow { out := make([]runners.AttachmentRow, 0, len(refs)) for _, r := range refs { out = append(out, runners.AttachmentRow{ID: r.ID, StoragePath: r.StoragePath}) } return out } // dispatcherAdapter implements runners.Dispatcher backed by *notify.Dispatcher. type dispatcherAdapter struct{ d *notify.Dispatcher } func (a dispatcherAdapter) Dispatch(ctx context.Context, msg notify.Message) error { return a.d.Dispatch(ctx, msg) } // emailLookupAdapter implements notify.EmailLookup over user.Service so the // EmailNotifier can resolve a recipient address from a userID without notify // importing the user package. type emailLookupAdapter struct{ svc user.Service } func (a emailLookupAdapter) EmailForUser(ctx context.Context, userID uuid.UUID) (string, error) { u, err := a.svc.Get(ctx, userID) if err != nil { return "", err } return u.Email, nil } // phaseEventEmitterAdapter implements project.PhaseEventEmitter by translating a // PhaseChangeEvent into a notify.Message (Topic="requirement.phase_change") and // dispatching it. Fire-and-forget: dispatch errors are logged inside Dispatch, // never surfaced, so a notify outage never blocks a phase change. type phaseEventEmitterAdapter struct{ d *notify.Dispatcher } func (a phaseEventEmitterAdapter) EmitPhaseChange(ctx context.Context, e project.PhaseChangeEvent) { meta := map[string]any{ "project_id": e.ProjectID.String(), "project_slug": e.ProjectSlug, "requirement_id": e.RequirementID.String(), "requirement_number": e.RequirementNumber, "from": string(e.From), "to": string(e.To), "actor_id": e.ActorID.String(), } // 主要接收人是需求 owner(关心进度的人),而非发起变更的 actor(可能是编排器 // 或其它协作者)。owner 与 actor 不同则两者都通知;相同则只发一条避免重复。 recipients := map[uuid.UUID]struct{}{} if e.OwnerID != uuid.Nil { recipients[e.OwnerID] = struct{}{} } if e.ActorID != uuid.Nil { recipients[e.ActorID] = struct{}{} } for uid := range recipients { _ = a.d.Dispatch(ctx, notify.Message{ UserID: uid, Topic: "requirement.phase_change", Severity: notify.SeverityInfo, Title: "Requirement phase changed", Metadata: meta, }) } } // workspaceStateAdapter implements project.WorkspaceStateLookup over the // change-request repository: the Done gate passes when THIS requirement has a // change request in state merged (requirement-scoped, not "any PR in the // workspace"). Backed by changerequest.Repository so the gate runs in a system // context without per-caller auth. type workspaceStateAdapter struct{ crRepo changerequest.Repository } func (a workspaceStateAdapter) PullRequestMerged(ctx context.Context, workspaceID, requirementID uuid.UUID) (bool, error) { crs, err := a.crRepo.ListByWorkspace(ctx, workspaceID) if err != nil { return false, err } for _, cr := range crs { if cr.State == changerequest.StateMerged && cr.RequirementID != nil && *cr.RequirementID == requirementID { return true, nil } } return false, nil } // acpEventsPurgeAdapter exposes acp.Repository.PurgeEventsBefore as the narrow // interface required by runners.AcpEventsPurge. type acpEventsPurgeAdapter struct{ repo acp.Repository } func (a acpEventsPurgeAdapter) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) { return a.repo.PurgeEventsBefore(ctx, before) } // acpTurnsPurgeAdapter exposes acp.Repository.PurgeTurnsBefore as the narrow // interface required by runners.AcpTurnsPurge. type acpTurnsPurgeAdapter struct{ repo acp.Repository } func (a acpTurnsPurgeAdapter) PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) { return a.repo.PurgeTurnsBefore(ctx, before) } // mcpTokensPurgeAdapter exposes mcp.Repository.PurgeExpired as the narrow // interface required by runners.MCPTokensPurge. type mcpTokensPurgeAdapter struct{ repo mcp.Repository } func (a mcpTokensPurgeAdapter) PurgeExpired(ctx context.Context, before time.Time) (int64, error) { return a.repo.PurgeExpired(ctx, before) } // acpModelPriceAdapter resolves an LLM model_id to its prices for ACP cost // accounting, bridging acp.ModelPriceLookup to chat.Repository.GetLLMModel. // Reuses the single pricing source (chat) without an acp→chat compile-time dep. type acpModelPriceAdapter struct{ repo chat.Repository } func (a acpModelPriceAdapter) PriceForModel(ctx context.Context, modelID uuid.UUID) (acp.ModelPrice, error) { m, err := a.repo.GetLLMModel(ctx, modelID) if err != nil || m == nil { return acp.ModelPrice{Found: false}, nil } return acp.ModelPrice{ PromptPerM: m.PromptPricePerMillionUSD, CompletionPerM: m.CompletionPricePerMillionUSD, ThinkingPerM: m.ThinkingPricePerMillionUSD, Found: true, }, nil } // acpModelValidatorAdapter validates that a model_id references an enabled // llm_model when an admin assigns one to an agent kind. type acpModelValidatorAdapter struct{ repo chat.Repository } func (a acpModelValidatorAdapter) IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error) { m, err := a.repo.GetLLMModel(ctx, modelID) if err != nil || m == nil { return false, nil } return m.Enabled, nil } // acpSessionReaperAdapter exposes the ACP repo's reaper surface as the narrow // interface required by runners.AcpSessionReaper. type acpSessionReaperAdapter struct{ repo acp.Repository } func (a acpSessionReaperAdapter) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]runners.ReaperSession, error) { rows, err := a.repo.ListSessionsForReaper(ctx, wallClockStartedBefore, idleSince) if err != nil { return nil, err } out := make([]runners.ReaperSession, 0, len(rows)) for _, r := range rows { out = append(out, runners.ReaperSession{ ID: r.ID, UserID: r.UserID, StartedAt: r.StartedAt, LastActivityAt: r.LastActivityAt, MaxWallClockSeconds: r.MaxWallClockSeconds, }) } return out, nil } func (a acpSessionReaperAdapter) MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error { return a.repo.MarkSessionTerminatedReason(ctx, id, reason) } // ===== code index / project memory / docs adapters ===== // embeddingSelector resolves the configured platform embedder from cfg.CodeIndex. // When no embedding endpoint is configured it returns codeindex.ErrEmbeddingDisabled // so callers degrade to keyword fallback (memory) or a typed disabled error (search). type embeddingSelector struct { registry *llm.Registry cfg config.CodeIndexConfig } func (s *embeddingSelector) Embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) { if s.cfg.EmbeddingEndpointID == "" { return nil, uuid.Nil, "", 0, codeindex.ErrEmbeddingDisabled } endpointID, err := uuid.Parse(s.cfg.EmbeddingEndpointID) if err != nil { return nil, uuid.Nil, "", 0, fmt.Errorf("code_index.embedding_endpoint_id parse: %w", err) } emb, err := s.registry.GetEmbedder(ctx, endpointID) if err != nil { return nil, uuid.Nil, "", 0, err } return emb, endpointID, s.cfg.EmbeddingModel, llm.PlatformEmbeddingDims, nil } // workspaceLocatorAdapter resolves workspace/worktree on-disk paths for codeindex // without exposing the full workspace.Repository (and without an import cycle: // codeindex defines the narrow interface, app implements it over wsRepo). type workspaceLocatorAdapter struct{ repo workspace.Repository } func (a *workspaceLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (codeindex.WorkspaceInfo, error) { w, err := a.repo.GetWorkspaceByID(ctx, wsID) if err != nil { return codeindex.WorkspaceInfo{}, err } return codeindex.WorkspaceInfo{ID: w.ID, MainPath: w.MainPath, DefaultBranch: w.DefaultBranch}, nil } func (a *workspaceLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, string, uuid.UUID, error) { wt, err := a.repo.GetWorktreeByID(ctx, worktreeID) if err != nil { return "", "", uuid.Nil, err } return wt.Path, wt.Branch, wt.WorkspaceID, nil } // docsLocatorAdapter resolves on-disk directories for docs (commit summaries). type docsLocatorAdapter struct{ repo workspace.Repository } func (a *docsLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (string, error) { w, err := a.repo.GetWorkspaceByID(ctx, wsID) if err != nil { return "", err } return w.MainPath, nil } func (a *docsLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, error) { wt, err := a.repo.GetWorktreeByID(ctx, worktreeID) if err != nil { return "", err } return wt.Path, nil } // commitSummarizer implements docs.Summarizer using the chat title-generator model // via the llm registry. It collects text deltas from a single Stream into markdown. type commitSummarizer struct { registry *llm.Registry chatRepo chat.Repository log *slog.Logger } func (s *commitSummarizer) Summarize(ctx context.Context, branch, commitSHA, diff string) (string, string, string, error) { model, err := s.chatRepo.GetTitleGeneratorModel(ctx) if err != nil { return "", "", "", fmt.Errorf("resolve summary model: %w", err) } if model == nil { return "", "", "", fmt.Errorf("no title-generator model configured for commit summaries") } client, err := s.registry.GetClient(ctx, model.EndpointID) if err != nil { return "", "", "", fmt.Errorf("resolve summary client: %w", err) } const sys = "You are a senior engineer writing concise, accurate commit summaries. " + "Given a git commit's metadata and --stat, produce a short markdown summary: a one-line title, " + "then a brief bullet list of what changed and why. Do not invent changes not implied by the stat." prompt := fmt.Sprintf("Branch: %s\nCommit: %s\n\n```\n%s\n```", branch, commitSHA, diff) stream, err := client.Stream(ctx, model.ModelID, llm.Request{ SystemPrompt: sys, Messages: []llm.Message{{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: prompt}}}}, MaxOutputTokens: 800, }) if err != nil { return "", "", "", fmt.Errorf("summary stream: %w", err) } var b strings.Builder for ev := range stream { switch ev.Type { case llm.EventTextDelta: b.WriteString(ev.Text) case llm.EventError: if ev.Err != nil { return "", "", "", fmt.Errorf("summary stream: %w", ev.Err) } } } body := strings.TrimSpace(b.String()) if body == "" { return "", "", "", fmt.Errorf("summary stream produced no text") } // Derive a title from the first markdown line. title := firstLine(body) return title, body, model.ModelID, nil } // firstLine returns the first non-empty line, stripped of leading markdown // heading / bullet markers, capped to a reasonable title length. func firstLine(s string) string { for _, ln := range strings.Split(s, "\n") { ln = strings.TrimSpace(ln) ln = strings.TrimLeft(ln, "#-*> ") if ln != "" { if len(ln) > 200 { ln = ln[:200] } return ln } } return "" }