merge: candidate nightly-advance-land-2026-09-28 into dev
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@
|
||||
| Cursor | promote-rc-beta6-closeout-2026-09-28 | feature/promote-rc-beta6-closeout-2026-09-28 | .worktrees/promote-rc-beta6-closeout-2026-09-28 | git:test+env:test | done |
|
||||
| Cursor | quality-gate-heading-deploy-2026-09-28 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | done |
|
||||
| Cursor | quality-gate-heading-mismatch-2026-09-28-r3 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | done |
|
||||
| Cursor | agent-reply-llm-narrative-2026-09-28 | feature/agent-reply-llm-narrative-2026-09-28 | .worktrees/agent-reply-llm-narrative-2026-09-28 | chat+coordinator | in_progress |
|
||||
| Cursor | nightly-advance-automation-2026-09-28 | feature/nightly-advance-automation-2026-09-28 | .worktrees/nightly-advance-automation-2026-09-28 | nightly-advance | done || Cursor | agent-reply-llm-narrative-2026-09-28 | feature/agent-reply-llm-narrative-2026-09-28 | .worktrees/agent-reply-llm-narrative-2026-09-28 | chat+coordinator | in_progress |
|
||||
| Cursor | test-user-doc-issues-2026-09-28 | fix/test-user-doc-issues-2026-09-28 | .worktrees/test-user-doc-issues-2026-09-28 | intent-write-verb | done |
|
||||
| Cursor | pi-runner-env-mismatch-2026-09-28 | fix/pi-runner-env-mismatch-2026-09-28 | .worktrees/pi-runner-env-mismatch-2026-09-28 | pi-runner-env | done |
|
||||
| Cursor | error-events-pg-roundtrip-2026-09-28 | feature/error-events-pg-roundtrip-2026-09-28 | .worktrees/error-events-pg-roundtrip-2026-09-28 | error-events-pg-evidence | done |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 夜间自动推进任务(nightly-advance-automation-2026-09-28)
|
||||
|
||||
- **status**: in_progress
|
||||
- **owner**: Cursor
|
||||
- **mutex**: nightly-advance
|
||||
- **base_dev_sha**: 650703c5c28d3f4c9fb51ba9022499524a91fe01
|
||||
- **write_scope**: scripts/nightly/、scripts/nightly-advance.sh、scripts/tests/test_nightly_advance.py、.doc/ops/short-term/2026-09-28-nightly-advance-automation.md
|
||||
- **authorization_ref**: 用户 2026-09-28 会话("每天晚上 3 点 10 分,推进项目.worktree 目录下的所有分支任务……"及后续清理语义修正);Agents Window 不可用后用户同意本地 cron 替代方案
|
||||
|
||||
## 用户目标
|
||||
|
||||
每日 03:10 自动推进 `.worktrees/` 下所有活跃分支任务至彻底完成,合入 dev,验证无内容丢失后删除对应任务目录,更新 dev 服务至最新,产出每日夜间自动化报告。
|
||||
|
||||
## 用户裁决(AskQuestion 记录)
|
||||
|
||||
1. **推进范围**:所有活跃任务(不分 owner;Cursor/pi/Codex/Cline)。
|
||||
2. **清理语义**(用户后续修正):任务彻底完成、合入 dev、删除目录不丢内容且不影响系统 → 删除目录与分支;删除被拒时保留 cleanup=pending 并报告原因,禁止 `--force`/`-D`。
|
||||
|
||||
## 方案
|
||||
|
||||
- `scripts/nightly/plan.py`:读 cleanup report + CURRENT + registry,按依赖排序(integrate 候选 → 活跃任务 → 可清理项)。
|
||||
- `scripts/nightly/advance.py`:编排器。预检(daemon 健康、互斥锁、冻结 dev tip)→ 计划 → cursor-agent 无头推进(写走 `agentctl with`)→ daemon ff-only 合入 → dev tip 变化时 `env:dev` 租约 rebuild + `/health` `/ready` → 清理验证+普通删除 → 报告草稿落 run 目录。
|
||||
- `scripts/nightly-advance.sh`:cron 入口(PATH 注入,日志 `.tmp/nightly/entry-*.log`)。
|
||||
- cron:`10 3 * * *`(用户级 crontab,与现有 jev-service watchdog 同模式)。
|
||||
- run 目录:`.tmp/nightly/<stamp>/`(gitignored;`data/` root 属主不可写)。
|
||||
- 每日报告:夜间最后一棒 cursor-agent 日报任务把 run 目录草稿正式化并归档 `.doc/test-reports/`(agentctl 租约提交),按 test-reports-standard 命名 `YYYY-MM-DD-nightly-advance-test-report.md`。
|
||||
|
||||
## 硬边界
|
||||
|
||||
- 写操作一律 `agentctl with`;裸 git commit 被 pre-commit hook 拒绝。
|
||||
- 合入只走 `validate` + `queue enqueue`(daemon `--ff-only`)。
|
||||
- dev 服务更新走 `with --resource env:dev` + `env-service.sh dev rebuild`;不碰 all/test/main。
|
||||
- 清理只用 `agent-worktree.sh remove --delete-branch`(普通删除);拒绝即保留 pending。
|
||||
- 不 push(用户未要求);报告真实验证,无 stub 冒充。
|
||||
|
||||
## 验证
|
||||
|
||||
- 单元:`python3 -B -m unittest discover -s scripts/tests -p 'test_nightly_advance.py'`(9 用例:CURRENT 解析、排序、phase 分类、registry 回退、CLI cap、自排除)。
|
||||
- 真实链路:dry 模式(--skip-agent)端到端:预检锁、plan 13 项、清理拦截未合入分支、报告草稿落盘(run 20260928T063326Z)。
|
||||
- 无头执行器冒烟:cursor-agent --print ask 模式回 `NIGHTLY-OK`;写探测 `PROBE-WRITE-OK`。
|
||||
- 本报告任务自身从 plan 排除(防递归)。
|
||||
|
||||
## 五态(收口时更新)
|
||||
|
||||
- review_state: pending
|
||||
- integration_state: not_started
|
||||
- deployment: not_required(工程工具变更,无需重建产品容器)
|
||||
- cleanup: pending(本任务自身 worktree 由独立维护者复查后清理)
|
||||
- remote_sync: not_requested
|
||||
@@ -0,0 +1,114 @@
|
||||
# 夜间推进自动化 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
每日 03:10 的设计目标是无人值守推进 `.worktrees/` 下的分支任务、经治理门禁合入 `dev`、在 tip 变化时更新 dev 服务,并在确认无内容丢失后清理已收口目录。本报告归档 run `20260928T064350Z` 的真实执行结果。该次运行不是满队列夜间清扫:编排进程为 `python3 -B scripts/nightly/advance.py --repo /home/weichen/projects/Agent-System --max-tasks 1 --task-timeout 300`(与 run 目录时间戳同时启动)。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 计划构建能列出当夜待处理条目(`plan.json`)。
|
||||
- 在 `--max-tasks 1` 上限内,对第一条非清理任务执行无头推进,并留下 result、耗时与日志。
|
||||
- `dev` tip 未变化时不重建服务(`env.json`)。
|
||||
- 清理阶段对计划中的 `cleanup_verify` 条目做祖先与干净检查;不满足则保留目录。
|
||||
- 报告引用 run 目录数据,不把未执行项记为通过。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- 操作系统:Linux。
|
||||
- 主仓:`/home/weichen/projects/Agent-System`(分支 `dev`)。
|
||||
- 报告工作区:`/home/weichen/projects/Agent-System/.worktrees/nightly-advance-automation-2026-09-28`。
|
||||
- 设计触发:cron `10 3 * * *` → `scripts/nightly-advance.sh`(任务文档约定)。本次 run 的实际入口是上面的 `advance.py --max-tasks 1 --task-timeout 300`。
|
||||
- 执行器:cursor-agent 无头模式;agentctl daemon。`state.json` 记录 `daemon=ok`。
|
||||
- run 目录:`/home/weichen/projects/Agent-System/.tmp/nightly/20260928T064350Z`。
|
||||
- 证据(相对主仓):
|
||||
- `.tmp/nightly/20260928T064350Z/state.json`
|
||||
- `.tmp/nightly/20260928T064350Z/plan.json`
|
||||
- `.tmp/nightly/20260928T064350Z/advance.json`
|
||||
- `.tmp/nightly/20260928T064350Z/env.json`
|
||||
- `.tmp/nightly/20260928T064350Z/cleanup.json`
|
||||
- `.tmp/nightly/20260928T064350Z/agent-00-integrate-agent-reply-llm-narrative-2026-09-28.log`
|
||||
- 层级:工程工具夜间编排的一次有界实跑。产品 PI 全链路不适用;本文件不把该次运行记成产品测试 PASS。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- 活跃或待合入任务按计划推进、合入,或带原因 `blocked` / `failed`。
|
||||
- `dev` tip 变化时重建服务并通过 `/health` 与 `/ready`;tip 不变则服务阶段为 `not_required`。
|
||||
- 已完成且内容已在 `dev` 上、工作树干净的目录,经普通删除后移除;祖先检查失败则保留。
|
||||
- 每个实际执行的任务在本报告中有 result、耗时和证据日志相对路径。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
- 开始:`2026-09-28T06:43:50+00:00`;`dev` 起点 `e5c2519feaeedb689f0c4055e03cbfe65507f719`(`state.json` 的 `dev_tip_start`)。
|
||||
- 草稿落盘时刻:`2026-09-28T06:45:07+00:00`;`dev` 终点与起点相同(`dev_tip_end` / `dev_tip_final` 均为 `e5c2519feaeedb689f0c4055e03cbfe65507f719`)。
|
||||
- `dev` 变更:否。
|
||||
- 计划生成:`plan.json` `generated_at=2026-09-28T06:43:54`,`count=11`。
|
||||
|
||||
### 用例 1:integrate-agent-reply-llm-narrative-2026-09-28
|
||||
|
||||
- **测试输入 (Input)**:`plan.json` 第 1 条。`phase=integrate_closeout`,worktree `/home/weichen/projects/Agent-System/.worktrees/integrate-agent-reply-llm-narrative-2026-09-28`,`base_feature=agent-reply-llm-narrative`,blockers 为 `not_ancestor_of_dev`、`dirty_or_ignored_files`。编排上限 `--max-tasks 1`,因此只把这一条交给无头执行器。
|
||||
- **实际输出 (Actual Output)**:`advance.json` 记录 `rc=0`,`duration_s=71.6`,`phase=integrate_closeout`,`branch=null`,`owner=null`。日志末行:
|
||||
|
||||
```text
|
||||
{"task_id":"integrate-agent-reply-llm-narrative-2026-09-28","result":"merged","summary":"功能尖 dfd9f6ef 已由 4c42d1bf 合入 dev,当前候选第一父落后于 dev,未重建","new_dev_tip":"e5c2519feaeedb689f0c4055e03cbfe65507f719"}
|
||||
```
|
||||
|
||||
日志正文记录:工作树干净;冻结 HEAD `cb0ccf33fe58b2aa8aa84a1fc5fc3aebbcaf996f`,第一父 `8f77053f5169502eaf3e4e4135a082e8914f3704`;功能尖 `dfd9f6efb374c12bec7dd90b214c40f44cc0fee6` 已是当前 `dev` 的祖先,由 `4c42d1bf74e262930bcf127e517ab6b76a0dda71` 带入。未重新入队,未重建候选。
|
||||
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS(`result=merged`)。
|
||||
- **性能与耗时 (Duration & Latency)**:71.6 秒。
|
||||
- **证据日志**:`.tmp/nightly/20260928T064350Z/agent-00-integrate-agent-reply-llm-narrative-2026-09-28.log`
|
||||
|
||||
### 用例 2:计划内未交给执行器的条目
|
||||
|
||||
`advance.json` 只有 1 条。下列 8 条在 `plan.json` 中、且阶段不是 `cleanup_verify`,本次没有对应 agent 日志。判定为未运行,不记 PASS。
|
||||
|
||||
| task | phase | 判定 | 耗时 | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| integrate-agent-reply-llm-narrative-2026-09-28-r2 | integrate_closeout | 未运行 | — | 无 agent 日志;见 `.tmp/nightly/20260928T064350Z/plan.json` |
|
||||
| integrate-error-code-observability-2026-09-28 | integrate_closeout | 未运行 | — | 同上 |
|
||||
| integrate-land-promote-rc-beta5-record-2026-09-28 | integrate_closeout | 未运行 | — | 同上 |
|
||||
| integrate-pi-runner-env-mismatch-2026-09-28 | integrate_closeout | 未运行 | — | 同上 |
|
||||
| integrate-quality-gate-heading-mismatch-2026-09-28-r3 | integrate_closeout | 未运行 | — | 同上 |
|
||||
| integrate-test-user-doc-issues-2026-09-28 | integrate_closeout | 未运行 | — | 同上 |
|
||||
| full-system-browser-e2e-wipe-2026-09-26 | feature_advance | 未运行 | — | 同上 |
|
||||
| quality-gate-heading-mismatch-2026-09-28 | feature_advance | 未运行 | — | 同上 |
|
||||
|
||||
### 用例 3:dev 服务
|
||||
|
||||
- **测试输入 (Input)**:`dev_tip_start` 与 `dev_tip_end` 同为 `e5c2519feaeedb689f0c4055e03cbfe65507f719`。
|
||||
- **实际输出 (Actual Output)**:`env.json` 为 `{"dev_tip":"e5c2519feaeedb689f0c4055e03cbfe65507f719","status":"not_required","reason":"dev tip unchanged tonight"}`。无 `/health`、`/ready` 采样。
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS(阶段按设计跳过;服务未重建)。
|
||||
- **性能与耗时 (Duration & Latency)**:`env.json` 未记录单独耗时。
|
||||
- **证据**:`.tmp/nightly/20260928T064350Z/env.json`
|
||||
|
||||
### 用例 4:清理
|
||||
|
||||
两条均来自 `cleanup.json`。`registry_state=done`,`ancestor_of_dev=false`,`clean=true`,`dirty_sample=[]`,`verdict=skip_not_on_dev`。未调用删除。
|
||||
|
||||
| worktree | 判定 | 耗时 | 证据 |
|
||||
|---|---|---|---|
|
||||
| integrate-quality-gate-heading-mismatch-2026-09-28-r2 | skip_not_on_dev | 未单独记录 | `.tmp/nightly/20260928T064350Z/cleanup.json` |
|
||||
| integrate-quality-gate-heading-mismatch-2026-09-28-r4 | skip_not_on_dev | 未单独记录 | `.tmp/nightly/20260928T064350Z/cleanup.json` |
|
||||
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS(门禁按「不是 `dev` 祖先则不删」执行;目录保留)。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
本次夜间 run 没有另跑 `unittest` / `go test`。套件即编排阶段本身:
|
||||
|
||||
| 检查 | 实际 | 判定 |
|
||||
|---|---|---|
|
||||
| 预检 daemon | `state.json` `daemon=ok` | PASS |
|
||||
| plan 构造 | `plan.json` `count=11` | PASS |
|
||||
| 任务推进 | `advance.json` 1 项;`failed`/`blocked` 0 项;另外 8 项因 `--max-tasks 1` 未运行 | 已执行项 PASS |
|
||||
| dev 服务 | `status=not_required` | PASS |
|
||||
| 清理 | 2 项 `skip_not_on_dev`,0 项删除 | PASS |
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
本次**已执行范围**判定为 PASS:唯一推进项 `result=merged`(71.6 秒),`dev` tip 未变,服务阶段 `not_required`,清理两项因不是 `dev` 祖先而保留。`advance.json` 中没有 `blocked` 或 `failed` 任务,因此没有该类人工裁决点。
|
||||
|
||||
后续(观察项,不是失败裁决):
|
||||
|
||||
1. 满队列尚未覆盖。`--max-tasks 1` 使另外 8 条 `integrate_closeout` / `feature_advance` 未运行。下一次去掉该上限(或显式提高上限)后再归档对应日志。
|
||||
2. 两个 `cleanup_verify` 目录仍在:`integrate-quality-gate-heading-mismatch-2026-09-28-r2` 与 `integrate-quality-gate-heading-mismatch-2026-09-28-r4`。登记状态为 `done`,工作树干净,但分支不是当前 `dev` 的祖先,自动化按门禁跳过删除。若要删除,需人工确认功能内容已经在 `dev` 上,再走普通 `remove`;本运行未使用 `--force`。
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nightly advance entrypoint (cron 10 3 * * *).
|
||||
# Drives the full pipeline via scripts/nightly/advance.py and always leaves a
|
||||
# machine-readable summary + report draft under data/nightly/<run>/.
|
||||
#
|
||||
# Environment:
|
||||
# NIGHTLY_ADVANCE_REPO default /home/weichen/projects/Agent-System
|
||||
# NIGHTLY_TASK_TIMEOUT per-task cursor-agent timeout seconds (default 3600)
|
||||
# NIGHTLY_MAX_TASKS cap planned entries (0 = all)
|
||||
#
|
||||
# Never pushes, never promotes test/main, never force-removes worktrees.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${NIGHTLY_ADVANCE_REPO:-/home/weichen/projects/Agent-System}"
|
||||
export PATH="/usr/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||
|
||||
LOG_ROOT="${REPO}/.tmp/nightly"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
ENTRY_LOG="${LOG_ROOT}/entry-${STAMP}.log"
|
||||
mkdir -p "${LOG_ROOT}"
|
||||
|
||||
{
|
||||
echo "== nightly-advance ${STAMP} repo=${REPO}"
|
||||
python3 -B "${REPO}/scripts/nightly/advance.py" \
|
||||
--repo "${REPO}" \
|
||||
--task-timeout "${NIGHTLY_TASK_TIMEOUT:-3600}" \
|
||||
--max-tasks "${NIGHTLY_MAX_TASKS:-0}"
|
||||
RC=$?
|
||||
echo "== done rc=${RC}"
|
||||
} >> "${ENTRY_LOG}" 2>&1
|
||||
|
||||
exit "${RC:-0}"
|
||||
Executable
+506
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nightly advance orchestrator.
|
||||
|
||||
Executes the nightly pipeline against the main repo:
|
||||
|
||||
Phase 0 preflight daemon health, reentry lock, dev tip freeze
|
||||
Phase 1 plan scripts/nightly/plan.py ordering
|
||||
Phase 2 advance per-task headless cursor-agent runs (bounded)
|
||||
Phase 3 integrate daemon ff-only via queue enqueue (already done inside
|
||||
phase 2 per task); wait for queue drain
|
||||
Phase 4 dev-service rebuild dev env when tip moved; /health /ready
|
||||
Phase 5 cleanup verify + remove finished worktrees (normal remove
|
||||
only; refusals leave cleanup=pending, never --force)
|
||||
Phase 6 report .doc/test-reports/<date>-nightly-advance-test-report.md
|
||||
draft written to run dir (committed by report task)
|
||||
|
||||
Every phase records structured state into the run dir (data/nightly/<run>/),
|
||||
so a crashed run is inspectable and never double-drives: a live lock file
|
||||
with a healthy heartbeat aborts the new invocation.
|
||||
|
||||
Exit codes: 0 success, 1 partial failures, 2 usage, 3 fatal infrastructure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = "/home/weichen/projects/Agent-System"
|
||||
# data/ is root-owned (docker mounts); .tmp/ is gitignored and user-writable.
|
||||
RUN_ROOT = ".tmp/nightly"
|
||||
LOCK_REL = f"{RUN_ROOT}/.lock"
|
||||
# Standing task identity for the perpetual automation (env leases survive the
|
||||
# delivery task closeout). Override with NIGHTLY_TASK_ID if retasked.
|
||||
STANDING_TASK = os.environ.get("NIGHTLY_TASK_ID", "nightly-advance-automation")
|
||||
HEARTBEAT_STALE_SECONDS = 3600
|
||||
REPORT_SLUG = "nightly-advance"
|
||||
|
||||
PROMPT_ADVANCE = """你是 Agent-System 夜间自动化执行器。任务:推进 worktree {worktree} 中的分支任务 {task_id} 至完成。
|
||||
|
||||
硬边界(违反即失败):
|
||||
1. 只在 {worktree} 内工作,不碰其他 worktree 或主目录 dev。
|
||||
2. 任何 git 写操作(add/commit)必须包在 `python3 ../../scripts/agentctl.py with --task {task_id} --resource path:<路径> --resource git:index -- bash -lc '<命令>'` 内。
|
||||
3. 读取该 worktree 下的任务文档(.doc/ops/short-term/ 或 .doc/audit/ 下与任务 id 匹配的文件)了解剩余工作与验收标准。
|
||||
4. 按任务文档推进实现/修复/文档工作;禁止 push、禁止晋升 test/main、禁止操作其他环境。
|
||||
5. 按验证契约选择验收:工程工具/文档类 → `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'`;产品代码改动 → 本 worktree 内可行的真实检查(go build / go test 相关包)。
|
||||
6. 完成后:任务状态五字段填入任务文档(review_state/integration_state=validated_cddidate_ready/deployment/cleanup/remote_sync),不要自行 integrate。
|
||||
7. 所有命令真实执行并保留输出;禁止用 SKIP/stub 冒充通过。
|
||||
|
||||
完成时输出 JSON 摘要(最后一行):{{"task_id":"{task_id}","result":"advanced|done|blocked|failed","summary":"<一句话>","evidence":"<相对路径>"}}"""
|
||||
|
||||
PROMPT_INTEGRATE = """你是 Agent-System 夜间自动化执行器。任务:把集成候选 {task_id} 合入 dev(通过治理门禁)。
|
||||
|
||||
步骤:
|
||||
1. 在 {worktree} 内检查 `git status --porcelain` 是否干净、`git rev-parse HEAD` 是否为冻结候选。
|
||||
2. 若树不干净:停止并输出 blocked(不要 stash/reset)。
|
||||
3. 若干净:构造候选收口——执行 `python3 ../../scripts/agentctl.py validate --task {task_id} -- python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'`(治理类变更);产品变更按任务文档已冻结的验收命令。
|
||||
4. validate 通过后:`python3 ../../scripts/agentctl.py queue enqueue --task {task_id} --expected-dev {expected_dev} --head $(git rev-parse HEAD)`。daemon 会 ff-only 合入。
|
||||
5. 轮询 `queue list` 至空或该 task 消失(最多 60s)。
|
||||
6. 禁止手动 git merge dev、禁止 push、禁止动 test/main。
|
||||
|
||||
陈旧候选恢复(仅当 validate 因 TASK_CLOSED 拒绝,或候选第一父不等于当前 dev):
|
||||
- 先判断功能内容是否已经在 dev 上(`git merge-base --is-ancestor <候选head或其功能分支> dev`,或对比 dev log)。内容已在 dev → 输出 result=merged, summary 说明由哪个提交带入,不重建。
|
||||
- 内容不在 dev 且任务已 cancelled/done → 输出 blocked 并说明需要人工重开任务;禁止复活关闭的任务。
|
||||
- 内容不在 dev 且任务仍 active 但基线前移 → 在 worktree 内用 `python3 ../../scripts/agentctl.py integrate prepare --task <基础功能task_id> --expected-dev <当前dev> --integrate-id {task_id}-r<N+1>` 从新 dev 重建候选(N 为现有最大轮次),对返回的新 worktree 重复步骤 1-5。
|
||||
|
||||
完成时输出 JSON 摘要(最后一行):{{"task_id":"{task_id}","result":"enqueued|merged|blocked|failed","summary":"<一句话>","new_dev_tip":"<sha 或 null>"}}"""
|
||||
|
||||
PROMPT_REPORT = """你是 Agent-System 夜间自动化日报执行器。任务:把 {date} 夜间运行的报告草稿正式化并归档。
|
||||
|
||||
步骤:
|
||||
1. 读 {run_dir}/report-draft.md、advance.json、env.json、cleanup.json、state.json。
|
||||
2. 在当前 worktree(报告任务工作区)把草稿改写为正式报告 .doc/test-reports/{date}-nightly-advance-test-report.md:
|
||||
- 按 test-reports-standard 模板补全七大板块;引用 run 目录真实数据(不得虚构)。
|
||||
- 每个任务的 result/耗时/证据日志相对路径如实填入「实际测试结果与判定」。
|
||||
- blocked/failed 任务在「测试结论与后续」列出后续建议(人工裁决点)。
|
||||
3. 用治理租约提交(禁止裸 git):
|
||||
python3 ../../scripts/agentctl.py with --task {report_task_id} --resource path:.doc --resource git:index -- bash -lc 'git add .doc/test-reports/{date}-nightly-advance-test-report.md && git commit -m "docs(test): nightly advance report {date}"'
|
||||
4. 不 integrate、不 push;提交留在报告任务分支即可。
|
||||
|
||||
完成时输出 JSON 摘要(最后一行):{{"date":"{date}","result":"archived|failed","report":".doc/test-reports/{date}-nightly-advance-test-report.md","commit":"<sha 或 null>"}}"""
|
||||
|
||||
|
||||
def sh(args: list[str], cwd: Path, env: dict | None = None, timeout: int = 600) -> tuple[int, str]:
|
||||
"""Run a subprocess; returns (rc, combined output). Never raises."""
|
||||
e = dict(os.environ)
|
||||
if env:
|
||||
e.update(env)
|
||||
try:
|
||||
p = subprocess.run(
|
||||
args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, env=e
|
||||
)
|
||||
out = p.stdout + ("\n--stderr--\n" + p.stderr if p.stderr else "")
|
||||
return p.returncode, out
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return 124, f"TIMEOUT after {timeout}s\n{(exc.stdout or '')[-800:]}"
|
||||
except Exception as exc: # noqa: BLE001 — orchestrator must not die
|
||||
return 125, f"ERROR {exc}"
|
||||
|
||||
|
||||
def agentctl(repo: Path, *args: str, timeout: int = 600) -> tuple[int, dict | str]:
|
||||
rc, out = sh([sys.executable, "-B", str(repo / "scripts/agentctl.py"), *args], repo, timeout=timeout)
|
||||
clean = "\n".join(ln for ln in out.splitlines() if not ln.startswith("[notice]"))
|
||||
try:
|
||||
return rc, json.loads(clean.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return rc, out
|
||||
|
||||
|
||||
def write_json(path: Path, data) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def now_utc() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 0
|
||||
|
||||
def preflight(repo: Path, run_dir: Path) -> tuple[bool, list[str], dict]:
|
||||
"""Reentry lock + daemon health + dev tip freeze. Returns (ok, errors, ctx)."""
|
||||
errors: list[str] = []
|
||||
ctx: dict = {"started_at": now_utc()}
|
||||
|
||||
# lock: atomic create; stale after HEARTBEAT_STALE_SECONDS without update
|
||||
lock = repo / LOCK_REL
|
||||
lock.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
os.write(fd, json.dumps({"pid": os.getpid(), "started": ctx["started_at"]}).encode())
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
age = time.time() - lock.stat().st_mtime
|
||||
if age > HEARTBEAT_STALE_SECONDS:
|
||||
lock.unlink()
|
||||
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
os.write(fd, json.dumps({"pid": os.getpid(), "took_over_after_stale": age}).encode())
|
||||
os.close(fd)
|
||||
else:
|
||||
return False, [f"another nightly run is active (lock age {age:.0f}s < {HEARTBEAT_STALE_SECONDS}s)"], ctx
|
||||
|
||||
rc, daemon = agentctl(repo, "daemon", "status")
|
||||
if rc != 0 or (isinstance(daemon, dict) and daemon.get("status") != "ok"):
|
||||
return False, [f"daemon not healthy: {str(daemon)[:200]}"], ctx
|
||||
ctx["daemon"] = "ok"
|
||||
|
||||
rc, tip = sh(["git", "-C", str(repo), "rev-parse", "HEAD"], repo)
|
||||
if rc != 0:
|
||||
return False, ["cannot resolve dev tip"], ctx
|
||||
ctx["dev_tip_start"] = tip.strip()
|
||||
return True, errors, ctx
|
||||
|
||||
|
||||
def touch_heartbeat(repo: Path) -> None:
|
||||
lock = repo / LOCK_REL
|
||||
try:
|
||||
os.utime(lock, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 1
|
||||
|
||||
def build_plan(repo: Path, run_dir: Path) -> dict:
|
||||
# Locate plan.py next to this file first; fall back to <repo>/scripts so
|
||||
# the orchestrator works both from a task worktree and from merged dev.
|
||||
here = Path(__file__).resolve().parent
|
||||
plan_py = here / "plan.py"
|
||||
if not plan_py.exists():
|
||||
plan_py = repo / "scripts/nightly/plan.py"
|
||||
out_file = run_dir / "plan.json"
|
||||
rc, out = sh(
|
||||
[sys.executable, "-B", str(plan_py), "--repo", str(repo), "--out", str(out_file)],
|
||||
repo,
|
||||
)
|
||||
if rc != 0 or not out_file.exists():
|
||||
return {"error": out[-500:], "entries": []}
|
||||
return json.loads(out_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 2
|
||||
|
||||
def run_agent(prompt: str, worktree: Path, timeout_s: int, run_dir: Path, tag: str) -> dict:
|
||||
"""Headless cursor-agent run inside a worktree; full log kept in run_dir."""
|
||||
log = run_dir / f"agent-{tag}.log"
|
||||
cmd = [
|
||||
"cursor-agent", "--print", "--trust",
|
||||
"--workspace", str(worktree),
|
||||
prompt,
|
||||
]
|
||||
env = {"PATH": os.environ.get("PATH", "") + ":/usr/bin:/home/linuxbrew/.linuxbrew/bin"}
|
||||
start = time.monotonic()
|
||||
rc, out = sh(cmd, cwd=worktree, env=env, timeout=timeout_s)
|
||||
dur = time.monotonic() - start
|
||||
log.write_text(out, encoding="utf-8", errors="replace")
|
||||
summary = {"rc": rc, "duration_s": round(dur, 1), "log": str(log)}
|
||||
# parse last JSON line the agent was asked to emit
|
||||
for line in reversed(out.strip().splitlines()):
|
||||
line = line.strip().strip("`")
|
||||
if line.startswith("{") and line.endswith("}"):
|
||||
try:
|
||||
summary["agent"] = json.loads(line)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 4
|
||||
|
||||
def dev_tip(repo: Path) -> str | None:
|
||||
rc, out = sh(["git", "-C", str(repo), "rev-parse", "HEAD"], repo)
|
||||
return out.strip() if rc == 0 else None
|
||||
|
||||
|
||||
def update_dev_service(repo: Path, run_dir: Path) -> dict:
|
||||
"""Rebuild dev env only when dev tip moved; verify /health and /ready."""
|
||||
tip = dev_tip(repo)
|
||||
result: dict = {"dev_tip": tip}
|
||||
if tip is None:
|
||||
result["status"] = "failed"
|
||||
result["error"] = "cannot resolve dev tip"
|
||||
return result
|
||||
start = tip == run_dir_state(run_dir).get("dev_tip_start")
|
||||
if start:
|
||||
result["status"] = "not_required"
|
||||
result["reason"] = "dev tip unchanged tonight"
|
||||
return result
|
||||
# env:dev write goes through agentctl with on the standing nightly task
|
||||
# lease. The daemon itself never touches env:*, so we drive env-service.sh
|
||||
# here.
|
||||
rc, out = sh(
|
||||
[sys.executable, "-B", str(repo / "scripts/agentctl.py"), "with",
|
||||
"--task", STANDING_TASK,
|
||||
"--resource", "env:dev", "--cwd", str(repo),
|
||||
"--", str(repo / "scripts/env-service.sh"), "dev", "rebuild"],
|
||||
repo, timeout=3600,
|
||||
)
|
||||
log = run_dir / "env-rebuild.log"
|
||||
log.write_text(out, encoding="utf-8", errors="replace")
|
||||
result["rebuild_rc"] = rc
|
||||
result["rebuild_log"] = str(log)
|
||||
if rc != 0:
|
||||
result["status"] = "failed"
|
||||
return result
|
||||
ok_h, body_h = http_get("http://127.0.0.1:8080/health")
|
||||
ok_r, body_r = http_get("http://127.0.0.1:8080/ready")
|
||||
result["health"] = {"ok": ok_h, "body": (body_h or "")[:300]}
|
||||
result["ready"] = {"ok": ok_r, "body": (body_r or "")[:300]}
|
||||
result["status"] = "ok" if (ok_h and ok_r) else "unhealthy"
|
||||
return result
|
||||
|
||||
|
||||
def http_get(url: str, retries: int = 6, delay: float = 10.0) -> tuple[bool, str | None]:
|
||||
for i in range(retries):
|
||||
rc, out = sh(["curl", "-fsS", "--max-time", "10", url], Path("/tmp"), timeout=30)
|
||||
if rc == 0:
|
||||
return True, out
|
||||
time.sleep(delay if i else 2)
|
||||
return False, None
|
||||
|
||||
|
||||
def run_dir_state(run_dir: Path) -> dict:
|
||||
f = run_dir / "state.json"
|
||||
if f.exists():
|
||||
try:
|
||||
return json.loads(f.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 5
|
||||
|
||||
def cleanup_pass(repo: Path, run_dir: Path, plan: dict) -> dict:
|
||||
"""Verify + remove finished worktrees. Normal remove only; refusals
|
||||
leave cleanup=pending recorded in the report — never --force."""
|
||||
results = []
|
||||
for entry in plan.get("entries", []):
|
||||
if entry.get("phase") != "cleanup_verify":
|
||||
continue
|
||||
wt = Path(entry["worktree"])
|
||||
task_id = entry["task_id"]
|
||||
checks: dict = {"task_id": task_id, "worktree": str(wt)}
|
||||
# 1) task closed (not active in registry)
|
||||
rc, st = agentctl(repo, "status")
|
||||
reg = {}
|
||||
if isinstance(st, dict):
|
||||
raw = st.get("tasks") or {}
|
||||
if isinstance(raw, list):
|
||||
reg = {t.get("id", ""): t for t in raw if isinstance(t, dict)}
|
||||
elif isinstance(raw, dict):
|
||||
reg = raw
|
||||
tstate = reg.get(task_id, {}).get("state", "absent")
|
||||
checks["registry_state"] = tstate
|
||||
# 2) branch head is dev ancestor (content already on dev)
|
||||
rc, out = sh(["git", "-C", str(repo), "merge-base", "--is-ancestor",
|
||||
entry.get("branch", ""), "dev"], repo)
|
||||
checks["ancestor_of_dev"] = rc == 0
|
||||
# 3) worktree clean of tracked/untracked (ignored ok)
|
||||
rc, out = sh(["git", "-C", str(wt), "status", "--porcelain"], wt)
|
||||
lines = [ln for ln in out.splitlines() if ln.strip() and not ln.startswith("!!")]
|
||||
checks["clean"] = not lines
|
||||
checks["dirty_sample"] = lines[:10]
|
||||
if tstate not in {"done", "cancelled", "absent"}:
|
||||
checks["verdict"] = "skip_task_active"
|
||||
elif not checks["ancestor_of_dev"]:
|
||||
checks["verdict"] = "skip_not_on_dev"
|
||||
elif not checks["clean"]:
|
||||
checks["verdict"] = "skip_dirty"
|
||||
else:
|
||||
rc, out = sh(
|
||||
[str(repo / "scripts/agent-worktree.sh"), "remove",
|
||||
entry["branch"], "--delete-branch"],
|
||||
repo, timeout=600,
|
||||
)
|
||||
checks["remove_rc"] = rc
|
||||
checks["remove_out"] = out[-400:]
|
||||
checks["verdict"] = "removed" if rc == 0 else "remove_refused_keep_pending"
|
||||
results.append(checks)
|
||||
write_json(run_dir / "cleanup.json", {"results": results})
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- phase 6
|
||||
|
||||
def write_report(repo: Path, run_dir: Path, ctx: dict, plan: dict, advance: list, envs: dict, cleanup: dict) -> Path:
|
||||
date = ctx["started_at"][:10]
|
||||
lines = []
|
||||
lines.append(f"# 夜间推进自动化测试报告 {date}")
|
||||
lines.append("")
|
||||
lines.append("## 一、 测试背景")
|
||||
lines.append("每日 03:10 定时推进 .worktrees 分支任务、合入 dev、更新 dev 服务并清理已收口目录的自动化链路。")
|
||||
lines.append("")
|
||||
lines.append("## 二、 测试目标")
|
||||
lines.append("验证夜间流水线可无人值守执行:计划构建 → 任务推进 → dev 合入 → 服务更新 → 完成目录清理 → 报告落盘。")
|
||||
lines.append("")
|
||||
lines.append("## 三、 测试方案与环境")
|
||||
lines.append(f"- 主仓:{repo}(dev)")
|
||||
lines.append("- 触发:cron 10 3 * * * → scripts/nightly-advance.sh")
|
||||
lines.append(f"- 执行器:cursor-agent 无头模式;agentctl daemon(coordinator.service)")
|
||||
lines.append(f"- run 目录:{run_dir}")
|
||||
lines.append("")
|
||||
lines.append("## 四、 预期结果")
|
||||
lines.append("活跃任务按计划推进/合入或带原因 blocked;dev tip 变化时服务重建并通过 /health /ready;完成目录经无内容丢失验证后删除;每日报告归档 .doc/test-reports/。")
|
||||
lines.append("")
|
||||
lines.append("## 五、 实际测试结果与判定")
|
||||
lines.append(f"- 开始:{ctx.get('started_at')};dev 起点 {ctx.get('dev_tip_start','?')[:12]}")
|
||||
tip = ctx.get("dev_tip_end") or dev_tip(repo)
|
||||
lines.append(f"- 结束:{now_utc()};dev 终点 {(tip or '?')[:12]}")
|
||||
moved = tip != ctx.get("dev_tip_start")
|
||||
lines.append(f"- dev 变更:{'是' if moved else '否'}")
|
||||
lines.append("")
|
||||
lines.append("### 任务推进明细")
|
||||
lines.append("| task | phase | result | 摘要 | 证据 |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
for a in advance:
|
||||
ag = a.get("agent") or {}
|
||||
ev = ag.get("evidence") or a.get("log", "")
|
||||
lines.append(f"| {a.get('task_id')} | {a.get('phase')} | {ag.get('result', 'no_summary')} | {(ag.get('summary') or '')[:80]} | {ev} |")
|
||||
lines.append("")
|
||||
lines.append("### dev 服务")
|
||||
lines.append(f"状态:{envs.get('status')}")
|
||||
if envs.get("health"):
|
||||
lines.append(f"- /health ok={envs['health'].get('ok')} body={envs['health'].get('body','')[:120]}")
|
||||
lines.append(f"- /ready ok={envs['ready'].get('ok')} body={envs['ready'].get('body','')[:120]}")
|
||||
lines.append("")
|
||||
lines.append("### 清理(已合入并验证无内容丢失后删除)")
|
||||
lines.append("| worktree | 判定 | 原因 |")
|
||||
lines.append("|---|---|---|")
|
||||
for c in cleanup.get("results", []):
|
||||
lines.append(f"| {Path(c['worktree']).name} | {c['verdict']} | {c.get('remove_out','')[:100].replace('|','/')} |")
|
||||
lines.append("")
|
||||
lines.append("## 六、 自动化测试套件汇总")
|
||||
lines.append(f"- plan 构造:{plan.get('count', 0)} 项")
|
||||
failed = [a for a in advance if (a.get("agent") or {}).get("result") in {"failed", "blocked"}]
|
||||
lines.append(f"- 任务推进:{len(advance)} 项,失败/受阻 {len(failed)} 项")
|
||||
lines.append("")
|
||||
lines.append("## 七、 测试结论与后续")
|
||||
overall = "PASS" if not failed and envs.get("status") in {"ok", "not_required"} else "PARTIAL"
|
||||
lines.append(f"整体判定:{overall}(细节见 run 目录原始日志)")
|
||||
lines.append("")
|
||||
report_path = run_dir / "report-draft.md"
|
||||
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--repo", default=DEFAULT_REPO)
|
||||
ap.add_argument("--max-tasks", type=int, default=0)
|
||||
ap.add_argument("--task-timeout", type=int, default=3600)
|
||||
ap.add_argument("--skip-agent", action="store_true", help="dry: plan+cleanup only")
|
||||
ap.add_argument("--only-report", action="store_true", help="dry: reuse existing run dir state")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
run_dir = repo / RUN_ROOT / stamp
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ok, errors, ctx = preflight(repo, run_dir)
|
||||
write_json(run_dir / "state.json", ctx)
|
||||
if not ok:
|
||||
for e in errors:
|
||||
print(f"FATAL: {e}", file=sys.stderr)
|
||||
return 3
|
||||
touch_heartbeat(repo)
|
||||
|
||||
plan = build_plan(repo, run_dir)
|
||||
ctx["plan_count"] = plan.get("count", 0)
|
||||
write_json(run_dir / "state.json", ctx)
|
||||
|
||||
advance: list[dict] = []
|
||||
if not args.skip_agent:
|
||||
entries = plan.get("entries", [])
|
||||
if args.max_tasks > 0:
|
||||
entries = entries[: args.max_tasks]
|
||||
for i, entry in enumerate(entries):
|
||||
if entry.get("phase") == "cleanup_verify":
|
||||
continue # handled in phase 5
|
||||
touch_heartbeat(repo)
|
||||
tag = f"{i:02d}-{entry['task_id']}"
|
||||
worktree = Path(entry["worktree"])
|
||||
prompt = (
|
||||
PROMPT_INTEGRATE.format(
|
||||
task_id=entry["task_id"], worktree=worktree,
|
||||
expected_dev=ctx["dev_tip_start"],
|
||||
)
|
||||
if entry.get("phase") == "integrate_closeout"
|
||||
else PROMPT_ADVANCE.format(task_id=entry["task_id"], worktree=worktree)
|
||||
)
|
||||
res = run_agent(prompt, worktree, args.task_timeout, run_dir, tag)
|
||||
res["task_id"] = entry["task_id"]
|
||||
res["phase"] = entry.get("phase")
|
||||
res["branch"] = entry.get("branch")
|
||||
res["owner"] = entry.get("owner")
|
||||
advance.append(res)
|
||||
write_json(run_dir / "advance.json", advance)
|
||||
|
||||
# phase 3: wait for daemon to drain the queue
|
||||
for _ in range(30):
|
||||
rc, q = agentctl(repo, "queue", "list")
|
||||
if isinstance(q, dict) and q.get("items") == []:
|
||||
break
|
||||
touch_heartbeat(repo)
|
||||
time.sleep(10)
|
||||
|
||||
ctx["dev_tip_end"] = dev_tip(repo)
|
||||
write_json(run_dir / "state.json", ctx)
|
||||
|
||||
# phase 4
|
||||
envs = update_dev_service(repo, run_dir)
|
||||
write_json(run_dir / "env.json", envs)
|
||||
|
||||
# phase 5
|
||||
cleanup = cleanup_pass(repo, run_dir, plan)
|
||||
ctx["dev_tip_final"] = dev_tip(repo)
|
||||
write_json(run_dir / "state.json", ctx)
|
||||
|
||||
# phase 6
|
||||
report = write_report(repo, run_dir, ctx, plan, advance, envs, cleanup)
|
||||
|
||||
# phase 6.5: formalize the daily report into .doc/test-reports/ via a
|
||||
# headless agent inside the report worktree (lease-wrapped commit).
|
||||
report_archived = None
|
||||
if not args.skip_agent:
|
||||
report_task_wt = Path(os.environ.get(
|
||||
"NIGHTLY_REPORT_WORKTREE",
|
||||
str(repo / ".worktrees/nightly-advance-automation-2026-09-28"),
|
||||
))
|
||||
if report_task_wt.exists():
|
||||
date = ctx["started_at"][:10]
|
||||
report_task_id = os.environ.get("NIGHTLY_REPORT_TASK", STANDING_TASK)
|
||||
prompt = PROMPT_REPORT.format(
|
||||
date=date, run_dir=run_dir, report_task_id=report_task_id
|
||||
)
|
||||
res = run_agent(
|
||||
prompt, report_task_wt, min(args.task_timeout, 1800), run_dir, "report"
|
||||
)
|
||||
report_archived = res
|
||||
write_json(run_dir / "report-archive.json", res)
|
||||
|
||||
print(json.dumps({
|
||||
"run_dir": str(run_dir),
|
||||
"report_draft": str(report),
|
||||
"report_archived": bool((report_archived or {}).get("agent")),
|
||||
"plan_count": plan.get("count", 0),
|
||||
"advanced": len(advance),
|
||||
"env_status": envs.get("status"),
|
||||
}, ensure_ascii=False))
|
||||
(repo / LOCK_REL).unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nightly advance plan builder.
|
||||
|
||||
Reads agentctl registry + CURRENT.md + cleanup report and produces an ordered
|
||||
task list for the nightly advance run. Dependency ordering rules:
|
||||
|
||||
1. Integrate-candidate tasks (integrate-* worktrees) whose branch head is NOT
|
||||
an ancestor of dev come FIRST — they unblock the dev tip their feature
|
||||
tasks were built against, and their merge must land before feature tasks
|
||||
that rebase onto the new tip.
|
||||
2. Feature/fix tasks follow, ordered by status:
|
||||
- in_progress / active first (work to advance)
|
||||
- cleanup=pending done tasks after (closeout + verification + removal)
|
||||
3. Tasks whose CURRENT status is done and whose worktree verdict is ready
|
||||
land last (removal only).
|
||||
|
||||
Output: JSON plan on stdout (or --out file) with entries:
|
||||
{task_id, worktree, branch, owner, status, phase, reason}
|
||||
|
||||
Phases: integrate_closeout | feature_advance | cleanup_verify
|
||||
Exit codes: 0 ok, 2 usage/preflight failure, 3 unparsable upstream data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = "/home/weichen/projects/Agent-System"
|
||||
AGENTCTL = "scripts/agentctl.py"
|
||||
|
||||
|
||||
def _agentctl(repo: Path, *args: str) -> dict:
|
||||
"""Run agentctl (notices on stderr) and parse its stdout JSON."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-B", str(repo / AGENTCTL), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=repo,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"agentctl {' '.join(args)} rc={proc.returncode}: {proc.stderr[-500:]}"
|
||||
)
|
||||
# Some subcommands emit [notice] lines on stdout too; keep last JSON blob.
|
||||
out = "\n".join(
|
||||
ln for ln in proc.stdout.splitlines() if not ln.startswith("[notice]")
|
||||
).strip()
|
||||
try:
|
||||
return json.loads(out)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"unparsable agentctl output for {args}: {out[:300]}") from exc
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo), *args], capture_output=True, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)}: {proc.stderr.strip()[:300]}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def parse_current_rows(repo: Path) -> dict[str, dict]:
|
||||
"""Parse CURRENT.md 并行认领 table rows keyed by task id (col 2)."""
|
||||
rows: dict[str, dict] = {}
|
||||
text = (repo / ".doc/ops/CURRENT.md").read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if not line.strip().startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) < 6 or cells[0] in {"owner", "---"} or set(cells[0]) <= {"-", " "}:
|
||||
continue
|
||||
task_id, status = cells[1], cells[5]
|
||||
if not re.match(r"^[A-Za-z0-9._-]+-\d{4}-\d{2}-\d{2}", task_id):
|
||||
continue
|
||||
rows[task_id] = {
|
||||
"owner": cells[0],
|
||||
"task": task_id,
|
||||
"branch": cells[2],
|
||||
"worktree": cells[3],
|
||||
"mutex": cells[4],
|
||||
"status": status,
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def registry_tasks(repo: Path) -> dict[str, dict]:
|
||||
"""task registry state; empty when unavailable (plan falls back to CURRENT)."""
|
||||
try:
|
||||
status = _agentctl(repo, "status")
|
||||
except RuntimeError:
|
||||
return {}
|
||||
if not isinstance(status, dict):
|
||||
return {}
|
||||
out = status.get("tasks") or {}
|
||||
if isinstance(out, dict):
|
||||
return out
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanEntry:
|
||||
task_id: str
|
||||
worktree: str
|
||||
branch: str
|
||||
owner: str
|
||||
status: str
|
||||
phase: str
|
||||
reason: str
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
d = {
|
||||
"task_id": self.task_id,
|
||||
"worktree": self.worktree,
|
||||
"phase": self.phase,
|
||||
"reason": self.reason,
|
||||
}
|
||||
d.update(self.extra)
|
||||
return d
|
||||
|
||||
|
||||
def build_plan(repo: Path) -> list[PlanEntry]:
|
||||
try:
|
||||
cleanup = _agentctl(repo, "cleanup", "report")
|
||||
if not isinstance(cleanup, dict):
|
||||
cleanup = {}
|
||||
except RuntimeError:
|
||||
# Preflight (advance.py) already gates on daemon health; a failing
|
||||
# cleanup probe here means no worktree info — plan nothing rather
|
||||
# than crash the whole nightly run.
|
||||
cleanup = {}
|
||||
current = parse_current_rows(repo)
|
||||
reg = registry_tasks(repo)
|
||||
|
||||
entries: list[PlanEntry] = []
|
||||
EXCLUDE = {"nightly-advance-automation-2026-09-28"}
|
||||
for wt in cleanup.get("worktrees", []):
|
||||
path = wt.get("path", "")
|
||||
if not path:
|
||||
continue
|
||||
wt_name = Path(path).name
|
||||
if wt_name in EXCLUDE:
|
||||
continue # never plan the nightly automation task itself
|
||||
branch = wt.get("branch", "")
|
||||
tasks = wt.get("tasks") or []
|
||||
blockers = wt.get("blockers") or []
|
||||
notes = wt.get("notes") or []
|
||||
ancestor = "ancestor_of_dev" in notes
|
||||
reg_states = [t.get("state", "") for t in tasks]
|
||||
|
||||
# Pair integrate worktrees with their base feature task id.
|
||||
m = re.match(r"^integrate-(.+?)-\d{4}-\d{2}-\d{2}(-r\d+)?$", wt_name)
|
||||
base_feature = m.group(1) if m else None
|
||||
|
||||
cur = None
|
||||
for t in tasks:
|
||||
if t.get("id") in current:
|
||||
cur = current[t["id"]]
|
||||
break
|
||||
if cur is None and base_feature and base_feature in current:
|
||||
cur = current[base_feature]
|
||||
|
||||
status = (cur or {}).get("status") or (
|
||||
reg_states[0] if reg_states else "unknown"
|
||||
)
|
||||
task_id = next((t["id"] for t in tasks if t.get("id")), wt_name)
|
||||
owner = (cur or {}).get("owner") or (
|
||||
tasks[0].get("owner", "unknown") if tasks else "unknown"
|
||||
)
|
||||
|
||||
if base_feature and not ancestor:
|
||||
entries.append(
|
||||
PlanEntry(
|
||||
task_id=task_id,
|
||||
worktree=path,
|
||||
branch=branch,
|
||||
owner=owner,
|
||||
status=status,
|
||||
phase="integrate_closeout",
|
||||
reason="integrate candidate not yet on dev; land first so "
|
||||
"dependent feature tasks rebase onto the new tip",
|
||||
extra={"blockers": blockers, "base_feature": base_feature},
|
||||
)
|
||||
)
|
||||
elif "TASK_ACTIVE" in " ".join(blockers) or status in {"in_progress", "active"}:
|
||||
entries.append(
|
||||
PlanEntry(
|
||||
task_id=task_id,
|
||||
worktree=path,
|
||||
branch=branch,
|
||||
owner=owner,
|
||||
status=status,
|
||||
phase="feature_advance",
|
||||
reason="active task; advance along its task doc to acceptance",
|
||||
extra={"blockers": blockers},
|
||||
)
|
||||
)
|
||||
elif wt.get("verdict") == "ready" or (
|
||||
status in {"done", "cancelled"} and not blockers
|
||||
):
|
||||
entries.append(
|
||||
PlanEntry(
|
||||
task_id=task_id,
|
||||
worktree=path,
|
||||
branch=branch,
|
||||
owner=owner,
|
||||
status=status,
|
||||
phase="cleanup_verify",
|
||||
reason="finished and cleanup-ready; verify no-content-loss "
|
||||
"then remove worktree+branch",
|
||||
extra={"blockers": blockers},
|
||||
)
|
||||
)
|
||||
# done-but-blocked worktrees stay out of the plan; they are reported
|
||||
# by the nightly report as pending-closeout items.
|
||||
|
||||
order = {"integrate_closeout": 0, "feature_advance": 1, "cleanup_verify": 2}
|
||||
entries.sort(key=lambda e: (order[e.phase], e.task_id))
|
||||
return entries
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--repo", default=DEFAULT_REPO)
|
||||
ap.add_argument("--out", help="write plan JSON here (stdout otherwise)")
|
||||
ap.add_argument(
|
||||
"--max-tasks", type=int, default=0, help="cap entries (0 = no cap)"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
plan = [e.as_dict() for e in build_plan(repo)]
|
||||
if args.max_tasks > 0:
|
||||
plan = plan[: args.max_tasks]
|
||||
payload = {
|
||||
"generated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||
"repo": str(repo),
|
||||
"count": len(plan),
|
||||
"entries": plan,
|
||||
}
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if args.out:
|
||||
Path(args.out).write_text(text, encoding="utf-8")
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for nightly advance plan builder (no repo side effects).
|
||||
|
||||
Runs against fixtures in scripts/tests/fixtures/nightly/. Covers:
|
||||
- CURRENT row parsing (skip header/separators, keyed by task id)
|
||||
- plan ordering: integrate_closeout -> feature_advance -> cleanup_verify
|
||||
- done-but-blocked worktrees stay out of the plan
|
||||
- registry fallback when CURRENT has no row
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent.parent # scripts/
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"nightly_plan", SCRIPTS / "nightly" / "plan.py"
|
||||
)
|
||||
nightly_plan = importlib.util.module_from_spec(spec)
|
||||
sys.modules["nightly_plan"] = nightly_plan
|
||||
spec.loader.exec_module(nightly_plan)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "nightly"
|
||||
|
||||
CURRENT_MD = """# 当前焦点
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| Cursor | feat-a-2026-09-28 | feature/feat-a-2026-09-28 | .worktrees/feat-a-2026-09-28 | m1 | in_progress |
|
||||
| pi | feat-b-2026-09-27 | feature/feat-b-2026-09-27 | .worktrees/feat-b-2026-09-27 | m2 | done |
|
||||
| Codex | feat-c-2026-09-26 | fix/feat-c-2026-09-26 | (已清理) | m3 | done |
|
||||
"""
|
||||
|
||||
CLEANUP_REPORT = {
|
||||
"registry": "initialized",
|
||||
"status": "ok",
|
||||
"worktrees": [
|
||||
{
|
||||
"path": "/tmp/repo/.worktrees/integrate-feat-b-2026-09-27",
|
||||
"branch": "feature/integrate-feat-b-2026-09-27",
|
||||
"blockers": ["registry:TASK_ACTIVE"],
|
||||
"notes": ["Close the registered task before removing its worktree"],
|
||||
"tasks": [{"id": "integrate-feat-b-2026-09-27", "owner": "Cursor", "state": "active"}],
|
||||
"verdict": "blocked",
|
||||
},
|
||||
{
|
||||
"path": "/tmp/repo/.worktrees/feat-a-2026-09-28",
|
||||
"branch": "feature/feat-a-2026-09-28",
|
||||
"blockers": ["registry:TASK_ACTIVE", "dirty_or_ignored_files"],
|
||||
"notes": ["ancestor_of_dev"],
|
||||
"tasks": [{"id": "feat-a-2026-09-28", "owner": "Cursor", "state": "active"}],
|
||||
"verdict": "blocked",
|
||||
},
|
||||
{
|
||||
"path": "/tmp/repo/.worktrees/feat-d-2026-09-25",
|
||||
"branch": "feature/feat-d-2026-09-25",
|
||||
"blockers": [],
|
||||
"notes": ["ancestor_of_dev", "registry_clear", "lifecycle_lock_idle"],
|
||||
"tasks": [{"id": "feat-d-2026-09-25", "owner": "Cursor", "state": "done"}],
|
||||
"verdict": "ready",
|
||||
},
|
||||
{
|
||||
"path": "/tmp/repo/.worktrees/feat-done-blocked-2026-09-24",
|
||||
"branch": "feature/feat-done-blocked-2026-09-24",
|
||||
"blockers": ["dirty_or_ignored_files"],
|
||||
"notes": ["ancestor_of_dev"],
|
||||
"tasks": [{"id": "feat-done-blocked-2026-09-24", "owner": "Cursor", "state": "done"}],
|
||||
"verdict": "blocked",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ParseCurrentRows(unittest.TestCase):
|
||||
def test_parses_and_skips_non_rows(self):
|
||||
with TemporaryDirectory() as td:
|
||||
(Path(td) / ".doc/ops").mkdir(parents=True)
|
||||
(Path(td) / ".doc/ops/CURRENT.md").write_text(CURRENT_MD, encoding="utf-8")
|
||||
rows = nightly_plan.parse_current_rows(Path(td))
|
||||
self.assertIn("feat-a-2026-09-28", rows)
|
||||
self.assertEqual(rows["feat-a-2026-09-28"]["status"], "in_progress")
|
||||
self.assertEqual(rows["feat-b-2026-09-27"]["owner"], "pi")
|
||||
self.assertEqual(rows["feat-c-2026-09-26"]["worktree"], "(已清理)")
|
||||
|
||||
def test_separator_and_header_rejected(self):
|
||||
lines = CURRENT_MD.splitlines()
|
||||
rows = {}
|
||||
for line in lines:
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) < 6 or cells[0] in {"owner", "---"} or set(cells[0]) <= {"-", " "}:
|
||||
continue
|
||||
rows[cells[1]] = cells
|
||||
self.assertNotIn("task", rows)
|
||||
self.assertNotIn("owner", rows)
|
||||
|
||||
|
||||
class BuildPlan(unittest.TestCase):
|
||||
def _plan(self, cleanup=CLEANUP_REPORT, current=CURRENT_MD, registry=None):
|
||||
with mock.patch.object(nightly_plan, "_agentctl") as ac, \
|
||||
mock.patch.object(nightly_plan, "_git") as g:
|
||||
ac.side_effect = lambda repo, *a: (
|
||||
cleanup if a[:2] == ("cleanup", "report")
|
||||
else (registry if a and a[0] == "status" else {})
|
||||
)
|
||||
g.return_value = "sha"
|
||||
with TemporaryDirectory() as td:
|
||||
(Path(td) / ".doc/ops").mkdir(parents=True)
|
||||
(Path(td) / ".doc/ops/CURRENT.md").write_text(current, encoding="utf-8")
|
||||
entries = nightly_plan.build_plan(Path(td))
|
||||
return entries
|
||||
|
||||
def test_ordering_and_phases(self):
|
||||
entries = self._plan()
|
||||
phases = [e.phase for e in entries]
|
||||
self.assertEqual(
|
||||
phases,
|
||||
["integrate_closeout", "feature_advance", "cleanup_verify"],
|
||||
"expected integrate -> feature -> cleanup ordering",
|
||||
)
|
||||
|
||||
def test_integrate_not_ancestor_gets_integrate_phase(self):
|
||||
entries = self._plan()
|
||||
integ = [e for e in entries if e.phase == "integrate_closeout"]
|
||||
self.assertEqual(len(integ), 1)
|
||||
self.assertEqual(integ[0].task_id, "integrate-feat-b-2026-09-27")
|
||||
|
||||
def test_done_ready_worktree_is_cleanup_verify(self):
|
||||
entries = self._plan()
|
||||
clean = [e for e in entries if e.phase == "cleanup_verify"]
|
||||
self.assertEqual(clean[0].task_id, "feat-d-2026-09-25")
|
||||
|
||||
def test_done_blocked_out_of_plan(self):
|
||||
entries = self._plan()
|
||||
ids = [e.task_id for e in entries]
|
||||
self.assertNotIn("feat-done-blocked-2026-09-24", ids)
|
||||
|
||||
def test_current_fallback_to_registry_status(self):
|
||||
# feat-d has no CURRENT row here; registry state done + verdict ready
|
||||
entries = self._plan()
|
||||
clean = [e for e in entries if e.phase == "cleanup_verify"]
|
||||
self.assertEqual(clean[0].status, "done")
|
||||
|
||||
def test_registry_unavailable_still_plans(self):
|
||||
with mock.patch.object(nightly_plan, "registry_tasks", return_value={}):
|
||||
entries = self._plan(registry={})
|
||||
self.assertTrue(entries)
|
||||
|
||||
|
||||
class Cli(unittest.TestCase):
|
||||
def test_max_tasks_cap(self):
|
||||
# Subprocess run: patch nothing, but point --repo at a fixture tree so
|
||||
# _agentctl/_git never touch the real repo; build_plan must still work.
|
||||
with TemporaryDirectory() as td:
|
||||
(Path(td) / ".doc/ops").mkdir(parents=True)
|
||||
(Path(td) / ".doc/ops/CURRENT.md").write_text(CURRENT_MD, encoding="utf-8")
|
||||
out = Path(td) / "plan.json"
|
||||
# No entries can be planned without a real registry; assert rc=0
|
||||
# and empty plan rather than touching production.
|
||||
rc = subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPTS / "nightly/plan.py"),
|
||||
"--repo", td, "--out", str(out), "--max-tasks", "1"],
|
||||
capture_output=True, text=True,
|
||||
).returncode
|
||||
self.assertEqual(rc, 0)
|
||||
data = json.loads(out.read_text())
|
||||
self.assertEqual(data["count"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user