From 19aac17a0a63af3a49c29410b32fb86ae3782ea1 Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 14 Oct 2026 11:49:50 +0800 Subject: [PATCH] feat(ship): clarify delivery stop semantics --- .../delivery-loop/references/state-machine.md | 28 +++++---- skills/ship/references/manager.md | 2 +- skills/ship/references/record.md | 14 +++++ skills/ship/references/worker-prompt.md | 4 ++ skills/ship/scripts/next_step.py | 24 +++++--- skills/ship/scripts/ship_lib.py | 12 ++++ skills/ship/scripts/validate_ship.py | 12 ++++ tests/test_ship_record.py | 58 +++++++++++++++++++ 8 files changed, 133 insertions(+), 21 deletions(-) diff --git a/skills/delivery-loop/references/state-machine.md b/skills/delivery-loop/references/state-machine.md index 08fa40b..d4895c7 100644 --- a/skills/delivery-loop/references/state-machine.md +++ b/skills/delivery-loop/references/state-machine.md @@ -7,27 +7,31 @@ change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact ``` 入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目 -ACK 配置必须使用以下 canonical `stopAt`: +Ship 配置使用以下 canonical `stopAt`: -- `verified`:只完成代码和验证。 -- `validation_ready`:不可变产物已部署到开发/测试环境并通过健康检查,可交给用户验证。 -- `review_ready`:PR/MR、预览产物和非生产验证证据已齐备,等待审核。 -- `released`:经过本次 approval 后完成稳定发布或生产部署。 +- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。 +- `review_ready`:PR/MR 已创建,等待审核。 +- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。 +- `released`:产物已构建并发布,映射内部 `published`。 +- `deployed`:目标环境实际部署完成,映射内部 `deployed`。 + +旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready` +(`release_ready` 的别名);新记录应使用 canonical 名称。 入口 skill 的自然语言停止点映射如下: -| 用户目标 | ACK stopAt | 必要内部里程碑 | +| 用户目标 | Ship stopAt | 必要内部里程碑 | | --- | --- | --- | -| 只验证 | `verified` | `verified` | +| 只做开发验证 | `develop_verified` | `verified` | | 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 | -| 部署测试环境 | `validation_ready` | `artifact_built`、`deployed`、健康检查 | -| 合并后回归但不发布 | `review_ready` 或任务级中间状态 | `merged`、`regression_passed` | -| 完整正式部署 | `released` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` | +| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` | +| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` | +| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` | ## 每个阶段的要求 - `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。 -- `verified`:根因或需求验收明确;自动测试和必要的黑盒/真机验证通过。 +- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。 - `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。 - `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。 - `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。 @@ -39,7 +43,7 @@ ACK 配置必须使用以下 canonical `stopAt`: 测试面缺少结果时都不能通过回归。 - `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。 - `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。 -- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过;生产部署还必须进入 `released`。 +- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。 ## 配置和授权 diff --git a/skills/ship/references/manager.md b/skills/ship/references/manager.md index 99b7981..cf74309 100644 --- a/skills/ship/references/manager.md +++ b/skills/ship/references/manager.md @@ -51,7 +51,7 @@ 3. 按 JSON 行动: - `status: done`:写完成报告,结束。 - `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt: - released` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。 + deployed` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。 - `actor: manager`:补账本或问用户,再跑 `next_step.py`。 - `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` / `manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻 diff --git a/skills/ship/references/record.md b/skills/ship/references/record.md index 69f3f80..ce70350 100644 --- a/skills/ship/references/record.md +++ b/skills/ship/references/record.md @@ -90,6 +90,20 @@ evidence: `delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、 `pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。 +用户可见的 canonical `stopAt` 与内部里程碑映射如下: + +| stopAt | 停止含义 | 内部里程碑 | +| --- | --- | --- | +| `develop_verified` | 开发验证完成:单元测试、新增功能集成测试和静态检查通过;不等于完整回归 | `verified` | +| `review_ready` | PR/MR 已创建,等待审核 | `pr_opened` | +| `release_ready` | PR 已合并且完整回归通过;尚未表示发布或部署 | `regression_passed` | +| `released` | 产物已构建并发布 | `published` | +| `deployed` | 目标环境实际部署完成并通过健康检查 | `deployed` | + +为兼容旧记录,`verified` 是 `develop_verified` 的别名,`validation_ready` 是 +`release_ready` 的别名;新记录应使用 canonical 名称。`delivery.state` 内部里程碑 +名称保持不变。 + `source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时 `source.kind: ack` 且 `ref` 为缺陷任务 id。 diff --git a/skills/ship/references/worker-prompt.md b/skills/ship/references/worker-prompt.md index ac81ee1..f771fd6 100644 --- a/skills/ship/references/worker-prompt.md +++ b/skills/ship/references/worker-prompt.md @@ -21,6 +21,10 @@ scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。 验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。 授权:<本步允许的 git/PR/部署动作;未写的不要做> +测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、 +新增功能集成测试和静态检查,不执行合并后的完整 tester 回归;`regress` 只在合并后 +负责完整回归,必须覆盖发现的每个 tester catalog 根和项目声明的原生测试套件。 + 若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行 `python3 /scripts/catalog.py roots --root `。`verify_task` 按 任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、 diff --git a/skills/ship/scripts/next_step.py b/skills/ship/scripts/next_step.py index 36288aa..7a88a38 100644 --- a/skills/ship/scripts/next_step.py +++ b/skills/ship/scripts/next_step.py @@ -10,7 +10,13 @@ import sys from pathlib import Path from typing import Any -from ship_lib import ensure_stage_layout, load_yaml, record_stage_event, stage_for_step +from ship_lib import ( + STOP_AT_STATE, + ensure_stage_layout, + load_yaml, + record_stage_event, + stage_for_step, +) SPEC_PLACEHOLDERS = ( "<用户可见的目标", @@ -26,12 +32,6 @@ TASK_PLACEHOLDERS = ( FAULT_PLACEHOLDER = "<没有故障时删除" DONE_TASK = frozenset({"verified", "merged", "done"}) READY_DEP = frozenset({"verified", "merged", "done"}) -STOP_AT_STATE = { - "verified": "verified", - "review_ready": "pr_opened", - "validation_ready": "deployed", - "released": "deployed", -} DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = ( ("verified", "verify", "tester"), ("pr_opened", "open_pr", "delivery-loop"), @@ -173,6 +173,11 @@ def _next_delivery( nxt = DELIVERY_STEPS[start] state, step, skill = nxt summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}" + if step == "verify": + summary = ( + "派 tester worker 做开发阶段验证:执行单元测试、新增功能集成测试和静态检查;" + "不执行合并后的完整 tester 回归" + ) if step == "regress": summary = ( "派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester " @@ -323,7 +328,10 @@ def _decide_short( actor="manager", step="ask_user", status="ask_user", - summary="stopAt 未配置,主会话向用户确认 verified / review_ready / validation_ready / released", + summary=( + "stopAt 未配置,主会话向用户确认 develop_verified / review_ready / " + "release_ready / released / deployed" + ), ) if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current): last = _manager(current).get("lastStep") diff --git a/skills/ship/scripts/ship_lib.py b/skills/ship/scripts/ship_lib.py index ebdbc53..7d1ed4a 100644 --- a/skills/ship/scripts/ship_lib.py +++ b/skills/ship/scripts/ship_lib.py @@ -48,6 +48,18 @@ DELIVERY_STATE = frozenset( "deployed", } ) +# User-facing stop points. The two legacy names remain accepted so existing +# ship records continue to resolve to the new phase meanings. +STOP_AT_STATE = { + "develop_verified": "verified", + "review_ready": "pr_opened", + "release_ready": "regression_passed", + "released": "published", + "deployed": "deployed", + "verified": "verified", # legacy alias for develop_verified + "validation_ready": "regression_passed", # legacy alias for release_ready +} +STOP_AT = frozenset(STOP_AT_STATE) SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"}) COMMIT_ROLE = frozenset({"baseline", "merged", "head"}) diff --git a/skills/ship/scripts/validate_ship.py b/skills/ship/scripts/validate_ship.py index e09de94..d838b0d 100644 --- a/skills/ship/scripts/validate_ship.py +++ b/skills/ship/scripts/validate_ship.py @@ -13,6 +13,7 @@ from ship_lib import ( DELIVERY_STATE, KIND, SHIP_STATUS, + STOP_AT, SOURCE_KIND, TASK_STATUS, check_ship_id, @@ -77,6 +78,17 @@ def validate_ship_dir(ship_dir: Path) -> list[str]: if not isinstance(data.get("title"), str) or not str(data.get("title")).strip(): _issue(issues, "title 不能为空") + for path, value in ( + ("stopAt", data.get("stopAt")), + ("delivery.stopAt", (data.get("delivery") or {}).get("stopAt") + if isinstance(data.get("delivery"), dict) else None), + ): + if value not in (None, "") and value not in STOP_AT: + _issue( + issues, + f"{path} 无效: {value!r}({', '.join(sorted(STOP_AT))})", + ) + source = data.get("source") if source is not None: src = _mapping(source, "source", issues) diff --git a/tests/test_ship_record.py b/tests/test_ship_record.py index d8869e8..7146802 100644 --- a/tests/test_ship_record.py +++ b/tests/test_ship_record.py @@ -244,6 +244,64 @@ def _fill_task(ship_dir: Path) -> None: class NextStepTests(unittest.TestCase): + def _prepare_delivery_dispatch( + self, root: Path, ship_id: str, stop_at: str, state: str + ) -> None: + ship_dir = _create(root, ship_id, "short", stop_at=stop_at) + (ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8") + _fill_task(ship_dir) + _patch_current(ship_dir, delivery_state=state) + data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8")) + data["evidence"]["commits"] = [{"sha": "abc1234deadbeef", "role": "head"}] + (ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8") + + def test_stop_at_mappings_cover_each_delivery_boundary(self) -> None: + cases = ( + ("develop_verified", "change_ready", "verify", "verified"), + ("review_ready", "verified", "open_pr", "pr_opened"), + ("release_ready", "merged", "regress", "regression_passed"), + ("released", "artifact_built", "publish", "published"), + ("deployed", "published", "deploy", "deployed"), + ) + for stop_at, state, step, target in cases: + with self.subTest(stop_at=stop_at): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ship_id = f"ship-{stop_at.replace('_', '-')}" + self._prepare_delivery_dispatch(root, ship_id, stop_at, state) + doc = _next(root, ship_id, "short") + self.assertEqual(doc["step"], step) + self.assertEqual(doc["targetState"], target) + + def test_legacy_stop_at_names_resolve_to_new_boundaries(self) -> None: + cases = ( + ("verified", "change_ready", "verify", "verified"), + ("validation_ready", "merged", "regress", "regression_passed"), + ) + for stop_at, state, step, target in cases: + with self.subTest(stop_at=stop_at): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ship_id = f"legacy-{stop_at.replace('_', '-')}" + self._prepare_delivery_dispatch(root, ship_id, stop_at, state) + doc = _next(root, ship_id, "short") + self.assertEqual(doc["step"], step) + self.assertEqual(doc["targetState"], target) + + def test_invalid_stop_at_fails_ship_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + result = _run( + CREATE, + "--project-root", str(root), + "--ship-id", "invalid-stop-at", + "--kind", "short", + "--title", "无效停止点", + "--stop-at", "not_a_stop_point", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("stopAt 无效", result.stderr) + def test_new_short_ship_writes_spec(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)