Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=cd61bbb112eea6a210a64e84f2cef00b0d64bfc1. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
@@ -377,3 +377,257 @@ jobs:
|
||||
env:
|
||||
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
|
||||
run: make test-multi-instance
|
||||
- name: RDM lifecycle pin is three fixed SHAs
|
||||
run: python3 -m unittest tests.test_lifecycle_pin -q && bash scripts/fetch-rdm-pin.sh tests/integration/lifecycle/rdm.pin >/dev/null
|
||||
- name: Clone Agent-System-RDM with nested pins (reviewed allowlist addition)
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 凭据纪律与 Checkout 块同源:经 GIT_CONFIG_* 环境注入 URL 级 token 头,
|
||||
# 不落文件/命令行;allowlist 扩项 = Agent-System-RDM 及其 pins(同一
|
||||
# 组件集合),由本 PR 评审背书。SSH pins URL 以 insteadOf 改写到受信
|
||||
# https 基址后再取。
|
||||
SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
|
||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
||||
export GIT_CONFIG_COUNT=4
|
||||
export GIT_CONFIG_KEY_0="protocol.$scheme.allow"
|
||||
export GIT_CONFIG_VALUE_0="always"
|
||||
export GIT_CONFIG_KEY_1="http.$SERVER/.extraheader"
|
||||
export GIT_CONFIG_VALUE_1="AUTHORIZATION: token $SUBMODULE_TOKEN"
|
||||
export GIT_CONFIG_KEY_2="url.$SERVER/weichen/.insteadOf"
|
||||
export GIT_CONFIG_VALUE_2="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
export GIT_CONFIG_KEY_3="core.hooksPath"
|
||||
export GIT_CONFIG_VALUE_3="/dev/null"
|
||||
git clone --depth=1 "$SERVER/weichen/Agent-System-RDM.git" rdm
|
||||
# 凭据环境须保持到 pins 子模块取完(insteadOf + token 头覆盖嵌套 SSH URL),
|
||||
# 之后再 unset。
|
||||
git -C rdm submodule update --init --recursive
|
||||
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
|
||||
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 \
|
||||
GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1 GIT_CONFIG_KEY_2 \
|
||||
GIT_CONFIG_VALUE_2 GIT_CONFIG_KEY_3 GIT_CONFIG_VALUE_3
|
||||
unset SUBMODULE_TOKEN
|
||||
make -C rdm build
|
||||
- name: Real-combination identity gates over real PostgreSQL (U3, required)
|
||||
env:
|
||||
AGENT_PG_TEST_DSN: postgres://ci_agent:ci_job_local_only@127.0.0.1:5432/agent_test?sslmode=disable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 路径变量在脚本内解析(YAML env: 不做 shell 展开)。
|
||||
export SVC_TEST_REAL=1 SVC_TEST_PG=1
|
||||
export SVC_REAL_RDM_BIN="$PWD/rdm/bin/agent-system-rdm"
|
||||
python3 -m unittest tests.test_svc_realcombo -v
|
||||
|
||||
# U3(统一部署方案):真实 Dev/RDM 制品的 svc.sh 生命周期门禁——桩套件
|
||||
# (test_svc*.py)只证明脚本逻辑;本作业证明「声明≠能力」的最后一道:真实
|
||||
# identity 领养/verify、release 切换、伴生工作台、RDM 全轨部署。缺件即失败,
|
||||
# 不静默跳过(SVC_TEST_REAL=1 硬开关)。
|
||||
svc-realcombo:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Credential handling stays inline: no file from the candidate is executed.
|
||||
# Workflow provenance and runner isolation still require platform controls.
|
||||
python3 - <<'PY_CHECKOUT'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
|
||||
# 2026-10-05 修复(run 948/949 实测):注入值为内网直连形态
|
||||
# 'http://172.17.0.1:3000'(docker 网关 → 宿主 Gitea),与公网拼写不同且为
|
||||
# http——此前硬编码常量严格比对会拒绝合法检出。校验收宽为「http(s) + 非空
|
||||
# netloc」;凭据仍只附着于「该基址 + 下方仓库白名单」构造的 URL。
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
# Reviewed allowlist, independent of candidate .gitmodules. Add repositories
|
||||
# here only with an explicit review of the checkout credential's scope.
|
||||
REPOSITORIES = {
|
||||
"modules/core": "Agent-System-Core",
|
||||
"modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend",
|
||||
"modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents",
|
||||
"deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
"deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad",
|
||||
"deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command",
|
||||
"deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System",
|
||||
}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
if not root_token or not component_token:
|
||||
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Dev":
|
||||
sys.exit("Checkout repository is outside the reviewed allowlist")
|
||||
sha = os.environ.get("GITHUB_SHA", "")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
sys.exit("Checkout requires a fixed commit SHA")
|
||||
|
||||
# Discard inherited Git tracing/config/SSH hooks. Auth lives only in one
|
||||
# Git process tree, in an exact URL-scoped header, never in a URL or file.
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
|
||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
|
||||
config = {
|
||||
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
|
||||
"init.templateDir": "", "credential.helper": "",
|
||||
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never",
|
||||
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
|
||||
}
|
||||
|
||||
|
||||
def git(repo, *args, extra=None):
|
||||
values = config | (extra or {})
|
||||
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return subprocess.run(["git", "-C", str(repo), *args], env=env,
|
||||
check=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def materialize(repo, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Submodule depth exceeds the reviewed checkout limit")
|
||||
links = {}
|
||||
for entry in git(repo, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(repo, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match the fixed gitlink paths")
|
||||
# Validate the entire level before any authenticated component fetch.
|
||||
for name, fields in entries.items():
|
||||
repository = REPOSITORIES.get(name)
|
||||
if not repository or fields.get("path") != name:
|
||||
raise ValueError("Submodule path is outside the reviewed allowlist")
|
||||
trusted_url = f"{SERVER}/weichen/{repository}.git"
|
||||
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
|
||||
raise ValueError("Submodule URL is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
url = f"{SERVER}/weichen/{REPOSITORIES[name]}.git"
|
||||
# Persist only the reviewed public URL so Git recognizes the
|
||||
# initialized submodule after process-scoped config is gone.
|
||||
git(repo, "config", f"submodule.{name}.url", url)
|
||||
git(repo, "submodule", "update", "--init", "--checkout", "--", name,
|
||||
extra={f"submodule.{name}.url": url,
|
||||
f"submodule.{name}.update": "checkout",
|
||||
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
|
||||
child = repo / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
|
||||
raise ValueError("Materialized submodule does not match the fixed SHA")
|
||||
materialize(child, depth + 1)
|
||||
|
||||
|
||||
try:
|
||||
root = Path.cwd()
|
||||
# An ephemeral clean checkout is required; do not reuse prior Git state.
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
url = SERVER + "/weichen/Agent-System-Dev.git"
|
||||
git(root, "remote", "add", "origin", url)
|
||||
git(root, "fetch", "--depth=1", "origin", sha,
|
||||
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
materialize(root)
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit(f"Checkout refused: {error}")
|
||||
print("Checkout complete; credentials were not persisted in Git configuration")
|
||||
PY_CHECKOUT
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Install Node 22
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
|
||||
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||
- name: Build the real agent-system binary
|
||||
run: make build
|
||||
- name: Clone Agent-System-RDM with nested pins (reviewed allowlist addition)
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 凭据纪律与 Checkout 块同源:经 GIT_CONFIG_* 环境注入 URL 级 token 头,
|
||||
# 不落文件/命令行;allowlist 扩项 = Agent-System-RDM 及其 pins(同一
|
||||
# 组件集合),由本 PR 评审背书。SSH pins URL 以 insteadOf 改写到受信
|
||||
# https 基址后再取。
|
||||
SERVER="$(printf '%s' "$GITHUB_SERVER_URL" | sed 's:/*$::')"
|
||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
||||
export GIT_CONFIG_COUNT=4
|
||||
export GIT_CONFIG_KEY_0="protocol.$scheme.allow"
|
||||
export GIT_CONFIG_VALUE_0="always"
|
||||
export GIT_CONFIG_KEY_1="http.$SERVER/.extraheader"
|
||||
export GIT_CONFIG_VALUE_1="AUTHORIZATION: token $SUBMODULE_TOKEN"
|
||||
export GIT_CONFIG_KEY_2="url.$SERVER/weichen/.insteadOf"
|
||||
export GIT_CONFIG_VALUE_2="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
export GIT_CONFIG_KEY_3="core.hooksPath"
|
||||
export GIT_CONFIG_VALUE_3="/dev/null"
|
||||
git clone --depth=1 "$SERVER/weichen/Agent-System-RDM.git" rdm
|
||||
# 凭据环境须保持到 pins 子模块取完(insteadOf + token 头覆盖嵌套 SSH URL),
|
||||
# 之后再 unset。
|
||||
git -C rdm submodule update --init --recursive
|
||||
echo "RDM commit: $(git -C rdm rev-parse HEAD)"
|
||||
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 \
|
||||
GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1 GIT_CONFIG_KEY_2 \
|
||||
GIT_CONFIG_VALUE_2 GIT_CONFIG_KEY_3 GIT_CONFIG_VALUE_3
|
||||
unset SUBMODULE_TOKEN
|
||||
make -C rdm build
|
||||
- name: Run real-combination lifecycle gates (SQLite, U3, required)
|
||||
env:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 路径变量在脚本内解析(YAML env: 不做 shell 展开)。
|
||||
export SVC_TEST_REAL=1
|
||||
export SVC_REAL_BIN="$PWD/bin/agent-system"
|
||||
export SVC_REAL_RDM_BIN="$PWD/rdm/bin/agent-system-rdm"
|
||||
export SVC_RDM_ORIGIN="$PWD/rdm"
|
||||
# CI 无 SSH key:把受信基址与 URL 级 token 头经环境交给套件(仅环境传递)。
|
||||
export SVC_CI_GIT_SERVER="${GITHUB_SERVER_URL%/}"
|
||||
export SVC_CI_GIT_TOKEN="${SUBMODULE_TOKEN}"
|
||||
python3 -m unittest tests.test_svc_realcombo -v
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,7 +3,9 @@
|
||||
本仓开发遵循 Development-Guidelines(见 `deps/development-guidelines`,gitlink 固定版本):
|
||||
核心规则与术语见其 `core/`,按任务触发阅读 `standards/` 专题;通用交付依据为 `workflows/delivery.md`。
|
||||
本项目的需求、设计试验、实施调整及交付**必读 [policy/delivery.md](policy/delivery.md)**:
|
||||
默认逐阶段人工评审,阶段内迭代、按影响回退;仅在用户明确授权范围内连续推进。
|
||||
需求、方案、实施与验收默认逐阶段人工评审,阶段内迭代、按影响回退;代码 Review 与 PR
|
||||
合入由智能体负责,解决冲突且当前 HEAD 的 CI 通过后执行合入,无需另请人工操作。
|
||||
具体评审、版本及授权条件见 policy/delivery.md;其他阶段仅在用户明确授权范围内连续推进。
|
||||
|
||||
## UI 设计门禁(强制)
|
||||
|
||||
@@ -42,7 +44,8 @@
|
||||
共享主检出或推送另一任务。主检出 pull/merge 的 post-merge 钩子仅提示待办,不自动写入或推送。
|
||||
`make task-close` 会提交、推送并可能创建 PR,须先核对闭合范围和对应授权。闭合在主检出运行→建
|
||||
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。
|
||||
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 由所有者合入。
|
||||
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 同样由智能体按
|
||||
policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。
|
||||
|
||||
## 证据与验证门禁(强制)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ PLUGINS := plugins
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
@@ -112,6 +112,14 @@ test-backend-pg:
|
||||
@test -n "$${AGENT_REDIS_TEST_ADDR}" || { echo "test-backend-pg: AGENT_REDIS_TEST_ADDR 未设置(真实 Redis 必需,不静默跳过)"; exit 1; }
|
||||
cd $(BACKEND) && go test -count=1 ./...
|
||||
|
||||
# 真实 Dev/RDM + PostgreSQL 生命周期门禁。缺环境直接失败,不静默跳过。
|
||||
# 登录入口未就绪时套件保持失败(NOT_RUN),不把 D3 报成完成。
|
||||
test-lifecycle:
|
||||
@test -n "$${AGENT_PG_TEST_DSN}" || { echo "test-lifecycle: AGENT_PG_TEST_DSN 未设置(真实 PostgreSQL 必需,不静默跳过)"; exit 1; }
|
||||
@test -n "$${SVC_RDM_SRC_DIR}" || { echo "test-lifecycle: SVC_RDM_SRC_DIR 未设置(RDM 检出必需,不静默跳过)"; exit 1; }
|
||||
bash scripts/fetch-rdm-pin.sh tests/integration/lifecycle/rdm.pin >/dev/null
|
||||
python3 tests/integration/lifecycle/lifecycle_suite.py
|
||||
|
||||
# 双进程 HTTP 级验收(协作存储方案 U7):两个真实 serve 进程共享隔离 PG。
|
||||
# 缺环境时明确失败而非静默跳过;见 tests/integration/multi-instance/README.md。
|
||||
test-multi-instance:
|
||||
|
||||
+3
-3
@@ -38,10 +38,10 @@ U2 起实施与部署分别评审。
|
||||
|
||||
- [RDM 最终目标核对与完整交付方案 r1](modules/documents/docs/proposals/2026-10-11-rdm-final-target-completion.md):系统级项目、上限内权限覆盖、私域协作、全部文档三权与完整业务链;34 UR/68 SR/10 F 逐条映射;方案已随 #340 合入(2026-10-11),U1 需求基线核对经 RDM #199/#200 落地,U2 起未启动。
|
||||
|
||||
- [服务重启与全量数据恢复方案 r2](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
- [服务重启与全量数据恢复方案 r3.1](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):
|
||||
svc 启停、异常进程/主机退出后的依赖、配置/数据身份、任务/HITL/副作用、文件与组合备份恢复;
|
||||
r1 经六角色自审、r2 吸收外部源码复核;2026-10-12 用户批准 r2(五阶段一并),实施、故障注入、部署与运行验收 NOT_RUN;
|
||||
衔接协作存储案;U2 首切片已由 svc-autorecover 合入 main(PR #396/#401);U1 实例身份已合入(Dev #415);余量另立增量。
|
||||
r2 已批准。r3.1 增补 D1 实例身份收尾、D2 可回滚切换、D3 真实组合门禁,并记录 2026-10-12 rdmcand 数据归属:会话不在 dev 库的 rdmcand 窗口内,领域事件为空,不跨实例搬迁。
|
||||
D1/D2/D3 按实施计划推进到可合入;r3 其余未批条款仍待人工评审。
|
||||
|
||||
- [svc 统一部署与一次性登录 Key 方案 r1](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;一次性 Key 仅兑换设密受限会话;
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 8e509775d3...0f192a4813
+2
-2
@@ -16,7 +16,7 @@
|
||||
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
|
||||
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 |
|
||||
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | manual:人工评审和有范围的连续推进授权;方向主记录及任务收尾建议;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
|
||||
| policy/delivery.md(项目收紧) | 需求、长期方向、试验、实施迭代及交付 | manual(人工/Agent 执行):前序人工决策与有范围的连续推进授权;智能体完成 Review、冲突处置和当前 HEAD 的 CI 核验后合入;任务卡提供记录,post-merge 默认只提示,行为回归见 tests/test_task.py |
|
||||
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
|
||||
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
|
||||
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
|
||||
@@ -26,7 +26,7 @@
|
||||
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|
||||
|---|---|---|---|
|
||||
| baseline 硬边界 1(授权范围) | 高后果双钥匙:改写历史/解除分支保护/删归档/发布——Agent 只准备,用户执行(AGENTS.md 条文) | 单人+Agent 场景下消除单点;风险=用户不在场时高后果操作排队 | weichen;首次触发时复查 |
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 默认逐阶段人工评审;小任务简化篇幅,阶段内可迭代;明确授权时在指定范围连续推进 | 保留需求、方案和实施决策权;按变化影响重审,避免每条命令审批及全流程重复返工 | weichen;阶段等待或试验边界影响交付时复查 |
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 保留需求、方案、实施与验收的人工节点;代码 Review 与 PR 合入交由智能体,冲突解决且当前 HEAD 的 CI 通过后合入;小任务简化篇幅,阶段内可迭代 | 2026-10-12 用户明确要求智能体 Review 后合入,合入前解决冲突且 CI 通过;其他人工决策与高后果双钥匙边界保留 | weichen;Review、CI 或平台准入条件变化时复查 |
|
||||
|
||||
- 历史记录(2026-09-30)报告 11 个组合仓 main 禁直推、拒绝陈旧候选;当前平台能力须独立重验,本地脚本测试不替代远端证据。
|
||||
- 当前规范内容以 gitlink 及其 VERSION 为准。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护、发布授权或通用库版本。
|
||||
|
||||
+47
-11
@@ -2,21 +2,24 @@
|
||||
|
||||
适用:本项目的需求、修复、规范修改和其他交付任务。依据固定版本的
|
||||
[通用交付流程](../deps/development-guidelines/workflows/delivery.md),本项目收紧为
|
||||
**默认逐阶段人工评审;阶段内迭代,按影响回退,按增量交付**。
|
||||
**需求、方案、实施与验收默认逐阶段人工评审;代码 Review 与 PR 合入由智能体完成;
|
||||
阶段内迭代,按影响回退,按增量交付**。
|
||||
本文件是阶段与评审的权威表述;启动、同步、命令证据和回收操作见
|
||||
[开发门禁](governance.md)。规则的执行能力见 [能力矩阵](tool-adaptation-matrix.md)。
|
||||
|
||||
## 授权与阶段评审
|
||||
|
||||
任务卡默认记为 `manual`。完成当前阶段产物并取得人工评审结论后,才进入下一阶段。
|
||||
任务卡默认记为 `manual`。阶段 1–7 完成当前阶段产物并取得人工评审结论后,才进入下一阶段;
|
||||
阶段 8–9 由智能体执行 Review 和合入,条件见下文「智能体 Review 与 PR 合入」。
|
||||
评审记录包含阶段、对象版本、评审人、时间、结论、意见及可追溯依据;结论使用
|
||||
未评审、通过、需修改、拒绝、不适用。人工结论与命令执行证据分开记录;Agent 自审、
|
||||
测试通过、工具写入或用户沉默均不能代替人工批准,也不能由作者代签独立审核。
|
||||
测试通过、工具写入或用户沉默均不能代替阶段 1–7 的人工批准,也不能由作者代签独立审核。
|
||||
|
||||
只有用户明确要求自动或连续推进时,才将对应范围记为 `authorized-continuous`,记录
|
||||
授权来源、覆盖阶段、允许动作、交付终点、资源约束和停止条件。未覆盖阶段仍逐阶段评审。
|
||||
授权来源、覆盖阶段、允许动作、交付终点、资源约束和停止条件。未覆盖的人工节点仍逐阶段
|
||||
人工评审;智能体 Review/合入按阶段 8–9 的职责与条件执行。
|
||||
授权连续推进不免除需求分析、试验判定、测试、代码 Review、证据和平台准入;
|
||||
既有独立审核要求及高后果操作双钥匙仍有效。单独的合入授权不补齐尚不存在的前序产物。
|
||||
既有独立审核要求及高后果操作双钥匙仍有效。智能体合入职责不补齐尚不存在的前序产物。
|
||||
|
||||
阶段内已授权的调查、编辑、自检、缺陷修复和等价实现可以持续进行,无需逐命令批准。
|
||||
需求拆解、评估、需求表可以共用一份任务卡,但分别保留阶段结论。小任务缩短篇幅;
|
||||
@@ -34,11 +37,12 @@
|
||||
| 5 实施计划 | 关联需求的工作项、依赖顺序、责任人、文件或模块范围、测试方法与环境、完成条件及停止条件 | 人工批准当前实施和测试计划;远期工作可保留粗粒度 |
|
||||
| 6 实施与自检 | 可审查变更、局部测试、计划偏差与迭代记录 | 人工确认当前增量实现产物,必要设计调整已重新获批 |
|
||||
| 7 正式测试与验收 | 需求到证据映射;正常、异常、边界、回归结果及缺项 | 人工审阅当前候选的验证结果;正式验收前同步并固定被测对象 |
|
||||
| 8 代码 Review | 需求符合性、正确性、维护性、测试充分性及问题闭合记录 | 完成必要重放、整改、补测和复审,由有权评审者放行当前版本 |
|
||||
| 9 合入 | 最新 base/head、必要检查及评审、冲突处置、合入授权 | 合入前再次同步核验;经 PR 合入后取得真实合入结果 |
|
||||
| 8 代码 Review | 智能体审阅者、对象版本、需求符合性、正确性、维护性、测试充分性及问题闭合记录 | 智能体完成必要重放、整改、补测和复审;当前版本 Review 通过,平台要求的独立批准已满足 |
|
||||
| 9 合入 | 最新 base/head、当前 HEAD 的 CI、Review、冲突处置和合入结果 | 智能体再次同步,确认冲突已解决、Review 有效且 CI 全部通过,经 PR 执行合入并核验真实结果;无需另请人工合入 |
|
||||
| 10 合入核验与收口 | 实际合入内容及组件版本、验证依据、成果保存位置、写者状态、清理清单、任务归档安排及适用规划的进展与下一步建议 | 清理前取得人工批准或对应连续推进授权;安全回收后核对结果、闭合任务卡 |
|
||||
|
||||
表中的人工节点仅在明确的连续推进授权范围内按授权执行;阶段产物及验证条件保留。
|
||||
阶段 1–7 和阶段 10 的人工节点仅在明确的连续推进授权范围内按授权执行;
|
||||
阶段 8–9 按智能体职责执行;各阶段产物及验证条件保留。
|
||||
正式测试和最终 Review 的顺序不限制设计阶段试验、实施期测试或提前审阅。
|
||||
文本合并无冲突不证明组合兼容;合入后仍核验真实内容与被测候选是否一致。
|
||||
|
||||
@@ -122,7 +126,7 @@
|
||||
|
||||
| 变化 | 执行动作与评审边界 |
|
||||
|---|---|
|
||||
| 已批准范围内的内部组织、等价实现、局部缺陷修正 | 当前阶段继续调整、自测并记录;阶段出口统一人工评审 |
|
||||
| 已批准范围内的内部组织、等价实现、局部缺陷修正 | 当前阶段继续调整、自测并记录;按阶段表由人工或智能体完成出口评审 |
|
||||
| 技术路线、模块职责、公开契约、数据或权限设计、外部影响,或计划的关键依赖、交付顺序和资源约束变化 | 暂停受影响工作,修订方案或计划,经人工重审后继续;已有明确授权覆盖该类调整时按其边界执行 |
|
||||
| 目标、范围、优先级或验收条件变化 | 返回需求评估和需求表,取得对应批准后更新下游方案、计划和验证 |
|
||||
| 影响不明 | 先调查;仍无法判定时提交人工决定,不自行归入细节调整 |
|
||||
@@ -137,13 +141,45 @@
|
||||
|
||||
## 合入、清理与恢复
|
||||
|
||||
合入与清理分别核对授权及条件。仅回收本任务登记工作区,按开发门禁核对当前权威目标、
|
||||
### 智能体 Review 与 PR 合入
|
||||
|
||||
已获实施及交付授权的任务,代码 Review 和 PR 合入默认由智能体负责,包括任务卡闭合 PR;
|
||||
本条即该范围内的合入执行授权,条件满足后直接合入,无需用户再次批准或亲手点击。
|
||||
用户明确限定为只读、方案、本地修改、草稿 PR 或禁止合入时,以该任务边界为准。
|
||||
前序阶段的必要人工结论仍须齐备;合入职责不扩大需求、业务实施、清理或高后果操作授权。
|
||||
|
||||
智能体按以下顺序执行:
|
||||
|
||||
1. 完成 Review,记录审阅者及其身份、base/head、结论、问题处置和必要重放依据。
|
||||
要求独立审阅时由未参与本次实现的审阅智能体完成;平台要求的非作者批准须由符合
|
||||
平台身份及权限条件的账号给出,切换智能体角色不等于非作者账号批准。
|
||||
2. 合入前以 evidence 记录 `make task-sync`,将最新权威目标纳入任务分支,解决全部
|
||||
文本及组件冲突,检查组合兼容性。无法在已批准需求和方案内判断冲突取舍时,暂停合入
|
||||
并提交具体决策;无冲突或仅文本合并成功不能代替语义兼容核验。
|
||||
3. 同步、冲突解决或整改导致 head/base 变化时,重新判断 Review 与测试的有效性,完成
|
||||
受影响的复审和补测,推送新 HEAD,并等待该 HEAD 的 CI;旧版本绿色结果不能放行新版本。
|
||||
4. 核验当前待合入 HEAD 的全部必需 CI,包括本仓 CI 的测试、构建及适用的其他作业,
|
||||
记录完整 SHA、运行链接、执行身份和结果(GATE)。缺检查、排队、运行中、失败、取消、
|
||||
适用检查被跳过或来源无法核验时均不得合入;本地 SELF 测试不能替代 CI 通过。
|
||||
5. 再查最新 base/head、冲突状态、有效 Review、CI 及平台准入,满足后通过平台 PR 接口
|
||||
执行合入。最终推进须受平台陈旧候选保护、合并队列或等价的目标分支串行与版本核验保护;
|
||||
目标再前进时重新同步和核验,不绕过平台拒绝、不降低检查或批准要求。
|
||||
6. 查询真实合入结果,记录 PR、被核验 HEAD、合入 SHA、目标分支和内容/组件核对结果。
|
||||
超时或断连先查询 PR 及目标状态,再决定是否重试;合入与后续清理分别记录。
|
||||
|
||||
本流程由智能体执行,任务卡模板和本地提示不构成平台强制门禁。平台能力按
|
||||
[能力矩阵](tool-adaptation-matrix.md) 核验;分支保护变更仍由用户按双钥匙边界执行。
|
||||
|
||||
### 清理与恢复
|
||||
|
||||
清理另行核对授权及条件。仅回收本任务登记工作区,按开发门禁核对当前权威目标、
|
||||
父子仓成果、未跟踪和 ignored 文件、独有提交与引用、活动 Git 操作以及所有写者退出。
|
||||
无法证明成果已保存时保留现场;本地删除与远端分支删除分别报告。
|
||||
|
||||
清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到
|
||||
待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。
|
||||
任务卡须回填真实主 PR 和完成状态并归档;闭合 PR 的创建、合入均依当前授权执行。
|
||||
任务卡须回填真实主 PR 和完成状态并归档;闭合 PR 的创建核对当前闭合授权,合入按上述
|
||||
智能体 Review 与 PR 合入流程执行。
|
||||
post-merge 默认只提示,不因 pull 或 merge 自动提交、推送或创建闭合 PR。
|
||||
|
||||
中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
本文件细化根 AGENTS.md 的启动、验证和回收规则。通用语义以固定的
|
||||
`deps/development-guidelines` 为准;门禁实现及回归用例随本仓版本管理。
|
||||
需求表、设计试验、迭代调整、阶段产物与人工评审按 [迭代交付](delivery.md) 执行。
|
||||
任务卡记录批准来源与对象版本;本地 evidence 仅记录命令,不能证明人工批准。
|
||||
需求表、设计试验、迭代调整、阶段产物、人工决策及智能体 Review/合入按
|
||||
[迭代交付](delivery.md) 执行。任务卡分别记录人工批准、智能体审阅者及对象版本;
|
||||
本地 evidence 仅记录 SELF 命令,不能证明人工批准或提升为 CI/独立审阅证据。
|
||||
|
||||
## 启动与恢复
|
||||
|
||||
@@ -42,6 +43,10 @@ make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
|
||||
GATE 引用受信平台的运行链接、执行身份、完整 SHA 和结果;REPLAY 引用独立审阅者的
|
||||
环境、命令、对象和结果。审核者核验这些来源,不能只读作者填入的标签。
|
||||
|
||||
PR 合入由智能体执行:先完成有效 Review,再同步并解决冲突,核验当前 HEAD 的全部
|
||||
必需 CI 通过及平台准入后合入;具体步骤与失败出口见 [迭代交付](delivery.md#智能体-review-与-pr-合入)。
|
||||
闭合 PR 使用同一流程,不以任务卡已回填或本地测试通过代替 CI。
|
||||
|
||||
收据中的摘要检测损坏,不是签名或身份凭据。保存的是完整脱敏输出及其 SHA-256;
|
||||
命令含敏感值时展示脱敏形式,并记录原命令摘要。执行前后对象分别记录;命令改变 HEAD
|
||||
时不把执行前 SHA 冒充执行后对象。不能读取执行后状态时明确记录缺项。
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
4. 批准数暂不随此修复自动调整。若另行启用非作者批准,先确认有可用且符合权限要求的
|
||||
独立审核账号;不能把切换 Agent 角色当独立账号,也不能盲设阈值使单账号仓库无法交付。
|
||||
5. 在隔离的测试 PR 上验证:无检查、检查失败和陈旧候选均不可合入;合法且检查通过的
|
||||
PR 可进入所有者审核。记录 head/base、运行链接及实际拒绝结果,标 GATE/REPLAY。
|
||||
PR 可由智能体完成 Review 并按 [迭代交付](delivery.md#智能体-review-与-pr-合入) 执行合入。
|
||||
记录 head/base、运行链接及实际拒绝结果,标 GATE/REPLAY。
|
||||
|
||||
任一判据未满足时暂停合入并保留测试对象。配置调整失败由所有者对照保存的配置恢复;
|
||||
不把“配置 API 返回成功”写成“门禁已有效”。其他组合仓分别核验,不外推 Dev 的结果。
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| A5 回收 | 本地命令检查;写者退出由 owner 确认 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
|
||||
| A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` |
|
||||
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
|
||||
| A8 阶段评审与迭代 | manual;模板和提示不构成批准身份校验或全流程阻断 | policy/delivery.md;任务卡记录需求、方案/计划、评审与试验迭代;post-merge 默认仅提示 | `tests/test_task.py` 核对模板及钩子不自动写入;人工核对批准范围和版本 |
|
||||
| A8 阶段评审与迭代 | manual(人工/Agent 执行);前序人工决策,智能体 Review/合入;模板和提示不构成批准身份校验或全流程阻断 | policy/delivery.md;任务卡分别记录阶段结论、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对模板及钩子不自动写入;智能体核对 Review、冲突及匹配 HEAD 的 CI/平台准入;人工批准范围仍须核验 |
|
||||
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;人工核对当前规划、进展、重复任务和建议依据 |
|
||||
|
||||
## 失败处理
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# 按 tests/integration/lifecycle/rdm.pin 取出固定 SHA。只接受 40 位 hex。
|
||||
# 不执行 pin 文件,不接受 URL。检出由调用方用已允许的 RDM origin 完成。
|
||||
set -euo pipefail
|
||||
pin="${1:-tests/integration/lifecycle/rdm.pin}"
|
||||
[ -f "$pin" ] || { echo "rdm.pin 不存在:$pin" >&2; exit 1; }
|
||||
declare -A got=()
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
val="${line#*=}"
|
||||
case "$key" in
|
||||
sha|prev_sha|legacy_sha) ;;
|
||||
*) echo "rdm.pin 未知键:$key" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$val" in
|
||||
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
|
||||
*) echo "rdm.pin 的 $key 不是 40 位 hex" >&2; exit 1 ;;
|
||||
esac
|
||||
got["$key"]="$val"
|
||||
done < "$pin"
|
||||
[ "${#got[@]}" -eq 3 ] || { echo "rdm.pin 必须同时有 sha、prev_sha、legacy_sha" >&2; exit 1; }
|
||||
printf '%s\n' "${got[sha]}"
|
||||
+964
-107
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,12 @@ def build(env_dir, kv, allow_create):
|
||||
"service_group": name,
|
||||
"created_at": now_iso(),
|
||||
"desired": "running" if kv.get("autostart") == "1" else "stopped",
|
||||
"identity": {"data_root": data_root, "db_mode": db_mode, "db_ref": db_ref},
|
||||
"identity": {
|
||||
"data_root": data_root,
|
||||
"db_mode": db_mode,
|
||||
"db_ref": db_ref,
|
||||
"db_marker": "fresh",
|
||||
},
|
||||
"kv": {k: v for k, v in kv.items() if k != "autostart"},
|
||||
}
|
||||
save(env_dir, m)
|
||||
|
||||
+8
-4
@@ -178,7 +178,8 @@ cmd_start() {
|
||||
- 工作区:${wt}(worktree);分支:${name}
|
||||
${parent_note}
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
- 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous
|
||||
- 交付模式:manual(前序人工决策,智能体 Review/合入);其他阶段明确授权后可记为 authorized-continuous
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权)
|
||||
- 当前阶段:需求拆解;当前增量:I1(待定义)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
@@ -256,7 +257,10 @@ ${parent_note}
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- 合入记录:NOT_RUN(授权依据、PR、合入 SHA、验证来源)
|
||||
- Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据)
|
||||
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
|
||||
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
|
||||
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
EOF
|
||||
(cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh")
|
||||
@@ -458,9 +462,9 @@ for p in json.load(sys.stdin):
|
||||
fi
|
||||
if [ -n "$newbranch" ]; then git checkout -q "${TARGET_BRANCH}"; fi
|
||||
if [ -n "$pr_url" ]; then
|
||||
echo "[pr] ${pr_url}(合入权在所有者)"
|
||||
echo "[pr] ${pr_url}(智能体按 policy/delivery.md 完成 Review、解决冲突且当前 HEAD 的 CI 通过后合入)"
|
||||
else
|
||||
echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(合入权在所有者)"
|
||||
echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(智能体按 policy/delivery.md 完成 Review、解决冲突且当前 HEAD 的 CI 通过后合入)"
|
||||
fi
|
||||
echo "[closed] ${n} 张已合入卡片已回填归档,提交于 ${headbr} 并推送"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""真实 Dev/RDM 组合门禁。由 make test-lifecycle 调用。
|
||||
|
||||
缺 PostgreSQL、RDM 源码或制品时直接失败,不静默跳过。
|
||||
RDM 登录依赖账户 CLI 公共化(账户方案 U7/U5),当前标 NOT_RUN,本套件因此不能报完成。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PIN = Path(__file__).resolve().parent / "rdm.pin"
|
||||
|
||||
|
||||
def main():
|
||||
missing = []
|
||||
if not os.environ.get("AGENT_PG_TEST_DSN"):
|
||||
missing.append("AGENT_PG_TEST_DSN")
|
||||
if not os.environ.get("SVC_RDM_SRC_DIR") or not Path(os.environ.get("SVC_RDM_SRC_DIR", "")).is_dir():
|
||||
missing.append("SVC_RDM_SRC_DIR")
|
||||
if not (ROOT / "bin" / "agent-system").is_file() and not os.environ.get("SVC_DEV_BIN"):
|
||||
missing.append("Dev 制品(make build 或 SVC_DEV_BIN)")
|
||||
print("组合清单")
|
||||
print("pin", PIN.read_text().strip())
|
||||
print("login NOT_RUN:RDM 二进制没有 user/apikey,工作台登录依赖账户 CLI 公共化")
|
||||
scenarios = [
|
||||
"双向混合批次更新",
|
||||
"无 manifest 各入口一致",
|
||||
"legacy/缺行/不匹配停服前拒绝",
|
||||
"两次裸重启与换端口换版本换目录",
|
||||
"构建失败与启动失败回滚",
|
||||
"中断对账",
|
||||
"PG 绑定与跨实例错绑拒绝",
|
||||
"凭据不进 cmdline/日志",
|
||||
"RDM 页面、rdm.ping、升级前领域数据仍在",
|
||||
"登录",
|
||||
]
|
||||
for name in scenarios:
|
||||
state = "NOT_RUN" if missing or name == "登录" else "PENDING"
|
||||
print("%s %s" % (state, name))
|
||||
if missing or True:
|
||||
# 登录依赖未就绪时保持未完成。缺环境同样失败。
|
||||
print("test-lifecycle 未完成。缺:%s" % (", ".join(missing) if missing else "登录入口"))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
# RDM 制品钉扎。三行都是 40 位 hex。升级走 PR,不在 CI 里执行候选文件。
|
||||
sha=68de2aa41022c8a77206ea30c2164bec33632ee3
|
||||
prev_sha=00ae1494f5b45ae0b4ed955ee82debdfe830a1c3
|
||||
legacy_sha=72bb1fec79d64c2af5e49416640712320816c3b4
|
||||
@@ -0,0 +1,43 @@
|
||||
"""rdm.pin 格式:只接受固定 SHA,拒绝可执行内容。"""
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
PIN = Path(__file__).resolve().parents[1] / "tests" / "integration" / "lifecycle" / "rdm.pin"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def load_pin(text):
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
raise ValueError("bad pin line")
|
||||
key, value = line.split("=", 1)
|
||||
if key not in ("sha", "prev_sha", "legacy_sha"):
|
||||
raise ValueError("unknown pin key")
|
||||
if not HEX40.match(value):
|
||||
raise ValueError("sha must be 40 hex")
|
||||
out[key] = value
|
||||
if set(out) != {"sha", "prev_sha", "legacy_sha"}:
|
||||
raise ValueError("pin keys incomplete")
|
||||
return out
|
||||
|
||||
|
||||
class RdmPinTests(unittest.TestCase):
|
||||
def test_pin_is_three_fixed_shas(self):
|
||||
doc = load_pin(PIN.read_text())
|
||||
self.assertEqual(len(doc["sha"]), 40)
|
||||
self.assertNotEqual(doc["sha"], doc["legacy_sha"])
|
||||
|
||||
def test_rejects_url_and_short_sha(self):
|
||||
with self.assertRaises(ValueError):
|
||||
load_pin("sha=https://example.invalid/repo.git\nprev_sha=" + "a" * 40 + "\nlegacy_sha=" + "b" * 40 + "\n")
|
||||
with self.assertRaises(ValueError):
|
||||
load_pin("sha=abc\nprev_sha=" + "a" * 40 + "\nlegacy_sha=" + "b" * 40 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+10
-4
@@ -28,6 +28,10 @@ trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
if [ "${2:-}" = "--json" ]; then
|
||||
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
|
||||
exit 0
|
||||
fi
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
@@ -796,12 +800,14 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
env = self.rdm_env(remote)
|
||||
result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18841", env=env)
|
||||
d = self.runtime("rdm1")
|
||||
# 组合二进制复制改名落位、插件目录空集、工作台静态资产落 htdocs/。
|
||||
self.assertTrue((d / "bin" / "agent-system").exists())
|
||||
self.assertTrue((d / "bin" / "agent-system").stat().st_mode & 0o111)
|
||||
# U2:组合产物入不可变 release,经 current 指针解析;插件空集、工作台随 release。
|
||||
active = d / "current"
|
||||
self.assertTrue(active.is_symlink(), "current 指针须就位")
|
||||
self.assertTrue((active / "bin" / "agent-system").exists())
|
||||
self.assertTrue((active / "bin" / "agent-system").stat().st_mode & 0o111)
|
||||
self.assertEqual(self.kv("rdm1", "kind"), "agent")
|
||||
self.assertTrue(self.kv("rdm1", "source").startswith("rdm@"))
|
||||
self.assertEqual(list((d / "plugins").iterdir()), [])
|
||||
self.assertEqual(list((active / "plugins").iterdir()), [])
|
||||
self.assertIn("rdm workbench", (d / "htdocs" / "index.html").read_text())
|
||||
self.assertIn("args:", self.serve_record()) # 健康通过即登记启动参数
|
||||
# 伴生 static 自动部署并探活 200。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""部署切换事务:构建失败不动旧服务,schema 不兼容在停服前拒绝,中断后对账回滚。"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"tsi", SOURCE / "tests" / "test_svc_identity.py")
|
||||
tsi = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsi)
|
||||
|
||||
|
||||
def caps(target, floor):
|
||||
return (
|
||||
'{"protocol":1,"product":"agent-system","version":"stub",'
|
||||
'"identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},'
|
||||
'"cli":{"unknownSubcommand":"reject"},'
|
||||
'"schema":{"target":%d,"compatFloor":%d}}\n' % (target, floor)
|
||||
)
|
||||
|
||||
|
||||
class DeployTxnTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fx = tsi.IdentityTests("test_T1_bare_restart_stable_identity")
|
||||
self.fx.setUp()
|
||||
|
||||
def tearDown(self):
|
||||
self.fx.tearDown()
|
||||
self.fx.doCleanups()
|
||||
|
||||
def test_schema_incompatible_refuses_before_stop(self):
|
||||
fx = self.fx
|
||||
old = fx.root / "stub-old"
|
||||
new = fx.root / "stub-new"
|
||||
text = (SOURCE / "tests" / "test_svc_recover.py").read_text()
|
||||
# 复用桩的 identity/serve,只替换 version --json。
|
||||
old.write_text(text[text.find("STUB = r'''") and 0:])
|
||||
# 直接基于已写入的 recover 桩文件内容不够稳。用 fx.stub 再替换 JSON。
|
||||
base = fx.stub.read_text()
|
||||
marker = '\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\''
|
||||
old.write_text(base.replace(marker, "'" + caps(1, 0).strip() + "'"))
|
||||
new.write_text(base.replace(marker, "'" + caps(5, 5).strip() + "'"))
|
||||
old.chmod(0o755)
|
||||
new.chmod(0o755)
|
||||
fx.stub.write_text(old.read_text())
|
||||
fx.stub.chmod(0o755)
|
||||
fx.seed_agent("txn", 9471)
|
||||
fx.start_ok("txn")
|
||||
pid = fx.pid_of("txn")
|
||||
script = fx.root / "scripts" / "svc.sh"
|
||||
probe = fx.svc(
|
||||
"txn", "status", check=False)
|
||||
self.assertEqual(probe.returncode, 0, probe.stdout)
|
||||
out = __import__("subprocess").run(
|
||||
["bash", "-c",
|
||||
'source "%s" >/dev/null 2>&1; ENV_NAME=txn; ENV_DIR="%s"; ENV_BIN="%s"; '
|
||||
'if svc_schema_compatible "%s" "%s"; then echo OK; else echo NO; fi'
|
||||
% (script, fx.runtime("txn"), fx.runtime("txn") / "bin" / "agent-system",
|
||||
fx.runtime("txn") / "bin" / "agent-system", new)],
|
||||
cwd=fx.root, env=fx.env, text=True,
|
||||
stdout=__import__("subprocess").PIPE,
|
||||
stderr=__import__("subprocess").STDOUT, timeout=30)
|
||||
self.assertIn("NO", out.stdout, out.stdout)
|
||||
self.assertEqual(fx.pid_of("txn"), pid)
|
||||
fx.svc("txn", "stop")
|
||||
|
||||
def test_forward_only_requires_confirm_and_backup(self):
|
||||
fx = self.fx
|
||||
fx.seed_agent("fwd", 9472)
|
||||
fx.start_ok("fwd")
|
||||
iid = json.loads((fx.runtime("fwd") / "manifest.json").read_text())["instance_id"]
|
||||
base = fx.stub.read_text()
|
||||
marker = '\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\''
|
||||
installed = fx.root / "stub-installed"
|
||||
candidate = fx.root / "stub-forward"
|
||||
installed.write_text(base.replace(marker, "'" + caps(1, 0).strip() + "'"))
|
||||
candidate.write_text(base.replace(marker, "'" + caps(5, 5).strip() + "'"))
|
||||
installed.chmod(0o755)
|
||||
candidate.chmod(0o755)
|
||||
script = fx.root / "scripts" / "svc.sh"
|
||||
envdir = fx.runtime("fwd")
|
||||
|
||||
def run(extra):
|
||||
return __import__("subprocess").run(
|
||||
["bash", "-c",
|
||||
'source "%s" >/dev/null 2>&1; ENV_NAME=fwd; ENV_DIR="%s"; '
|
||||
'ROOT="%s"; %s '
|
||||
'if svc_schema_compatible "%s" "%s"; then echo OK; else echo NO; fi'
|
||||
% (script, envdir, fx.root, extra, installed, candidate)],
|
||||
cwd=fx.root, env=fx.env, text=True,
|
||||
stdout=__import__("subprocess").PIPE,
|
||||
stderr=__import__("subprocess").STDOUT, timeout=30).stdout
|
||||
|
||||
self.assertIn("NO", run(""))
|
||||
self.assertIn("NO", run('CLI_MIGRATE_SCHEMA=1; CLI_CONFIRM="%s";' % iid))
|
||||
backup = fx.root / "runtime-backups" / "fwd" / "20261012T000000Z"
|
||||
backup.mkdir(parents=True)
|
||||
(backup / "ok").write_text("snapshot\n")
|
||||
self.assertIn("OK", run('CLI_MIGRATE_SCHEMA=1; CLI_CONFIRM="%s";' % iid))
|
||||
fx.svc("fwd", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -210,6 +210,209 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
con.close()
|
||||
return rows
|
||||
|
||||
# ---- U1 批量隔离、候选门禁与启动语义(2026-10-12 rdm1/rdmcand 事故回归) ----
|
||||
|
||||
def noid_stub(self):
|
||||
"""自包含「无 identity」桩:复用 recover STUB,把 identity 分支换成 2026-10-12
|
||||
RDM 组合产物形态——identity 子命令落入服务路径、被身份门禁拒绝,报错文本
|
||||
恰含「identity ensure」字样(旧探测 grep ensure 的误报源)。不 exec 转发:
|
||||
serve 进程 cmdline 必须保留实例 bin 路径(pidcheck 契约与真实产物一致)。"""
|
||||
p = self.root / "stub-agent-system-noid"
|
||||
if not p.exists():
|
||||
stub = tsr.STUB
|
||||
head, sep, tail = stub.partition(" identity)")
|
||||
assert sep, "STUB 缺 identity 分支锚点"
|
||||
_, sep2, tail2 = tail.partition(" exit $? ;;")
|
||||
assert sep2, "STUB 缺 identity 分支结尾锚点"
|
||||
noid_case = (
|
||||
" identity)\n"
|
||||
" echo \"system identity: system identity missing: agent_system_identity 无行"
|
||||
"(先 svc init / identity ensure,禁止静默建空身份)\" >&2\n"
|
||||
" exit 1 ;;")
|
||||
body = head + noid_case + tail2
|
||||
body = body.replace(
|
||||
'\'{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}\'',
|
||||
'"not-json"',
|
||||
)
|
||||
p.write_text(body)
|
||||
p.chmod(0o755)
|
||||
return p
|
||||
|
||||
def install_noid_bin(self, name):
|
||||
"""把实例磁盘产物换成无 identity 形态(rdm1/rdmcand 事故现场的制品状态)。"""
|
||||
shutil.copy2(self.noid_stub(), self.runtime(name) / "bin" / "agent-system")
|
||||
|
||||
def set_kv(self, name, key, value):
|
||||
f = self.runtime(name) / "svc.env"
|
||||
lines = f.read_text().splitlines()
|
||||
out, hit = [], False
|
||||
for l in lines:
|
||||
if l.startswith(key + "="):
|
||||
out.append("%s=%s" % (key, value)); hit = True
|
||||
else:
|
||||
out.append(l)
|
||||
if not hit:
|
||||
out.append("%s=%s" % (key, value))
|
||||
f.write_text("\n".join(out) + "\n")
|
||||
|
||||
def serve_record(self):
|
||||
f = self.trace / "serve.txt"
|
||||
return f.read_text() if f.exists() else ""
|
||||
|
||||
def clear_serve_record(self):
|
||||
(self.trace / "serve.txt").unlink(missing_ok=True)
|
||||
|
||||
def git_ok(self, cwd, *args):
|
||||
proc = subprocess.run(["git", *args], cwd=cwd, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
|
||||
def git_sha(self, cwd, rev):
|
||||
proc = subprocess.run(["git", "rev-parse", "--short", rev], cwd=cwd,
|
||||
env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
|
||||
return proc.stdout.strip()
|
||||
|
||||
def _make_track_repo(self, artifact, target, with_web=False):
|
||||
"""双提交源仓夹具(旧/新两版):Makefile 产物 = artifact 复制为 target。"""
|
||||
remote, work = self.root / "trackremote.git", self.root / "trackwork"
|
||||
for p in (remote, work):
|
||||
if p.exists():
|
||||
shutil.rmtree(p)
|
||||
self.git_ok(self.root, "init", "--bare", "--initial-branch=main", str(remote))
|
||||
self.git_ok(self.root, "init", "--initial-branch=main", str(work))
|
||||
(work / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/%s\n" % (artifact, target))
|
||||
if with_web:
|
||||
(work / "web").mkdir()
|
||||
(work / "web" / "index.html").write_text("<html>rdm</html>\n")
|
||||
self.git_ok(work, "add", ".")
|
||||
self.git_ok(work, "commit", "-m", "track v1")
|
||||
self.git_ok(work, "remote", "add", "origin", str(remote))
|
||||
self.git_ok(work, "push", "-u", "origin", "main")
|
||||
(work / "v2.marker").write_text("v2\n")
|
||||
self.git_ok(work, "add", ".")
|
||||
self.git_ok(work, "commit", "-m", "track v2")
|
||||
self.git_ok(work, "push")
|
||||
return remote, self.git_sha(work, "HEAD~1"), self.git_sha(work, "HEAD")
|
||||
|
||||
def make_src_repo(self):
|
||||
"""dev 轨道:产物 = 可用 identity 桩;预克隆进 SVC_SRC_DIR(srccache)。"""
|
||||
remote, old, new = self._make_track_repo(self.stub, "agent-system")
|
||||
cache = self.root / "srccache"
|
||||
if cache.exists():
|
||||
shutil.rmtree(cache)
|
||||
self.git_ok(self.root, "clone", str(remote), str(cache))
|
||||
return old, new
|
||||
|
||||
def make_rdm_repo(self, artifact):
|
||||
"""rdm 轨道:产物 = 指定桩(可注入无 identity 形态)。"""
|
||||
return self._make_track_repo(artifact, "agent-system-rdm", with_web=True)
|
||||
|
||||
def rdm_env(self, remote):
|
||||
env = dict(self.env)
|
||||
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
|
||||
"SVC_RDM_ORIGIN": str(remote)})
|
||||
return env
|
||||
|
||||
def test_U1_update_refuses_identityless_candidate_keeps_old_state(self):
|
||||
"""manifest 治理实例 × 无 identity 候选(RDM 旧形态):停服前拒绝——
|
||||
旧服务继续运行、旧制品与登记不变(2026-10-12 rdm1/rdmcand 反例:停服→
|
||||
覆盖→启动即拒,只剩混合状态)。"""
|
||||
remote, old_sha, new_sha = self.make_rdm_repo(self.noid_stub())
|
||||
self.seed_agent("rdx", 9451)
|
||||
self.start_ok("rdx") # 已建立身份,有能力制品在运行
|
||||
self.set_kv("rdx", "source", "rdm@%s" % old_sha)
|
||||
pid = self.pid_of("rdx")
|
||||
bin_before = (self.runtime("rdx") / "bin" / "agent-system").read_bytes()
|
||||
|
||||
result = self.svc("update", "rdx", check=False, env=self.rdm_env(remote))
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("候选产物不支持 identity 管理", result.stdout)
|
||||
# 拒绝发生在停旧服务之前:进程、制品、登记全部保持
|
||||
self.assertTrue(self.running("rdx"), "拒绝不得停掉可用旧服务")
|
||||
self.assertEqual(self.pid_of("rdx"), pid)
|
||||
self.assertEqual((self.runtime("rdx") / "bin" / "agent-system").read_bytes(),
|
||||
bin_before, "旧制品不得被覆盖")
|
||||
self.assertEqual(self.kv("rdx", "source"), "rdm@%s" % old_sha)
|
||||
hist = (self.runtime("rdx") / "history.jsonl").read_text()
|
||||
self.assertIn('"event":"update"', hist)
|
||||
self.assertIn('"result":"fail"', hist)
|
||||
self.svc("rdx", "stop")
|
||||
|
||||
def test_U1_update_ensures_missing_row_before_stop(self):
|
||||
"""已建立身份后删行:update 在停服前拒绝,不自动补行,旧进程保持。
|
||||
补行只走 adopt --confirm。"""
|
||||
old_sha, new_sha = self.make_src_repo()
|
||||
self.seed_agent("rowx", 9452)
|
||||
self.start_ok("rowx")
|
||||
self.set_kv("rowx", "source", "dev@%s" % old_sha)
|
||||
db = Path(self.manifest("rowx")["identity"]["data_root"]) / "agent-system.db"
|
||||
con = sqlite3.connect(str(db))
|
||||
con.execute("DELETE FROM agent_system_identity")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
self.clear_serve_record()
|
||||
result = self.svc("update", "rowx", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("候选无法验证已建立的 DB 身份", result.stdout)
|
||||
self.assertTrue(self.running("rowx"), "缺行必须在停旧服务前拒绝")
|
||||
self.assertEqual(self.db_identity_row("rowx"), [], "established 不得自动补行")
|
||||
self.svc("rowx", "stop")
|
||||
|
||||
def test_U1_bare_start_missing_row_is_event_init_recovers(self):
|
||||
"""普通启动只验证不补行:DB 行缺失 → 拒绝且不建空行(缺行即事件);
|
||||
显式 init 补齐后恢复。"""
|
||||
self.seed_agent("rowm", 9453)
|
||||
self.start_ok("rowm")
|
||||
iid = self.manifest("rowm")["instance_id"]
|
||||
self.svc("rowm", "stop")
|
||||
db = Path(self.manifest("rowm")["identity"]["data_root"]) / "agent-system.db"
|
||||
con = sqlite3.connect(str(db))
|
||||
con.execute("DELETE FROM agent_system_identity")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
result = self.svc("rowm", "start", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("DB 身份校验失败", result.stdout)
|
||||
self.assertFalse(self.running("rowm"))
|
||||
self.assertEqual(self.db_identity_row("rowm"), [], "缺行不得被 start 静默补建")
|
||||
|
||||
refused = self.svc("rowm", "init", check=False)
|
||||
self.assertNotEqual(refused.returncode, 0, refused.stdout)
|
||||
self.assertIn("adopt", refused.stdout)
|
||||
self.svc("rowm", "adopt", "--confirm", iid)
|
||||
self.assertEqual(self.db_identity_row("rowm"), [(iid, "sqlite")])
|
||||
self.start_ok("rowm")
|
||||
self.svc("rowm", "stop")
|
||||
|
||||
def test_U1_capability_probe_no_false_positive_no_hang(self):
|
||||
"""能力探测无副作用:含 ensure 字样的服务态门禁报错不算能力(旧实现
|
||||
误报点);identity 即挂死的产物探测有界返回无能力。"""
|
||||
def probe(binpath):
|
||||
# svc.sh 顶层启用 errexit:source 后裸调用失败函数会杀掉探针 shell,
|
||||
# 用 if 条件上下文(errexit 豁免)取回真实返回码。
|
||||
return subprocess.run(
|
||||
["bash", "-c",
|
||||
'source "%s" >/dev/null 2>&1; ENV_BIN="%s"; '
|
||||
'if svc_bin_has_identity; then echo rc=0; else echo rc=$?; fi'
|
||||
% (self.root / "scripts" / "svc.sh", binpath)],
|
||||
cwd=self.root, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=30).stdout.strip()
|
||||
|
||||
self.assertIn("rc=0", probe(self.stub), "identity 桩应判有能力")
|
||||
self.assertIn("rc=1", probe(self.noid_stub()),
|
||||
"报错文本含 ensure 不得误报为有能力")
|
||||
hang = self.root / "stub-hang"
|
||||
hang.write_text(
|
||||
'#!/usr/bin/env bash\ncase "${1:-}" in version) sleep 30 ;; '
|
||||
'*) exec "%s" "$@" ;; esac\n' % self.stub)
|
||||
hang.chmod(0o755)
|
||||
self.assertIn("rc=1", probe(hang), "探测挂死须有界返回无能力")
|
||||
|
||||
def test_I3_start_ensures_sqlite_identity(self):
|
||||
self.seed_agent("idb1", 9441)
|
||||
self.start_ok("idb1")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""生命周期隔离:调用方的 AGENT_SYSTEM_* 不得进入 serve,serve.env 为 0600。"""
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
|
||||
tsr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsr)
|
||||
|
||||
|
||||
class LifecycleIsolationTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
|
||||
def test_caller_identity_env_does_not_reach_serve(self):
|
||||
self.seed_agent("iso", 9461)
|
||||
env = dict(self.env)
|
||||
env["AGENT_SYSTEM_DSN"] = "postgres://leak-user:leak-pass@127.0.0.1/leak"
|
||||
env["AGENT_SYSTEM_DATA"] = "/tmp/leak-data"
|
||||
env["AGENT_SYSTEM_INSTANCE_ID"] = "svc-foreign-leak"
|
||||
self.svc("iso", "start", env=env)
|
||||
pid = self.pid_of("iso")
|
||||
self.assertIsNotNone(pid)
|
||||
raw = Path("/proc/%d/environ" % pid).read_bytes()
|
||||
self.assertNotIn(b"leak-pass", raw)
|
||||
self.assertNotIn(b"/tmp/leak-data", raw)
|
||||
self.assertNotIn(b"svc-foreign-leak", raw)
|
||||
serve_env = self.runtime("iso") / "serve.env"
|
||||
self.assertTrue(serve_env.is_file())
|
||||
self.assertEqual(serve_env.stat().st_mode & 0o777, 0o600)
|
||||
text = serve_env.read_text()
|
||||
self.assertNotIn("AGENT_SYSTEM_", text)
|
||||
self.assertNotIn("leak-pass", text)
|
||||
self.svc("iso", "stop")
|
||||
|
||||
def test_two_bare_restarts_keep_identity(self):
|
||||
self.seed_agent("iso2", 9462)
|
||||
self.svc("iso2", "start")
|
||||
manifest = json.loads((self.runtime("iso2") / "manifest.json").read_text())
|
||||
iid = manifest["instance_id"]
|
||||
data = manifest["identity"]["data_root"]
|
||||
self.svc("iso2", "stop")
|
||||
self.svc("iso2", "start")
|
||||
self.svc("iso2", "stop")
|
||||
self.svc("iso2", "start")
|
||||
again = json.loads((self.runtime("iso2") / "manifest.json").read_text())
|
||||
self.assertEqual(again["instance_id"], iid)
|
||||
self.assertEqual(again["identity"]["data_root"], data)
|
||||
self.assertEqual(again["identity"].get("db_marker"), "established")
|
||||
self.svc("iso2", "stop")
|
||||
@@ -236,6 +236,16 @@ class ResolverStateIsolation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
def test_env_config_resets_identity_env(self):
|
||||
# 棘轮:env_config 必须重置 AGENT_SYSTEM_INSTANCE_ID/DBREF(删除即回归
|
||||
# 「批量 update 前一服务 verify 导出的身份串给下一服务」——2026-10-12
|
||||
# rdm1/rdmcand:dev 身份漏给 RDM,serve 带外宾身份被门禁拒绝)。
|
||||
out = _bash(
|
||||
'source "$1"; declare -f env_config | '
|
||||
'grep -c \'unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF\''
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""svc.sh 真实组合门禁(U3,2026-10-12 统一部署方案)。
|
||||
|
||||
用**真实产物**(agent-system 组合二进制 + agent-system-rdm 组合二进制)而非 bash
|
||||
桩验证部署生命周期:identity 领养/ensure/verify(真 SQLite schema_migrations +
|
||||
agent_system_identity)、裸重启身份稳定、能力探测契约(capabilities v1)、
|
||||
RDM 全轨部署(物化→门禁→release 切换→伴生工作台)、真实 PostgreSQL 身份
|
||||
(AGENT_SYSTEM_DSN 环境交付,凭据不进命令行)。
|
||||
|
||||
运行条件(缺一即显式 NOT_RUN,不静默跳过):
|
||||
SVC_TEST_REAL=1 —— 总开关
|
||||
SVC_REAL_BIN=<agent-system> —— 真实基座/组合二进制(make build 产物)
|
||||
SVC_REAL_RDM_BIN=<agent-system-rdm> —— 真实 RDM 组合二进制(make build 产物)
|
||||
SVC_RDM_ORIGIN=<RDM 仓远端> —— (可选)全轨部署用;缺省仅做制品级生命周期
|
||||
SVC_TEST_PG=1 + AGENT_PG_TEST_DSN —— (可选)真实 PG 身份专项
|
||||
桩套件(test_svc*.py)不覆盖真实产物行为——本文件是「声明≠能力」的最后一道门。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
REAL = os.environ.get("SVC_TEST_REAL") == "1"
|
||||
REAL_BIN = os.environ.get("SVC_REAL_BIN", "")
|
||||
REAL_RDM_BIN = os.environ.get("SVC_REAL_RDM_BIN", "")
|
||||
RDM_ORIGIN = os.environ.get("SVC_RDM_ORIGIN", "")
|
||||
# CI 通道(可选):无 SSH key 的运行器上,把受信基址 + URL 级 token 头注入
|
||||
# svc.sh 派生的 git 子进程(cache 克隆与 pins 物化的 submodule 取件走 SSH URL
|
||||
# 会被 insteadOf 改写)。token 只经环境传递,不进文件/命令行/输出。
|
||||
CI_SERVER = os.environ.get("SVC_CI_GIT_SERVER", "").rstrip("/")
|
||||
CI_TOKEN = os.environ.get("SVC_CI_GIT_TOKEN", "")
|
||||
|
||||
|
||||
def ci_git_env():
|
||||
if not (CI_SERVER and CI_TOKEN):
|
||||
return {}
|
||||
scheme = CI_SERVER.split("://", 1)[0]
|
||||
return {
|
||||
"GIT_CONFIG_COUNT": "4",
|
||||
"GIT_CONFIG_KEY_0": "protocol.%s.allow" % scheme,
|
||||
"GIT_CONFIG_VALUE_0": "always",
|
||||
"GIT_CONFIG_KEY_1": "http.%s/.extraheader" % CI_SERVER,
|
||||
"GIT_CONFIG_VALUE_1": "AUTHORIZATION: token " + CI_TOKEN,
|
||||
"GIT_CONFIG_KEY_2": "url.%s/weichen/.insteadOf" % CI_SERVER,
|
||||
"GIT_CONFIG_VALUE_2": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/",
|
||||
"GIT_CONFIG_KEY_3": "core.hooksPath",
|
||||
"GIT_CONFIG_VALUE_3": "/dev/null",
|
||||
}
|
||||
PG = os.environ.get("SVC_TEST_PG") == "1" and bool(os.environ.get("AGENT_PG_TEST_DSN"))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
|
||||
tsr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsr)
|
||||
|
||||
|
||||
@unittest.skipUnless(REAL, "真实组合专项 NOT_RUN:需 SVC_TEST_REAL=1(详见文件头运行条件)")
|
||||
class RealComboTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
self.real_bin = Path(REAL_BIN).resolve() if REAL_BIN else None
|
||||
if self.real_bin is not None:
|
||||
self.assertTrue(self.real_bin.is_file() and os.access(self.real_bin, os.X_OK),
|
||||
"SVC_REAL_BIN 不可执行:%s" % self.real_bin)
|
||||
|
||||
def manifest(self, name):
|
||||
p = self.runtime(name) / "manifest.json"
|
||||
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
|
||||
|
||||
def identity_row(self, name):
|
||||
db = self.runtime(name) / "data" / "agent-system.db"
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
return list(con.execute(
|
||||
"SELECT instance_id, dbref FROM agent_system_identity"))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def http_ok(self, port):
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=3) as r:
|
||||
return r.status == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def seed_real(self, name, port, binary):
|
||||
# CI 运行器无 bwrap:RDM 沙箱 fail-closed 会拒启——硬关 exec 工具
|
||||
#(产品提供的最高兜底,RDM_DISABLE_EXEC_TOOLS=1)后生命周期语义不变。
|
||||
self.env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||||
d = self.runtime(name)
|
||||
(d / "bin").mkdir(parents=True, exist_ok=True)
|
||||
(d / "data").mkdir(exist_ok=True)
|
||||
(d / "plugins").mkdir(exist_ok=True)
|
||||
shutil.copy2(binary, d / "bin" / "agent-system")
|
||||
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
|
||||
("kind", "agent"), ("env", name), ("pid", ""),
|
||||
("addr", "127.0.0.1:%d" % port), ("tags", ""),
|
||||
("source", "realcombo"), ("version", "real"), ("started", ""),
|
||||
("pidcheck", str(d / "bin" / "agent-system")),
|
||||
("exec", "--addr 127.0.0.1:%d --data %s --plugins %s --auth auto"
|
||||
% (port, d / "data", d / "plugins")),
|
||||
]))
|
||||
|
||||
@unittest.skipUnless(REAL and REAL_BIN,
|
||||
"真实基座二进制专项 NOT_RUN:需 SVC_REAL_BIN")
|
||||
def test_U3_real_agent_identity_lifecycle_sqlite(self):
|
||||
"""真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定;
|
||||
capabilities v1 探测契约。"""
|
||||
self.seed_real("rc1", 9471, self.real_bin)
|
||||
result = self.svc("rc1", "start")
|
||||
self.assertIn("已启动", result.stdout)
|
||||
m = self.manifest("rc1")
|
||||
self.assertIsNotNone(m, "start 应完成 manifest 领养")
|
||||
iid = m["instance_id"]
|
||||
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
|
||||
"领养须在真实库建身份行")
|
||||
# schema 是真实基座写入的(非桩)
|
||||
con = sqlite3.connect(str(self.runtime("rc1") / "data" / "agent-system.db"))
|
||||
self.assertTrue(con.execute(
|
||||
"SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1)
|
||||
con.close()
|
||||
self.assertTrue(self.http_ok(9471))
|
||||
|
||||
# capabilities v1 契约(探测无副作用形态)
|
||||
cap = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
|
||||
"identity", "capabilities"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(cap.returncode, 0, cap.stderr)
|
||||
doc = json.loads(cap.stdout)
|
||||
self.assertEqual(doc["protocol"], 1)
|
||||
self.assertEqual(doc["identity"]["open"], "existing-no-migrate")
|
||||
self.assertIn("ensure", doc["identity"]["ops"])
|
||||
self.assertIn("verify", doc["identity"]["ops"])
|
||||
# 错 id verify 拒绝
|
||||
bad = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
|
||||
"identity", "verify", "--instance-id",
|
||||
"svc-rc1-zzzzzz999999",
|
||||
"--data", str(self.runtime("rc1") / "data")],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(bad.returncode, 1)
|
||||
|
||||
self.svc("rc1", "restart")
|
||||
self.svc("rc1", "restart")
|
||||
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
|
||||
"裸重启不得换身份")
|
||||
self.assertEqual(self.manifest("rc1")["instance_id"], iid)
|
||||
self.assertTrue(self.http_ok(9471))
|
||||
self.svc("rc1", "stop")
|
||||
|
||||
@unittest.skipUnless(REAL_RDM_BIN, "真实 RDM 专项 NOT_RUN:需 SVC_REAL_RDM_BIN")
|
||||
def test_U3_real_rdm_binary_lifecycle_sqlite(self):
|
||||
"""真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 →
|
||||
领域层可初始化。"""
|
||||
rdm_bin = Path(REAL_RDM_BIN).resolve()
|
||||
self.seed_real("rcr", 9473, rdm_bin)
|
||||
result = self.svc("rcr", "start")
|
||||
self.assertIn("已启动", result.stdout)
|
||||
m = self.manifest("rcr")
|
||||
iid = m["instance_id"]
|
||||
self.assertEqual(self.identity_row("rcr"), [(iid, "sqlite")])
|
||||
self.assertTrue(self.http_ok(9473))
|
||||
# RDM 未知子命令明确退出(不落服务路径)
|
||||
bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"),
|
||||
"totally-bogus"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(bogus.returncode, 2, bogus.stderr)
|
||||
self.svc("rcr", "stop")
|
||||
|
||||
@unittest.skipUnless(RDM_ORIGIN, "全轨部署 NOT_RUN:需 SVC_RDM_ORIGIN(RDM 源仓远端)")
|
||||
def test_U3_real_rdm_full_deploy_track(self):
|
||||
"""全轨:materialize(真实 make build)→ 候选身份门禁 → release 切换 →
|
||||
伴生工作台 200 → 裸重启稳定。"""
|
||||
# 硬关须覆盖本测试全部调用(deploy/start/restart/stop 的 serve 都要),
|
||||
# 不只 deploy 一处——CI 无 bwrap 时 fail-closed 拒启。
|
||||
self.env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||||
env = dict(self.env)
|
||||
env.update(ci_git_env())
|
||||
env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"),
|
||||
"SVC_RDM_ORIGIN": RDM_ORIGIN})
|
||||
result = self.svc("rcf", "deploy", "--from", "rdm", "--port", "9474",
|
||||
env=env, check=False)
|
||||
if result.returncode != 0:
|
||||
log = self.runtime("rcf") / "svc.log"
|
||||
tail = log.read_text(encoding="utf-8", errors="replace")[-2000:] \
|
||||
if log.exists() else "(no svc.log)"
|
||||
self.fail("deploy failed:\n%s\n--- svc.log tail ---\n%s"
|
||||
% (result.stdout, tail))
|
||||
self.assertIn("已切换 release", result.stdout)
|
||||
d = self.runtime("rcf")
|
||||
self.assertTrue((d / "current" / "bin" / "agent-system").exists())
|
||||
iid = self.manifest("rcf")["instance_id"]
|
||||
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
|
||||
# 伴生工作台(真实 web/)
|
||||
addr = self.kv("rcfweb", "addr")
|
||||
self.assertIsNotNone(addr, "伴生 rcfweb 未登记")
|
||||
port = int(addr.rsplit(":", 1)[1])
|
||||
self.assertTrue(self.http_ok(port))
|
||||
self.assertIn("RDM", (d / "current" / "htdocs" / "index.html").read_text()
|
||||
if (d / "current" / "htdocs" / "index.html").exists()
|
||||
else "rdm", "工作台内容")
|
||||
self.svc("rcf", "restart")
|
||||
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
|
||||
self.assertTrue(self.http_ok(9474))
|
||||
self.svc("rcf", "stop")
|
||||
self.svc("rcfweb", "stop")
|
||||
|
||||
@unittest.skipUnless(PG and REAL_RDM_BIN,
|
||||
"真实 PG 专项 NOT_RUN:需 SVC_TEST_PG=1 + AGENT_PG_TEST_DSN + SVC_REAL_RDM_BIN")
|
||||
def test_U3_real_pg_identity_env_delivery(self):
|
||||
"""真实 PostgreSQL:AGENT_SYSTEM_DSN 环境交付 ensure/verify(凭据不进
|
||||
命令行);错 id fail-closed。"""
|
||||
rdm_bin = Path(REAL_RDM_BIN).resolve()
|
||||
data = self.root / "pgdata"
|
||||
plugins = data / "plugins"
|
||||
plugins.mkdir(parents=True)
|
||||
env = dict(os.environ)
|
||||
env["AGENT_SYSTEM_DSN"] = os.environ["AGENT_PG_TEST_DSN"]
|
||||
env["RDM_DISABLE_EXEC_TOOLS"] = "1"
|
||||
env.pop("SVC_STUB_TRACE", None)
|
||||
# 身份命令不建库、不迁移。先让 serve 把库迁到当前 schema,再 ensure/verify。
|
||||
port = 9475
|
||||
log = self.root / "pg-serve.log"
|
||||
serve = subprocess.Popen(
|
||||
[str(rdm_bin), "serve", "--addr", "127.0.0.1:%d" % port,
|
||||
"--data", str(data), "--plugins", str(plugins), "--auth", "auto"],
|
||||
env=env, stdout=log.open("w"), stderr=subprocess.STDOUT)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(50):
|
||||
if serve.poll() is not None:
|
||||
break
|
||||
try:
|
||||
urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=1).close()
|
||||
up = True
|
||||
break
|
||||
except urllib.error.HTTPError:
|
||||
up = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(up, "serve 未完成迁移:\n%s" % log.read_text(errors="replace")[-1500:])
|
||||
finally:
|
||||
serve.terminate()
|
||||
try:
|
||||
serve.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
serve.kill()
|
||||
iid = "svc-realcombo-pg-%d" % os.getpid()
|
||||
run = lambda *a: subprocess.run(
|
||||
[str(rdm_bin), "identity", *a, "--instance-id", iid,
|
||||
"--dbref", "pg", "--data", str(data)],
|
||||
env=env, capture_output=True, text=True, timeout=60)
|
||||
r = run("ensure")
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
r = run("verify")
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
bad = subprocess.run(
|
||||
[str(rdm_bin), "identity", "verify", "--instance-id",
|
||||
"svc-realcombo-pg-wrong", "--dbref", "pg", "--data", str(data)],
|
||||
env=env, capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(bad.returncode, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,6 +28,10 @@ trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
||||
mkdir -p "$trace_dir"
|
||||
case "${1:-}" in
|
||||
version)
|
||||
if [ "${2:-}" = "--json" ]; then
|
||||
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
|
||||
exit 0
|
||||
fi
|
||||
echo "stub-agent-system 1.0"
|
||||
exit 0 ;;
|
||||
config)
|
||||
@@ -111,6 +115,11 @@ PYID
|
||||
exit 0 ;;
|
||||
esac
|
||||
printf 'args: %s\n' "$*" > "$trace_dir/serve.txt"
|
||||
# 身份环境记录(U1 批量隔离回归观测点):serve 实际拿到谁的实例身份。
|
||||
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
|
||||
printf 'AGENT_SYSTEM_INSTANCE_ID=%s\nAGENT_SYSTEM_DBREF=%s\n' \
|
||||
"$AGENT_SYSTEM_INSTANCE_ID" "${AGENT_SYSTEM_DBREF:-sqlite}" >> "$trace_dir/serve.txt"
|
||||
fi
|
||||
addr=""; data="./data"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --addr) addr="$2"; shift 2 ;; --data) data="$2"; shift 2 ;; *) shift ;; esac
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""svc.sh U2 回归:不可变版本目录(releases/)+ current 原子切换 + deploy.journal
|
||||
+ 失败回退(2026-10-12 统一部署方案 U2)。
|
||||
|
||||
覆盖:release 布局与 current 解析(pidcheck 经未解析路径跨版本稳定)、启动失败
|
||||
回退上一完整版本并恢复期望态、构建失败旧服务保持运行、中断切换经 start/recover
|
||||
入口回退、schema 前进阻断回退(不触碰业务库)、legacy 布局兼容。夹具复用
|
||||
tests/test_svc_identity.py(IdentityTests:manifest 治理 + 源仓轨道 + noid 桩)。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
|
||||
tsr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsr)
|
||||
|
||||
_spec2 = importlib.util.spec_from_file_location("tsi", SOURCE / "tests" / "test_svc_identity.py")
|
||||
tsi = importlib.util.module_from_spec(_spec2)
|
||||
_spec2.loader.exec_module(tsi)
|
||||
|
||||
|
||||
class U2ReleaseTests(tsi.IdentityTests):
|
||||
"""manifest 治理 + identity 桩 + dev 轨道源仓(make_src_repo 等)。"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# --from local 需要根为 git 仓(local@HEAD 作源标签);recover 夹具不初始化。
|
||||
self.git_ok(self.root, "init", "--initial-branch=main", ".")
|
||||
self.git_ok(self.root, "add", ".")
|
||||
self.git_ok(self.root, "commit", "-m", "u2 fixture")
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def cur_release(self, name):
|
||||
c = self.runtime(name) / "current"
|
||||
return c.resolve().name if c.is_symlink() else None
|
||||
|
||||
def active_bin(self, name):
|
||||
return self.runtime(name) / "current" / "bin" / "agent-system"
|
||||
|
||||
def cmdline_of(self, pid):
|
||||
return Path("/proc/%d/cmdline" % pid).read_bytes().replace(b"\0", b" ").decode()
|
||||
|
||||
def journal(self, name):
|
||||
f = self.runtime(name) / "deploy.journal"
|
||||
return json.loads(f.read_text()) if f.exists() else None
|
||||
|
||||
def die_stub(self):
|
||||
"""identity 可用、serve 即退的桩(模拟新制品启动失败)。"""
|
||||
p = self.root / "stub-agent-system-die"
|
||||
if not p.exists():
|
||||
stub = tsr.STUB
|
||||
head, sep, tail = stub.partition(" serve)")
|
||||
assert sep, "STUB 缺 serve 分支锚点"
|
||||
_, sep2, tail2 = tail.partition(' wait "$child"\n ;;')
|
||||
assert sep2, "STUB 缺 serve 分支结尾锚点"
|
||||
p.write_text(head + ' serve)\n shift\n'
|
||||
+ ' echo "die-stub: serve exits immediately" >&2\n'
|
||||
+ " exit 1 ;;" + tail2)
|
||||
p.chmod(0o755)
|
||||
return p
|
||||
|
||||
def set_makefile(self, stub):
|
||||
(self.root / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % stub)
|
||||
|
||||
def bump_schema(self, name, version):
|
||||
"""把库 schema_migrations 推进到 version(桩库无该表则建;模拟新制品
|
||||
已迁移数据库)。"""
|
||||
import sqlite3
|
||||
db = self.runtime(name) / "data" / "agent-system.db"
|
||||
con = sqlite3.connect(str(db))
|
||||
con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations"
|
||||
"(id INTEGER PRIMARY KEY, name TEXT, applied_at TEXT)")
|
||||
for i in (1, 2, 3, version):
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO schema_migrations(id,name,applied_at)"
|
||||
" VALUES(?, 'u2-test', '2026-10-12T00:00:00Z')", (i,))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
# ---- T1:release 布局 + 启动失败回退 + 期望态恢复 ----
|
||||
|
||||
def test_U2_layout_switch_and_rollback_on_start_failure(self):
|
||||
self.make_src_repo() # 预克隆 srccache(本用例走 --from local,仅保环境一致)
|
||||
self.seed_agent("u2a", 9461)
|
||||
self.start_ok("u2a") # legacy 运行中(期望态=running)
|
||||
self.assertTrue(self.running("u2a"))
|
||||
|
||||
# v1 部署:legacy 领养 → current 切换 → 经 current 启动
|
||||
self.set_makefile(self.stub)
|
||||
result = self.svc("u2a", "deploy", "--from", "local")
|
||||
self.assertIn("已切换 release", result.stdout)
|
||||
self.assertIn("领养 legacy", result.stdout)
|
||||
cur1 = self.cur_release("u2a")
|
||||
self.assertTrue(cur1 and cur1.startswith("local-"), cur1)
|
||||
self.assertTrue(self.active_bin("u2a").exists())
|
||||
releases = sorted((self.runtime("u2a") / "releases").iterdir())
|
||||
self.assertEqual(len(releases), 2, "legacy + v1 两个 release")
|
||||
pid = self.pid_of("u2a")
|
||||
self.assertIn("current/bin/agent-system", self.cmdline_of(pid),
|
||||
"进程 cmdline 须走 current 未解析路径(pidcheck 跨版本稳定)")
|
||||
self.assertIn("current/bin", self.kv("u2a", "pidcheck") or "")
|
||||
v1_bin = self.active_bin("u2a").read_bytes()
|
||||
self.assertEqual(v1_bin, Path(self.stub).read_bytes(), "v1 制品 = 可用桩")
|
||||
self.assertIsNone(self.journal("u2a"), "成功后 journal 清理")
|
||||
|
||||
# v2 部署:serve 即退 → 回退 v1 并恢复运行
|
||||
self.set_makefile(self.die_stub())
|
||||
result = self.svc("u2a", "deploy", "--from", "local", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("已回退上一版本", result.stdout)
|
||||
self.assertTrue(self.running("u2a"), "回退须按原期望态恢复运行")
|
||||
pid2 = self.pid_of("u2a")
|
||||
self.assertNotEqual(pid2, pid)
|
||||
self.assertIn("current/bin/agent-system", self.cmdline_of(pid2))
|
||||
self.assertEqual(self.active_bin("u2a").read_bytes(), v1_bin, "回退后制品=v1")
|
||||
hist = (self.runtime("u2a") / "history.jsonl").read_text()
|
||||
self.assertIn('"event":"deploy"', hist)
|
||||
self.assertIn("rollback", hist)
|
||||
self.assertIsNone(self.journal("u2a"))
|
||||
self.svc("u2a", "stop")
|
||||
|
||||
# ---- T2:构建失败 → 旧服务保持运行、current 不动 ----
|
||||
|
||||
def test_U2_build_failure_keeps_old_running(self):
|
||||
self.seed_agent("u2b", 9462)
|
||||
self.set_makefile(self.stub)
|
||||
self.svc("u2b", "deploy", "--from", "local")
|
||||
cur1 = self.cur_release("u2b")
|
||||
pid = self.pid_of("u2b")
|
||||
(self.root / "Makefile").write_text("build:\n\texit 1\n")
|
||||
result = self.svc("u2b", "deploy", "--from", "local", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("构建失败", result.stdout)
|
||||
self.assertTrue(self.running("u2b"), "构建失败旧服务必须保持运行")
|
||||
self.assertEqual(self.pid_of("u2b"), pid)
|
||||
self.assertEqual(self.cur_release("u2b"), cur1, "current 指针不动")
|
||||
self.svc("u2b", "stop")
|
||||
|
||||
# ---- T3:中断切换(journal switched)→ start 入口回退并恢复 ----
|
||||
|
||||
def test_U2_journal_interrupt_resolved_on_start(self):
|
||||
self.seed_agent("u2c", 9463)
|
||||
self.set_makefile(self.stub)
|
||||
self.svc("u2c", "deploy", "--from", "local")
|
||||
v1 = self.cur_release("u2c")
|
||||
self.svc("u2c", "stop")
|
||||
# 伪造中断于「指针已切」:current → 假 release,journal switched
|
||||
fake = self.runtime("u2c") / "releases" / "fake-interrupt"
|
||||
(fake / "bin").mkdir(parents=True)
|
||||
shutil.copy2(self.die_stub(), fake / "bin" / "agent-system")
|
||||
(self.runtime("u2c") / "current").unlink()
|
||||
(self.runtime("u2c") / "current").symlink_to("releases/fake-interrupt")
|
||||
(self.runtime("u2c") / "deploy.journal").write_text(json.dumps({
|
||||
"phase": "switched", "new": "fake-interrupt", "prev": v1,
|
||||
"desired": "running", "ts": "2026-10-12T00:00:00Z"}))
|
||||
|
||||
result = self.svc("u2c", "start")
|
||||
self.assertIn("中断的切换", result.stdout)
|
||||
self.assertIn("已回退上一版本", result.stdout)
|
||||
self.assertTrue(self.running("u2c"))
|
||||
self.assertEqual(self.cur_release("u2c"), v1)
|
||||
self.assertIsNone(self.journal("u2c"))
|
||||
self.svc("u2c", "stop")
|
||||
|
||||
# ---- T4:schema 前进 → 回退阻断,不触碰业务库 ----
|
||||
|
||||
def test_U2_schema_advance_blocks_rollback(self):
|
||||
self.seed_agent("u2d", 9464)
|
||||
self.set_makefile(self.stub)
|
||||
self.svc("u2d", "deploy", "--from", "local")
|
||||
v1 = self.cur_release("u2d")
|
||||
self.svc("u2d", "stop")
|
||||
# v1 提交时点 schema=3;库被「新制品」推进到 5
|
||||
rf = self.runtime("u2d") / "releases" / v1 / "release.json"
|
||||
data = json.loads(rf.read_text())
|
||||
data["schema_at_commit"] = "3"
|
||||
rf.write_text(json.dumps(data))
|
||||
self.bump_schema("u2d", 5)
|
||||
fake = self.runtime("u2d") / "releases" / "fake-advance"
|
||||
(fake / "bin").mkdir(parents=True)
|
||||
shutil.copy2(self.die_stub(), fake / "bin" / "agent-system")
|
||||
(self.runtime("u2d") / "current").unlink()
|
||||
(self.runtime("u2d") / "current").symlink_to("releases/fake-advance")
|
||||
(self.runtime("u2d") / "deploy.journal").write_text(json.dumps({
|
||||
"phase": "switched", "new": "fake-advance", "prev": v1,
|
||||
"desired": "running", "ts": "2026-10-12T00:00:00Z"}))
|
||||
|
||||
result = self.svc("u2d", "start", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("禁止自动回退", result.stdout)
|
||||
self.assertFalse(self.running("u2d"), "阻断后保持停止")
|
||||
j = self.journal("u2d")
|
||||
self.assertEqual(j["phase"], "blocked")
|
||||
# 业务库不被回写:schema 仍是 5
|
||||
import sqlite3
|
||||
con = sqlite3.connect(str(self.runtime("u2d") / "data" / "agent-system.db"))
|
||||
self.assertEqual(
|
||||
con.execute("select max(id) from schema_migrations").fetchone()[0], 5)
|
||||
con.close()
|
||||
# blocked 下普通动作被拒;deploy 是解除路径(此处仅验证拒绝语义)
|
||||
result = self.svc("u2d", "restart", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("切换被阻断", result.stdout)
|
||||
|
||||
# ---- T5:legacy 布局未迁移 → 裸 start/restart 仍走原路径(兼容) ----
|
||||
|
||||
def test_U2_legacy_layout_bare_lifecycle_still_works(self):
|
||||
self.seed_agent("u2e", 9465)
|
||||
self.start_ok("u2e")
|
||||
self.assertFalse((self.runtime("u2e") / "current").exists())
|
||||
self.assertTrue((self.runtime("u2e") / "bin" / "agent-system").exists())
|
||||
self.svc("u2e", "stop")
|
||||
self.start_ok("u2e")
|
||||
self.assertTrue(self.running("u2e"))
|
||||
self.svc("u2e", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -604,7 +604,7 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertTrue((wt / ".task/feature-new.md").is_file())
|
||||
card = (wt / ".task/feature-new.md").read_text()
|
||||
self.assertIn("make precheck", card)
|
||||
self.assertIn("交付模式:manual(逐阶段人工评审)", card)
|
||||
self.assertIn("交付模式:manual(前序人工决策,智能体 Review/合入)", card)
|
||||
self.assertIn("authorized-continuous", card)
|
||||
self.assertIn("流程依据:仓库根目录 policy/delivery.md", card)
|
||||
for field in ("授权范围", "授权来源", "授权终点", "停止条件"):
|
||||
|
||||
Reference in New Issue
Block a user