diff --git a/docs/103-rollback-agent-time-fix-2026-07-01.md b/docs/103-rollback-agent-time-fix-2026-07-01.md new file mode 100644 index 0000000..6f3bd73 --- /dev/null +++ b/docs/103-rollback-agent-time-fix-2026-07-01.md @@ -0,0 +1,51 @@ +# 103 回退说明 — Agent 时间基准修复(2026-07-01) + +## 背景 + +- 分支:`0630007-agent-time-fix`(从 `0630007-memind` 切出,**不含** miniapp / 0630006 改动) +- 修复:向 Agent 注入 `TKMind 当前时间基准`(Asia/Shanghai),避免回复里报 UTC 时间 + +## 发布前 103 状态(可回退目标) + +| 项 | 值 | +|---|---| +| release_id | `20260701-075736-16828a5` | +| git_head | `16828a58f7fc79d7b9a174ffa87f5579894798fc` | +| git_branch | `0630007-memind` | + +回退到上述 release 发布前版本: + +```bash +bash scripts/rollback-portal-runtime-prod.sh 20260701-075736-16828a5 --yes +``` + +## 查看 103 上所有可回退点 + +```bash +bash scripts/rollback-portal-runtime-prod.sh --list +``` + +## 回退源优先级 + +1. `/Users/john/Project/archives/Memind-source-before-/` +2. `/Users/john/Project/backups/memind/memind-full--before.tar.gz` + +发布脚本会自动创建上述两份备份;回退脚本优先用 archive(最快)。 + +## 发布后验收 + +1. `curl -s http://127.0.0.1:8081/api/status` → 200 +2. 新建 H5 会话,问「现在几点」→ 应报 **上海时间**(非 UTC 前一天 23:xx) +3. 日志无 `User auth bootstrap failed` + +## 若新 release 有问题 + +假设新 release_id 为 `20260701-HHMMSS-`,立即回退: + +```bash +bash scripts/rollback-portal-runtime-prod.sh 20260701-HHMMSS- --yes +``` + +失败的 live 目录会保留在: + +`/Users/john/Project/archives/Memind-failed-after--/` diff --git a/scripts/rollback-portal-runtime-prod.sh b/scripts/rollback-portal-runtime-prod.sh new file mode 100755 index 0000000..f68c86d --- /dev/null +++ b/scripts/rollback-portal-runtime-prod.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${STUDIO_HOST:-58.38.22.103}" +REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}" +APP_DIR="${REMOTE_ROOT}/Memind" +BACKUP_DIR="${REMOTE_ROOT}/backups/memind" +ARCHIVE_DIR="${REMOTE_ROOT}/archives" +HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}" +PORTAL_LABEL="cn.tkmind.memind-portal" +LAUNCHD_GUI="gui/$(id -u)" +AUTO_YES=0 +RELEASE_ID="" + +usage() { + cat <<'EOF' +用法: + bash scripts/rollback-portal-runtime-prod.sh [--yes] + bash scripts/rollback-portal-runtime-prod.sh --list [--limit N] + +说明: + 将 103 Portal 回退到指定 release 发布前的 live 目录。 + + RELEASE_ID 形如 20260701-075736-16828a5(与 release-portal-runtime-prod.sh 输出一致)。 + + 回退优先级: + 1. /Users/john/Project/archives/Memind-source-before- + 2. /Users/john/Project/backups/memind/memind-full--before.tar.gz + + 当前失败的 live 目录会移入 archive,便于事后排查。 + +示例: + bash scripts/rollback-portal-runtime-prod.sh --list + bash scripts/rollback-portal-runtime-prod.sh 20260701-075736-16828a5 --yes +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) AUTO_YES=1 ;; + --list) + ssh -o BatchMode=yes "${HOST}" "bash -s" <<'REMOTE' +set -euo pipefail +echo "=== 当前 live manifest ===" +if [[ -f /Users/john/Project/Memind/.release-manifest.txt ]]; then + cat /Users/john/Project/Memind/.release-manifest.txt +else + echo "(无 manifest)" +fi +echo +echo "=== 可回退 archive(最近 10 条)===" +ls -1dt /Users/john/Project/archives/Memind-source-before-* 2>/dev/null | head -10 || true +echo +echo "=== 可回退 full backup(最近 10 条)===" +ls -1dt /Users/john/Project/backups/memind/memind-full-*-before.tar.gz 2>/dev/null | head -10 || true +REMOTE + exit 0 + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "${RELEASE_ID}" ]]; then + RELEASE_ID="$1" + else + echo "未知参数: $1" >&2 + usage >&2 + exit 1 + fi + ;; + esac + shift +done + +if [[ -z "${RELEASE_ID}" ]]; then + usage >&2 + exit 1 +fi + +say() { + printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +say "103 回退预检查" +ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo rollback-ssh-ok" >/dev/null + +ARCHIVED_SOURCE="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}" +FULL_BACKUP="${BACKUP_DIR}/memind-full-${RELEASE_ID}-before.tar.gz" + +ssh -o BatchMode=yes "${HOST}" "test -d '${ARCHIVED_SOURCE}' || test -f '${FULL_BACKUP}'" || { + echo "找不到 release ${RELEASE_ID} 的回退源:" >&2 + echo " ${ARCHIVED_SOURCE}" >&2 + echo " ${FULL_BACKUP}" >&2 + echo "可先运行: bash scripts/rollback-portal-runtime-prod.sh --list" >&2 + exit 1 +} + +if [[ "${AUTO_YES}" -ne 1 ]]; then + say "回退确认" + echo "目标主机: ${HOST}" + echo "回退 release: ${RELEASE_ID}" + echo "当前 live: ${APP_DIR}" + ssh -o BatchMode=yes "${HOST}" " + if [[ -d '${ARCHIVED_SOURCE}' ]]; then echo 'source=archived_source'; fi + if [[ -f '${FULL_BACKUP}' ]]; then echo 'source=full_backup'; fi + if [[ -f '${APP_DIR}/.release-manifest.txt' ]]; then echo '--- current manifest ---'; cat '${APP_DIR}/.release-manifest.txt'; fi + " + read -r -p "确认回退到 ${RELEASE_ID} 发布前版本? [y/N] " confirm /dev/null 2>&1 || true +lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true +sleep 2 +lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true + +say "准备回退目录" +rm -rf "${RESTORE_DIR}" +mkdir -p "${RESTORE_DIR}" + +if [[ -d "${ARCHIVED_SOURCE}" ]]; then + say "从 archived_source 恢复: ${ARCHIVED_SOURCE}" + cp -a "${ARCHIVED_SOURCE}/." "${RESTORE_DIR}/" +elif [[ -f "${FULL_BACKUP}" ]]; then + say "从 full backup 恢复: ${FULL_BACKUP}" + tar -xzf "${FULL_BACKUP}" -C "${RESTORE_DIR}" +else + echo "rollback source missing" >&2 + exit 1 +fi + +say "归档当前失败 live 目录" +if [[ -d "${APP_DIR}" ]]; then + rm -rf "${FAILED_LIVE}" + mv "${APP_DIR}" "${FAILED_LIVE}" +fi +mv "${RESTORE_DIR}" "${APP_DIR}" + +cat > "${APP_DIR}/.rollback-manifest.txt" < "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" < + + + + Label + ${PORTAL_LABEL} + ProgramArguments + + ${APP_DIR}/scripts/run-memind-portal-prod.sh + + WorkingDirectory + ${APP_DIR} + EnvironmentVariables + + PATH + /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + ${HOME}/Library/Logs/memind-portal.log + StandardErrorPath + ${HOME}/Library/Logs/memind-portal.log + + +EOF + launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true + launchctl enable "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true + launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || \ + nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 & +else + nohup /opt/homebrew/opt/node@24/bin/node "${APP_DIR}/server.mjs" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 & +fi + +say "健康检查" +for _ in $(seq 1 60); do + portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" + if [[ "${portal_code}" == "200" ]]; then + break + fi + sleep 2 +done +portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" +if [[ "${portal_code}" != "200" ]]; then + echo "rollback health check failed: portal=${portal_code}" >&2 + exit 1 +fi + +say "回退完成" +printf 'live_dir=%s\n' "${APP_DIR}" +printf 'failed_live=%s\n' "${FAILED_LIVE}" +printf 'rolled_back_release=%s\n' "${RELEASE_ID}" +REMOTE_SCRIPT + +say "103 回退后状态" +ssh -o BatchMode=yes "${HOST}" "curl -s '${HEALTH_URL}' && echo && cat '${APP_DIR}/.rollback-manifest.txt' 2>/dev/null || true" diff --git a/session-reconcile.test.mjs b/session-reconcile.test.mjs index 0d8506a..844df75 100644 --- a/session-reconcile.test.mjs +++ b/session-reconcile.test.mjs @@ -6,6 +6,16 @@ import { reconcileAgentSession, } from './session-reconcile.mjs'; +function harnessMemoryResponse(pathname) { + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return { + ok: true, + text: async () => JSON.stringify({ remembered: true, ok: true }), + }; + } + return null; +} + test('extensionConfigsMatch compares available_tools', () => { const sessionExt = { name: 'developer', available_tools: ['write', 'edit'] }; const desired = { name: 'developer', available_tools: ['write', 'edit', 'shell', 'tree'] }; @@ -77,6 +87,8 @@ test('reconcileAgentSession tolerates invalid working directory during resume sy text: async () => JSON.stringify({ ok: true }), }; } + const harness = harnessMemoryResponse(pathname); + if (harness) return harness; throw new Error(`unexpected path: ${pathname}`); }; @@ -90,6 +102,8 @@ test('reconcileAgentSession tolerates invalid working directory during resume sy '/sessions/session-1', '/agent/update_working_dir', '/sessions/session-1/extensions', + '/agent/harness_remember', + '/agent/harness_bootstrap', ]); }); @@ -118,6 +132,8 @@ test('reconcileAgentSession skips restart when extensions already match', async text: async () => JSON.stringify({ ok: true }), }; } + const harness = harnessMemoryResponse(pathname); + if (harness) return harness; throw new Error(`unexpected path: ${pathname}`); }; @@ -126,7 +142,12 @@ test('reconcileAgentSession skips restart when extensions already match', async sessionPolicy: { extensionOverrides: [{ name: 'skills', available_tools: [] }] }, }); - assert.deepEqual(calls, ['/sessions/session-1', '/sessions/session-1/extensions']); + assert.deepEqual(calls, [ + '/sessions/session-1', + '/sessions/session-1/extensions', + '/agent/harness_remember', + '/agent/harness_bootstrap', + ]); }); test('reconcileAgentSession restarts after adding missing extensions', async () => { @@ -151,6 +172,8 @@ test('reconcileAgentSession restarts after adding missing extensions', async () text: async () => JSON.stringify({ ok: true }), }; } + const harness = harnessMemoryResponse(pathname); + if (harness) return harness; throw new Error(`unexpected path: ${pathname}`); }; @@ -164,5 +187,7 @@ test('reconcileAgentSession restarts after adding missing extensions', async () '/sessions/session-1/extensions', '/agent/add_extension', '/agent/restart', + '/agent/harness_remember', + '/agent/harness_bootstrap', ]); }); diff --git a/user-memory-profile.test.mjs b/user-memory-profile.test.mjs index cda72fd..f665285 100644 --- a/user-memory-profile.test.mjs +++ b/user-memory-profile.test.mjs @@ -107,6 +107,22 @@ test('buildSessionMemoryEntries injects stored conversation memories', () => { assert.match(entries.at(-1).content, /AI 产品设计/); }); +test('buildSessionMemoryEntries injects time anchor even without memory store', () => { + const sessionPolicy = buildAgentExtensionPolicy({ + ...DEFAULT_USER_CAPABILITIES, + memory_store: false, + }); + const entries = buildSessionMemoryEntries({ + workingDir: '/tmp/unused', + sessionPolicy, + now: Date.UTC(2026, 5, 29, 5, 0, 0), + }); + assert.equal(entries.length, 1); + assert.equal(entries[0].title, 'TKMind 当前时间基准'); + assert.match(entries[0].content, /Asia\/Shanghai/); + assert.match(entries[0].content, /13:00(中午)/); +}); + test('renderCurrentTimeAnchor formats Shanghai weekday from exact date', () => { const text = renderCurrentTimeAnchor({ now: Date.UTC(2026, 5, 29, 5, 0, 0), diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 16848cf..701b630 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1658,7 +1658,6 @@ export function createWechatMpService({ tolerateInvalidWorkingDir: true, }, ); -<<<<<<< HEAD const routeHasTools = await sessionHasRequiredTools( fetchForSession, existingRoute.agentSessionId,