Skip to content

Hermes Instance Lifecycle Rebuild

日期: 2026-05-16 目标: 用状态机驱动 Instance 生命周期,消除 docker restart 死循环,QR 与容器解耦,容器 immutable。


背景

旧架构:支付 → spawn 空容器 → 等扫码 → 写文件 → docker restart → iLink 限流 → --restart unless-stopped 立即拉起 → 二次限流 → 死循环。

根本错误:

  1. 容器生命周期被扫码流程绑架
  2. docker restart 被当作业务重试机制
  3. 没有状态机,只有布尔 gateway_running
  4. 容器 mutable — 运行后改 env 再 restart

目标架构

Frontend  →  NestJS API  →  Instance State Machine  →  Provision Worker  →  spawn-profile.sh  →  Docker

                                              poll ProvisionTask(DB-backed queue)

状态机:

CREATED → PENDING_BIND → BIND_CONFIRMED → PROVISIONING → READY → STOPPED → DESTROYED

                                              FAILED

核心原则:

  • 容器 immutable:env 一次注入,从不 docker restart
  • QR 不依赖容器:iLink QR API 由 NestJS 直接调
  • 重置 = destroy + recreate:换号、绑错、重绑都走 stop-profile.sh --destroy + spawn-profile.sh
  • provision 异步:confirmed 不阻塞 HTTP,enqueue 后 worker 跑

阶段划分

Phase 1 — 状态机 + Schema 改造(破坏性 migration)

backend/prisma/schema.prisma

prisma
enum InstanceStatus {
  CREATED          // 新建,未做任何事
  PENDING_BIND     // 等用户扫码
  BIND_CONFIRMED   // 扫码完成,待 spawn
  PROVISIONING     // spawn-profile.sh 跑中
  READY            // 容器健康,可用
  STOPPED          // 主动停止(订阅到期/退款)
  FAILED           // spawn 失败,需人工或 retry
  DESTROYED        // 容器删除 + profile dir 清理
}

model HermesInstance {
  // 现有字段保留
  // 新增:
  weixinChannel String?   // ilink_bot_id,扫码后写入
  weixinToken   String?   // ilink bot_token
  profileName   String?   // = instanceId(spawn-profile 用)
  lastError     String?   // 最近一次失败信息
  spawnedAt     DateTime?
  destroyedAt   DateTime?
}

Migration: prisma migrate dev --name instance_state_machine

⚠ 老数据:所有现存 PROVISIONING/READY 记录手动设为 DESTROYED(ECS 上 stop-profile.sh --destroy 清干净),让新流程从 0 开始。


Phase 2 — provisionPaidOrder 拆分(不 spawn 容器)

backend/src/modules/provisioning/provisioning.service.ts

typescript
async provisionPaidOrder(orderId: string) {
  // 1. 创建 Subscription + Instance(status=PENDING_BIND)
  // 2. 发 NewAPI LLM key → 写入 instance.llmApiKey
  // 3. 返回,不调用 spawnHermesProfile
}

// 新方法:扫码完成后调
async spawnAfterBind(instanceId: string) {
  // 1. instance.status = PROVISIONING
  // 2. 创建 ProvisionTask(orderId, status=PENDING)  ← 入队
  // 3. 返回 — worker 异步处理
}

删除:provisionPaidOrder 内的 spawnHermesProfile 调用块(line 165-194)。


Phase 3 — Provision Worker(DB 队列,零新依赖)

新文件 backend/src/modules/provisioning/provision-worker.service.ts

typescript
@Injectable()
export class ProvisionWorkerService implements OnModuleInit {
  // 每 5s 扫 ProvisionTask(status=PENDING),取一条跑
  // 跑完成功 → status=SUCCEEDED + Instance.status=READY
  // 跑失败 → status=FAILED + Instance.status=FAILED + lastError
  // 不无限重试:attempts >= 3 锁死,需人工
}

跑的内容:

  1. 读 Instance → 拿 weixinChannelllmApiKeyprofileName
  2. spawn-profile.sh <profileName> container LLM_API_KEY=xxx WEIXIN_HOME_CHANNEL=xxx WEIXIN_TOKEN=xxx
  3. 解析输出 → 写回 externalRefhostPortbaseUrlspawnedAt
  4. healthcheck(curl http://127.0.0.1:<hostPort>/api/status)→ 等 gateway_state=running
  5. Instance.status = READY

注册到 provisioning.module.ts


Phase 4 — service-runtime 重构

backend/src/modules/service-runtime/service-runtime.service.ts

typescript
// 删除:saveWeixinChannel, restartContainer
// 改 pollWeixinStatus:
async pollWeixinStatus(qrcode: string, instanceId: string) {
  const { data } = await axios.get(...);
  if (data.status === 'confirmed') {
    const channelId = String(data.ilink_bot_id ?? '');
    const token = String(data.bot_token ?? '');
    await this.prisma.hermesInstance.update({
      where: { id: instanceId },
      data: {
        weixinChannel: channelId,
        weixinToken: token,
        status: 'BIND_CONFIRMED',
      },
    });
    await this.provisioning.spawnAfterBind(instanceId);  // 入队
    return { status: 'confirmed', channelId };
  }
  return { status: data.status };
}

service-runtime.controller.ts:33

typescript
// wechat-qr 允许 PENDING_BIND
if (!['PENDING_BIND', 'BIND_CONFIRMED'].includes(sub.instance?.status ?? '')) {
  return { error: 'instance_not_pending_bind' };
}

新增端点:

typescript
@Get('/services/instance/:subscriptionId/provision-status')
// 前端轮询 — 返回 instance.status + lastError

Phase 5 — spawn-profile.sh 接收 WeChat 参数

scripts/admin/spawn-profile.sh

bash
# 新参数(KEY=VALUE 形式已支持)
#   WEIXIN_HOME_CHANNEL=<ilink_bot_id>
#   WEIXIN_TOKEN=<bot_token>

# 写 .env 时注入(line 122-142 替换):
cat > "$PROFILE_DIR/.env" <<EOF
...
WEIXIN_HOME_CHANNEL=${WEIXIN_HOME_CHANNEL:-}
WEIXIN_TOKEN=${WEIXIN_TOKEN:-}
GATEWAY_ALLOW_ALL_USERS=true
SEARXNG_URL=${SEARXNG_URL}
...
EOF

# Docker run 改 restart policy(line 205):
--restart on-failure:3

不再需要 ~/.hermes/.env 双写。一次 docker run,永不 restart。


Phase 6 — 重置/换号路径(destroy + recreate)

新端点 POST /services/instance/:subscriptionId/rebind

typescript
// 1. stop-profile.sh <profileName> --destroy
// 2. Instance: status=DESTROYED → 重置为 PENDING_BIND,清 weixinChannel/weixinToken/externalRef/hostPort
// 3. 前端重新拉 QR

stop-profile.sh 已经存在,直接 exec。


Phase 7 — 前端 StatusPage 状态机驱动

frontend/src/pages/StatusPage.tsx

新增 phase:

typescript
type WeixinState =
  | { phase: 'idle' }
  | { phase: 'qr'; qrcode: string; scanUrl: string }
  | { phase: 'confirmed' }
  | { phase: 'provisioning' }   // 新:等 spawn worker
  | { phase: 'ready' }           // 新:容器健康
  | { phase: 'failed'; error: string }  // 新:spawn 失败
  | { phase: 'error'; message: string };

confirmed 后切到 provisioning,轮询 /provision-status,看到 READYready。失败显示 lastError + 提供 "重试绑定" 按钮(调 /rebind)。


老数据迁移

ECS 上一次性脚本:

bash
# 1. 停所有当前 Hermes 容器
docker ps -a --filter "name=hermes-" --format '{{.Names}}' | xargs -r docker stop
docker ps -a --filter "name=hermes-" --format '{{.Names}}' | xargs -r docker rm -f

# 2. 清 profile 目录
rm -rf /home/twilight/hermes-profiles/*

# 3. DB 更新所有 Instance.status = DESTROYED
# 通过 prisma migrate + 一条 SQL 脚本

老用户重新进入 StatusPage → 点 "重新绑定" → 走新流程。


Tier 差异化(199 标准 vs 999 专业)

决策:取消 SHARED 概念,两 tier 都跑独立容器。差异落在配额 + 资源 + 优先级,做共享容器池。

理由:

  • 现状 provisioning.service.ts:166 已经写死 tier='container',SHARED 是假的
  • iLink 物理限制:1 bot_id = 1 微信账号,共享容器不可能多用户绑微信
  • 简化架构,省掉一条分叉

差异表:

维度标准版 ¥199专业版 ¥999
容器独立独立
CPU0.251.0
Memory256m1g
NewAPI 月配额2M tokens无上限
Hermes max_turns60120
Provision Worker 优先级低(FIFO)高(队列优先)
WeChat 支持
WebSearch

落地点:

  1. Phase 1 schema

    • Plan.instanceKind 字段保留(前端展示用),provisioning 不再读
    • 老数据:UPDATE HermesInstance SET kind='DEDICATED' WHERE kind='SHARED'
    • InstanceKind 枚举可保留 SHARED 值,但永不创建新行
  2. Phase 2 provisioning

    • 删除 provisioning.service.ts:90-106findFirst SHARED 复用块
    • 永远走 tx.hermesInstance.create({ kind: 'DEDICATED', ... })
  3. Phase 3 worker — 优先级队列

    typescript
    // 拉任务时按 plan.priceCnyFen 倒序
    const next = await prisma.provisionTask.findFirst({
      where: { status: 'PENDING' },
      orderBy: [
        { order: { plan: { priceCnyFen: 'desc' } } },  // 高价先跑
        { createdAt: 'asc' },                          // 同价 FIFO
      ],
    });
  4. Phase 5 spawn-profile.sh — 接收 tier 参数

    bash
    # 新参数
    #   TIER_CPUS=<float>     默认 0.25
    #   TIER_MEMORY=<size>    默认 256m
    #   MAX_TURNS=<int>       默认 60
    TIER_CPUS="${TIER_CPUS:-0.25}"
    TIER_MEMORY="${TIER_MEMORY:-256m}"
    MAX_TURNS="${MAX_TURNS:-60}"
    
    # docker run 用:--cpus $TIER_CPUS --memory $TIER_MEMORY
    # config.yaml 改 sed: max_turns: ${MAX_TURNS}

    config.yaml.template{{MAX_TURNS}} 占位符,spawn 时 sed 替换。

  5. Phase 3 worker — 透传 tier

    typescript
    const plan = order.plan;
    const isProf = plan.priceCnyFen >= 99900;
    const args = [
      ...,
      `TIER_CPUS=${isProf ? '1.0' : '0.25'}`,
      `TIER_MEMORY=${isProf ? '1g' : '256m'}`,
      `MAX_TURNS=${isProf ? '120' : '60'}`,
    ];
  6. NewAPI 配额

    • 已有:provisioning.service.ts:153newApi.createUserToken(name, monthlyQuotaTokens)
    • 标准版 monthlyQuotaTokens=2_000_000 → NewAPI 自动限流
    • 专业版 monthlyQuotaTokens=null → NewAPI 无限
    • 无需改动,已就位

不做的事

  • ❌ 不引入 Redis / BullMQ —— 用 ProvisionTask 表当队列足够
  • ❌ 不做 multi-profile 共享容器 —— 风控 + 隔离风险太高
  • ❌ 不改 NewAPI key 流程 —— 已稳定,保留
  • ❌ 不动 SearXNG 集成 —— 已经在 spawn-profile.sh 中通过 .env 注入
  • ❌ 不做真共享池 —— Tier 差异化纯靠资源/配额/优先级,无架构分叉

验收清单

  • [ ] 新用户支付 → 5s 内能拿到 QR(无需等容器启动)
  • [ ] 扫码确认 → 30s 内 Instance 进入 READY
  • [ ] 全程 docker restart 调用
  • [ ] iLink 限流不再出现(连续扫码 3 次不触发)
  • [ ] 微信内发消息 → Hermes 回复(含 WebSearch 通过 SearXNG)
  • [ ] 重绑场景:rebind → 新 QR → 新容器,旧容器干净销毁
  • [ ] spawn 失败 → Instance.status=FAILED,前端显示 lastError,可重试
  • [ ] 标准版容器 docker inspect 显示 CpuPeriod/CpuQuota 对应 0.25 CPU;专业版 1.0 CPU
  • [ ] 专业版用户的 ProvisionTask 排队时优先于同时入队的标准版
  • [ ] 标准版用户连续调用 NewAPI 超过 2M tokens 后返回 429(配额限流)

落地顺序

  1. Phase 1 schema + migration(开发环境先验)
  2. Phase 5 spawn-profile.sh 改造(独立可测)
  3. Phase 2 + 3 provisioning 拆分 + worker
  4. Phase 4 service-runtime 重构
  5. Phase 6 rebind 端点
  6. Phase 7 前端
  7. ECS 老数据清理 + 上线

每 phase 独立 PR,CI 通过再 merge。


风险

  • iLink QR 拉取频率:前端轮询 3s 一次,扫码超时若用户长时间不扫,iLink 可能限流 QR 接口。需测临界值。
  • spawn-profile.sh 跑时间:当前 ~10-30s(docker pull + container start + healthcheck)。worker 间隔 5s 扫,最差延迟 5s,可接受。
  • Worker 单点:单容器跑 NestJS 时 worker 单实例。多 NestJS 副本时需 advisory lock(pg_advisory_lock)。当前架构单副本,暂不处理。

团队内部文档