跳到主要内容

第 3 章:领域模型、PostgreSQL 与 sqlc

3.1 数据库在 Multica 中不是“最后一层存储”

Multica 的浏览器、Server、Daemon 和 Agent CLI 随时可能独立断开。要让工作不随某个进程消失,PostgreSQL 必须承担更多责任:

  • 领域事实源;
  • 任务队列与状态机;
  • 调度租约和幂等记录;
  • 权限、归因和审计快照;
  • 实时断线后的恢复依据;
  • Server 多实例之间的竞争仲裁。

因此,阅读数据库不是为了背表名,而是为了找系统不变量。

当前快照包含 262 个向上迁移、42 个手写查询文件,sqlc 生成的 models.go 中有 82 个模型结构体。生成代码很大,但它把数据库形状变成了编译期 Go 类型。

3.2 领域全景

可以把主要表按六个领域簇理解:

图是概念关系,不表示每条边都有数据库外键。当前仓库的演进规则明确要求:新关系通常由应用层维护,不新增 FK 或 ON DELETE CASCADE;旧迁移中仍可能存在历史约束,不能把规则误读成“整个数据库绝无外键”。

3.3 身份与工作区簇

核心模型:

  • user:账号身份;
  • workspace:数据隔离、编号前缀、上下文与产品设置边界;
  • member:用户在 Workspace 中的角色;
  • workspace_invitation:邀请状态;
  • personal_access_token:人类/CLI 长期令牌;
  • daemon_token:Daemon 机器凭据;
  • task_token:任务内短生命周期能力令牌;
  • notification_preferenceclient_usage_daily:用户侧设置与使用记录。

Workspace ID 几乎贯穿所有业务查询。正确的访问模式不是“先按 ID 查到记录,再相信客户端说它属于当前 Workspace”,而是使用带 Workspace 条件的查询,例如 GetCommentInWorkspaceGetAgentInWorkspace

这既防越权,也防 UUID 被错误复用到摘要、事件或 Claim Payload 中造成侧信道泄漏。

3.4 工作对象簇

Issue

Issue 包含:

  • Workspace 与项目归属;
  • title / description / status / priority / due_date
  • 多态 creator_type + creator_id
  • 多态 assignee_type + assignee_id
  • 父子 Issue、排序位置、验收条件、上下文引用;
  • 编号与标识符所需字段。

assignee_type 使 Issue 可以指向 Member、Agent 或 Squad。数据库无法用一个普通 FK 同时表达三张表,因此类型对与权限必须由 Handler/Service 联合验证。

Comment 与 Activity

Comment 不只是留言:它还能触发 Agent、形成线程、记录来源 Task,并与附件、Reaction、Resolution 连接。

activity_log 记录结构化行为,例如状态变化、分配变化和 Squad Leader 的 no_action 结论。Comment 是对话内容,Activity 是审计时间线;两者不要混用。

Label、Property、Pin、Subscriber

这些看似 UI 辅助表,实际参与筛选、通知、自动订阅和 Agent 上下文:

  • Label 可以关联 Issue、Agent、Skill;
  • Property Definition 与 Issue Property Value 支持自定义字段;
  • Subscriber 决定事件后谁收到 Inbox/通知;
  • Pin 是用户/工作区导航状态。

3.5 Agent、Runtime 与 Task 是三种不同生命周期

Agent:长期配置

Agent 持有:

  • 名称、头像、说明与指令;
  • Provider/Runtime 配置、模型、Thinking Level、Service Tier;
  • 自定义环境、参数、MCP;
  • Owner、状态、归档;
  • 最大并发;
  • permission_mode 与 Connected App allowlist;
  • 禁用的 Runtime-local Skills。

它像一个团队成员档案,不代表当前正在运行的进程。

Runtime:某台执行能力的登记

agent_runtime 持有 Provider、Daemon ID、Profile、状态、最后心跳、设备信息和 Owner。

同一台 Daemon 可以在多个 Workspace 注册;同一 Workspace 又可以因 Provider 或自定义 Profile 产生多个 Runtime。Runtime 离线不等于 Agent 被删除,它只是当前不可执行。

Task:一次执行尝试

AgentTaskQueue 是全系统字段最密集的模型之一,因为它必须冻结一次执行的完整证据:

  • agent_id / runtime_id / issue_id / chat_session_id / autopilot_run_id
  • status / priority / attempt / max_attempts / fire_at
  • dispatched_at / started_at / completed_at
  • session_id / work_dir / force_fresh_session
  • trigger_comment_id / coalesced_comment_ids / delivered_comment_ids
  • parent_task_id / retry_of_task_id / rerun_of_task_id
  • failure_reason / error / result / usage
  • Squad、Leader、Escalation、Handoff;
  • runtime_mcp_overlay / runtime_connected_apps
  • originator_user_id / accountable_user_id / originator_source
  • 触发证据类型、证据 ID 与委派来源。

字段多不是单纯“表设计失控”。它反映了一个重要选择:任务一旦进入队列,就尽量携带足以执行、授权、恢复和审计的快照,避免运行几分钟后依赖已经变化的外部对象。

3.6 为什么重试创建新 Task

Multica 的系统重试不是把原行从 failed 改回 queued,而是创建子 Task:

task A: attempt=1, status=failed
└─ task B: attempt=2, retry_of_task_id=A, status=queued
└─ task C: attempt=3, retry_of_task_id=B, status=deferred

这样可以:

  • 保留每次真实尝试的错误、消息、用量和时长;
  • 区分系统重试与人类手动 rerun;
  • 让 Session/Workdir 继承策略在创建时显式计算;
  • 避免历史观测被覆盖;
  • 对并发查询更友好,终态仍是终态。

实现集中在 agent.sqlCreateRetryTaskTaskService.FailTask

3.7 归因为什么需要两个人字段

originator_user_idaccountable_user_id 看起来重复,实际用途不同:

  • Originator:授权链顶端的人,用于 Agent-to-Agent 调用判定等安全决策;
  • Accountable User:成本、审计和可见性上应归属的人;
  • 对普通人类触发,两者通常相同;
  • 对没有直接人类触发的 Autopilot,可以通过 Rule/Trigger Publisher 找到 Accountable User,但授权链仍可能没有 Originator。

源码注释反复强调:Accountable User 绝不能被当作授权依据。否则“谁应对这次自动任务负责”会被误用成“谁实时批准了这次能力访问”。

相关逻辑在 internal/attribution 与 Task 创建路径中。

3.8 Skill 与能力簇

服务端 Skill 使用:

  • skill:名称、说明、主内容、配置、Workspace;
  • skill_file:附属文件;
  • agent_skill:Agent 关联与启用状态;
  • skill_to_label:分类。

任务 Claim 时不一定把全部内容直接塞入一条巨大响应。系统可以传 Skill Ref、内容 Hash 与 Bundle,让 Daemon 用内容寻址缓存解析。这使大 Skill 在多次任务间复用,也能校验服务端声明与本地缓存一致。

3.9 Project 与 Resource

Project 是稳定的业务上下文;project_resource 是可扩展资源容器,resource_type + resource_ref(JSON) 表达不同资源。

当前最关键的两类是:

  • 仓库信息:供 Daemon Bare Cache / Worktree 使用;
  • local_directory:把任务绑定到某台 Daemon 上已有的本地目录。

resource_ref 的多态换来扩展性,也要求 Handler 和 Daemon 双端做严格验证。特别是本地目录,不能因为数据库里存了一段路径就直接信任。

3.10 Squad 与 Autopilot 簇

Squad:

  • squad:Workspace、Leader Agent、名称与状态;
  • squad_member:成员类型、成员 ID、角色;
  • Issue/Autopilot 通过 assignee_type=squad 指向它。

Autopilot:

  • autopilot:规则主体、执行模式、目标 Agent/Squad、模板;
  • autopilot_trigger:Schedule/Webhook 等触发条件;
  • autopilot_run:每次执行;
  • autopilot_rule_version:实质性发布的追加式归因快照;
  • webhook_delivery:入站投递的可靠队列;
  • Collaborator/Subscriber:写权限与通知范围。

规则版本单独追加,是因为“今天由谁编辑的规则”不能覆盖“昨天那次 Run 当时由谁负责”的审计事实。

3.11 Chat 与 Channel 簇

Multica Chat 与外部渠道被分成两层:

  • chat_session / chat_message / chat_draft_restore / chat_pinned_agent:产品内统一对话;
  • channel_installation / channel_user_binding / channel_chat_session_binding:外部平台到 Multica 的映射;
  • channel_inbound_message_dedup / channel_inbound_audit:重放防护和丢弃原因;
  • channel_binding_token:外部用户绑定;
  • channel_outbound_card_message:富消息回写状态。

这个模型让 Slack Thread 和 Feishu Chat 最终都落到 Chat Session,但保留各自回复目标与安装凭据。

3.12 GitHub 与通用 VCS 为什么有两套表

GitHub 使用 App Installation、PR 与 Check Suite 模型:

  • github_installation
  • github_pull_request
  • github_pull_request_check_suite
  • issue_pull_request 等关联。

Forgejo/Gitea/GitLab 使用 Token + Instance URL 的通用模型:

  • vcs_connection
  • vcs_pull_request
  • vcs_commit_status
  • issue_vcs_pull_request

不是简单重复:GitHub App 安装和 Check Suite 的身份/事件模型与 Token 型实例差异足够大,强行共用会在每个查询里制造分支。

3.13 sqlc 的角色

手写 SQL 位于 server/pkg/db/queries,sqlc 生成:

  • 参数结构体;
  • 返回模型;
  • Queries 方法;
  • WithTx 事务绑定。

优点:

  • SQL 仍可使用 PostgreSQL 的锁、CTE、SKIP LOCKED、JSONB 和数组能力;
  • Go 调用端有编译期类型;
  • 查询命名成为业务词汇,如 ClaimAgentTaskRecoverOrphanedTasksForRuntime
  • 事务内与事务外使用同一方法形状。

代价是生成文件很大,不应把改动直接写进 generated/。事实源是 Query SQL 与 Migration。

3.14 三类并发控制模式

原子状态迁移

Task 更新通常在 WHERE status IN (...) 中限定前态。返回 0 行意味着另一个请求已经赢得迁移,而不是无条件覆盖。

行锁与唯一约束仲裁

例如 Channel 第一次消息并发创建 Chat Session:双方都尝试创建,唯一约束只允许一个绑定;失败方重新读取获胜行。

Workspace 删除与 Chat/Project 创建还会对父记录取锁,避免“删除到一半又插入子记录”。

租约与 Fence Token

Scheduler、Webhook Delivery、Channel WS Supervisor、Task Prepare 都使用时间租约或 Claim Token:

  • 获胜者定期心跳;
  • 过期者可被接管;
  • 旧 Worker 的终态更新必须带原 Lease/Fence,避免复活后覆盖新 Worker。

这比一个进程内 Mutex 更适合多 Server 实例。

3.15 为什么现代迁移偏好应用层级联

当前工程规则不鼓励新增 FK/Cascade,主要考虑:

  • 多态 Actor/Assignee 本来无法由普通 FK 表达;
  • 大规模删除需要明确顺序、事件和副作用;
  • 某些审计/历史记录应在父对象删除后保留;
  • 在线迁移和跨版本兼容更容易控制;
  • 异步 Worker 与软删除常需要“看见”中间状态。

但代价很真实:

  • 每个删除流程都必须完整列出子资源;
  • 每个读取都必须防孤儿数据;
  • 测试要覆盖并发创建/删除;
  • 注释和查询不变量更重要。

这不是“应用层一定优于数据库约束”,而是本项目在多态、审计和在线演进约束下做出的统一选择。

3.16 读数据层时优先看什么

  1. 先看 models.go 的当前字段形状。
  2. 再看对应 Query SQL,理解真实 WHERE、锁和状态前置条件。
  3. 若字段语义不明,查引入它的 Migration 注释。
  4. 最后看 Service 怎样把多个 Query 包成事务并发布事件。

不要只读最早的 001 初始化迁移;262 次演进后,早期约束和枚举可能已经被拓宽或替换。

3.17 本章结论

Multica 的数据库同时是领域模型、队列、调度协调器和恢复日志。最关键的建模选择有三个:

  1. Agent、Runtime、Task 分离,各自拥有不同生命周期。
  2. Task 冻结权限、归因、上下文和恢复证据,重试创建新尝试而不覆盖历史。
  3. 多态关系与现代弱引用由应用层事务、锁、唯一约束和测试共同维护。

下一章回到 Server 入口,看认证、中间件、路由和后台 Worker 怎样围绕这些数据模型组装起来。


上一章:Monorepo 骨架与架构边界
下一章:服务启动、路由、中间件与认证