From 570ecbd1b038cf3fe0a0734a9c7de60d5f4fd7f2 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 10 Sep 2026 15:01:32 +0800 Subject: [PATCH] feat(wechat): add WX gate evidence and v1.49 103 cutover runbook Prioritize scheduled-task intent over page.generate for automation phrases, add run-wechat-gate-evidence.mjs for WX-01..13, and document 103 maintenance-window steps for Goose v1.49. Co-authored-by: Cursor --- docs/baselines/README.md | 2 + docs/goose-v149-103-cutover-runbook.md | 428 ++++++++++++++++++ docs/goose-v149-phase3-closeout.md | 4 + scripts/check-goosed-v149-wechat-evidence.mjs | 18 +- scripts/install-local-goose-v149.sh | 108 +++++ scripts/run-wechat-gate-evidence.mjs | 158 +++++++ scripts/run-wechat-scenario-test.mjs | 143 ++++-- scripts/uninstall-local-goosed-pool.sh | 64 +++ wechat/handlers/scheduled-task.mjs | 11 +- wechat/handlers/scheduled-task.test.mjs | 17 + wechat/intent/classifier.mjs | 5 + 11 files changed, 905 insertions(+), 53 deletions(-) create mode 100644 docs/goose-v149-103-cutover-runbook.md create mode 100755 scripts/install-local-goose-v149.sh create mode 100644 scripts/run-wechat-gate-evidence.mjs create mode 100755 scripts/uninstall-local-goosed-pool.sh diff --git a/docs/baselines/README.md b/docs/baselines/README.md index faa0787..64f48ba 100644 --- a/docs/baselines/README.md +++ b/docs/baselines/README.md @@ -13,4 +13,6 @@ Phase 3 收口:[goose-v149-phase3-closeout.md](../goose-v149-phase3-closeout.md) · 聚合:`node scripts/run-goosed-v149-phase3.mjs` +103 维护窗只读/事后基线(不进 Git):`test/_103_baselines/pre-v149-*` / `post-v149-*`,见 [goose-v149-103-cutover-runbook.md](../goose-v149-103-cutover-runbook.md) §2.2、§10。 + **Git 策略**:只跟踪语义基线(`*-latest.json`、`*-current.json`、`runtime-baseline-*.json`)。每次 smoke 生成的时间戳 `*-evidence-*.json` 已写入根目录 `.gitignore`,勿提交。 diff --git a/docs/goose-v149-103-cutover-runbook.md b/docs/goose-v149-103-cutover-runbook.md new file mode 100644 index 0000000..3dcc1a7 --- /dev/null +++ b/docs/goose-v149-103-cutover-runbook.md @@ -0,0 +1,428 @@ +# Goose v1.49 · 103 生产切换 Runbook + +> **状态:** 草案(本地 v1.49 证据已齐;**未授权**在 103/105 执行任何操作) +> +> **适用范围:** 103(`58.38.22.103` / Mac Studio arm64)将 goosed 运行时从 **1.41 native** 升级到 **v1.49 TKMind 补丁树** +> +> **不包含:** Portal runtime 整包发版(见 [portal-release skill](../.claude/skills/portal-release/SKILL.md))、105 nginx 灰度(见 [release-canary-103.md](./release-canary-103.md))、Memory owner 切换(见 [TKMIND_V1_49_MIGRATION.md](../TKMIND_V1_49_MIGRATION.md)) + +--- + +## 1. 目标与边界 + +### 1.1 要达成什么 + +| 目标 | 说明 | +|------|------| +| 9 实例 goosed 升级到 v1.49 | 仍监听 **`18006`–`18014`**,与现网 `TKMIND_API_TARGETS` / `h5_user_sessions.goosed_target` 兼容 | +| 保留 TKMind 定制 | `tkmind_compat` REST、PG Session 共享、sandbox-fs MCP、harness、计费 relay、微信/页面 fail-closed | +| 用户数据零丢失 | RDS、PG `memind_sessions`、MindSpace 磁盘、Redis 路由键不删库 | +| 可回滚 | 15 分钟内回到 1.41 native 或 Docker standby | + +### 1.2 明确不做(除非单独批准) + +- 修改 RDS schema / 跑未审批 migration +- 切换 Memory 写入 owner 到 upstream v1.49 +- 拆除 `:18036` DeepSeek no-think 代理 +- 在本 runbook 内顺带发 Portal runtime(可同维护窗,但须独立 Gate + 脚本) +- 从本机 Mac 直接 `rsync` 二进制到 103(须在 **103 上** clone + vendor + 编译,或交付已审计的 release tar) + +### 1.3 与 native 1.41 迁移的关系 + +2026-07-30 已完成 [goosed-native-103-migration-plan.md](./goosed-native-103-migration-plan.md)(Docker → native **1.41**)。 + +本 runbook 是 **第二次运行时升级**:native 1.41 → native **v1.49**。目录可沿用 `/Users/john/Project/tkmind_go-native` 的 launchd 模式,但二进制与启动命令改为 `goose serve`(见 [goose-v149-local-phase0-manifest.md](./goose-v149-local-phase0-manifest.md))。 + +--- + +## 2. 准入闸门(全部满足才申请维护窗) + +### 2.1 Memind 仓库 + +| # | 项 | 命令 / 标准 | +|---|-----|-------------| +| 1 | v1.49 补丁已 review | `tkmind_go-v149` · 分支 `upgrade/v1.49-tkmind` · commit 记录在 Phase 3 清单 | +| 2 | Memind 侧脚本/文档已进 `main` | `check-release-ready.sh`、canary 脚本、WX gate 脚本 | +| 3 | Phase 3 本地证据 | `node scripts/run-goosed-v149-phase3-offline.mjs` → `GOOSE_V149_PHASE3_OFFLINE_OK` | +| 4 | 完整 LLM smoke(可选但推荐) | `GOOSE_V149_ALLOW_REAL_LLM=1` + `run-goosed-v149-phase3.mjs` → `GOOSE_V149_PHASE3_OK` | +| 5 | Memory manifest 无 drift | `node scripts/compare-goose-v149-memory-manifest.mjs` | +| 6 | WX 单测 + 本地 webhook | `node scripts/run-wechat-gate-evidence.mjs`;`run-wechat-scenario-test.mjs` poem-page | +| 7 | **用户明确批准** | 合并 `main`、103 维护窗、本 runbook 执行 | + +### 2.2 103 只读基线(维护前 24h 内采集) + +在 **103 上只读**执行,保存到本机 `test/_103_baselines/pre-v149-YYYYMMDD/`: + +```bash +ssh john@58.38.22.103 ' + date -u +%Y-%m-%dT%H:%M:%SZ + cat /Users/john/Project/Memind/.release-manifest.txt + grep -E "^(TKMIND_API_TARGETS|GOOSED_RUNTIME|GOOSED_MCP_|GOOSE_SESSION|MEMIND_DEEPSEEK)" /Users/john/Project/Memind/.env | sed "s/=.*/=***/" + /Users/john/Project/tkmind_go-native/goosed --version 2>/dev/null || ls /Users/john/Project/tkmind_go-native/releases/ + for p in $(seq 18006 18014); do + printf "goosed_%s=" "$p" + curl -kfsS --connect-timeout 3 "https://127.0.0.1:${p}/status" || echo FAIL + done + curl -fsS http://127.0.0.1:8081/api/status + curl -fsS http://127.0.0.1:8082/mindspace/v1/contract | head -c 400 +' +``` + +记录:`release_id`、`GOOSED_TAG`(Docker standby)、九个 launchd label 状态、Portal `git_head`。 + +--- + +## 3. 103 资产清单(不能遗漏) + +### 3.1 目录与仓库 + +| 路径 | 用途 | v1.49 动作 | +|------|------|------------| +| `/Users/john/Project/Memind` | Portal live + MCP 脚本 | **不删**;确认 `mindspace-sandbox-mcp.mjs` 与 runtime manifest 一致 | +| `/Users/john/Project/tkmind_go-native` | 当前 1.41 native 池 | 备份后部署 v1.49 binary;保留旧 `releases/goosed-*` 回滚 | +| `/Users/john/Project/tkmind_go-v149` | v1.49 源码(**103 上需 clone**) | 见 §4 | +| `/Users/john/Project/tkmind_go-v141-prod-prep/.hermit/...` | Rust git vendor 缓存 | 见 §4.2;或从本机 tar 传到 103 | +| `/Users/john/Project/goosed-prod` | Docker standby | **保留**停机的 compose + 原 `GOOSED_TAG` | +| `/Users/john/MindSpace` | MindSpace 服务 | **不动** | +| `~/Library/LaunchAgents/cn.tkmind.goosed-native-1800*.plist` | 9 实例 | 更新 `ProgramArguments` 指向 v1.49 | +| `~/Library/LaunchAgents/cn.tkmind.memind-deepseek-no-think.plist` | `:18036` | **必须保持运行** | + +### 3.2 持久化(备份,禁止覆盖) + +与 `release-portal-runtime-prod.sh` manifest 一致,**额外**: + +| 资产 | 备份 | +|------|------| +| 阿里云 RDS `goose` | 控制台快照 | +| PG `memind_sessions` @ `:5432` | `pg_dump` | +| PG `mindspace_userdata_prod` @ `:5433` | `pg_dump` | +| `tkmind_go-native/` 整目录 | `tar.gz` | +| `tkmind_go-v149/`(首次 clone 前无) | 首次 build 后备份 `target/release/goose` | +| Portal `.env` | 单独拷贝(含 secret,不进 Git) | +| 105 nginx `m.tkmind.cn` / `wechat.m.tkmind.cn` | 发布脚本同款备份 | + +### 3.3 服务依赖(切换后仍须全绿) + +| 组件 | 端口 | 说明 | +|------|------|------| +| Portal | 8081 | `cn.tkmind.memind-portal` | +| MindSpace | 8082 | 独立服务 | +| memind_adm | 8085 | 智趣/Cursor **后台白名单**在此配置,非 `.env` | +| DeepSeek no-think | 18036 | native goosed 自定义 provider 指向 | +| PostgreSQL | 5432 / 5433 | Session + userdata | +| Redis | 6379 | worker 路由(可重建,但切换前记录) | +| imgproxy | launchd | 页面缩略图 | + +--- + +## 4. 103 上从零拉齐 GitHub / 离线依赖 + +> 本机 Mac 已有缓存 **不等于** 103 已有。以下在 **103 SSH 会话**内执行(或维护窗预演)。 + +### 4.1 Goose 源码 + +```bash +# 在 103 上 +cd /Users/john/Project + +# upstream 基线 tag(对照用) +# https://github.com/aaif-goose/goose/releases/tag/v1.49.0 + +# TKMind 补丁树(与本地一致的分支/commit) +git clone git@git.tkmind.cn:tkmind/tkmind_go.git tkmind_go-v149 # 或已有 remote 的实际 URL +cd tkmind_go-v149 +git fetch origin upgrade/v1.49-tkmind +git checkout upgrade/v1.49-tkmind +git rev-parse HEAD # 必须与 Phase 3 记录的 commit 一致 +``` + +若使用 worktree,路径固定为 `/Users/john/Project/tkmind_go-v149`(与 `GOOSE_V149_ROOT` 默认一致)。 + +### 4.2 Rust / cargo vendor(GitHub 不稳定时) + +| 依赖 | 来源 | 103 命令 | +|------|------|----------| +| **rust-sdk** | hermit 缓存 | `bash /Users/john/Project/Memind/scripts/ensure-goose-v149-vendor.sh` | +| **cudaforge** | hermit 缓存 | 同上 | +| **rusty_v8** | GitHub release | `bash /Users/john/Project/Memind/scripts/ensure-goose-v149-v8.sh` | + +Hermit 根目录默认:`/Users/john/Project/tkmind_go-v141-prod-prep/.hermit/rust/git/checkouts/`。 + +若 103 无 hermit 树,从本机传输(维护窗前): + +```bash +# 本机 +tar -czf /tmp/goose-v149-hermit-vendor.tgz -C /Users/john/Project/tkmind_go-v141-prod-prep .hermit +scp /tmp/goose-v149-hermit-vendor.tgz john@58.38.22.103:/tmp/ +# 103 +mkdir -p /Users/john/Project/tkmind_go-v141-prod-prep +tar -xzf /tmp/goose-v149-hermit-vendor.tgz -C /Users/john/Project/tkmind_go-v141-prod-prep +``` + +v8 直链见 [goose-v149-offline-downloads.md](./goose-v149-offline-downloads.md)。 + +### 4.3 工具链 + +```bash +# 103 上确认 +uname -m # arm64 +rustup show # 需 1.96.1 +/opt/homebrew/opt/node@24/bin/node --version +/opt/homebrew/opt/postgresql@17/bin/pg_isready -h 127.0.0.1 -p 5432 +``` + +### 4.4 编译 v1.49(103 本机) + +```bash +cd /Users/john/Project/Memind +export GOOSE_V149_ROOT=/Users/john/Project/tkmind_go-v149 +bash scripts/ensure-goose-v149-vendor.sh +bash scripts/ensure-goose-v149-v8.sh +GOOSE_V149_BUILD_MODE=codemode bash scripts/build-goosed-v149-local.sh +# 日志:/tmp/goosed-v149-build.log +/Users/john/Project/tkmind_go-v149/target/release/goose --version # 期望 goose 1.49.x +strings .../goose | grep -q platform_extensions::code_execution && echo code-mode OK +``` + +**禁止**使用 `GOOSE_V149_BUILD_MODE=minimal` 上生产。 + +### 4.5 安装 release 二进制(推荐) + +```bash +STAMP=$(date +%Y%m%d-v149-m1) +install -m 755 /Users/john/Project/tkmind_go-v149/target/release/goose \ + /Users/john/Project/tkmind_go-native/releases/goose-${STAMP} +# 更新 run-goosed-native.sh 或各 .env.1800N 中的 GOOSE_BIN 指向该 release +``` + +保留上一版 `releases/goosed-20260729-v141-m1`(或当前 live)用于回滚。 + +--- + +## 5. 推荐切换策略 + +### 5.1 两阶段(推荐) + +| 阶段 | 范围 | 端口 | 目的 | +|------|------|------|------| +| **A. Canary goosed** | 单实例 | `:18015` | 与 [release-canary-103.md](./release-canary-103.md) 候选 goosed 对齐;Portal 灰度用户(如唐 `wx_ul610et8`) | +| **B. 全池切换** | 9 实例 | `:18006`–`:18014` | 维护窗内替换全部 launchd | + +阶段 A 可在 **不** 替换 18006–18014 的情况下验证 v1.49 + sandbox-fs + DeepSeek + 微信。 + +### 5.2 全池切换(维护窗) + +参考 [goosed-native-103-migration-plan.md](./goosed-native-103-migration-plan.md) Phase 1–2,替换为 v1.49 启动参数。 + +--- + +## 6. 单实例配置模板(v1.49) + +以 **18006** 为例;18007–18014 复制并改 `GOOSE_PORT` / launchd label。 + +### 6.1 环境变量(`.env.18006` 或 launchd `EnvironmentVariables`) + +```bash +GOOSE_PORT=18006 +GOOSE_HOST=127.0.0.1 +GOOSE_TLS=true +GOOSE_SERVER__SECRET_KEY=<与 Portal TKMIND_SERVER__SECRET_KEY 同步> +GOOSE_DISABLE_KEYRING=1 + +# 生产 Session PG(不是本地 goose_sessions_v149_dev) +GOOSE_SESSION_DB_URL=postgresql://@127.0.0.1:5432/memind_sessions + +# MCP — 宿主机路径,必须与 Portal .env 一致 +GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node +GOOSED_MCP_SERVER_PATH=/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs +PORTAL_RUNTIME_DIR=/Users/john/Project/Memind +MINDSPACE_STORAGE_ROOT=/Users/john/MindSpace/data/mindspace + +# Memory — 强制 legacy owner(禁止 upstream 写 MySQL) +MEMORY_BACKEND=legacy +MEMORY_VECTOR_ENABLED=0 +MEMORY_AGENT_INJECTION_MODE=off +MEMORY_LIFECYCLE_ENABLED=0 +MEMORY_CANDIDATE_ENABLED=0 + +# XDG 每实例隔离(避免 9 实例争用同一 state) +XDG_CONFIG_HOME=/Users/john/Project/tkmind_go-native/state/18006/config +XDG_DATA_HOME=/Users/john/Project/tkmind_go-native/state/18006/data +XDG_STATE_HOME=/Users/john/Project/tkmind_go-native/state/18006/state +XDG_CACHE_HOME=/Users/john/Project/tkmind_go-native/state/18006/cache +``` + +### 6.2 启动命令 + +v1.49 **不再**使用 `goosed agent`,改为: + +```bash +exec /Users/john/Project/tkmind_go-native/releases/goose- serve \ + --host 127.0.0.1 --port 18006 --tls +``` + +legacy REST(`/agent/start`、`/sessions/{id}/reply`)由 **`tkmind_compat`** 在 v1.49 内提供;切换后必须跑: + +```bash +node /Users/john/Project/Memind/scripts/check-goosed-v149-all.mjs # 在 103 上针对 localhost:18006 预演 +``` + +### 6.3 Portal `.env` 核对项 + +| 变量 | 要求 | +|------|------| +| `TKMIND_API_TARGETS` | 九个 `https://127.0.0.1:18006` … `18014` URL | +| `GOOSED_RUNTIME` | `native` | +| `GOOSED_MCP_*` | 宿主机绝对路径,且文件存在 | +| `MEMIND_DEEPSEEK_DISABLE_THINKING` | `1` | +| DeepSeek provider URL | `http://127.0.0.1:18036/v1`(非 `host.docker.internal`) | +| Memory 开关 | **不** 因 v1.49 默认开启 upstream Memory | + +--- + +## 7. 维护窗执行清单 + +**建议窗口:** ≥ 45 分钟(9 实例 + provider 同步 + P0 回归) + +### Phase 0 — 备份(可先执行) + +- [ ] RDS 快照 +- [ ] `pg_dump memind_sessions`、`mindspace_userdata_prod` +- [ ] Portal persist tar + `tkmind_go-native` tar +- [ ] 记录当前 `releases/goosed-*` 文件名 + +### Phase 1 — 切流量 + +- [ ] 105 维护页或阻止新会话(见 native 迁移计划 drain) +- [ ] 可选 soft drain Redis `memind:runtime:worker:goosed-N:drain` +- [ ] 超时后停 `cn.tkmind.memind-portal`(或仅 block 新 session,视 drain) + +### Phase 2 — 替换 goosed 二进制与 launchd + +- [ ] 确认 `:18036` DeepSeek 代理仍 `active` +- [ ] `launchctl bootout` 九个 `cn.tkmind.goosed-native-1800N` +- [ ] 部署 v1.49 `goose` release + 更新 run 脚本 +- [ ] `launchctl bootstrap` 九个实例 +- [ ] 九个 `/status` 返回 `ok` + +```bash +for p in $(seq 18006 18014); do + echo -n "$p: " + curl -kfsS --connect-timeout 3 "https://127.0.0.1:$p/status" || echo FAIL +done +``` + +- [ ] **Provider 同步到全部 9 个 target**(Admin 模型中心 / 103 既有流程) +- [ ] sandbox-fs 冒烟:`node scripts/check-goosed-v149-sandbox-fs.mjs`(指向 18006) + +### Phase 3 — Portal + +**若无 Portal 代码变更:** + +- [ ] 重启 `cn.tkmind.memind-portal`(读 `.env`) +- [ ] **跳过** Docker remount + +**若同窗发 Portal runtime:** + +- [ ] `bash scripts/check-release-ready.sh` +- [ ] Gate report 绑定 commit + artifact SHA +- [ ] `bash scripts/release-portal-runtime-prod.sh --yes`(或 canary 流程) + +### Phase 4 — P0 回归(103 localhost + 105 外网) + +| 优先级 | 场景 | 验证 | +|--------|------|------| +| P0 | Portal `/api/status` | 200 | +| P0 | `/auth/login` 错误密码 | 401 + 正确文案 | +| P0 | 新建 H5 对话 | `goosed_target` 落在 18006–18014 | +| P0 | **旧 session 续聊** | 维护前存在的 session `/reply` 成功 | +| P0 | sandbox-fs 落盘 | page.generate → `public/*.html` 200 | +| P1 | 微信真实回调 | WX-01~13;非仅 health | +| P1 | DeepSeek 工具轮次 | 无 `reasoning_content` 裸奔错误 | +| P1 | imgproxy / MindSearch | 生产 URL | + +Gate:维护窗 commit 须绑定 **Core + Impact Gate report**([production-release-guardian.md](./production-release-guardian.md))。 + +### Phase 5 — 恢复流量 + +- [ ] 105 nginx → `58.38.22.103:8081`(或灰度 `19082` 路径) +- [ ] 外网 `https://m.tkmind.cn/api/status` + +### Phase 6 — 观察 24–72h + +- [ ] `GOOSED_RUNTIME=native node scripts/runtime-worker-metrics.mjs sample` +- [ ] `monitor-goosed-fds.mjs` 覆盖 18006–18014 +- [ ] 续聊 / 页面 / 微信投诉 + +--- + +## 8. 回滚 + +**触发:** 九 target 非全绿、旧 session 批量续聊失败、P0 回归失败、FD 泄漏 + +**目标:** < 15 分钟 + +| 步骤 | 操作 | +|------|------| +| 1 | 105 保持维护 | +| 2 | `launchctl bootout` 九个 v1.49 native 实例 | +| 3 | 恢复 launchd 指向 **1.41** `releases/goosed-*` | +| 4 | 或紧急:`cd goosed-prod && docker compose up -d`(原 `GOOSED_TAG`,仅 18006 standby) | +| 5 | Portal `.env` 恢复备份 | +| 6 | 若 Portal 已发新版:按 `archived_source` / backup `release_id` 回滚 | +| 7 | 九路 `/status` + `/auth/login` + 旧 session 续聊抽样 | + +**回滚不需要:** 恢复 RDS / PG(若切换中未改库)。 + +本地 canary 回滚(仅开发机):`bash scripts/switch-goose-v149-canary.sh rollback` + +--- + +## 9. 与 Portal 发版的关系 + +| 动作 | 独立? | 说明 | +|------|--------|------| +| Goose v1.49 全池 | 是 | 本 runbook | +| Portal runtime | 是 | `release-portal-runtime-prod.sh`;artifact 含 `mindspace-sandbox-mcp.mjs` | +| 105 灰度 | 可组合 | 先 canary goosed `:18015` + 灰度 Portal | +| Memory owner | **禁止**绑在 v1.49 包内 | 单独审批 | + +**推荐顺序:** Memind 合并 `main` → 103 上 build v1.49 → canary(18015 + 唐)→ 全池维护窗 →(可选)Portal Gate 发版。 + +--- + +## 10. 证据附件(维护窗结束后归档) + +保存到 `test/_103_baselines/post-v149-YYYYMMDD/`: + +| 文件 | 内容 | +|------|------| +| `goose-version.txt` | `goose --version` × 9 | +| `status-loop.txt` | 18006–18014 `/status` | +| `portal-manifest.txt` | `.release-manifest.txt` | +| `gate-report.json` | Core + Impact,绑定 commit | +| `phase3-offline.log` | 本机 `GOOSE_V149_PHASE3_OFFLINE_OK` | +| `wechat-smoke.log` | 本地 `run-wechat-scenario-test`(103 真实 WX 另补) | +| `rollback-pointer.txt` | 1.41 release 文件名 + Docker `GOOSED_TAG` | + +--- + +## 11. 相关文档 + +| 文档 | 用途 | +|------|------| +| [TKMIND_V1_49_MIGRATION.md](../TKMIND_V1_49_MIGRATION.md) | 决策表、Memory 闸门 | +| [goose-v149-phase3-closeout.md](./goose-v149-phase3-closeout.md) | 本地证据与合并前闸门 | +| [goose-v149-offline-downloads.md](./goose-v149-offline-downloads.md) | v8 / vendor 直链 | +| [103-runtime-topology.md](./103-runtime-topology.md) | 端口与服务拓扑 | +| [goosed-native-103-migration-plan.md](./goosed-native-103-migration-plan.md) | 1.41 native 迁移(已执行) | +| [production-release-guardian.md](./production-release-guardian.md) | Gate 场景 | +| [发包必看.md](./发包必看.md) | Portal 发版强制项 | + +--- + +## 12. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-09-10 | 初稿:103 clone/vendor/build/9 实例/回滚清单;本地 Phase 3 证据已齐,待用户批准执行 | diff --git a/docs/goose-v149-phase3-closeout.md b/docs/goose-v149-phase3-closeout.md index 6da4b61..82576c6 100644 --- a/docs/goose-v149-phase3-closeout.md +++ b/docs/goose-v149-phase3-closeout.md @@ -72,3 +72,7 @@ pnpm dev ## 基线产物索引 见 [docs/baselines/README.md](./baselines/README.md)。 + +## 103 生产切换(单独审批) + +本地 Phase 3 证据齐后,103 维护窗执行步骤见 **[goose-v149-103-cutover-runbook.md](./goose-v149-103-cutover-runbook.md)**(clone/vendor/build、9 实例 launchd、回滚、与 Portal 发版顺序)。**未获明确批准前不得按 runbook 操作 103/105。** diff --git a/scripts/check-goosed-v149-wechat-evidence.mjs b/scripts/check-goosed-v149-wechat-evidence.mjs index a7e3642..397ec00 100644 --- a/scripts/check-goosed-v149-wechat-evidence.mjs +++ b/scripts/check-goosed-v149-wechat-evidence.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** - * WeChat routing / intent evidence for v1.49 cutover (unit + contract checks; no live MP). + * WeChat routing / intent evidence for v1.49 cutover. + * Runs WX-01..WX-13 gate suites + wechat-intent-router; optional live webhook smoke. */ import { spawnSync } from 'node:child_process'; import path from 'node:path'; @@ -8,11 +9,13 @@ import { fileURLToPath } from 'node:url'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const skip = process.env.GOOSE_V149_WECHAT_EVIDENCE_SKIP === '1'; +const live = process.env.GOOSE_V149_WECHAT_LIVE_SMOKE === '1'; -function runTest(label, file) { - const result = spawnSync(process.execPath, ['--test', file], { +function runScript(script, args = [], label = script) { + const result = spawnSync(process.execPath, [path.join(root, 'scripts', script), ...args], { cwd: root, encoding: 'utf8', + env: process.env, }); if (result.status !== 0) { throw new Error(`${label} failed: ${result.stderr?.trim() || result.stdout?.trim()}`); @@ -25,11 +28,12 @@ async function main() { return; } - runTest('wechat-intent-router', 'wechat-intent-router.test.mjs'); + const gateArgs = live ? ['--live-only', '--live-scenario', 'poem-page'] : []; + runScript('run-wechat-gate-evidence.mjs', gateArgs, 'wechat-gate-evidence'); + const fs = await import('node:fs'); const bundlePath = path.join(root, 'wechat-mp.bundle.mjs'); const sourcePath = path.join(root, 'wechat-mp.mjs'); - const fs = await import('node:fs'); const bundleExists = fs.existsSync(bundlePath); const sourceExists = fs.existsSync(sourcePath); if (!bundleExists && !sourceExists) { @@ -37,13 +41,13 @@ async function main() { } console.log('GOOSE_V149_WECHAT_EVIDENCE_OK:'); - console.log(' unit=wechat-intent-router.test.mjs'); + console.log(' gate=WX-01..WX-13 deterministic suites'); + console.log(` live_smoke=${live ? 'poem-page' : 'not-run (set GOOSE_V149_WECHAT_LIVE_SMOKE=1)'}`); if (bundleExists) { console.log(` bundle=${bundlePath} (${fs.statSync(bundlePath).size} bytes)`); } else { console.log(` source=${sourcePath} (bundle not built; prod uses wechat-mp.bundle.mjs)`); } - console.log(' note=live MP ACK/page-link E2E requires WeChat credentials; not run locally'); } main().catch((error) => { diff --git a/scripts/install-local-goose-v149.sh b/scripts/install-local-goose-v149.sh new file mode 100755 index 0000000..507b637 --- /dev/null +++ b/scripts/install-local-goose-v149.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Install Goose v1.49 (goose serve) as a local LaunchAgent on loopback :18049. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${GOOSE_V149_PORT:-18049}" +LABEL="${GOOSE_V149_LAUNCHD_LABEL:-com.tkmind.local-goose-v149}" +LAUNCHD_DIR="${HOME}/Library/LaunchAgents" +PLIST="${LAUNCHD_DIR}/${LABEL}.plist" +RUN_SCRIPT="${ROOT}/scripts/run-goosed-v149-local.sh" +LOG_ROOT="${GOOSE_V149_RUNTIME_ROOT:-/tmp/goose-v149-runtime}" +GUI="gui/$(id -u)" + +usage() { + cat </dev/null || true + rm -f "${PLIST}" + if lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then + lsof -ti "TCP:${PORT}" -sTCP:LISTEN | xargs kill -TERM 2>/dev/null || true + fi + echo "[install-local-goose-v149] uninstalled ${LABEL}" +} + +if [[ "${1:-}" == "--uninstall" ]]; then + uninstall + exit 0 +fi + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ ! -x "${RUN_SCRIPT}" ]]; then + echo "run script missing: ${RUN_SCRIPT}" >&2 + exit 1 +fi + +for blocked in 18006 18007 18008 18009 18010 18011 18012 18013 18014 8081; do + if [[ "${PORT}" == "${blocked}" ]]; then + echo "refusing stable/production port ${PORT}" >&2 + exit 1 + fi +done + +mkdir -p "${LAUNCHD_DIR}" "${LOG_ROOT}" + +cat > "${PLIST}" < + + + + Label + ${LABEL} + ProgramArguments + + /bin/bash + ${RUN_SCRIPT} + + WorkingDirectory + ${ROOT} + RunAtLoad + + KeepAlive + + StandardOutPath + ${LOG_ROOT}/goose-v149.launchd.log + StandardErrorPath + ${LOG_ROOT}/goose-v149.launchd.err.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin + GOOSE_V149_ALLOW_REAL_LLM + 1 + GOOSE_V149_PORT + ${PORT} + GOOSE_SERVER__SECRET_KEY + local-dev-secret + GOOSE_V149_RUNTIME_ROOT + ${LOG_ROOT} + + + +EOF + +plutil -lint "${PLIST}" >/dev/null +launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true +launchctl bootstrap "${GUI}" "${PLIST}" +launchctl enable "${GUI}/${LABEL}" +launchctl kickstart -k "${GUI}/${LABEL}" + +echo "[install-local-goose-v149] installed ${LABEL} on https://127.0.0.1:${PORT}" +echo "Verify: curl -sk https://127.0.0.1:${PORT}/status -H 'X-Secret-Key: local-dev-secret'" diff --git a/scripts/run-wechat-gate-evidence.mjs b/scripts/run-wechat-gate-evidence.mjs new file mode 100644 index 0000000..2ad5508 --- /dev/null +++ b/scripts/run-wechat-gate-evidence.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * WX-01..WX-13 release-gate evidence + optional live WeChat webhook smoke. + * + * Deterministic suites mirror release-gate/coverage.mjs: + * - wechat-channel-contract → WX-01..08 (+ FILE-08 overlap) + * - wechat-terminal-and-atomic → WX-09..13 + * - wechat-native-image-delivery → WX-05 + * + * Live smoke (optional): POST /webhooks/wechat-mp/messages via run-wechat-scenario-test.mjs + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const DETERMINISTIC_SUITES = [ + { + id: 'wechat-channel-contract', + scenarios: ['WX-01', 'WX-02', 'WX-03', 'WX-04', 'WX-06', 'WX-07', 'WX-08'], + command: [ + '--test', + 'wechat-mp.test.mjs', + 'wechat-oauth.test.mjs', + 'wechat-pay.test.mjs', + 'wechat-media.test.mjs', + 'wechat/wechat-channel.test.mjs', + 'wechat/image-generation-policy.test.mjs', + 'mindspace-chat-docx-package.test.mjs', + 'mindspace-html-download-links.test.mjs', + 'mindspace-public-asset-token.test.mjs', + 'server/portal-mindspace-asset-routes.test.mjs', + ], + }, + { + id: 'wechat-terminal-and-atomic-delivery', + scenarios: ['WX-09', 'WX-10', 'WX-11', 'WX-12', 'WX-13'], + command: [ + '--test', + 'user-auth.test.mjs', + 'wechat-mp.test.mjs', + 'wechat-media.test.mjs', + 'wechat/wechat-channel.test.mjs', + 'chat-image-turn-scope.test.mjs', + 'tkmind-proxy.test.mjs', + ], + }, + { + id: 'wechat-native-image-delivery', + scenarios: ['WX-05'], + command: ['--test', 'wechat-media.test.mjs'], + }, +]; + +function runNode(args, label) { + const result = spawnSync(process.execPath, args, { + cwd: root, + encoding: 'utf8', + env: process.env, + }); + if (result.status !== 0) { + const detail = result.stderr?.trim() || result.stdout?.trim() || `${label} exited ${result.status}`; + throw new Error(`${label} failed: ${detail.slice(-2000)}`); + } + return result.stdout?.trim() ?? ''; +} + +async function assertLiveEnv() { + const { loadH5Environment } = await import('./load-env.mjs'); + loadH5Environment(import.meta.dirname); + const missing = ['H5_WECHAT_MP_TOKEN', 'H5_WECHAT_MP_APP_ID', 'DATABASE_URL'] + .filter((key) => !String(process.env[key] ?? '').trim()); + if (missing.length) { + throw new Error( + `live smoke 缺少环境变量: ${missing.join(', ')}。请在 .env.local 配置(见 docs/local-dev.md §公众号 Agent 调试)`, + ); + } +} + +function parseArgs(argv) { + const options = { + live: false, + liveOnly: false, + liveScenario: 'poem-page', + liveAll: false, + skipDeterministic: false, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--live') options.live = true; + else if (arg === '--live-only') { + options.live = true; + options.liveOnly = true; + options.skipDeterministic = true; + } else if (arg === '--live-all') { + options.live = true; + options.liveAll = true; + } else if (arg === '--live-scenario' && argv[index + 1]) { + options.live = true; + options.liveScenario = argv[++index]; + } else if (arg === '--skip-deterministic') options.skipDeterministic = true; + else if (arg === '-h' || arg === '--help') { + console.log(`Usage: + node scripts/run-wechat-gate-evidence.mjs + node scripts/run-wechat-gate-evidence.mjs --live-only --live-scenario poem-page + node scripts/run-wechat-gate-evidence.mjs --live-only --live-all + node scripts/run-wechat-gate-evidence.mjs --skip-deterministic --live-only --live-scenario chat-general + +Deterministic: WX-01..WX-13 unit/contract suites (no LLM). +Live smoke: Portal :8081 + DATABASE_URL + H5_WECHAT_MP_TOKEN + H5_WECHAT_MP_APP_ID.`); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +async function main() { + const options = parseArgs(process.argv); + const covered = new Set(); + + if (!options.skipDeterministic) { + runNode(['--test', 'wechat-intent-router.test.mjs'], 'wechat-intent-router'); + for (const suite of DETERMINISTIC_SUITES) { + runNode(suite.command, suite.id); + for (const scenarioId of suite.scenarios) covered.add(scenarioId); + console.log(`[wechat-gate] PASS ${suite.id} → ${suite.scenarios.join(', ')}`); + } + } + + if (options.live) { + await assertLiveEnv(); + const liveArgs = ['scripts/run-wechat-scenario-test.mjs']; + if (options.liveAll) liveArgs.push('--all'); + else liveArgs.push('--scenario', options.liveScenario); + runNode(liveArgs, 'wechat-live-smoke'); + console.log('[wechat-gate] PASS live smoke'); + } + + console.log('WECHAT_GATE_EVIDENCE_OK:'); + console.log(` deterministic=${options.skipDeterministic ? 'skipped' : 'WX-01..WX-13'}`); + console.log(` live=${options.live ? (options.liveAll ? 'all-scenarios' : options.liveScenario) : 'not-run'}`); + if (covered.size) { + console.log(` scenarios=${[...covered].sort().join(',')}`); + } + if (!options.live) { + console.log(' note=add --live-only --live-scenario poem-page for WX-04/13 webhook E2E'); + } else if (options.liveOnly) { + console.log(' mode=live-only'); + } +} + +main().catch((error) => { + console.error(`WECHAT_GATE_EVIDENCE_FAIL: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/run-wechat-scenario-test.mjs b/scripts/run-wechat-scenario-test.mjs index 09bd6c8..d0f27bf 100644 --- a/scripts/run-wechat-scenario-test.mjs +++ b/scripts/run-wechat-scenario-test.mjs @@ -13,19 +13,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import mysql from 'mysql2/promise'; +import { loadH5Environment } from './load-env.mjs'; -function loadDotEnv(envPath) { - if (!fs.existsSync(envPath)) return; - for (const line of fs.readFileSync(envPath, 'utf8').split(/\n/)) { - if (!line || line.startsWith('#') || !line.includes('=')) continue; - const i = line.indexOf('='); - const key = line.slice(0, i); - const val = line.slice(i + 1); - if (!(key in process.env)) process.env[key] = val; - } -} - -loadDotEnv(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.env')); +loadH5Environment(import.meta.dirname); const SCENARIOS = [ { @@ -48,7 +38,8 @@ const SCENARIOS = [ id: 'poem-page', name: '写诗 → 做成页面', message: '帮我写一首关于八月午后的短诗,然后做成精美 HTML 页面', - timeoutMs: 600_000, + resetSessionMessage: '换新会话', + timeoutMs: Number(process.env.WECHAT_SCENARIO_POEM_PAGE_TIMEOUT_MS ?? 900_000), expect: { forbidReply: ['没有按服务号页面技能', '没能可靠确认'], requireGoosedTools: ['sandbox-fs__write_file'], @@ -168,46 +159,81 @@ async function waitForMessageStatus(pool, { appId, openid, msgId }, timeoutMs) { function grepGoosedSessionLogs(sessionId) { if (!sessionId) return { tools: [], sandboxFsFailed: false, lines: [], logFile: null }; + const tools = new Set(); + let sandboxFsFailed = false; + const matchedLines = []; + + const v149Root = process.env.GOOSE_V149_RUNTIME_ROOT || '/tmp/goose-v149-runtime'; + const today = new Date().toISOString().slice(0, 10); + const v149LogDir = path.join(v149Root, 'state', 'goose', 'logs', 'cli', today); + if (fs.existsSync(v149LogDir)) { + for (const name of fs.readdirSync(v149LogDir)) { + if (!name.endsWith('.log')) continue; + const filePath = path.join(v149LogDir, name); + let inSession = false; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + if (!line.trim()) continue; + let obj; + try { + obj = JSON.parse(line); + } catch { + continue; + } + const sid = obj?.fields?.session_id; + if (sid === sessionId) inSession = true; + if (inSession && sid && sid !== sessionId) inSession = false; + const toolName = obj?.fields?.tool_name; + if (inSession && toolName) { + tools.add(toolName); + matchedLines.push(`tool_name: ${toolName}`); + } + if (inSession && /Failed to load extension sandbox-fs|Transport closed/i.test(line)) { + sandboxFsFailed = true; + } + } + } + } + const logGlob = `${process.env.HOME}/Library/Logs/goosed-native-*.log`; - let loadLine = ''; let logFile = ''; try { - loadLine = execSync( + const loadLine = execSync( `grep -H "Session loaded.*session_id: ${sessionId}" ${logGlob} 2>/dev/null | tail -1 || true`, { encoding: 'utf8' }, ).trim(); + if (loadLine.includes(':')) logFile = loadLine.split(':')[0]; } catch { - loadLine = ''; + logFile = ''; } - if (loadLine.includes(':')) { - logFile = loadLine.split(':')[0]; - } - let raw = ''; if (logFile) { try { - raw = execSync( + const raw = execSync( `awk '/Session loaded.*session_id: ${sessionId}/{found=1; next} found && /Session loaded/{exit} found' "${logFile}" | grep "tool_name:" || true`, { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }, ); - } catch { - raw = ''; - } - } - const lines = raw.split('\n').filter(Boolean); - const tools = [...new Set(lines.map((l) => l.match(/tool_name: ([^,]+)/)?.[1]).filter(Boolean))]; - let sandboxFsFailed = false; - if (logFile) { - try { + for (const line of raw.split('\n').filter(Boolean)) { + const toolName = line.match(/tool_name: ([^,]+)/)?.[1]; + if (toolName) { + tools.add(toolName); + matchedLines.push(line); + } + } const failRaw = execSync( `awk '/Session loaded.*session_id: ${sessionId}/{found=1} found && /Failed to load extension sandbox-fs/{print; exit}' "${logFile}" || true`, { encoding: 'utf8' }, ); - sandboxFsFailed = failRaw.trim().length > 0; + if (failRaw.trim()) sandboxFsFailed = true; } catch { - sandboxFsFailed = false; + // ignore legacy log parse errors } } - return { tools, sandboxFsFailed, lines: lines.slice(-20), logFile: logFile || null }; + + return { + tools: [...tools], + sandboxFsFailed, + lines: matchedLines.slice(-20), + logFile: logFile || v149LogDir, + }; } function parseArgs(argv) { @@ -232,6 +258,34 @@ function parseArgs(argv) { } async function runScenario(scenario, ctx) { + if (scenario.resetSessionMessage) { + console.log(` 预步骤: ${scenario.resetSessionMessage}`); + const resetMsgId = `sim_${scenario.id}_reset_${Date.now()}`; + const resetPost = await postWechatMessage({ + baseUrl: ctx.baseUrl, + token: ctx.token, + appId: ctx.appId, + openid: ctx.openid, + ghId: ctx.ghId, + content: scenario.resetSessionMessage, + msgId: resetMsgId, + }); + if (resetPost.status !== 200) { + console.error(`✘ 预步骤 webhook 返回 ${resetPost.status}`); + return false; + } + const resetOutcome = await waitForMessageStatus( + ctx.pool, + { appId: ctx.appId, openid: ctx.openid, msgId: resetMsgId }, + 120_000, + ); + console.log(` 预步骤状态: ${resetOutcome.status}, session: ${resetOutcome.agent_session_id ?? '-'}`); + if (resetOutcome.status !== 'done' && resetOutcome.status !== 'failed') { + console.error('✘ 预步骤超时'); + return false; + } + } + const msgId = `sim_${scenario.id}_${Date.now()}`; console.log(`\n==> 场景: ${scenario.name} (${scenario.id})`); console.log(` 消息: ${scenario.message.slice(0, 60)}…`); @@ -265,13 +319,16 @@ async function runScenario(scenario, ctx) { console.log(` (用户消息: ${String(outcome.displayText).slice(0, 60)}…)`); } + const requiresGoosedTools = (scenario.expect?.requireGoosedTools ?? []).length > 0; const goosed = grepGoosedSessionLogs(outcome.agent_session_id); - if (goosed.sandboxFsFailed) { - console.error('✘ goosed: sandbox-fs 扩展加载失败'); - } else if (goosed.tools.some((t) => t.startsWith('sandbox-fs__'))) { - console.log(`✔ goosed 工具: ${goosed.tools.filter((t) => t.startsWith('sandbox-fs__')).join(', ')}`); - } else { - console.error(`✘ goosed 未使用 sandbox-fs 工具 (实际: ${goosed.tools.slice(0, 8).join(', ') || '无'})`); + if (requiresGoosedTools) { + if (goosed.sandboxFsFailed) { + console.error('✘ goosed: sandbox-fs 扩展加载失败'); + } else if (goosed.tools.some((t) => t.startsWith('sandbox-fs__'))) { + console.log(`✔ goosed 工具: ${goosed.tools.filter((t) => t.startsWith('sandbox-fs__')).join(', ')}`); + } else { + console.error(`✘ goosed 未使用 sandbox-fs 工具 (实际: ${goosed.tools.slice(0, 8).join(', ') || '无'})`); + } } let ok = outcome.status === 'done'; @@ -303,7 +360,7 @@ async function runScenario(scenario, ctx) { ok = false; } } - if (goosed.sandboxFsFailed) ok = false; + if (requiresGoosedTools && goosed.sandboxFsFailed) ok = false; console.log(ok ? '✔ 场景通过' : '✘ 场景失败'); return ok; @@ -318,7 +375,11 @@ async function main() { const token = process.env.H5_WECHAT_MP_TOKEN; const appId = process.env.H5_WECHAT_MP_APP_ID; - if (!token || !appId) throw new Error('缺少 H5_WECHAT_MP_TOKEN / H5_WECHAT_MP_APP_ID'); + if (!token || !appId) { + throw new Error( + '缺少 H5_WECHAT_MP_TOKEN / H5_WECHAT_MP_APP_ID(写入 .env.local,见 docs/local-dev.md §公众号 Agent 调试)', + ); + } const baseUrl = `http://127.0.0.1:${port}`; const status = await fetch(`${baseUrl}/api/status`); diff --git a/scripts/uninstall-local-goosed-pool.sh b/scripts/uninstall-local-goosed-pool.sh new file mode 100755 index 0000000..2aee2dc --- /dev/null +++ b/scripts/uninstall-local-goosed-pool.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Uninstall local goosed 1.41 native launchd pool (default 18006/18007). +set -euo pipefail + +PORTS="${GOOSED_NATIVE_POOL_PORTS:-18006,18007}" +LAUNCHD_DIR="${HOME}/Library/LaunchAgents" +GUI="gui/$(id -u)" + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + +IFS=',' read -r -a port_list <<< "${PORTS}" + +for port in "${port_list[@]}"; do + port="${port// /}" + [[ -n "${port}" ]] || continue + label="com.tkmind.local-goosed-${port}" + plist="${LAUNCHD_DIR}/${label}.plist" + launchctl bootout "${GUI}/${label}" 2>/dev/null || true + if [[ -f "${plist}" ]]; then + rm -f "${plist}" + echo "removed ${plist}" + fi + if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then + lsof -ti "TCP:${port}" -sTCP:LISTEN | xargs kill -TERM 2>/dev/null || true + sleep 1 + lsof -ti "TCP:${port}" -sTCP:LISTEN | xargs kill -9 2>/dev/null || true + echo "stopped listener on port ${port}" + fi +done + +# Legacy single-instance label (pre per-port pool). +launchctl bootout "${GUI}/com.tkmind.local-goosed" 2>/dev/null || true +legacy_plist="${LAUNCHD_DIR}/com.tkmind.local-goosed.plist" +if [[ -f "${legacy_plist}" ]]; then + rm -f "${legacy_plist}" + echo "removed legacy plist: ${legacy_plist}" +fi + +echo "[uninstall-local-goosed-pool] done; ports=${PORTS}" diff --git a/wechat/handlers/scheduled-task.mjs b/wechat/handlers/scheduled-task.mjs index d7a22ce..6c5e9f0 100644 --- a/wechat/handlers/scheduled-task.mjs +++ b/wechat/handlers/scheduled-task.mjs @@ -19,15 +19,16 @@ export async function handleWechatScheduledTaskIntent({ if (!scheduledTaskService) return null; const agentText = String(intent?.agentText ?? '').trim(); - if (isPageGenerateText(agentText)) { - return null; - } - const timezone = env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; const taskIntent = parseScheduledTaskIntent(agentText, { timezone, }); - if (!isScheduledTaskIntent(taskIntent)) return null; + if (!isScheduledTaskIntent(taskIntent)) { + if (isPageGenerateText(agentText)) { + return null; + } + return null; + } try { if (taskIntent.action === 'list_scheduled_tasks') { diff --git a/wechat/handlers/scheduled-task.test.mjs b/wechat/handlers/scheduled-task.test.mjs index b65187c..b67c020 100644 --- a/wechat/handlers/scheduled-task.test.mjs +++ b/wechat/handlers/scheduled-task.test.mjs @@ -36,6 +36,23 @@ test('handleWechatScheduledTaskIntent passes incomplete create requests to goose assert.equal(reply, null); }); +test('handleWechatScheduledTaskIntent passes scheduled page automation to goose even when page keywords appear', async () => { + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '我想创建一个定时执行任务,今晚 21:45 分执行做新闻页面', + msgId: 'msg-sched-page-1', + }, + user: { userId: 'user-1' }, + scheduledTaskService: { + async listTasks() { + throw new Error('should not list for create_scheduled_task'); + }, + }, + }); + assert.equal(reply, null); +}); + test('handleWechatScheduledTaskIntent yields page.generate requests to cursor/goose path', async () => { const reply = await handleWechatScheduledTaskIntent({ intent: { diff --git a/wechat/intent/classifier.mjs b/wechat/intent/classifier.mjs index c6649db..6689de7 100644 --- a/wechat/intent/classifier.mjs +++ b/wechat/intent/classifier.mjs @@ -1,3 +1,4 @@ +import { isScheduledTaskIntent, parseScheduledTaskIntent } from '../../scheduled-task-intent.mjs'; import { parseWechatChannelPrefix, WECHAT_CHANNEL_PREFIX } from './channel-prefix.mjs'; import { CONNECTIVITY_TEST_PATTERN, @@ -80,6 +81,10 @@ export function classifyWechatIntent(intent) { wantsDocx: wantsDocxDownload(text), }, channelPrefix); } + const scheduledTaskIntent = parseScheduledTaskIntent(text); + if (isScheduledTaskIntent(scheduledTaskIntent)) { + return withChannelMeta({ kind: 'chat.general', text }, channelPrefix); + } if (isPageGenerateText(text)) { return withChannelMeta({ kind: 'page.generate',