Files
bj_power/bj_power_mes/agent约束.md
T

897 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# agent约束.md
> **内容范围:** 仅存放架构约定、代码规范、框架级安全原则等**非业务**内容。不写入具体设备、工序、场景等业务细节(业务内容归 `agent业务.md` 管理)。无新增架构/规范层面的发现,不强行写入。
本文档禁止agent更改任何内容,禁止人工更改。同架构新项目复刻开发的固定规则。AI 不猜测「能不能做」,只执行「固定约定 + 可修改范围」。人工提前确认所有基础基准文件后交付 AI,AI 仅读取使用、禁止私自修改基准文件内容。
---
## 一、新项目启动前,人工必须确认的基础基准文件
| # | 文件 | 说明 | 新项目行为 |
|---|------|------|-----------|
| 1 | `doc/signals.sql` | PLC 全部点位、寄存器地址、数据类型、读写方向 | 人工替换为新设备信号表 |
| 2 | `schema/*.go` | Ent ORM 表结构定义(运行 `go generate` 生成 `ent/` 代码) | 人工修改表结构后运行生成 |
| 3 | `doc/data.sql` | 数据库种子数据(设备类型、设备实例、槽位、配方、工序步骤) | 人工替换为新产线设备/配方 |
| 4 | `agent业务.md` | 业务规则文档(工艺步骤、调度规则、闭环) | 人工填写工艺步骤和业务规则 |
> 以上 4 项是 AI 的「只读数据源」,AI 不修改、不新增、不删除其中的任何内容。
> Mock 模式是配置项,每个新项目都保留模拟能力。
> `agent业务.md`: 1. 标记的说明,agent 要注释到相关代码里;2. 重要业务节点标记清楚:触发点、流向、结果;3. 业务要闭环:如何从 0 启动、多种收尾情况如何处理
> 数据库表注释很重要,开发人员会完整维护,agent 也要遵守。
---
## 二、业务文档编写约束(`agent业务.md` 编写规范)
### 文档定位
`agent业务.md` 是**业务人员视角**的文档,描述「产线要做什么」,不描述「代码怎么实现」。
**可以写:** 产线设备及工序、触发/完成条件、异常处理、业务规则、正常/收尾流程。
**禁止写(SQL/schema 已有,不许重复):** 设备槽位数、IP、信号地址、设备类型码、exchange/batch/dualGrip 字段值、Go 常量名/包名/函数名、工件/槽位状态机。
### 编写规则
1. **OP 是执行单元,非判断单元**:描述单设备上的完整操作,不描述条件分支逻辑。
2. **条件分支独立标记**:路由判断作为步骤间规则描述,由 `recipe_step.next_step_default` 承载。
3. **自然语言**:不使用代码中的常量名、表名、字段名、信号名。
4. **不重复数据**:基准文件中已有的信息不重复声明。
---
## 三、项目目录结构与包职责(固定,不可变)
```
spherical/
├── apis/ # API 定义 (.api)go-zero 代码生成源
├── cmd/ # 程序入口(debug、mockrun、engine_mockrun
├── common/ # 通用工具(errorx、httpx、logx、token
├── constants/ # 全局常量(槽位状态、工件状态、工序类型)
├── doc/ # 基准数据文件
├── etc/ # 配置文件
├── internal/
│ ├── action/ # 动作定义(RobotAction + Validate
│ ├── alarm/ # 告警服务
│ ├── config/ # 配置结构体
│ ├── db/ # 数据库连接(Ent + 原生 SQL)
│ ├── engine/ # 全配置化流程引擎(ACQUIRE→EXECUTE→WAIT→COMPLETE,纯执行器)
│ ├── eventbus/ # 事件总线
│ ├── eventlog/ # 事件日志异步批量落库
│ ├── handler/ # HTTP Handlergo-zero 生成)
│ ├── logic/ # HTTP 业务逻辑
│ ├── middleware/ # HTTP 中间件
│ ├── mock/ # Mock PLC
│ ├── plc/ # PLC 连接管理(S7 连接/读写/重连,不含业务逻辑)
│ ├── plcactions/ # PLC 动作执行器(Req→等Done→复位 闭环)
│ ├── preload/ # 预加载(信号地址映射、产品类型)
│ ├── processor/ # 产线核心逻辑
│ │ ├── actor/ # 设备 Actor(锁保护槽位状态机,不写 DB)
│ │ ├── eventloop/ # 事件循环(唯一生产状态写路径)
│ │ ├── scheduler/ # 调度器(三层管道:生成→过滤→排序,只读 DB)
│ │ ├── steplog/ # 步骤日志
│ │ └── tool/ # 工具(扫码枪、激光打标)
│ ├── robot/ # 机器人控制器
│ ├── sse/ # SSE 推送
│ ├── svc/ # ServiceContext 根容器
│ └── upload/ # 文件上传
├── schema/ # Ent 表结构定义(Go 源文件)
├── ent/ # Ent 自动生成(禁止手动修改)
├── spherical.go # Windows 主入口
└── spherical_default.go # macOS/Linux 主入口
```
---
## 四、启动顺序(svc.NewServiceContext 内部,固定)
```
1. 解析配置 conf.MustLoad(yaml) → config.Config
2. 数据库连接 db.MustNewDB
3. 预加载数据 preload.Init → 信号地址表、产品类型到内存
4. 事件总线 eventbus.NewLocalBus()
5. 告警服务 alarm.NewService
6. SSE 处理器 sse.NewHandler()
7. PLC 连接 plc.Build 或 Mock
8. 机器人管理器 robot.MustNewManager
9. 设备 Actor BuildActors(ctx, entClient) → map[machineID]MachineActor
10. 工单处理器 NewJobProcessor(...)
11. PLC Worker NewPlcWorker(machineActors, plcExecutor)
12. 事件循环 NewProductionEventLoop(...) ← 产线大脑
13. SignalRouter BuildProductionLine(...) → 注入 EventLoop 为 sink
14. 信号轮询 SignalRouter.Run(ctx, 1s) → goroutine
15. 恢复协调器 NewRecoveryCoordinator(...)
16. 总线启动 bus.Start(ctx)
17. 日志写入器 eventLogWriter.Start(ctx)
18. SSE 桥接 sse.NewEventBridge(bus, sseHandler)
19. 事件订阅 bus.Subscribe(SCHEDULE_TICK, ...) → EventLoop.SendScheduleTick()
20. 断电恢复 DBState.RecoverOnStartup(ctx) → 恢复未完成工单
21. Actor 同步 InitActorsFromDB(ctx) → 内存状态对齐数据库
22. 事件循环启动 go eventLoop.Run(ctx) → goroutine
```
---
## 五、运行时信号流与数据流
### 5.1 PLC 信号 → 设备状态变更(完整链路)
```
PLC 硬件信号
→ SignalRouter1s 轮询,上升沿检测)
→ EventLoop.msgCh(串行处理,单一 goroutine
├── handleMachineSignal → Actor.MarkSlotDone → 投递 MACHINE_DONE
├── handleMachineDone → 推进步骤 / 设置 WAITING_UNLOAD
├── trySchedule → RuntimeSnapshot → Scheduler.ScheduleAll(生成→过滤→排序)
└── dispatchWorker → PlcWorker.Execute(RobotAction)
├── Actor 分配/查找槽位
├── PlcExecutor(写Req→等Done→复位Req→等Done清零)
└── WorkerResult → EventLoop
├── Actor.LoadComplete / UnloadComplete(更新内存)
├── DBState.AdvanceStep(推进工序,持久化)
└── trySchedule(闭环触发下一轮)
```
### 5.2 API 请求流程
```
前端 HTTP → handler 参数校验 → logic 业务逻辑
├── 读操作:直接查询 entClient
└── 写操作:通过 EventLoop 消息 → command_handlers → DBState.xxx()
```
---
## 六、数据库严格定义
### 6.1 核心约束
- 表定义源:`schema/*.go`,运行 `go generate` 生成 `ent/`。**禁止手动修改 `ent/`**
- `equipment_slot` 是设备槽位唯一数据源,写操作必须通过 EventLoop → DBState
- MachineActor 不直接写 DB,只操作内存状态
- **数据库字段定义清晰,不可随意改动语义或复用。** 每个字段有明确的业务含义(如 `done_signal_name` 仅用于 SignalRouter 轮询监听,不可随意写入其他信号名),禁止为临时需求借用已有字段,禁止随意扩大字段用途
### 6.2 数据库操作分层
| 层级 | 负责 | 位置 |
|------|------|------|
| 只读查询 | 直接使用 `entClient` | handler/logic 层 |
| 生产状态写入 | 必须通过 EventLoop → DBState | eventloop/dbstate.go |
| 复杂统计查询 | 原生 SQL + 注释说明用途 | logic 层 |
### 6.3 设备建模规则(框架级通用,数据驱动)
不区分工站/搬运设备的显式分类字段,由数据驱动,判别优先级从高到低:
| 优先级 | 条件 | 含义 | 行为 |
|--------|------|------|------|
| 1 | `equipmentType.code = ROBOT` | 搬运设备 | 串行互斥,slotCount 为车载暂存容量,不参与 MachineActor |
| 2 | 非 ROBOT + `slotCount >= 1` | 工站 | 持有工件,进入 MachineActor 状态机 |
| 3 | 非 ROBOT + `slotCount = 0` | 未就绪 | 视为退化/未配置 |
> 机器人 `slotCount > 0` 但优先级 1 命中,仍为 handler,槽位语义为装载→搬运→卸载(瞬时),由 `robot.Controller` 管理。
**信号驱动子类型区分(不存储为字段):**`doneSignalName` → 加工/检测工站;无 → 缓冲工站。
**硬件能力字段(必须显式存储):**
| 字段 | 适用 | 说明 |
|------|------|------|
| `exchange` | 工站 | 支持换料协议:单次到达同时取成品放毛坯 |
| `batch` | 工站 | 批量完成模式:所有槽位同时报完工 |
| `dualGrip` | 搬运设备 | 双夹持能力:配合 exchange 单次往返换料 |
### 6.4 PLC 信号握手协议(框架级标准)
`plcactions.PlcExecutor` 实现,3 步时序:
```
1. 写参数字节(如位置号、托盘号),非必须
2. 置位 Req = true(触发 PLC 动作)
3. 返回(不等待 Done,由 SignalRouter 异步监控)
```
- 命名规范:`{设备名}{动作}Req` / `{设备名}{动作}Done` 成对
- 参数命名:`{设备名}{动作}{参数名}`
- 并发约束:同一设备 Req 不能并发下发,由 EventLoop 串行化保证
#### 6.4.1 PLC 信号硬约束
| # | 规则 | 原因 | 违反后果 |
|---|------|------|---------|
| 1 | **Go 只写 Req=true,绝不写 Req=false** | PLC 程序自行管理信号复位,Go 写 false 会与 PLC 自身逻辑冲突,干扰 PLC 正常运行 | 信号错乱、PLC 状态异常 |
| 2 | **任意两次 PLC 写操作之间必须间隔 ≥ 1 秒** | PLC 扫描周期有限,连续写入可能导致后一次覆盖前一次,或 PLC 漏读上升沿 | 信号丢失 |
| 3 | **所有 done 信号由 SignalRouter 统一异步监控**,不在 executor 中同步阻塞等待 | 同步等待超时后无人收信号,PLC 手动发也收不到;统一监控确保所有信号可追溯 | 信号丢失、排障无日志 |
| 4 | **每次 PLC 读写必须打印日志**,含信号名、地址、值、成功/失败,无论正常还是异常 | 排障需要完整链路,漏日志等于盲飞 | 排障无依据 |
| 5 | **禁止在 Go 代码中复位任何 PLC 信号**(包括 Req 和 Done) | PLC 是信号的主人,Go 只能触发,不能复位 | 与 PLC 程序冲突 |
### 6.5 调度器(框架级通用)
**只读**组件,三层管道:
```
活跃Job → TaskGenerator.Generate() → 候选动作
→ ConstraintFilter.Filter() → 过滤后候选
→ PolicyEngine.Rank() → 排序后候选
→ 取第一个 → dispatchWorker
```
- 触发时机:定时(60s)、Worker 完成后、信号处理完成后;机器人忙时跳过
- 一次调度只派遣一个动作,派遣前动态分配目标设备
- 并发控制:EventLoop 单 goroutine 串行 + robot.Controller sync.Mutex
#### 6.5.1 约束过滤器设计原则
| # | 规则 | 反模式 |
|---|------|--------|
| 1 | **状态输入来自真实系统**`buildSystemState` 每个字段必须有填充逻辑,禁止仅 `make()` 后空置 | `ActiveExchanges` 空初始化导致 `hasExchangePair` 永远 false |
| 2 | **降级必须加设备占用守卫**:降级(如 Exchange→Load)必须检查目标设备空闲,空闲才降级,否则 `continue` | 无条件降级导致设备重复塞工件 |
| 3 | **覆盖三种设备状态**:A.空闲允许降级 B.有料+可配对走原逻辑 C.有料+无配对丢弃 | 提交修改时需在注释中说明三种场景 |
| 4 | **写入/读取路径格式对齐**:多动作类型走同一写入路径时,注释标出格式对齐点 | `PositionRefId` 格式不一致 |
| 5 | **过滤假设无状态**:不依赖「前一次已过滤了竞争候选」,`MachineHasJob` 从 DB 实时读 |
### 6.6 数据初始化流程
```
1. schema/tools/generate.go → 读取 schema/*.go → 生成 ent/
2. schema/tools/migrate.go → 创建/更新数据库表结构
3. schema/tools/data.go signals → 导入 doc/signals.sql
4. schema/tools/data.go seed → 导入 doc/data.sql
```
| 工具 | 命令 | 输入 | 输出 |
|------|------|------|------|
| generate | `go run generate.go` | `schema/*.go` | `ent/` |
| migrate | `go run migrate.go` | `ent/` + PG | 数据库表结构 |
| data signals | `go run data.go signals` | `doc/signals.sql` | `signal` 表 |
| data seed | `go run data.go seed` | `doc/data.sql` | equipment/recipe 等表 |
变更 schema 需执行步骤 1→2→3→4;变更 signals.sql 仅需步骤 3;变更 data.sql 仅需步骤 4。
---
## 七、架构总纲(框架不可变)
### 7.1 五大架构原则
| # | 原则 | 含义 | 违反后果 |
|---|------|------|---------|
| 1 | **单一写路径** | 所有生产状态变更必须经过 EventLoop → DBState → DB | 数据不一致、并发冲突 |
| 2 | **只读直通** | Scheduler/Logic 直接查询 entClient,读无需经 EventLoop | — |
| 3 | **信号驱动** | 不预排任务,下一步由 PLC 信号或 Worker 结果事件触发实时决策 | 状态脱节 |
| 4 | **串行化并发** | EventLoop 单 goroutine 串行,Robot 互斥锁 | 竞态条件 |
| 5 | **防御式编程** | 外部输入边界校验;函数 error 不许 `_` 忽略 | 隐性失败、panic |
### 7.2 编码架构原则
| # | 原则 | 含义 |
|---|------|------|
| 1 | **类型最小化** | 一个概念一个类型,禁止冗余翻译层 |
| 2 | **校验前置** | 外部输入边界处校验(零值、nil、空字符串) |
| 3 | **错误零容忍** | error 必须日志告警或向上抛出 |
| 4 | **直连不绕路** | 跨包引用直接 import 目标包,不通过中间别名 |
| 5 | **map 安全访问** | 加锁或单 goroutine,访问前检查 key 存在性 |
| 6 | **显式结构体优先** | 消息/事件/命令用显式结构体,禁止 `map[string]any` |
| 7 | **编译期接口验证** | 接口实现必须用 `var _ Interface = (*Impl)(nil)` 做编译期检查,确保编译期即发现接口不匹配 |
| 8 | **接口路径统一** | 已有接口抽象必须充分使用,禁止并行 switch 分发绕过接口;一个概念只走一条路径 |
### 7.3 实体关系
| 关系 | 基数 | 约束 |
|------|------|------|
| WorkOrder → Job | 1:N | Job 是工单实例化 |
| Job → Recipe | N:1 | 配方由产品类型决定,运行时不可变 |
| Recipe → RecipeStep | 1:N(有序) | `next_step_default` 决定流转 |
| Equipment → EquipmentSlot | 1:N | 1 槽位 = 1 原子资源,同持 1 个 Job |
| Equipment → MachineActor | 1:1 | 统一实现,Type 适配 |
| Equipment → PlcExecutor | 1:1 | 设备特有信号握手封装 |
### 7.4 写入路径约束
| 操作类型 | 路径 | 位置 |
|----------|------|------|
| 生产状态写入 | EventLoop → DBState → ent → PG | `eventloop/dbstate.go` |
| 设备槽位写入 | EventLoop → DBState.SetEquipmentSlot | `eventloop/dbstate.go` |
| 只读查询 | Logic/Scheduler 直接使用 entClient | `logic/``scheduler/` |
| 事件审计 | EventBus → EventLogWriter → ent(异步批量) | `eventlog/writer.go` |
> **Actor 不写 DBScheduler 不写 DB。只有 EventLoop 能修改生产状态。**
---
## 八、代码编写强制规范
### 8.1 依赖注入
- 所有组件通过 `New` 构造函数注入。`New` 只能做字段赋值和必要参数处理,**禁止调用实例方法**(如 `m.Init()`
- 禁止 `SetXxx()` 后期绑定;结构体依赖字段一律小写私有
- 不允许单独的 `Init()` 方法:初始化逻辑内联在 `New`
- **依赖注入参数必须用接口类型**,不用具体类型:`NewXxx(worker HardwareWorker)` ✅,`NewXxx(worker *PlcWorker)`
- **依赖图必须为纯树形单向**,禁止循环依赖、双向依赖、网状依赖:组件间只能 `svcCtx → A → B`,不允许 `A ↔ B` 互相引用
### 8.2 context.Context
- ctx 仅存放超时、取消信号、TraceID。**禁止 ctx.WithValue 存入常驻对象**
### 8.3 命名规范
- 接收者:单字母;禁止匈牙利命名法(`alarmService` 不写 `alarmSvc`
- 导出字段:大写开头,完整单词;布尔变量用完整单词(`exists`/`ok`/`found`,不用 `exist`/`isOk`
- PLC 信号:`XxxReq` / `XxxDone` 成对
### 8.4 事件订阅
- 事件订阅、handler 映射、路由注册必须在构造函数内完成,禁止跨文件 `init()` 零散注册
- **跨切面通知(告警、SSE 推送)必须通过 EventBus 解耦**,禁止组件间直接调用:`bus.Publish(event)` ✅,`alarmService.Notify()`
### 8.5 事务
- 多表状态修改必须开启事务(`entClient.Tx()` 或 DBState 原子方法)
- `AdvanceStep` 所有 DB 写在同一 `ent.Tx` 内完成;`FinishJob` 提取 `finishJobTx` 方法接受 `*ent.Tx`,避免嵌套事务
### 8.6 超时
- 所有阻塞等待逻辑必须配置 Timeout,禁止无限等待
### 8.7 注释规范
- **常量必须有行内注释**;关键业务函数有文档注释(业务场景、执行流程、参数、返回)
- 导出结构体字段有行内注释;注释描述影响,不只写「做什么」
- 禁止无意义注释(函数名已自解释)、禁止过时注释
### 8.8 日志规范
- 消息格式:**中文业务描述 + 英文 key**(如 `"工件上料失败 jobId:%d"`
- 关键字段必含:`jobId``orderId``machineId``equipmentName``action``slotNo``stepIndex``signal`
- 同一工件生命周期通过 `jobId` 串联所有日志;错误日志必须含所有上下文字段
#### 8.8.1 外部通信日志强制规范
**所有与外部系统的通信,无论正常还是异常,必须打印日志。** 禁止只打错误日志、不打正常日志。
| 外部系统 | 日志必须包含 |
|----------|------------|
| **PLC** | 每次读写:信号名、地址、数据类型、写入值/读取值、成功/失败。写 Req 必须打「开始」日志;收到 Done 必须打「结束」日志 |
| **MOM** | 每次请求/响应:接口名、请求参数、响应内容、成功/失败 |
| **AGV** | 每次进线/离场:AGV 编号、接驳台编号、时间、成功/失败 |
| **立库/WMS** | 每次交互:操作类型、参数、结果、成功/失败 |
**日志级别规则:**
- 正常操作:`Info`
- 读取失败/网络错误:`Warn`(可重试,非致命)
- 业务逻辑错误/数据异常:`Error`
#### 8.8.2 日志文件存储规范
- **按自然天切割**:每天一个日志文件,命名格式 `{基础名}-YYYY-MM-DD.log`(如 `spherical-2026-08-07.log`
- **日志文件放独立目录**:日志文件必须放 `log/` 子目录,禁止放在 exe 同级目录
- 每天内仍按大小(MaxSize)滚动,保留 MaxBackups 个备份
- 历史日志保留 MaxAge 天,超期自动删除
- 禁止单文件无限增长
### 8.9 Git 规范(AI 辅助开发)
- AI 只能执行读操作(`status`/`diff`/`log`/`branch`/`show`
- 禁止执行写操作(`add`/`commit`/`push`/`rebase`/`reset`/`stash`/`checkout`/`restore`/`clean`
- 代码修改只通过 Edit/Write 工具,不通过 Git 回滚
### 8.10 值对象规范
所有 ID 类型用值对象封装(`internal/processor/types/id_types.go`),禁止裸 `int`/`string`
| 值对象 | 底层 | 校验 | 方法 |
|--------|------|------|------|
| `JobID` | `int` | `> 0` | `Valid()`, `Int()`, `String()`, `ParseJobID()` |
| `OrderID` | `int` | `> 0` | 同上 |
| `MachineID` | `int` | `> 0` | 同上 |
| `SlotNo` | `int` | `> 0` | 同上 |
| `StepID` | `string` | 非空 | `Valid()`, `String()`, `ParseStepID()` |
| `ProductID` | `int` | `> 0` | 同上 |
| `WorkpieceType` | `string` | 非空 | 同上 |
`Valid()` 校验时机:外部输入边界、DB 写入前、公共方法入口。
禁止反模式:裸 `int` 参数、map key 用 `int`、日志直接输出值对象、跨层传递 `int`
### 8.11 Validate 校验函数规范
- 结构体校验逻辑使用**独立函数** `ValidateXxx()``ValidateRobotAction()`**禁止**在结构体上定义 `Validate()` 方法
- 原因:`Validate()` 方法与接口方法(如 `Validate() error`)容易混淆,独立函数名明确校验对象
- 校验必须在派单前、DB 写入前等边界处调用
---
## 九、新项目可修改的范围
### 9.1 基准文件(不可修改,AI 只读)
`doc/signals.sql``schema/*.go``doc/data.sql``doc/agent业务.md`
### 9.2 业务逻辑(完全重写)
`actor/``eventloop/command_handlers.go``eventloop/candidate_handlers.go``scheduler/filter.go``scheduler/policy.go``scheduler/generator.go``plc_worker.go``plcactions/executor.go``recipe_loader.go``robot/``alarm/``handler/`+`logic/``sse/`
### 9.3 可微调参数
轮询周期、消息队列长度、日志批量条数、Mock 清理范围、预加载数据类型
---
## 十、代码安全要求
1. 阻塞调用**必须带超时+ctx取消**
2. error **不许 `_` 忽略**,必须日志告警或向上抛出
3. 切片/map 取值前**判空/长度**,下标访问**校验索引**
4. 多协程共享内存**加锁或单队列串行**
5. 文件/连接/句柄/通道**defer 关闭**
6. 外部参数**合法性校验**(零值/null/空字符串)
7. 循环逻辑**必须有退出条件或最大重试**
8. 第三方调用/JSON 解析**异常捕获**
9. 定时任务/异步 goroutine **panic recover**
10. 批量 DB 操作**加事务或分批**
11. `mustGetAddr` 等查找函数返回错误**必须日志告警+处理**,不能 panic 导致程序退出;空地址返回前必须校验
---
## 十一、设备动作安全架构(防撞机 / 防误操作硬性约束)
### 11.1 核心原则
调度层约束过滤器是**软保护**(依赖 DB,有假阳性风险)。**Actor 带锁槽位检查是硬守卫**——所有 PLC 使能方法(Load/Exchange/Unload/AirBlow)在调用 Station 前必须过 Actor 守卫。
### 11.2 场景覆盖矩阵
| 场景 | 危险指令 | Actor 硬守卫 |
|------|---------|-------------|
| 装料到有料设备 | `load_to_machine` | `AllocateSlot` 无空槽即报错 |
| 空磨床换料 | `exchange_grinder` | `HasEmptySlot()→拒绝` |
| 缓存台没空位放 | `load_full_check` | `AllocateSlot` 遍历全槽 |
| 接驳台没货取 | `load_from_dock` | `FindSlotByJobID` + 槽位一致性 |
| 接驳台有货放成品 | `unload_to_dock` | `Snapshot` 检查目标槽位为空 |
| 磨床空误下换料 | `exchange_grinder` | `HasEmptySlot()→拒绝` |
### 11.3 Actor 守卫实现契约
- **装料类**`a.AllocateSlot(act.JobID)` 带锁遍历空槽,无空槽不下发 PLC
- **取料类**`a.FindSlotByJobID(act.JobID)` 确认工件存在 + 槽位一致(双校验),否则拒绝
- **换料类**`a.HasEmptySlot()==true` → 拒绝(空设备无工件可换)
- **放料类**`a.Snapshot()` 遍历目标槽位,`Status != Empty` → 拒绝(防撞料)
- **槽位状态比较必须用常量** `string(constants.SlotStatus_Empty)`,禁止 `""` 或字面量
### 11.4 新增动作安全检查清单
| 检查项 |
|--------|
| □ 是否有 Actor 守卫(`w.actors[id]` 访问)? |
| □ 守卫失败是否 `return error` 阻断后续? |
| □ 装料=调用 `AllocateSlot` / 取料=调用 `FindSlotByJobID` / 换料=调用 `HasEmptySlot` / 放料=检查目标为空? |
| □ 槽位比较用 `SlotStatus_Empty` 常量? |
| □ 覆盖 Actor 不存在的情况(`if a, ok := w.actors[id]; ok`)? |
| □ 错误信息含设备ID+动作类型? |
### 11.5 三层防御
```
调度层(软保护)→ Actor 层(硬守卫,不可绕过)→ PLC 层(信号握手)
```
---
## 十二、框架级可排查性原则
| # | 原则 | 含义 |
|---|------|------|
| 1 | **显式结构体取代 map** | 跨组件数据用显式结构体(`WorkerResultPayload`等),禁止 `map[string]any` |
| 2 | **带校验的解析类型** | 字符串格式封装为带 `Valid` 的结构体(如 `PositionRef`),禁止零值表示失败 |
| 3 | **函数单一职责** | 单函数超 80 行必须按动作类型拆分 |
| 4 | **错误日志含原始输入** | `slog.Error` 必须含触发错误的原始数据 |
| 5 | **防御性 nil 检查** | map 取值检查 nil;外部输入校验有效性 |
---
## 十三、框架级安全防护原则
### 13.1 宕机风险防护
| # | 原则 | 违反后果 |
|---|------|---------|
| 1 | 运行时 goroutine 必须 `defer recover()`EventLoop.Run、dispatchWorker、EventLogWriter.Run、SignalRouter.Run | 产线崩溃 |
| 2 | 启动路径 fail-fast 正确(`New`/`RecoverOnStartup`/`InitActorsFromDB` 不要求 recover | — |
| 3 | `workerBusy` 标志必须配套超时自动重置(如 5 分钟) | 调度永久阻塞 |
| 4 | DB 操作必须 `context.WithTimeout` | EventLoop 阻塞 |
| 5 | `Send()` 必须 `select + default` 非阻塞,满时记告警 | 信号/API 阻塞 |
| 6 | DB 写 error 必须处理,禁止 `_` 忽略 | 状态不一致 |
### 13.2 并发安全
| # | 原则 |
|---|------|
| 1 | goroutine 启动前提取所有共享数据为局部变量 |
| 2 | map 取值必用 `v, ok := m[key]` |
| 3 | 类型断言必带 ok 检查 |
| 4 | JSON 反序列化 `intFromPayload` 兼容 int/int64/float64 |
### 13.3 业务安全
| # | 原则 | 违反后果 |
|---|------|---------|
| 1 | `handleWorkerResult` 必须幂等(记录已处理 taskID) | 重复推进步骤 |
| 2 | Exchange LoadJob 失败必须同步处理(挂起或标记重配对) | LoadJob 卡住 |
| 3 | 槽位分配失败 fail-fast(挂起+报警,禁 fallback 到槽位 1 | 撞料 |
| 4 | Execute 返回 error 禁止调用 LoadComplete/UnloadComplete | Actor 与硬件不一致 |
| 5 | Exchange 状态推进在同一事务内完成 | 断电后 LoadJob 卡住 |
| 6 | 全检台 RequestInspect 用原子标志去重,卸料后重置 | 重复信号 |
| 7 | 全检台容量从 Actor.SlotCount() 或配置获取,不硬编码 | 配置失效 |
| 8 | 槽位状态用 `SlotStatus_Empty` 常量比较,禁用 `""` | 空槽误判为非空 |
---
## 十四、业务流程完整性约束
### 14.1 三阶段核心问题
| 阶段 | 核心问题 | 解决 |
|------|---------|------|
| **开始** | 创建工单 DB 更新但 Actor 未同步 | 创建后触发调度;Actor 守卫降级为辅助检查 |
| **中间** | 设备状态变更后 Actor 与 DB 不一致 | EventLoop 串行处理,Worker 结果同步 Actor |
| **结尾** | 工单完成后资源未释放 | 取消时强制释放所有资源 |
### 14.2 工单开始阶段
创建流程:验证→检查→开事务(创建工单+工件+更新槽位)→提交→发布 SCHEDULE_TICK → SSE 通知。
**关键约束:**
| # | 约束 | 违反后果 |
|---|------|---------|
| 1 | `PositionRefId` 格式必须为 `equipmentId:slotNo` | 解析失败 |
| 2 | 创建工单后必须发布 `SCHEDULE_TICK` 触发调度 | 工件不启动 |
| 3 | Actor 守卫支持降级:`!found``SrcSlot > 0` 时允许执行 + WARN 日志 | 新工单无法启动 |
| 4 | 接驳台槽位号范围校验 | 越界信号错误 |
### 14.3 工单中间阶段
| 动作 | 执行前检查 | 执行后同步 | 特殊处理 |
|------|----------|----------|---------|
| `load_from_dock` | FindSlotByJobID | LoadComplete | 新工单 Actor 降级 |
| `load_to_machine` | AllocateSlot | LoadComplete | 失败 fail-fast |
| `unload_from_machine` | FindSlotByJobID | UnloadComplete | fail-fast |
| `exchange_grinder` | HasEmptySlot() | 同步卸料+上料 | 空磨床拒绝 |
| `unload_to_dock` | 检查目标空 | UnloadComplete | 非空拒绝 |
磨床换料约束:空→仅 `load_to_machine`;有工件→`exchange_grinder`;最后一件→`unload_from_machine`
### 14.4 工单结尾阶段
- **完成**:所有工件 Completed → 释放接驳台+缓存台 → 更新工单状态 → SSE 通知
- **取消**:标记 Scrapped → 释放所有设备槽位+缓存台 → 更新状态 → 发布报废事件
**资源释放约束:**
- 取消必须释放所有设备槽位(磨床/转台/全检台)+ 缓存台占用
- 接驳台卸料完成后 `Status=Empty`
- 工单完成后清理 `jobRuntimes`
### 14.5 接驳台槽位索引规范(防 bug)
`buildSystemState` 查询 `equipment_slot` 构建 `DockSlots` map**key 必须用 `d.SlotNo`,禁止用 `d.ID`**`ID` 是自增主键,多设备混合自增会导致 key 错乱)。
---
## 十五、数据库字段默认值规范(防坑指南)
### 15.0 核心口诀
- **Nillable 字段(`*int`**:判 nil → 判 > 0 → 才使用。三步缺一不可。
- **非 Nillable 字段**:创建时显式 `SetXxx()`,不依赖 schema 默认值。
- **枚举字段**:永远用 `constants.Xxx` 常量比较,不用 `""` 或字面量。
```
三层默认值各管各的:
Go 代码: 零值 (int=0, string="", *int=nil)
schema: Default() / Optional().Nillable()
PG: INSERT 结果
创建时:不调 SetXxx → schema 默认值生效;调了 → 你设的值生效
读取时:Nillable → *int (nil=NULL);非 Nillable → int (0=0)
头号杀手:*int nil 解引用 → 0 → "0" → 解析失败
二号杀手:忘 SetXxx → 依赖 schema 默认值 → 换库行为变
三号杀手:枚举 "" 当有效值 → 状态判断全错
```
### 15.1 关键字段三层对照
| 字段 | Go 类型 | schema 默认值 | Go 零值 | 易错点 |
|------|---------|-------------|--------|--------|
| `status` (Job) | `JobStatus`(string) | `"CREATED"` | `""` | `""` 非有效状态 |
| `positionRefId` | `string` | `""` | `""` | **禁止写 `"0"`** |
| `tempSlotNo` | `*int` | NULL | `nil` | **nil 解引用 panic** |
| `dockNo` | `*int` | NULL | `nil` | 同上 |
| `priority` | `int` | `2` | `0` | 0=最高优先级,意外抢占 |
| `currentJobId` (slot) | `*int` | NULL | `nil` | **nil 当 0 → 查询工件ID=0** |
| `stepType` (recipe_step) | `string` | **无默认值** | `""` | 创建时必设 |
| `recipeId` (product) | `*int` | NULL | `nil` | nil 解引用 |
### 15.2 强制规则
| # | 规则 | 违反后果 |
|---|------|---------|
| 1 | `*int` 字段使用前必须判 nil + 判 > 0 | panic 或写入 `"0"` |
| 2 | 禁止将 Nillable 的 Go 零值(0)当业务值 | 解析失败 |
| 3 | 创建时非零值含义字段必须显式 `SetXxx()` | 换库行为变 |
| 4 | 枚举字段用 `constants` 常量比较,禁用 `""` | 逻辑错误 |
| 5 | 新增字段确认三层默认值一致 | 行为不可预测 |
### 15.3 安全代码模板
```go
// 模板1:安全读取 Nillable
func safeInt(val *int) int {
if val == nil || *val <= 0 { return 0 }
return *val
}
// 模板2:创建时显式 Set 所有字段
jobCreate := entClient.Job.Create().
SetStatus(constants.JobStatus_Created). // 显式
SetPositionType(constants.PositionType_OnDock).
SetPriority(2).
SetPositionRefId(validOrEmpty).
SetVersion(0)
if dockNo > 0 { jobCreate.SetDockNo(dockNo) } // Nillable 有值才设
// 模板3:枚举比较用常量
if job.Status == constants.JobStatus_Created { ... } // ✅
// if job.Status == "" { ... } // ❌
// 模板4:安全 positionRefId
func buildPosRef(machineID, slotNo int) string {
if machineID <= 0 || slotNo <= 0 { return "" }
return fmt.Sprintf("%d:%d", machineID, slotNo)
}
```
### 15.4 创建必设字段清单
**Job**: WorkOrderId, ProductTypeId, RecipeID, CurrentStepId, Status, PositionType, PositionRefId, Priority, VersionNillable: DockNo, DockSlotNo, TempSlotNo 有值才设)
**WorkOrder**: ProductTypeId, Quantity, Status, Source, WorkOrderNo
**EquipmentSlot**: EquipmentId, SlotNo, StatusNillable: DockNo 接驳台才设)
### 15.5 值对象定义一致性原则
值对象注释只在 `id_types.go` 中唯一确定,业务结构体字段注释只能引用,不重新解释。特殊值(如 `0`)必须在值对象注释中说明业务含义。
---
## 十六、测试约束
### 16.1 mockrun 测试框架
`cmd/mockrun/` 不依赖真实 PLC,使用 Mock PLC 自动回复信号。
```bash
# 终端1go run . -f etc/spherical-api.yaml
# 终端2go run cmd/mockrun/main.go -debug -scenario round -jobs 12
```
### 16.2 场景覆盖
| 场景 | 参数 | 覆盖边界 |
|------|------|---------|
| `round` | `-jobs 12` | 圆形件完整流程 |
| `single_job` | 默认 | 单工件端到端 |
| `square` | `-jobs 6` | 方形件含转台换向 |
| `exchange` | `-jobs 4` | 磨床换料 |
| `cache_reflow` | `-jobs 4` | 全检台满→缓存→回流 |
| `no_material` | 默认 | 空接驳台→暂停 |
| `no_space` | `-jobs 4` | 填满→暂停 |
| `last_piece` | 默认 | 单工件收尾:只卸不换 |
覆盖验证:正常流程、换料、收尾、缓存回流、无料/无空间暂停、资源释放、并发安全。
### 16.3 验证项(`-verify=true`
工单状态 COMPLETED、工件完成数、设备槽位 EMPTY、接驳台成品数量。
### 16.4 测试数据要求
`data.sql` 初始化,Mock 自动清空旧数据,每种产品类型至少 1 工单,每工单 ≥ 3 工件,缓存回流 ≥ 8,无空间 ≥ 13。
### 16.5 通过标准
```
✅ 工单: COMPLETED ✅ 工件: N完成 0报废 ✅ 设备槽位 EMPTY ✅ 接驳台成品到位
```
---
## 十七、positionRefId 字段决策
- **状态**`job``String` 字段,存储如 `"1:42"`;业务已用独立字段解耦,不再依赖解析
- **决策**:保留,降级为展示/辅助字段(删除成本高、有调试价值、无危害)
- **规则**
1. 它是**只写展示字段**,禁止业务逻辑解析它
2. 需要位置信息时用 `positionType` + `dockNo`/`dockSlotNo`/`tempSlotNo`
3. 写入格式 `"设备ID:槽位号"` 或空字符串,**禁止写入 `"0"`**
---
## 十八、业务并发与设备互斥占用机制
### 18.1 双层防护模型
```
内部并发(机器人动作、调度、信号处理)
└── EventLoop 单 goroutine 串行化处理
├── workerBusy 原子标志 → 防止并发派发 Worker 动作
├── Actor sync.Mutex → 保护单个设备槽位状态机
└── goroutine 启动前快照提取 → 防止共享内存并发读写
外部并发(HTTP API、AGV 回调)
└── equipment.occupied 字段 → 外部入口即时校验
├── 被占用 → 立即拒绝,不投递消息到 EventLoop
└── 空闲 → 先标记占用 → 再投递消息
```
**核心原则:EventLoop 串行化处理内部并发;`equipment.occupied` 字段处理外部并发。两者互补,不可互相替代。**
### 18.2 equipment 表占用字段
```sql
ALTER TABLE equipment ADD COLUMN occupied BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE equipment ADD COLUMN occupied_by VARCHAR(20); -- ROBOT / AGV / MANUAL
ALTER TABLE equipment ADD COLUMN occupied_reason VARCHAR(100);
ALTER TABLE equipment ADD COLUMN occupied_at TIMESTAMPTZ;
ALTER TABLE equipment ADD COLUMN occupied_by_operator VARCHAR(100);
```
**仅接驳台需要互斥占用。** 缓存台、磨床、转向台等只有一个机器人操作,EventLoop 串行已保证安全。
### 18.3 互斥规则
| 当前占用 | 机器人取放 | AGV 进线 | AGV 离场 | 人工操作 |
|----------|:---:|:---:|:---:|:---:|
| IDLE | 允许 | 允许 | 允许 | 允许 |
| ROBOT | — | **拒绝** | 允许 | 拒绝 |
| AGV | **拒绝** | 拒绝 | 允许 | 拒绝 |
| MANUAL | **拒绝** | **拒绝** | 允许 | 允许(解锁) |
AGV 离场不受任何占用影响。
### 18.4 读写约束
| 操作 | 路径 | 说明 |
|------|------|------|
| 读取(调度器过滤) | `buildSystemState``EquipmentOccupied` map | 从 DB 读,仅对接驳台 |
| 读取(外部入口校验) | HTTP handler 直接查 `entClient.Equipment.Get()` | 投递消息前校验 |
| 写入(外部入口) | HTTP handler 先写 DB 再投递消息 | 防止投递排队期间的竞态 |
| 写入(内部) | EventLoop 消息处理中写 DB | 串行化保证安全 |
| 清除 | EventLoop 消息处理中写 DB | 统一清理 |
### 18.5 占用常量
```go
const (
OccupiedBy_Robot = "ROBOT" // 机器人取放(仅接驳台相关动作)
OccupiedBy_AGV = "AGV" // AGV 进线
OccupiedBy_Manual = "MANUAL" // 人工占用(故障排查/维护,仅解锁操作)
)
```
### 18.6 与现有并发机制的配合
| 现有机制 | 配合方式 |
|----------|---------|
| EventLoop 串行处理 | 占用字段作为消息处理输入之一 |
| `workerBusy` 原子标志 | 忙时不调度 + 占用字段额外过滤 |
| Actor `sync.Mutex` | 保护槽位状态机,与占用字段互不干扰 |
| `dispatchWorker` goroutine | 快照提取机制不变 |
---
## 十九、全配置化流程引擎(engine 包)
### 19.1 引擎定位
`internal/engine/` 是**纯执行器**,不包含任何设备类型或工序知识。所有业务逻辑通过数据库配置驱动。
**与 EventLoop 的关系**:EventLoop 保留为消息路由器,负责接收外部消息(HTTP/PLC/Worker)→ 更新 DB 事实 → 调用 Engine.TryAdvanceJob 推进 job 步骤。Engine 不直接处理 HTTP 消息或 PLC 信号,只通过 EventLoop 调用或 SignalRouter 回调触发。
### 19.2 引擎核心循环(4 阶段)
```
ACQUIRE(获取资源)→ EXECUTE(发送信号)→ WAIT(等待完成)→ COMPLETE(后置操作)
```
| 阶段 | 读取配置表 | 操作 |
|------|-----------|------|
| ACQUIRE | `recipe_step_resource` | SELECT FOR UPDATE 事务原子获取资源(robot/temp_slot 锁、设备槽位;`{dock_slot}` 解析 job 绑定槽位);决策步骤直接进入 DECISION 处理 |
| EXECUTE | `recipe_step_signal` | 按 sort_order 发送 param/req 信号到 PLC;信号间按 wait_after_ms 等待(毫秒,0=不等待) |
| WAIT | `recipe_step_signal`done 角色) | SignalRouter 异步监控 Done 信号,引擎不主动轮询 |
| COMPLETE | `recipe_step_action` | 释放资源、设置设备槽位状态、发送后置信号、步骤跳转;**步骤切换时死循环防护计数**(见 19.4-4) |
### 19.3 引擎文件职责
| 文件 | 职责 |
|------|------|
| `engine.go` | 核心引擎,TryAdvanceJob 主循环,AlarmService 接口 |
| `acquirer.go` | 资源获取,SELECT FOR UPDATE 事务,死循环检测+报警 |
| `executor.go` | 信号发送,按 sort_order 写入 PLC,失败报警 |
| `completer.go` | 后置操作执行,资源释放/状态设置/跳转,Done 信号匹配 |
| `decision.go` | 决策步骤处理,按 priority 匹配条件分支 |
| `db_ops.go` | 数据库操作封装,加载配置、读写资源 |
| `adapter.go` | I/O 层适配器,桥接引擎与 SignalRouter/PLC |
| `types.go` | 类型定义,Job/JobStep/StepSignal/StepAction 等 |
### 19.4 引擎约束
| # | 约束 | 说明 |
|---|------|------|
| 1 | 引擎不包含业务知识 | 不知道磨床、接驳台、全检台的区别,只关心资源获取 |
| 2 | 资源获取原子性 | SELECT FOR UPDATE 事务,全部成功或全部回滚 |
| 3 | 快照隔离 | job_step 从 recipe 复制,recipe 变更不影响运行中 job |
| 4 | 死循环防护 | total_step_count 在**步骤切换时**advanceToNextStep/jumpToStep 事务内)+1,超限(>= max_step_count,默认 200)→ job.status = ERROR + 报警。**资源等待/决策等待不计数**(如缓存台回流、无料暂停属正常阻塞),防止正常等待被误判为死循环 |
| 5 | 事件驱动触发 | Done 信号到达、资源释放时触发 tryAdvance,非轮询 |
| 6 | held_by_job_id | 支持同一 job 跨步骤持有资源(如机器人持料过清洗) |
| 7 | 日志规范 | 所有步骤日志包含 jobId/stepId/stepName/equipmentType,使用 slog 包 |
| 8 | 报警规范 | 所有不可恢复错误(死循环、PLC 写入失败、DB 写入失败、Done 标记失败)必须创建 alarm 记录 |
### 19.5 引擎报警编码
| alarmCode | 触发条件 | 级别 |
|-----------|---------|------|
| `STEP_LOOP_DETECTED` | 步骤切换计数超限(total_step_count >= max_step_count | ERROR |
| `PLC_WRITE_FAILED` | 写入 param/req 信号到 PLC 失败 | ERROR |
| `DB_WRITE_FAILED` | 数据库写入失败(更新 job_step 状态、事务提交) | ERROR |
| `DONE_SIGNAL_MARK_FAILED` | 标记 Done 信号失败 | ERROR |
### 19.6 引擎测试
```bash
go run cmd/engine_mockrun/main.go -scenario single # 单工件完整流程
go run cmd/engine_mockrun/main.go -scenario concurrent -jobs 4 # 并发资源竞争
go run cmd/engine_mockrun/main.go -scenario decision -jobs 8 # 决策分支
go run cmd/engine_mockrun/main.go -scenario square -jobs 2 # 配油盘流程
```
### 19.7 数据库迁移
新引擎表结构通过 `doc/engine_schema.sql` 管理,`data.exe reset-all` 自动执行。配方种子数据在 `doc/new_recipe.sql`
### 19.8 与 EventLoop 的调用关系
```
EventLoop(消息路由器) Engine(纯执行器)
│ │
├── 新 Job 创建 ──────────────→ TryAdvanceJob(jobID)
├── PLC Done 信号 ────────────→ OnDoneSignal(signalName)
│ └── TryAdvanceJob(affectedJobIDs)
├── 资源释放事件 ─────────────→ OnResourceReleased(type, key)
│ └── 唤醒等待资源的 pending job
└── 调度 Tick ────────────────→ TryAdvanceJob(pendingJobIDs)
```
EventLoop 不再包含业务逻辑(if 磨床/接驳台/全检台),只负责消息路由和 DB 事实更新。
### 19.9 洋葱结构(架构分层,依赖单向不可循环)
整个系统按"洋葱"分层,**每一层只允许依赖内层,禁止反向依赖或跨层调用**。这一结构是"不能死循环"的架构级保证:依赖单向 → 无循环依赖;写路径唯一 → 无并发写冲突。
```
┌─────────────────────────────────────────────────────┐
│ 第 0 层:输入源(HTTP API / MOM 轮询 / PLC 信号) │ ← 外部世界
├─────────────────────────────────────────────────────┤
│ 第 1 层:装配层 svc.NewServiceContext │ ← 依赖注入,只做组装,无业务逻辑
├─────────────────────────────────────────────────────┤
│ 第 2 层:业务处理(handler → logic │ ← 无状态,校验输入 → 投递命令
├─────────────────────────────────────────────────────┤
│ 第 3 层:EventLoop(消息路由器 + 唯一写路径) │ ← 串行处理所有消息,更新 DB 事实
│ · 单 goroutine 消费消息通道(丢满即丢弃+告警) │
│ · 所有 DB 状态变更必须经过这里 │
│ · 引擎触发事件(Done/资源释放)回流到这里 │
├─────────────────────────────────────────────────────┤
│ 第 4 层:Engine(纯执行器,零业务知识) │ ← ACQUIRE→EXECUTE→WAIT→COMPLETE
│ · 事务原子性(SELECT FOR UPDATE
│ · 快照隔离(job_step 从 recipe 复制) │
│ · 死循环防护(步骤切换计数) │
├─────────────────────────────────────────────────────┤
│ 第 5 层:DB(唯一事实源) + PLC I/OPlcExecutor │ ← 最内层,被所有上层依赖
└─────────────────────────────────────────────────────┘
```
**分层规则(不可违反):**
1. 第 3 层 EventLoop 是**唯一生产状态写路径**HTTP/PLC/Worker 消息全部汇入消息通道,串行处理,禁止在引擎之外直接写 job/job_step/equipment_slot 状态。
2. 第 4 层 Engine 不持有任何设备/工序知识,只读配置子表执行四阶段循环;引擎不直接持有 goplc.ClientI/O 经 `EngineSignalAdapter → PlcExecutor` 安全链路。
3. 依赖方向严格单向:Handler→EventLoop→Engine→DB/PLC。**禁止** Engine 回调 Handler、禁止 EventLoop 外的模块直接调用引擎内部方法。
4. 引擎触发外部调度只能通过 `EventTrigger` 接口(事件总线发布 → EventLoop 订阅 → trySchedule),形成闭环但不产生依赖环。
**"保证不能死循环"的三道防线:**
1. **步骤切换计数**`total_step_count` 在步骤推进/跳转的事务内 +1,超限 → job=ERROR + `STEP_LOOP_DETECTED` 报警(防配置错误导致的 A→B→A→B 循环)。
2. **等待不计数**:资源获取失败、决策分支无匹配均属正常阻塞等待,不累计步骤数(防全检台满→缓存回流的正常等待被误杀)。
3. **写路径唯一**EventLoop 单 goroutine 串行,任何时刻一个 job 只有一个推进上下文,不可能出现两个 goroutine 同时推进同一 job 形成的活锁/重复加工。
---
## 二十、本地开发环境约束(AI 工具轻量级运行)
> 开发机为 macOS。执行任何任务必须优先轻量级运行,防止全量构建/全量测试卡死开发机。
1. **禁止全量 `go build`**:本地验证不需要编译整个项目时,不得执行全量 `go build`(本项目依赖重,全量构建会卡死开发机)。
2. **禁止无差别全量测试**:不得执行 `go test ./...` 全量跑测试;只跑改动相关包的测试(如 `go test ./internal/engine/`)。
3. **优先轻量验证**:验证改动用 `go run <具体命令>`(只编译该命令的依赖闭包,如 `go run cmd/engine_mockrun/main.go`)或 `go test <指定包>`
4. **本地跑不需要构建产物就不构建**:本地开发、回归验证一律 `go run` 直跑;只有打包发布(`build.sh` / `build.bat`)才需要真正构建。