首页
/ GStack /office-hours SESSION_COUNT 恒为 0 的根因与修复:对齐 developer-profile.json 的读/写两侧(Fix 1671)

GStack /office-hours SESSION_COUNT 恒为 0 的根因与修复:对齐 developer-profile.json 的读/写两侧(Fix 1671)

2026-09-05 12:16:29作者:何将鹤

本文基于 GStack 仓库中的设计文档 FIX_1671_PROFILE_MIGRATION.md,完整复盘 /office-hours 技能长期报告 SESSION_COUNT: 0 的存储层缺陷:v1.0.0.0 迁移把读取路径切到新文件,却遗留了写入旧文件的写入端,导致回访用户分层(welcome_back)永远不可达。你将看到完整的根因链路、三处修复改动的源码级实现(新增 --log-session 子命令、do_readmode:"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_ASSIGNMENTCROSS_PROJECT,输出 "Welcome back. Last time you were working on [LAST_ASSIGNMENT]...",并明确"No pitch this time";
  • 该分层的判定源头是 bin/gstack-developer-profiledo_read 的分层逻辑:SESSION_COUNT >= 8inner_circle>= 4regular>= 1welcome_back,否则为 introduction

缺陷的表现是:无论用户实际运行过多少次 /office-hours,每次调用都报告 SESSION_COUNT: 0TIER: introductionwelcome_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 追加记录。整个因果链如下:

  1. 全新用户的家目录里没有任何 profile 文件;
  2. /office-hours 前言阶段调用 gstack-developer-profile --read,触发 bin/gstack-developer-profileensure_profile:发现两个文件都不存在,便创建一个 sessions: [] 的空 developer-profile.json 桩文件;
  3. 会话结束时,写入端执行 echo '{...}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl",把会话记录写进了读取端永远不会再读的旧文件;
  4. 下一次 --read 时,developer-profile.json 里的 sessions 依然是空的,于是 SESSION_COUNT: 0TIER: 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 会话记录的统一写入入口,行为链路如下:

  1. 静默跳过(silent-skip)验证:输入必须是可解析 JSON 且包含必填字段 datemode,否则直接 return 0 静默返回,绝不阻塞技能主流程。这一模式刻意对齐 bin/gstack-timeline-log 的既有验证范式(gstack-timeline-logskill/event 字段的处理);
  2. 时间戳注入:条目缺少 ts 字段时自动注入 new Date().toISOString();用户已提供的 ts 则原样保留(见 do_log_session 的 bun 内联脚本);
  3. 读改写聚合:读取现有 developer-profile.json,把条目追加进 sessions[],同时更新三个聚合字段——signals_accumulated 按信号字符串逐个自增(与 do_migrateL71-L75 的口径一致),resources_showntopicsSet 做去重并集;
  4. 原子写:写入 mktemp 临时文件后 mv 覆盖,临时文件注册在 trap ... EXIT 中清理,与二进制内既有的原子写模式(do_migrateL56-L105)一致;
  5. 入队同步:写完后调用 gstack-brain-enqueue "developer-profile.json"(后台 & 执行),为已启用 GBrain 同步的用户把变更排入跨机器同步队列,镜像 bin/gstack-timeline-log 的既有做法。

子命令在 dispatch 分支 中注册,帮助文本说明其为 /office-hours 服务:必填 datemode,非法输入静默跳过。

改动二: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_PROJECTDESIGN_*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-hoursdeveloper-profile.json 存在读-改-写竞态,但代码库对 gstack-config(YAML 的 r-m-w)接受同等竞态,属既有取舍、非本修复引入,划出范围;
  • 不升 schemaschema_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) 块复现缺陷形态:

  1. 临时 $HOMEGSTACK_HOME 指向 mkdtemp 目录)下先跑 --read——旧代码上此处即创建空桩并报告 SESSION_COUNT: 0 / TIER: introduction(这正是 bug 形态);
  2. --log-session 提交一条 startup 模式 JSON;
  3. 再次 --read,断言 SESSION_COUNT: 1TIER: welcome_backLAST_PROJECT: testTOTAL_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_DIRSnode_modulestestdocs、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.tsbin/gstack-artifacts-init 以及若干文档文件;
  • 另设两条直接断言:office-hours/SKILL.mdoffice-hours/SKILL.md.tmpl 必须包含 gstack-developer-profile --log-session,且不得再匹配 echo '...' >> ...builder-profile.jsonl 的旧模式。

测试注释坦诚声明了边界:正则只捕获字面路径写入,变量间接写入(FILE=...; echo >> "$FILE")检测不到,因此以 SKILL.md 精确断言作为主防线、正则作兜底。这条不变量专门拦截"未来贡献者在某个新技能的 .tmpl 里把遗留写入带回来"这一整类回归。

验收标准与上线

设计文档给出的验收标准与上线方式:

  • 验收:全新 $HOME 下第二次 /office-hours 调用返回 TIER: welcome_backbun 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 界定了本修复之后的工作边界:

  1. 彻底弃用 builder-profile.jsonl:在再过一个发布周期后移除写入端 + shim + memory-ingest 中的对应类型;
  2. RC2:autoplan 内联子技能,绕过其 timeline-log 前言,需单独修复;
  3. RC3:为多 agent 身份的重度用户增加 GSTACK_PROFILE_SCOPE 开关;
  4. 两个与 #1671 根因无关的既有问题:/plan-tune 目前不调用 --deriveinferred/gap 会漂移;mode:"resources" 条目在既有分层聚合器口径下仍会虚增 SESSION_COUNT(本次 do_read 已在输出侧过滤,但写入侧簿记机制本身是独立议题)。

小结:从 #1671 可以复用的四个工程模式

这次修复体量很小(一个新子命令、一个过滤器、两处写入点替换),但它示范了几个在 agent 技能仓库这类"shell + 模板 + 状态文件"体系中反复适用的模式:

  1. 迁移必须同时切换读写两侧——只搬读路径的存储迁移必然制造"读者永远读到空文件"的静默失效,且由于行为退化温和(用户只会觉得技能"不认识我"),潜伏期可以很长;
  2. 静默跳过优于硬失败——写入端被技能流程以 2>/dev/null || true 调用,验证失败必须 exit 0 而非报错,避免簿记逻辑打断会话主流程,这与 gstack-timeline-log 的既有契约一致;
  3. 原子写 + 明确声明已接受的竞态——mktemp + mv 防半写文件,并发 r-m-w 竞态则显式登记为与 gstack-config 同级的既有取舍,而不是假装不存在;
  4. 用静态不变量测试锁死契约——行为测试防"改坏了",而 static-no-legacy-writes.test.ts 这类全仓扫描防"改回去了",两者缺一不可。
登录后查看全文
热门项目推荐
相关项目推荐