From a1e1e9291c3db54b64fe5a9e831a5aa45f581e2e Mon Sep 17 00:00:00 2001 From: Seth Wang Date: Tue, 1 Sep 2026 10:28:10 +0800 Subject: [PATCH 1/3] test(e2e): keep isolated server alive across execs --- T-45-e2e-env-report.md | 132 +++++++++++++++++++++++++++++ e2e_test/CLAUDE.md | 11 ++- e2e_test/README.md | 49 +++++++++-- e2e_test/lib/tmux.sh | 77 +++++++++++++++++ e2e_test/run_all.sh | 19 +++++ e2e_test/setup.sh | 34 +++++++- e2e_test/teardown.sh | 33 ++++++-- e2e_test/tests_guard/run.sh | 160 +++++++++++++++++++++++++++++++++++- 8 files changed, 492 insertions(+), 23 deletions(-) create mode 100644 T-45-e2e-env-report.md create mode 100644 e2e_test/lib/tmux.sh diff --git a/T-45-e2e-env-report.md b/T-45-e2e-env-report.md new file mode 100644 index 000000000..8703b6656 --- /dev/null +++ b/T-45-e2e-env-report.md @@ -0,0 +1,132 @@ +# T-45 — e2e environment evidence + +測量基準固定為完整 commit `c8d2506f386bce15f731c50fb53d5c8e06e9c62b`, +worktree 為 `/Users/seth_wang/ai_workspace/officraft-t45-e2e-env-x107`。共用 +`/Users/seth_wang/ai_workspace/OffiCraft` 沒有 checkout;正式 port `7755` +本輪一直由既有 `ocserverd` PID `3821` 持有,未碰。所有以下站台操作都在 +own worktree 的 `8791` 隔離 DB/binary 上,沒有滿載測試。 + +## A — 修法前重現 + +- `evidence/t45-recon-20260901-091510`:setup 在獨立 exec 分別耗時 16s、 + 11s,均 rc=0;exec 結束後下一個 health probe 失敗。原始 stderr: + `curl: (7) Failed to connect to 127.0.0.1 port 8791 after 0 ms: Couldn't connect to server`。 +- 同目錄的持續 shell positive control 在 6s 內取得 HTTP 200、listener PID + `28720`;差異是 exec 邊界,不是 server route。 +- 同 shell 的完整 positive control 在 + `evidence/t45-recon-20260901-092650`:setup 9s、health 200 且 + `git_sha=c8d2506f`、login 200、Chromium spec `1 passed / 1s`、teardown + 0s。兩個中間 rc=1 已辨識為量測 wiring(SHA 長短比較、漏傳 + `OC_E2E_PASSWORD`),不是產品根因。 + +## A — 候選與採用 + +候選 ① 是每輪唯一私有 tmux socket/session;代價是 tmux 變成明確依賴, +並增加 namespace/state/teardown。候選 ② 是 launchd 等外部 service;代價是 +跨平台、權限、job/pid/socket identity 與清理複雜度最高。候選 ③ 是同 shell +明文契約加跨 exec hard-fail guard;代價是 agent 拆步仍不可用,且 guard 必須 +可執行並有 red mutant。本包採 ①,未採 ②/③。 + +採用前的實際 tmux positive control 在 +`evidence/t45-recon-20260901-093249-tmux`:私有 socket +`oc-t45-x107-server-20260901`、session `t45-server`,下一個獨立 exec 讀到 +`has-session rc=0`、listener PID `42087`、API HTTP 200、 +`git_sha=c8d2506f`,command 是 own `.state/ocserverd serve`;exact +`kill-session rc=0`、8791 released,7755 untouched。 + +## A — 修法後回歸 + +變更位於: + +- `e2e_test/lib/tmux.sh`:tmux prerequisite、`oc-e2e-*` namespace validation、 + exact session start/stop。 +- `e2e_test/setup.sh`:每輪建立並記錄唯一 tmux socket/session,server 活過 + setup exec 邊界。 +- `e2e_test/teardown.sh`:先停止 state 指定的 exact session,再做既有 exact + PID/port cleanup;不猜 incomplete identity。 +- `e2e_test/tests_guard/run.sh`:接線、缺 tmux、fleet socket refusal、fake + tmux positive control,以及 `MUT-T-45`。 +- `e2e_test/README.md`:獨立 exec 契約與手動流程。 +- `e2e_test/CLAUDE.md`:實作者入口的 tmux/獨立 exec 隔離規則。 + +本輪 `evidence/t45-impl-20260901-setup`: + +- setup:11s,rc=0。 +- 下一個獨立 exec 的 health:0s,rc=0;tmux + `oc-e2e-748320878b234bef882786a3367f9ebe` 的 has-session rc=0,listener + PID `60320`,`/api/version` HTTP 200,`git_sha=c8d2506f`,command 是 + own `.state/ocserverd serve`。 +- 再下一個獨立 exec 的同一支 + `tests/02_monitoring_hardware_cards.spec.js`:3s,rc=0,原始 Playwright + stdout 為 `1 passed (1.3s)`,stderr 空。 +- 清理:exact private `kill-session` 成功;teardown 0s、rc=0,原始輸出為 + `[teardown] :8791 released` 與 `[teardown] ✅ clean`;最終 8791 無 listener, + 7755 仍為 PID `3821`。tmux state files 已移除。 +- 語法/whitespace:4 個 bash `-n` 與 `git diff --check` 全部 rc=0。 +- `e2e_test/tests_guard/run.sh`:最終 strict namespace 版本 35s,rc=0,`PASS=310 FAIL=0`、 + `[tests_guard] all green`。`MUT-T-45` 證明移除 namespace guard 後 fleet + socket command 會真的通過,因此 shipped guard 是 load-bearing。 +- strict namespace guard 加入後的最後一輪實際重跑:setup 21s rc=0;下一個 + exec health 0s rc=0(socket/session + `oc-e2e-24770e711bc745c0a071b84986ac34dd`、listener PID `9943`、API 200、 + SHA `c8d2506f`);再下一個 exec spec 2s rc=0、`1 passed (1.2s)`;exact + kill rc=0、teardown 0s rc=0,8791 released、7755 仍 PID `3821`。 + +沒有開 PR、沒有 merge;交由 Kyle 複審與後續 land。 + +## B — cmux/browser discovery(獨立、尚未定案) + +Joey 提供的三次歷史一手紀錄只能以相鄰訊息區間表示: + +- `2026-08-31 18:35:13.577–18:36:24.084 +08` +- `2026-08-31 19:31:15.660–19:33:28.492 +08` +- `2026-08-31 21:26:14.502–21:27:21.040 +08` + +三次 `agent.browsers.getForUrl(...)` 原文都是 `No browser is available`, +接著 `agent.browsers.list()` 原文都是 `[]`;沒有 backend/driver/port/PID +或可操作 tab。區間不是呼叫瞬間,不能冒充精確 timestamp。 + +Joey 在 2026-09-01 自己的 member session 做了現在的對照: + +- `CMUX_WORKSPACE_ID=`、`CMUX_SURFACE_ID=`、 + `CMUX_PANE_ID=`,printf rc=0。 +- 前後 `cmux workspace list`:stdout 空;stderr 原文 + `Error: Failed to write to socket (Broken pipe, errno 32)`;rc=1。 +- 唯一一次 `cmux browser open "https://officraft.hardcoretech.link/"`: + stdout 空;同一 stderr;rc=1;未點擊、未登入,沒有可觀測 workspace/surface/tab + 變化。 + +Kyle 的目前 session 另有不對稱對照:`cmux version` rc=0、 +`cmux browser status` rc=0 且輸出 `enabled`;確定不存在的子命令輸出 +`Error: Unknown command: definitely-not-a-command`;只有 workspace/socket +路徑回 broken pipe。這證明不是「所有 cmux 路徑都死」,但仍不能把 broken +pipe 分成「無 workspace」或「socket 本身壞掉」。Chrome extension 路徑不列入 +B,因為它和 cmux browser 是不同子系統。 + +在 recon 當時,射程問題「成員設計上是否應該用得到 cmux browser」仍無答案。 +以上只證明 Joey 現在的環境,不回推昨日三次;也不能把 B 寫成 cmux 程式 +bug 或設計上不用。四次被擋是事實,並未因 A 找到機制而結案。 + +## B — owner 裁定與可執行契約 + +2026-09-01 owner 回覆白話決策卡 `rc-6f9385ec29f6`,選項 `[0]`:**成員做 +e2e 不使用 cmux browser,一律走 A 的隔離 Playwright;cmux browser 不算 +支援路徑。** 因此 B 不被記成 cmux 程式已修好或已證明壞掉,而是把使用者 +從不支援的 browser-tool 路徑導回正式 e2e 路徑。 + +本包將這個決定落在 own worktree: + +- `e2e_test/run_all.sh` 預設宣布 `Playwright`;明確設定 + `OC_E2E_BROWSER_BACKEND=cmux` 時,在 setup 前以 rc=2 拒絕,原文為: + `[run_all] FATAL: OffiCraft members do not use cmux browser for e2e; run 'bash e2e_test/run_all.sh' or setup.sh -> Playwright -> teardown.sh instead. NOT a server failure.` +- `e2e_test/README.md` 與 `e2e_test/CLAUDE.md` 都明寫成員不走 cmux;看到 + `No browser is available`/`[]` 時停止重試 cmux,改跑 + `bash e2e_test/run_all.sh` 或 setup → Playwright → teardown。 +- `e2e_test/tests_guard/run.sh` 新增 B 契約檢查與 `MUT-T-45/B`;移除 cmux + refusal line 會被測試抓到。語法檢查與 `git diff --check` rc=0,tests_guard + 最終為 rc=0、`PASS=317 FAIL=0`、`[tests_guard] all green`。 + +這個 repo 沒有 cmux CLI 的攔截入口,因此守衛能保證的是本 harness 的明確 +backend 選擇與使用者指引;它不宣稱能攔截 repo 外部的 `cmux browser open`。 +Joey 歷史三次與現在 session 的原始證據仍保留為 B 的背景,且不回推成同一 +根因。`rc-2bb4e5f7d7c1` 的站台重啟歸因是另一個 owner 問題,不由本包代判。 diff --git a/e2e_test/CLAUDE.md b/e2e_test/CLAUDE.md index 3127aa302..92ca10dd4 100644 --- a/e2e_test/CLAUDE.md +++ b/e2e_test/CLAUDE.md @@ -2,11 +2,18 @@ 進入 `e2e_test/` 時讀本檔;repo-wide 規則在根目錄 `CLAUDE.md`。本檔只保留 e2e harness 會讓實作者猜錯的隔離、生命週期與驗證邊界。 +## 0. member browser contract(T-45/B) + +- OffiCraft 成員做 e2e **不使用 cmux browser**。正式路徑是本目錄的隔離 Playwright:`bash e2e_test/run_all.sh`,或 `setup.sh` → Playwright → `teardown.sh`。 +- `agent.browsers.getForUrl(...)` 的 `No browser is available` 與 `agent.browsers.list()` 的 `[]` 是 cmux/browser-tool 路徑的結果,不是這支 e2e server 的 setup 指示;不要重試 `cmux browser open`。改走上面的 Playwright 路徑,失敗時保留原始輸出。 +- `run_all.sh` 對明確的 `OC_E2E_BROWSER_BACKEND=cmux` 會印具名 FATAL 並拒絕執行;這是防止未來把不支援的 backend 接回來,不宣稱能攔截 repo 外的 cmux CLI。 + ## 1. target 與一次 run -- Go `ocserverd` 是唯一 target;入口是 `bash e2e_test/run_all.sh`。每輪使用隔離 port(預設由 e2e config 指定)、repo-local SQLite、臨時 owner password、fresh DB、exact-PID teardown;不能碰 repo 根 config 或 production server。產生隔離 `oc.toml` 時已有檔案要拒絕覆蓋,因為它可能正指向正式 DB。 +- Go `ocserverd` 是唯一 target;入口是 `bash e2e_test/run_all.sh`。每輪使用隔離 port(預設由 e2e config 指定)、repo-local SQLite、臨時 owner password、fresh DB、每輪唯一的非 fleet tmux socket/session,以及 exact-session/exact-PID teardown;不能碰 repo 根 config 或 production server。產生隔離 `oc.toml` 時已有檔案要拒絕覆蓋,因為它可能正指向正式 DB。 +- `setup.sh`、Playwright 與 `teardown.sh` 可以分屬不同 agent exec。setup 以 `lib/tmux.sh` 將 server 放進 `oc-e2e-*` 私有 tmux namespace,並把 socket/session 寫進 `.state/`;不能改回只在 setup shell 內存活的普通背景 child,也不能使用 fleet 的 `-L officraft` socket。缺 tmux 或殘留 identity 時要在建立 server 前大聲拒絕。 - setup 必須在 server 前 stage 全部 embed assets:SPA→`webdist`、docs→`docsdist`、seeds→`seedsdist`、binaries/catalog→`bindist`,再 fresh build/migrate/serve。缺一項可能讓 server 起得來但 agent boot、MCP catalog 或 binary route 假綠/假紅。 -- 失敗時 teardown 仍跑,但只處理本輪捕獲的 listener/process;不能用模糊 process kill。prod safety 依 `lib/common.sh` 從現行 source 取得 production ports、identity、residue 與 explicit isolation/destructiveness ack,不能把某個歷史 port 當唯一防線。 +- 失敗時 teardown 仍跑,但只處理本輪 state 記錄的 tmux session、listener/process;不能用模糊 process kill。prod safety 依 `lib/common.sh` 從現行 source 取得 production ports、identity、residue 與 explicit isolation/destructiveness ack,不能把某個歷史 port 當唯一防線。 ## 2. CI、本機與 live-agent 分界 diff --git a/e2e_test/README.md b/e2e_test/README.md index 91bd85e1c..e398dba74 100644 --- a/e2e_test/README.md +++ b/e2e_test/README.md @@ -19,12 +19,13 @@ a one-command, objectively-verified check. ``` e2e_test/ - setup.sh # start isolated service: fresh DB → migrate → serve(:8791) → login - teardown.sh # tear down: stop serve (only our pid) → drop isolated DB → verify + setup.sh # start isolated service: fresh DB → migrate → tmux serve(:8791) → login + teardown.sh # tear down: stop exact tmux session/pids → drop isolated DB → verify run_all.sh # one-shot: setup → playwright specs → teardown (teardown always runs) playwright.config.js package.json # @playwright/test lib/common.sh # shared config + prod-safety guards + lib/tmux.sh # per-run tmux carrier + namespace/availability guards tests/ 01_login.spec.js # A · skeleton: version up, login, token authorizes DB-backed endpoint 02_monitoring_hardware_cards.spec.js # B1 · monitor page renders CPU/RAM/POWER columns (browser) @@ -52,6 +53,23 @@ either — set `OC_E2E_SKIP_BUILD=1` to skip the SPA build when running only tho ## Run +### Member browser contract (T-45/B) + +OffiCraft members **do not use cmux browser for e2e**. The supported member +route is this repository's isolated **Playwright** harness: + +```text +bash e2e_test/run_all.sh +``` + +If `agent.browsers.getForUrl(...)` says `No browser is available`, or +`agent.browsers.list()` returns `[]`, stop retrying cmux. That is not a request +to repair this e2e server; `cmux browser open` is outside the member e2e +contract. Use `run_all.sh` (or `setup.sh` → Playwright → `teardown.sh`) and +report the exact output if that supported route fails. `run_all.sh` also +refuses an explicit `OC_E2E_BROWSER_BACKEND=cmux` selection with the same +guidance. + ```bash cd e2e_test bash run_all.sh # setup → every default-ON spec → teardown, in one shot @@ -78,11 +96,26 @@ embedded agent/MCP assets as a production build. Or drive the phases by hand: ```bash -bash setup.sh # bring the service up, persist token to .state/ -OC_E2E_BASE=http://127.0.0.1:8791 npx playwright test -bash teardown.sh # clean up +bash setup.sh # bring the service up in a private tmux session +source .state/env # loads OC_E2E_BASE for the next independent exec +export OC_E2E_PASSWORD="$(cat .state/owner.password)" +npx playwright test # the server remains alive across this exec boundary +bash teardown.sh # stop that exact session, then clean up ``` +### Independent-exec contract + +Agents may run `setup.sh`, the Playwright command, and `teardown.sh` as separate +tool/exec calls. `setup.sh` therefore requires `tmux` and starts `ocserverd` in +a fresh per-run `oc-e2e-*` socket and session, recording both names under +`.state/`. It never uses the fleet `officraft` socket. If `tmux` is missing or +the state files already exist, setup fails loudly before creating a server. + +`teardown.sh` reads the exact recorded socket/session, stops that session first, +then applies the existing exact-pid and port checks. An incomplete or +non-`oc-e2e-*` identity is refused rather than guessed; the shared fleet ports +remain outside this harness's cleanup scope. + ## Isolation & prod safety (hard rules) - Runs on a **non-prod port (:8791)** with an **isolated SQLite** DB. `common.sh` @@ -105,8 +138,10 @@ bash teardown.sh # clean up - Ambient fleet env (`OC_ID` / `OC_TOKEN` / `OC_BASE`) is **stripped** before starting the service or any tool, so nothing authenticates against or emits to the fleet/prod server. -- `teardown.sh` stops **only the pid it captured** at setup — never `pkill` / - `killall`. +- `setup.sh` and `teardown.sh` use only a fresh per-run `oc-e2e-*` tmux + namespace; the fleet `-L officraft` socket is never a cleanup target. +- `teardown.sh` stops the exact recorded tmux session and **only the pids it + captured** at setup — never `pkill` / `killall`. ### ⚠️ Responder-identity checks are NOT applied everywhere yet (T-a3ba, known gap) diff --git a/e2e_test/lib/tmux.sh b/e2e_test/lib/tmux.sh new file mode 100644 index 000000000..20eeb0018 --- /dev/null +++ b/e2e_test/lib/tmux.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# e2e_test/lib/tmux.sh — keep the isolated serve alive between agent execs. +# +# setup.sh and teardown.sh both source this file. The tmux socket/session are +# per-run names, never the shared `officraft` socket. Keeping the validation in +# one helper matters: a setup that starts on a private socket but a teardown that +# accepts an arbitrary state-file value is still a production teardown hazard. + +oc_e2e_tmux_validate_name() { + local role="$1" name="$2" + if [[ "$name" =~ ^oc-e2e-[0-9a-f]{32}$ ]]; then + return 0 + fi + echo "[e2e] FATAL: tmux $role '$name' is not an isolated oc-e2e namespace; refusing to touch a shared or production socket/session." >&2 + return 2 +} + +oc_e2e_tmux_require() { + if ! command -v tmux >/dev/null 2>&1; then + echo "[e2e] FATAL: tmux is required for the isolated e2e server; setup must keep ocserverd alive across independent agent execs." >&2 + return 2 + fi + return 0 +} + +# oc_e2e_tmux_start SOCKET SESSION REPO_ROOT SERVER LOG -> pane pid on stdout. +# The command is intentionally launched through a private tmux socket and with +# the same ambient-fleet scrub as the old nohup path. The pane pid is returned +# only as a diagnostic/launch pid; setup still records the actual socket holder +# after the identity-bound health check. +oc_e2e_tmux_start() { + local socket="$1" session="$2" repo_root="$3" server="$4" log="$5" pane_pid command + oc_e2e_tmux_validate_name socket "$socket" || return $? + oc_e2e_tmux_validate_name session "$session" || return $? + oc_e2e_tmux_require || return $? + + command="cd \"$repo_root\" && exec env -u OC_ID -u OC_TOKEN -u OC_BASE OC_RELEASE_API_BASE=http://127.0.0.1:1 \"$server\" serve >\"$log\" 2>&1" + if ! tmux -L "$socket" -f /dev/null new-session -d -s "$session" -c "$repo_root" "$command"; then + echo "[e2e] FATAL: tmux could not create isolated session '$session' on socket '$socket'." >&2 + return 1 + fi + + if ! pane_pid="$(tmux -L "$socket" -f /dev/null display-message -p -t "$session" '#{pane_pid}')"; then + echo "[e2e] FATAL: tmux created '$session' but could not read its pane pid." >&2 + return 1 + fi + case "$pane_pid" in + ''|*[!0-9]*) + echo "[e2e] FATAL: tmux session '$session' returned a non-numeric pane pid '$pane_pid'." >&2 + return 1 + ;; + esac + printf '%s\n' "$pane_pid" +} + +# oc_e2e_tmux_stop SOCKET SESSION -> 0 when the exact private session is gone. +# A missing session is a clean no-op; a failed kill is loud and leaves the +# caller's later exact-pid/port checks as a second line of defence. +oc_e2e_tmux_stop() { + local socket="$1" session="$2" + oc_e2e_tmux_validate_name socket "$socket" || return $? + oc_e2e_tmux_validate_name session "$session" || return $? + if ! oc_e2e_tmux_require; then + echo "[teardown] WARN: cannot inspect tmux session '$session' because tmux is unavailable." >&2 + return 1 + fi + if ! tmux -L "$socket" -f /dev/null has-session -t "$session" >/dev/null 2>&1; then + echo "[teardown] tmux session '$session' already gone (socket '$socket')" + return 0 + fi + if tmux -L "$socket" -f /dev/null kill-session -t "$session" >/dev/null 2>&1; then + echo "[teardown] stopped tmux session '$session' (socket '$socket')" + return 0 + fi + echo "[teardown] WARN: failed to stop exact tmux session '$session' on socket '$socket'" >&2 + return 1 +} diff --git a/e2e_test/run_all.sh b/e2e_test/run_all.sh index 872101115..1a1a7f4d4 100755 --- a/e2e_test/run_all.sh +++ b/e2e_test/run_all.sh @@ -27,6 +27,25 @@ # a change here is the `macos-e2e` job on the PR and its log. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# T-45/B: member e2e uses the Playwright path, not cmux browser discovery. The +# browser tool lives outside this repository, so this explicit selector is the +# boundary we can enforce here: a caller that tries to route this harness +# through cmux gets a named refusal instead of a misleading setup failure. +case "${OC_E2E_BROWSER_BACKEND:-playwright}" in + playwright) + echo "[run_all] member e2e browser backend=Playwright (cmux browser is unsupported; use this harness)" + ;; + cmux) + echo "[run_all] FATAL: OffiCraft members do not use cmux browser for e2e; run 'bash e2e_test/run_all.sh' or setup.sh -> Playwright -> teardown.sh instead. NOT a server failure." >&2 + exit 2 + ;; + *) + echo "[run_all] FATAL: unsupported member e2e browser backend '${OC_E2E_BROWSER_BACKEND}' (supported: Playwright; cmux browser is not a member e2e path)." >&2 + exit 2 + ;; +esac + # `||` does NOT swallow common.sh's own hard guards: an `exit 2` inside a sourced # file exits this script with 2 regardless (verified) — this only catches the # file being missing/unreadable, which would otherwise surface as a confusing diff --git a/e2e_test/setup.sh b/e2e_test/setup.sh index 63892e2f7..2d1dc53c5 100755 --- a/e2e_test/setup.sh +++ b/e2e_test/setup.sh @@ -5,6 +5,7 @@ # All prod-safety guards live in lib/common.sh. set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" +source "$(dirname "${BASH_SOURCE[0]}")/lib/tmux.sh" cd "$REPO_ROOT" mkdir -p "$STATE_DIR" @@ -38,6 +39,20 @@ if lsof -nP -iTCP:"$OC_E2E_PORT" -sTCP:LISTEN >/dev/null 2>&1; then echo "[setup] FATAL: :$OC_E2E_PORT already in use — run teardown.sh first." >&2 exit 2 fi +# The private tmux state is another leftover guard. Reusing a state file could +# make a later teardown target an earlier run's session, so refuse before the +# teardown is armed and before any new resource is created. +if [ -e "$STATE_DIR/tmux.socket" ] || [ -e "$STATE_DIR/tmux.session" ]; then + echo "[setup] FATAL: isolated tmux state already exists in $STATE_DIR — run teardown.sh first; refusing to overwrite the earlier session identity." >&2 + exit 2 +fi +# A plain nohup child is reaped by the agent executor when this script's exec +# ends. tmux is an explicit prerequisite for the independent-exec contract; +# fail here, before arming/mutating, rather than later as a misleading refused +# connection to :$OC_E2E_PORT. +if ! oc_e2e_tmux_require; then + exit 2 +fi # 1b. ARM THE TEARDOWN (T-ff8a). This line is the boundary of the script: # everything above it is a REFUSAL gate that has created nothing, everything @@ -49,6 +64,15 @@ fi # that dies half-way through HAS created things and must still be cleaned up. oc_e2e_arm_teardown +# Per-run tmux names are state, not a shared/default socket. uuidgen is already +# required below for the per-run owner password, and the prefix is validated by +# lib/tmux.sh before either start or stop can touch tmux. +TMUX_RUN_ID="$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]')" +TMUX_SOCKET="oc-e2e-$TMUX_RUN_ID" +TMUX_SESSION="oc-e2e-$TMUX_RUN_ID" +printf '%s\n' "$TMUX_SOCKET" > "$STATE_DIR/tmux.socket" +printf '%s\n' "$TMUX_SESSION" > "$STATE_DIR/tmux.session" + # 2. fresh DB (migrate runs in 2c, after the build steps). rm -rf "$REPO_ROOT/var/data" @@ -138,10 +162,12 @@ if lsof -nP -iTCP:"$OC_E2E_PORT" -sTCP:LISTEN >/dev/null 2>&1; then exit 2 fi -# 3. start serve in the background (ambient fleet env stripped). -echo "[setup] starting isolated serve…" -oc_env nohup "$STATE_DIR/ocserverd" serve > "$STATE_DIR/serve.log" 2>&1 & -SERVE_LAUNCH_PID="$!" +# 3. start serve in a detached, per-run tmux session (ambient fleet env stripped). +echo "[setup] starting isolated serve in tmux (socket=$TMUX_SOCKET session=$TMUX_SESSION)…" +if ! SERVE_LAUNCH_PID="$(oc_e2e_tmux_start "$TMUX_SOCKET" "$TMUX_SESSION" "$REPO_ROOT" "$STATE_DIR/ocserverd" "$STATE_DIR/serve.log")"; then + echo "[setup] FATAL: could not start isolated serve in tmux (socket=$TMUX_SOCKET session=$TMUX_SESSION)." >&2 + exit 1 +fi echo "$SERVE_LAUNCH_PID" > "$STATE_DIR/serve.launch.pid" # Expected build identity: gitSHA() (server/ocserverd/server.go) is unstamped diff --git a/e2e_test/teardown.sh b/e2e_test/teardown.sh index 810155563..4e1adf47f 100755 --- a/e2e_test/teardown.sh +++ b/e2e_test/teardown.sh @@ -9,11 +9,32 @@ # default and a foreign product while the real prod port went unnamed (T-191d). set -uo pipefail source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" +source "$(dirname "${BASH_SOURCE[0]}")/lib/tmux.sh" echo "[teardown] base=$OC_E2E_BASE" -# 1. stop serve — kill BOTH the recorded listener pid AND the launch pid (the -# socket holder can differ from the nohup launch pid, so killing only the +# 1. stop the exact per-run tmux session first. A tmux pane can outlive the +# setup shell, so killing only the recorded listener pid could leave a live +# tmux server/session behind even after the port is released. +TMUX_SOCKET="" +TMUX_SESSION="" +if [ -f "$STATE_DIR/tmux.socket" ]; then + TMUX_SOCKET="$(cat "$STATE_DIR/tmux.socket" 2>/dev/null || true)" +fi +if [ -f "$STATE_DIR/tmux.session" ]; then + TMUX_SESSION="$(cat "$STATE_DIR/tmux.session" 2>/dev/null || true)" +fi +if [ -n "$TMUX_SOCKET" ] || [ -n "$TMUX_SESSION" ]; then + if [ -n "$TMUX_SOCKET" ] && [ -n "$TMUX_SESSION" ]; then + oc_e2e_tmux_stop "$TMUX_SOCKET" "$TMUX_SESSION" || true + else + echo "[teardown] WARN: incomplete tmux state (socket='${TMUX_SOCKET:-}' session='${TMUX_SESSION:-}') — refusing to guess a session." >&2 + fi + oc_e2e_destroy "$STATE_DIR/tmux.socket" "$STATE_DIR/tmux.session" +fi + +# 2. stop serve — kill BOTH the recorded listener pid AND the launch pid (the +# socket holder can differ from the tmux pane/launch pid, so killing only the # launch pid could leave a stray listener). Only pids WE recorded. for f in serve.pid serve.launch.pid; do if [ -f "$STATE_DIR/$f" ]; then @@ -25,7 +46,7 @@ for f in serve.pid serve.launch.pid; do fi done -# 2. poll until the port is actually released (up to ~8s). +# 3. poll until the port is actually released (up to ~8s). released="" for _ in $(seq 1 16); do if ! lsof -nP -iTCP:"$OC_E2E_PORT" -sTCP:LISTEN >/dev/null 2>&1; then released=1; break; fi @@ -53,11 +74,11 @@ else echo "[teardown] WARN: :$OC_E2E_PORT still listening — inspect manually (not force-killing a process we did not launch)." >&2 fi -# 3. NOTE: when ocwarden-install specs (C2) are added, bootout the isolated warden +# 4. NOTE: when ocwarden-install specs (C2) are added, bootout the isolated warden # here by its EXACT launchctl label + rm its tokfile/plist. The minimal # skeleton installs no warden, so there is nothing to bootout yet. -# 4. drop isolated DB + run state (self-created only). +# 5. drop isolated DB + run state (self-created only). # T-ff8a: every delete here goes through oc_e2e_destroy (lib/common.sh) — it # writes each target to $OC_E2E_DESTROY_RECORD before dispatching to a # REPLACEABLE impl. Two reasons, both structural: (a) "what did this run delete" @@ -70,7 +91,7 @@ oc_e2e_destroy "$REPO_ROOT/var/data" oc_e2e_destroy "$STATE_DIR/owner.tok" "$STATE_DIR/env" "$STATE_DIR/serve.log" "$STATE_DIR/ocserverd" echo "[teardown] dropped isolated DB + state" -# 4b. restore server/ocserverd/webdist/ to pristine (.gitkeep only). The go leg +# 5b. restore server/ocserverd/webdist/ to pristine (.gitkeep only). The go leg # stages the SPA there for go:embed; the COMMITTED prebuilt bin/ocserverd must # always be built from a pristine webdist (server/CLAUDE.md) — leaving the # staged dist behind would bait a later rebuild into embedding it. diff --git a/e2e_test/tests_guard/run.sh b/e2e_test/tests_guard/run.sh index 889b2f303..b9f77254f 100755 --- a/e2e_test/tests_guard/run.sh +++ b/e2e_test/tests_guard/run.sh @@ -1398,6 +1398,7 @@ mkdir -p "$FF8A_E2E/lib" "$FF8A_ROOT/server/ocserverd" "$FF8A_ROOT/var/data" # mutant trees in 19d). A COPY, not a symlink: the mutants below rewrite it. cp "$HERE/../../server/ocserverd/config.go" "$FF8A_ROOT/server/ocserverd/config.go" cp "$HERE/../lib/common.sh" "$FF8A_E2E/lib/common.sh" +cp "$HERE/../lib/tmux.sh" "$FF8A_E2E/lib/tmux.sh" cp "$HERE/../setup.sh" "$HERE/../teardown.sh" "$HERE/../run_all.sh" "$FF8A_E2E/" # An oc.toml on the WRONG port — the first of setup's three prod guards, chosen # because it fires earliest and needs no ports, no npm and no go toolchain. @@ -3099,6 +3100,156 @@ printf "%s|%s|%s\n" "$a" "$h" "$n"' _ "$1" || bad "seven_gate: live.sh launches the warden without sg_scrub_env — the assertion above would be proving something about an environment the warden never gets" fi +# ── 27) T-45: the independent-exec carrier is tmux, and its guard is live ──── +# +# setup.sh and teardown.sh are invoked by agents as separate execs. A plain +# background child dies at that boundary, so this case pins the replacement's +# three safety properties in a hermetic fixture: tmux is an explicit +# prerequisite, every socket/session name is per-run and non-fleet, and the +# setup/teardown scripts actually source and call the helper. The fake tmux +# never creates a real server; it only records argv and returns controlled +# answers. +T45_TMUX_FIXTURE="$SHIMDIR/t45-tmux" +mkdir -p "$T45_TMUX_FIXTURE/bin" "$T45_TMUX_FIXTURE/empty" +T45_TMUX_LOG="$T45_TMUX_FIXTURE/tmux.log" +cat > "$T45_TMUX_FIXTURE/bin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${T45_TMUX_LOG:?}" +case "$*" in + *display-message*) printf '4242\n'; exit 0 ;; + *has-session*) exit 0 ;; + *kill-session*) exit 0 ;; + *new-session*) exit 0 ;; + *) exit 1 ;; +esac +SH +chmod +x "$T45_TMUX_FIXTURE/bin/tmux" +T45_TMUX_LIB="$HERE/../lib/tmux.sh" +if [[ ! -f "$T45_TMUX_LIB" ]]; then + bad "T-45: lib/tmux.sh is missing — the independent-exec carrier has no implementation" +else + source "$T45_TMUX_LIB" + _t45_code() { grep -v '^[[:space:]]*#' "$1"; } + _t45_code "$HERE/../setup.sh" | grep -qF 'lib/tmux.sh' \ + && ok "T-45: setup.sh sources the tmux helper in code" \ + || bad "T-45: setup.sh no longer sources lib/tmux.sh — its independent-exec fix is disconnected" + _t45_code "$HERE/../setup.sh" | grep -qF 'oc_e2e_tmux_start' \ + && ok "T-45: setup.sh starts the server through the tmux helper" \ + || bad "T-45: setup.sh no longer calls oc_e2e_tmux_start — it can regress to a child that dies at exec" + _t45_code "$HERE/../teardown.sh" | grep -qF 'oc_e2e_tmux_stop' \ + && ok "T-45: teardown.sh stops the exact tmux session through the helper" \ + || bad "T-45: teardown.sh no longer calls oc_e2e_tmux_stop — a live carrier could leak after port cleanup" + + T45_OLD_PATH="$PATH" + PATH="$T45_TMUX_FIXTURE/bin:$PATH" + export T45_TMUX_LOG + T45_SOCKET='oc-e2e-0123456789abcdef0123456789abcdef' + T45_SESSION='oc-e2e-fedcba9876543210fedcba9876543210' + + : > "$T45_TMUX_LOG" + T45_START_PID="$(oc_e2e_tmux_start "$T45_SOCKET" "$T45_SESSION" '/tmp/repo' '/tmp/server' '/tmp/log' 2>"$T45_TMUX_FIXTURE/start.stderr")" + T45_START_RC=$? + check "T-45: a private tmux carrier starts successfully" "0" "$T45_START_RC" + check "T-45: the carrier returns the numeric pane pid for diagnostics" "4242" "$T45_START_PID" + grep -qF -- "-L $T45_SOCKET" "$T45_TMUX_LOG" \ + && ok "T-45: start uses the requested private socket" \ + || bad "T-45: start did not pass the private socket to tmux (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" + grep -qF 'new-session' "$T45_TMUX_LOG" \ + && ok "T-45: start creates a detached tmux session" \ + || bad "T-45: start never issued new-session (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" + + oc_e2e_tmux_stop "$T45_SOCKET" "$T45_SESSION" \ + >"$T45_TMUX_FIXTURE/stop.stdout" 2>"$T45_TMUX_FIXTURE/stop.stderr" + T45_STOP_RC=$? + check "T-45: the exact private tmux session stops cleanly" "0" "$T45_STOP_RC" + grep -qF 'kill-session' "$T45_TMUX_LOG" \ + && ok "T-45: teardown issues kill-session on the exact private session" \ + || bad "T-45: teardown never issued the exact kill-session (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" + + T45_BEFORE_CALLS="$(wc -l < "$T45_TMUX_LOG" | tr -d ' ')" + oc_e2e_tmux_stop 'officraft' "$T45_SESSION" \ + >"$T45_TMUX_FIXTURE/shared.stdout" 2>"$T45_TMUX_FIXTURE/shared.stderr" + T45_SHARED_RC=$? + T45_AFTER_CALLS="$(wc -l < "$T45_TMUX_LOG" | tr -d ' ')" + check "T-45: a cleanup aimed at the fleet socket is refused" "2" "$T45_SHARED_RC" + check "T-45: the fleet-socket refusal issues no tmux command" "$T45_BEFORE_CALLS" "$T45_AFTER_CALLS" + grep -qF 'shared or production' "$T45_TMUX_FIXTURE/shared.stderr" \ + && ok "T-45: the fleet-socket refusal names the safety boundary" \ + || bad "T-45: the fleet-socket refusal is silent or unnamed (stderr: $(cat "$T45_TMUX_FIXTURE/shared.stderr"))" + + T45_NO_TMUX_MSG="$(PATH="$T45_TMUX_FIXTURE/empty" /bin/bash -c '. "$1"; oc_e2e_tmux_require' _ "$T45_TMUX_LIB" 2>&1)" + T45_NO_TMUX_RC=$? + check "T-45: missing tmux fails before setup can create an isolated server" "2" "$T45_NO_TMUX_RC" + case "$T45_NO_TMUX_MSG" in + *'tmux is required'*) ok "T-45: missing tmux explains the independent-exec prerequisite" ;; + *) bad "T-45: missing tmux did not explain the prerequisite (stderr: $T45_NO_TMUX_MSG)" ;; + esac + + T45_MUT="$T45_TMUX_FIXTURE/tmux-mut.sh" + sed 's/^oc_e2e_tmux_validate_name() {$/oc_e2e_tmux_validate_name() { return 0;/' \ + "$T45_TMUX_LIB" > "$T45_MUT" + if cmp -s "$T45_MUT" "$T45_TMUX_LIB"; then + bad "T-45: namespace-guard mutant did not apply — the red-mutant proof is blind" + else + T45_MUT_LOG="$T45_TMUX_FIXTURE/mut.log" + : > "$T45_MUT_LOG" + T45_TMUX_LOG="$T45_MUT_LOG" + T45_MUT_OUTPUT="$(PATH="$T45_TMUX_FIXTURE/bin:$PATH" /bin/bash -c '. "$1"; oc_e2e_tmux_stop officraft "$2"' _ "$T45_MUT" "$T45_SESSION" 2>"$T45_TMUX_FIXTURE/mut.stderr")" + T45_MUT_RC=$? + T45_MUT_CALLS="$(wc -l < "$T45_MUT_LOG" | tr -d ' ')" + if [[ "$T45_MUT_RC" == "0" && "$T45_MUT_CALLS" -gt 0 ]]; then + ok "MUT-T-45: removing the namespace guard lets the fleet-socket command through (the shipped guard is load-bearing)" + else + bad "MUT-T-45: removing the namespace guard did not make the unsafe command run (rc=$T45_MUT_RC calls=$T45_MUT_CALLS output=$T45_MUT_OUTPUT) — the test does not prove the guard matters" + fi + fi + T45_TMUX_LOG="$T45_TMUX_FIXTURE/tmux.log" + PATH="$T45_OLD_PATH" + unset T45_TMUX_LOG +fi + +# ── 28) T-45/B: member e2e must name the supported browser route ───────────── +# +# cmux/browser-tool is outside this repository, so source code cannot intercept +# `cmux browser open`. The enforceable boundary is the supported harness +# selector plus the user-facing refusal: an explicit cmux route must fail before +# setup, and the docs must tell a member where to go instead of making it retry +# an unavailable backend. The mutation below makes the message disappear; the +# assertion is deliberately about the shipped line, not a comment that merely +# mentions cmux. +T45_RUN_ALL="$HERE/../run_all.sh" +T45_MEMBER_ERROR="[run_all] FATAL: OffiCraft members do not use cmux browser for e2e;" +T45_MEMBER_ROUTE="[run_all] member e2e browser backend=Playwright" +T45_MEMBER_README="$HERE/../README.md" +T45_MEMBER_CLAUDE="$HERE/../CLAUDE.md" +grep -qF 'OC_E2E_BROWSER_BACKEND' "$T45_RUN_ALL" \ + && ok "T-45/B: run_all has an explicit browser-backend selector" \ + || bad "T-45/B: run_all has no explicit browser-backend selector — cmux could return a misleading downstream failure" +grep -qF "$T45_MEMBER_ERROR" "$T45_RUN_ALL" \ + && ok "T-45/B: explicit cmux selection fails with a named member-route error" \ + || bad "T-45/B: explicit cmux selection has no named member-route error" +grep -qF "$T45_MEMBER_ROUTE" "$T45_RUN_ALL" \ + && ok "T-45/B: the supported Playwright route is announced" \ + || bad "T-45/B: run_all no longer announces the supported Playwright route" +grep -qF 'OffiCraft members **do not use cmux browser for e2e**' "$T45_MEMBER_README" \ + && ok "T-45/B: README states the member cmux boundary in user-facing language" \ + || bad "T-45/B: README no longer states that members do not use cmux browser" +grep -qF 'If `agent.browsers.getForUrl(...)` says' "$T45_MEMBER_README" \ + && grep -qF '`No browser is available`' "$T45_MEMBER_README" \ + && ok "T-45/B: README maps the browser-tool failure to the supported route" \ + || bad 'T-45/B: README no longer explains what to do after `No browser is available`' +grep -qF 'OffiCraft 成員做 e2e **不使用 cmux browser**' "$T45_MEMBER_CLAUDE" \ + && ok "T-45/B: CLAUDE.md carries the same member contract for agents" \ + || bad "T-45/B: CLAUDE.md no longer carries the member cmux contract" + +T45_MEMBER_MUT="$T45_TMUX_FIXTURE/run-all-mut.sh" +sed '/OffiCraft members do not use cmux browser for e2e;/d' "$T45_RUN_ALL" > "$T45_MEMBER_MUT" +if grep -qF "$T45_MEMBER_ERROR" "$T45_MEMBER_MUT"; then + bad "MUT-T-45/B: removing the cmux refusal line left the guard apparently green" +else + ok "MUT-T-45/B: removing the cmux refusal line removes the named guard (message is load-bearing)" +fi + echo "[tests_guard] PASS=$PASS FAIL=$FAIL" [[ "$FAIL" -eq 0 ]] || exit 1 @@ -3123,11 +3274,12 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # (2026-08-11, hole 191). # # SO IT IS NOW SET NEAR THE COUNT, WITH DELIBERATE SLACK, AND IT IS EXPECTED TO -# BE EDITED. 303 today, floor 300: three assertions of room. (291/288 → 298/295 +# BE EDITED. 310 today, floor 307: three assertions of room. (291/288 → 298/295 # when 2026-08-11's bash-3.2 round added 23e's three cells and case 26's four → # 303/300 when ⑤'s downgrade traded two cells away — `sg_mutant step_done` and -# the ⑤-red/⑦-green pair — for seven in 21b-i/21b-v. Each move edited the floor -# in the same commit, which is the edit this block asks for.) The slack is measured, not guessed — deleting the whole of case 26 (then +# the ⑤-red/⑦-green pair — for seven in 21b-i/21b-v → 310/307 when T-45 added +# its 15 carrier/namespace assertions. Each move edited the floor in the same +# commit, which is the edit this block asks for.) The slack is measured, not guessed — deleting the whole of case 26 (then # 8 assertions) gave PASS=283, which was FATAL and named at 288 and GREEN at # 280. Read the # guarantee narrowly: a change that removes FOUR OR MORE assertions is loud; one @@ -3164,7 +3316,7 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # printed the marker with no floor evaluated at all: MEASURED, floor block # deleted and the trailing echo kept → PASS=153 FAIL=0 rc=0, last line # `[tests_guard] all green`, `bin/ci.sh` all green. Keep it in the branch. -PASS_FLOOR=292 +PASS_FLOOR=307 if [[ "$PASS" -lt "$PASS_FLOOR" ]]; then echo "[tests_guard] FATAL: only $PASS assertion(s) ran, floor is $PASS_FLOOR." >&2 echo "[tests_guard] FAIL=0 with a collapsed PASS count means cases went missing, not that they passed." >&2 From 453a640133022e9cc47825e0020752849793958a Mon Sep 17 00:00:00 2001 From: Seth Wang Date: Tue, 1 Sep 2026 11:05:04 +0800 Subject: [PATCH 2/3] test: harden e2e lifecycle guard evidence --- T-45-e2e-env-report.md | 90 +++++++++++++++---- e2e_test/CLAUDE.md | 2 +- e2e_test/README.md | 8 +- e2e_test/lib/common.sh | 22 ++++- e2e_test/lib/tmux.sh | 20 +++-- e2e_test/setup.sh | 9 +- e2e_test/teardown.sh | 9 +- e2e_test/tests_guard/run.sh | 70 ++++++++++----- .../t45-followup-20260901-setup-blocked.md | 23 +++++ .../t45-paired-20260901-setup/controls.md | 62 +++++++++++++ .../t45-paired-20260901-setup/launch-time | 1 + .../nohup/api-version.rc | 1 + .../nohup/api-version.stderr | 1 + .../nohup/api-version.stdout | 0 evidence/t45-paired-20260901-setup/nohup/head | 1 + .../nohup/health.elapsed_seconds | 1 + .../nohup/health.end | 1 + .../nohup/health.start | 1 + .../nohup/listener.rc | 1 + .../nohup/listener.stderr | 0 .../nohup/listener.stdout | 0 .../nohup/serve-log.tail.rc | 1 + .../nohup/serve-log.tail.stderr | 0 .../nohup/serve-log.tail.stdout | 9 ++ .../t45-paired-20260901-setup/nohup/setup.end | 1 + .../t45-paired-20260901-setup/nohup/setup.rc | 1 + .../nohup/setup.start | 1 + .../nohup/setup.stderr | 70 +++++++++++++++ .../nohup/setup.stdout | 34 +++++++ .../nohup/state-pid.ps.rc | 1 + .../nohup/state-pid.ps.stderr | 0 .../nohup/state-pid.ps.stdout | 0 .../nohup/state-serve.pid | 1 + .../tmux/api-version.rc | 1 + .../tmux/api-version.stderr | 0 .../tmux/api-version.stdout | 1 + evidence/t45-paired-20260901-setup/tmux/head | 1 + .../tmux/health.elapsed_seconds | 1 + .../t45-paired-20260901-setup/tmux/health.end | 1 + .../tmux/health.start | 1 + .../tmux/listener.rc | 1 + .../tmux/listener.stderr | 0 .../tmux/listener.stdout | 2 + .../tmux/serve-log.tail.rc | 1 + .../tmux/serve-log.tail.stderr | 0 .../tmux/serve-log.tail.stdout | 12 +++ .../t45-paired-20260901-setup/tmux/setup.end | 1 + .../t45-paired-20260901-setup/tmux/setup.rc | 1 + .../tmux/setup.start | 1 + .../tmux/setup.stderr | 70 +++++++++++++++ .../tmux/setup.stdout | 34 +++++++ .../tmux/state-pid.ps.rc | 1 + .../tmux/state-pid.ps.stderr | 0 .../tmux/state-pid.ps.stdout | 1 + .../tmux/state-serve.pid | 1 + .../t45-paired-20260901-setup/tmux/tmux-state | 2 + .../tmux/tmux.has-session.rc | 1 + .../tmux/tmux.has-session.stderr | 0 .../tmux/tmux.has-session.stdout | 0 .../tmux/tmux.panes.rc | 1 + .../tmux/tmux.panes.stderr | 0 .../tmux/tmux.panes.stdout | 1 + 62 files changed, 521 insertions(+), 58 deletions(-) create mode 100644 evidence/t45-followup-20260901-setup-blocked.md create mode 100644 evidence/t45-paired-20260901-setup/controls.md create mode 100644 evidence/t45-paired-20260901-setup/launch-time create mode 100644 evidence/t45-paired-20260901-setup/nohup/api-version.rc create mode 100644 evidence/t45-paired-20260901-setup/nohup/api-version.stderr create mode 100644 evidence/t45-paired-20260901-setup/nohup/api-version.stdout create mode 100644 evidence/t45-paired-20260901-setup/nohup/head create mode 100644 evidence/t45-paired-20260901-setup/nohup/health.elapsed_seconds create mode 100644 evidence/t45-paired-20260901-setup/nohup/health.end create mode 100644 evidence/t45-paired-20260901-setup/nohup/health.start create mode 100644 evidence/t45-paired-20260901-setup/nohup/listener.rc create mode 100644 evidence/t45-paired-20260901-setup/nohup/listener.stderr create mode 100644 evidence/t45-paired-20260901-setup/nohup/listener.stdout create mode 100644 evidence/t45-paired-20260901-setup/nohup/serve-log.tail.rc create mode 100644 evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stderr create mode 100644 evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stdout create mode 100644 evidence/t45-paired-20260901-setup/nohup/setup.end create mode 100644 evidence/t45-paired-20260901-setup/nohup/setup.rc create mode 100644 evidence/t45-paired-20260901-setup/nohup/setup.start create mode 100644 evidence/t45-paired-20260901-setup/nohup/setup.stderr create mode 100644 evidence/t45-paired-20260901-setup/nohup/setup.stdout create mode 100644 evidence/t45-paired-20260901-setup/nohup/state-pid.ps.rc create mode 100644 evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stderr create mode 100644 evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stdout create mode 100644 evidence/t45-paired-20260901-setup/nohup/state-serve.pid create mode 100644 evidence/t45-paired-20260901-setup/tmux/api-version.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/api-version.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/api-version.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/head create mode 100644 evidence/t45-paired-20260901-setup/tmux/health.elapsed_seconds create mode 100644 evidence/t45-paired-20260901-setup/tmux/health.end create mode 100644 evidence/t45-paired-20260901-setup/tmux/health.start create mode 100644 evidence/t45-paired-20260901-setup/tmux/listener.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/listener.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/listener.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/serve-log.tail.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/setup.end create mode 100644 evidence/t45-paired-20260901-setup/tmux/setup.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/setup.start create mode 100644 evidence/t45-paired-20260901-setup/tmux/setup.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/setup.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/state-pid.ps.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/state-serve.pid create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux-state create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.has-session.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stdout create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.panes.rc create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.panes.stderr create mode 100644 evidence/t45-paired-20260901-setup/tmux/tmux.panes.stdout diff --git a/T-45-e2e-env-report.md b/T-45-e2e-env-report.md index 8703b6656..6f0621fa5 100644 --- a/T-45-e2e-env-report.md +++ b/T-45-e2e-env-report.md @@ -1,24 +1,61 @@ # T-45 — e2e environment evidence -測量基準固定為完整 commit `c8d2506f386bce15f731c50fb53d5c8e06e9c62b`, -worktree 為 `/Users/seth_wang/ai_workspace/officraft-t45-e2e-env-x107`。共用 -`/Users/seth_wang/ai_workspace/OffiCraft` 沒有 checkout;正式 port `7755` -本輪一直由既有 `ocserverd` PID `3821` 持有,未碰。所有以下站台操作都在 -own worktree 的 `8791` 隔離 DB/binary 上,沒有滿載測試。 +測量不寫 `main`,每一筆都固定記錄實際 checkout:修法前 nohup 基準是完整 +commit `c8d2506f386bce15f731c50fb53d5c8e06e9c62b`,tmux 實作基準是 +`d84a2302fa43c7ddb4aa9f839834b86cf8c3db85`;報告與後續 guard 修正在 +`/Users/seth_wang/ai_workspace/officraft-t45-e2e-env-x107`。共用 +`/Users/seth_wang/ai_workspace/OffiCraft` 沒有 checkout。正式 port `7755` +只做唯讀觀察,未由本票操作;所有站台操作都在 own worktree 的隔離 port/DB/ +binary 上,沒有滿載測試。 ## A — 修法前重現 -- `evidence/t45-recon-20260901-091510`:setup 在獨立 exec 分別耗時 16s、 - 11s,均 rc=0;exec 結束後下一個 health probe 失敗。原始 stderr: +- `evidence/t45-recon-20260901-091510`:c8d2506f 的 setup 在獨立 exec 分別 + 耗時 16s、11s,均 rc=0;exec 結束後下一個 health probe 失敗。原始 stderr: `curl: (7) Failed to connect to 127.0.0.1 port 8791 after 0 ms: Couldn't connect to server`。 - 同目錄的持續 shell positive control 在 6s 內取得 HTTP 200、listener PID - `28720`;差異是 exec 邊界,不是 server route。 + `28720`;它只證明同 shell 與獨立 exec 的行為不同,不單獨證明死因。 - 同 shell 的完整 positive control 在 `evidence/t45-recon-20260901-092650`:setup 9s、health 200 且 `git_sha=c8d2506f`、login 200、Chromium spec `1 passed / 1s`、teardown 0s。兩個中間 rc=1 已辨識為量測 wiring(SHA 長短比較、漏傳 `OC_E2E_PASSWORD`),不是產品根因。 +## A — 複審後的成對量測 + +這一輪修正了原先把「server 死了」誤寫成「exec 邊界就是根因」的問題。 +在同一個啟動 exec、同一個 user/executor、同一時間(`2026-09-01 +10:47:14 +08`)並行啟動兩個不同 port 的隔離 checkout: + +- nohup:c8d2506f,`8791`,setup start epoch `1788230834.541188000`、end + `1788230852.302288000`、rc=0;setup 自己印出 listener PID `25609`、launch + PID `25608`,serve log 有 `ocserverd serving on http://127.0.0.1:8791`。 +- tmux:d84a2302,`8793`,setup start epoch `1788230834.541518000`、end + `1788230851.800961000`、rc=0;setup 自己印出 listener/pane PID `25592`, + `/api/version` 回報 `git_sha=d84a2302`。 + +下一個獨立 exec(epoch `1788230884`)的 health:nohup `/api/version` 原始 +stderr 是 `curl: (7) Failed to connect to 127.0.0.1 port 8791 after 0 ms: Couldn't connect to server`、rc=7,listener PID `25609` 已不存在;tmux +HTTP 200、rc=0。再下一個獨立 exec(`2026-09-01T10:51:16+0800`)仍是 nohup +rc=7/無 listener,而 tmux HTTP 200/rc=0、listener `25592` 仍在;正確的 +`tmux -L oc-e2e-00c5d45e5ced4f62ac0fb7f81a87f6cc has-session` rc=0。 + +為了排除「兩條同時啟動互撞」,清理 pair 後只跑 c8d2506f 的 nohup:setup +`2026-09-01T10:51:57+0800`–`10:52:12+0800` rc=0,印出 listener PID +`40738`;下一個 exec `10:52:20+0800` 已是同一個原始 connection-refused、rc=7, +無 listener。這表示在本 Codex runtime/這個 setup 路徑,問題不是只有並行干擾。 + +但「到底是哪個 signal/系統元件殺掉」仍未被隔離:serve log 沒有 fatal, +setup 的 PID 在下一個 exec 消失;最小 nohup heartbeat 控制也只留下 1 筆 +heartbeat、沒有 caught-signal log,之後 PID 消失。因此目前能負責任回答的是: +**普通 background child 在這個 runtime 的獨立 exec 生命週期不可靠,tmux +carrier 在同條件下留下可觀測且可清理的 listener;精確 killer/signal 尚無一手 +證據。** 這不是「所有 executor 都會殺 nohup」的普遍根因結論,也不把 Kyle +的 nohup positive control 改寫成錯誤。 + +原始成對資料(包含 setup/health/PID/listener/tmux raw output)在: +`evidence/t45-paired-20260901-setup/`。 + ## A — 候選與採用 候選 ① 是每輪唯一私有 tmux socket/session;代價是 tmux 變成明確依賴, @@ -27,6 +64,11 @@ own worktree 的 `8791` 隔離 DB/binary 上,沒有滿載測試。 明文契約加跨 exec hard-fail guard;代價是 agent 拆步仍不可用,且 guard 必須 可執行並有 red mutant。本包採 ①,未採 ②/③。 +採用 ① 的論證現在是**管理性**而不是未證明的根因治療:每輪私有 tmux +socket/session 提供明確 run identity、可直接觀察的 carrier、以及 exact +`kill-session` cleanup;現有 fleet socket 不會被碰。成對量測只是說明它在本 +Codex runtime 的 lifecycle 結果較穩定,不宣稱 tmux 是唯一可行的背景機制。 + 採用前的實際 tmux positive control 在 `evidence/t45-recon-20260901-093249-tmux`:私有 socket `oc-t45-x107-server-20260901`、session `t45-server`,下一個獨立 exec 讀到 @@ -39,13 +81,13 @@ own worktree 的 `8791` 隔離 DB/binary 上,沒有滿載測試。 變更位於: - `e2e_test/lib/tmux.sh`:tmux prerequisite、`oc-e2e-*` namespace validation、 - exact session start/stop。 -- `e2e_test/setup.sh`:每輪建立並記錄唯一 tmux socket/session,server 活過 - setup exec 邊界。 + exact session start/stop;env scrub 從 `common.sh` 的單一來源產生。 +- `e2e_test/setup.sh`:每輪建立並記錄唯一 tmux socket/session;明確寫出 + tmux 是 lifecycle/管理選擇,不把 nohup 死亡寫成普遍根因。 - `e2e_test/teardown.sh`:先停止 state 指定的 exact session,再做既有 exact - PID/port cleanup;不猜 incomplete identity。 + PID/port cleanup;空或 incomplete state 也會清掉,不猜 session identity。 - `e2e_test/tests_guard/run.sh`:接線、缺 tmux、fleet socket refusal、fake - tmux positive control,以及 `MUT-T-45`。 + tmux positive control、呼叫/位置/empty-state guard,以及 `MUT-T-45`。 - `e2e_test/README.md`:獨立 exec 契約與手動流程。 - `e2e_test/CLAUDE.md`:實作者入口的 tmux/獨立 exec 隔離規則。 @@ -63,16 +105,25 @@ own worktree 的 `8791` 隔離 DB/binary 上,沒有滿載測試。 `[teardown] :8791 released` 與 `[teardown] ✅ clean`;最終 8791 無 listener, 7755 仍為 PID `3821`。tmux state files 已移除。 - 語法/whitespace:4 個 bash `-n` 與 `git diff --check` 全部 rc=0。 -- `e2e_test/tests_guard/run.sh`:最終 strict namespace 版本 35s,rc=0,`PASS=310 FAIL=0`、 - `[tests_guard] all green`。`MUT-T-45` 證明移除 namespace guard 後 fleet - socket command 會真的通過,因此 shipped guard 是 load-bearing。 +- `e2e_test/tests_guard/run.sh`:複審修正後耗時 45.3s,rc=0,`PASS=317 FAIL=0`、 + `[tests_guard] all green`。PASS floor 現為 314;移除 B 的 7 顆 assertion + 會降到 310 並被 floor 擋住。setup 的 executable call、teardown 的 executable + stop/empty-state condition、以及 cmux gate-before-setup position 都不再靠 + grep 到一段註解即可通過。 - strict namespace guard 加入後的最後一輪實際重跑:setup 21s rc=0;下一個 exec health 0s rc=0(socket/session `oc-e2e-24770e711bc745c0a071b84986ac34dd`、listener PID `9943`、API 200、 SHA `c8d2506f`);再下一個 exec spec 2s rc=0、`1 passed (1.2s)`;exact kill rc=0、teardown 0s rc=0,8791 released、7755 仍 PID `3821`。 +- follow-up 變更後的 own worktree setup 於 `11:02:12`–`11:02:26 +08` rc=2, + 原始 stderr 為 `[setup] FATAL: :8791 became occupied during build/migrate/seed (TOCTOU window) — refuse to stomp it. Find and stop that listener, then re-run.`。 + 唯讀 `lsof`/`ps` 顯示 PID `55975` 實際屬於 T-46 own worktree + `/Users/seth_wang/.officraft/agents/ow-f5025c393ead/work/t46-rebase-4039`; + 本 worker 沒有停止或修改它。完整原文在 + `evidence/t45-followup-20260901-setup-blocked.md`,所以這次不能當成 + follow-up implementation 的 server failure 或正式站/SSE blocker。 -沒有開 PR、沒有 merge;交由 Kyle 複審與後續 land。 +沒有開 PR、沒有 merge;本輪仍交由 Kyle 複審與後續 land。 ## B — cmux/browser discovery(獨立、尚未定案) @@ -104,8 +155,9 @@ pipe 分成「無 workspace」或「socket 本身壞掉」。Chrome extension B,因為它和 cmux browser 是不同子系統。 在 recon 當時,射程問題「成員設計上是否應該用得到 cmux browser」仍無答案。 -以上只證明 Joey 現在的環境,不回推昨日三次;也不能把 B 寫成 cmux 程式 -bug 或設計上不用。四次被擋是事實,並未因 A 找到機制而結案。 +以上只證明 Joey/Kyle 各自**現在**的 session,不回推 Joey 昨日三次的環境; +也不能把 B 寫成 cmux 程式 bug 或設計上不用。四次被擋是事實,並未因 A +找到一種 lifecycle 機制而結案。 ## B — owner 裁定與可執行契約 diff --git a/e2e_test/CLAUDE.md b/e2e_test/CLAUDE.md index 92ca10dd4..429ce397b 100644 --- a/e2e_test/CLAUDE.md +++ b/e2e_test/CLAUDE.md @@ -11,7 +11,7 @@ ## 1. target 與一次 run - Go `ocserverd` 是唯一 target;入口是 `bash e2e_test/run_all.sh`。每輪使用隔離 port(預設由 e2e config 指定)、repo-local SQLite、臨時 owner password、fresh DB、每輪唯一的非 fleet tmux socket/session,以及 exact-session/exact-PID teardown;不能碰 repo 根 config 或 production server。產生隔離 `oc.toml` 時已有檔案要拒絕覆蓋,因為它可能正指向正式 DB。 -- `setup.sh`、Playwright 與 `teardown.sh` 可以分屬不同 agent exec。setup 以 `lib/tmux.sh` 將 server 放進 `oc-e2e-*` 私有 tmux namespace,並把 socket/session 寫進 `.state/`;不能改回只在 setup shell 內存活的普通背景 child,也不能使用 fleet 的 `-L officraft` socket。缺 tmux 或殘留 identity 時要在建立 server 前大聲拒絕。 +- `setup.sh`、Playwright 與 `teardown.sh` 可以分屬不同 agent exec。setup 以 `lib/tmux.sh` 將 server 放進 `oc-e2e-*` 私有 tmux namespace,並把 socket/session 寫進 `.state/`;不能改回未受明確 lifecycle/exact cleanup 管理的普通背景 child,也不能使用 fleet 的 `-L officraft` socket。tmux 的選擇依據是可觀測、可識別、可清理;目前 Codex runtime 的成對量測雖觀察到 nohup 路徑在下一個 exec 失去 listener,這不是對所有 executor 的普遍根因宣稱。缺 tmux 或殘留 identity 時要在建立 server 前大聲拒絕。 - setup 必須在 server 前 stage 全部 embed assets:SPA→`webdist`、docs→`docsdist`、seeds→`seedsdist`、binaries/catalog→`bindist`,再 fresh build/migrate/serve。缺一項可能讓 server 起得來但 agent boot、MCP catalog 或 binary route 假綠/假紅。 - 失敗時 teardown 仍跑,但只處理本輪 state 記錄的 tmux session、listener/process;不能用模糊 process kill。prod safety 依 `lib/common.sh` 從現行 source 取得 production ports、identity、residue 與 explicit isolation/destructiveness ack,不能把某個歷史 port 當唯一防線。 diff --git a/e2e_test/README.md b/e2e_test/README.md index e398dba74..aa8ac81e7 100644 --- a/e2e_test/README.md +++ b/e2e_test/README.md @@ -99,7 +99,7 @@ Or drive the phases by hand: bash setup.sh # bring the service up in a private tmux session source .state/env # loads OC_E2E_BASE for the next independent exec export OC_E2E_PASSWORD="$(cat .state/owner.password)" -npx playwright test # the server remains alive across this exec boundary +npx playwright test # tmux carries the server across this exec boundary bash teardown.sh # stop that exact session, then clean up ``` @@ -111,6 +111,12 @@ a fresh per-run `oc-e2e-*` socket and session, recording both names under `.state/`. It never uses the fleet `officraft` socket. If `tmux` is missing or the state files already exist, setup fails loudly before creating a server. +The tmux choice is a lifecycle and operations decision: it gives the run an +explicit identity, a directly inspectable session, and an exact cleanup target. +A paired measurement in the current Codex runtime also observed the old nohup +path losing its listener at the next independent exec while tmux survived, but +that observation is not a universal claim that every executor kills nohup. + `teardown.sh` reads the exact recorded socket/session, stops that session first, then applies the existing exact-pid and port checks. An incomplete or non-`oc-e2e-*` identity is refused rather than guessed; the shared fleet ports diff --git a/e2e_test/lib/common.sh b/e2e_test/lib/common.sh index 012171d0d..1590f8c3a 100755 --- a/e2e_test/lib/common.sh +++ b/e2e_test/lib/common.sh @@ -77,11 +77,29 @@ done # Strip ambient fleet env (OC_ID / OC_TOKEN / OC_BASE) so the isolated serve and # any tool we spawn never talk to the fleet/prod server. Critical: without this, -# ambient OC_* silently redirects auth/telemetry at the real server. +# ambient OC_* silently redirects auth/telemetry at the real server. Keep the +# scrub list here, as the single source of truth; tmux.sh asks this file for the +# shell command prefix instead of carrying a second, drift-prone copy. # OC_RELEASE_API_BASE (t-dc68): pin the GitHub Releases update check at an # unroutable loopback — the harness must never reach the real api.github.com # (hermeticity + the anonymous rate limit); checks fail fast and honestly. -oc_env() { env -u OC_ID -u OC_TOKEN -u OC_BASE OC_RELEASE_API_BASE="http://127.0.0.1:1" "$@"; } +OC_E2E_SCRUB_ENV_ARGS=(-u OC_ID -u OC_TOKEN -u OC_BASE) +OC_E2E_RELEASE_API_BASE="http://127.0.0.1:1" +oc_env() { + env "${OC_E2E_SCRUB_ENV_ARGS[@]}" \ + "OC_RELEASE_API_BASE=$OC_E2E_RELEASE_API_BASE" "$@" +} + +# Emit a shell-safe command prefix for a child started by an external carrier +# (currently tmux). The caller appends its own executable and arguments. +oc_e2e_scrub_env_command_prefix() { + local arg + printf 'env' + for arg in "${OC_E2E_SCRUB_ENV_ARGS[@]}"; do + printf ' %q' "$arg" + done + printf ' %q' "OC_RELEASE_API_BASE=$OC_E2E_RELEASE_API_BASE" +} # python3 as a text tool only (tomllib/json parsing) — not a server dependency. py() { python3 "$@"; } diff --git a/e2e_test/lib/tmux.sh b/e2e_test/lib/tmux.sh index 20eeb0018..899dafe4e 100644 --- a/e2e_test/lib/tmux.sh +++ b/e2e_test/lib/tmux.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# e2e_test/lib/tmux.sh — keep the isolated serve alive between agent execs. +# e2e_test/lib/tmux.sh — carry and manage the isolated serve between agent execs. # # setup.sh and teardown.sh both source this file. The tmux socket/session are # per-run names, never the shared `officraft` socket. Keeping the validation in @@ -24,17 +24,23 @@ oc_e2e_tmux_require() { } # oc_e2e_tmux_start SOCKET SESSION REPO_ROOT SERVER LOG -> pane pid on stdout. -# The command is intentionally launched through a private tmux socket and with -# the same ambient-fleet scrub as the old nohup path. The pane pid is returned -# only as a diagnostic/launch pid; setup still records the actual socket holder -# after the identity-bound health check. +# The command is intentionally launched through a private tmux socket. The +# ambient-fleet scrub is emitted by common.sh's single source of truth; keeping +# it out of this helper prevents the two launch paths from drifting. The pane +# pid is returned only as a diagnostic/launch pid; setup still records the +# actual socket holder after the identity-bound health check. oc_e2e_tmux_start() { - local socket="$1" session="$2" repo_root="$3" server="$4" log="$5" pane_pid command + local socket="$1" session="$2" repo_root="$3" server="$4" log="$5" pane_pid command env_prefix oc_e2e_tmux_validate_name socket "$socket" || return $? oc_e2e_tmux_validate_name session "$session" || return $? oc_e2e_tmux_require || return $? - command="cd \"$repo_root\" && exec env -u OC_ID -u OC_TOKEN -u OC_BASE OC_RELEASE_API_BASE=http://127.0.0.1:1 \"$server\" serve >\"$log\" 2>&1" + if ! declare -F oc_e2e_scrub_env_command_prefix >/dev/null 2>&1; then + echo "[e2e] FATAL: common.sh's environment scrub helper is unavailable; refusing to start the isolated serve without fleet isolation." >&2 + return 2 + fi + env_prefix="$(oc_e2e_scrub_env_command_prefix)" || return $? + command="cd \"$repo_root\" && exec $env_prefix \"$server\" serve >\"$log\" 2>&1" if ! tmux -L "$socket" -f /dev/null new-session -d -s "$session" -c "$repo_root" "$command"; then echo "[e2e] FATAL: tmux could not create isolated session '$session' on socket '$socket'." >&2 return 1 diff --git a/e2e_test/setup.sh b/e2e_test/setup.sh index 2d1dc53c5..fddf88aff 100755 --- a/e2e_test/setup.sh +++ b/e2e_test/setup.sh @@ -46,9 +46,12 @@ if [ -e "$STATE_DIR/tmux.socket" ] || [ -e "$STATE_DIR/tmux.session" ]; then echo "[setup] FATAL: isolated tmux state already exists in $STATE_DIR — run teardown.sh first; refusing to overwrite the earlier session identity." >&2 exit 2 fi -# A plain nohup child is reaped by the agent executor when this script's exec -# ends. tmux is an explicit prerequisite for the independent-exec contract; -# fail here, before arming/mutating, rather than later as a misleading refused +# A plain background child is not a reliable lifecycle carrier for this harness: +# a paired measurement in this Codex runtime observed the old nohup listener gone +# at the next independent exec while the tmux listener remained. That is an +# observed failure mode, not a universal claim about every executor. tmux is +# selected here for an explicit per-run identity, observation, and cleanup +# surface; fail before arming/mutating rather than later as a misleading refused # connection to :$OC_E2E_PORT. if ! oc_e2e_tmux_require; then exit 2 diff --git a/e2e_test/teardown.sh b/e2e_test/teardown.sh index 4e1adf47f..c5b5f7d8e 100755 --- a/e2e_test/teardown.sh +++ b/e2e_test/teardown.sh @@ -18,13 +18,16 @@ echo "[teardown] base=$OC_E2E_BASE" # tmux server/session behind even after the port is released. TMUX_SOCKET="" TMUX_SESSION="" -if [ -f "$STATE_DIR/tmux.socket" ]; then +if [ -e "$STATE_DIR/tmux.socket" ]; then TMUX_SOCKET="$(cat "$STATE_DIR/tmux.socket" 2>/dev/null || true)" fi -if [ -f "$STATE_DIR/tmux.session" ]; then +if [ -e "$STATE_DIR/tmux.session" ]; then TMUX_SESSION="$(cat "$STATE_DIR/tmux.session" 2>/dev/null || true)" fi -if [ -n "$TMUX_SOCKET" ] || [ -n "$TMUX_SESSION" ]; then +# Test for the state files, not their contents. An empty file is still stranded +# state from a partial setup and must be removed, or every later setup deadlocks +# on its [ -e ] leftover guard forever. +if [ -e "$STATE_DIR/tmux.socket" ] || [ -e "$STATE_DIR/tmux.session" ]; then if [ -n "$TMUX_SOCKET" ] && [ -n "$TMUX_SESSION" ]; then oc_e2e_tmux_stop "$TMUX_SOCKET" "$TMUX_SESSION" || true else diff --git a/e2e_test/tests_guard/run.sh b/e2e_test/tests_guard/run.sh index b9f77254f..2d33111c4 100755 --- a/e2e_test/tests_guard/run.sh +++ b/e2e_test/tests_guard/run.sh @@ -3102,11 +3102,13 @@ fi # ── 27) T-45: the independent-exec carrier is tmux, and its guard is live ──── # -# setup.sh and teardown.sh are invoked by agents as separate execs. A plain -# background child dies at that boundary, so this case pins the replacement's -# three safety properties in a hermetic fixture: tmux is an explicit -# prerequisite, every socket/session name is per-run and non-fleet, and the -# setup/teardown scripts actually source and call the helper. The fake tmux +# setup.sh and teardown.sh are invoked by agents as separate execs. A paired +# measurement in this Codex runtime saw the old nohup listener disappear at the +# next exec while the tmux listener remained; that is an observed failure mode, +# not a universal claim about every executor. This case therefore pins the +# selected carrier's explicit management properties in a hermetic fixture: +# tmux is an explicit prerequisite, every socket/session name is per-run and +# non-fleet, and setup/teardown really execute the helper calls. The fake tmux # never creates a real server; it only records argv and returns controlled # answers. T45_TMUX_FIXTURE="$SHIMDIR/t45-tmux" @@ -3128,17 +3130,31 @@ T45_TMUX_LIB="$HERE/../lib/tmux.sh" if [[ ! -f "$T45_TMUX_LIB" ]]; then bad "T-45: lib/tmux.sh is missing — the independent-exec carrier has no implementation" else + # tmux.sh deliberately consumes common.sh's single environment-scrub helper; + # source both here so this fixture exercises the same wiring as setup.sh. + source "$HERE/../lib/common.sh" source "$T45_TMUX_LIB" _t45_code() { grep -v '^[[:space:]]*#' "$1"; } - _t45_code "$HERE/../setup.sh" | grep -qF 'lib/tmux.sh' \ + T45_SETUP="$HERE/../setup.sh" + T45_TEARDOWN="$HERE/../teardown.sh" + T45_SETUP_CODE="$(_t45_code "$T45_SETUP")" + T45_TEARDOWN_CODE="$(_t45_code "$T45_TEARDOWN")" + printf '%s\n' "$T45_SETUP_CODE" | grep -qE '^[[:space:]]*source .*lib/tmux\.sh' \ && ok "T-45: setup.sh sources the tmux helper in code" \ || bad "T-45: setup.sh no longer sources lib/tmux.sh — its independent-exec fix is disconnected" - _t45_code "$HERE/../setup.sh" | grep -qF 'oc_e2e_tmux_start' \ + printf '%s\n' "$T45_SETUP_CODE" | grep -qE '^[[:space:]]*if ! SERVE_LAUNCH_PID=.*oc_e2e_tmux_start' \ && ok "T-45: setup.sh starts the server through the tmux helper" \ - || bad "T-45: setup.sh no longer calls oc_e2e_tmux_start — it can regress to a child that dies at exec" - _t45_code "$HERE/../teardown.sh" | grep -qF 'oc_e2e_tmux_stop' \ - && ok "T-45: teardown.sh stops the exact tmux session through the helper" \ - || bad "T-45: teardown.sh no longer calls oc_e2e_tmux_stop — a live carrier could leak after port cleanup" + || bad "T-45: setup.sh has no executable oc_e2e_tmux_start assignment — a trailing comment must not satisfy the carrier guard" + if printf '%s\n' "$T45_TEARDOWN_CODE" \ + | grep -qE '^[[:space:]]*oc_e2e_tmux_stop[[:space:]]' \ + && printf '%s\n' "$T45_TEARDOWN_CODE" \ + | grep -qF 'if [ -e "$STATE_DIR/tmux.socket" ] || [ -e "$STATE_DIR/tmux.session" ]; then' \ + && ! printf '%s\n' "$T45_TEARDOWN_CODE" \ + | grep -qE '^[[:space:]]*if[[:space:]]+false([;[:space:]]|$)'; then + ok "T-45: teardown.sh executes the exact tmux stop and clears even empty state files" + else + bad "T-45: teardown.sh must execute oc_e2e_tmux_stop and clear even empty tmux state files" + fi T45_OLD_PATH="$PATH" PATH="$T45_TMUX_FIXTURE/bin:$PATH" @@ -3151,9 +3167,12 @@ else T45_START_RC=$? check "T-45: a private tmux carrier starts successfully" "0" "$T45_START_RC" check "T-45: the carrier returns the numeric pane pid for diagnostics" "4242" "$T45_START_PID" - grep -qF -- "-L $T45_SOCKET" "$T45_TMUX_LOG" \ - && ok "T-45: start uses the requested private socket" \ - || bad "T-45: start did not pass the private socket to tmux (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" + if grep -qF -- "-L $T45_SOCKET" "$T45_TMUX_LOG" \ + && grep -qF 'env -u OC_ID -u OC_TOKEN -u OC_BASE OC_RELEASE_API_BASE=http://127.0.0.1:1' "$T45_TMUX_LOG"; then + ok "T-45: start uses the requested private socket and single-source env scrub" + else + bad "T-45: start did not pass the private socket and single-source env scrub to tmux (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" + fi grep -qF 'new-session' "$T45_TMUX_LOG" \ && ok "T-45: start creates a detached tmux session" \ || bad "T-45: start never issued new-session (log: $(tr '\n' '|' < "$T45_TMUX_LOG"))" @@ -3222,9 +3241,16 @@ T45_MEMBER_ERROR="[run_all] FATAL: OffiCraft members do not use cmux browser for T45_MEMBER_ROUTE="[run_all] member e2e browser backend=Playwright" T45_MEMBER_README="$HERE/../README.md" T45_MEMBER_CLAUDE="$HERE/../CLAUDE.md" -grep -qF 'OC_E2E_BROWSER_BACKEND' "$T45_RUN_ALL" \ - && ok "T-45/B: run_all has an explicit browser-backend selector" \ - || bad "T-45/B: run_all has no explicit browser-backend selector — cmux could return a misleading downstream failure" +T45_RUN_ALL_CODE="$(_t45_code "$T45_RUN_ALL")" +T45_CMUX_GATE_LINE="$(printf '%s\n' "$T45_RUN_ALL_CODE" | grep -nF 'case "${OC_E2E_BROWSER_BACKEND:-playwright}" in' | head -1 | cut -d: -f1)" +T45_SETUP_LINE="$(printf '%s\n' "$T45_RUN_ALL_CODE" | grep -nF 'if ! bash "$HERE/setup.sh"; then' | head -1 | cut -d: -f1)" +if printf '%s\n' "$T45_RUN_ALL_CODE" | grep -qF 'OC_E2E_BROWSER_BACKEND' \ + && [[ -n "$T45_CMUX_GATE_LINE" && -n "$T45_SETUP_LINE" \ + && "$T45_CMUX_GATE_LINE" -lt "$T45_SETUP_LINE" ]]; then + ok "T-45/B: run_all has an explicit browser-backend selector before setup" +else + bad "T-45/B: the browser-backend selector is missing or runs after setup — cmux must refuse before build/migrate/server" +fi grep -qF "$T45_MEMBER_ERROR" "$T45_RUN_ALL" \ && ok "T-45/B: explicit cmux selection fails with a named member-route error" \ || bad "T-45/B: explicit cmux selection has no named member-route error" @@ -3274,12 +3300,12 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # (2026-08-11, hole 191). # # SO IT IS NOW SET NEAR THE COUNT, WITH DELIBERATE SLACK, AND IT IS EXPECTED TO -# BE EDITED. 310 today, floor 307: three assertions of room. (291/288 → 298/295 +# BE EDITED. 317 today, floor 314: three assertions of room. (291/288 → 298/295 # when 2026-08-11's bash-3.2 round added 23e's three cells and case 26's four → # 303/300 when ⑤'s downgrade traded two cells away — `sg_mutant step_done` and -# the ⑤-red/⑦-green pair — for seven in 21b-i/21b-v → 310/307 when T-45 added -# its 15 carrier/namespace assertions. Each move edited the floor in the same -# commit, which is the edit this block asks for.) The slack is measured, not guessed — deleting the whole of case 26 (then +# the ⑤-red/⑦-green pair — for seven in 21b-i/21b-v → 317/314 when T-45 added +# its 22 carrier/namespace/browser assertions. Each move edited the floor in the +# same commit, which is the edit this block asks for.) The slack is measured, not guessed — deleting the whole of case 26 (then # 8 assertions) gave PASS=283, which was FATAL and named at 288 and GREEN at # 280. Read the # guarantee narrowly: a change that removes FOUR OR MORE assertions is loud; one @@ -3316,7 +3342,7 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # printed the marker with no floor evaluated at all: MEASURED, floor block # deleted and the trailing echo kept → PASS=153 FAIL=0 rc=0, last line # `[tests_guard] all green`, `bin/ci.sh` all green. Keep it in the branch. -PASS_FLOOR=307 +PASS_FLOOR=314 if [[ "$PASS" -lt "$PASS_FLOOR" ]]; then echo "[tests_guard] FATAL: only $PASS assertion(s) ran, floor is $PASS_FLOOR." >&2 echo "[tests_guard] FAIL=0 with a collapsed PASS count means cases went missing, not that they passed." >&2 diff --git a/evidence/t45-followup-20260901-setup-blocked.md b/evidence/t45-followup-20260901-setup-blocked.md new file mode 100644 index 000000000..07f84ff21 --- /dev/null +++ b/evidence/t45-followup-20260901-setup-blocked.md @@ -0,0 +1,23 @@ +# T-45 follow-up setup refusal + +The current worktree was `/Users/seth_wang/ai_workspace/officraft-t45-e2e-env-x107` +at `2026-09-01T11:02:12+0800`, with the fixed local checkout still based on +`d84a2302`. `bash e2e_test/setup.sh` completed its build, staging, build, +migrate, and password-seeding steps, then returned `rc=2` at +`2026-09-01T11:02:26+0800` with this original stderr: + +```text +[setup] FATAL: :8791 became occupied during build/migrate/seed (TOCTOU window) — refuse to stomp it. Find and stop that listener, then re-run. +``` + +The listener was checked read-only immediately afterward: + +```text +COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +ocserverd 55975 seth_wang 9u IPv4 0x10b0d09f8c89900f 0t0 TCP 127.0.0.1:8791 (LISTEN) +55975 55973 55525 S /Users/seth_wang/.officraft/agents/ow-f5025c393ead/work/t46-rebase-4039/e2e_test/.state/ocserverd serve +``` + +PID `55975` belongs to T-46's own worktree, not this T-45 worktree. It was not +stopped or modified. This is a local-port contention/refusal, not a claim about +the production station or the SSE stream. diff --git a/evidence/t45-paired-20260901-setup/controls.md b/evidence/t45-paired-20260901-setup/controls.md new file mode 100644 index 000000000..af6c3052c --- /dev/null +++ b/evidence/t45-paired-20260901-setup/controls.md @@ -0,0 +1,62 @@ +# T-45 paired follow-up controls + +All station setup and health commands below used the own worktrees only. The +shared `/Users/seth_wang/ai_workspace/OffiCraft` tree and production `:7755` +were not targets. + +## Pair launch + +The same launch exec started these two commands concurrently: + +```text +bash /Users/seth_wang/ai_workspace/officraft-t45-pair-nohup/e2e_test/setup.sh +OC_E2E_PORT=8793 bash /Users/seth_wang/ai_workspace/officraft-t45-pair-tmux/e2e_test/setup.sh +``` + +Both setup commands returned `rc=0`. The raw setup stdout/stderr, listener +state, PID probes, and tmux probes are the sibling files in this directory. + +## Nohup-alone control + +After both pair runs were torn down with their own `teardown.sh`, the old +c8d2506f nohup checkout was run alone. + +```text +setup_start +2026-09-01T10:51:57+0800 +[setup] serve healthy AND identity-verified — git_sha=c8d2506f listener pid=40738 (launch pid=40737) +[setup] ✅ ready — base=http://127.0.0.1:8791 token→/Users/seth_wang/ai_workspace/officraft-t45-pair-nohup/e2e_test/.state/owner.tok +setup_rc=0 +setup_end +2026-09-01T10:52:12+0800 +``` + +The next independent exec returned: + +```text +probe_start +2026-09-01T10:52:20+0800 +api +curl: (7) Failed to connect to 127.0.0.1 port 8791 after 0 ms: Couldn't connect to server + +http_code=000 +rc=7 +listener +rc=1 +recorded pid +40738 +ps +rc=1 +``` + +The old serve log still ended with `ocserverd serving on +http://127.0.0.1:8791`; it contained no fatal shutdown message. + +## Minimal background control + +A separate minimal `nohup` heartbeat process was started in one exec at +`2026-09-01T10:52:51+0800` with PID `42022`. The next exec at +`10:52:58+0800` returned `ps_rc=1`, showed one heartbeat line, and showed +``. This supports that ordinary background lifecycle is not a +reliable carrier in this runtime, but it does not identify the exact signal or +system component that removed the process. diff --git a/evidence/t45-paired-20260901-setup/launch-time b/evidence/t45-paired-20260901-setup/launch-time new file mode 100644 index 000000000..be3d7b379 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/launch-time @@ -0,0 +1 @@ +2026-09-01T10:47:14+0800 diff --git a/evidence/t45-paired-20260901-setup/nohup/api-version.rc b/evidence/t45-paired-20260901-setup/nohup/api-version.rc new file mode 100644 index 000000000..7f8f011eb --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/api-version.rc @@ -0,0 +1 @@ +7 diff --git a/evidence/t45-paired-20260901-setup/nohup/api-version.stderr b/evidence/t45-paired-20260901-setup/nohup/api-version.stderr new file mode 100644 index 000000000..0137c5201 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/api-version.stderr @@ -0,0 +1 @@ +curl: (7) Failed to connect to 127.0.0.1 port 8791 after 0 ms: Couldn't connect to server diff --git a/evidence/t45-paired-20260901-setup/nohup/api-version.stdout b/evidence/t45-paired-20260901-setup/nohup/api-version.stdout new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/head b/evidence/t45-paired-20260901-setup/nohup/head new file mode 100644 index 000000000..c7df043f8 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/head @@ -0,0 +1 @@ +c8d2506f386bce15f731c50fb53d5c8e06e9c62b diff --git a/evidence/t45-paired-20260901-setup/nohup/health.elapsed_seconds b/evidence/t45-paired-20260901-setup/nohup/health.elapsed_seconds new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/health.elapsed_seconds @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/nohup/health.end b/evidence/t45-paired-20260901-setup/nohup/health.end new file mode 100644 index 000000000..be61e80b8 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/health.end @@ -0,0 +1 @@ +1788230884 diff --git a/evidence/t45-paired-20260901-setup/nohup/health.start b/evidence/t45-paired-20260901-setup/nohup/health.start new file mode 100644 index 000000000..be61e80b8 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/health.start @@ -0,0 +1 @@ +1788230884 diff --git a/evidence/t45-paired-20260901-setup/nohup/listener.rc b/evidence/t45-paired-20260901-setup/nohup/listener.rc new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/listener.rc @@ -0,0 +1 @@ +1 diff --git a/evidence/t45-paired-20260901-setup/nohup/listener.stderr b/evidence/t45-paired-20260901-setup/nohup/listener.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/listener.stdout b/evidence/t45-paired-20260901-setup/nohup/listener.stdout new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.rc b/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stderr b/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stdout b/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stdout new file mode 100644 index 000000000..7b02f5906 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/serve-log.tail.stdout @@ -0,0 +1,9 @@ +2026/09/01 10:47:31 [backup] WARNING newest existing backup was stale (no previous backup) — this studio had no recent retreat point +2026/09/01 10:47:31 [backup] ok (premigration): officraft-20260901-024731-premigration.db (0 MB in 3ms) +2026/09/01 10:47:31 goose: no migrations to run. current version: 65 +[ocserverd] journal_mode=wal (reads do not queue behind each other) +[reconcile] cadence started (period=30s) +[outsource] cadence started (period=30s) +[scheduled] cadence started (period=60s) +ocserverd serving on http://127.0.0.1:8791 +2026/09/01 10:47:32 [update-check] GitHub release check failed (channel include_prerelease=false): Get "http://127.0.0.1:1/repos/pkyosx/OffiCraft/releases?per_page=20": dial tcp 127.0.0.1:1: connect: connection refused diff --git a/evidence/t45-paired-20260901-setup/nohup/setup.end b/evidence/t45-paired-20260901-setup/nohup/setup.end new file mode 100644 index 000000000..a3787cb22 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/setup.end @@ -0,0 +1 @@ +1788230852.302288000 diff --git a/evidence/t45-paired-20260901-setup/nohup/setup.rc b/evidence/t45-paired-20260901-setup/nohup/setup.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/setup.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/nohup/setup.start b/evidence/t45-paired-20260901-setup/nohup/setup.start new file mode 100644 index 000000000..06c942e36 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/setup.start @@ -0,0 +1 @@ +1788230834.541188000 diff --git a/evidence/t45-paired-20260901-setup/nohup/setup.stderr b/evidence/t45-paired-20260901-setup/nohup/setup.stderr new file mode 100644 index 000000000..aca986740 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/setup.stderr @@ -0,0 +1,70 @@ +npm warn deprecated whatwg-encoding@3.1.1: Use @exodus/bytes instead for a more spec-conformant and faster implementation + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +2026/09/01 10:47:31 OK 00001_schema.sql (667.46µs) +2026/09/01 10:47:31 OK 00002_settings.sql (124.92µs) +2026/09/01 10:47:31 OK 00003_reply_card.sql (185.13µs) +2026/09/01 10:47:31 OK 00004_tasks.sql (789.13µs) +2026/09/01 10:47:31 OK 00005_task_closeout.sql (222.54µs) +2026/09/01 10:47:31 OK 00006_last_op_reason.sql (210.71µs) +2026/09/01 10:47:31 OK 00007_webhook.sql (201.33µs) +2026/09/01 10:47:31 OK 00008_task_manual_display_name.sql (214.71µs) +2026/09/01 10:47:31 OK 00009_task_creator.sql (210.29µs) +2026/09/01 10:47:31 OK 00010_task_manual_iskey_required.sql (131.42µs) +2026/09/01 10:47:31 OK 00011_task_duplicated.sql (749.25µs) +2026/09/01 10:47:31 OK 00012_webhook_platform.sql (401.38µs) +2026/09/01 10:47:31 OK 00013_reply_card_expired.sql (832.21µs) +2026/09/01 10:47:31 OK 00014_webhook_observability.sql (734.5µs) +2026/09/01 10:47:31 OK 00015_reply_card_attachments.sql (229.38µs) +2026/09/01 10:47:31 OK 00016_step_superseded.sql (740.79µs) +2026/09/01 10:47:31 OK 00017_worker_spawn_observability.sql (1.38ms) +2026/09/01 10:47:31 OK 00018_worker_desired_machine.sql (219.29µs) +2026/09/01 10:47:31 OK 00019_worker_refocus_since.sql (226.33µs) +2026/09/01 10:47:31 OK 00020_worker_desired_state.sql (337.67µs) +2026/09/01 10:47:31 OK 00021_worker_banked_cost.sql (308.08µs) +2026/09/01 10:47:31 OK 00022_task_artifact.sql (191.46µs) +2026/09/01 10:47:31 OK 00023_task_reassigned_from.sql (417.96µs) +2026/09/01 10:47:31 OK 00024_member_kind_outsource_and_linked_task.sql (796.75µs) +2026/09/01 10:47:31 OK 00025_outsource_worker_fold_into_member.sql (927.92µs) +2026/09/01 10:47:31 OK 00026_outsource_delegation_policy.sql (185.63µs) +2026/09/01 10:47:31 OK 00028_task_lock_and_step_waiting_reason.sql (444.63µs) +2026/09/01 10:47:31 OK 00029_task_outsource_target.sql (634.63µs) +2026/09/01 10:47:31 OK 00030_drop_outsource_delegation_policy.sql (159.88µs) +2026/09/01 10:47:31 OK 00031_task_handoff.sql (598.71µs) +2026/09/01 10:47:31 OK 00032_agent_runtime.sql (401.67µs) +2026/09/01 10:47:31 OK 00033_web_push.sql (153.5µs) +2026/09/01 10:47:31 OK 00034_warden_command_queue.sql (153.08µs) +2026/09/01 10:47:31 OK 00035_normalize_auto_machine_placement.sql (95.79µs) +2026/09/01 10:47:31 OK 00036_task_outsource_dispatched.sql (276µs) +2026/09/01 10:47:31 OK 00037_task_frozen_by.sql (259.58µs) +2026/09/01 10:47:31 OK 00039_member_last_machine_id.sql (251.17µs) +2026/09/01 10:47:31 OK 00040_member_avatar.sql (250.42µs) +2026/09/01 10:47:31 OK 00041_task_handover_notes.sql (577.42µs) +2026/09/01 10:47:31 OK 00042_member_actual_model.sql (254.88µs) +2026/09/01 10:47:31 OK 00043_document_history.sql (152.46µs) +2026/09/01 10:47:31 OK 00044_member_reported_launch_facts.sql (596.58µs) +2026/09/01 10:47:31 OK 00045_drop_legacy_task_manual_history.sql (79.13µs) +2026/09/01 10:47:31 OK 00046_task_step_note.sql (260.92µs) +2026/09/01 10:47:31 OK 00047_role_insight.sql (128.29µs) +2026/09/01 10:47:31 OK 00048_doc_cap_chars_per_segment.sql (80.63µs) +2026/09/01 10:47:31 OK 00049_doc_cap_chars_manual_split.sql (96.88µs) +2026/09/01 10:47:31 OK 00050_scheduled_message.sql (208.08µs) +2026/09/01 10:47:31 OK 00051_member_session_boot_ts.sql (291.54µs) +2026/09/01 10:47:31 OK 00052_scheduled_message_custom_cadence.sql (910.04µs) +2026/09/01 10:47:31 OK 00053_scheduled_message_custom_months.sql (302.33µs) +2026/09/01 10:47:31 OK 00054_reaim_custom_cursors.go (1.31ms) +2026/09/01 10:47:31 OK 00055_boot_document.sql (140.58µs) +2026/09/01 10:47:31 OK 00056_task_kickoff_notified.sql (384.58µs) +2026/09/01 10:47:31 OK 00057_member_forced_stop_at.sql (296.63µs) +2026/09/01 10:47:31 OK 00058_member_handover_noticed_ts.sql (299.96µs) +2026/09/01 10:47:31 OK 00059_custom_theme_table.go (140.33µs) +2026/09/01 10:47:31 OK 00060_task_id_sequence.sql (136.79µs) +2026/09/01 10:47:31 OK 00061_drop_non_general_lessons.sql (92.71µs) +2026/09/01 10:47:31 OK 00062_drop_lessons_task_type.sql (875.83µs) +2026/09/01 10:47:31 OK 00063_member_agent_iat_floor.sql (310.71µs) +2026/09/01 10:47:31 OK 00065_reply_card_multi_select.sql (978.92µs) +2026/09/01 10:47:31 goose: successfully migrated database to version: 65 +2026/09/01 10:47:31 goose: no migrations to run. current version: 65 diff --git a/evidence/t45-paired-20260901-setup/nohup/setup.stdout b/evidence/t45-paired-20260901-setup/nohup/setup.stdout new file mode 100644 index 000000000..d04bc3b6c --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/setup.stdout @@ -0,0 +1,34 @@ +[setup] repo=/Users/seth_wang/ai_workspace/officraft-t45-pair-nohup base=http://127.0.0.1:8791 target=go +[setup] building frontend SPA (VITE_USE_MOCK=false)… + +added 209 packages in 2s + +> officraft-frontend@0.0.0 build +> npm run typecheck && vite build + + +> officraft-frontend@0.0.0 typecheck +> tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.guards.json + +vite v5.4.21 building for production... +transforming... +✓ 216 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 11.13 kB │ gzip: 3.94 kB +dist/assets/index-Zx-AMiwl.css 183.01 kB │ gzip: 25.87 kB +dist/assets/index-CraAOLAa.js 793.66 kB │ gzip: 252.65 kB +✓ built in 1.22s +[setup] staged frontend/dist → server/ocserverd/webdist +[setup] staging product-guide docs (docs/guide → docsdist, go:embed)… +[build-docsdist] staged docs/guide → server/ocserverd/docsdist (13 docs, 6 assets) +[setup] staging seeds and bindist assets (go:embed)… +[build-seedsdist] staged seeds → server/ocserverd/seedsdist from /Users/seth_wang/ai_workspace/officraft-t45-pair-nohup/seeds (13 files) +[build-bindist] staged ocwarden (anchor embedded) + ocagent + officraft + mcp-catalog.json → server/ocserverd/bindist +[setup] building ocserverd (go build from server/ocserverd)… +[setup] migrate (ocserverd migrate, goose)… +[ocserverd] migrations applied + seed ensured (var/data/e2e.db) +[setup] seeding owner password (ocserverd set-password, hash → DB settings)… +[setup] starting isolated serve… +[setup] serve healthy AND identity-verified — git_sha=c8d2506f listener pid=25609 (launch pid=25608) +[setup] ✅ ready — base=http://127.0.0.1:8791 token→/Users/seth_wang/ai_workspace/officraft-t45-pair-nohup/e2e_test/.state/owner.tok diff --git a/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.rc b/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.rc new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.rc @@ -0,0 +1 @@ +1 diff --git a/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stderr b/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stdout b/evidence/t45-paired-20260901-setup/nohup/state-pid.ps.stdout new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/nohup/state-serve.pid b/evidence/t45-paired-20260901-setup/nohup/state-serve.pid new file mode 100644 index 000000000..fc2e11f04 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/nohup/state-serve.pid @@ -0,0 +1 @@ +25609 diff --git a/evidence/t45-paired-20260901-setup/tmux/api-version.rc b/evidence/t45-paired-20260901-setup/tmux/api-version.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/api-version.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/api-version.stderr b/evidence/t45-paired-20260901-setup/tmux/api-version.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/api-version.stdout b/evidence/t45-paired-20260901-setup/tmux/api-version.stdout new file mode 100644 index 000000000..168ef43ca --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/api-version.stdout @@ -0,0 +1 @@ +{"version":"0.0.0","git_sha":"d84a2302","git_time":"2026-09-01T10:28:10+08:00","catalog_hash":"f39c14c4fc9c6491","update_available":false,"latest_version":null} \ No newline at end of file diff --git a/evidence/t45-paired-20260901-setup/tmux/head b/evidence/t45-paired-20260901-setup/tmux/head new file mode 100644 index 000000000..6ad98fc87 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/head @@ -0,0 +1 @@ +d84a2302fa43c7ddb4aa9f839834b86cf8c3db85 diff --git a/evidence/t45-paired-20260901-setup/tmux/health.elapsed_seconds b/evidence/t45-paired-20260901-setup/tmux/health.elapsed_seconds new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/health.elapsed_seconds @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/health.end b/evidence/t45-paired-20260901-setup/tmux/health.end new file mode 100644 index 000000000..be61e80b8 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/health.end @@ -0,0 +1 @@ +1788230884 diff --git a/evidence/t45-paired-20260901-setup/tmux/health.start b/evidence/t45-paired-20260901-setup/tmux/health.start new file mode 100644 index 000000000..be61e80b8 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/health.start @@ -0,0 +1 @@ +1788230884 diff --git a/evidence/t45-paired-20260901-setup/tmux/listener.rc b/evidence/t45-paired-20260901-setup/tmux/listener.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/listener.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/listener.stderr b/evidence/t45-paired-20260901-setup/tmux/listener.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/listener.stdout b/evidence/t45-paired-20260901-setup/tmux/listener.stdout new file mode 100644 index 000000000..5361f4ffb --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/listener.stdout @@ -0,0 +1,2 @@ +COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +ocserverd 25592 seth_wang 9u IPv4 0x4432089e578970be 0t0 TCP 127.0.0.1:8793 (LISTEN) diff --git a/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.rc b/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stderr b/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stdout b/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stdout new file mode 100644 index 000000000..defafbfda --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/serve-log.tail.stdout @@ -0,0 +1,12 @@ +2026/09/01 10:47:30 [backup] WARNING newest existing backup was stale (no previous backup) — this studio had no recent retreat point +2026/09/01 10:47:30 [backup] ok (premigration): officraft-20260901-024730-premigration.db (0 MB in 3ms) +2026/09/01 10:47:30 goose: no migrations to run. current version: 65 +[ocserverd] journal_mode=wal (reads do not queue behind each other) +[reconcile] cadence started (period=30s) +[outsource] cadence started (period=30s) +[scheduled] cadence started (period=60s) +ocserverd serving on http://127.0.0.1:8793 +2026/09/01 10:47:31 [update-check] GitHub release check failed (channel include_prerelease=false): Get "http://127.0.0.1:1/repos/pkyosx/OffiCraft/releases?per_page=20": dial tcp 127.0.0.1:1: connect: connection refused +[reconcile] recycle: gate skip mira gate=no-actionable-pct pct=- pct_ts=- boot_ts=- boot_secs=- online=false +[reconcile] tick: 1 candidate(s) +[reconcile] mira: desired=offline command=none — offline: converged diff --git a/evidence/t45-paired-20260901-setup/tmux/setup.end b/evidence/t45-paired-20260901-setup/tmux/setup.end new file mode 100644 index 000000000..78e99a7a5 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/setup.end @@ -0,0 +1 @@ +1788230851.800961000 diff --git a/evidence/t45-paired-20260901-setup/tmux/setup.rc b/evidence/t45-paired-20260901-setup/tmux/setup.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/setup.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/setup.start b/evidence/t45-paired-20260901-setup/tmux/setup.start new file mode 100644 index 000000000..51e55d4be --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/setup.start @@ -0,0 +1 @@ +1788230834.541518000 diff --git a/evidence/t45-paired-20260901-setup/tmux/setup.stderr b/evidence/t45-paired-20260901-setup/tmux/setup.stderr new file mode 100644 index 000000000..349d3868d --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/setup.stderr @@ -0,0 +1,70 @@ +npm warn deprecated whatwg-encoding@3.1.1: Use @exodus/bytes instead for a more spec-conformant and faster implementation + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +2026/09/01 10:47:30 OK 00001_schema.sql (886.75µs) +2026/09/01 10:47:30 OK 00002_settings.sql (171.38µs) +2026/09/01 10:47:30 OK 00003_reply_card.sql (259.54µs) +2026/09/01 10:47:30 OK 00004_tasks.sql (935.21µs) +2026/09/01 10:47:30 OK 00005_task_closeout.sql (288.25µs) +2026/09/01 10:47:30 OK 00006_last_op_reason.sql (294.5µs) +2026/09/01 10:47:30 OK 00007_webhook.sql (296.46µs) +2026/09/01 10:47:30 OK 00008_task_manual_display_name.sql (316.88µs) +2026/09/01 10:47:30 OK 00009_task_creator.sql (432.04µs) +2026/09/01 10:47:30 OK 00010_task_manual_iskey_required.sql (189.08µs) +2026/09/01 10:47:30 OK 00011_task_duplicated.sql (1.24ms) +2026/09/01 10:47:30 OK 00012_webhook_platform.sql (562.08µs) +2026/09/01 10:47:30 OK 00013_reply_card_expired.sql (922.88µs) +2026/09/01 10:47:30 OK 00014_webhook_observability.sql (1.12ms) +2026/09/01 10:47:30 OK 00015_reply_card_attachments.sql (415.88µs) +2026/09/01 10:47:30 OK 00016_step_superseded.sql (969.92µs) +2026/09/01 10:47:30 OK 00017_worker_spawn_observability.sql (1.67ms) +2026/09/01 10:47:30 OK 00018_worker_desired_machine.sql (481.17µs) +2026/09/01 10:47:30 OK 00019_worker_refocus_since.sql (422.5µs) +2026/09/01 10:47:30 OK 00020_worker_desired_state.sql (383.5µs) +2026/09/01 10:47:30 OK 00021_worker_banked_cost.sql (351.46µs) +2026/09/01 10:47:30 OK 00022_task_artifact.sql (279.67µs) +2026/09/01 10:47:30 OK 00023_task_reassigned_from.sql (461.17µs) +2026/09/01 10:47:30 OK 00024_member_kind_outsource_and_linked_task.sql (1.02ms) +2026/09/01 10:47:30 OK 00025_outsource_worker_fold_into_member.sql (1.19ms) +2026/09/01 10:47:30 OK 00026_outsource_delegation_policy.sql (284.08µs) +2026/09/01 10:47:30 OK 00028_task_lock_and_step_waiting_reason.sql (611.63µs) +2026/09/01 10:47:30 OK 00029_task_outsource_target.sql (772.96µs) +2026/09/01 10:47:30 OK 00030_drop_outsource_delegation_policy.sql (184.04µs) +2026/09/01 10:47:30 OK 00031_task_handoff.sql (716.63µs) +2026/09/01 10:47:30 OK 00032_agent_runtime.sql (528.54µs) +2026/09/01 10:47:30 OK 00033_web_push.sql (221.88µs) +2026/09/01 10:47:30 OK 00034_warden_command_queue.sql (184.96µs) +2026/09/01 10:47:30 OK 00035_normalize_auto_machine_placement.sql (128.58µs) +2026/09/01 10:47:30 OK 00036_task_outsource_dispatched.sql (305.83µs) +2026/09/01 10:47:30 OK 00037_task_frozen_by.sql (334.29µs) +2026/09/01 10:47:30 OK 00039_member_last_machine_id.sql (325.38µs) +2026/09/01 10:47:30 OK 00040_member_avatar.sql (337.46µs) +2026/09/01 10:47:30 OK 00041_task_handover_notes.sql (782.29µs) +2026/09/01 10:47:30 OK 00042_member_actual_model.sql (327.29µs) +2026/09/01 10:47:30 OK 00043_document_history.sql (197.17µs) +2026/09/01 10:47:30 OK 00044_member_reported_launch_facts.sql (789.67µs) +2026/09/01 10:47:30 OK 00045_drop_legacy_task_manual_history.sql (115µs) +2026/09/01 10:47:30 OK 00046_task_step_note.sql (484.79µs) +2026/09/01 10:47:30 OK 00047_role_insight.sql (168.17µs) +2026/09/01 10:47:30 OK 00048_doc_cap_chars_per_segment.sql (104.21µs) +2026/09/01 10:47:30 OK 00049_doc_cap_chars_manual_split.sql (128.92µs) +2026/09/01 10:47:30 OK 00050_scheduled_message.sql (281.63µs) +2026/09/01 10:47:30 OK 00051_member_session_boot_ts.sql (377.46µs) +2026/09/01 10:47:30 OK 00052_scheduled_message_custom_cadence.sql (1.2ms) +2026/09/01 10:47:30 OK 00053_scheduled_message_custom_months.sql (374.58µs) +2026/09/01 10:47:30 OK 00054_reaim_custom_cursors.go (134.67µs) +2026/09/01 10:47:30 OK 00055_boot_document.sql (170.46µs) +2026/09/01 10:47:30 OK 00056_task_kickoff_notified.sql (373.5µs) +2026/09/01 10:47:30 OK 00057_member_forced_stop_at.sql (460.83µs) +2026/09/01 10:47:30 OK 00058_member_handover_noticed_ts.sql (377.67µs) +2026/09/01 10:47:30 OK 00059_custom_theme_table.go (178.92µs) +2026/09/01 10:47:30 OK 00060_task_id_sequence.sql (182.42µs) +2026/09/01 10:47:30 OK 00061_drop_non_general_lessons.sql (118.29µs) +2026/09/01 10:47:30 OK 00062_drop_lessons_task_type.sql (1.14ms) +2026/09/01 10:47:30 OK 00063_member_agent_iat_floor.sql (483.08µs) +2026/09/01 10:47:30 OK 00065_reply_card_multi_select.sql (1.35ms) +2026/09/01 10:47:30 goose: successfully migrated database to version: 65 +2026/09/01 10:47:30 goose: no migrations to run. current version: 65 diff --git a/evidence/t45-paired-20260901-setup/tmux/setup.stdout b/evidence/t45-paired-20260901-setup/tmux/setup.stdout new file mode 100644 index 000000000..b6fb56360 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/setup.stdout @@ -0,0 +1,34 @@ +[setup] repo=/Users/seth_wang/ai_workspace/officraft-t45-pair-tmux base=http://127.0.0.1:8793 target=go +[setup] building frontend SPA (VITE_USE_MOCK=false)… + +added 209 packages in 2s + +> officraft-frontend@0.0.0 build +> npm run typecheck && vite build + + +> officraft-frontend@0.0.0 typecheck +> tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.guards.json + +vite v5.4.21 building for production... +transforming... +✓ 216 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 11.13 kB │ gzip: 3.94 kB +dist/assets/index-Zx-AMiwl.css 183.01 kB │ gzip: 25.87 kB +dist/assets/index-CraAOLAa.js 793.66 kB │ gzip: 252.65 kB +✓ built in 1.20s +[setup] staged frontend/dist → server/ocserverd/webdist +[setup] staging product-guide docs (docs/guide → docsdist, go:embed)… +[build-docsdist] staged docs/guide → server/ocserverd/docsdist (13 docs, 6 assets) +[setup] staging seeds and bindist assets (go:embed)… +[build-seedsdist] staged seeds → server/ocserverd/seedsdist from /Users/seth_wang/ai_workspace/officraft-t45-pair-tmux/seeds (13 files) +[build-bindist] staged ocwarden (anchor embedded) + ocagent + officraft + mcp-catalog.json → server/ocserverd/bindist +[setup] building ocserverd (go build from server/ocserverd)… +[setup] migrate (ocserverd migrate, goose)… +[ocserverd] migrations applied + seed ensured (var/data/e2e.db) +[setup] seeding owner password (ocserverd set-password, hash → DB settings)… +[setup] starting isolated serve in tmux (socket=oc-e2e-00c5d45e5ced4f62ac0fb7f81a87f6cc session=oc-e2e-00c5d45e5ced4f62ac0fb7f81a87f6cc)… +[setup] serve healthy AND identity-verified — git_sha=d84a2302 listener pid=25592 (launch pid=25592) +[setup] ✅ ready — base=http://127.0.0.1:8793 token→/Users/seth_wang/ai_workspace/officraft-t45-pair-tmux/e2e_test/.state/owner.tok diff --git a/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.rc b/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stderr b/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stdout b/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stdout new file mode 100644 index 000000000..826ac99f7 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/state-pid.ps.stdout @@ -0,0 +1 @@ +25592 25591 /Users/seth_wang/ai_workspace/officraft-t45-pair-tmux/e2e_test/.state/ocserverd serve diff --git a/evidence/t45-paired-20260901-setup/tmux/state-serve.pid b/evidence/t45-paired-20260901-setup/tmux/state-serve.pid new file mode 100644 index 000000000..ecce52095 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/state-serve.pid @@ -0,0 +1 @@ +25592 diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux-state b/evidence/t45-paired-20260901-setup/tmux/tmux-state new file mode 100644 index 000000000..119aaf507 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/tmux-state @@ -0,0 +1,2 @@ +socket=oc-e2e-00c5d45e5ced4f62ac0fb7f81a87f6cc +session=oc-e2e-00c5d45e5ced4f62ac0fb7f81a87f6cc diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.rc b/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stderr b/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stdout b/evidence/t45-paired-20260901-setup/tmux/tmux.has-session.stdout new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.panes.rc b/evidence/t45-paired-20260901-setup/tmux/tmux.panes.rc new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/tmux.panes.rc @@ -0,0 +1 @@ +0 diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.panes.stderr b/evidence/t45-paired-20260901-setup/tmux/tmux.panes.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/evidence/t45-paired-20260901-setup/tmux/tmux.panes.stdout b/evidence/t45-paired-20260901-setup/tmux/tmux.panes.stdout new file mode 100644 index 000000000..fc4fb9f20 --- /dev/null +++ b/evidence/t45-paired-20260901-setup/tmux/tmux.panes.stdout @@ -0,0 +1 @@ +25592 ocserverd From 8d462353333fd02ad27a86e4c43771102a5b909c Mon Sep 17 00:00:00 2001 From: Seth Wang Date: Tue, 1 Sep 2026 11:48:58 +0800 Subject: [PATCH 3/3] ci(e2e): install tmux on macos runner --- .github/workflows/ci.yml | 10 +++++++ e2e_test/tests_guard/run.sh | 52 ++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c684249e..e88542b34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,6 +372,16 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + # e2e_test/setup.sh deliberately uses tmux as the cross-exec lifecycle + # carrier. GitHub's macOS image does not provide it by default; install + # the same prerequisite explicitly so CI exercises the shipped path. + - name: install tmux for isolated e2e + run: | + if ! command -v tmux >/dev/null 2>&1; then + brew install tmux + fi + tmux -V + # setup.sh refuses to run without an oc.toml that binds the non-prod port # and names a repo-local DSN — those refusals ARE the prod guards, so a # runner has to satisfy them rather than skip them. diff --git a/e2e_test/tests_guard/run.sh b/e2e_test/tests_guard/run.sh index 2d33111c4..7035ba914 100755 --- a/e2e_test/tests_guard/run.sh +++ b/e2e_test/tests_guard/run.sh @@ -3142,7 +3142,8 @@ else printf '%s\n' "$T45_SETUP_CODE" | grep -qE '^[[:space:]]*source .*lib/tmux\.sh' \ && ok "T-45: setup.sh sources the tmux helper in code" \ || bad "T-45: setup.sh no longer sources lib/tmux.sh — its independent-exec fix is disconnected" - printf '%s\n' "$T45_SETUP_CODE" | grep -qE '^[[:space:]]*if ! SERVE_LAUNCH_PID=.*oc_e2e_tmux_start' \ + T45_START_CALL='if ! SERVE_LAUNCH_PID="$(oc_e2e_tmux_start ' + printf '%s\n' "$T45_SETUP_CODE" | grep -qF "$T45_START_CALL" \ && ok "T-45: setup.sh starts the server through the tmux helper" \ || bad "T-45: setup.sh has no executable oc_e2e_tmux_start assignment — a trailing comment must not satisfy the carrier guard" if printf '%s\n' "$T45_TEARDOWN_CODE" \ @@ -3204,6 +3205,48 @@ else *) bad "T-45: missing tmux did not explain the prerequisite (stderr: $T45_NO_TMUX_MSG)" ;; esac + # The helper's fail-closed refusal is necessary for a member runtime, but CI + # itself is one continuous shell step and does not naturally need a carrier. + # The macOS real-browser job must therefore install the dependency explicitly; + # otherwise this suite can be green locally and dead before its first spec in + # CI. Extract only the macos-e2e job so a comment or another job cannot satisfy + # this check. + T45_CI_WORKFLOW="$HERE/../../.github/workflows/ci.yml" + if [[ ! -f "$T45_CI_WORKFLOW" ]]; then + bad "T-45: CI workflow is missing — the macos-e2e tmux prerequisite is not guarded" + else + T45_CI_E2E_CODE="$(awk ' + /^ macos-e2e:[[:space:]]*$/ { in_job=1; next } + in_job && /^ [[:alnum:]_.-]+:[[:space:]]*$/ { exit } + in_job { print } + ' "$T45_CI_WORKFLOW")" + T45_CI_TMUX_STEP_LINE="$(printf '%s\n' "$T45_CI_E2E_CODE" | grep -nE '^[[:space:]]*- name: install tmux for isolated e2e[[:space:]]*$' | head -1 | cut -d: -f1)" + T45_CI_BREW_LINE="$(printf '%s\n' "$T45_CI_E2E_CODE" | grep -nE '^[[:space:]]*brew install tmux[[:space:]]*$' | head -1 | cut -d: -f1)" + T45_CI_VERIFY_LINE="$(printf '%s\n' "$T45_CI_E2E_CODE" | grep -nE '^[[:space:]]*tmux -V[[:space:]]*$' | head -1 | cut -d: -f1)" + T45_CI_RUN_LINE="$(printf '%s\n' "$T45_CI_E2E_CODE" | grep -nE '^[[:space:]]*bash e2e_test/run_all\.sh([[:space:]]|$)' | head -1 | cut -d: -f1)" + if [[ -n "$T45_CI_TMUX_STEP_LINE" && -n "$T45_CI_BREW_LINE" \ + && -n "$T45_CI_VERIFY_LINE" && -n "$T45_CI_RUN_LINE" \ + && "$T45_CI_TMUX_STEP_LINE" -lt "$T45_CI_RUN_LINE" \ + && "$T45_CI_BREW_LINE" -lt "$T45_CI_RUN_LINE" \ + && "$T45_CI_VERIFY_LINE" -lt "$T45_CI_RUN_LINE" ]]; then + ok "T-45: macos-e2e installs and verifies tmux before run_all" + else + bad "T-45: macos-e2e has no guarded tmux install/verification before run_all — CI can die before the first spec" + fi + fi + + # MUTANT: rename the helper so a substring grep still sees + # `oc_e2e_tmux_start` inside `DISABLED_oc_e2e_tmux_start`. The guard must + # reject that source shape; it is not enough to prove the name appears. + T45_START_MUT="$T45_TMUX_FIXTURE/setup-disabled-start.sh" + sed 's/oc_e2e_tmux_start/DISABLED_oc_e2e_tmux_start/g' "$T45_SETUP" > "$T45_START_MUT" + T45_START_MUT_CODE="$(_t45_code "$T45_START_MUT")" + if printf '%s\n' "$T45_START_MUT_CODE" | grep -qF "$T45_START_CALL"; then + bad "MUT-T-45: renaming oc_e2e_tmux_start left the carrier guard apparently green" + else + ok "MUT-T-45: renaming the carrier helper removes the exact executable call" + fi + T45_MUT="$T45_TMUX_FIXTURE/tmux-mut.sh" sed 's/^oc_e2e_tmux_validate_name() {$/oc_e2e_tmux_validate_name() { return 0;/' \ "$T45_TMUX_LIB" > "$T45_MUT" @@ -3300,11 +3343,12 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # (2026-08-11, hole 191). # # SO IT IS NOW SET NEAR THE COUNT, WITH DELIBERATE SLACK, AND IT IS EXPECTED TO -# BE EDITED. 317 today, floor 314: three assertions of room. (291/288 → 298/295 +# BE EDITED. 319 today, floor 316: three assertions of room. (291/288 → 298/295 # when 2026-08-11's bash-3.2 round added 23e's three cells and case 26's four → # 303/300 when ⑤'s downgrade traded two cells away — `sg_mutant step_done` and # the ⑤-red/⑦-green pair — for seven in 21b-i/21b-v → 317/314 when T-45 added -# its 22 carrier/namespace/browser assertions. Each move edited the floor in the +# its 22 carrier/namespace/browser assertions → 319/316 when the CI tmux +# prerequisite and exact-call mutant were added. Each move edited the floor in the # same commit, which is the edit this block asks for.) The slack is measured, not guessed — deleting the whole of case 26 (then # 8 assertions) gave PASS=283, which was FATAL and named at 288 and GREEN at # 280. Read the @@ -3342,7 +3386,7 @@ echo "[tests_guard] PASS=$PASS FAIL=$FAIL" # printed the marker with no floor evaluated at all: MEASURED, floor block # deleted and the trailing echo kept → PASS=153 FAIL=0 rc=0, last line # `[tests_guard] all green`, `bin/ci.sh` all green. Keep it in the branch. -PASS_FLOOR=314 +PASS_FLOOR=316 if [[ "$PASS" -lt "$PASS_FLOOR" ]]; then echo "[tests_guard] FATAL: only $PASS assertion(s) ran, floor is $PASS_FLOOR." >&2 echo "[tests_guard] FAIL=0 with a collapsed PASS count means cases went missing, not that they passed." >&2