GStack /office-hours SESSION_COUNT 恒为 0 的根因与修复:对齐 developer-profile.json 的读/写两侧(Fix 1671)
本文基于 GStack 仓库中的设计文档 FIX_1671_PROFILE_MIGRATION.md,完整复盘 /office-hours 技能长期报告 SESSION_COUNT: 0 的存储层缺陷:v1.0.0.0 迁移把读取路径切到新文件,却遗留了写入旧文件的写入端,导致回访用户分层(welcome_back)永远不可达。你将看到完整的根因链路、三处修复改动的源码级实现(新增 --log-session 子命令、do_read 的 mode:"resources" 过滤、技能模板中的写入端替换)、四组确定性测试的设计,以及一组刻意不做的事(不加锁、不升 schema、不做自动对账)背后的工程取舍。
问题现象:回访分层被永久锁死在 introduction
/office-hours 是 GStack 中模拟 Y Combinator "office hours" 的技能,它会依据用户的会话历史输出不同的开场行为:新用户(introduction)需要走完整的介绍与 YC 推荐话术;而回访用户(welcome_back 及更高层级)则跳过推荐话术,直接进入"上次你在做什么"的跟进。这段行为定义在 office-hours/sections/design-and-handoff.md 中:
TIER = welcome_back(第 2–3 次会话)时,技能读取LAST_ASSIGNMENT和CROSS_PROJECT,输出 "Welcome back. Last time you were working on [LAST_ASSIGNMENT]...",并明确"No pitch this time";- 该分层的判定源头是 bin/gstack-developer-profile 中
do_read的分层逻辑:SESSION_COUNT >= 8为inner_circle,>= 4为regular,>= 1为welcome_back,否则为introduction。
缺陷的表现是:无论用户实际运行过多少次 /office-hours,每次调用都报告 SESSION_COUNT: 0 和 TIER: introduction,welcome_back 分支因此不可达。设计文档标注该问题自 v1.0.0.0(引入它的原始 PR 为 garrytan/gstack#1039,commit 0a803f9,2026-04-18)起在线上存活了约 5 周,影响所有"全新 $HOME"用户。
根因:读侧与写侧的存储文件分叉
v1.0.0.0 的迁移把读路径搬到了 ~/.gstack/developer-profile.json,但 office-hours/SKILL.md.tmpl 中的写入端仍然向旧文件 ~/.gstack/builder-profile.jsonl 追加记录。整个因果链如下:
- 全新用户的家目录里没有任何 profile 文件;
/office-hours前言阶段调用gstack-developer-profile --read,触发 bin/gstack-developer-profile 的ensure_profile:发现两个文件都不存在,便创建一个sessions: []的空developer-profile.json桩文件;- 会话结束时,写入端执行
echo '{...}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl",把会话记录写进了读取端永远不会再读的旧文件; - 下一次
--read时,developer-profile.json里的sessions依然是空的,于是SESSION_COUNT: 0、TIER: introduction无限循环。
用设计文档的原话概括:"Reader and writer disagree on storage."(读端和写端对存储位置不一致。)这是一类典型的存储迁移半程失败:只迁移了读端,写端被留在了旧路径上。
顺带说明隔离机制:bin/gstack-developer-profile 中状态根目录解析为 GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}",GSTACK_STATE_ROOT 优先级最高,这是测试环境隔离(对应文档 D16 约定)的基础,后文的回归测试正是依赖它。
修复方案:让写入端使用与读端相同的文件
修复的核心原则只有一句:"Make the writer use the same file the reader does." 具体包含三处改动。
改动一:为 gstack-developer-profile 新增 --log-session 子命令
bin/gstack-developer-profile 新增了 do_log_session 函数,作为 /office-hours 会话记录的统一写入入口,行为链路如下:
- 静默跳过(silent-skip)验证:输入必须是可解析 JSON 且包含必填字段
date和mode,否则直接return 0静默返回,绝不阻塞技能主流程。这一模式刻意对齐 bin/gstack-timeline-log 的既有验证范式(gstack-timeline-log对skill/event字段的处理); - 时间戳注入:条目缺少
ts字段时自动注入new Date().toISOString();用户已提供的ts则原样保留(见 do_log_session 的 bun 内联脚本); - 读改写聚合:读取现有
developer-profile.json,把条目追加进sessions[],同时更新三个聚合字段——signals_accumulated按信号字符串逐个自增(与do_migrate中 L71-L75 的口径一致),resources_shown和topics以Set做去重并集; - 原子写:写入
mktemp临时文件后mv覆盖,临时文件注册在trap ... EXIT中清理,与二进制内既有的原子写模式(do_migrate的 L56-L105)一致; - 入队同步:写完后调用
gstack-brain-enqueue "developer-profile.json"(后台&执行),为已启用 GBrain 同步的用户把变更排入跨机器同步队列,镜像 bin/gstack-timeline-log 的既有做法。
子命令在 dispatch 分支 中注册,帮助文本说明其为 /office-hours 服务:必填 date、mode,非法输入静默跳过。
改动二:do_read 过滤 mode:"resources" 记录
/office-hours 的 Phase 6 会在每次真实会话结束后再自动追加一条 mode:"resources" 的簿记记录(记录本次展示过的创始人资源链接)。如果读取端不过滤它,这条紧随其后的簿记条目会顶掉真实会话,污染下一次会话看到的 LAST_PROJECT / LAST_ASSIGNMENT / LAST_DESIGN_TITLE,虚增 SESSION_COUNT(进而抬升 TIER),甚至仅靠簿记就触发 builder→founder 的 NUDGE_ELIGIBLE。
这个潜伏 bug 在写入端损坏期间被掩盖(反正 sessions 恒空),修复写入端后必须一并激活处理。实现位于 do_read:
# SESSION_COUNT / TIER / CROSS_PROJECT / NUDGE 必须反映真实会话,
# 而不是资源簿记事件(Phase 6 自动追加)。
const realSessions = sessions.filter(e => e.mode !== 'resources');
所有基于"最近会话"的派生量(LAST_*、CROSS_PROJECT、DESIGN_*、NUDGE_ELIGIBLE 的 builder 会话计数)都改从 realSessions 计算;而 RESOURCES_SHOWN 等聚合量仍从 profile 顶层的 resources_shown 字段读取,两者互不干扰。
改动三:替换 office-hours 技能中的写入端
office-hours/SKILL.md.tmpl 中两处写入点从旧式裸 echo 追加:
# 修复前
echo '{...}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl"
替换为调用新子命令:
# 修复后(见 office-hours/SKILL.md.tmpl 会话记录点)
~/.claude/skills/gstack/bin/gstack-developer-profile --log-session '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' 2>/dev/null || true
生成的 office-hours/SKILL.md 与 Phase 6 资源簿记所在的 office-hours/sections/design-and-handoff.md 均通过 bun run gen:skill-docs 从 .tmpl 重新生成,保证产物与模板一致(这也是验收标准之一)。
刻意不做的事:修复边界的取舍
设计文档用单独一节明确了六项"不在本次修复内"的决策,这些边界声明本身是工程决策的重要信息:
- 不新增二进制:
developer-profile.json的 owner 就是gstack-developer-profile,写入端作为其子命令加入,与既有--migrate/--derive写侧子命令并列,而不是塞进gstack-*-log事件写入器家族; - 不加 mkdir 锁:并发调用
/office-hours时developer-profile.json存在读-改-写竞态,但代码库对gstack-config(YAML 的 r-m-w)接受同等竞态,属既有取舍、非本修复引入,划出范围; - 不升 schema:
schema_version保持1,修复只是让写入端开始使用既有 schema; - 不为存量用户自动对账:已经滞留在
builder-profile.jsonl的历史记录不会被自动合并进developer-profile.json;用户下次运行时新会话进入welcome_back,旧数据留在旧文件中(在弃用期内其他工具仍可读取)。多数受影响用户只有寥寥数条滞留记录,损失主要是观感上的。文档明确说明放弃了"仅一版生效的对账通道",理由是净噪声、不符合"right-sized diff"原则; - 不含 RC2(autoplan timeline rollup)与 RC3(项目级 scope 开关):各自独立 PR 跟进;
- 不改 gbrain glob:office-hours 的 manifest 仍会 glob
~/.gstack/builder-profile.jsonl作为上下文来源;新写入停落该文件后快照会逐渐"变冷",若成为 UX 问题再跟进。
测试:四组全部为门级、免费、确定性测试
回归测试:读-写-读序列
test/gstack-developer-profile.test.ts 中的 --log-session (#1671 fix) 块复现缺陷形态:
- 临时
$HOME(GSTACK_HOME指向mkdtemp目录)下先跑--read——旧代码上此处即创建空桩并报告SESSION_COUNT: 0/TIER: introduction(这正是 bug 形态); - 以
--log-session提交一条 startup 模式 JSON; - 再次
--read,断言SESSION_COUNT: 1、TIER: welcome_back、LAST_PROJECT: test、TOTAL_SIGNAL_COUNT: 2。
该测试在未修复的 main 上必然失败(子命令不存在),修复后通过——标准回归测试形态。
do_read 过滤与聚合断言
同文件的 L529-L697 覆盖了两类断言:
- LAST_ 选取*:先记录一条 startup 会话,再追加一条
mode:"resources"簿记,--read必须返回真实会话的LAST_ASSIGNMENT/LAST_DESIGN_TITLE,同时RESOURCES_SHOWN仍正确聚合; - 计数与分层边界:resources 簿记不得把
SESSION_COUNT顶过分层阈值(如 3 次真实会话 + 4 条 resources 记录仍应为welcome_back而非regular;7 次真实 + 6 条簿记仍是regular,第 8 次真实会话才升inner_circle);NUDGE_ELIGIBLE的双门槛(≥3 次 builder 真实会话 且 ≥5 个累计信号)在边界两侧(2 次 builder、或 3 次但仅 4 信号)均有针对>=误改成>的哨兵测试;CROSS_PROJECT也验证了尾部异项目的 resources 记录不会翻转结果。
验证/聚合测试还确认:非法 JSON 或缺必填字段时静默跳过且不创建桩文件、缺失 ts 自动注入、用户 ts 被保留、多会话间 signals_accumulated 按 {a:2,b:1,c:1} 精确累加、resources_shown/topics 为去重并集。
静态 grep 不变量:防止未来回归到旧文件
test/static-no-legacy-writes.test.ts 是本次修复新增的仓库级不变量测试,把"生产代码不得再写 builder-profile.jsonl"固化为持续约束:
- 遍历所有技能目录(跳过 SKIP_DIRS:
node_modules、test、docs、vendored 目录等),用正则 WRITE_PATTERN 匹配形如>> ...builder-profile.jsonl/writeFileSync(...)/appendFileSync(...)的字面路径写入; - 白名单 ALLOWED_FILES 只放行读取方与文档:
bin/gstack-developer-profile(迁移/对账路径)、遗留 shim bin/gstack-builder-profile(它只是exec gstack-developer-profile --read "$@"的委托)、bin/gstack-memory-ingest.ts、bin/gstack-artifacts-init以及若干文档文件; - 另设两条直接断言:office-hours/SKILL.md 与 office-hours/SKILL.md.tmpl 必须包含
gstack-developer-profile --log-session,且不得再匹配echo '...' >> ...builder-profile.jsonl的旧模式。
测试注释坦诚声明了边界:正则只捕获字面路径写入,变量间接写入(FILE=...; echo >> "$FILE")检测不到,因此以 SKILL.md 精确断言作为主防线、正则作兜底。这条不变量专门拦截"未来贡献者在某个新技能的 .tmpl 里把遗留写入带回来"这一整类回归。
验收标准与上线
设计文档给出的验收标准与上线方式:
- 验收:全新
$HOME下第二次/office-hours调用返回TIER: welcome_back;bun test在触碰文件上隔离通过;bun run gen:skill-docs产生的 diff 与.tmpl编辑完全一致; - 上线:单 commit,按 CHANGELOG 风格指南做 PATCH 级版本递增;CHANGELOG 条目由
/ship编写,用户视角的措辞以"现在你能体验到什么"开头(第二次访问即进入welcome_back分层)。
该条目已实际落库,见 CHANGELOG.md 中的修复记录:/office-hours SESSION_COUNT stuck at 0 since v1.0,读写文件分叉,读端首次调用自动迁移既有遗留数据,附 33 个回归测试与静态 grep 不变量,Closes #1671、#1677。
遗留事项:修复后的后续清单
文档末尾列出的 follow-up TODO 界定了本修复之后的工作边界:
- 彻底弃用
builder-profile.jsonl:在再过一个发布周期后移除写入端 + shim + memory-ingest 中的对应类型; - RC2:autoplan 内联子技能,绕过其 timeline-log 前言,需单独修复;
- RC3:为多 agent 身份的重度用户增加
GSTACK_PROFILE_SCOPE开关; - 两个与 #1671 根因无关的既有问题:
/plan-tune目前不调用--derive,inferred/gap会漂移;mode:"resources"条目在既有分层聚合器口径下仍会虚增SESSION_COUNT(本次do_read已在输出侧过滤,但写入侧簿记机制本身是独立议题)。
小结:从 #1671 可以复用的四个工程模式
这次修复体量很小(一个新子命令、一个过滤器、两处写入点替换),但它示范了几个在 agent 技能仓库这类"shell + 模板 + 状态文件"体系中反复适用的模式:
- 迁移必须同时切换读写两侧——只搬读路径的存储迁移必然制造"读者永远读到空文件"的静默失效,且由于行为退化温和(用户只会觉得技能"不认识我"),潜伏期可以很长;
- 静默跳过优于硬失败——写入端被技能流程以
2>/dev/null || true调用,验证失败必须exit 0而非报错,避免簿记逻辑打断会话主流程,这与gstack-timeline-log的既有契约一致; - 原子写 + 明确声明已接受的竞态——
mktemp+mv防半写文件,并发 r-m-w 竞态则显式登记为与gstack-config同级的既有取舍,而不是假装不存在; - 用静态不变量测试锁死契约——行为测试防"改坏了",而 static-no-legacy-writes.test.ts 这类全仓扫描防"改回去了",两者缺一不可。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00