Skip to content

feat(harness): 集群 RSI 并行轮协调器 - #268

Open
NumberWan wants to merge 4 commits into
InferMatrixLab:mainfrom
NumberWan:parallel-round-offline
Open

NumberWan wants to merge 4 commits into
InferMatrixLab:mainfrom
NumberWan:parallel-round-offline

Conversation

@NumberWan

@NumberWan NumberWan commented Oct 30, 2026 •

Copy link
Copy Markdown

这支 PR 是什么

这是 #249 的一块离线切片:把「一轮并行优化」的协调器做成 prepare / submit / serve 三个命令。它不启动 Claude Code 或 Codex,也不把几条 lane 的改动叠回 incumbent。

现在 InferRSI 的一轮只推一个 candidate。它从当前最好的版本(incumbent)分出一个目录,改一处,再用 AB/BA 跟 incumbent 比。几条 coding agent 同时改时会出事:大家抢同一张 GPU;工作目录不是从 incumbent 起;两条各自变快的改动不知道怎么合;失败的那几条也不会留在同一份账本里。

这支 PR 解决这一轮里面的三件事:

  1. 冻结计划。 人先写好这一轮有哪几条 lane。每条 lane 有自己的 id、对应哪一条搜索计划、只属于 harness / model / infra 其中一个平面、只能改自己的 glob、有 attempt 上限和截止时间。prepare 会拒绝重叠的 glob、碰到 protected 文件、两条 lane 追同一个 entry。通过之后把计划写成 round-plan.json,并带上覆盖计划正文的 digest。
  2. 异步交件。 submit 只比较这条 lane 允许改的文件,把差异放进 outbox,回一张 ticket。同一份 manifest 永远回同一张 ticket。这一步不占 GPU,所以 agent 不用等 benchmark 跑完。符号链接和其他非常规文件在读到目标内容之前就拒绝。范围内的删除在算哈希之前就拒绝。过了截止时间的 lane 直接拒绝。
  3. 一台设备上排队量。 serve 一次只量一张 ticket。设备锁被别人拿着时,ticket 保持 queued,不先扣一次 attempt。拿到锁之后才 reserve。账本先写成 claimed,然后才跑第一条候选命令。build、candidate trial、incumbent trial 都在隔离环境里跑。隔离建不起来就拒绝,不会退回普通子进程。截止时间每次重新读时钟,不使用调用方传入的 now。trial 退出码是 0、却没有可读的 latency 文件时,写下 unsettled,账本停在 claimed,status 不再显示 queued。

量出来的数字只供这一轮比较。测试里的 latency 是 fixture 输入,不是 campaign 结果。agent 自己报的时间、外部 harness 的投票,都不算数。

还没有做的:接上 Claude Code 或 Codex、SwarmFlow 范本、close,以及把通过的几条改动叠成新的 incumbent。这些留在 #249,不在这支 PR 里。

没有集群,和有集群,控制流差在哪

下面两张图只说明一轮里面工作怎么排。它们不是这支 PR 跑出来的性能结果。

没有集群时,一次只改一处。上一处量完,下一处才开始。卡的墙钟是每一代加起来。

flowchart LR
  startNode[incumbent] --> gen1[改一处]
  gen1 --> measure1[占着这张卡量完]
  measure1 --> gen2[再改下一处]
  gen2 --> measure2[再占着这张卡量完]
  measure2 --> later[下一代继续排队]
Loading

有集群时,同一个 incumbent 同时分出多条 lane。每条只改自己的文件。交件可以同时发生。量测仍然只有一个 serve 进程,因为一张卡不能同时跑两份 benchmark。先做完的 ticket 先量,不用等所有 agent 都交件。

flowchart TB
  incumbent[同一个 incumbent]
  incumbent --> laneA[lane A 只改自己的文件]
  incumbent --> laneB[lane B 只改自己的文件]
  incumbent --> laneC[lane C 只改自己的文件]
  laneA --> ticketA[ticket A]
  laneB --> ticketB[ticket B]
  laneC --> ticketC[ticket C]
  ticketA --> serveNode[一个 serve 拿设备锁排队量]
  ticketB --> serveNode
  ticketC --> serveNode
  serveNode --> ledger[每张 ticket 的结果写入 ledger]
Loading

这支 PR 停在 ledger 这一格。它不会自动挑出赢家,也不会把几条改动合成一个新的 incumbent。那是 close,还没写。

这支 PR 里一张 ticket 怎么走

flowchart TB
  prepareNode[prepare 冻结 round plan] --> submitNode[submit 交件并拿到 ticket]
  submitNode --> locked{设备锁空闲?}
  locked -->|否| queued[保持 queued 不扣 attempt]
  locked -->|是| deadline{还在截止时间前?}
  deadline -->|否| rejected[rejected 并取消这次保留]
  deadline -->|是| claimed[账本写成 claimed]
  claimed --> runNode[隔离环境里跑 build 和两次 trial]
  runNode --> readable{latency 文件可读?}
  readable -->|否| unsettled[unsettled 账本留在 claimed]
  readable -->|是| measured[measured 写下两边的 latency]
Loading

隔离的具体边界:

  • 新的根只挂可写目录、可读目录、Python 运行时,以及动态链接器需要的库。不把宿主根目录挂回去。
  • 命令只拿到允许的环境变量:PATH、语系、TZ、PYTHONIOENCODING。
  • 这台机器 max_user_namespaces 是 0,或者 unshare 实际被拒绝时,后端直接拒绝执行。namespaces_available 会真的试一次 unshare,现场测试在这种机器上跳过,而不是失败。
  • 子进程超时、隔离失败、trial 没有写出可读结果,都留下收据。已经 claimed 的那一笔不退。

测试

Give one round a plan-supplied attempt budget and a fail-closed execution boundary so lanes can submit synthetic candidates without sharing a device lease.

Refs InferMatrixLab#249

Signed-off-by: NumberWan <wantszkin2003@gmail.com>

@Dong1017 Dong1017 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent source review of head 56a40049148ced7bb80e60249a8f5df40d5c1587, against API base / observed main f93df0548a26714f6514337a8525c7c14108acad. Requesting changes for three current execution-contract defects, detailed inline: protected read/environment exposure, claim persistence after execution, and incomplete absolute-deadline enforcement.

I traced prepare/submit/serve, lane-budget transitions, confinement, leases, the CLI and all added tests against the existing architecture and #249 discussion. The attempt-scoped digest and permanently retained reservation charge are useful; they do not clear the inline execution-boundary findings. The Draft/partial-delivery description is appropriately explicit. Existing M0–M2 assignment/scope acceptance remains a maintainer decision, separate from this code review.

Verification was source and existing hosted-status inspection only. I did not execute this branch, tests, namespaces, models, devices or a paid workflow. Current hosted evidence contains the declaration check; no Ready full-check/installed-package result was observed for this head. Fixture results and author-reported tests do not qualify OS confinement or a campaign outcome. Next for the existing author @NumberWan: fix the three boundaries and supply the corresponding targeted evidence, preserving unknown/failed execution states and the Draft scope. No pilot, device use, incumbent advance, merge or Issue closure is authorized by this review.

Comment thread src/inferrsi/infra/confinement.py Outdated
Comment on lines +124 to +131
del readable
if not writable:
raise ConfinementUnavailable("refusing to run with an empty writable set")
config = writable[0] / ".inferrsi-confine.json"
payload = {
"argv": list(argv),
"writable": [str(path) for path in writable],
"env": dict(os.environ),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Enforce the declared read/credential boundary before candidate execution. readable is discarded, the existing host root remains visible in enter_namespace(), and the complete coordinator environment is copied into a JSON file under a candidate-writable directory and passed to execvpe. On a host where namespace setup succeeds, a build/trial can still read coordinator/grader files and other lanes that the host user can read; any coordinator-only environment credentials are also supplied to it. A read-only remount does not make these inputs invisible. This contradicts the explicit invisible-set contract in #249. Construct the allowed filesystem view and a minimal environment without exporting the full host environment, and fail closed if that boundary cannot be established. Verify build and trial stages with harmless private canaries; the argv-only FixtureBackend tests do not establish this property.

Comment thread src/inferrsi/harness/parallel_round.py Outdated
incumbent_body = json.loads(incumbent_result.read_text(encoding="utf-8"))
candidate_metric = candidate_body["metrics"]["latency_ms"]
incumbent_metric = incumbent_body["metrics"]["latency_ms"]
finished = advance(advance(ledger, binding, "claimed"), binding, "finished", actual_cost=0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Persist the execution claim before running any build or trial. The only durable state saved before the three commands is reserved; claimed is introduced here after commands have run and result files have been read. A crash/timeout after starting work therefore leaves an executed attempt looking unstarted. A later ConfinementUnavailable (for example after a completed build) even takes the reserved → cancelled path. This is the execution-state ambiguity already distinguished in #249's follow-up. Persist a non-idempotent claim before the first candidate command, distinguish pre-launch refusal from failure after execution starts, and retain the claimed/charged ambiguity until trusted settlement. Add a case that interrupts after launch and verifies restart cannot treat that execution as an unclaimed cancellation. The existing permanent reservation charge should remain.

Comment thread src/inferrsi/harness/parallel_round.py Outdated
candidate,
(candidate, build_dir),
(),
time.monotonic() + 30,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Carry the absolute lane deadline through submission and every launch. admit_attempt(..., now) checks expiry once before materialization, but build, candidate and incumbent each receive a fresh time.monotonic() + 30. An attempt reserved just before expiry can start later commands after the frozen deadline; submit() also accepts new manifests after expiry without checking it. A restart rereading the plan does not close either path. Recheck the trusted clock at submission/claim/launch and bound running work by the same absolute cutoff, rather than resetting a per-command allowance. Cover both “reserve before expiry, launch after expiry” and “start before expiry, submit new bytes after expiry,” including restart, as requested in #249.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current-head follow-up (9b4b08b9, deadline finding still partially open): The new _launch.run() calls seconds_remaining(lane['deadline_utc'], current()) for every command, but in the production CLI tools/parallel_round.py calls serve_ticket(..., now=datetime.now(timezone.utc)) without a clock. serve_ticket.current() then returns that same entry-time now for every call (parallel_round.py:380–381). Thus a lane with 5s remaining can still run build, candidate and incumbent for up to 5s each (or three independent 30s caps when more remains), crossing the actual absolute deadline despite the new checks. The advancing-clock test only exercises the injected test callback, not this default. Please have the default current() read a fresh trusted clock for each gate, or derive one monotonically decreasing deadline once and share it across all stages; cover an expiry between build and candidate in a model-free fixture. Source inspection only, no command execution. The earlier thread remains the right place for this issue.

@Dong1017 Dong1017 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review of the existing Draft head 56a40049: one previously unraised file-deletion handling gap in the offline submit path (inline). Existing confinement, execution-claim, and deadline findings remain in their original threads; this review does not repeat or resolve them. Scope: static inspection of parallel_round.py, its caller and fixture tests against #249; no tests, namespace checks, model calls, or device experiments were run. This is not approval or M0–M2 acceptance.

Comment thread src/inferrsi/harness/parallel_round.py Outdated
outside = [path for path in changed if not _match(path, lane["globs"])]
if outside or not changed:
return {"submitted": False, "outside": outside, "ticket": None}
manifest = {path: sha256(work / path) for path in changed}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — An in-scope deletion crashes submission instead of producing a ticket or explicit rejection. _changed() includes paths present only in incumbent (set(before) | set(after)), so removing an allowed file, e.g. knobs/a.txt under lane glob knobs/a.txt, passes the outside check. This comprehension then calls sha256(work / path) on the deleted path and raises FileNotFoundError; the subsequent copy2 and _materialize likewise lack a deletion representation. Thus a normal scoped change cannot be submitted or deterministically measured. Either encode deletion/tombstone entries in the ticket manifest and apply removals when materializing, or explicitly reject deletions with a structured status before hashing. Add a model-free deletion-in-scope fixture covering submit and serve. This is a static code-path finding, not an executed test.

@Dong1017 Dong1017 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One additional blocking file-boundary finding at 56a40049: submission can copy a coordinator-readable external file through a lane-controlled symlink. This intake path is distinct from the existing candidate-execution confinement finding and must be closed before accepting untrusted lane submissions.

Verified with a disposable synthetic external canary and the exact pinned function bodies; no real private file or candidate command was accessed/executed. Existing findings remain in their threads; no duplicate findings or broader feature requirements are added.

Verification boundary

Function-level reproduction used AST-extracted prepare/submit and necessary helpers/artifact functions, standard library only, with a sanitized environment. Allowed path knobs/a.txt was replaced by a symlink to a synthetic sibling file outside work. Result: submitted=true, outside=[], source_is_symlink=true, outbox_is_regular_copy=true, copied_external_canary=true. This is not a full CLI or OS-confinement qualification; no model/hardware/network execution or repository modification occurred.

Comment thread src/inferrsi/harness/parallel_round.py Outdated
for path in changed:
target = destination / path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(work / path, target)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Reject lane-controlled symlinks before hashing or copying submissions. _files() admits symlinked files through is_file(), and _changed() reads their targets before this relative-path scope check. Replacing permitted knobs/a.txt with a symlink to a coordinator-readable file outside work still yields outside=[]; submission succeeds and copy2 writes the external contents into the outbox as a regular file. A synthetic sibling-canary reproduction confirmed this without running candidate commands. Fixing candidate namespace confinement alone does not protect this earlier intake path.

Reject symlinks/nonregular entries before any content read, and use root-confined, race-resistant no-follow reads for snapshot/hash/copy, including parent components. Checking resolve() and then reopening normally leaves a replacement race. Add a synthetic external-canary rejection test; no real sensitive files are needed.

Refuse symlinks and in-scope deletions before any content read, persist a
claim before the first candidate command, and bound every launch by the
frozen deadline. Confinement now builds a fresh root and a minimal
environment instead of remounting the host root.

Refs InferMatrixLab#249

Signed-off-by: NumberWan <wantszkin2003@gmail.com>
@NumberWan

Copy link
Copy Markdown
Author

Addressed the five findings on 56a4004 in 9b4b08b.

  • Filesystem view. namespace_plan builds a fresh root from the writable set, the readable set, and the Python runtime. It refuses a mount of / and does not remount the host root. The command receives an allowlisted environment (PATH, locale, TZ, PYTHONIOENCODING); the config is a coordinator tempfile, not a file under the candidate tree. FixtureBackend is still a test double (import hook plus the same minimal environment). Build and trial canaries show SECRET_CANARY and a private file stay out of both stages. Unshare failure still does not fall back to a plain process.
  • Claim before launch. serve reserves, materializes, rechecks the deadline, then advances to claimed and saves that entry before the first confined command. ConfinementUnavailable after that point leaves the entry claimed and returns unsettled. A restart with the result file removed does not reserve again and does not cancel. A refusal before launch can still cancel the reservation. The worst-case charge is kept.
  • One deadline. submit re-reads the plan and refuses an expired lane. serve rechecks at claim and before every command, and the subprocess timeout is the time left until that same deadline (capped at 30s so a far deadline does not overflow poll). Covered: reserve before expiry / launch after expiry, and a new submission after expiry, including a second read.
  • Deletions. An in-scope deletion is a structured deletion refusal before hashing. A crafted manifest with deleted is rejected before the lease, the claim, and any candidate command.
  • Symlinks. Snapshot, hash, and copy use O_NOFOLLOW from the tree root. A symlink or other non-regular entry is rejected before its target is read, including when it masks a path the incumbent still has. The external-canary fixture does not land in the outbox.

Still out of scope: OS confinement on this host (user namespaces are unavailable, so the default backend fails closed), M3, incumbent advance, and shared drivers.

@NumberWan
NumberWan marked this pull request as ready for review November 2, 2026 03:05

@Dong1017 Dong1017 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental static review of head 9b4b08b9 following the author's five targeted fixes: one newly identified expected-failure reporting gap in the unshare backend (inline). Existing confinement, deadline, claim and symlink findings remain on their original threads; this does not repeat, dismiss or approve them. I inspected the new confinement.py, parallel_round.py, CLI caller and available fixture tests; no real namespace, test, model or device run was performed. This is not an M0–M2 acceptance or launch authorization.

stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=remaining,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Persist a terminal unsettled receipt when the real namespace subprocess times out. subprocess.run(..., timeout=remaining) raises subprocess.TimeoutExpired on the normal time-limit path. Here only the finally cleanup runs; the exception escapes UnshareBackend.run. In serve_ticket, the claimed ledger has already been persisted, but its handler only catches ConfinementUnavailable and BudgetError, so this timeout exits without writing results/<ticket>.json. A subsequent status call incorrectly reports queued (until a new serve invocation detects the claim). Catch the expected subprocess timeout, preserve the claimed/charged ambiguity, write an explicit unsettled receipt with stage/reason, and test this with a stubbed timeout—no real namespace is needed. This is separate from proving OS confinement on a host.

@Dong1017 Dong1017 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at 9b4b08b9: the new filesystem view still lacks the runtime files needed to start a dynamically linked Python command.

  • One new P2 startup defect is detailed inline. It affects the real namespace backend on an otherwise namespace-capable Linux host.
  • The existing deadline follow-up and timeout-receipt finding remain applicable; this review does not repeat them.
  • The persisted pre-launch claim and explicit deletion refusal improve the earlier paths. They do not establish native confinement qualification or complete the parent task. Next action remains with @NumberWan: repair the current blockers and provide bounded software evidence.
Evidence and scope

Reviewed head: 9b4b08b9f7b4bde0f18d6e3db80d8e47cf059539; API base: f93df0548a26714f6514337a8525c7c14108acad; assessed main: 919ed42c0d01dc0aa3b613085b9041d735d2d4db.

Inspected the six-file repair delta, current prepare/submit/serve and CLI consumers, runtime-root/mount/exec paths, and relevant fixture tests against the existing execution contract. Current per-head evidence contains successful declaration checks; no Ready full-check or installed-package success was observed. Verification was source and existing hosted evidence only: no repository tests, namespaces, model/device runs or paid evaluation were executed. This review did not implement the PR and grants no launch, merge or goal-acceptance authorization.


def runtime_roots() -> tuple[Path, ...]:
"""Python prefix needed to start the interpreter. Never the host root."""
candidates = [Path(sys.prefix).resolve(), Path(sys.executable).resolve().parent]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Include the executable's runtime dependency closure in the fresh root. runtime_roots() contributes only sys.prefix and the executable's parent. For a normal dynamically linked CPython with prefix /usr/local (or /usr), the ELF interpreter and some shared libraries are reached through /lib* paths outside those mounts. enter_namespace() creates only the planned bind targets before chroot and then calls execvpe; it does not create the required loader paths/symlinks. Consequently, a namespace-capable Linux host can build the jail successfully but fail even the initial Python build/trial with ENOENT for the missing ELF interpreter, before any task code runs. This is distinct from namespace availability and the existing timeout finding.

Construct an explicit read-only runtime image/allowlist that includes the interpreter's required loader and shared-library paths, while retaining the private-file boundary; do not restore the host-root bind. A model-free Linux smoke case should start the actual UnshareBackend with a trivial Python command and separately confirm that a private canary stays invisible. The current tests intercept subprocess.run or use the import-hook fixture, so they do not cover this startup path. Static path analysis, not an executed namespace reproduction; the loader/ENOENT behavior is documented by execve(2).

…e jail

A namespace timeout now leaves an unsettled receipt, each launch reads a fresh
clock, and the fresh root includes the dynamic loader and its libraries.

Refs InferMatrixLab#249

Signed-off-by: NumberWan <wantszkin2003@gmail.com>
@NumberWan

Copy link
Copy Markdown
Author

Addressed the three findings on 9b4b08b in fc9d232.

  • Timeout receipt. UnshareBackend turns subprocess.TimeoutExpired into ConfinementUnavailable and still deletes the coordinator config. serve catches that, and a backend that raises TimeoutExpired directly, after the claim is already durable. The receipt is unsettled, names the stage (build / candidate / incumbent), and status no longer reports queued. The claim is not cancelled.
  • Fresh deadline. The production path no longer reuses the timestamp captured when serve was entered. Each admission, claim check and command reads the clock again, and the subprocess timeout is the time left until that same absolute deadline (still capped at 30s). Covered: the clock crosses the deadline after build and before the candidate trial; the candidate command is not started, and the entry stays claimed.
  • Loader closure. The fresh root still does not mount /. Besides the Python prefix, the plan now bind-mounts the executable's PT_INTERP loader, the libraries ldd resolves, and ld.so.cache when it exists, at their original absolute paths. A private canary directory is not in that plan. The live UnshareBackend smoke is in the tree and skips where user namespaces are unavailable (max_user_namespaces=0 on this host); it was not executed here.

Still out of scope: OS-confinement qualification on this host, M3, incumbent advance, and shared drivers.

@agent-ci-infra-bot agent-ci-infra-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit: fc9d232
Coverage: Snapshot: 2221 files, 35993584 bytes; skipped 0 entries; Initial model context: 22 files; other accepted files available through tools; Git workspace contains a fixed snapshot, not full upstream history

Comment thread tools/parallel_round.py
now=datetime.now(timezone.utc),
)
elif args.command == "serve":
result = serve_ticket(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F1 — critical, must-fix, regression — serve CLI is broken: serve_ticket() still requires now, but this commit removed it from the only call site

  • Where: tools/parallel_round.py:53 (signature at src/inferrsi/harness/parallel_round.py:394)
  • Problem: serve_ticket(round_dir, ticket, incumbent, *, backend, now: datetime, clock=None) keeps now as a required keyword-only parameter while this commit deleted now=datetime.now(timezone.utc) from the CLI call (tools/parallel_round.py diff, -1 line) and moved the fresh-clock behaviour into current(). now is now never read in the body, so the parameter is dead but still mandatory. Reproduced: PYTHONPATH=src python -m tools.parallel_round serve --round-dir /tmp/rd --ticket abc --incumbent /tmp → TypeError: serve_ticket() missing 1 required keyword-only argument: 'now'.
  • Conditions: every serve invocation through tools/parallel_round.py, i.e. the documented coordinator path the PR advertises. All tests pass only because each call in tests/test_parallel_round.py passes now=WHEN.
  • Impact: the coordinator cannot measure any ticket outside unit tests; the serve path fails before reading the plan, so the PR's main entry point is unusable.
  • Fix: make the parameter optional or drop it, e.g. now: datetime | None = None (keep current() reading datetime.now(timezone.utc) so the fresh-deadline contract from the earlier round stays), or pass now=datetime.now(timezone.utc) again from the CLI. Add a CLI-level regression test that calls main(["serve", ...]), not only direct serve_ticket calls.

Comment thread tests/test_confinement.py
assert "SECRET_CANARY" not in plan["env"]


@pytest.mark.skipif(not namespaces_available(), reason="user namespaces are unavailable")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F2 — high, must-fix, test gating — the live namespace smoke fails instead of skipping when unprivileged user namespaces are blocked

  • Where: tests/test_confinement.py:135
  • Problem: the guard only reads /proc/sys/user/max_user_namespaces. When that value is positive but namespace creation is denied, the test runs and errors: UnshareBackend().run → ConfinementUnavailable: unshare: unshare failed: Operation not permitted. Executed in this container: PYTHONPATH=src python -m pytest tests/test_round_budget.py tests/test_confinement.py tests/test_parallel_round.py → 1 failed, 23 passed, the failure being test_unshare_starts_python_and_hides_a_private_canary. Here max_user_namespaces = 498239 and kernel.apparmor_restrict_unprivileged_userns = 1, while unshare --user true returns EPERM.
  • Conditions: unprivileged containers (default seccomp profiles) and AppArmor-restricted unprivileged-userns hosts, which is the default on Ubuntu 24.04+ and therefore on common GitHub-hosted runners. This is unrelated to whether the host has namespaces at all.
  • Impact: the repository's full pytest (ready and nightly lanes) goes red on such hosts, and the PR's statement that the smoke "skips where user namespaces are unavailable" is wrong, so this test cannot serve as the confinement qualification evidence it claims to be.
  • Fix: probe the syscall rather than the sysctl, e.g. subprocess.run(["unshare", "--user", "true"], ...) and skip on a non-zero return, or wrap UnshareBackend().run(...) in try/except ConfinementUnavailable and pytest.skip(str(exc)).

Comment thread src/inferrsi/harness/parallel_round.py Outdated
)
except (ConfinementUnavailable, BudgetError) as exc:
return _record(round_dir, ticket, status="unsettled", reason=str(exc))
candidate_body = json.loads(candidate_result.read_text(encoding="utf-8"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F3 — medium, suggestion, error handling — a trial that exits 0 without a valid {result} escapes as an uncaught exception and leaves no terminal receipt

  • Where: src/inferrsi/harness/parallel_round.py:446 (unchanged since the previous reviewed commit)
  • Problem: _launch.run only checks record["status"] != "completed", then serve_ticket reads trial_dir/candidate.json and incumbent.json outside the except (ConfinementUnavailable, BudgetError) handler. If the result file is missing or not valid JSON, FileNotFoundError/JSONDecodeError propagate out of serve_ticket; the ledger entry stays claimed and results/<ticket>.json is never written. This is the same receipt gap that was fixed for subprocess.TimeoutExpired in this round.
  • Conditions: a trial command that exits 0 without writing exactly the {result} path (a stub, or a benchmark whose output path was changed/broken) or that writes a non-JSON body.
  • Impact: serve aborts with a traceback, status reports queued until another serve run detects the claim, and the attempt remains charged with no unsettled receipt.
  • Fix: move the result read/parse into a try that records _record(round_dir, ticket, status="unsettled", reason=f"{stage} result missing/invalid") instead of letting the exception escape (the claim itself should stay as it is).

@agent-ci-infra-bot

Copy link
Copy Markdown

This PR adds the offline parallel-round coordinator (prepare/submit/serve/status), lane attempt budgets, a fail-closed confinement backend and its tests. Two blocking defects: the serve CLI now calls serve_ticket() without its still-required now argument (TypeError on the only production entry point), and the new live namespace test errors instead of skipping when unprivileged user namespaces are blocked. Executed here: PYTHONPATH=src pytest tests/test_round_budget.py tests/test_confinement.py tests/test_parallel_round.py → 1 failed, 23 passed; ruff could not be executed in this container (binary not runnable), and no test-lane CI check runs exist for this head (only the knowledge-policy check).
Earlier findings: F(56a4004 #1 read/credential boundary) fixed — the fresh root excludes /, readable is mounted, and only an allowlisted env reaches the command; F(#2 claim before launch) fixed — claimed is persisted before the first command; F(#3 deadline) fixed in the engine, but the fix broke the CLI wiring (F1); F(#4 deletion) fixed — structured deletion refusal before hashing; F(#5 symlink) fixed — no-follow snapshot/read with linked-path rejection. F(9b4b08b timeout receipt) fixed — TimeoutExpired becomes ConfinementUnavailable/unsettled receipt. F(9b4b08b clock) fixed — default current() reads a fresh clock. F(9b4b08b loader closure) fixed in code, but the added smoke could not be validated here (unprivileged unshare returns EPERM) and mis-gates (F2).
Labels: kind:task (bounded delivery slice of the #249 coordinator work; no priority label returned because the repository requires a status decision for priority:*).

Reviewed commit: fc9d232
Coverage: Snapshot: 2221 files, 35993584 bytes; skipped 0 entries; Initial model context: 22 files; other accepted files available through tools; Git workspace contains a fixed snapshot, not full upstream history

@agent-ci-infra-bot agent-ci-infra-bot Bot added kind:task Bounded delivery task with a delegated slice bot-reviewed labels Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Review at head fc9d2328: not approving yet.

  1. serve CLI is broken (tools/parallel_round.py:53-58): serve_ticket(round_dir, ticket, incumbent, backend=select_backend()) omits now, but src/inferrsi/harness/parallel_round.py:388-396 still declares now: datetime as a required keyword-only argument, so the only production entry point raises TypeError. Expected: pass now=datetime.now(timezone.utc) (or make now optional and derive it from clock) and add a CLI-level test.
  2. Open bot findings at this head: F2 (tests/test_confinement.py:135, live namespace smoke fails instead of skipping when user namespaces are blocked) and F3 (src/inferrsi/harness/parallel_round.py:446, a trial exiting 0 without a valid result leaves no terminal receipt) have no reply or fix.
  3. Open CHANGES_REQUESTED from Dong1017 (runtime dependency closure in the fresh root, src/inferrsi/infra/confinement.py): please confirm whether fc9d2328 addresses it and reply in that thread.
  4. Ready CI has not run at this head (only declarations).

Copy link
Copy Markdown
Collaborator

Coverage review at head fc9d2328a6ef1a9b79e98b961967699622f5e305 (base f93df0548a26714f6514337a8525c7c14108acad). Re-checked against my earlier short-SHA note and the bot review at this head. No new commits. Not approving yet.

  1. serve CLI is still broken (tools/parallel_round.py ~L53–58): serve_ticket(round_dir, ticket, incumbent, backend=select_backend()) omits now, but src/inferrsi/harness/parallel_round.py ~L388–396 still declares now: datetime as a required keyword-only argument, so the only production entry point raises TypeError. (Inside the function the body uses current() / clock and does not read now, so making now optional — or removing it and passing now=datetime.now(timezone.utc) from the CLI — both work; please pick one and add a CLI-level test.)
  2. Open bot findings at this head (agent-ci-infra-bot): F2 (tests/test_confinement.py live namespace smoke fails instead of skipping when user namespaces are blocked) and F3 (src/inferrsi/harness/parallel_round.py ~L446, a trial exiting 0 without a valid result leaves no terminal receipt) have no reply or fix on this head.
  3. Outstanding Dong1017 CHANGES_REQUESTED (runtime dependency closure in the fresh root, src/inferrsi/infra/confinement.py, and earlier threads): author claims on fc9d232 address loader closure via PT_INTERP / ldd / ld.so.cache, but please confirm in those review threads whether the blockers are cleared and supply the targeted evidence.
  4. Ready CI has not run at this head (only declaration-related evidence observed previously).

Reviewed commit range: f93df0548a26714f6514337a8525c7c14108acad … fc9d2328a6ef1a9b79e98b961967699622f5e305.

The production serve command required a clock it never read, a blocked unshare was treated as a usable namespace, and a zero exit without a latency file left the ticket without a result.

Signed-off-by: NumberWan <wantszkin2003@gmail.com>
@NumberWan

Copy link
Copy Markdown
Author

fc9d232 上的三点已经在 cf22cb5 处理。说明改成简体中文。

  1. serve CLI。 serve_ticket 的 now 改成可选,生产入口不再因为少传这个参数而 TypeError。函数内部仍用 clock 或当前时间,不把进场时间冻成截止时间。补了 test_serve_cli_runs_without_a_caller_supplied_clock。
  2. namespace 测试误失败。 namespaces_available 在 max_user_namespaces > 0 之后再跑一次 unshare --user --map-root-user --mount true。返回非 0、超时或找不到命令时视为不可用,现场测试走 skip。补了 test_blocked_unshare_reports_namespaces_unavailable。这台机器 max_user_namespaces 是 0,原有的现场测试仍然 skipped,没有实际跑进 namespace。
  3. 没有结果文件就没有收据。 build / candidate / incumbent 都返回 completed,但 latency JSON 缺失、损坏或缺字段时,写下 unsettled,原因写明 trial 没有可读的 latency 结果。claim 不取消。补了 test_completed_trial_without_a_latency_file_is_unsettled。

Dong1017 在 9b4b08b 上提的动态链接器闭合,仍是 fc9d232 里用 PT_INTERP、ldd 和 ld.so.cache 做的那一版;这次没有改那条挂载逻辑。这台机器不能创建 user namespace,所以那条现场启动测试这里仍然跳过。

pytest tests/test_round_budget.py tests/test_confinement.py tests/test_parallel_round.py:26 passed,1 skipped。ruff check 通过。PR 仍不把 M0–M2 当成已验收。

Copy link
Copy Markdown
Collaborator

增量 review:fc9d2328a6ef1a9b79e98b961967699622f5e305..cf22cb50f243b17227863f8e79f601b25faf5480(1 个提交,base f93df0548a26714f6514337a8525c7c14108acad)。谢谢这么快修完,这次先不 approve。

已核对、确认修好的:

  • serve CLI:src/inferrsi/harness/parallel_round.py L394 now 改为可选并 del now,tools/parallel_round.py L53-58 不传 now 也不会再 TypeError;截止时间仍由 current() 每次重新读时钟。
  • F2 namespace 误失败:src/inferrsi/infra/confinement.py namespaces_available() 现在实际跑一次 unshare --user --map-root-user --mount -- true,非 0 / 超时 / OSError 都返回 False,test_blocked_unshare_reports_namespaces_unavailable 覆盖了 EPERM 情形。
  • F3 缺结果无收据:parallel_round.py L451-462 对读取 / 解析 / 缺字段统一写 unsettled,且在 advance(..., "finished") 之前返回,账本停在 claimed,与之前的约定一致。

还需要处理的:

  1. Ready CI 没在这个 head 上跑:cf22cb50 目前只有 declarations,没有 full-check (3.10/3.11)、installed-package、pr-references。PR 描述里的 26 passed / 1 skipped 是本地结果,请触发完整 CI,让新增的 CLI 子进程测试在 CI 环境里也跑一遍。
  2. tests/test_parallel_round.py L409-439 的 CLI 测试断言太宽:body["status"] in {"measured", "unsettled", "rejected", "queued"} 几乎接受任何结果,只能证明没有 TypeError。计划截止时间是 2099 年,所以这里会走真实 select_backend():没有 user namespace 时应该确定是 unsettled(UnavailableBackend)。建议按 namespaces_available() 分支断言具体状态,至少在不可用时断言 unsettled 且 reason 指向 confinement 不可用,这样以后 fail-closed 行为退化时测试能抓到。
  3. Dong1017 的 CHANGES_REQUESTED 仍未关闭:9b4b08b9 上关于 fresh root 动态链接器闭合的 thread(src/inferrsi/infra/confinement.py L99)以及更早几条 thread 都还没有作者回复。这次没改挂载逻辑,现场 UnshareBackend 启动测试在你和 bot 的机器上都被跳过,所以 PT_INTERP / ldd / ld.so.cache 这一版在真实 namespace 里能否启动 Python 还没有人验证过。请在对应 thread 里回复,最好能在一台能创建 user namespace 的机器(或 CI runner)上跑一次 test_unshare_starts_python_and_hides_a_private_canary 并贴出结果,再请 Dong1017 确认。

小问题(不阻塞):namespaces_available() 每次调用都会起一个 unshare 子进程,select_backend() 只在 serve 入口调一次所以问题不大;如果以后在循环里调用,可以考虑缓存结果。

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review verdict — DO NOT PASS

Reviewed base f93df0548a26714f6514337a8525c7c14108acad and head cf22cb50f243b17227863f8e79f601b25faf5480 (full diff; no previous head was seeded). Two execution-contract blockers remain. I will re-read after the author pushes.

Blockers

  1. P1 — The unshare supervisor cannot import enter_namespace on the layout this PR tests. UnshareBackend.run (src/inferrsi/infra/confinement.py:330) starts sys.executable -c "from inferrsi.infra.confinement import enter_namespace" with env=minimal_environment() (:346). The allowlist (:20) is only PATH, LANG, LC_ALL, LC_CTYPE, TZ, and PYTHONIOENCODING. PYTHONPATH is omitted, and cwd is the candidate tree, so the child sys.path does not include the checkout that holds src/inferrsi. When that is the only way to import inferrsi, the child exits with ModuleNotFoundError before enter_namespace. Any non-zero status is raised as ConfinementUnavailable (:358), and serve_ticket records unsettled without running build or either trial. tools/parallel_round.py serve uses select_backend(), so a namespace-capable host takes this path. test_serve_cli_runs_without_a_caller_supplied_clock sets PYTHONPATH=src and accepts unsettled, so it stays green when the jail never starts. Expected correction: put only the directory that contains the inferrsi package (derived from this module's __file__) on the supervisor environment. Keep the candidate execvpe environment on the allowlist. Add a test that the supervisor environment can import inferrsi and still omits secrets such as SECRET_CANARY.

  2. P2 — Restart does not treat an existing ledger row for this ticket as the same attempt. _claimed_entry (src/inferrsi/harness/parallel_round.py:343) matches only phase == "claimed". Otherwise serve_ticket always calls admit_attempt, which mints ordinal = len(entries)+1 (:431).

    • Trigger A: _save_ledger persists reserved (:434) before _materialize (:437) and before claimed is saved (:444). copytree / copy2 raising, or a stop in that window, leaves no receipt. The next serve allocates another attempt. When the new charge exceeds cap, the ticket is recorded rejected for budget exhaustion and never launches. When the cap has room, the same ticket runs under a second permanent charge while the first row stays reserved.
    • Trigger B: _save_ledger of finished (:464) happens before _record writes results/<ticket>.json (:465). A stop in that window has no claimed row, so the next serve launches build, candidate, and incumbent again.
      Expected correction: a persisted run_id ending in @ticket is that attempt. Resume reserved without a new charge. For claimed or finished, write a terminal receipt and do not launch.

Closed on this head (source inspection)

  • Submit refuses symlinks via a no-follow walk, and refuses an in-glob deletion instead of hashing a missing path.
  • serve_ticket drops the caller's now and re-reads clock or datetime.now(timezone.utc) for admission and each stage. Submit checks deadline_utc. A stage TimeoutExpired becomes an unsettled receipt.
  • claimed is saved before _launch. A later claimed row with no receipt is recorded unsettled and is not launched again. The reserved and finished windows above are what remains of that contract.
  • The namespace plan is built from the writable set, the readable set, runtime_roots(), and runtime_files() (PT_INTERP, ldd libraries, and /etc/ld.so.cache). Source / is refused. The coordinator environment is reduced to the allowlist. namespaces_available() probes unshare, and the live test is skipped when that probe fails.
  • now is optional on serve_ticket, so the CLI serve call matches the signature. A completed trial with an unreadable latency file becomes unsettled.

Follow-ups (non-blocking)

  • Each stage still uses min(seconds_remaining(...), 30) (parallel_round.py:357), so a stage stops at 30s while the lane deadline still has time left.
  • A symlink present at scan time is rejected. _scan and _read_nofollow do not fstat the opened fd and do not open with O_NONBLOCK, so a regular file replaced by a FIFO after stat can block the coordinator in os.read.

Verification

Static read of parallel_round.py, round_budget.py, confinement.py, device_lease.py, tools/parallel_round.py, and the new tests, checked against the earlier threads on #268. Not verified here: pytest, unshare, namespaces, models, devices, or a paid workflow. Loader mounts and OS confinement are not qualified by this pass. Synthetic latency in this harness is not a campaign result. M0–M2 scope acceptance stays with the maintainer.

Dong-bot will re-read after the author pushes.

@NumberWan NumberWan changed the title feat(harness): offline parallel-round coordinator feat(harness): 集群 RSI 并行轮协调器 Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:harness Harness module: profile, A/B loop, quality gates, evidence reports bot-reviewed kind:task Bounded delivery task with a delegated slice

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants