24小时AI托管项目指南
目标:持续模拟新用户和多个长期用户,阅读真实反馈,改善体验、补齐和扩展既有功能,由专用机器人提交待审 PR,维护者决定合并与上线
阅读入口
- 1. 最终应该交付什么
- 2. 哪些照抄,哪些逐项目重配
- 3. 首次接入步骤
- 4. 每轮维护的完整流程
- 5. 新用户与多个长期用户
- 6. 真实反馈与需求判断
- 7. 沙箱逐步接近生产
- 8. 开发前同步与维护者并行开发
- 9. 专用 GitHub App 与 PR 流程
- 10. 调度、运行条件与成本
- 11. 可直接使用的长期任务提示词
- 12. 记录、过程展示、暂停与回退
- 13. 迁移与验收清单
- 附录 A. 项目配置与控制开关
- 附录 B. 运行记录、互斥与 CLI 执行器完整代码
- 附录 C. GitHub App 发布适配器完整代码
- 附录 D. 离线验证代码
- 来源与样例验证口径
1. 最终应该交付什么
“24 小时托管”是全天持续可触发的产品维护闭环,不是让模型不停空转,也不是每隔几小时扫一遍源码找 bug
检查维护者新代码、远端最新基线和现有 PR
→ 阅读已接入的真实反馈
→ 选择新用户或某个长期用户的真实任务
→ 通过界面实际使用并冻结观察记录
→ 判断:缺陷 / 体验优化 / 功能补齐或扩展 / 沙箱缺口 / 需求取舍
→ 在独立工作区实施,验证前后体验
→ 发布前再次同步、核对冲突并重验
→ 专用 App 机器人提交或更新待审 PR
→ 人工审核与合并,另行决定部署
→ 继续新用户、长期续用及真实反馈的下一轮长期保留以下规则:
- 不要求先有 bug:功能正常但难找、繁琐、容易误操作、缺少后半程,均可成为优化依据
- 允许补齐和合理扩展现有功能:较大的产品取舍、新方向或大范围交互调整也可先在隔离分支形成方案、原型或实现 Draft PR,让维护者在 PR 中批准、要求修改或否决;真实权限扩大、费用、不可逆数据与生产操作仍单独审批
- 不设每日、单轮或同时待审 PR 数量硬配额:每轮有需要就做;同一问题更新原 PR,不同主题拆开;待审 PR 不阻挡其他独立工作
- 新用户持续进入,长期用户持续积累:多位长期模拟用户各自保留数据、习惯与未完成任务,不每轮重新开荒
- 真人反馈先分析再决定:不是每条建议都实现,也不是没报错就不处理
- 维护者随时可以继续开发:AI 开发前及发布前检查最新共享代码,不覆盖维护者的现场,不把其私有开发夹带到 PR
- 提交 PR 与发布上线分开:机器人不直接推保护分支,不自动合并、部署或进行生产业务写入
一套合格交付应有:项目专属配置、稳定调度、隔离测试环境、人物与反馈台账、专用发布身份、可续接记录、验证证据、停用入口,以及首个无人值守轮次的实际验收
2. 哪些照抄,哪些逐项目重配
| 项目 | 通用部分 | 每个项目需要确认或生成的部分 |
|---|---|---|
| 维护闭环 | 先体验后定位、证据与推测分开、PR 人审 | 产品主任务、可扩展边界、验收标准 |
| 新/长期用户 | 新会话与持久化角色并存 | 角色目标、测试身份、数据目录、独立浏览器环境 |
| 沙箱 | 共用正式界面、业务规则与协议,只隔离外部副作用 | 请求边界、状态机、假数据、故障和身份模拟 |
| GitHub App | 安装身份认证、短时令牌、PR 作者回读 | App 所属方、安装账户、选定仓库、仓库 ID、机器人 ID |
| App 私钥 | 同一个 App 可在经确认的多个项目中引用同一受限密钥 | 不同 App 使用各自生成的密钥;不同执行机单独配置交付与文件权限 |
| 仓库配置 | 一个项目一个配置,令牌收窄到单仓库 | 仓库全名、默认基线、主工作区、允许的隔离工作区范围 |
| 调度 | 同项目去重、避免重叠、保留运行记录 | 当前聊天/项目、执行主机、时区、频率、模型可用性与预算 |
| 本地服务 | 回环监听、只清理本轮拥有的进程 | 端口、测试域名、进程与数据根目录;同机项目不要抢同一端口 |
| 真实反馈 | 只读采集、去重、分流、判断与追踪 | 应用内反馈库、工单、Issues、监控入口及各自读取权限 |
| 工程验证 | 前后对照、定向回归、行为复验 | 安装/启动/构建/类型/测试命令,仓库日志和审图规则 |
| 过程展示 | 当前阶段、已做、下一步、阻塞、PR | 私有展示位置、脱敏规则、是否另行建设公开回放 |
不要照搬旧项目的 App 安装范围、仓库 ID、main/master、端口、绝对路径、会话 ID、账号数据、时间戳或“已验证”结论
同一账户安装的同一个 App 可以选择多个仓库,但每个项目仍应分别申请只含本仓库的 installation token;更换账户、组织策略不同或希望隔离故障范围时,重新评估是否使用独立 App G1
3. 首次接入步骤
3.1 先确定边界,不先开定时
记录产品主要用户、维护范围、主任务、正式入口、测试入口、反馈来源、可用执行机和实际预算
明确:较大产品取舍也允许先做成可审阅的提案型 PR,不把“范围较大”默认变成先停工询问。PR 写明价值、假设、备选方案、风险、验收与回退;对真实费用、权限、数据破坏和生产执行的审批仍独立保留
读取当前项目规则及真实启动方式,不把所有项目默认成纯静态 Web,也不为已有项目换构建、状态库或 UI 框架
3.2 列出本项目的真实命令
把以下项目逐一填入项目运行说明,不直接套用另一仓库的命令:
- 依赖安装与锁文件检查
- 本地启动、测试服务启动、浏览器访问地址和端口
- 定向测试、类型检查、lint、正式构建
- 日志、截图和数据备份位置
- 开发日志、设计图与 PR 要求
3.3 建立独立维护目录
建议把签名器和运行资料放在仓库外,每项目单独命名;业务代码与非敏感回归测试仍留在仓库中
<PRIVATE_PROFILE>/
project.json 项目专属身份和路径,初始 enabled=false
control.json 人工批准、自动化和发布控制开关
maint_bot.py 本文给出的通用发布适配器
runtime.py 本文给出的互斥与记录辅助代码
test_maintenance.py 本文给出的离线验证
.venv/ 明确解释器与依赖环境
<RUNTIME_ROOT>/
state.json 当前阶段与可续接状态
personas.json 新用户策略、各长期角色档案
feedback-sources.json 读取入口、接入状态、覆盖游标
feedback-decisions.json 需求判断和处理链路
review-decisions.json 维护者批准/要求修改/否决的理由及回归映射
sandbox-gaps.json 沙箱与生产的差异和验收
runs/<UNIQUE_RUN_ID>/ 本轮目标、观察、验证、截图、结果
public/index.html 仅含经过脱敏的进度快照3.4 核验专用机器人,再允许远端写入
按第 9 节注册或复用 App,确认选定仓库,填完附录 A,运行身份检查和推送预检
个人连接器能读取仓库、返回 push=true,不等于机器人发布身份接好了;只改 Git 的显示名称也不算接入
3.5 先手动跑一次完整流程
至少完成一个新用户任务、一个长期角色初始化或续用任务、一次反馈渠道读取或明确未接入状态
发现清晰改进时完成验证并提交真实待审 PR;没发现合适改进就正常记录,不为验证身份制造空 PR
3.6 再建立调度并验收后台轮次
复制第 11 节提示词,填入本项目参数,由调度工具创建或更新同一个任务,回读核对目标聊天、频率、时区和启用状态
调度已启用、手动试跑通过、首个无人值守运行成功是三个不同状态,要分别记录
4. 每轮维护的完整流程
| 阶段 | 本轮应该产生的东西 | 常见误判 |
|---|---|---|
| 同步检查 | 主工作区状态、最新共享基线 SHA、现有 PR/维护者改动情况 | 在过时分支重复实现维护者已写好的功能 |
| 反馈读取 | 本轮增量、去重信息、源覆盖范围及缺失入口 | 一个渠道无条目就报告生产没有反馈 |
| 体验 | 用户目标、真实动作、当时可见内容、是否完成任务 | 只点到了按钮就认为目标完成 |
| 观察冻结 | 定位源码前保存的观察记录 | 看了实现答案后再补写“新用户体验” |
| 判断 | 分类、价值、范围、验收标准、优先级、需求取舍 | 把所有问题都叫 bug,或所有建议都实现 |
| 实施 | 一个独立主题的改动、相应日志与回归 | 夹带其他未审 PR 或维护者本地工作 |
| 验证 | 旧体验基线、新验收结果、命令/输入/输出/退出码、界面复验 | 构建通过就宣称用户体验或线上已修复 |
| 发布前复核 | 再次获取基线并重验受影响行为 | 拿开发开始时的验证覆盖期间新增代码 |
| PR 与交接 | 专用机器人作者、正确仓库/基线、风险和回退 | 认证失败后偷偷用个人账号代发 |
| 收尾 | 完整结果、待办、角色进度、反馈状态与本轮进程处置 | 没有新问题的轮次不留痕迹 |
缺陷要保留失败复现与修复后通过的证据;体验优化或新验收行为则比较原流程与改进后的完整任务,不强行把原本正常的行为写成故障
可以量化点击、返回、等待、丢失上下文和重复输入,但只报真实测量结果;没计时就不编造秒数,AI 模拟意见也不写成真实用户心理或市场统计
5. 新用户与多个长期用户
5.1 两条循环都要持续存在
- 新用户:每次干净的独立会话,不预置老用户的歌单、设置、登录态或“正确入口”答案
- 长期用户:稳定的模拟角色与自己的数据,接着上次使用,不每次重置成同一套演示状态
在机器和工具正常运行的滚动 24 小时内覆盖首次使用与长期续用,并持续轮换长期角色;可因重要真人反馈调整优先级,但记录缺口,不长期丢弃新用户视角
多个长期角色默认串行轮换即可,不必等同于同时开启多个 AI 或多个高费用任务
5.2 一个通用角色表
| 角色 | 长期目标 | 容易暴露的问题 |
|---|---|---|
| 高频主流程用户 | 反复完成产品最常见任务 | 重复操作、入口深、状态恢复和快捷路径 |
| 重度整理用户 | 持续新增、分类、查找与修改大量自己的内容 | 长列表、数据关系、去重、批量流程、持久化 |
| 轻量回访/键盘用户 | 隔一段时间再来,用不同输入方式继续上次任务 | 忘记上下文、键盘不可达、设置和历史丢失 |
把目标换成本产品的任务,例如记账、查资料、管理待办、播放音乐;不要只换角色名字而让三个人每次走同一条路线
每个长期角色记录:独立身份、浏览器环境、数据目录、实际首次/上次使用时间、已完成任务、未完成任务、习惯与已知卡点
同一浏览器的多个标签不等于多个隔离用户:同源 Cookie 和本地存储通常共享;采用专用 profile/context,或经过验证的独立来源与后端数据命名空间
初始化时导入的“历史内容”要标为合成数据,不虚称角色已真实使用数月;跨设备同步用同一个角色的两个隔离设备会话,跨账号则使用不同身份,二者不要混淆
若沙箱还不支持多角色或刷新持久化,先把这项列为能力缺口并改进;不要在状态页把“角色已定义”写成“长期用户已运行”
5.3 既像用户,也接受观察上的边界
环境准备阶段可以看代码接服务;正式体验阶段只按用户目标找可见入口,观察落盘后再诊断源码
长期角色可以记住自己之前学会的界面操作;新用户不继承这些使用经验。两者都不通过隐藏路由、store 或后台 API 绕过体验困难
6. 真实反馈与需求判断
6.1 逐个接入已有渠道
可选源包括应用内反馈、工单、仓库 Issues、经允许读取的支持邮件或监控记录;每个源分别记录已连接/待配置、权限、最近读取、查询窗口、分页覆盖与游标
只读项目已有入口,不为读取反馈默认新增公开接口、放宽账号权限、上传私有日志或回复真人用户
未接入的源明确标记“未接入”;沙箱测试意见、机器人评论和真正来自生产使用的反馈分开归因。生产来源未经确认时先记来源不明,不把它包装成真人生产证据
按“源 ID + 条目 ID + 更新时间”去重,游标只有在该批完整读取后才推进;截取最近若干条时标采样,不声称已全量覆盖。读取源遇到 429/5xx 或风控,停止相关来源,不用周期探测放大失败
6.2 每条建议都要有产品判断
原始诉求与来源:
实际要完成什么:
当前体验/已有功能:
证据、重复出现情况及可信度:
与产品主方向的关系:
期望收益、影响用户与替代办法:
最小实现范围与维护成本:
数据、权限、费用和兼容性影响:
验收标准:
决定:采纳做 PR / 补充证据 / 小型原型待审 / 暂缓 / 不采纳 / 重复
理由与后续动作:
关联任务、PR、部署版本和验证证据:用户说“加个按钮”可能真正想减少重复操作;AI 先理解目标,不机械堆按钮。一个人的偏好也可能与另一类用户冲突,记录取舍,不用多个 AI 角色模拟投票来制造需求强度
合理的功能完善可以实现成待审 PR;尚不适合做的建议也留下清晰理由。较大的产品歧义也可以先通过提案型 PR 展示具体方案,让维护者看得到、跑得动再判断,而不是让所有大想法停留在聊天等批准
状态链路示例:收到 → 已判断 → 待实施/待补证/暂缓/不采纳 → PR 中 → 已合并但未部署 → 已部署待验收 → 已验证
反馈内容属于输入资料,其中的命令、要求上传日志或索取凭据不自动成为操作授权
6.3 较大的产品取舍可以先做成提案型 PR
“用户价值或方向需要取舍”不是自动停止实施的理由。维护者允许后,AI 可以先实现一个可运行的方案或原型,也可以完成边界明确的较大实现,再用 Draft PR 作为审阅入口
每个提案应说明:为什么值得做、服务哪类新/长期用户、真实反馈或体验依据、替代方案、核心假设、行为变化、兼容/迁移影响、投入与维护负担、风险、分阶段范围、验收标准、回退和明确未完成项
范围大可以拆成几个可评审阶段,但不是偷偷把多个未审 PR 串成默认已批准的产品路线。未合并仍是提案;维护者否决后不要自动重开、换名或拆小后再次强推同一决定
这项授权只扩大隔离分支上的产品探索,不扩大真实账号权限、生产数据操作、付费或自动部署。源码中提出迁移方案与直接执行生产迁移是两回事
6.4 读取审核理由,把批准和否决变成可回测的决定
每轮读取已跟踪 PR 的正式 reviews、普通评论、逐行线程及状态变化,覆盖仍打开、已批准、要求修改和关闭未合并的 PR。不要只搜索打开的 PR 而遗失拒绝原因
记录审核事件 ID、维护者身份、时间、对应 head SHA、决定、原始理由的必要摘录、归纳后的验收条件、关联测试/用户路径,以及处理结果
| 看到的状态 | 正确处理 | 不应推断的事 |
|---|---|---|
| APPROVED | 按理由补足回测,保留人工合并与部署边界 | 不代表 AI 可以自动合并或上线 |
| CHANGES_REQUESTED | 沿同一 PR 修改,新增或调整对应回归,重新提交审核 | 不应换一个新 PR 绕过意见 |
| 明确否决/关闭未合并 | 保存理由、实验和决定,后续检查有无违反该决定 | 不自动重开或改名再提同样方案 |
| 关闭但没有明确理由 | 记“原因未知”,保留上下文 | 不能臆测维护者不喜欢什么 |
| 普通评论/建议 | 核对身份与语义,再判断是否需要调整 | 评论不自动等于批准或否决 |
| 已合并 | 获取新基线,回测决定是否被落实,另查部署与生产验收 | 合并不等于部署,也不等于真实用户已受益 |
将理由落实成可以检查的东西,例如“不要新增顶栏按钮”对应入口数量/键盘可达性复验,“保留旧工作流”对应新旧路径兼容回归。产品偏好与一次性的技术限制分开记录,别把一句局部意见扩成永久全局规则
旧 head 上已经解决的评论不要反复处理;对被否决方案,只有维护者的新指示或新的实质证据才重新提出,并关联旧决定。普通外部评论者与维护者决定分开,AI 不代替维护者批准、否决或擅自解决其评论线程
7. 沙箱逐步接近生产
7.1 目标是使用流程一致,不是复制所有外部系统
共用正式的页面、导航、组件树、业务状态机和请求协议,只在外部身份、网络依赖、文件/支付等副作用边界接入隔离替身
不要另造一套永远成功的页面,也不要通过生产鉴权后门让假账号混进正式服务。真实数据只使用经允许且脱敏的最小样本,优先合成数据
7.2 按实际体验补齐差异
| 维度 | 最低要覆盖的变化 | 验收依据 |
|---|---|---|
| 身份 | 未登录、普通 A/B、必要管理员 | 正式业务前置条件与跨账号隔离 |
| 搜索/发现 | 关键词相关、无结果、排序与分页 | 同一界面真实完成“找到内容”的目标 |
| 内容生命周期 | 新增、保存、重新找到、修改、撤销、重新打开 | 数据关系与跨轮持久化 |
| 主任务 | 播放/查询/编辑等产品主链路及其后半程 | 不通过隐藏 API 跳过困难 |
| 异常 | 加载、空数据、权限不足、延迟、限流/服务错误 | 正确反馈与下一步,不把失败伪装成空结果 |
| 升级与回访 | 新代码继续使用老测试数据 | 状态不无故重置,迁移问题可定位 |
差异台账逐项写生产依据、沙箱表现、差异、测试、证据、PR 与当前状态,按需补齐,不预先重造整个互联网
改沙箱也走 PR;检查正式构建未引入测试身份、假数据或调试入口。沙箱通过不等于真实网络、音质、第三方接口或原生后台/锁屏能力已通过,后者单列实机或线上验收
8. 开发前同步与维护者并行开发
8.1 “取最新代码”不等于盲目 pull 用户工作区
每轮开始、为新问题建分支前和发布 PR 前,都要检查:
- 维护者主工作区与 AI 工作区的分支、HEAD、已暂存/未暂存/未跟踪改动
- 本地已提交但尚未共享的内容,以及现有未审/已合并 PR
- fetch 后的最新远端基线 SHA,与上次观察、实施和验证时的 SHA
- 待修问题是否已经被维护者解决、改变规则或被新功能替代
主工作区只读观察;不要自动 stash、reset、clean 或替维护者切分支。维护者未提交/未推送的开发不自动复制进机器人分支或推到远端
8.2 获取最新共享基线
先把 PRIMARY、TOPIC 和 BASE 换成本项目实际路径和基线;在 Codex 桌面端优先使用其管理的工作区工具创建/复用属于本任务的隔离工作区
git -C "$PRIMARY" status --porcelain=v1
git -C "$PRIMARY" branch --show-current
git -C "$PRIMARY" rev-parse HEAD
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
fetch-base --checkout "$TOPIC"
git -C "$PRIMARY" log --oneline "origin/$BASE..HEAD"这里的 fetch 只更新远端跟踪引用,不改两个工作区的文件和当前 HEAD。初次工作区若是 detached HEAD,先在该 AI 工作区建立合规的准备分支,再 fetch;不要去主工作区建分支
新问题应从 fetch 后的 origin/$BASE 新建独立主题分支,而不是在任意旧待审分支上继续堆功能:
git -C "$TOPIC" switch -c "$NEW_TOPIC_BRANCH" "origin/$BASE"仅在确认该 AI 工作区干净、没有其他任务改动时执行这条命令;NEW_TOPIC_BRANCH 应带项目允许的 codex/ 前缀及唯一任务标识
8.3 继续旧 PR 或发现并行修改
- 先看维护者最新变更与本任务差异是否重叠;已解决就更新台账,不再复制一份同样的修复
- 用户本地正在开发相同模块时保留其现场,处理无关任务;语义冲突才请其决定,不让一个冲突冻结所有维护
- AI 自有旧分支需要吸收新基线时,按仓库允许的方式在该隔离分支审阅并合入,再做受影响回归
- 不自动 rebase 后 force push;仓库若禁止合并提交,可从新基线建新主题并只重新应用本任务补丁,关联旧 PR,保留原审阅现场
- 个人作者 PR 不由机器人接管;同一机器人问题优先更新已有 PR
附录适配器在发布前检查远端基线与本地跟踪引用一致,并检查主题已包含最新基线。一次检查仍有时间窗口,所以合并阶段继续依赖仓库 CI、必要人工评审和保护分支规则
记录 observed_base_sha、implementation_base_sha、verified_head_sha 和 pre_publish_base_sha;基线变化后重做受影响验证,不沿用旧测试结论
9. 专用 GitHub App 与 PR 流程
9.1 新建或复用
新建 App 时通常只需 Contents 读写、Pull requests 读写和默认 Metadata 只读。当前轮询式维护无需 Webhook 就先不启用;也不使用代表个人用户的 OAuth 路线来冒充机器人
安装时选择 Only select repositories。复用已有 App 时保留旧项目,仅经维护者确认追加本项目,不把共享配置的仓库字段改成新项目以致旧项目失效 G2
App ID 与 Client ID 不同;安装 ID 对应安装账户,仓库 ID 和默认基线逐仓库确认。可在 GitHub 设置与相应元数据接口取得这些值,确认后填配置,保留脱敏的核对证据
密钥由 App 所属者在设置中生成,保存到受限文件或专用签名服务。聊天、仓库和运行日志不出现私钥/令牌正文;同 App 的多个项目可引用同一受限密钥,不必每项目复制一份
9.2 身份与令牌验收
App JWT → 核对 App ID、slug 与所属方
→ 核对目标仓库的安装与“选定仓库”范围
→ 请求仅该 repository_id 的短时 installation token
→ 核对令牌仅含一个目标仓库和预期权限
→ 查询实际 viewer,确认 slug[bot] 与机器人用户 ID
→ 核对仓库 ID、默认基线
→ 推送预检,再启用本项目发布入口安装令牌可以按仓库及权限进一步收窄,有时效,Git HTTP 操作也可使用安装身份 G1。附录代码只把令牌放在本次调用内存和短时 Git AskPass 环境,不写入 remote URL 或持久化 Git 凭据,命令结束撤销;撤销未确认时关闭后续发布门槛
第一次真实修复 PR 创建后回读作者、仓库、基线、head SHA 和状态;dry-run 成功只说明相应预检,不是 PR 已创建的证据
9.3 发布边界
只提交明确列出的本主题文件;遵守仓库日志、原型审阅、格式与提交规则。认证失败保留本地补丁和草稿,不转用个人 PAT、个人 Git 登录态或连接器发 PR
不为了改 CI 自动扩大 App 权限;工作流文件、生产密钥、发布与权限变更另审。配置开关是误操作防线,不是对持有全盘权限的代理的不可绕过隔离;长期执行机应配置最小权限、保护分支/必要评审、无机器人绕过名单,并把生产凭据与普通测试任务隔开
9.4 机器人使用自己的 App 图标,个人头像保持原样
修改的是 GitHub App 的 Display information,不是维护者个人账户的 Profile picture。多个项目共用一个 App 时,机器人标识会一起变化;各项目的仓库授权与个人头像不随之变化
独立图标是需要单独提供的资源:准备本项目/团队自制或经许可使用的 PNG、JPG 或 GIF,按 GitHub 当前要求控制在 1 MB 以下,推荐 200×200,其他可接受尺寸以当前页面校验为准。不要把旧项目的本机图像路径当成另一项目已拥有的资源 G3
配置步骤:GitHub 设置 → Developer settings → GitHub Apps → 目标 App → General → Display information → Upload a logo → 选择图像 → 确认裁剪/Set new avatar;只动 App 图像,不进入个人 Profile 的头像编辑
验收时既看 App 公开页,也查询实际机器人账号的 avatarUrl。没有自定义图像时,实际 bot 的头像可能沿用宿主账号;不能仅凭 App 名称或配置页标题就说视觉隔离完成。保持登录名和机器人 ID 不变,头像仅用于识别,不代替 App 安装身份认证
如果自动上传需要 Chrome 扩展的“Allow access to file URLs”,先说明访问范围并取得单独确认;工具拦截内置设置页时,不绕过限制。维护者可直接在 GitHub 文件选择器手动选图,无需开放全局本地文件网址访问。若确实临时启用该开关,完成后由具备权限的一方恢复原状态并记录,别把“已允许”写成“已开启/已恢复”
图标、author、committer 和合并者是不同概念:
| 项目 | 应该使用什么 | 需要保留的事实 |
|---|---|---|
| App/机器人视觉标识 | 独立机器人图像 | 不编辑维护者个人头像 |
| 机器人原始提交 author/committer | 经核验的 bot 名称和 noreply 邮箱 | 不借个人身份提交,不只改显示名称冒充安装身份 |
| 维护者人工合并后生成的提交 | 实际合并方式产生的真实身份 | 页面可能显示 bot authored 与 OWNER committed,各自显示自己的头像 |
| GitHub 页面右上角登录头像 | 当前登录的维护者 | 不因设置 App 图标而更换 |
不要为了让历史里只剩机器人图标而重写人工提交、改维护者个人头像或自动合并。共享 App 改图标也会影响其已有关联的机器人展示,应提前说明影响范围
可选地把下面完整代码保存为 verify_brand.py,与本文的 maint_bot.py 和 runtime.py 放在一起;它只输出身份与头像元数据,图像是否正确仍需与公开页一起核对:
import argparse
import json
from maint_bot import Bot, load_config
parser = argparse.ArgumentParser()
parser.add_argument('--config', required=True)
args = parser.parse_args()
with Bot(load_config(args.config)) as bot:
viewer = bot.api('POST', '/graphql', {
'query': 'query { viewer { login databaseId avatarUrl } }'
})['data']['viewer']
owner = bot.api('GET', '/users/' + bot.c['app_owner'])
print(json.dumps({
'bot': viewer,
'app_owner': {'login': owner['login'], 'avatar_url': owner['avatar_url']},
'bot_avatar_distinct_from_owner': viewer['avatarUrl'] != owner['avatar_url'],
'note': 'Independent icon does not replace actor/ID/installation verification'
}, ensure_ascii=False, indent=2))"$BOT_PY" "$PROFILE/verify_brand.py" --config "$PROFILE/project.json"10. 调度、运行条件与成本
10.1 桌面端版本
已有项目聊天通常使用回到当前聊天的定时任务,便于复用人物历史、反馈台账和待审 PR;只有明确需要每轮独立聊天时才选独立任务
默认可从每 4 小时一次起步,覆盖白天与夜间;这是可配置频率,不是固定 SLA。先查已有任务再创建,优先更新,回读确认,不手改内部调度文件
本地项目任务依赖电脑开机、App 运行和项目可访问;网页端任务不直接等同于有权访问本机代码目录的执行器 O1
10.2 不依赖个人电脑的长期主机版本
使用常在线主机或受支持的云端执行环境;模型接入、浏览器能力、项目源码、测试服务与凭据交付逐一验收。个人电脑的浏览器登录、VPN、文件和已运行进程不会因“上云”自动就绪
CLI 版可把 codex exec 放进明确批准的外部执行器,并由附录 runtime.py 包裹完整进程,保证锁在整个运行期间持有;CLI 自身不是桌面任务的调度管理界面 O1
"$BOT_PY" "$PROFILE/runtime.py" --root "$RUNTIME" --timeout 1800 -- \
codex exec --sandbox workspace-write --json \
--output-last-message "$RUNTIME/last-result.txt" \
"读取本项目维护说明和持久化台账,按约定完成一轮用户体验与产品维护"在已准备好的项目隔离工作区运行。先核对本机 codex exec --help 与当前官方说明;显式配置允许访问的运行目录和发布适配器,不以关闭所有限制代替接入。实际浏览器/MCP 工具要先手动试跑验证,不把只有命令行输出的源码巡检说成用户体验
App 版与 CLI 版选定一套主执行架构即可;不要在每个 App 轮次里再启动另一个 Codex 来“实现自动化”,造成重复执行和重复计费
10.3 软预算、硬超时和费用是三回事
- 例如“每轮约 20 分钟”是计划预算,不是模型费用限额,也不是 PR 配额
- 上例
--timeout 1800是执行器真实的进程超时,按项目任务长度调整;进程组结束不等于所有外部服务都已清理 - 模型/工具价格、账户额度、外部 API、测试机和存储费用以当前渠道及实际使用为准,不虚构跨项目统一报价
- 如需严格费用熔断,接入实际计费或额度来源;尚无数据时记“未计量”,不要展示假的剩余金额
- 每项目独立锁、端口、状态目录和预算归因;示例文件锁只面向单机本地文件系统,多机/同步盘不据此宣称已实现分布式互斥
11. 可直接使用的长期任务提示词
将尖括号参数换成本项目实际值;明确批准本仓库审核分支的提交和 PR 后,再由调度工具保存这段任务,不把文档本身视为所有项目的长期写入授权
持续维护 <PROJECT_NAME>,仓库 <OWNER/REPO>,主工作区 <PRIMARY_CHECKOUT>,基线 <BASE_BRANCH>,资料目录 <RUNTIME_ROOT>,机器人入口 <BOT_COMMAND>。遵守当前项目规则
本任务不限于修 bug,也包括让现有功能更好用、补齐完整流程、合理扩展既有功能、完善沙箱,以及判断真人反馈中的需求。每轮有依据、可验收的工作都可以做成待审 PR,不设每日或待审数量硬上限;同一问题更新原 PR,不同主题分开
每轮先读取最新项目规则和持久化状态、人物档案、反馈源、需求决策、review-decisions.json、沙箱差异及已跟踪 PR,包括已批准、要求修改和关闭未合并的 PR。先只读检查维护者工作区的新改动和未推送提交,再用机器人 fetch 最新共享基线;新主题从最新基线建独立分支。不要对维护者工作区 pull、stash、reset、clean 或切分支,不把其未提交/未推送内容混入机器人 PR
只读检查已接入反馈源,对新增/更新条目去重,标注采样和未接入来源。每条建议识别真实任务、证据、受益、方向匹配、已有替代、成本、数据/权限影响和验收标准,决定采纳、补证、原型待审、暂缓、不采纳或重复,并留理由。不机械照单实现,不自动回复真人,不公开私有反馈
从新用户与多个长期模拟用户中选本轮主任务。新用户持续进入,使用干净独立会话;长期角色各自保留隔离身份、数据、历史、习惯和未完成任务,跨轮接着使用,不每次重新播种演示数据。区分新角色定义、初始化和真正跨轮运行;合成历史不冒充实际长期使用。多标签不等于用户隔离。正常运行的滚动24小时持续覆盖新用户与长期续用,记录漏测,不用模拟角色人数伪造真人需求强度
体验阶段只按用户目标操作真实界面,不先看源码找答案、不直跳隐藏路由或读 store 绕过困难。先保存动作、原文反馈、当前 DOM/截图与观察,再诊断源码。界面观察工具不一致时交叉核实,不把工具延迟误报为产品问题
缺账号时在现有隔离沙箱继续。沙箱缺身份、数据、持久化、异常或交互就补齐并提 PR;复用正式页面、业务规则和协议,只隔离外部副作用。维护沙箱差异台账,正式构建不得包含测试身份后门。真实设备、第三方结果质量与生产行为单列验收
观察正常但繁琐的体验也可优化;既有功能的合理补齐或扩展有证据和清晰验收即可实施为 PR。较大的产品取舍、新方向或大交互调整也可在隔离分支做成方案/原型/实现 Draft PR,不因“改动大”默认先停工。写明备选、假设、收益、风险、迁移/兼容性、成本、验收与回退,由维护者在 PR 中批准、要求修改或否决;真实凭据权限、不可逆数据操作、付费及生产动作仍需独立授权
在属于本任务的隔离工作区与允许前缀的主题分支修改,保护其他人的文件和暂存状态。补仓库要求的日志/设计审阅与回归,保存旧体验基线、新行为、命令/输入/输出/退出码和同一用户目标的界面复验。缺陷保留红绿复现,优化不强行伪称旧行为故障
发布前再次 fetch 并检查基线变化;先审查是否已被维护者实现或影响,必要时只在 AI 主题内吸收最新共享基线并重验。不自动 force push,不接管个人作者 PR;冲突保留现场,其他独立任务可继续。记录观察/实施基线和验证/发布 SHA,不沿用过期验证
每轮读取已跟踪 PR 的正式审核、普通评论、逐行线程和关闭/合并状态,识别维护者批准、要求修改、否决及其理由。按事件ID与head SHA去重,把理由映射成验收条件和回归用例,回测是否满足;旧意见已解决时不反复处理。否决后保留决定,不自动重开/换名再提相同方案,新的实质证据或维护者新指示才重新提案。APPROVED不代表自动合并或部署,不替维护者发审核或擅自解决其评论线程
提交和 PR 只使用经核验的 GitHub App 安装身份,令牌仅本仓库。禁止个人身份回退;作者、仓库、head 和基线回读不符则报告。仅推审核分支、创建/更新 Draft PR,不直接推保护分支,不自动合并或部署。身份或权限缺失时保留本地工作和草稿,不伪称 PR 已创建
遇到429、风控或5xx,停止相关真实上游并落盘阻断,不自动重试、不定时探测恢复。没新问题、不采纳建议或环境受阻也留本轮记录。阶段变化更新现在做什么、已完成、下一步、阻塞和PR;只报有意义变化,重复阻塞去重
机器人使用 App 自己的独立图标,不借宿主个人头像,不改变维护者个人资料。核对实际 bot avatarUrl 与 actor ID;人工合并的 committer 仍保留真实维护者身份,不为隐藏头像改写历史
只清理本轮拥有的进程和测试资料。按维护者要求暂停时停调度及本项目发布门槛,保留证据;恢复前重新核验环境、基线、身份和未完成工作,不扩大其他项目或账号权限12. 记录、过程展示、暂停与回退
12.1 每轮留下的最小证据
- 使用的是新用户、哪个长期模拟角色,还是哪条真人反馈
- 开始/结束时间、实际版本、环境与身份边界
- 用户目标、实际操作、可见文案、截图和任务结果
- 观察与技术推测、产品建议分别记录
- 需求决定及理由,未覆盖项、未连接来源与复验计划
- 基线变化、验证命令和字面结果、PR 链接及作者回读
- 进程清理、回退方式和下一步
每个真实轮次建立独立目录;每个阶段原子更新状态,保留原始观察,后续更正追加说明。锁要覆盖整个执行进程;在一个短脚本里获得锁后立即退出,再去操作浏览器,并没有锁住后续体验
12.2 让维护者看见工作,不泄露全部内部日志
当前聊天显示重要阶段;状态页展示现在、已做、下一步、阻塞和 PR。明确“落盘快照”“运行中”“最近心跳”,不让一张静态页看起来像持续直播
原始工具事件、截图可能包含用户资料、Cookie、签名地址或内部路径,保持私有;公开页仅展示审核过的摘要和图片,不直接转发完整模型/终端日志
本机预览只能服务已脱敏的 public 子目录,回环监听;不要把含配置、私钥引用和原始反馈的整个维护目录作为静态站目录
"$BOT_PY" -m http.server "$STATUS_PORT" --bind 127.0.0.1 \
--directory "$RUNTIME/public"远程访问或公开直播另行配置认证、访问范围、数据脱敏和保留期限,不顺手开公网隧道
12.3 暂停与恢复
- 通过原调度工具暂停相同任务,避免再触发
- 关闭本项目发布入口;即使 GitHub 或密钥失效,附录的
disable仍可在本地执行 - 若当前还有运行,按记录的执行器/进程组优雅中止,只处理本项目拥有的进程,不全局杀浏览器或 Node
- 保留补丁、PR、用户档案与反馈决策,不默认删除或卸载共享 App
- 恢复前核对最新共享代码、账户/工具、阻断原因、测试数据、预算和发布身份
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" disable回退发布入口不等于回退业务代码;每个 PR 另给最小回退方法。生产回退、撤销仓库授权或卸载 App 是独立操作,先核对影响,尤其不要为了停一个项目卸载多个项目共用的 App
13. 迁移与验收清单
未满足的项保留为明确状态,不用“配置好了”一句话覆盖全部验收
附录 A. 项目配置与控制开关
下列核心样例面向 GitHub.com、Python 3.11+、macOS/Linux、同仓库主题分支 PR。Windows、GitHub Enterprise、自建 Git 服务、跨仓库 fork PR、多机锁或生产部署流程需要另做适配,不直接沿用端点、文件锁或进程组实现
A.1 建立独立环境
PROJECT_KEY="<UNIQUE_PROJECT_KEY>"
PROFILE="$HOME/.config/ai-maintenance/$PROJECT_KEY"
RUNTIME="$HOME/.local/state/ai-maintenance/$PROJECT_KEY"
umask 077
mkdir -p "$PROFILE" "$RUNTIME"
python3 -m venv "$PROFILE/.venv"
BOT_PY="$PROFILE/.venv/bin/python"
"$BOT_PY" -m pip install requests 'PyJWT[crypto]'
"$BOT_PY" -m pip freeze > "$PROFILE/requirements.lock"依赖首次解析后先验证,再用生成的锁定清单在其他主机安装;不要把本文测试版本号当作永久安全版本。调用始终用明确的 BOT_PY,避免切项目后 shell 自动换解释器而缺库
A.2 保存为 project.json
所有 0 都替换为真实正整数;尖括号路径与标识替换为当前项目值。初始 enabled=false,避免复制后误发
{
"project_key": "<UNIQUE_PROJECT_KEY>",
"repository": "<OWNER>/<REPOSITORY>",
"repository_id": 0,
"base_branch": "<VERIFIED_DEFAULT_BRANCH>",
"branch_prefix": "codex/maintenance-",
"app_id": 0,
"app_slug": "<APP_SLUG>",
"app_owner": "<APP_OWNER>",
"installation_id": 0,
"expected_bot_id": 0,
"private_key_path": "<ABS_PRIVATE_KEY_PATH>",
"control_path": "<ABS_PROFILE_PATH>/control.json",
"primary_checkout": "<ABS_PRIMARY_CHECKOUT>",
"worktree_parent": "<ABS_APPROVED_WORKTREE_PARENT>",
"log_paths": ["<PROJECT_DEVELOPMENT_LOG_PATH_OR_PREFIX>"],
"enabled": false
}app_owner 与仓库拥有者不必相同;安装账户与目标仓库所属账户应对应。默认基线逐仓库核对;若组织明确使用非默认分支或特殊作者规则,应先调整适配器检查和测试,而非跳过失败
A.3 保存为 control.json
{
"automation_enabled": true,
"publication_approved": false,
"allow_personal_identity_fallback": false,
"auto_merge": false,
"auto_deploy": false,
"publication_hold": null
}维护者明确允许本项目机器人提交审核分支和 PR 后,将 publication_approved 设为 true;这不批准生产写入或扩大 App 授权。出现发布阻断后,先核对原因再由维护者决定清除,不要每轮自动清空 hold
A.4 人物与反馈台账最小形状
保存为 personas.json,先定义后初始化,实际跑过才填时间和证据:
{
"new_users": {"recurring": true, "fresh_session": true},
"long_term_users": [
{"id": "long-primary", "goal": "<REPEATED_CORE_TASK>", "status": "defined_not_initialized", "storage_profile": null, "actual_history": []},
{"id": "long-organizer", "goal": "<ORGANIZE_AND_FIND_AGAIN>", "status": "defined_not_initialized", "storage_profile": null, "actual_history": []}
]
}保存为 feedback-sources.json;先配置/读取,后声称接入:
{
"sources": [
{"id": "repository-issues", "type": "github_issues", "repository": "<OWNER>/<REPOSITORY>", "access": "read_only", "status": "configured_not_verified", "cursor": null},
{"id": "application-feedback", "type": "project_feedback_store", "endpoint": null, "status": "not_connected", "cursor": null}
]
}保存为 review-decisions.json,事件实际读到后再填,不编造批准或拒绝理由:
{
"maintainer_logins": ["<VERIFIED_MAINTAINER_LOGIN>"],
"tracked_prs": [],
"events": [],
"decisions": [],
"regression_links": [],
"policy": {"read_closed_unmerged_prs": true, "reopen_rejected_automatically": false}
}每个事件至少包含来源链接、事件 ID、作者、时间、对应 head SHA、决定/理由、归纳后的验收条件及关联测试或用户路径;多个来源分页和游标分别保留
A.5 初始化、预检与日常命令
从后面代码块分别保存三个 .py 文件到 PROFILE,先运行离线测试,再配置真实 App
"$BOT_PY" "$PROFILE/test_maintenance.py"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" verify
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
fetch-base --checkout "$TOPIC"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
preflight --checkout "$TOPIC"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" enableenable 会再次认证并检查控制文件中的人工批准。preflight 只进行 Git dry-run;也先审查仓库已有 hooks,因为 hooks 自身可能执行其他命令
改动完成后,只暂存属于本主题的明确文件,按仓库规则补日志,再提交:
git -C "$TOPIC" add -- "$CHANGED_FILE" "$DEVELOPMENT_LOG"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
commit --checkout "$TOPIC" --message "fix(<SCOPE>): <中文现象或结果>" \
--files "$CHANGED_FILE" "$DEVELOPMENT_LOG"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
fetch-base --checkout "$TOPIC"
"$BOT_PY" "$PROFILE/maint_bot.py" --config "$PROFILE/project.json" \
publish --checkout "$TOPIC" --title "<PR_TITLE>" --body-file "$PR_BODY_FILE"每个变量先替换为本项目实际值;中间包含新基线时应先按第 8 节吸收并重验,应先完成这一步再 publish。适配器只负责可验证的发布控制,不负责替代产品判断、界面操作、工程命令或日志审阅
附录 B. runtime.py 完整代码
运行记录与公共快照的调用需要放在实际拥有本轮锁的执行器中。公共摘要须先脱敏;HTML 转义不等于隐私脱敏。示例执行器只结束自己创建的进程组,浏览器或独立服务另按本轮拥有关系清理
import argparse
import fcntl
import html
import json
import os
from pathlib import Path
import tempfile
import signal
import subprocess
from contextlib import contextmanager
from datetime import datetime, timezone
def atomic_json(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, name = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=2)
file.write('\n')
file.flush()
os.fsync(file.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
@contextmanager
def run_lease(runtime_root):
root = Path(runtime_root)
root.mkdir(parents=True, exist_ok=True, mode=0o700)
fd = os.open(root / 'run.lock', os.O_CREAT | os.O_RDWR, 0o600)
with os.fdopen(fd, 'r+') as file:
try:
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise RuntimeError('another run owns this project') from error
try:
yield
finally:
fcntl.flock(file, fcntl.LOCK_UN)
def save_status(runtime_root, stage, goal, done, next_action):
root = Path(runtime_root)
path = root / 'state.json'
state = json.loads(path.read_text()) if path.exists() else {}
state.update(stage=stage, goal=goal, done=done, next=next_action,
updated_at=datetime.now(timezone.utc).isoformat())
atomic_json(path, state)
return state
def public_snapshot(runtime_root, reviewed_summary):
fields = ('stage', 'goal', 'done', 'next', 'updated_at')
body = ''.join('<dt>' + html.escape(key) + '</dt><dd>' +
html.escape(str(reviewed_summary.get(key, ''))) + '</dd>'
for key in fields)
public = Path(runtime_root) / 'public'
public.mkdir(parents=True, exist_ok=True)
page = ('<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>维护进度快照</title><h1>维护进度快照</h1>'
'<p>这是最近一次落盘记录,不是模型持续工作的证明</p><dl>' + body + '</dl></html>')
fd, name = tempfile.mkstemp(prefix='.index-', dir=public)
with os.fdopen(fd, 'w', encoding='utf-8') as file:
file.write(page)
os.replace(name, public / 'index.html')
def hold_origin(runtime_root, origin, reason):
path = Path(runtime_root) / 'state.json'
state = json.loads(path.read_text()) if path.exists() else {}
state.setdefault('held_origins', {})[origin] = {
'reason': reason, 'resume': 'explicit_user_decision',
'at': datetime.now(timezone.utc).isoformat(),
}
atomic_json(path, state)
def run_command(root, command, timeout):
root = Path(root)
with run_lease(root):
run_id = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
run = root / 'runs' / run_id
run.mkdir(parents=True, mode=0o700)
save_status(root, 'running', '执行已配置的维护任务', [], '等待任务结果')
timed_out, interrupted = False, False
def cancel(signum, frame):
raise InterruptedError('run interrupted')
previous = {signum: signal.getsignal(signum) for signum in (signal.SIGINT, signal.SIGTERM)}
for signum in previous:
signal.signal(signum, cancel)
process = None
try:
with (run / 'events.jsonl').open('wb') as out, (run / 'stderr.log').open('wb') as err:
try:
process = subprocess.Popen(command, stdout=out, stderr=err, start_new_session=True)
exit_code = process.wait(timeout=timeout)
except OSError as error:
if isinstance(error, InterruptedError):
interrupted = True
exit_code = 130
else:
err.write(('startup failed: ' + type(error).__name__).encode())
exit_code = 127
except subprocess.TimeoutExpired:
timed_out = True
exit_code = 124
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
if process is not None and process.poll() is None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
atomic_json(run / 'execution.json', {'run_id': run_id, 'command': command,
'exit_status': exit_code, 'timed_out': timed_out, 'interrupted': interrupted})
save_status(root, 'interrupted' if timed_out or interrupted else 'process_exited',
'维护执行器已退出,体验结果另见本轮报告', [run_id],
'核对报告、PR 与验收证据;进程退出码不等于任务成功')
return exit_code
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--root', required=True)
parser.add_argument('--timeout', type=int, default=1800)
parser.add_argument('command', nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ['--'] else args.command
if not command or args.timeout <= 0:
parser.error('provide a command and a positive hard timeout')
os.umask(0o077)
raise SystemExit(run_command(args.root, command, args.timeout))附录 C. maint_bot.py 完整代码
此代码不依赖某个旧项目的 Python 文件、配置路径或 App ID。只支持固定的 GitHub.com API,不开放任意地址代理;HTTP 错误不重试,发布身份异常不回退个人凭据
所有本地 Git 工作区和 hooks 仍应属于受控执行环境;凭据服务与不可信构建任务的强隔离需由执行主机落实,不把一份 Python 脚本说成完整安全沙箱
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlencode
import jwt
import requests
from requests.adapters import HTTPAdapter
from runtime import atomic_json
class BotError(RuntimeError):
pass
def load_config(path):
config = json.loads(Path(path).read_text())
for name in ('app_id', 'installation_id', 'repository_id', 'expected_bot_id'):
if not isinstance(config.get(name), int) or isinstance(config[name], bool) or config[name] <= 0:
raise BotError('fill positive integer: ' + name)
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', config.get('repository', '')):
raise BotError('fill repository as OWNER/REPO')
for name in ('private_key_path', 'control_path', 'primary_checkout', 'worktree_parent'):
value = config.get(name, '')
if '<' in value or not Path(value).expanduser().is_absolute():
raise BotError('fill absolute path: ' + name)
if not config.get('base_branch') or not config.get('branch_prefix', '').startswith('codex/'):
raise BotError('fill base branch and codex/ topic prefix')
return config
class Bot:
def __init__(self, config):
self.c = config
self.token = None
self.secrets = []
self.http = requests.Session()
self.http.trust_env = True
self.http.mount('https://', HTTPAdapter(max_retries=0))
self.repo = config['repository']
self.base = config['base_branch']
def __enter__(self):
try:
self.authenticate()
return self
except Exception:
self.close()
raise
def __exit__(self, *_):
self.close()
def api(self, method, route, body=None, app=False):
if not route.startswith('/') or route.startswith('//'):
raise BotError('GitHub API relative path required')
auth = self.app_jwt if app else self.token
try:
response = self.http.request(
method, 'https://api.github.com' + route, json=body,
headers={'Authorization': 'Bearer ' + auth,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10'},
timeout=(10, 30), allow_redirects=False,
)
except requests.RequestException as error:
raise BotError('GitHub transport: ' + type(error).__name__) from None
if not 200 <= response.status_code < 300:
raise BotError('GitHub HTTP ' + str(response.status_code) + ' ' + route)
return response.json() if response.content else {}
def authenticate(self):
c = self.c
key = Path(c['private_key_path']).expanduser()
if key.is_symlink() or key.stat().st_uid != os.getuid() or key.stat().st_mode & 0o077:
raise BotError('private key must be owned by this user and mode 0600')
now = int(time.time())
self.app_jwt = jwt.encode({'iat': now - 60, 'exp': now + 540,
'iss': str(c['app_id'])}, key.read_bytes(), algorithm='RS256')
self.secrets.append(self.app_jwt)
app = self.api('GET', '/app', app=True)
if (app['id'], app['slug'], app['owner']['login']) != (c['app_id'], c['app_slug'], c['app_owner']):
raise BotError('wrong App identity')
install = self.api('GET', '/repos/' + self.repo + '/installation', app=True)
if (install['id'] != c['installation_id'] or install['repository_selection'] != 'selected'
or install['account']['login'] != self.repo.split('/')[0]):
raise BotError('wrong installation or repository selection')
token = self.api('POST', '/app/installations/' + str(c['installation_id']) + '/access_tokens',
{'repository_ids': [c['repository_id']],
'permissions': {'contents': 'write', 'pull_requests': 'write'}}, app=True)
self.token = token['token']
self.secrets.append(self.token)
self.permissions = token['permissions']
if (self.permissions.get('contents') != 'write' or self.permissions.get('pull_requests') != 'write'
or set(self.permissions) - {'contents', 'pull_requests', 'metadata'}):
raise BotError('token permissions differ from this profile')
repos = self.api('GET', '/installation/repositories?per_page=100')
if repos['total_count'] != 1 or [(r['id'], r['full_name']) for r in repos['repositories']] != [(c['repository_id'], self.repo)]:
raise BotError('token is not restricted to this one repository')
viewer = self.api('POST', '/graphql', {'query': 'query { viewer { login databaseId } }'})
if viewer.get('errors'):
raise BotError('viewer verification failed')
actor = viewer['data']['viewer']
if (actor['login'], actor['databaseId']) != (c['app_slug'] + '[bot]', c['expected_bot_id']):
raise BotError('writer is not the expected bot')
self.actor = actor['login']
self.email = str(actor['databaseId']) + '+' + self.actor + '@users.noreply.github.com'
repository = self.api('GET', '/repos/' + self.repo)
if repository['id'] != c['repository_id'] or repository['default_branch'] != self.base:
raise BotError('repository ID or reviewed base branch changed')
def gate(self, require_enabled=True):
policy = json.loads(Path(self.c['control_path']).expanduser().read_text())
valid = (policy.get('automation_enabled') is True
and policy.get('publication_approved') is True
and policy.get('allow_personal_identity_fallback') is False
and policy.get('auto_merge') is False and policy.get('auto_deploy') is False
and not policy.get('publication_hold'))
if not valid or (require_enabled and self.c.get('enabled') is not True):
raise BotError('publication gate is closed')
def git(self, root, args):
with tempfile.TemporaryDirectory(prefix='bot-askpass-') as directory:
ask = Path(directory) / 'askpass.py'
ask.write_text('''#!/usr/bin/env python3
import os, re, sys
from urllib.parse import urlparse
prompt = sys.argv[1]
match = re.search(r"https://[^'\\s]+", prompt)
if not match:
raise SystemExit(1)
url = urlparse(match.group())
if (url.hostname != 'github.com' or url.port not in (None, 443)
or url.path != ''' + repr('/' + self.repo + '.git') + ''' or url.query or url.fragment):
raise SystemExit(1)
if 'Username' in prompt:
print('x-access-token')
elif 'Password' in prompt and url.username == 'x-access-token':
print(os.environ['AI_MAINT_TOKEN'])
else:
raise SystemExit(1)
''')
ask.chmod(0o700)
env = {k: v for k, v in os.environ.items()
if not k.startswith(('GH_', 'GITHUB_', 'GIT_CONFIG_', 'GIT_TRACE', 'GIT_AUTHOR_', 'GIT_COMMITTER_'))}
env.update(GIT_ASKPASS=str(ask), GIT_TERMINAL_PROMPT='0', AI_MAINT_TOKEN=self.token,
GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1', LC_ALL='C')
command = ['git', '-c', 'credential.helper=', '-c', 'http.extraHeader=',
'-c', 'credential.useHttpPath=true', *args]
result = subprocess.run(command, cwd=root, env=env, text=True, capture_output=True)
output = result.stdout + result.stderr
for secret in self.secrets:
output = output.replace(secret, '[REDACTED]')
if result.returncode:
raise BotError('Git exit ' + str(result.returncode) + ': ' + output[:1200])
return output.strip()
def checkout(self, value):
root = Path(value).resolve()
parent = Path(self.c['worktree_parent']).expanduser().resolve()
primary = Path(self.c['primary_checkout']).expanduser().resolve()
if root == primary or not root.is_relative_to(parent):
raise BotError('use an isolated checkout inside the configured parent')
if Path(self.git(root, ['rev-parse', '--show-toplevel'])).resolve() != root:
raise BotError('pass the checkout root')
origin = self.git(root, ['remote', 'get-url', 'origin']).removesuffix('.git')
if origin not in ('https://github.com/' + self.repo, 'git@github.com:' + self.repo):
raise BotError('checkout repository does not match the profile')
branch = self.git(root, ['branch', '--show-current'])
if branch == self.base or not branch.startswith(self.c['branch_prefix']):
raise BotError('use a reviewed topic branch')
return root, branch
def fetch_base(self, args):
root, branch = self.checkout(args.checkout)
head = self.git(root, ['rev-parse', 'HEAD'])
self.git(root, ['fetch', '--no-tags', 'https://github.com/' + self.repo + '.git',
'refs/heads/' + self.base + ':refs/remotes/origin/' + self.base])
sha = self.git(root, ['rev-parse', 'refs/remotes/origin/' + self.base])
current = self.api('GET', '/repos/' + self.repo + '/git/ref/heads/' + self.base)['object']['sha']
if sha != current or self.git(root, ['rev-parse', 'HEAD']) != head:
raise BotError('base or checkout changed during fetch; inspect again')
return {'base': self.base, 'base_sha': sha, 'head_unchanged': True, 'branch': branch}
def commit(self, args):
self.gate()
root, branch = self.checkout(args.checkout)
staged = set(self.git(root, ['diff', '--cached', '--name-only', '-z']).split('\0')) - {''}
if not staged or staged != set(args.files):
raise BotError('staged paths must exactly match the explicit file list')
for name in staged:
path = (root / name).resolve()
if not path.is_relative_to(root) or path.suffix.lower() in ('.pem', '.key') or path.name.startswith('.env'):
raise BotError('sensitive or external staged path')
if name.startswith('.github/workflows/'):
raise BotError('workflow changes require separate permission review')
if path.is_file() and (path.stat().st_size > 10_000_000 or b'PRIVATE KEY-----' in path.read_bytes()):
raise BotError('review large files or private key content separately')
if self.c.get('log_paths') and not any(any(name == p or name.startswith(p.rstrip('/') + '/')
for p in self.c['log_paths']) for name in staged):
raise BotError('include the project-required development log')
if not re.match(r'^(feat|fix|docs|refactor|perf|style|test|build|ci|chore|revert)(\([^)]+\))?: .+', args.message):
raise BotError('use a reviewed Conventional Commit subject')
self.git(root, ['diff', '--cached', '--check'])
self.git(root, ['-c', 'user.name=' + self.actor, '-c', 'user.email=' + self.email,
'commit', '-m', args.message])
return {'actor': self.actor, 'branch': branch, 'sha': self.git(root, ['rev-parse', 'HEAD'])}
def publish(self, args):
self.gate()
root, branch = self.checkout(args.checkout)
if self.git(root, ['status', '--porcelain=v1']):
raise BotError('finish verification and commit only this issue before publishing')
commits = self.git(root, ['log', 'origin/' + self.base + '..HEAD', '--format=%an%x09%ae%x09%cn%x09%ce']).splitlines()
expected = '\t'.join([self.actor, self.email, self.actor, self.email])
if not commits or any(line != expected for line in commits):
raise BotError('topic commits must belong to the verified bot')
body = Path(args.body_file).read_text()
if 'PRIVATE KEY-----' in body or any(s in body for s in self.secrets):
raise BotError('sensitive PR body')
query = urlencode({'state': 'all', 'head': self.repo.split('/')[0] + ':' + branch,
'base': self.base, 'per_page': 100})
old = self.api('GET', '/repos/' + self.repo + '/pulls?' + query)
if len(old) >= 100 or any(p['state'] != 'open' or p['user']['login'] != self.actor for p in old):
raise BotError('closed or personal PR branch: keep it unchanged and choose another topic')
if len(old) > 1:
raise BotError('ambiguous existing PR')
latest = self.api('GET', '/repos/' + self.repo + '/git/ref/heads/' + self.base)['object']['sha']
if self.git(root, ['rev-parse', 'refs/remotes/origin/' + self.base]) != latest:
raise BotError('fetch latest base, inspect changes and reverify before publication')
self.git(root, ['merge-base', '--is-ancestor', latest, 'HEAD'])
url = 'https://github.com/' + self.repo + '.git'
self.git(root, ['push', url, 'HEAD:refs/heads/' + branch])
sha = self.git(root, ['rev-parse', 'HEAD'])
route = '/repos/' + self.repo + '/pulls'
if old:
pr = self.api('PATCH', route + '/' + str(old[0]['number']), {'title': args.title, 'body': body})
else:
pr = self.api('POST', route, {'base': self.base, 'head': branch,
'title': args.title, 'body': body, 'draft': True})
pr = self.api('GET', route + '/' + str(pr['number']))
if (pr['user']['login'] != self.actor or pr['base']['ref'] != self.base
or pr['base']['repo']['full_name'] != self.repo or pr['head']['sha'] != sha
or pr['head']['ref'] != branch or pr['head']['repo']['full_name'] != self.repo
or pr['state'] != 'open'):
raise BotError('PR author, repository, head or base read-back differs')
return {'url': pr['html_url'], 'actor': pr['user']['login'], 'base': self.base,
'head_sha': sha, 'draft': pr['draft']}
def close(self):
if self.token:
try:
self.api('DELETE', '/installation/token')
except BotError:
path = Path(self.c['control_path']).expanduser()
policy = json.loads(path.read_text())
policy['publication_hold'] = 'token revocation unconfirmed; review expiry before resuming'
atomic_json(path, policy)
print('warning: token revocation unconfirmed; stop publication and review expiry', file=sys.stderr)
self.token = None
self.http.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--config', required=True, type=Path)
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('verify')
fetch = commands.add_parser('fetch-base')
fetch.add_argument('--checkout', required=True)
preflight = commands.add_parser('preflight')
preflight.add_argument('--checkout', required=True)
commands.add_parser('enable')
commands.add_parser('disable')
commit = commands.add_parser('commit')
commit.add_argument('--checkout', required=True)
commit.add_argument('--message', required=True)
commit.add_argument('--files', nargs='+', required=True)
publish = commands.add_parser('publish')
publish.add_argument('--checkout', required=True)
publish.add_argument('--title', required=True)
publish.add_argument('--body-file', required=True)
args = parser.parse_args()
config = load_config(args.config)
if args.command == 'disable':
config['enabled'] = False
atomic_json(args.config, config)
print(json.dumps({'publication_enabled': False}))
return
with Bot(config) as bot:
if args.command == 'verify':
result = {'verified': True, 'actor': bot.actor, 'repository': bot.repo,
'base': bot.base, 'permissions': bot.permissions, 'personal_fallback': False}
elif args.command == 'fetch-base':
result = bot.fetch_base(args)
elif args.command == 'preflight':
bot.gate(require_enabled=False)
root, branch = bot.checkout(args.checkout)
output = bot.git(root, ['push', '--dry-run', 'https://github.com/' + bot.repo + '.git',
'HEAD:refs/heads/' + branch])
result = {'actor': bot.actor, 'repository': bot.repo, 'dry_run': True, 'output': output}
elif args.command == 'enable':
bot.gate(require_enabled=False)
config['enabled'] = True
atomic_json(args.config, config)
result = {'publication_enabled': True, 'actor': bot.actor, 'repository': bot.repo}
elif args.command == 'commit':
result = bot.commit(args)
else:
result = bot.publish(args)
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
try:
main()
except BotError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1)附录 D. test_maintenance.py 离线验证代码
与前两个文件放在同一目录,用配置环境的 Python 运行。测试使用虚构仓库、伪造响应和临时目录,不调用真实 GitHub,不创建 PR,不消耗模型调用
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch, Mock
import maint_bot as m
import runtime as r
class MaintenanceTests(unittest.TestCase):
def config(self, root):
return {'app_id': 2, 'installation_id': 3, 'repository_id': 11,
'expected_bot_id': 13, 'repository': 'acme/demo',
'app_slug': 'maintain-agent', 'app_owner': 'devteam',
'base_branch': 'stable', 'branch_prefix': 'codex/maintenance-',
'private_key_path': str(root / 'key.pem'), 'control_path': str(root / 'control.json'),
'primary_checkout': str(root / 'primary'), 'worktree_parent': str(root / 'worktrees'),
'enabled': True, 'log_paths': []}
def bot(self, root):
bot = m.Bot(self.config(root))
bot.actor = 'maintain-agent[bot]'
bot.email = '13+maintain-agent[bot]@users.noreply.github.com'
bot.token = 'fixture-installation-token'
bot.secrets = [bot.token]
return bot
def policy(self):
return {'automation_enabled': True, 'publication_approved': True,
'allow_personal_identity_fallback': False, 'auto_merge': False, 'auto_deploy': False}
def authenticate(self, root, mode='ok'):
c = self.config(root)
key = root / 'key.pem'; key.write_text('fixture only'); key.chmod(0o600)
calls = []
def api(bot, method, route, body=None, app=False):
calls.append((method, route, body))
if route == '/app': return {'id': 2, 'slug': 'maintain-agent', 'owner': {'login': 'devteam'}}
if route.endswith('/installation'):
return {'id': 3, 'repository_selection': 'all' if mode == 'all' else 'selected', 'account': {'login': 'acme'}}
if route.endswith('/access_tokens'):
self.assertEqual(body, {'repository_ids': [11], 'permissions': {'contents': 'write', 'pull_requests': 'write'}})
perms = {'contents': 'write', 'pull_requests': 'write', 'metadata': 'read'}
if mode == 'permissions': perms['administration'] = 'write'
return {'token': 'fixture-installation-token', 'permissions': perms}
if route.startswith('/installation/repositories'):
return {'total_count': 2 if mode == 'scope' else 1, 'repositories': [{'id': 11, 'full_name': 'acme/demo'}]}
if route == '/graphql':
return {'data': {'viewer': {'login': 'a-person' if mode == 'actor' else 'maintain-agent[bot]', 'databaseId': 13}}}
if route == '/repos/acme/demo': return {'id': 11, 'default_branch': 'main' if mode == 'base' else 'stable'}
if route == '/installation/token': return {}
raise AssertionError(route)
with patch.object(m.Bot, 'api', api), patch.object(m.jwt, 'encode', return_value='fixture-jwt'):
if mode == 'ok':
with m.Bot(c) as bot: self.assertEqual(bot.actor, 'maintain-agent[bot]')
else:
with self.assertRaises(m.BotError):
with m.Bot(c): pass
return calls
def test_authentication_and_single_repository_scope(self):
with tempfile.TemporaryDirectory() as d:
self.assertEqual(self.authenticate(Path(d))[-1][:2], ('DELETE', '/installation/token'))
def test_wrong_scope_permissions_actor_base_revoke_token(self):
for mode in ('scope', 'permissions', 'actor', 'base'):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d:
self.assertEqual(self.authenticate(Path(d), mode)[-1][:2], ('DELETE', '/installation/token'))
def test_all_repositories_installation_is_rejected(self):
with tempfile.TemporaryDirectory() as d:
self.assertFalse(any(route.endswith('/access_tokens') for _, route, _ in self.authenticate(Path(d), 'all')))
def test_gate_accepts_only_explicit_approval_and_closed_dangerous_actions(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); bot = self.bot(root); p = root / 'control.json'
p.write_text(json.dumps(self.policy())); bot.gate()
for key in self.policy():
policy = self.policy(); policy[key] = not policy[key]; p.write_text(json.dumps(policy))
with self.subTest(key=key), self.assertRaises(m.BotError): bot.gate()
p.write_text(json.dumps(self.policy())); bot.c['enabled'] = False
with self.assertRaises(m.BotError): bot.gate()
def test_http_failure_has_no_retry_or_redirect(self):
with tempfile.TemporaryDirectory() as d:
bot = self.bot(Path(d)); bot.http.request = Mock(return_value=Mock(status_code=503))
with self.assertRaisesRegex(m.BotError, 'HTTP 503'): bot.api('GET', '/repos/acme/demo')
self.assertEqual(bot.http.request.call_count, 1)
self.assertFalse(bot.http.request.call_args.kwargs['allow_redirects'])
def test_git_auth_rejects_other_hosts_and_repositories(self):
original_run = subprocess.run
with tempfile.TemporaryDirectory() as d:
bot = self.bot(Path(d))
def run(command, **kwargs):
self.assertIn('credential.helper=', command)
env = kwargs['env']; self.assertNotIn('GH_TOKEN', env); self.assertNotIn('GITHUB_TOKEN', env)
for url, expected in [('https://x-access-token@github.com/acme/demo.git', 0),
('https://x-access-token@github.com.evil.invalid/acme/demo.git', 1),
('https://x-access-token@github.com/acme/other.git', 1)]:
result = original_run([sys.executable, env['GIT_ASKPASS'], "Password for '" + url + "': "], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, expected)
self.assertEqual(result.stdout, bot.token + '\n' if expected == 0 else '')
return subprocess.CompletedProcess(command, 0, '', '')
with patch.object(m.subprocess, 'run', side_effect=run), patch.dict(os.environ, {'GH_TOKEN': 'personal-fixture'}):
bot.git(Path(d), ['status', '--porcelain=v1'])
def test_primary_and_wrong_origin_are_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); bot = self.bot(root)
with self.assertRaises(m.BotError): bot.checkout(root / 'primary')
topic = root / 'worktrees' / 'topic'; topic.mkdir(parents=True)
bot.git = Mock(side_effect=[str(topic), 'https://github.com/acme/wrong.git'])
with self.assertRaises(m.BotError): bot.checkout(topic)
def publish(self, root, existing=None, readback_actor=None):
bot = self.bot(root); bot.gate = lambda: None
bot.checkout = lambda value: (root, 'codex/maintenance-case')
branch, sha = 'codex/maintenance-case', 'a' * 40
def git(path, args):
if args[:2] == ['status', '--porcelain=v1']: return ''
if args[:1] == ['log']: return '\t'.join([bot.actor, bot.email] * 2)
if args[:1] == ['push']: return ''
if args == ['rev-parse', 'refs/remotes/origin/stable']: return sha
if args == ['merge-base', '--is-ancestor', sha, 'HEAD']: return ''
if args == ['rev-parse', 'HEAD']: return sha
raise AssertionError(args)
writes = []
def api(method, route, body=None, app=False):
if route == '/repos/acme/demo/git/ref/heads/stable': return {'object': {'sha': sha}}
if '?' in route:
self.assertIn('base=stable', route); return existing or []
writes.append((method, body))
if method in ('POST', 'PATCH'):
if method == 'POST': self.assertEqual(body['base'], 'stable'); self.assertTrue(body['draft'])
return {'number': 7}
return {'number': 7, 'user': {'login': readback_actor or bot.actor}, 'state': 'open',
'base': {'ref': 'stable', 'repo': {'full_name': 'acme/demo'}},
'head': {'ref': branch, 'sha': sha, 'repo': {'full_name': 'acme/demo'}},
'html_url': 'https://github.com/acme/demo/pull/7', 'draft': True}
bot.git, bot.api = git, api
body = root / 'body.md'; body.write_text('verified fixture')
args = type('Args', (), {'checkout': str(root), 'body_file': str(body), 'title': 'fix: example'})()
return bot, args, writes
def test_pr_uses_configured_base_and_verified_author(self):
with tempfile.TemporaryDirectory() as d:
bot, args, _ = self.publish(Path(d)); result = bot.publish(args)
self.assertEqual(result['actor'], bot.actor); self.assertEqual(result['base'], 'stable')
def test_personal_pr_is_not_modified(self):
with tempfile.TemporaryDirectory() as d:
bot, args, writes = self.publish(Path(d), [{'user': {'login': 'a-person'}, 'state': 'open'}])
with self.assertRaises(m.BotError): bot.publish(args)
self.assertEqual(writes, [])
def test_pr_wrong_author_readback_fails(self):
with tempfile.TemporaryDirectory() as d:
bot, args, _ = self.publish(Path(d), readback_actor='a-person')
with self.assertRaises(m.BotError): bot.publish(args)
def test_fetch_keeps_head_and_only_moves_remote_tracking_base(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); bot = self.bot(root)
bot.checkout = lambda value: (root, 'codex/maintenance-case')
calls = []
def git(path, args):
calls.append(args)
if args == ['rev-parse', 'HEAD']: return 'topic'
if args == ['rev-parse', 'refs/remotes/origin/stable']: return 'latest'
if args[:1] == ['fetch']: return ''
raise AssertionError(args)
bot.git = git; bot.api = lambda *a, **kw: {'object': {'sha': 'latest'}}
result = bot.fetch_base(type('Args', (), {'checkout': str(root)})())
self.assertTrue(result['head_unchanged'])
self.assertFalse(any('pull' in command or 'reset' in command for command in calls))
def test_new_remote_base_blocks_stale_publication(self):
with tempfile.TemporaryDirectory() as d:
bot, args, _ = self.publish(Path(d))
original = bot.git
bot.git = lambda root, command: 'older' if command == ['rev-parse', 'refs/remotes/origin/stable'] else original(root, command)
with self.assertRaisesRegex(m.BotError, 'fetch latest base'): bot.publish(args)
def test_failed_token_revocation_closes_future_publication_gate(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); bot = self.bot(root)
(root / 'control.json').write_text(json.dumps(self.policy()))
bot.api = Mock(side_effect=m.BotError('HTTP 503'))
bot.close()
self.assertTrue(json.loads((root / 'control.json').read_text())['publication_hold'])
with self.assertRaises(m.BotError): bot.gate()
def test_disable_works_without_github_or_private_key(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); p = root / 'project.json'; r.atomic_json(p, self.config(root))
with patch.object(sys, 'argv', ['maint_bot.py', '--config', str(p), 'disable']), patch.object(m.Bot, 'authenticate', side_effect=AssertionError('network')):
m.main()
self.assertFalse(json.loads(p.read_text())['enabled'])
def test_atomic_status_preserves_holds_and_escapes_public_html(self):
with tempfile.TemporaryDirectory() as d:
r.hold_origin(d, 'https://upstream.invalid', 'HTTP 429')
state = r.save_status(d, 'observe', '<script>private</script>', [], 'next')
self.assertIn('https://upstream.invalid', state['held_origins'])
r.public_snapshot(d, state)
page = (Path(d) / 'public/index.html').read_text()
self.assertNotIn('<script>', page); self.assertIn('<script>', page)
self.assertFalse((Path(d) / 'public/control.json').exists())
def test_concurrent_run_is_rejected_and_lock_is_released(self):
with tempfile.TemporaryDirectory() as d:
with r.run_lease(d):
with self.assertRaises(RuntimeError):
with r.run_lease(d): pass
with r.run_lease(d): pass
def test_runner_records_exit_failure_and_timeout(self):
for command, timeout, expected in [([sys.executable, '-c', 'raise SystemExit(7)'], 5, 7),
([sys.executable, '-c', 'import time;time.sleep(5)'], 0.05, 124),
(['/missing-maintenance-command'], 5, 127)]:
with self.subTest(expected=expected), tempfile.TemporaryDirectory() as d:
self.assertEqual(r.run_command(d, command, timeout), expected)
file = next((Path(d) / 'runs').glob('*/execution.json'))
self.assertEqual(json.loads(file.read_text())['exit_status'], expected)
if __name__ == '__main__':
unittest.main(verbosity=2)来源与样例验证口径
本指南的产品维护流程来自实际接入中的通用化设计;以下官方资料用于核对产品与认证机制,能力变化时应重新确认:
- O1:OpenAI Docs — Scheduled tasks
- O2:OpenAI Docs — Non-interactive mode
- G1:GitHub Docs — Authenticating as a GitHub App installation
- G2:GitHub Docs — Reviewing and modifying installed GitHub Apps
- G3:GitHub Docs — Creating a custom badge for your GitHub App
本文通用样例以 Python 3.12.4、requests 2.32.5、PyJWT 2.10.1 做离线测试,覆盖认证范围、身份、基线、个人 PR 保护、凭据目标限制、失败阻断、互斥、状态保存、执行超时和本地停用等控制逻辑
离线样例通过不代表新项目真实机器人、UI、长期角色、生产反馈或无人值守运行已验收;每个迁移项目仍应完成第 13 节清单
