# Forge — Long-Task Watch（轻量独立后台轮询 + 回调编排）设计方案

> 状态：**后端已实现(分支 `feat/watch`,commit ad374ec,未 merge)**。
> 已建:types(AsyncWatchSpec)、lib/watch/{watch-store,watch-runner,register,template}、
> tool-dispatcher 注册钩子、chat-standalone 启动 runner(进度→watch_status 事件 /
> 完成→runTurn 回灌)、app/api/watches(list/cancel/delete)。tsc 通过。
> **待做**:① UI——extension + /chat 订阅 `watch_status` 渲染状态条 chip、Settings→Monitor
> 的 watch 列表+cancel/delete;② 给 nac.upgrade 等连接器加 `async:` 块(消费者);
> ③ 实测 + merge。
> 一句话定性(zliu)：watch = **在 chat 里支持的一个轻量化异步回调机制**。
> 消费者:TP 升级、**NAC 直连升级(nac.upgrade → 轮询 nac.get_version 直到
> build 匹配)**、pytest 跑——凡是"发起后要等、完成再回 chat"的都用它。
> 起因：TP `upgrade_lab`(NAC 升级)同步阻塞 5-10 分钟。连接器已用
> `detach` 让它不卡死,但"发起后盯到完成"这步若靠 AI 在对话里轮询,
> **不可靠**。需要一个**轻量、独立、纯后台**的机制:发起后自动定期 poll,
> 完成时**回调一个工具**(而非通知用户),全程不依赖 AI / 不依赖对话存活。

## 0. 目标 & 非目标(按 zliu 最新意见收敛)

**目标**
- 发起即返回,不 hold 对话/标签页。
- **轻量、独立**:自带一个小 ticker + 一张表,**不挂靠 Schedules**(那是用户
  预定任务,语义不同),也不新增 standalone 进程。
- 完成/失败 → **回灌到发起的那个 chat 会话**:把 poll 结果作为一条
  tool-result/系统事件喂回该 session,**让助手吐一条消息**(并可顺势继续判断/
  调用下一步工具)。这天然复用 chat:若该会话来自 telegram,消息就自然回到
  telegram;来自 /chat 就在 /chat 冒出来——**不需要单独的 telegram/email 通道**。
- (可选)纯编排:也支持声明式 `on_done` 直接调一个工具做链式(升级完→自动
  run_pytest),不吐消息。两种模式按 manifest 选。
- **每次 poll 可给轻量进度反馈,但不抢主对话**(zliu):进度走**独立的
  状态通道**(`watch_status` push),在 chat 里渲染成一个**会就地更新的小状态条/
  chip**——每 tick 替换、不追加,**不进消息历史、不触发 LLM**,完成即消失。
  只有最终 `on_done`/`on_fail` 的终态消息才真正落进对话线程。即:
  **过程 = 环境状态(ambient),结果 = 一条真消息**。
- **防死循环是一等需求**:后台自动调度最怕失控,必须有硬上限。
- **极简管理面**:不做常规 UI,但要**一个能看 active watch 列表 + 取消/删除**
  的地方,避免 watch 堆积、占资源、跑飞。

**非目标**
- 不做"独立于 chat 之外"的新通知通道——完成消息走发起会话本身的 chat 流。
- 不做复杂表达式引擎(done 判定走安全的点路径)。
- 不进 Schedules UI、不算一种 Schedule 类型。

## 1. 形态:独立 `watch` 模块

```
AI(在 session S)─call─> upgrade_lab ─(detach 25s, 返回 fired_at)─┐
                                                                  ├─ dispatcher 见 async: registerWatch(..., session_id=S)
            <── 立即返回 {dispatched, watch_id} ──────────────────┘
                         …………(对话可结束)…………
watch ticker(独立, ~30-60s)
  └ 对每条 active watch,到点:
      poll_tool ── done_path? ──┬─ 是 → 完成动作 → 终态 done
                                 ├─ fail_path? → 失败动作 → 终态 failed
                                 └─ 否 → polls++;超 max_polls / 过 timeout → 终态 timed_out(走失败动作)

完成/失败动作(二选一,按 manifest):
  • mode=chat(默认):把结果作为 tool-result 回灌 session S → resume 该会话 →
    助手吐消息(走 chat-standalone 现有的 resume + bridgePush 流;telegram 会话则到 telegram)。
  • mode=tool:派发声明的 on_done.tool(链式编排,不吐消息)。
```

- ticker 是一个 `setInterval`(随 **chat-standalone** 起,单实例守卫),**不是新
  进程**,也**不复用 Schedules tick**——逻辑独立、好推理、好限流。放 chat-standalone
  里还顺手:回灌 session 用的就是它自己的 resume 能力。
- registerWatch 记下 `session_id`(发起工具的那个 chat 会话),完成时回灌。
- watch 是**短生命周期**:到达终态(done/failed/timed_out/cancelled)即停轮询。

## 2. Manifest 声明(连接器侧,新增 `async` 块)

```yaml
upgrade_lab:
  destructive: true
  async:
    poll: check_lab_upgrade_status        # 同连接器内的查询工具
    poll_args:                            # 用触发工具的 result/args 拼 poll 入参
      deployinfo: "{args.deployinfo}"
      lab: "{args.lab}"
      since: "{result.fired_at}"          # {result.*}=触发工具返回值;{args.*}=触发入参
    # 完成判定:二选一。
    done_path: done                       # (A) poll 结果该路径 truthy = 完成(TP:连接器自己比好返回 done:true)
    # done_match:                         # (B) 值比较(NAC:get_version 返回 build 号,跟目标比)
    #   path: captured.build              #     poll 结果里的路径
    #   equals: "{args.target_build}"     #     equals / contains,期望值从触发 args/result 模板取
    fail_path: any_failure                # 可选:truthy = 失败
    interval_sec: 60                      # 轮询间隔(下限 30s)
    timeout_sec: 1200                     # 总时长上限(到点判 timed_out)
    max_polls: 40                         # 轮询次数硬上限(防失控)
    on_done:                              # 完成动作
      mode: chat                          # chat(默认,回灌发起会话让助手吐消息) | tool(链式) | none(仅落库)
      message: "升级完成,核对结果。"      # mode=chat 时注入会话的提示(可引用 {poll.*})
      tool: ""                            # mode=tool 时要调的工具名
      args: {}                            # mode=tool 入参,可引用 {poll.*}/{args.*}
    on_fail:                              # 可选,同形(默认 mode=chat,把失败/超时也吐回会话)
      mode: chat
      message: "升级未在预期内完成,请手动核对。"
    progress:                             # 每次 poll 的轻量反馈(不抢主对话)
      show: true                          # 默认 true;false = 后台静默,只在终态出消息
      message: "盯升级中… 第 {poll_count}/{max_polls} 次,build={poll.build}"  # 可引用 {poll.*}/{poll_count}
```

**两条输出通道(关键:进度不抢主对话)**
- **进度通道**:每 tick 通过独立 push topic `watch_status` 推一条
  `{watch_id, state, poll_count, text}`。chat 端渲染成一个**就地更新的小状态条/
  chip**(按 watch_id 去重替换,不追加),**不写入消息历史、不触发 LLM、不计入
  上下文**;watch 一到终态该状态条自动消失/收起。这样轮询再多也不污染对话。
- **结果通道**:只有 `on_done`/`on_fail`(mode=chat)产出**一条真消息**进对话
  线程。`progress.show:false` 则全程静默,只留终态。
- 实现:进度走 `bridgePush('watch_status', …)`(extension/web 各加一个轻量
  status-bar 组件订阅);结果走已有的 session resume 回灌(§1)。
- **终态清 chip(必须显式发,别靠超时)**:watch 到终态时,`on_done`/`on_fail`
  只走结果通道(发完成消息),**不会再发常规进度**——若不补一条信号,chip 会
  一直挂到 prune 超时(150s)才消失,出现"结果已给、进度条还在转"的割裂。
  所以 `finish()` 额外补发一条**终态 `watch_status`**:`{watch_id, state, done:true, text}`。
  前端收到 `done:true`(或 `state≠active`)就**立刻删除该 chip**;否则才更新。
  即:进度通道既负责"更新",也负责"收尾删除"。(prune 150s 仅作兜底,正常
  路径靠这条终态信号即时清除。)

- **完成回灌 chat**:默认 `mode=chat`——把最后一次 poll 结果 + `message` 作为
  tool-result 喂回发起的 session,助手据此吐一条消息(还能继续调用下一步)。
  `mode=tool` 则纯链式派发工具、不吐消息;`mode=none` 只落库置终态(AI 下次
  需要时可查)。tool 模式走和 chat 同一条 dispatcher,可链式。
- **done 判定不用 eval**:`done_path`/`fail_path` 点路径取真值;`done_match`
  做 equals/contains 比较(期望值模板取),都不跑 eval、零注入面。

NAC 直连升级的 async 块(第二个消费者,用 done_match):
```yaml
upgrade:            # nac connector
  async:
    poll: get_version
    poll_args: {}                 # get_version 无参,连 settings 里的 host 即可
    done_match: { path: build, equals: "{args.target_build}" }  # build6957 → "6957"
    interval_sec: 60
    timeout_sec: 900              # 含 reboot,放宽
    on_done: { mode: chat, message: "NAC {settings.host} 已升级到 build {poll.build}。" }
```
注:`nac.upgrade` 目前参数没有 target_build——接 watch 时给它加一个(或从
镜像文件名 `build(\d+)` 自动解析),传给 watch 做比较。

## 3. 防死循环(纯后台调度的核心约束)

| 失控源 | 护栏 |
|---|---|
| 轮询永不停 | `max_polls`(默认 40)+ `timeout_sec`(默认 1200s),先到先终止 |
| 同一 watch 并发重入 | **单飞**:每条 watch 有 `running` 锁,上一次 poll 没回来不发下一次 |
| 回调再生回调(A→B→A) | 回调链 `chain_depth` 上限(默认 3)+ 已访问 watch 链路环检测 |
| 回调本身又是 async 工具 | 允许,但继承并递减 `chain_depth`;到 0 则只跑、不再登记新 watch |
| watch 越堆越多 | 全局 active watch 上限(默认 50);超了拒绝新登记并在触发工具结果里报错 |
| poll 报错刷屏 | 连续 N 次错误(默认 5)→ 终态 `errored`,不再重试 |
| 僵尸 watch | 任何 watch 硬性 `max_lifetime`(默认 2×timeout)兜底清理 |

所有上限都有**默认值**且 manifest 可调小,不可调到无界。

## 4. 重启不丢

- watch **持久化在 SQLite**(`connector_watches`),不是内存。
- Forge 重启/崩溃 → ticker 起来后扫 `state=active` 的 watch,凭存下的
  `fired_at`/`next_poll_at`/`polls` 续轮询,**不靠 AI、不靠原对话**。
- 宕机超过 `max_lifetime` 的 watch → 标 `timed_out`、跑 `on_fail`(若有),
  绝不静默丢。

## 5. 极简管理面(防资源占用 / 可取消)

不做常规 UI,但提供**最小可观测+可控**:

- **API**:`GET /api/watches`(列 active+近期终态)、`POST /api/watches/:id/cancel`、
  `DELETE /api/watches/:id`。
- **UI 落点**(二选一,倾向 A):
  - **A. Settings → Monitor** 加一个 "Background Watches" 折叠区:每行显示
    connector·poll_tool·polls/max·下次轮询·状态,带 Cancel/Delete。和已有
    进程监控同处,零新页面。
  - **B.** /chat 侧栏一个折叠小列表。
- cancel = 立即置 `cancelled` 终态、停轮询;delete = 删行。

## 6. 改动清单

| 在哪 | 改什么 | 量 |
|---|---|---|
| 类型 | `lib/connectors/types.ts`:`ConnectorTool.async?: AsyncWatchSpec`(poll/poll_args/done_path/done_match/fail_path/interval/timeout/max_polls/on_done/on_fail/**progress**) | 小 |
| 存储 | `lib/watch/watch-store.ts`(新):SQLite 表 + CRUD + 单实例守卫 | ~100 行 |
| 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker，单飞、护栏、终态机、调 poll/回调、**每 tick + 终态各发一条 `watch_status`**(终态带 `done:true` 让前端立即清 chip) | ~170 行 |
| 派发 | `lib/chat/tool-dispatcher.ts`:跑完带 `async` 的工具 → `registerWatch`,返回附 `watch_id` | 小 |
| API | `app/api/watches/route.ts` + `[id]`:list / cancel / delete | 小 |
| UI(状态条) | extension + /chat 各加一个轻量组件,订阅 `watch_status` push → 渲染就地更新的 chip(不进消息流) | 小 |
| UI(管理面) | Settings Monitor 加 Background Watches 折叠区(只读+cancel/delete) | 小 |
| 连接器 | `upgrade_lab`/`upgrade_device` 加 `async:` 块 | 几行 |

**主体在 Forge**(类型+store+runner+API+UI);连接器只加声明块。**不复用
Schedules、不新增进程**——独立轻量,正合你要的形态。

## 7. TP 落地体验(后台轮询 + 完成回灌 chat)

```
用户:升级 AT16_Combined_FSW 到 build6957
AI  :upgrade_lab(command=…) → {dispatched, watch_id, fired_at}
AI  :「已发起,后台盯到完成会在这里告诉你。」← 对话可结束/去忙别的
（后台 ticker 每 60s poll check_lab_upgrade_status(since=fired_at)，无 AI 参与）
done:true → 回灌 session S → 助手在原会话吐:
  「✅ AT16_Combined_FSW 升级完成,FortiNAC(10.15.52.152)已到 build6957。」
（若该会话是 telegram,这条就到 telegram;是 /chat 就在 /chat 冒出来。
  若配了 on_done.mode=tool,则改为自动跑 run_pytest,不吐消息。）
```

## 8. 工作量

- Forge:store + runner + dispatcher 钩子 + API + Monitor 区 ≈ **1~1.5 天**
  (护栏/单飞/重启续跑要写稳)。
- 连接器:几行。

## 9. 待你决策

- [x] UI:不做常规 UI,但有**极简管理面**(列表+cancel/delete),落点倾向
      Settings→Monitor 的折叠区。
- [x] 重启不丢:SQLite 持久化,续跑;超期回灌/回调,不静默丢。
- [x] 完成动作:默认 **mode=chat 回灌发起会话让助手吐消息**(telegram 会话→telegram,
      /chat→/chat);可选 mode=tool 纯链式、mode=none 仅落库。**不**另起独立通知通道。
- [x] 进度反馈:每 tick 走独立 `watch_status` push,渲染成**就地更新的状态条/chip,
      不进消息流、不触发 LLM**;只有终态出一条真消息。`progress.show` 可关。
- [ ] **是否实现?**
- [ ] 管理面落点 A(Settings→Monitor)还是 B(/chat 侧栏)? 右上角菜单下有个 watch 按钮
- [ ] 回调链深度 / active 上限 / max_polls 默认值是否认可(3 / 50 / 40)?
- [ ] mode=chat 回灌时,助手是"只吐一条总结消息"还是"可继续自主调用工具"(后者更强但要纳入防循环上限)? 
  - 只回总结吧，终止，然后由 chat决定吧。不用做嵌套，因为本身就是异步跟踪了，就可以一直获取。如果真的需要嵌套就设置一个嵌套数量吧，
