diff --git a/BACKLOG.md b/BACKLOG.md index 3f771268..653792df 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -138,7 +138,8 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-693 | 🔵 active | **[`gate run` 的 context 階段在新 worktree 每次耗滿 90 秒上限且不保留進度(issue #780)]** 根因:`pmctl_context_workflow_refresh_bounded` 只用 `timeout 90` 從外面殺掉,`workflow-refresh` 沒傳預算給索引,索引在最後才 COMMIT,被殺就全丟。修法:`workflow-refresh` 新增 `--budget-ms`,wrapper 傳上限的三分之二,索引自己停下並保留進度,下一次接續(重用 #760 的機制)。 | ops/gate | 2026-11-05 | feedback:2026-11-05 pr:#784 | P2 | hygiene | | 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 | 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 | | --- @@ -2623,3 +2624,15 @@ symlink 或改名的 adapter 仍被拒絕。 **未重現**:issue 列的兩個並行案例(`concurrent-no-lost-updates` 8 個 writer 逾時 20 秒、`contention-matrix-flock-and-mkdir-fallback`)在這台機器上 main 就通過(分別約 12 秒與 34 到 46 秒,連跑三次都過),所以它們是負載相關的逾時,不是穩定失敗;#738 要求的 50 個並行 writer 的 exactly-once 驗收在原生 Windows 上仍未驗證。 **See**: issue #783、#738。 + +--- + +## CC-697 — scope manifest 超預算時提早停止 🔵 active + +**Problem**:issue #781。127 個變更路徑的 full-tier gate 在原生 Windows 建 scope manifest 花 495 秒(512 個 expansion,每個來源約 1 秒)才以 INCOMPLETE 結束,使用者要等 8 分鐘才知道需要接受截斷。 + +**修法(第一步,只做估算/提早中止)**:`_gate_scope_expansions_collect_into` 在處理下一個來源之前檢查來源、符號、搜尋匹配、契約消費者四個省略計數;這些計數只增不減,任一已大於 0 就已決定是 INCOMPLETE。沒有 `--accept-scope-truncation` 時停止掃描,manifest 的 `truncation` 記 `scan_stopped_early: true`(只在提早停止時出現,schema 與產生的 bundle 一併加上),`INCOMPLETE` 回報據此說明省略數是下限、expansion 清單不完整,接受旗標會涵蓋完整掃描的所有省略;有接受旗標時照舊掃完。只在來源之間停,所以單一來源的計數仍然完整。 + +**未做**:expansion-entry-budget 要等去重後才知道,不能提早判斷;同一 subject 的 manifest 快取供 `--accept-scope-truncation` 重用(issue 建議 2)、時間上限參數(建議 3)、`docs/platform-support.md` 的路徑數提醒(建議 4)、退出碼措辭(項目 4)。 + +**See**: issue #781、#621、#726、#754。 diff --git a/core/schema/gate-scope-manifest.schema.json b/core/schema/gate-scope-manifest.schema.json index e1efab4f..80435878 100644 --- a/core/schema/gate-scope-manifest.schema.json +++ b/core/schema/gate-scope-manifest.schema.json @@ -373,6 +373,10 @@ } }, "additionalProperties": false + }, + "scan_stopped_early": { + "const": true, + "description": "Present only when the expansion scan stopped at the first exceeded budget, so the omitted counts and the expansion list are partial (lower bounds). A run that accepts truncation never stops early." } }, "additionalProperties": false diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index e07722c6..bcf89b8c 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -2251,6 +2251,10 @@ if [[ "$SCOPE_MANIFEST_STATUS" == incomplete ]]; then "$(jq -c '.truncation.omitted' "$SCOPE_MANIFEST_PATH")" printf ' reasons: %s\n' \ "$(jq -c '.truncation.reasons' "$SCOPE_MANIFEST_PATH")" + if jq -e '.truncation.scan_stopped_early == true' "$SCOPE_MANIFEST_PATH" >/dev/null 2>&1; then + printf ' The scan stopped at the first omission: the omitted counts are lower bounds and the expansion list is partial (truncation.scan_stopped_early).\n' + printf ' --accept-scope-truncation scans every source, which takes longer, and accepts whatever that full scan omits, not only the omissions listed here.\n' + fi printf ' Inspect the manifest, then rerun with --accept-scope-truncation only if those omissions are acceptable.\n' } >&2 if [[ "$GATE_OUTPUT_EXISTED" != true ]]; then diff --git a/runtime/lib/gate-scope.sh b/runtime/lib/gate-scope.sh index 312e18ea..266f84cf 100644 --- a/runtime/lib/gate-scope.sh +++ b/runtime/lib/gate-scope.sh @@ -769,6 +769,7 @@ _gate_scope_expansions_collect_into() { # more than every other collector in this file combined. The set is small and # fixed for the whole call, so resolve it once here and answer from memory. local -A changed_seen=() + local stopped_early=false : > "$candidates" : > "$sources" @@ -785,6 +786,18 @@ _gate_scope_expansions_collect_into() { done < <(jq -j '.[] | ., "\u0000"' <<<"$changed_paths_json") while IFS= read -r -d '' source; do + # A run that will end INCOMPLETE gains nothing from scanning the rest: the + # omitted counters only ever grow, so one already above zero fixes the + # verdict, and each further source costs about a second on native Windows + # (#781: 495 s for 127 paths). Stop between sources, so a source is always + # counted whole, and let the counters read as lower bounds. A run that + # accepts truncation still builds the full bounded manifest. + if [[ "${ACCEPT_SCOPE_TRUNCATION:-false}" != true \ + && $((omitted_sources + omitted_symbols + omitted_matches \ + + omitted_contract_consumers)) -gt 0 ]]; then + stopped_early=true + break + fi source_count=$((source_count + 1)) if [[ "$source_count" -gt "$source_limit" ]]; then omitted_sources=$((omitted_sources + 1)) @@ -950,6 +963,7 @@ _gate_scope_expansions_collect_into() { GATE_SCOPE_OMITTED_CONTRACT_CONSUMERS="$omitted_contract_consumers" GATE_SCOPE_OMITTED_EXPANSION_ENTRIES="$omitted_entries" GATE_SCOPE_OMITTED_SEARCH_FAILURES="$search_failures" + GATE_SCOPE_SCAN_STOPPED_EARLY="$stopped_early" } _gate_scope_flags_resolve() { @@ -1266,6 +1280,7 @@ _gate_scope_manifest_write() { --argjson omitted_contract_consumers "$GATE_SCOPE_OMITTED_CONTRACT_CONSUMERS" \ --argjson omitted_entries "$GATE_SCOPE_OMITTED_EXPANSION_ENTRIES" \ --argjson omitted_search_failures "$GATE_SCOPE_OMITTED_SEARCH_FAILURES" \ + --argjson scan_stopped_early "${GATE_SCOPE_SCAN_STOPPED_EARLY:-false}" \ --argjson budget_hunks "$GATE_SCOPE_MAX_DIFF_HUNKS" \ --argjson budget_sources "$GATE_SCOPE_MAX_EXPANSION_SOURCES" \ --argjson budget_symbols "$GATE_SCOPE_MAX_SYMBOLS_PER_SOURCE" \ @@ -1311,7 +1326,7 @@ _gate_scope_manifest_write() { claim:"declared-review-reference-set", entries:$references[0] }, - truncation:{ + truncation:({ occurred:$truncation_occurred, budgets:{ diff_hunks:$budget_hunks, @@ -1336,7 +1351,7 @@ _gate_scope_manifest_write() { accepted:$truncation_accepted, source:(if $acceptance_source == "" then null else $acceptance_source end) } - }, + } + (if $scan_stopped_early then {scan_stopped_early:true} else {} end)), content:{ digest_algorithm:"sha256-canonical-json-without-content-digest", digest:("") diff --git a/runtime/lib/gate-structural-schemas.json b/runtime/lib/gate-structural-schemas.json index 3215f3dd..70d75d91 100644 --- a/runtime/lib/gate-structural-schemas.json +++ b/runtime/lib/gate-structural-schemas.json @@ -3912,6 +3912,10 @@ }, "type": "array", "uniqueItems": true + }, + "scan_stopped_early": { + "const": true, + "description": "Present only when the expansion scan stopped at the first exceeded budget, so the omitted counts and the expansion list are partial (lower bounds). A run that accepts truncation never stops early." } }, "required": [ diff --git a/tests/shell/test-pr-gate.sh b/tests/shell/test-pr-gate.sh index 3787f412..afa2aab3 100755 --- a/tests/shell/test-pr-gate.sh +++ b/tests/shell/test-pr-gate.sh @@ -7054,6 +7054,82 @@ test_scope_manifest_semantic_search_overflow_fails_closed() { pass "$name" } +# Behavior: a manifest scan that has already overflowed a budget stops before +# the next source (#781), and an accepting run still scans every source. +# Steps: +# 1. Change two TypeScript sources, each with 65 callers of its own symbol. +# 2. Run without acceptance: assert INCOMPLETE, the lower-bound note, and +# that only the first source's overflow (1) was counted. +# 3. Run with --accept-scope-truncation: assert both overflows (2) counted. +test_scope_manifest_overflow_stops_scan_unless_accepted() { + local name="scope-manifest/overflow-stops-scan-unless-accepted" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" + local runner="$dir/runner" out="$dir/out" err="$dir/err" + local manifest code which + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + git init -q -b main "$repo" + ( + cd "$repo" + git config user.email test@example.com + git config user.name 'Gate Test' + mkdir -p src callers + write_managed_gitignore + for which in a b; do + printf 'export function scopeStopSymbol_%s() { return false; }\n' "$which" > "src/shared-$which.ts" + for n in $(seq -w 1 65); do + printf 'scopeStopSymbol_%s();\n' "$which" > "callers/caller-$which-${n}.ts" + done + done + git add . + git commit -q -m initial + git checkout -q -b feature + for which in a b; do + printf 'export function scopeStopSymbol_%s() { return true; }\n' "$which" > "src/shared-$which.ts" + done + git add src + git commit -q -m change + ) + + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --mode sequential + code=$? + set -e + [[ "$code" -eq 3 ]] || { + fail "$name" "exit $code, expected 3: $(tail -n 30 "$err" 2>/dev/null)" + return + } + assert_file_contains "$name" "$err" "omitted counts are lower bounds" || return + manifest="$(find "$repo/.gate-results" \ + -maxdepth 1 -name 'gate-scope-manifest-*.json' -print -quit)" + if ! jq -e '.status == "incomplete" + and .truncation.scan_stopped_early == true + and .truncation.omitted.matches_per_query == 1' "$manifest" >/dev/null; then + fail "$name" "scan did not stop after the first overflow: $(jq -c .truncation "$manifest" 2>/dev/null)" + return + fi + + # Drop the first run's manifest so the one found below is the accepting run's. + rm -f -- "$repo"/.gate-results/gate-scope-manifest-*.json + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" \ + --base main --mode sequential --accept-scope-truncation + set -e + manifest="$(find "$repo/.gate-results" \ + -maxdepth 1 -name 'gate-scope-manifest-*.json' -print -quit)" + if ! jq -e '.status == "accepted_truncation" + and (.truncation | has("scan_stopped_early") | not) + and .truncation.omitted.matches_per_query == 2' \ + "$manifest" >/dev/null; then + fail "$name" "accepting run did not scan both sources: $(jq -c '{status,truncation}' "$manifest" 2>/dev/null)" + return + fi + pass "$name" +} + # Behavior: the manifest producer transports a maximum-size expansion through # a file descriptor instead of one jq argv value, preserving every entry even # when the serialized array exceeds Linux MAX_ARG_STRLEN. @@ -7522,6 +7598,7 @@ run_test test_scope_manifest_contract_bundle_uses_bounded_consumer_summary run_test test_scope_manifest_contract_bundle_overflow_is_truthful_truncation run_test test_scope_manifest_failed_search_is_not_reported_complete run_test test_scope_manifest_semantic_search_overflow_fails_closed +run_test test_scope_manifest_overflow_stops_scan_unless_accepted run_test test_scope_manifest_large_expansion_uses_file_input run_test test_large_diff_classification_transport_survives_slurpfile_boundary run_test test_scope_reference_index_collector_direct_decode