From 57929b68a92605a3e10d13280054245fd77218c9 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 6 Nov 2026 14:53:16 +0900 Subject: [PATCH 1/2] perf(artifacts): artifacts list reads mtimes in batches and formats times without date (CC-698, refs #749) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P9wHJRbhxx3v7XyQuq --- BACKLOG.md | 17 +++++- docs/platform-support.md | 7 +++ runtime/lib/pmctl-artifacts.sh | 95 +++++++++++++++++++++-------- tests/shell/test-pmctl-artifacts.sh | 39 ++++++++++++ 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 653792df..5135f2fc 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -139,7 +139,8 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-694 | 🔵 active | **[`PM_DISPATCH_STATE_ROOT` 用盘符写法时 `gate run` 先报告 detached 启动成功,监督进程不到 1 秒后才以 exit 2 失败(issue #779)]** `gate run` 在父程序、context 刷新與 operation record 之前檢查狀態根的寫法,拒絕時 exit 2、訊息指名變數(`PM_DISPATCH_STATE_ROOT` 或 `XDG_DATA_HOME`),不印 gate id;`docs/platform-support.md` 補一條。 | ops/gate | 2026-11-05 | feedback:2026-11-05 pr:#785 | P2 | hygiene | | CC-695 | 🔵 active | **[Windows 的 state root ACL 守衛:拒絕訊息沒有說是哪個主體(issue #778),測試的 `%TEMP%` 繼承 `CodexSandboxUsers` 讓 7 個 telemetry 案例失敗(issue #782)]** 守衛本身是對的,不放寬:訊息指名第一個違規主體與是否繼承,附上 `PM_DISPATCH_STATE_ROOT` 補救與不要隨手改父目錄 ACL 的提醒,`docs/platform-support.md` 補一條;`test-pmctl-context.sh` 用 `th_secure_state_root` 給 tmp_root 一個 owner-only ACL。 | ops/portability | 2026-11-05 | feedback:2026-11-05 pr:#786 | P2 | hygiene | | CC-696 | 🔵 active | **[原生 Windows 上 `memory-usage/sqlite-*` 三個案例穩定失敗:測試把含空白的 sqlite3 路徑不加引號、又不處理原生 sqlite3 的 CRLF(issue #783)]** 這三個案例的 `$real_sqlite "$db" ...` 沒有加引號,使用者目錄含空白(`/c/Users/Lien Chen/...`)時指令被拆開,查詢結果為空而讀成 `missing`;且原生 `sqlite3` 輸出 CRLF,bash 的命令替換只去掉結尾的換行字元,留下 CR。測試改為加引號並去掉 CR,產品程式沒有問題(資料庫內容正確)。並行兩個案例在 main 上現在通過,沒有重現。 | ops/test | 2026-11-05 | feedback:2026-11-05 pr:#787 | P2 | hygiene | -| CC-697 | 🔵 active | **[`gate` 的 scope manifest 超預算時仍掃完所有來源(issue #781)]** 127 個變更路徑的 full-tier gate 在原生 Windows 建 manifest 花 495 秒才以 INCOMPLETE 結束。省略計數只增不減,只要來源、符號、搜尋匹配或契約消費者任一項已超預算,結果就是 INCOMPLETE,其後的掃描只是白花時間。修法:不接受截斷時,在來源之間一發現超預算就停止並註明省略數是下限;接受截斷時照舊掃完。 | ops/gate | 2026-11-05 | feedback:2026-11-05 | P2 | hygiene | | +| CC-697 | 🔵 active | **[`gate` 的 scope manifest 超預算時仍掃完所有來源(issue #781)]** 127 個變更路徑的 full-tier gate 在原生 Windows 建 manifest 花 495 秒才以 INCOMPLETE 結束。省略計數只增不減,只要來源、符號、搜尋匹配或契約消費者任一項已超預算,結果就是 INCOMPLETE,其後的掃描只是白花時間。修法:不接受截斷時,在來源之間一發現超預算就停止並註明省略數是下限;接受截斷時照舊掃完。 | ops/gate | 2026-11-05 | feedback:2026-11-05 pr:#788 | P2 | hygiene | | +| CC-698 | 🔵 active | **[`pmctl artifacts list` 每筆 run 啟動多個外部程序,原生 Windows 約 0.18 秒/筆(issue #749)]** 60 筆約 11.8 秒、200 筆約 36 秒。改成批次 stat 取 mtime、用 bash 內建 `printf %()T` 轉時間,輸出逐字節不變;60 筆約 1.3 秒、200 筆約 2.1 秒。 | ops/portability | 2026-11-06 | feedback:2026-11-06 | P2 | hygiene | | --- @@ -2636,3 +2637,17 @@ symlink 或改名的 adapter 仍被拒絕。 **未做**:expansion-entry-budget 要等去重後才知道,不能提早判斷;同一 subject 的 manifest 快取供 `--accept-scope-truncation` 重用(issue 建議 2)、時間上限參數(建議 3)、`docs/platform-support.md` 的路徑數提醒(建議 4)、退出碼措辭(項目 4)。 **See**: issue #781、#621、#726、#754。 + +--- + +## CC-698 — `pmctl artifacts list` 的逐筆程序啟動 🔵 active + +**Problem**:issue #749。`pmctl_artifacts_list` 每筆 run 啟動 `basename`、`date`、多個 `stat` 與數個命令替換子殼層;原生 Windows 一個程序啟動約數十毫秒,總時間隨 run 數線性成長。 + +**量測(原生 Windows,同一 fixture,修改前後輸出逐字節相同,`cmp` 驗證)**:0 筆 1.24 s → 0.65 s;10 筆 2.5 s → 0.97 s;60 筆 10.7 s → 1.29 s;200 筆 36.1 s → 2.1 s。fixture 含無 footer、多 footer、相同時間戳的 run。 + +**修法**:`pmctl_artifacts_mtimes_collect` 每 50 個路徑一次 `stat`(Windows 命令列限制約 32 KB);時間字串用 bash 內建 `printf '%(%Y-%m-%dT%H:%M:%SZ)T'`,內建不可用時才退回 `date`;run id 用參數展開。排序、時間欄位(最新頂層 footer,否則 run 目錄)、輸出格式不變。 + +**未做**:`--limit`/分頁、串流輸出、Linux/WSL 前後量測、1000 筆規模、外部程序計數(issue 驗收項目,這裡只量了 wall-clock)。同一個 PR 夾帶 CC-697 的 `docs/platform-support.md` 說明(大型 diff 的 scope manifest 成本)。 + +**See**: issue #749、#742。 diff --git a/docs/platform-support.md b/docs/platform-support.md index aa34c15b..a1d956a8 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -133,6 +133,13 @@ if that bin directory is not already on PATH, the installer prints the exact > (only `lib/`, `shared/` and shell files are expanded) took about 1 to 1.3 s per changed such file (66 s for 50 files > of a 640-file synthetic repository, 162 s for a 179-file diff of this repository); the suites below still cannot > run natively. +> - **A large diff can spend minutes in the scope manifest before any reviewer starts (#781).** One report: 127 +> changed paths took 495 s and ended `INCOMPLETE` (search-match and expansion-entry budgets exceeded). Since CC-697 a +> run without `--accept-scope-truncation` stops scanning at the first exceeded budget and records +> `truncation.scan_stopped_early` in the manifest, so the `INCOMPLETE` report arrives sooner; its omitted counts are +> then lower bounds and its expansion list is partial. Rerunning with `--accept-scope-truncation` scans every source +> again (the earlier manifest is not reused) and accepts whatever that full scan omits. For a diff of roughly 100 +> paths or more, expect that rerun to take 8 minutes or longer on native Windows. > - **What CI proves on Windows** is only the bounded `windows-native-smoke` job above. > > #### Evidence boundary for native Windows diff --git a/runtime/lib/pmctl-artifacts.sh b/runtime/lib/pmctl-artifacts.sh index 63be6c56..fb93ed4b 100644 --- a/runtime/lib/pmctl-artifacts.sh +++ b/runtime/lib/pmctl-artifacts.sh @@ -86,31 +86,47 @@ pmctl_artifacts_file_size() { wc -c < "$path" | tr -d ' ' } -pmctl_artifacts_epoch_to_ts() { - local epoch="${1:-0}" - date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0 - date -u -r "$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0 - printf '%s\n' "$epoch" +# mtimes of many paths with one stat per batch instead of one per path (#749: +# a process start costs tens of milliseconds on native Windows). Fills the global +# _PMCTL_ARTIFACTS_MTIME (path -> epoch seconds); a path stat cannot read is left +# out. Batches stay small because the Windows command line is limited to ~32 KB. +declare -gA _PMCTL_ARTIFACTS_MTIME=() +pmctl_artifacts_mtimes_collect() { + local -a chunk=() + local mtime path gnu=0 + stat -c '%Y' / >/dev/null 2>&1 && gnu=1 + while [[ $# -gt 0 ]]; do + chunk=("${@:1:50}") + shift "$(( $# < 50 ? $# : 50 ))" + while IFS=$'\t' read -r mtime path; do + [[ "$mtime" =~ ^[0-9]+$ ]] && _PMCTL_ARTIFACTS_MTIME["$path"]="$mtime" + done < <( + if [[ "$gnu" -eq 1 ]]; then + stat -c $'%Y\t%n' -- "${chunk[@]}" 2>/dev/null + else + stat -f $'%m\t%N' -- "${chunk[@]}" 2>/dev/null + fi || true + ) + done } -pmctl_artifacts_run_created_epoch() { - local run_dir="${1:-}" footer newest=0 candidate - for footer in "$run_dir"/*.footer; do - [[ -e "$footer" ]] || continue - candidate="$(pmctl_artifacts_file_mtime "$footer")" - [[ "$candidate" =~ ^[0-9]+$ ]] || candidate=0 - (( candidate > newest )) && newest="$candidate" - done - if [[ "$newest" -eq 0 ]]; then - newest="$(pmctl_artifacts_file_mtime "$run_dir")" - fi - printf '%s\n' "$newest" +# ISO-8601 UTC for an epoch, left in REPLY (no command substitution, so no +# fork per run) without starting `date`: bash >= 4.2 has the %()T format. An +# epoch the builtin cannot format falls back to date and then to the raw number. +pmctl_artifacts_epoch_to_ts() { + local epoch="${1:-0}" + REPLY="" + TZ=UTC printf -v REPLY '%(%Y-%m-%dT%H:%M:%SZ)T' "$epoch" 2>/dev/null || REPLY="" + [[ -n "$REPLY" ]] && return 0 + REPLY="$(date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -r "$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || printf '%s' "$epoch")" } pmctl_artifacts_list() { local repo_root="${1:-}" work_dir="${2:-}" shift 2 || true - local runs_dir run_dir run_id epoch tmp_file any=0 + local runs_dir run_dir epoch tmp_file if [[ -z "$work_dir" ]]; then work_dir="$(portable_default_work_dir)" @@ -138,19 +154,46 @@ pmctl_artifacts_list() { return 0 fi - tmp_file="$(mktemp "${TMPDIR:-/tmp}/pmctl-artifacts-list.XXXXXX")" || return 2 + # A run's time is its newest top-level *.footer, else the run directory + # itself. All mtimes come from batched stat calls (#749), never one per run. + local -a run_dirs=() footers=() bare_dirs=() + local -A newest=() + local footer candidate ts for run_dir in "$runs_dir"/*; do - [[ -d "$run_dir" ]] || continue - run_id="$(basename "$run_dir")" - epoch="$(pmctl_artifacts_run_created_epoch "$run_dir")" - printf '%s\t%s\t%s\n' "$epoch" "$run_id" "$(pmctl_artifacts_epoch_to_ts "$epoch")" >> "$tmp_file" - any=1 + [[ -d "$run_dir" ]] && run_dirs+=("$run_dir") done - if [[ "$any" -eq 0 ]]; then - rm -f "$tmp_file" + if [[ "${#run_dirs[@]}" -eq 0 ]]; then printf '(no runs found)\n' return 0 fi + for footer in "$runs_dir"/*/*.footer; do + [[ -e "$footer" ]] && footers+=("$footer") + done + _PMCTL_ARTIFACTS_MTIME=() + if [[ "${#footers[@]}" -gt 0 ]]; then + pmctl_artifacts_mtimes_collect "${footers[@]}" + for footer in "${footers[@]}"; do + candidate="${_PMCTL_ARTIFACTS_MTIME[$footer]:-0}" + run_dir="${footer%/*}" + (( candidate > ${newest[$run_dir]:-0} )) && newest["$run_dir"]="$candidate" + done + fi + for run_dir in "${run_dirs[@]}"; do + [[ "${newest[$run_dir]:-0}" -eq 0 ]] && bare_dirs+=("$run_dir") + done + if [[ "${#bare_dirs[@]}" -gt 0 ]]; then + pmctl_artifacts_mtimes_collect "${bare_dirs[@]}" + for run_dir in "${bare_dirs[@]}"; do + newest["$run_dir"]="${_PMCTL_ARTIFACTS_MTIME[$run_dir]:-0}" + done + fi + tmp_file="$(mktemp "${TMPDIR:-/tmp}/pmctl-artifacts-list.XXXXXX")" || return 2 + for run_dir in "${run_dirs[@]}"; do + epoch="${newest[$run_dir]:-0}" + pmctl_artifacts_epoch_to_ts "$epoch" + ts="$REPLY" + printf '%s\t%s\t%s\n' "$epoch" "${run_dir##*/}" "$ts" + done > "$tmp_file" sort -rn "$tmp_file" | awk -F '\t' '{print $2 "\t" $3}' rm -f "$tmp_file" } diff --git a/tests/shell/test-pmctl-artifacts.sh b/tests/shell/test-pmctl-artifacts.sh index 9180334a..a4ac4fe1 100755 --- a/tests/shell/test-pmctl-artifacts.sh +++ b/tests/shell/test-pmctl-artifacts.sh @@ -123,6 +123,44 @@ case_artifacts_list_newest_first() { fi } +# Issue #749: the listing reads every mtime in batched stat calls. This pins what +# batching must not change: a run's time is its newest top-level footer, a run with +# no footer takes its directory's time, equal times keep the old tie order, and a +# partition larger than one stat batch (50 paths) is listed whole and in order. +case_artifacts_list_batched_mtimes_keep_semantics() { + local name="pmctl artifacts list: batched mtimes keep footer, directory and tie semantics" + should_run "$name" || return 0 + local store work out err status=0 probe parent i id rd got want + store="$tmp_root/state-batched" + work="$tmp_root/work-batched" + make_work_repo "$work" + probe="$(run_dir_for "$store" "$work" run-000)" + parent="$(dirname "$probe")" + # 120 runs, one footer each, minute i: newest is run-120. + for i in $(seq 1 120); do + id="run-$(printf '%03d' "$i")" + rd="$parent/$id" + mkdir -p "$rd" + printf x > "$rd/$id.footer" + TZ=UTC touch -t "$(printf '20260301%02d%02d' $((i / 60)) $((i % 60)))" "$rd/$id.footer" "$rd" + done + # Two footers: the newer one decides (older run dir, newer footer than run-120). + rd="$parent/run-two-footers"; mkdir -p "$rd" + printf x > "$rd/a.footer"; printf x > "$rd/b.footer" + TZ=UTC touch -t 202601010101 "$rd/a.footer" "$rd"; TZ=UTC touch -t 202603020000 "$rd/b.footer" + # No footer: the directory time decides. + rd="$parent/run-bare"; mkdir -p "$rd"; TZ=UTC touch -t 202603030000 "$rd" + out="$tmp_root/batched.out"; err="$tmp_root/batched.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" artifacts list --cd "$work" > "$out" 2> "$err" || status=$? + got="$(sed -n '1,3p' "$out" | tr '\n' ' ')" + want="$(printf 'run-bare\t2026-03-03T00:00:00Z run-two-footers\t2026-03-02T00:00:00Z run-120\t2026-03-01T02:00:00Z ')" + if [[ "$status" -eq 0 && "$got" == "$want" && "$(wc -l < "$out")" -eq 122 && ! -s "$err" ]]; then + pass "$name" + else + fail "$name" "status=$status first3=[$got] lines=$(wc -l < "$out") err=$(<"$err")" + fi +} + case_artifacts_list_empty() { local name="pmctl artifacts list: empty partition prints no runs" should_run "$name" || return 0 @@ -1694,6 +1732,7 @@ case_artifacts_show_relative_cd_from_subdirectory() { } case_artifacts_list_newest_first +case_artifacts_list_batched_mtimes_keep_semantics case_artifacts_list_empty case_artifacts_show_files case_artifacts_show_missing From 9915cc3877e3d65b40a262fa2794d82d3a72d0fd Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 6 Nov 2026 20:52:50 +0900 Subject: [PATCH 2/2] test(artifacts): the batched listing test pins the whole order, ties and a non-UTC TZ (CC-698, refs #749) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P9wHJRbhxx3v7XyQuq --- BACKLOG.md | 4 ++-- tests/shell/test-pmctl-artifacts.sh | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 5135f2fc..aaa901ad 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -140,7 +140,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-695 | 🔵 active | **[Windows 的 state root ACL 守衛:拒絕訊息沒有說是哪個主體(issue #778),測試的 `%TEMP%` 繼承 `CodexSandboxUsers` 讓 7 個 telemetry 案例失敗(issue #782)]** 守衛本身是對的,不放寬:訊息指名第一個違規主體與是否繼承,附上 `PM_DISPATCH_STATE_ROOT` 補救與不要隨手改父目錄 ACL 的提醒,`docs/platform-support.md` 補一條;`test-pmctl-context.sh` 用 `th_secure_state_root` 給 tmp_root 一個 owner-only ACL。 | ops/portability | 2026-11-05 | feedback:2026-11-05 pr:#786 | P2 | hygiene | | CC-696 | 🔵 active | **[原生 Windows 上 `memory-usage/sqlite-*` 三個案例穩定失敗:測試把含空白的 sqlite3 路徑不加引號、又不處理原生 sqlite3 的 CRLF(issue #783)]** 這三個案例的 `$real_sqlite "$db" ...` 沒有加引號,使用者目錄含空白(`/c/Users/Lien Chen/...`)時指令被拆開,查詢結果為空而讀成 `missing`;且原生 `sqlite3` 輸出 CRLF,bash 的命令替換只去掉結尾的換行字元,留下 CR。測試改為加引號並去掉 CR,產品程式沒有問題(資料庫內容正確)。並行兩個案例在 main 上現在通過,沒有重現。 | ops/test | 2026-11-05 | feedback:2026-11-05 pr:#787 | P2 | hygiene | | CC-697 | 🔵 active | **[`gate` 的 scope manifest 超預算時仍掃完所有來源(issue #781)]** 127 個變更路徑的 full-tier gate 在原生 Windows 建 manifest 花 495 秒才以 INCOMPLETE 結束。省略計數只增不減,只要來源、符號、搜尋匹配或契約消費者任一項已超預算,結果就是 INCOMPLETE,其後的掃描只是白花時間。修法:不接受截斷時,在來源之間一發現超預算就停止並註明省略數是下限;接受截斷時照舊掃完。 | ops/gate | 2026-11-05 | feedback:2026-11-05 pr:#788 | P2 | hygiene | | -| CC-698 | 🔵 active | **[`pmctl artifacts list` 每筆 run 啟動多個外部程序,原生 Windows 約 0.18 秒/筆(issue #749)]** 60 筆約 11.8 秒、200 筆約 36 秒。改成批次 stat 取 mtime、用 bash 內建 `printf %()T` 轉時間,輸出逐字節不變;60 筆約 1.3 秒、200 筆約 2.1 秒。 | ops/portability | 2026-11-06 | feedback:2026-11-06 | P2 | hygiene | | +| CC-698 | 🔵 active | **[`pmctl artifacts list` 每筆 run 啟動多個外部程序,原生 Windows 約 0.18 秒/筆(issue #749)]** 60 筆約 11.8 秒、200 筆約 36 秒。改成批次 stat 取 mtime、用 bash 內建 `printf %()T` 轉時間,輸出逐字節不變;60 筆約 1.3 秒、200 筆約 2.1 秒。 | ops/portability | 2026-11-06 | feedback:2026-11-06 pr:#789 | P2 | hygiene | | --- @@ -2648,6 +2648,6 @@ symlink 或改名的 adapter 仍被拒絕。 **修法**:`pmctl_artifacts_mtimes_collect` 每 50 個路徑一次 `stat`(Windows 命令列限制約 32 KB);時間字串用 bash 內建 `printf '%(%Y-%m-%dT%H:%M:%SZ)T'`,內建不可用時才退回 `date`;run id 用參數展開。排序、時間欄位(最新頂層 footer,否則 run 目錄)、輸出格式不變。 -**未做**:`--limit`/分頁、串流輸出、Linux/WSL 前後量測、1000 筆規模、外部程序計數(issue 驗收項目,這裡只量了 wall-clock)。同一個 PR 夾帶 CC-697 的 `docs/platform-support.md` 說明(大型 diff 的 scope manifest 成本)。 +**未做**:`--limit`/分頁、串流輸出、Linux/WSL 前後量測、1000 筆規模、外部程序計數(issue 驗收項目,這裡只量了 wall-clock)。把批次 stat(stat 種類探測、按位元組切塊、tab 分隔的 mtime)收進 `runtime/lib/portable.sh`,讓 `pmctl-context.sh` 的 `_ctx_batch_*`(空格分隔,路徑含空格會錯)與這裡共用,並統一用位元組數而非 50 筆切塊(審查建議,另開票)。同一個 PR 夾帶 CC-697 的 `docs/platform-support.md` 說明(大型 diff 的 scope manifest 成本)。 **See**: issue #749、#742。 diff --git a/tests/shell/test-pmctl-artifacts.sh b/tests/shell/test-pmctl-artifacts.sh index a4ac4fe1..6b2c947f 100755 --- a/tests/shell/test-pmctl-artifacts.sh +++ b/tests/shell/test-pmctl-artifacts.sh @@ -150,14 +150,24 @@ case_artifacts_list_batched_mtimes_keep_semantics() { TZ=UTC touch -t 202601010101 "$rd/a.footer" "$rd"; TZ=UTC touch -t 202603020000 "$rd/b.footer" # No footer: the directory time decides. rd="$parent/run-bare"; mkdir -p "$rd"; TZ=UTC touch -t 202603030000 "$rd" + # Equal times: the old whole-line `sort -rn` put the larger id first. + for id in run-tie-a run-tie-b; do + rd="$parent/$id"; mkdir -p "$rd"; printf x > "$rd/$id.footer" + TZ=UTC touch -t 202603050000 "$rd/$id.footer" "$rd" + done out="$tmp_root/batched.out"; err="$tmp_root/batched.err" - PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" artifacts list --cd "$work" > "$out" 2> "$err" || status=$? - got="$(sed -n '1,3p' "$out" | tr '\n' ' ')" - want="$(printf 'run-bare\t2026-03-03T00:00:00Z run-two-footers\t2026-03-02T00:00:00Z run-120\t2026-03-01T02:00:00Z ')" - if [[ "$status" -eq 0 && "$got" == "$want" && "$(wc -l < "$out")" -eq 122 && ! -s "$err" ]]; then + # A non-UTC TZ in the environment must not leak into the printed times. + TZ=Asia/Tokyo PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" artifacts list --cd "$work" > "$out" 2> "$err" || status=$? + # The whole listing, in order: a chunk lost by batching would show as a 1970 line. + want="$(printf 'run-tie-b\t2026-03-05T00:00:00Z\nrun-tie-a\t2026-03-05T00:00:00Z\nrun-bare\t2026-03-03T00:00:00Z\nrun-two-footers\t2026-03-02T00:00:00Z\n' + for i in $(seq 120 -1 1); do + printf 'run-%03d\t2026-03-01T%02d:%02d:00Z\n' "$i" $((i / 60)) $((i % 60)) + done)" + got="$(<"$out")" + if [[ "$status" -eq 0 && "$got" == "$want" && ! -s "$err" ]]; then pass "$name" else - fail "$name" "status=$status first3=[$got] lines=$(wc -l < "$out") err=$(<"$err")" + fail "$name" "status=$status lines=$(wc -l < "$out") first4=[$(sed -n '1,4p' "$out" | tr '\n' ' ')] 1970=$(grep -c 1970 "$out") err=$(<"$err")" fi }