Files
Blizzard 0378a770ca fix(hitl): resume 记录 KV 键不可含冒号(NATS: invalid key)
live NATS 联调发现:resume 记录键用 "pending:"+taskID,冒号是 NATS JetStream KV
非法字符(仅允许 [-/_=.a-zA-Z0-9])→ 中断时 persistResume 的 Put 静默失败、决定
到达时 loadResume 报 "nats: invalid key",任务永卡 waiting、无法恢复。内存桩接受
任意键,故单测漏过——正是只有 live NATS 才暴露的那类。

- pendingKey: "pending:"+id → "pending_"+id(合法键)。
- persistResume: Put 失败改 log.Printf 大声告警(不止 exec 轨迹),关键失败可见。
- 回归测试 TestPendingKeyIsNATSValid:直接钉键形匹配 NATS KV 字符集,绕开内存桩盲区。

live 验证(devnats + 真链路):提交 HITL 任务→waiting→杀 dispatcher→离线批准→
重启→1s waiting→2s running→3s done,deepseek 真实出稿。证明 checkpoint 抗重启 +
决定经 JetStream 抗离线 + 断点恢复。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:25:47 +08:00

81 lines
2.3 KiB
Go
Raw Permalink 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.
package eino
import (
"context"
"regexp"
"sync"
"testing"
"github.com/sundynix/sundynix-shared/bus"
)
// natsKVKey 是 NATS JetStream KV 允许的键字符集(首字符不可为 . 或 _)。
// resume 记录键曾用冒号分隔 → 真 NATS 报 "invalid key",内存桩测不到,故在此直接钉键形。
var natsKVKey = regexp.MustCompile(`^[a-zA-Z0-9][-/_=.a-zA-Z0-9]*$`)
func TestPendingKeyIsNATSValid(t *testing.T) {
for _, id := range []string{"task_4c47de5ce1a29e16", "abc123", "t_0"} {
if k := pendingKey(id); !natsKVKey.MatchString(k) {
t.Fatalf("pendingKey(%q)=%q 不是合法 NATS KV 键(冒号等字符会被拒)", id, k)
}
}
}
// memKV 是 CheckpointKV 的内存桩(并发安全),用于不依赖 NATS 的单测。
type memKV struct {
mu sync.Mutex
m map[string][]byte
}
func newMemKV() *memKV { return &memKV{m: map[string][]byte{}} }
func (k *memKV) Get(_ context.Context, key string) ([]byte, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
v, ok := k.m[key]
return v, ok, nil
}
func (k *memKV) Put(_ context.Context, key string, val []byte) error {
k.mu.Lock()
defer k.mu.Unlock()
k.m[key] = append([]byte(nil), val...)
return nil
}
func (k *memKV) Delete(_ context.Context, key string) error {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.m, key)
return nil
}
// bus.KVHandleJetStream KV 后端)须结构化满足 CheckpointKV——钉死 bus 与 eino 的隐式契约,
// 任一侧改了 Get/Put/Delete 签名都会在这里编译失败。
var _ CheckpointKV = (*bus.KVHandle)(nil)
// TestCheckpointStoreRoundtrip 验证 compose.CheckPointStore 适配:Set→Get 命中、Delete 后 Get 落空。
func TestCheckpointStoreRoundtrip(t *testing.T) {
s := newCheckpointStore(newMemKV())
ctx := context.Background()
const id = "task_42"
if _, ok, err := s.Get(ctx, id); err != nil || ok {
t.Fatalf("空 store 应未命中: ok=%v err=%v", ok, err)
}
cp := []byte("compose-serialized-state")
if err := s.Set(ctx, id, cp); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := s.Get(ctx, id)
if err != nil || !ok || string(got) != string(cp) {
t.Fatalf("Get 应回放原状态: got=%q ok=%v err=%v", got, ok, err)
}
if err := s.Delete(ctx, id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok, _ := s.Get(ctx, id); ok {
t.Fatalf("Delete 后不应再命中")
}
}