外设配额判点(INV-QUOTA-05)
wesgine 五个外设 cap 的判据位置——在唯一增长点判定,与增长同临界区。
核心问题
五个外设 cap 都曾越界,方式只有两种,而两种都不报错、不打日志:
形状一:判在三个调用方之一
技能 cap 曾只写在 Skills().InstallPackage,InstallFromDir 与 boot 播种直接走过去。
形状二:判与写分成两步
两个写者各读到 cap-1 各加一,集合落在 cap+1,每一步算术都对。
症状分岔
| 子系统 | 越界症状 |
|---|---|
| cron / email | 多一行(不致命) |
| Channels / MCPs | 集合就是 CellSpec 且 Cell.Start 会校验它 |
Channels/MCPs 的分岔更严重:运行期越界写入持久化成功、当场毫无异常,下一次重启才拒绝启动。
五处共有形状
适配器不知道 cap 的数值
↓
只收 QuotaFn(current, delta) error 提问
↓
数值留在 internal/cell/quotas.go 的 CheckAdapterCount
↓
每子系统恰好一处能让集合变大
↓
那一处握着自己的锁跨越「问」和「长」
QuotaFn 模式
// 适配器侧——不知道 cap 数值
type QuotaFn func(current, delta int) error
// 使用示例
func (s *Scheduler) Add(ctx context.Context, actor string, spec JobSpec) (string, error) {
// 在锁内:先问再长
s.mu.Lock()
defer s.mu.Unlock()
current := s.count()
if err := s.quotaFn(current, 1); err != nil {
return "", err // 超限拒绝
}
// 插入新 job
}
五个 cap 的判点
| Cap | 唯一增长点 | 临界区 |
|---|---|---|
MaxIMChannels | UpdateSpec 的 Channels append | SpecPatch 写锁 |
MaxMCPServers | UpdateSpec 的 MCPs append | SpecPatch 写锁 |
MaxCronJobs | cron.Scheduler.Add | s.mu |
MaxEmailAccounts | email.Store.PutAccount | s.mu |
MaxSkillsInstalled | skills.Loader.installToDir | l.mu |
关键约束
「增长」不是「写入」
整体替换(UpdateSpec 的 Channels = append(nil, patch...))对 cap 幂等,收缩不需要 cap。
只有 append-to-self 会跨并发调用方累加。
覆盖已有名字 delta=0
upsert 是编辑手势。卡在上限的 Cell 必须还能轮换凭据。
current 取自权威处
- 技能缓存在 boot 播种时是空的
SKILL.md解析失败的技能落进brokenSkills却仍占着目录
进程内互斥量是充分的
INV-RESIL-04 让单进程独占 Cell 目录 flock。写者集合在构造上就是进程内的。
MaxPlugins 例外
MaxPlugins 不在其列——boot 期截断而非写点拒绝,没有运行期写门。
并发测试
闸门要求并发测试证明临界区跨越「问」和「长」:
// 拆开临界区后必须变红
func TestQuota_Concurrent(t *testing.T) {
// 渠道那条跑 500 轮
// 把「单次不太可能」变成「整体必然」
}
从未红过的并发测试测的是调度器,不是不变量。
闸门规则
66-quota-single-writepoint.sh 五条规则:
- 适配器不持有 cap 数值(只收 QuotaFn)
- 适配器不调 CheckAdapterCount
- 每子系统恰好一处写点(双向普查)
- 写点内有 quotaFn 调用
- 有并发测试且拆开能变红