第 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_preference、client_usage_daily:用户侧设置与使用记录。
Workspace ID 几乎贯穿所有业务查询。正确的访问模式不是“先按 ID 查到记录,再相信客户端说它属于当前 Workspace”,而是使用带 Workspace 条件的查询,例如 GetCommentInWorkspace、GetAgentInWorkspace。
这既防越权,也防 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.sql 的 CreateRetryTask 与 TaskService.FailTask。
3.7 归因为什么需要两个人字段
originator_user_id 与 accountable_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 调用端有编译期类型;
- 查询命名成为业务词汇,如
ClaimAgentTask、RecoverOrphanedTasksForRuntime; - 事务内与事务外使用同一方法形状。
代价是生成文件很大,不应把改动直接写进 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 读数据层时优先看什么
- 先看
models.go的当前字段形状。 - 再看对应 Query SQL,理解真实 WHERE、锁和状态前置条件。
- 若字段语义不明,查引入它的 Migration 注释。
- 最后看 Service 怎样把多个 Query 包成事务并发布事件。
不要只读最早的 001 初始化迁移;262 次演进后,早期约束和枚举可能已经被拓宽或替换。
3.17 本章结论
Multica 的数据库同时是领域模型、队列、调度协调器和恢复日志。最关键的建模选择有三个:
- Agent、Runtime、Task 分离,各自拥有不同生命周期。
- Task 冻结权限、归因、上下文和恢复证据,重试创建新尝试而不覆盖历史。
- 多态关系与现代弱引用由应用层事务、锁、唯一约束和测试共同维护。
下一章回到 Server 入口,看认证、中间件、路由和后台 Worker 怎样围绕这些数据模型组装起来。
上一章:Monorepo 骨架与架构边界
下一章:服务启动、路由、中间件与认证。