diff --git a/docs/incidents/2026-09-23-goose-v149-prod-log-audit.md b/docs/incidents/2026-09-23-goose-v149-prod-log-audit.md index d92edd2..6ff100a 100644 --- a/docs/incidents/2026-09-23-goose-v149-prod-log-audit.md +++ b/docs/incidents/2026-09-23-goose-v149-prod-log-audit.md @@ -48,3 +48,21 @@ ssh john@114.85.107.50 'shasum -a 256 /Users/john/Project/tkmind_go-native/relea 3. 新 goosed 二进制(从干净 commit 构建)按 cutover runbook 替换,旧二进制保留在 `releases/`。 每一步都需单独批准。 + +## 2026-09-23 后续执行记录 + +| 时间 (UTC+8) | 动作 | 结果 | +|---|---|---| +| 17:34 | Portal 快速发布 `20260923-172809-5e2f27d6`(`main` @ `5e2f27d6`) | 8081/公网验证通过;测试页 `a2fcf61f-6387-49fb-a52b-e3bfdb79131e` | +| 17:34+ | goosed 全池加固 + 滚动重启 ×2 | 9 路 `GOOSED_NOFILE=65536`;二进制 `goose-20260923-v149-m4-508df26f5` | +| 17:34+ | `CODEX_HARNESS_MYSQL` → Portal `scripts/goosed-harness-mysql2-cli.mjs` | harness remember 应恢复(RDS `SELECT 1` 已通过) | +| — | 9 实例池规模 | **维持 9 路**(用户决定暂不缩减) | +| — | `:18015` 独立金丝雀 | `GOOSE_MAX_TURNS=100`、`GOOSE_MAX_TOOL_REPETITIONS=5`(未扩到全池) | + +备份: +- `memind-full-20260923-172809-5e2f27d6-before.tar.gz` +- `memind-persisted-20260923-172809-5e2f27d6-before.tar.gz` +- `archived_source=Memind-source-before-20260923-172809-5e2f27d6` +- goosed:`backups/pre-pool-m4-*`、`backups/pre-hardening-*` + +回滚 goosed:`GOOSE_BIN=releases/goose-20260923-v149-m3-4aea5abf` + `goose-v149-pool-hardening-103.sh --rollback`(或更早 backup)。 diff --git a/mindspace-cover-meta.mjs b/mindspace-cover-meta.mjs index 0961628..647c92e 100644 --- a/mindspace-cover-meta.mjs +++ b/mindspace-cover-meta.mjs @@ -35,7 +35,8 @@ export function upsertMindspaceCoverMeta(html, coverMeta, { replace = false } = if (/]*>/i.test(source)) { return source.replace(/]*)>/i, `${metaTag}`); } - return `${metaTag}${source}`; + const body = source.replace(/^\s*]*>\s*/i, ''); + return `${metaTag}${body}`; } export function normalizeCoverMetaSuggestion(raw) { diff --git a/mindspace-cover-meta.test.mjs b/mindspace-cover-meta.test.mjs index 921ac43..1491bcc 100644 --- a/mindspace-cover-meta.test.mjs +++ b/mindspace-cover-meta.test.mjs @@ -19,3 +19,11 @@ test('upsertMindspaceCoverMeta inserts meta into head when missing', () => { assert.match(next, /name="mindspace-cover"/); assert.equal(parseMindspaceCoverMeta(next).tag, '美食'); }); + +test('upsertMindspaceCoverMeta wraps fragments without duplicating doctype', () => { + const html = 'Guarded release'; + const next = upsertMindspaceCoverMeta(html, { tag: '精选页面', subtitle: 'Guarded release' }); + assert.match(next, /^/i); + assert.doesNotMatch(next, /\s*/i); + assert.match(next, /Guarded release<\/title>/); +}); diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs index 4290424..5a878f8 100644 --- a/mindspace-public-finish-sync.test.mjs +++ b/mindspace-public-finish-sync.test.mjs @@ -186,8 +186,9 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too const result = materializeMissingPublicHtmlWrites({ messages, publishDir }); assert.deepEqual(result.materialized, ['public/summer-essay.html']); const saved = fs.readFileSync(path.join(publishDir, 'public/summer-essay.html'), 'utf8'); - assert.match(saved, /<!doctype html><title>Summer<\/title>/i); + assert.match(saved, /<title>Summer<\/title>/i); assert.match(saved, /name="mindspace-geo"/); + assert.doesNotMatch(saved, /<body>\s*<!doctype html>/i); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } @@ -420,8 +421,9 @@ test('materializeMissingPublicHtmlWrites writes html from developer write tool c const result = materializeMissingPublicHtmlWrites({ messages, publishDir }); assert.deepEqual(result.materialized, ['public/guizhou-guide.html']); const saved = fs.readFileSync(path.join(publishDir, 'public/guizhou-guide.html'), 'utf8'); - assert.match(saved, /<!doctype html><title>Guizhou<\/title>/i); + assert.match(saved, /<title>Guizhou<\/title>/i); assert.match(saved, /name="mindspace-geo"/); + assert.doesNotMatch(saved, /<body>\s*<!doctype html>/i); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } diff --git a/scripts/goose-v149-canary.mjs b/scripts/goose-v149-canary.mjs index 02ac3ae..d16bde6 100644 --- a/scripts/goose-v149-canary.mjs +++ b/scripts/goose-v149-canary.mjs @@ -99,6 +99,26 @@ const BLOCKED_HOSTS = new Set([ export const DEFAULT_GOOSE_STABLE_TARGET = 'https://127.0.0.1:18006'; export const DEFAULT_GOOSE_V149_TARGET = 'https://127.0.0.1:18049'; +export function parseConfiguredPoolTargets(env = process.env) { + const csvTargets = String(env.TKMIND_API_TARGETS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const legacyTargets = [ + env.TKMIND_API_TARGET ?? DEFAULT_GOOSE_STABLE_TARGET, + env.TKMIND_API_TARGET_1, + ].filter(Boolean); + const seen = new Set(); + const targets = []; + for (const raw of [...csvTargets, ...legacyTargets]) { + const normalized = assertLoopbackGooseTarget(raw, 'pool goosed target'); + if (seen.has(normalized)) continue; + seen.add(normalized); + targets.push(normalized); + } + return targets; +} + function csvSet(value) { return new Set( String(value ?? '') @@ -159,10 +179,12 @@ export function resolveGooseApiTargetsFromEnv(env = process.env) { const mode = parseGooseCanaryMode(env); if (mode === 'off') return null; - const stable = assertLoopbackGooseTarget( - env.TKMIND_API_TARGET_STABLE ?? DEFAULT_GOOSE_STABLE_TARGET, - 'TKMIND_API_TARGET_STABLE', - ); + const poolTargets = parseConfiguredPoolTargets(env); + const stable = poolTargets[0] + ?? assertLoopbackGooseTarget( + env.TKMIND_API_TARGET_STABLE ?? DEFAULT_GOOSE_STABLE_TARGET, + 'TKMIND_API_TARGET_STABLE', + ); const v149 = assertLoopbackGooseTarget( env.TKMIND_API_TARGET_V149 ?? DEFAULT_GOOSE_V149_TARGET, 'TKMIND_API_TARGET_V149', @@ -186,12 +208,14 @@ export function resolveGooseApiTargetsFromEnv(env = process.env) { ); } + const targets = [...new Set([...poolTargets, v149])]; + return Object.freeze({ mode, stable, v149, primary: stable, - targets: [stable, v149], + targets, policy, }); } @@ -201,8 +225,11 @@ export function resolveGooseTargetForIdentity(identity, config) { return config?.stable ?? DEFAULT_GOOSE_STABLE_TARGET; } if (config.mode === 'all') return config.v149; - const targetKey = resolveCanaryTarget(identity, config.policy); - return targetKey === 'candidate' ? config.v149 : config.stable; + if (config.mode === 'users') { + const targetKey = resolveCanaryTarget(identity, config.policy); + return targetKey === 'candidate' ? config.v149 : null; + } + return config.stable; } export function describeGooseCanaryConfig(env = process.env) { diff --git a/scripts/goose-v149-canary.test.mjs b/scripts/goose-v149-canary.test.mjs index ca24823..86ee80f 100644 --- a/scripts/goose-v149-canary.test.mjs +++ b/scripts/goose-v149-canary.test.mjs @@ -25,29 +25,34 @@ test('resolveGooseApiTargetsFromEnv all mode pins Portal to v149', () => { assert.deepEqual(config.targets, ['https://127.0.0.1:18049']); }); -test('resolveGooseApiTargetsFromEnv users mode exposes dual targets', () => { +test('resolveGooseApiTargetsFromEnv users mode keeps full pool and adds v149', () => { const config = resolveGooseApiTargetsFromEnv({ TKMIND_GOOSE_CANARY: 'users', TKMIND_GOOSE_CANARY_USER_IDS: 'user-1', + TKMIND_API_TARGETS: 'https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008', + TKMIND_API_TARGET_V149: 'https://127.0.0.1:18015', }); assert.equal(config.mode, 'users'); assert.equal(config.primary, 'https://127.0.0.1:18006'); - assert.deepEqual(config.targets, ['https://127.0.0.1:18006', 'https://127.0.0.1:18049']); + assert.deepEqual(config.targets, [ + 'https://127.0.0.1:18006', + 'https://127.0.0.1:18007', + 'https://127.0.0.1:18008', + 'https://127.0.0.1:18015', + ]); }); -test('resolveGooseTargetForIdentity routes canary users to v149', () => { +test('resolveGooseTargetForIdentity routes canary users to v149 only', () => { const config = resolveGooseApiTargetsFromEnv({ TKMIND_GOOSE_CANARY: 'users', TKMIND_GOOSE_CANARY_USER_IDS: 'user-abc', + TKMIND_API_TARGET_V149: 'https://127.0.0.1:18015', }); assert.equal( resolveGooseTargetForIdentity({ id: 'user-abc' }, config), - 'https://127.0.0.1:18049', - ); - assert.equal( - resolveGooseTargetForIdentity({ id: 'other-user' }, config), - 'https://127.0.0.1:18006', + 'https://127.0.0.1:18015', ); + assert.equal(resolveGooseTargetForIdentity({ id: 'other-user' }, config), null); }); test('assertLoopbackGooseTarget refuses production hosts', () => { diff --git a/scripts/switch-goose-v149-canary-103.sh b/scripts/switch-goose-v149-canary-103.sh index ce82a74..742f2bf 100755 --- a/scripts/switch-goose-v149-canary-103.sh +++ b/scripts/switch-goose-v149-canary-103.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # Toggle Goose v1.49 canary routing on 103 stable Portal (.env). +# users mode keeps the full native pool in TKMIND_API_TARGETS and only routes +# allowlisted identities to TKMIND_API_TARGET_V149 (:18015 by default). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" ENV_FILE="${GOOSE_V149_CANARY_ENV_FILE:-/Users/john/Project/Memind/.env}" MARKER_BEGIN='# GOOSE_V149_CANARY_BEGIN' MARKER_END='# GOOSE_V149_CANARY_END' -STABLE_TARGET="${TKMIND_API_TARGET_STABLE:-https://127.0.0.1:18006}" V149_TARGET="${TKMIND_API_TARGET_V149:-https://127.0.0.1:18015}" PORTAL_LABEL="${MEMIND_PORTAL_LABEL:-cn.tkmind.memind-portal}" GUI="gui/$(id -u)" @@ -16,12 +17,16 @@ usage() { Usage: bash scripts/switch-goose-v149-canary-103.sh <command> Commands: - on [all|users] Route Portal to v1.49 canary (${V149_TARGET}) + on [all|users] Route allowlisted users (or everyone in all mode) to v1.49 canary (${V149_TARGET}) off|rollback Remove canary block from ${ENV_FILE} status Show active block -users mode example (唐 only): - TKMIND_GOOSE_CANARY_WECHAT_USER_IDS=wx_ul610et8 bash scripts/switch-goose-v149-canary-103.sh on users +users mode example (john only): + TKMIND_GOOSE_CANARY_USERNAMES=john bash scripts/switch-goose-v149-canary-103.sh on users + +Notes: + - Does not rewrite TKMIND_API_TARGETS or memory settings. + - all mode is intended for local loopback canary only; avoid on the 9-instance 103 pool. EOF } @@ -45,22 +50,14 @@ write_canary_block() { { echo "${MARKER_BEGIN}" echo "TKMIND_GOOSE_CANARY=${mode}" - echo "TKMIND_API_TARGET_STABLE=${STABLE_TARGET}" echo "TKMIND_API_TARGET_V149=${V149_TARGET}" if [[ "${mode}" == "all" ]]; then echo "TKMIND_API_TARGET=${V149_TARGET}" elif [[ "${mode}" == "users" ]]; then - echo "TKMIND_API_TARGET=${STABLE_TARGET}" - echo "TKMIND_API_TARGETS=${STABLE_TARGET},${V149_TARGET}" [[ -n "${TKMIND_GOOSE_CANARY_USER_IDS:-}" ]] && echo "TKMIND_GOOSE_CANARY_USER_IDS=${TKMIND_GOOSE_CANARY_USER_IDS}" [[ -n "${TKMIND_GOOSE_CANARY_USERNAMES:-}" ]] && echo "TKMIND_GOOSE_CANARY_USERNAMES=${TKMIND_GOOSE_CANARY_USERNAMES}" [[ -n "${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS:-}" ]] && echo "TKMIND_GOOSE_CANARY_WECHAT_USER_IDS=${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS}" fi - echo "MEMORY_BACKEND=legacy" - echo "MEMORY_VECTOR_ENABLED=0" - echo "MEMORY_AGENT_INJECTION_MODE=off" - echo "MEMORY_LIFECYCLE_ENABLED=0" - echo "MEMORY_CANDIDATE_ENABLED=0" echo "${MARKER_END}" } >> "${ENV_FILE}" } @@ -79,6 +76,9 @@ case "${cmd}" in echo "Missing ${ENV_FILE}" >&2 exit 1 fi + if [[ "${mode}" == "all" ]]; then + echo "[goose-v149-canary-103] WARN: all mode replaces the entire pool with v149; use users mode on 103 production" >&2 + fi if [[ "${mode}" == "users" && -z "${TKMIND_GOOSE_CANARY_USER_IDS:-}${TKMIND_GOOSE_CANARY_USERNAMES:-}${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS:-}" ]]; then echo "users mode requires identity allowlist env vars" >&2 exit 2 diff --git a/scripts/verify-public-finish-sync-runtime.mjs b/scripts/verify-public-finish-sync-runtime.mjs index 17bef61..fcebedf 100644 --- a/scripts/verify-public-finish-sync-runtime.mjs +++ b/scripts/verify-public-finish-sync-runtime.mjs @@ -12,6 +12,10 @@ const root = path.resolve(new URL('..', import.meta.url).pathname); const runtimeServer = path.join(root, '.runtime', 'portal', 'server.mjs'); const currentUser = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; +const RAW_HTML = + '<!doctype html><html lang="zh-CN"><head><title>Guarded release' + + '

Guarded release

'; + const toolRequestOnlyMessages = [ { role: 'assistant', @@ -23,7 +27,7 @@ const toolRequestOnlyMessages = [ name: 'write', arguments: { path: 'public/guarded-release.html', - content: 'Guarded release', + content: RAW_HTML, }, }, }, @@ -49,10 +53,11 @@ try { publishDir, }); assert.deepEqual(result.materialized, ['public/guarded-release.html']); - assert.equal( - fs.readFileSync(path.join(publishDir, 'public/guarded-release.html'), 'utf8'), - 'Guarded release', - ); + const written = fs.readFileSync(path.join(publishDir, 'public/guarded-release.html'), 'utf8'); + assert.ok(written.includes('Guarded release')); + assert.ok(written.includes('

Guarded release

')); + assert.ok(/data-mindspace-seo-outline=|name="mindspace-geo"/i.test(written)); + assert.doesNotMatch(written, /\s*/i); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 00f8ae6..2272039 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1395,9 +1395,7 @@ export function createTkmindProxy({ }, gooseCanaryConfig, ); - if (target && (targets.includes(target) || target === gooseCanaryConfig.v149 || target === gooseCanaryConfig.stable)) { - if (await targetHealthy(target)) return target; - } + if (target && await targetHealthy(target)) return target; } return pickTarget(); }