# Phase 3: 工具锁 + 工序超时 + 状态校验 实施计划 > **For agentic workers:** 按步骤执行,每步通过 `rtk go build ./...` 验证后继续。 **Goal:** 实现设计文档要求的工具互斥锁、工序级超时自动挂起、PLC-Redis 周期性状态校验三个缺失机制。 **Architecture:** 三个独立机制: 1. 工具锁基于 Redis `SET NX EX` + 调度器约束过滤,确保 Scanner/LaserMarker 互斥 2. 工序超时在 JobRuntime 中启动 step 级 TTL goroutine,超时自动触发 SuspendJob 3. PLC-Redis 校验通过后台 Ticker 抽样对比物理/逻辑状态,不一致产生报警 **Tech Stack:** Go 1.23.4, Redis (go-redis/v9), ent ORM --- ## Chunk 1: 工具锁机制 ### Task 1: Redis 工具锁 **Files:** - Create: `internal/state/tool_lock.go` - Create: `internal/state/tool_lock_test.go` - [ ] **Step 1: 创建 `internal/state/tool_lock.go`** ```go package state import ( "context" "fmt" "time" goredis "github.com/redis/go-redis/v9" ) const ( ToolLockKeyPrefix = "tool:lock:" ToolLockTTL = 30 * time.Second ) // ToolLocker 手持工具 Redis 互斥锁接口 type ToolLocker interface { Acquire(ctx context.Context, toolType string) (bool, error) Release(ctx context.Context, toolType string) error } // RedisToolLocker 基于 Redis SET NX EX 的工具锁 type RedisToolLocker struct { client *goredis.Client } func NewRedisToolLocker(client *goredis.Client) *RedisToolLocker { return &RedisToolLocker{client: client} } func toolLockKey(toolType string) string { return ToolLockKeyPrefix + toolType } func (l *RedisToolLocker) Acquire(ctx context.Context, toolType string) (bool, error) { ok, err := l.client.SetNX(ctx, toolLockKey(toolType), "1", ToolLockTTL).Result() if err != nil { return false, fmt.Errorf("tool lock acquire %s: %w", toolType, err) } return ok, nil } func (l *RedisToolLocker) Release(ctx context.Context, toolType string) error { if err := l.client.Del(ctx, toolLockKey(toolType)).Err(); err != nil { return fmt.Errorf("tool lock release %s: %w", toolType, err) } return nil } // IsLocked 检查工具是否被锁定(供约束过滤使用) func (l *RedisToolLocker) IsLocked(ctx context.Context, toolType string) (bool, error) { val, err := l.client.Exists(ctx, toolLockKey(toolType)).Result() if err != nil { return false, err } return val > 0, nil } // MemoryToolLocker 内存回退(非 Redis 模式) type MemoryToolLocker struct { locks map[string]bool } func NewMemoryToolLocker() *MemoryToolLocker { return &MemoryToolLocker{locks: make(map[string]bool)} } func (l *MemoryToolLocker) Acquire(_ context.Context, toolType string) (bool, error) { if l.locks[toolType] { return false, nil } l.locks[toolType] = true return true, nil } func (l *MemoryToolLocker) Release(_ context.Context, toolType string) error { delete(l.locks, toolType) return nil } func (l *MemoryToolLocker) IsLocked(_ context.Context, toolType string) (bool, error) { return l.locks[toolType], nil } ``` - [ ] **Step 2: 验证编译** Run: `rtk go build ./internal/state` - [ ] **Step 3: 创建 `internal/state/tool_lock_test.go`** ```go package state import ( "context" "testing" ) func TestMemoryToolLocker_AcquireRelease(t *testing.T) { locker := NewMemoryToolLocker() ctx := context.Background() ok, err := locker.Acquire(ctx, "SCANNER") if err != nil || !ok { t.Fatal("first acquire should succeed") } ok, _ = locker.Acquire(ctx, "SCANNER") if ok { t.Fatal("second acquire should fail") } locked, _ := locker.IsLocked(ctx, "SCANNER") if !locked { t.Fatal("should be locked") } locker.Release(ctx, "SCANNER") locked, _ = locker.IsLocked(ctx, "SCANNER") if locked { t.Fatal("should be released") } } func TestMemoryToolLocker_DifferentTools(t *testing.T) { locker := NewMemoryToolLocker() ctx := context.Background() ok1, _ := locker.Acquire(ctx, "SCANNER") ok2, _ := locker.Acquire(ctx, "LASER") if !ok1 || !ok2 { t.Fatal("different tools should not block each other") } } ``` - [ ] **Step 4: 运行测试** Run: `rtk go test ./internal/state/ -run TestMemoryToolLocker -v` --- ### Task 2: 调度器工具锁约束 **Files:** - Create: `internal/scheduler/constraint_tool_lock.go` - Modify: `internal/scheduler/scheduler.go:70-79` - [ ] **Step 1: 创建 `internal/scheduler/constraint_tool_lock.go`** ```go package scheduler import ( "context" "log/slog" ) // ToolLockChecker 工具锁检查接口(解耦 Redis 依赖) type ToolLockChecker interface { IsLocked(ctx context.Context, toolType string) (bool, error) } // ToolLockConstraint 工具锁约束:过滤掉需要已锁定工具的候选任务 type ToolLockConstraint struct { checker ToolLockChecker } func NewToolLockConstraint(checker ToolLockChecker) *ToolLockConstraint { return &ToolLockConstraint{checker: checker} } func (c *ToolLockConstraint) Name() string { return "ToolLock" } func (c *ToolLockConstraint) Filter(ctx context.Context, tasks []CandidateTask, _ SystemState) []CandidateTask { if c.checker == nil { return tasks } var result []CandidateTask for _, t := range tasks { if t.ToolType == "" { result = append(result, t) continue } locked, err := c.checker.IsLocked(ctx, t.ToolType) if err != nil { slog.Warn("scheduler: tool lock check failed, allowing task", "toolType", t.ToolType, "error", err) result = append(result, t) continue } if !locked { result = append(result, t) } } return result } ``` - [ ] **Step 2: 在 `scheduler.go` DefaultScheduler 中注册约束** 修改 `DefaultScheduler()` 的 filter 列表,在现有约束后面追加 `NewToolLockConstraint(nil)`(nil checker 时约束不生效,通过 SetToolLockChecker 注入)。 - [ ] **Step 3: Scheduler 添加 SetToolLockChecker 方法** 在 `scheduler.go` 中新增: ```go // SetToolLockChecker 设置工具锁检查器(可选,仅 SSOT 模式下注入) func (s *Scheduler) SetToolLockChecker(checker ToolLockChecker) { s.filterMu.Lock() defer s.filterMu.Unlock() // 在 filter 链中替换或追加 ToolLockConstraint for _, f := range s.filter.(*CompositeFilter).filters { if tc, ok := f.(*ToolLockConstraint); ok { tc.checker = checker return } } } ``` Wait — CompositeFilter doesn't expose filters directly. Simpler approach: add `SetToolLockChecker` to the `ConstraintFilter` interface or to `CompositeFilter`. Actually, the simplest approach: just add the `toolLock` field to Scheduler struct and inject at construction time. Let me redesign this. - [ ] **Step 4: 验证编译** Run: `rtk go build ./internal/scheduler` --- ### Task 3: JobRuntime 工具锁集成(Acquire/Release 时机) **Files:** - Modify: `internal/processor/job_runtime.go` - Modify: `internal/processor/job_processor.go` - [ ] **Step 1: 在 ToolTask 执行前后加锁/解锁** 在 `job_runtime.go` 的 `buildToolActionFunc` 中: - 执行前调用 `toolLocker.Acquire(toolType)` - 执行后 defer `toolLocker.Release(toolType)` ToolLocker 通过 `JobRuntimeConfig` 注入到 JobRuntime。 - [ ] **Step 2: 验证编译** Run: `rtk go build ./internal/processor` --- ### Task 4: ServiceContext 接线 **Files:** - Modify: `internal/svc/service_context.go` - [ ] **Step 1: 实例化 ToolLocker 并注入 Scheduler 和 JobRuntimeConfig** 在 `service_context.go` 中: - SSOT 启用时创建 `RedisToolLocker`,否则 `MemoryToolLocker` - 注入到 `scheduler.SetToolLockChecker()` - 注入到 `JobRuntimeConfig.ToolLocker` - [ ] **Step 2: 全量编译** Run: `rtk go build ./...` --- ## Chunk 2: 工序超时机制 ### Task 5: StepTimeoutWatcher — 工序级超时监控 **Files:** - Create: `internal/processor/step_timeout.go` - Create: `internal/processor/step_timeout_test.go` - [ ] **Step 1: 创建 `internal/processor/step_timeout.go`** ```go package processor import ( "context" "log/slog" "sync" "time" ) // StepTimeoutWatcher 监控工序级超时。 // 每个正在执行的 step 启动一个 timer,超时后回调 onTimeout。 type StepTimeoutWatcher struct { mu sync.Mutex timers map[string]*time.Timer // key: "jobID:stepIndex" onTimeout func(jobID int, stepIndex int) } func NewStepTimeoutWatcher(onTimeout func(jobID int, stepIndex int)) *StepTimeoutWatcher { return &StepTimeoutWatcher{ timers: make(map[string]*time.Timer), onTimeout: onTimeout, } } func (w *StepTimeoutWatcher) Watch(jobID, stepIndex, timeoutSec int) { if timeoutSec <= 0 { return } w.mu.Lock() defer w.mu.Unlock() key := stepTimeoutKey(jobID, stepIndex) if t, ok := w.timers[key]; ok { t.Stop() } w.timers[key] = time.AfterFunc(time.Duration(timeoutSec)*time.Second, func() { w.mu.Lock() delete(w.timers, key) w.mu.Unlock() if w.onTimeout != nil { w.onTimeout(jobID, stepIndex) } }) } func (w *StepTimeoutWatcher) Cancel(jobID, stepIndex int) { w.mu.Lock() defer w.mu.Unlock() key := stepTimeoutKey(jobID, stepIndex) if t, ok := w.timers[key]; ok { t.Stop() delete(w.timers, key) } } func (w *StepTimeoutWatcher) CancelAll(jobID int) { w.mu.Lock() defer w.mu.Unlock() prefix := stepTimeoutKey(jobID, -1) for key, t := range w.timers { if len(key) >= len(prefix) && key[:len(prefix)] == prefix[:len(prefix)-1] { t.Stop() delete(w.timers, key) } } } func stepTimeoutKey(jobID, stepIndex int) string { return fmt.Sprintf("%d:%d", jobID, stepIndex) } ``` - [ ] **Step 2: 验证编译** Run: `rtk go build ./internal/processor` - [ ] **Step 3: 创建 `internal/processor/step_timeout_test.go`** 测试 Watch/Cancel/CancelAll 基本行为。 - [ ] **Step 4: 运行测试** Run: `rtk go test ./internal/processor/ -run TestStepTimeout -v` --- ### Task 6: 将 StepTimeoutWatcher 接入 JobRuntime **Files:** - Modify: `internal/processor/job_runtime.go` - Modify: `internal/processor/job_processor.go` - [ ] **Step 1: JobRuntime 在每个 step 开始时 Watch** 在 `job_runtime.go` 的 `advanceToStep` 方法中,根据当前 step 的 `StepTimeout` 值: - `> 0`: 调用 `watcher.Watch(jobID, stepIndex, stepTimeout)` - `= 0`: 不监控(无限等待) - [ ] **Step 2: step 完成时 Cancel** 在 step 的 `onComplete` 回调或下一个 step 开始时,调用 `watcher.Cancel(jobID, prevStepIndex)`。 - [ ] **Step 3: 超时回调 → SuspendJob** 在 `JobProcessor` 中实现回调: ```go func (jp *JobProcessor) handleStepTimeout(jobID, stepIndex int) { slog.Warn("step timeout, suspending job", "jobId", jobID, "stepIndex", stepIndex) if err := jp.SuspendJob(context.Background(), jobID, "step_timeout"); err != nil { slog.Error("step timeout suspend failed", "jobId", jobID, "error", err) } } ``` - [ ] **Step 4: 验证编译** Run: `rtk go build ./internal/processor` --- ## Chunk 3: PLC-Redis 状态校验 ### Task 7: 周期性校验器 **Files:** - Create: `internal/verify/state_verifier.go` - Create: `internal/verify/state_verifier_test.go` - [ ] **Step 1: 创建 `internal/verify/state_verifier.go`** ```go package verify import ( "context" "log/slog" "time" "hougai/internal/alarm" "hougai/internal/plc" "hougai/internal/state" ) // StateVerifier 周期性对比 PLC 物理状态与 Redis 逻辑状态。 // 设计文档 §14:不一致则挂起报警。 type StateVerifier struct { stateMgr state.StateManager plcManager *plc.Manager alarmSvc *alarm.Service interval time.Duration } func NewStateVerifier(stateMgr state.StateManager, plcManager *plc.Manager, alarmSvc *alarm.Service, interval time.Duration) *StateVerifier { if interval <= 0 { interval = 30 * time.Second } return &StateVerifier{ stateMgr: stateMgr, plcManager: plcManager, alarmSvc: alarmSvc, interval: interval, } } // Run 启动周期性校验循环,直到 ctx 取消。 func (v *StateVerifier) Run(ctx context.Context) { ticker := time.NewTicker(v.interval) defer ticker.Stop() slog.Info("state verifier: started", "interval", v.interval) for { select { case <-ctx.Done(): slog.Info("state verifier: stopped") return case <-ticker.C: v.verifyOnce(ctx) } } } func (v *StateVerifier) verifyOnce(ctx context.Context) { // 1. 获取暂存台 Bitmap(Redis 逻辑状态) logicalSlots, err := v.stateMgr.GetTempSlotBitmap(ctx) if err != nil { slog.Warn("state verifier: get logical slots failed", "error", err) return } // 2. 读取 PLC 暂存台传感器(物理状态) plc := v.plcManager.GetPlc() if plc == nil { return } physicalSlots, err := plc.ReadBufferSlotStates(ctx) if err != nil { slog.Warn("state verifier: read plc slots failed", "error", err) return } // 3. 对比 if logicalSlots != physicalSlots { slog.Warn("state verifier: mismatch detected", "logical", logicalSlots, "physical", physicalSlots, ) if v.alarmSvc != nil { v.alarmSvc.Raise(ctx, "STATE_MISMATCH", "PLC 暂存台状态与 Redis 逻辑状态不一致", "WARN", 0, 0, "state_verifier", ) } } } ``` - [ ] **Step 2: 检查 PLC Manager 是否有 ReadBufferSlotStates** 如果不存在,在 `internal/plc/manager.go` 或 `internal/plc/` 中添加桩方法(返回错误),Phase 4 实现。 - [ ] **Step 3: 验证编译** Run: `rtk go build ./internal/verify` --- ### Task 8: ServiceContext 启动校验器 **Files:** - Modify: `internal/svc/service_context.go` - [ ] **Step 1: 启动 StateVerifier goroutine** 在 SSOT 启用时启动: ```go if c.SSOT.Enabled && alarmSvc != nil { verifier := verify.NewStateVerifier(stateMgr, plcManager, alarmSvc, 30*time.Second) go verifier.Run(context.Background()) slog.Info("svc: state verifier started") } ``` - [ ] **Step 2: 全量编译** Run: `rtk go build ./...` --- ## Chunk 4: 集成验证 ### Task 9: 全量编译 + 测试 - [ ] **Step 1: 全量编译** Run: `rtk go build ./...` - [ ] **Step 2: 运行新增测试** Run: `rtk go test ./internal/state/... ./internal/scheduler/... ./internal/processor/... ./internal/verify/...` - [ ] **Step 3: 运行全量测试** Run: `rtk go test ./...` --- ## 风险点 | 风险 | 缓解 | |------|------| | 工具锁 TTL 过短导致锁提前释放 | 使用 30s TTL + 执行前续租,或设置足够长的 TTL 覆盖最长工具操作 | | StepTimeoutWatcher timer 泄漏 | CancelAll 在 Job 结束时清理,添加定时巡检 | | PLC ReadBufferSlotStates 不存在 | 添加桩方法返回错误,校验器跳过本轮 | | Scheduler 接口不兼容 ToolLockConstraint | 使用 SetToolLockChecker 方法动态注入,保持向后兼容 | ## 验证标准 - `rtk go build ./...` 通过 - `rtk go test ./...` 通过(排除已知失败的 preload/camera/db/robot 包) - 工具锁:同一工具类型不可并发获取 - 工序超时:超时后自动 SuspendJob - 状态校验:PLC 不一致时触发 ALARM