Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=096b74b4775e81cfb2864f98cae2e9fc9d9c1a44.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-21 11:40:01 +00:00
6 changed files with 246 additions and 14 deletions
@@ -0,0 +1,96 @@
# fix/remaining-hardcoded-ports 任务卡
任务主记录:PR #676 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/676;状态:已完成(合入前闭合);owner:weichen
- 权威仓:ssh://git@192.168.3.2:2222/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 444fb1dee2a72452be23b97cbaaad9e28c42dffd
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-fix-remaining-hardcoded-ports(worktree);分支:fix/remaining-hardcoded-ports
- 分支类型:standalone;root_target:main;immediate_target:main;accepting_requirements:false
- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=f58f9b9127d88193b75ab8240478a0be0643a63075d58cc54884dfc7b9b4e64e;triage_receipt=/tmp/triage-remaining-port-flakes-20261021.json;receipt_sha256=e190b63d6a5bbcb0285d38a8ae9e1a548a6fb3e3a16dffe8e96dd54cc344a27b
- 授权与交付终点:消除 PR #668 Review 记下的同类端口硬编码脆弱面,提取 helper 并应用到 tests/test_svc.py 与 tests/test_svc_realcombo.py 的相关位点。不得改 scripts/svc.sh、不得削弱断言、不得改动 test_svc_recover.py / test_task_pr_check.py / test_task_triage.py 的端口。
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | 444fb1dee2a72452be23b97cbaaad9e28c42dffd | 444fb1dee2a72452be23b97cbaaad9e28c42dffd | 2026-10-21T10:19:04Z |
## 证据(Evidence)
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|---|---|---|---|---|---|
## 结果与验证
- 结果:新增 `tests/_port_alloc.py`(`free_test_port()` 每次调用通过 socket reservation 拿一个真正空闲的端口,with 块退出立即 close)。修改 `tests/test_svc.py`(6 处 `svc ... --port 18842/18843/18844` 改为每次 alloc 一个端口,668 与 942 行使用同一分配值因为是同一测试中相同实例的重复确认)和 `tests/test_svc_realcombo.py`(3 个用例各 alloc 一次端口,seed_real/svc/http_ok 三处共享)。`register_pg`/`DSN 18951` 按 PR #668 Review 注明仅写 svc.env、从不绑端口,保留不动;`test_svc_recover.py` 已有自己的 `free_fixture_port` 不动;`test_task_pr_check.py`/`test_task_triage.py` 的端口仅作字典键,不绑端口。
- 验证:PASS——`tests.test_port_alloc` 在 64 负载下 OK;`tests.test_svc` 23 项 OK;治理套件 527 项 OK(18 skipped);PR #668 的目标用例 `test_static_fresh_deploy_registers_pidcheck_and_starts` 仍通过。独立 Review(pi-glm,PASS 无阻塞项)。
- 影响与剩余项:不改任何生产代码;不削弱断言;realcombo 三例的运行级验证 NOT_RUN(环境门禁与禁网限制),由代码走读与模块级 skip 确认替代。Review 记 3 项非阻塞建议(守护测试未覆盖「外部占用」与 TIME_WAIT 避让、文件名单硬编码而非 glob、helper API 单端口 vs 真实用例可能需多端口对),均不阻塞。
## 合入与清理
- Review 记录:PASS(独立会话 pi-glm / zai-coding-cn/glm-5.3-flash;base 444fb1dee2a72452be23b97cbaaad9e28c42dffd,head 076a686592f99abb7b4e8c1aa73b7c73b5bccefe;报告 `.task/reviews/2026-10-20-remaining-ports-review.md`;无阻塞项,3 项非阻塞建议;重放依据:host 独立跑治理套件与 64 负载下的 helper 测试)
- 冲突记录:NOT_RUN(一次交付前同步,无变更)
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
```json
{
"summary_schema": 1,
"source": "SELF",
"timestamp": "2026-10-21T10:18:17.467858+00:00",
"command": "make precheck",
"exit_code": 0,
"cwd": "/home/weichen/projects/Agent-System-Dev",
"before_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"after_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"branch": "main",
"target_branch": "main",
"target_head": "444fb1dee2a72452be23b97cbaaad9e28c42dffd",
"root_target": "main",
"immediate_target": "main",
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"output_tail": [
"[repo] branch=main head=444fb1dee2",
"[remote] origin/main=444fb1dee2 ahead=0 behind=0",
"[tree] clean submodules_drift=0 uninit=0",
"[worktrees] linked=2",
"[recycle] merged un-recycled branches: feature/card-close-20261021-085838Z",
"[initiatives] local cards (not authoritative): none",
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
"[facts] policy/facts.md 6 条事实均在有效期内",
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
"[R0] 444fb1dee2(完整:444fb1dee2a72452be23b97cbaaad9e28c42dffd)",
"[worktree] before-sync: recursive component checks passed",
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
],
"output_sha256": "3ab6ad8a3228f50b19b9184ab2ded12a122fb8ecf601866a50c269dd41cf03e2",
"receipt_sha256": "8bbfc6e4ccd048cce9e097878522ee44a50c32215c0c1df2ab59c8f7d7ae9a83",
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/8bbfc6e4ccd048cce9e097878522ee44a50c32215c0c1df2ab59c8f7d7ae9a83.json"
}
```
## 闭合记录(合入前折叠)
- 本卡在 PR #676(https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/676)合入前闭合并归档,随该 PR 一同合入。
- 该 PR 的合并提交即本任务的落地记录。
- 合入后无需回填,也无需单独的闭合 PR。
@@ -0,0 +1,86 @@
# 独立代码审查:fix/remaining-hardcoded-ports(剩余同类端口硬编码改为内核分配,与 PR #668 同形)
- 审查对象:`444fb1dee2..HEAD`(HEAD=70c810a)。本单元 diff = `.task/fix-remaining-hardcoded-ports.md`(host 豁免路径)+ `tests/_port_alloc.py`(新增 13 行)+ `tests/test_port_alloc.py`(新增 24 行)+ `tests/test_svc.py`(4 处用例、6 个 `svc --port` 调用点 + 批量更新 2 个 seed 端口)+ `tests/test_svc_realcombo.py`(3 个 realcombo 用例 + import)。产品侧 `scripts/svc.sh` 零改动(`git diff 444fb1dee2..HEAD --stat -- scripts/` 为空)。
- 审查者身份:全新会话独立审查,未参与实现;本会话除本报告外不写任何仓库文件;无 git add/commit/push,无 PR 操作。工作区(detached HEAD 70c810a)在报告写入前后 `git status --porcelain` 仅有本报告。
- 依据:PR #668 审查(.task/reviews/2026-10-20-svc-flake-review.md)N4 记录的同类脆弱面 =「rdm 组真实绑口的 18842/18843/18844 与 realcombo 9471/9473/9474」;本单元即对该类做同形治理(bind(("127.0.0.1", 0)) 内核分配)。
## 结论:PASS(无阻塞项;非阻塞建议 3 条)
判定标准逐项:契约用例不再依赖固定端口(实测全数转换,动态临时口 32768-60999 与全仓剩余固定口不相交);helper 与 PR #668 及仓内既有 5 处夹具(test_svc_recover.py:187 `free_fixture_port` 等)逐字节同构,非新模式;受保护内容(`scripts/svc.sh`、`register_pg`/DSN 18951、`test_svc_recover.py`、`test_task_pr_check.py`、`test_task_triage.py`)零改动;补丁未外溢(`git diff --name-only` 仅 5 个文件)。realcombo 三例因需真实二进制环境(SVC_TEST_REAL / SVC_REAL_RDM_BIN / SVC_RDM_ORIGIN,本单元禁网)为 NOT_RUN,以代码走读 + 本地只读模块跑通(16 skipped 门槛确认)替代。
---
## 重点 1:helper `free_test_port` 的设计
**写法**(tests/_port_alloc.py):每次调用新开 `AF_INET/SOCK_STREAM` 套接字 → `bind(("127.0.0.1", 0))` 由内核从临时口范围分配 → `getsockname()[1]` 取实际端口 → `with` 退出即 close(return 表达式在 with 内求值,关闭发生在返回前)。不池化、不 SO_REUSEPORT,与 `free_fixture_port`(test_svc_recover.py:187-190)及 PR #668 写法同构。docstring 如实声明「closes it before returning」「Ports are not pooled」。
**「真空闲」的三类场景实测**(合成测试 /tmp/port_alloc_synthetic.py,独立于守护测试,3 次运行全 7/7 PASS):
1. **外部占用**:先取 helper 返回端口 P1 并真 LISTEN 占住(SO_ACCEPTCONN=1 确认),再次调用 helper 与后续 50 次调用共 51 次,**0 次命中 P1**,且每个返回值都能立即 bind+listen 成功——内核 `bind(0)` 的冲突检测以套接字表为准,与占用者是本进程还是外部进程无关,故 in-process LISTEN 持有者可作外部占用的等价证据。被占端口本身确实可用(我们正是用它完成 LISTEN)。
2. **TIME_WAIT**:制造真实 TIME_WAIT(服务端先关,端口 45387/53933/42733 三轮),先证明无 SO_REUSEADDR 的裸 rebind 报 EADDRINUSE(TIME_WAIT 确实阻断重绑),再测 helper 50 次调用 **0 次命中** TIME_WAIT 端口——内核分配阶段即跳过,helper 不会把调用方推进一个必然 bind 失败的端口。
3. **多调用区分度**:连续 500 次无持有调用 → 497 个不同值(远处可重复 3 次),但**相邻两次重复 0/499**——内核临时口游标使相邻调用实际上总不同。守护测试的 `assertNotEqual(first, second)` 依赖的正是相邻性,实证可靠(另 3×50 连续调用亦全不同)。远处重复(如两次 alloc 间隔数百次调用后撞回同值)无害:前一次的 reservation 早已 close 且从未 LISTEN 过、无 TIME_WAIT、无持有者,调用方 bind 必成功。
4. **子进程仍持套接字**:与场景 1 同表(内核冲突表不分进程),daemon 未退时 helper 不会返回其端口;helper 自身的 reservation 从未 accept/连接,close 后不留 TIME_WAIT。
**残余 TOCTOU 窗口(非阻塞,与 PR #668 同等接受)**:reservation close 到 daemon 真正 bind 之间端口不被保留。这是该模式(含仓内既有 5 处)的固有窗口,docstring 已如实声明。失效模式是响亮失败而非假绿:端口真被抢时 `check_port_free`(svc.sh:1575)报「端口被占用(非本服务)」非零退出 → `self.svc(check=True)` 断言带全量 stdout 失败,可归因(`port_owner`)。动态口 32768-60999(实测 `/proc/sys/net/ipv4/ip_local_port_range`)与仓内全部固定口(9431-9472、188xx-189xx)不相交,套件内不会互相踩。套件级彻底解法(传 fd/重试)属独立增量,不建议本任务扩围。
## 重点 2:三个 realcombo 用例端口一致性
逐例核验(tests/test_svc_realcombo.py):
- `test_U3_real_agent_identity_lifecycle_sqlite`:`:172 port = free_test_port()` 一次 → `seed_real("rc1", port)`(写 svc.env 的 addr 与 exec `--addr 127.0.0.1:port`)→ `svc start` → `http_ok(port)`(:186)→ 两次裸 restart(svc restart 沿用登记端口)→ `http_ok(port)`(:211)→ stop。三处同源同值。✓
- `test_U3_real_rdm_binary_lifecycle_sqlite`:`:219` 一次 alloc → seed_real/start/http_ok(:226)全用 `port`。✓
- `test_U3_real_rdm_full_deploy_track`:`:245 agent_port = free_test_port()` 一次 → `deploy --port str(agent_port)` → 裸 restart 后 `http_ok(agent_port)`(:269)。伴生 `rcfweb` 的端口不归本 alloc:svc.sh `deploy_rdm_companion_ui`(svc.sh:3141)以无 --port 子进程走 `derive_port`(8800+hash%300 冲突递增),测试从 `kv("rcfweb","addr")` 读回再探活(:263)——读写分离正确,alloc 只管 agent 口。✓
文件内 9471/9473/9474 零残留(grep 实测)。**alloc→bind 窗口在 realcombo 最宽**(seed_real 里的 `initialize_store` 会起停一个独立临时口的 fixture 服务,秒级),但该窗口语义与重点 1 相同:碰撞即响亮失败(start 门禁报占用),不会假绿;环境门禁(无真实二进制即 skip)意味着 CI 常规路径根本不执行,风险面进一步收窄。NOT_RUN 如实标注。
## 重点 3:test_svc.py 六个 `svc --port` 调用点
六个调用点(行号为 HEAD)全部经 `free_test_port()` 取值:`:904`(rdm e2e deploy)、`:931`(rdm 缺 web 失败 deploy)、`:944/:948/:954`(source-switch 三连)、`:970`(rdm update 轨道 deploy)。零 18841-18844 残留(grep 实测)。
**「同一测试多次 svc 调用端口应不同」的精确判定**:唯一的同测试多次调用是 `test_source_switch_requires_flag`,它 **有意** 共享一次 alloc(`:942 port = str(free_test_port())`,:944/:948/:954 三处同值)。这不违反意图:三个调用是对**同一实例 sw1** 的顺序 deploy → 被拒 deploy → 换源 redeploy;svc.sh deploy 流程「停旧 → 原子切换 → 启动」(agent_do_deploy,svc.sh:3211 起)在重绑前先停掉旧服务,`check_port_free`/`reclaim_own_port`(svc.sh:1575/1115)兜底本服务残留——端口不会「被自身 reuse 占满」,因为旧持有者在重绑前已被 svc.sh 自己停掉;被拒调用在 `require_allow_source_switch` 门禁就失败,从未 bind。该流程在改前用固定 18844 即已如此,本次仅换端口来源,实测通过(见证据 5)。每实例多端口场景(批量更新 a/b)则是每实例各 alloc 一次(`:673`),两次相邻调用必不同(重点 1.3)。判定:**无「每调用必须独立 alloc」的违例;共享 alloc 是对该用例语义的最小忠实转换。**
另有 `test_update_preserves_binding` 等用例经 update/restart 沿登记端口重启——属 svc.sh 持久化语义,端口来源已由 seed 处 alloc/保留,不在六调用点之列。
## 重点 4:守护测试 test_port_alloc 的覆盖度
**能否真的失败——能,两类回归可拦**:
- 退化成固定/缓存端口(如误改为 `return 18844`):`assertNotEqual(first, second)` 即刻失败——合成证据 4 用假 helper 复现了该失败路径。
- 返回不可绑端口(如未来改成返回他者持有口):循环内 `bind+listen` 直接抛 OSError → 用例 ERROR。
- 相邻两次拿到同值(游标回绕):assertNotEqual 失败(实测概率 ≈0,0/499 相邻重复,且该回绕对调用方无害——见重点 1.3,但作为回归信号仍成立)。
**覆盖缺口(非阻塞)**:守护测试未覆盖「端口已被外部占用时 helper 避开它」与 TIME_WAIT 避让——本审查以 /tmp 合成测试补证(外部 LISTEN 持有者 51 次调用 0 命中;TIME_WAIT 端口 50 次 0 命中),结论为 helper 行为正确,仅测试面未固化。alloc→bind TOCTOU 窗口则任何确定性测试都无法覆盖(内核不为已 close 的 reservation 保留端口),与 PR #668 的接受一致。若后续想固化,可在 `test_two_calls_...` 前置一步「先占住第一次的返回值再取第二次」的断言,属独立增量。
## 重点 5:范围核对
- `git diff --name-only 444fb1dee2..HEAD` 实测仅 5 文件:`.task/fix-remaining-hardcoded-ports.md` + 4 个测试文件;`scripts/` 零差异(`git diff --stat -- scripts/` 空)。✓
- `tests/test_svc_recover.py` 不动:正确——其已有同构 `free_fixture_port`(:187),全部用例早已内核分配。✓
- `tests/test_task_pr_check.py` 不动:正确——`ThreadingHTTPServer(("127.0.0.1", 0))`(:90)与 `closed_port_url()`(:100-102)本就内核分配,无固定绑口。✓
- `tests/test_task_triage.py` 不动:正确——夹具为内联 `bind(("127.0.0.1", 0))`(:230-232);文中的数字端口(如 30319)仅出现在 URL 脱敏字符串断言里,不 bind。✓
- `register_pg`/DSN 18951 保留:正确——`register_pg`(test_svc.py:242 起)仅 `write_text` 写 svc.env 元数据,从不建/绑 socket,18951 不可能引发占口型偶发失败。实测 base `444fb1dee2` 的 753-754 两行与 HEAD 逐字一致(行号平移到 757-758,系文件头新增 4 行 import 所致;本单元 diff 中 18951 出现次数为 0)。✓
**残余同类面(非阻塞,均不在本单元契约内,仅记录供后续增量)**:test_svc.py 仍有会在 `svc start/update` 时真实绑口的固定 seed 端口(18809、18832、18834-18837、18952,共 7 用例);`test_svc_plugins.py` 的 `deploy --port 18981/18982`、`test_svc_identity.py` 9431-9435、`test_svc_u2.py`/`test_svc_user_binding.py`/`test_svc_rdm_admin.py`/`test_svc_lifecycle_isolation.py` 的 943x-947x seed 端口同属 N4 类。动态临时口与它们不相交,本单元的转换不与之交互、也不掩盖其问题。
---
## 已运行的验证命令与结果(均只读;工作区除本报告外零修改)
| # | 命令 | 结果 |
|---|---|---|
| 1 | `git diff --stat 444fb1dee2..HEAD` 与 `--name-only` | 5 文件(任务卡 + 4 测试文件);`-- scripts/` 为空 |
| 2 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_port_alloc -v` | **OK**(Ran 1, 0.000s) |
| 3 | `python3 /tmp/port_alloc_synthetic.py`(合成测试:占住 helper 返回口再调用、51 次避让、TIME_WAIT 制造与避让、假 helper 触发断言;3 次运行) | 3 次 **exit=0,7/7 PASS**(典型值:held=36633 recall=49051;50-calls distinct=49-50/50 且 0 次命中被占口) |
| 4 | `python3 -c` 500 次连续调用统计 | 497/500 不同;**相邻重复 0/499** |
| 5 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest`(5 个改造用例:rdm e2e、rdm 缺 web、source-switch 三连、rdm update 轨道、批量更新双实例) | **OK**(Ran 5, 8.557s) |
| 6 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_realcombo -v` | OK(skipped=16:SVC_TEST_REAL/SVC_REAL_RDM_BIN/SVC_RDM_ORIGIN 未设 → realcombo 三例 NOT_RUN,禁网无法取得真实二进制) |
| 7 | `grep` 残留扫描(9471/9473/9474/18841-18844)与 `free_test_port` 调用点清单 | 两文件零残留;调用点 = test_svc.py:673/904/931/942/970 + realcombo:172/219/245 |
| 8 | `git show 444fb1dee2:tests/test_svc.py` 753-754 行 vs HEAD 757-758;本单元 diff 中 `18951` 计数 | 内容逐字一致;diff 计数 0 |
| 9 | `cat /proc/sys/net/ipv4/ip_local_port_range` | 32768 60999(与全部固定测试口不相交) |
| 10 | `git status --porcelain` / `git stash list`(测试运行前后) | 空(测试全在 /tmp 临时目录内自包含) |
## 判定标准对照
- 契约用例仍会因端口占用偶发失败:未发生(6 个 --port 调用点 + 批量更新 2 seed + realcombo 3 例全数转内核分配;动态口与仓内固定口不相交;占口失效模式为响亮失败)→ 非阻塞。
- helper 在外部占用/TIME_WAIT 下取错端口:未发生(合成实测 0 命中)→ 非阻塞。
- 受保护内容被改(svc.sh、register_pg 18951、recover/pr_check/triage 三文件):未发生(零 diff 实测)→ 非阻塞。
- 补丁外溢到契约外文件:未发生(5 文件实测,其一为 host 豁免的任务卡)→ 非阻塞。
- 结论:**PASS**。realcombo 三例运行级验证 NOT_RUN(环境门禁 + 禁网),以代码走读与模块级 skip 确认替代,已如实标注。
+13
View File
@@ -0,0 +1,13 @@
"""Shared free-port helper for svc tests (not a test module)."""
import socket
def free_test_port():
"""Return a port the kernel reports free on 127.0.0.1 right now.
Each call opens a fresh reservation and closes it before returning, so the
caller (e.g. `svc deploy`) can bind the port itself. Ports are not pooled.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
reservation.bind(("127.0.0.1", 0))
return reservation.getsockname()[1]
+24
View File
@@ -0,0 +1,24 @@
"""Regression for tests/_port_alloc.free_test_port."""
import os
import socket
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
class FreeTestPortTests(unittest.TestCase):
def test_two_calls_return_distinct_bindable_ports(self):
first = free_test_port()
second = free_test_port()
self.assertNotEqual(first, second)
for port in (first, second):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", port))
listener.listen(1)
self.assertEqual(listener.getsockname()[1], port)
if __name__ == "__main__":
unittest.main()
+13 -7
View File
@@ -10,12 +10,16 @@ import os
import shutil
import subprocess
import socket
import sys
import tempfile
import unittest
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
@@ -666,7 +670,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
"""
old_sha, new_sha = self.make_src_repo()
ports = {"a": 18841, "b": 18842}
ports = {"a": free_test_port(), "b": free_test_port()}
old_pids = {}
for name, port in ports.items():
d = self.runtime(name)
@@ -897,7 +901,8 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_rdm_source_deploy_end_to_end(self):
remote, work = self.make_rdm_repo()
env = self.rdm_env(remote)
result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18843", env=env)
result = self.svc("rdm1", "deploy", "--from", "rdm", "--port", str(free_test_port()),
env=env)
d = self.runtime("rdm1")
# U2:组合产物入不可变 release,经 current 指针解析;插件空集、工作台随 release。
active = d / "current"
@@ -923,7 +928,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_rdm_companion_missing_web_fails_deploy(self):
remote, work = self.make_rdm_repo(with_web=False)
env = self.rdm_env(remote)
result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", "18843",
result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", str(free_test_port()),
env=env, check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("ui-missing", result.stdout)
@@ -934,18 +939,19 @@ class SvcDbRecoveryTests(unittest.TestCase):
def test_source_switch_requires_flag(self):
remote, work = self.make_rdm_repo()
env = self.rdm_env(remote)
port = str(free_test_port())
# 先以 local 源部署(内嵌 UI 桩一律 200)。
self.svc("sw1", "deploy", "--from", "local", "--port", "18844")
self.svc("sw1", "deploy", "--from", "local", "--port", port)
old = self.kv("sw1", "source")
self.assertTrue(old.startswith("local@"))
# 无旗标换到 rdm → 拒绝,source 不变。
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", port,
env=env, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("allow-source-switch", denied.stdout)
self.assertEqual(self.kv("sw1", "source"), old)
# 带旗标允许换源,伴生就位。
ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", port,
"--allow-source-switch", env=env)
self.assertTrue(self.kv("sw1", "source").startswith("rdm@"))
self.assertIn("UI 就绪", ok.stdout)
@@ -961,7 +967,7 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertIn("RDM 仓提交", result.stdout)
self.assertIn("rdm v1", result.stdout)
# 部署 v1 → 远端推进 v2 → update 检测可更新并真更新(source 保持 rdm@ 轨道)。
self.svc("rdm1", "deploy", "--from", "rdm", "--port", "18842", env=env)
self.svc("rdm1", "deploy", "--from", "rdm", "--port", str(free_test_port()), env=env)
old = self.kv("rdm1", "source")
(work / "marker").write_text("v2\n")
subprocess.run(["git", "add", "."], cwd=work, env=self.env, timeout=60, check=True)
+14 -7
View File
@@ -21,6 +21,7 @@ import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import unittest
@@ -28,6 +29,9 @@ import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _port_alloc import free_test_port # noqa: E402
SOURCE = Path(__file__).resolve().parents[1]
REAL = os.environ.get("SVC_TEST_REAL") == "1"
@@ -165,7 +169,8 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
def test_U3_real_agent_identity_lifecycle_sqlite(self):
"""真实组合二进制:领养 ensure → verify → 两次裸重启身份/锚点稳定;
capabilities v1 探测契约。"""
self.seed_real("rc1", 9471, self.real_bin)
port = free_test_port()
self.seed_real("rc1", port, self.real_bin)
result = self.svc("rc1", "start")
self.assertIn("已启动", result.stdout)
m = self.manifest("rc1")
@@ -178,7 +183,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
self.assertTrue(con.execute(
"SELECT count(*) FROM schema_migrations").fetchone()[0] >= 1)
con.close()
self.assertTrue(self.http_ok(9471))
self.assertTrue(self.http_ok(port))
# capabilities v1 契约(探测无副作用形态)
cap = subprocess.run([str(self.runtime("rc1") / "bin" / "agent-system"),
@@ -203,7 +208,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
self.assertEqual(self.identity_row("rc1"), [(iid, "sqlite")],
"裸重启不得换身份")
self.assertEqual(self.manifest("rc1")["instance_id"], iid)
self.assertTrue(self.http_ok(9471))
self.assertTrue(self.http_ok(port))
self.svc("rc1", "stop")
@unittest.skipUnless(REAL_RDM_BIN, "真实 RDM 专项 NOT_RUN:需 SVC_REAL_RDM_BIN")
@@ -211,13 +216,14 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
"""真实 RDM 组合二进制:identity 接线(Backend 公共 CLI)→ serve 门禁 →
领域层可初始化。"""
rdm_bin = Path(REAL_RDM_BIN).resolve()
self.seed_real("rcr", 9473, rdm_bin)
port = free_test_port()
self.seed_real("rcr", port, 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))
self.assertTrue(self.http_ok(port))
# RDM 未知子命令明确退出(不落服务路径)
bogus = subprocess.run([str(self.runtime("rcr") / "bin" / "agent-system"),
"totally-bogus"],
@@ -236,7 +242,8 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
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",
agent_port = free_test_port()
result = self.svc("rcf", "deploy", "--from", "rdm", "--port", str(agent_port),
env=env, check=False)
if result.returncode != 0:
log = self.runtime("rcf") / "svc.log"
@@ -259,7 +266,7 @@ class RealComboTests(tsr.SvcAutoRecoverTests):
else "rdm", "工作台内容")
self.svc("rcf", "restart")
self.assertEqual(self.identity_row("rcf"), [(iid, "sqlite")])
self.assertTrue(self.http_ok(9474))
self.assertTrue(self.http_ok(agent_port))
self.svc("rcf", "stop")
self.svc("rcfweb", "stop")