Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 pr:#789 | P2 | hygiene | |

---

Expand Down Expand Up @@ -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)。把批次 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。
7 changes: 7 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 69 additions & 26 deletions runtime/lib/pmctl-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
}
Expand Down
49 changes: 49 additions & 0 deletions tests/shell/test-pmctl-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,54 @@ 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"
# 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"
# 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 lines=$(wc -l < "$out") first4=[$(sed -n '1,4p' "$out" | tr '\n' ' ')] 1970=$(grep -c 1970 "$out") err=$(<"$err")"
fi
}

case_artifacts_list_empty() {
local name="pmctl artifacts list: empty partition prints no runs"
should_run "$name" || return 0
Expand Down Expand Up @@ -1694,6 +1742,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
Expand Down
Loading