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
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ jobs:

test-guards:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Guard regression suite
env:
TEST_GUARDS_PROGRESS: "1"
run: ./scripts/test-guards.sh

test-guard-framework:
Expand Down
8 changes: 6 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr
| CC-484 | ✅ done | JapanJob 與 qa-testing-rules 的 `pmctl context` refresh 未生效:重現 session/index/update/pack 實際路徑、repo-root/project-key/DB freshness,補跨 repo live E2E 與 actionable diagnostics | ops/memory | 2026-07-13 | feedback:2026-07-13 | P1 | retrieval |
| CC-485 | ✅ done | 工具能力與維護者政策分離:通用 gate/test/PM 不規定使用流程;affected feedback 僅屬開發/PR,pm-dispatch release 固定由 `release-verify.sh --e2e`(內含 fresh full suite)+ checklist 驗收 | arch/process | 2026-07-13 | pr:#398 | P3 | design |
| CC-486 | ⏸ deferred | direct-impact test planner mapping 提前退出:changed path 含 `agents/*.md`/`commands/*.md` 時 `map_path` 呼叫未註冊的 `lint-frontmatter`,`add_suite` 回傳 1 並在 `set -e` 下無輸出終止,導致 `run-tests.sh --base ... --list` exit 1 | ops/test | 2026-07-13 | feedback:2026-07-13 | P2 | hygiene |
| CC-487 | 🔵 active | GitHub Actions `test-guards` 非確定性掛起:逐 case breadcrumb + bounded background writer/wait + orphan cleanup,避免 main CI 無輸出佔用 runner 近一小時 | ops/test | 2026-07-14 | feedback:2026-07-14 | P1 | hygiene |
| CC-487 | ✅ closed 2026-07-14 | GitHub Actions `test-guards` 非確定性掛起:逐 case breadcrumb + bounded background writer/wait + orphan cleanup,避免 main CI 無輸出佔用 runner 近一小時 | ops/test | 2026-07-14 | pr:#402 | P1 | hygiene |
| CC-488 | ✅ done | Codex canonical memory lifecycle 收口:Stop writer、自然語言更新路由、explicit config 與 live/private-memory migration 驗收 | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design |
| CC-489 | ⏸ deferred | `scripts/` domain ownership 重整:host/runtime/ops/test entrypoints 移至對應模組,由 manifest/registry 統一發現並以相容 shim 分批遷移 | arch | 2026-07-14 | feedback:2026-07-14 | P2 | design |
| CC-490 | 🔵 active | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design |
Expand Down Expand Up @@ -661,7 +661,7 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed

---

## CC-487 — GitHub Actions `test-guards` 非確定性掛起與 bounded diagnostics 🔵 active
## CC-487 — GitHub Actions `test-guards` 非確定性掛起與 bounded diagnostics ✅ 2026-07-14

**Problem**: main SHA `0b66f1f` 的 GitHub Actions run `29298816362` 中,`test-guards` job 從 2026-07-14 01:32:51Z 執行至 02:30:38Z,停留在 `== guard-inject-memory ==` 後沒有更多輸出,最後由使用者手動取消。runner cleanup 記錄兩個殘留 `bash` process。該區段不只包含 inject hook,亦包含 `memory-usage/concurrent-no-lost-updates` 與 `memory-usage/contention-matrix-flock-and-mkdir-fallback` 等背景 writer/`wait` 測試;CI 未啟用逐 case breadcrumb,現有 artifact 無法確定是哪一個 case 或 process 未收斂。

Expand All @@ -679,6 +679,10 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed

**Dependencies**: 承接 [[CC-477]] 已完成的 lock protocol 與 runner breadcrumbs;本票只處理仍存在的 CI hang 可診斷性與 bounded lifecycle。

**Closed 2026-07-14(Draft PR #402)**: Draft PR #402 已建立;standard implementation gate `gate-20260714-075600-a3affd` 與針對 busy-spin/nonzero-child coverage findings 的 targeted gate `gate-20260714-080619-5069e6` 均為 `Final: GO`。最終實作加入逐 case breadcrumb、tracked child registry、case deadline、TERM→KILL/EXIT cleanup、FIFO release 防阻塞與 hang/nonzero fault-injection regressions;`test-guards` 300/300、authoritative full suite 77/77 通過。依維護者流程於 PR #402 建立後 terminal close,合併該 PR 即完成交付。

**See**: pr:#402

---

## CC-488 — Codex canonical memory lifecycle 與更新路由收口 ✅ 2026-07-14
Expand Down
211 changes: 196 additions & 15 deletions scripts/test-guards.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,105 @@ export PM_GUARD_LOG_DIR="$(mktemp -d)"
TEST_LOG_FILE="$PM_GUARD_LOG_DIR/hooks.log"
TEST_GUARDS_DIAG_FILE="$PM_GUARD_LOG_DIR/diagnostics.log"
export TEST_GUARDS_DIAG_FILE
trap 'rm -rf "$PM_GUARD_LOG_DIR" "${DISPATCH_TEST_BRIEF:-}" "${DISPATCH_TEST_BIN:-}" "${tmp_root:-}"' EXIT

# Concurrency cases register every background child here. Bare `wait` is not
# acceptable in this suite: a wedged writer would otherwise consume the whole
# CI job without identifying the active case or process. The registry is also
# used by the suite-level EXIT trap so an interrupted run cannot orphan writers.
TEST_GUARDS_CHILD_PIDS=()
TEST_GUARDS_CHILD_LABELS=()

test_guards_children_reset() {
TEST_GUARDS_CHILD_PIDS=()
TEST_GUARDS_CHILD_LABELS=()
}

test_guards_child_track() {
TEST_GUARDS_CHILD_PIDS+=("$1")
TEST_GUARDS_CHILD_LABELS+=("$2")
}

test_guards_child_state() {
ps -o stat= -p "$1" 2>/dev/null | awk '{$1=$1; print}'
}

test_guards_children_dump() {
local context="$1" i pid label state
printf 'CHILD-DIAG case=%s context=%s registered=%s\n' \
"${TEST_GUARDS_CURRENT_CASE:-unknown}" "$context" "${#TEST_GUARDS_CHILD_PIDS[@]}" >&2
for i in "${!TEST_GUARDS_CHILD_PIDS[@]}"; do
pid="${TEST_GUARDS_CHILD_PIDS[$i]}"
label="${TEST_GUARDS_CHILD_LABELS[$i]}"
state="$(test_guards_child_state "$pid")"
printf 'CHILD-DIAG writer=%s pid=%s state=%s alive=%s\n' \
"$label" "$pid" "${state:-exited}" "$([[ -n "$state" ]] && printf yes || printf no)" >&2
done
}

test_guards_children_terminate() {
local signal="$1" pid
for pid in "${TEST_GUARDS_CHILD_PIDS[@]}"; do
kill -0 "$pid" 2>/dev/null && kill "-$signal" "$pid" 2>/dev/null || true
done
}

test_guards_children_reap() {
local pid
for pid in "${TEST_GUARDS_CHILD_PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
test_guards_children_reset
}

test_guards_children_cleanup() {
((${#TEST_GUARDS_CHILD_PIDS[@]})) || return 0
test_guards_children_terminate TERM
sleep 0.1
test_guards_children_terminate KILL
test_guards_children_reap
}

# test_guards_children_wait <deadline-seconds> <diagnostic-context>
# Returns 0 only when every registered child exits successfully. On timeout it
# prints the complete registry, performs TERM -> KILL cleanup, and returns 124.
test_guards_children_wait() {
local deadline="$1" context="$2" started=$SECONDS i pid rc status=0 alive
while :; do
alive=0
for pid in "${TEST_GUARDS_CHILD_PIDS[@]}"; do
kill -0 "$pid" 2>/dev/null && alive=$((alive + 1))
done
((alive == 0)) && break
if ((SECONDS - started >= deadline)); then
printf 'TIMEOUT test-guards case=%s context=%s deadline=%ss alive=%s\n' \
"${TEST_GUARDS_CURRENT_CASE:-unknown}" "$context" "$deadline" "$alive" >&2
test_guards_children_dump "$context"
test_guards_children_cleanup
return 124
fi
sleep 0.05
done

for i in "${!TEST_GUARDS_CHILD_PIDS[@]}"; do
pid="${TEST_GUARDS_CHILD_PIDS[$i]}"
rc=0
wait "$pid" || rc=$?
if ((rc != 0)); then
printf 'CHILD-FAIL case=%s context=%s writer=%s pid=%s exit=%s\n' \
"${TEST_GUARDS_CURRENT_CASE:-unknown}" "$context" \
"${TEST_GUARDS_CHILD_LABELS[$i]}" "$pid" "$rc" >&2
status=1
fi
done
test_guards_children_reset
return "$status"
}

test_guards_exit_cleanup() {
test_guards_children_cleanup
rm -rf "$PM_GUARD_LOG_DIR" "${DISPATCH_TEST_BRIEF:-}" "${DISPATCH_TEST_BIN:-}" "${tmp_root:-}"
}
trap test_guards_exit_cleanup EXIT

# Every inject-memory call in this suite goes through one bounded wrapper. The
# hook is a prompt-path component, so a single fixture must never consume the
Expand Down Expand Up @@ -2145,9 +2243,11 @@ memory_usage_commit_concurrent_no_lost_updates() {
# the hook's persistence path turns the read-modify-write into a race that
# drops updates, so this test fails (the mutation is caught). Uses a private
# temp dir (no shared /tmp scanning) so the result is isolation-stable.
local name="memory-usage/concurrent-no-lost-updates" got n=25
local name="memory-usage/concurrent-no-lost-updates" got n=25 status=0
should_run "$name" || return 0
got="$(
trap test_guards_children_cleanup EXIT
test_guards_children_reset
# shellcheck disable=SC1091
. "$SCRIPT_DIR/lib/memory.sh"
# shellcheck disable=SC1091
Expand All @@ -2159,18 +2259,19 @@ memory_usage_commit_concurrent_no_lost_updates() {
for ((i = 0; i < n; i++)); do
# threshold high so decay never fires; one a.md hit per writer.
serialize_with_lock "$sc" memory_usage_commit "$sc" 1000000 100 a.md &
test_guards_child_track "$!" "writer=$((i + 1))"
done
wait
test_guards_children_wait "${TEST_GUARDS_CHILD_DEADLINE:-20}" "writers=25,lock=auto" || exit $?
awk -F'\t' '$1=="a.md"{print $2}' "$sc"
rm -rf "$d"
)"
if [[ "$got" == "$n" ]]; then
)" || status=$?
if [[ "$status" -eq 0 && "$got" == "$n" ]]; then
PASS=$((PASS+1))
[[ "${VERBOSE:-}" ]] && printf ' PASS %s\n' "$name"
else
FAIL=$((FAIL+1))
FAILED_CASES+=("$name")
printf ' FAIL %s — final access_count=%q want=%q\n' "$name" "$got" "$n"
printf ' FAIL %s — status=%s final access_count=%q want=%q\n' "$name" "$status" "$got" "$n"
fi
}

Expand All @@ -2196,18 +2297,30 @@ memory_usage_commit_contention_matrix() {
memory_usage_commit "$sidecar" 1000000 100 a.md
printf '%s finished\n' "$writer_id" >> "$trace"
}
local backend round writer_id d sidecar trace barrier got starts acquired finished exits ids_ok rc
local backend round writer_id d sidecar trace barrier barrier_fd got starts acquired finished exits ids_ok rc wait_status
for backend in flock mkdir; do
for round in $(seq 1 4); do
trap test_guards_children_cleanup EXIT
test_guards_children_reset
d="$(mktemp -d)"
sidecar="$d/inject-usage.tsv"
trace="$d/trace"
barrier="$d/start"
mkfifo "$barrier"
# Keep both ends open in the coordinator so releasing the barrier can
# never block if a writer exits before reading its token.
exec {barrier_fd}<>"$barrier"
for writer_id in $(seq 1 25); do
(
printf '%s started\n' "$writer_id" >> "$trace"
IFS= read -r < "$barrier"
printf '%s started pid=%s\n' "$writer_id" "$BASHPID" >> "$trace"
IFS= read -r <&"$barrier_fd"
if [[ "${TEST_GUARDS_HANG_WRITER:-}" == "$backend/$round/$writer_id" ]]; then
printf '%s injected-hang\n' "$writer_id" >> "$trace"
# The inherited descriptor is open read/write, so a second read
# blocks indefinitely without spawning a sleep child or spinning
# on EOF. The bounded lifecycle must signal this writer directly.
IFS= read -r <&"$barrier_fd"
fi
rc=0
if [[ "$backend" == "mkdir" ]]; then
FAKE_FLOCK_MISSING=1 serialize_with_lock "$sidecar" _cc477_commit "$sidecar" "$trace" "$writer_id" || rc=$?
Expand All @@ -2216,9 +2329,13 @@ memory_usage_commit_contention_matrix() {
fi
printf '%s exit=%s\n' "$writer_id" "$rc" >> "$trace"
) &
test_guards_child_track "$!" "backend=$backend,round=$round,writer=$writer_id"
done
for writer_id in $(seq 1 25); do printf 'go\n' > "$barrier"; done
wait
for writer_id in $(seq 1 25); do printf 'go\n' >&"$barrier_fd"; done
exec {barrier_fd}>&-
wait_status=0
test_guards_children_wait "${TEST_GUARDS_CHILD_DEADLINE:-20}" \
"backend=$backend,round=$round,lockdir=$sidecar.lockdir" || wait_status=$?
got="$(awk -F'\t' '$1=="a.md" {print $2}' "$sidecar" 2>/dev/null)"
starts="$(awk '$2=="started" {n++} END {print n+0}' "$trace")"
acquired="$(awk '$2=="acquired" {n++} END {print n+0}' "$trace")"
Expand All @@ -2234,6 +2351,11 @@ memory_usage_commit_contention_matrix() {
printf 'backend=%s round=%s count=%s started=%s acquired=%s finished=%s exit0=%s ids-ok=%s lockdir=%s\n' \
"$backend" "$round" "${got:-missing}" "$starts" "$acquired" "$finished" "$exits" "$ids_ok" \
"$([[ -e "$sidecar.lockdir" ]] && printf present || printf absent)"
if ((wait_status != 0)); then
printf 'bounded-wait-exit=%s trace=%s\n' "$wait_status" "$(tr '\n' ';' < "$trace")"
rm -rf "$d"
exit "$wait_status"
fi
rm -rf "$d"
done
done
Expand All @@ -2246,6 +2368,59 @@ memory_usage_commit_contention_matrix() {
fi
}

# Behavior: the bounded concurrency lifecycle identifies and removes a writer
# that deliberately refuses to exit instead of leaving the suite in `wait`.
# Steps: recursively run only the contention matrix with writer 7 hung in the
# first flock round, assert a bounded non-zero result and actionable PID label,
# then prove the reported child PID no longer exists.
memory_usage_hanging_writer_is_bounded_and_cleaned() {
local name="bounded-child/hanging-writer-is-diagnosed-and-cleaned" out status=0 pid started elapsed
should_run "$name" || return 0
started=$SECONDS
out="$(TEST_GUARDS_HANG_WRITER='flock/1/7' TEST_GUARDS_CHILD_DEADLINE=1 \
TEST_GUARDS_PROGRESS=1 timeout --kill-after=2s 10s \
bash "$SCRIPT_DIR/test-guards.sh" \
--filter 'memory-usage/contention-matrix-flock-and-mkdir-fallback' 2>&1)" || status=$?
elapsed=$((SECONDS - started))
pid="$(sed -n 's/.*writer=backend=flock,round=1,writer=7 pid=\([0-9][0-9]*\).*/\1/p' <<< "$out" | head -n 1)"
if [[ "$status" -ne 0 && "$status" -ne 124 && "$elapsed" -lt 10 \
&& "$out" == *'TIMEOUT test-guards case=memory-usage/contention-matrix-flock-and-mkdir-fallback'* \
&& "$out" == *'writer=backend=flock,round=1,writer=7'* \
&& -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
pass "$name"
else
fail "$name" "status=$status elapsed=${elapsed}s pid=${pid:-missing} out=$out"
fi
}

# Behavior: a registered child that exits nonzero is reported with its label,
# PID, and original exit code even though it does not consume the deadline.
# Steps: launch one child that exits 7, wait through the shared bounded helper,
# assert its deterministic CHILD-FAIL diagnostic and aggregate failure status,
# then prove the child PID was reaped.
memory_usage_nonzero_writer_is_reported_and_reaped() {
local name="bounded-child/nonzero-writer-is-reported-and-reaped" out pid
should_run "$name" || return 0
out="$(
exec 2>&1
trap test_guards_children_cleanup EXIT
test_guards_children_reset
(exit 7) &
child_pid=$!
test_guards_child_track "$child_pid" 'writer=nonzero-fixture'
wait_status=0
test_guards_children_wait 5 'fixture=nonzero-child' || wait_status=$?
printf 'WAIT-RESULT status=%s pid=%s\n' "$wait_status" "$child_pid"
)"
pid="$(sed -n 's/^WAIT-RESULT status=1 pid=\([0-9][0-9]*\)$/\1/p' <<< "$out")"
if [[ "$out" == *'CHILD-FAIL case=bounded-child/nonzero-writer-is-reported-and-reaped context=fixture=nonzero-child writer=writer=nonzero-fixture'* \
&& "$out" == *'exit=7'* && -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
pass "$name"
else
fail "$name" "pid=${pid:-missing} out=$out"
fi
}

memory_age_bucket_mapping() {
# Unit: age bucket boundaries map to 100/70/50/30/10.
local name="memory-usage/age-bucket-mapping" got want ok=1
Expand Down Expand Up @@ -2565,6 +2740,8 @@ inject_hook_priority_always_bypasses_lifecycle_gate
memory_usage_commit_decay_halves
memory_usage_commit_concurrent_no_lost_updates
memory_usage_commit_contention_matrix
memory_usage_hanging_writer_is_bounded_and_cleaned
memory_usage_nonzero_writer_is_reported_and_reaped
memory_age_bucket_mapping

# Episode reminder tests (CC-019 inject hook extension)
Expand Down Expand Up @@ -4047,20 +4224,24 @@ if should_run "pm-bash: concurrent first-write to a fresh audit log never trunca
# every decision line survives.
_concurrency_log_dir="$(mktemp -d)"
_n=10
test_guards_children_reset
for _i in $(seq 1 "$_n"); do
PM_GUARD_LOG_DIR="$_concurrency_log_dir" \
printf '{"agent_type":"project-pm","tool_name":"Bash","tool_input":{"command":"git status"}}' \
| PM_GUARD_LOG_DIR="$_concurrency_log_dir" "$PMBASHHOOK" >/dev/null 2>&1 &
test_guards_child_track "$!" "audit-writer=$_i"
done
wait
_wait_status=0
test_guards_children_wait "${TEST_GUARDS_CHILD_DEADLINE:-20}" \
"writers=$_n,audit-log=$_concurrency_log_dir/hooks.log" || _wait_status=$?
_lines="$(wc -l < "$_concurrency_log_dir/hooks.log" 2>/dev/null | tr -d ' ')"
if [[ "${_lines:-0}" -eq "$_n" ]]; then
if [[ "$_wait_status" -eq 0 && "${_lines:-0}" -eq "$_n" ]]; then
pass "$name"
else
fail "$name" "expected $_n audit lines after $_n concurrent first-writes, got ${_lines:-0}"
fail "$name" "wait_status=$_wait_status expected $_n audit lines after $_n concurrent first-writes, got ${_lines:-0}"
fi
rm -rf "$_concurrency_log_dir"
unset _concurrency_log_dir _n _i _lines
unset _concurrency_log_dir _n _i _lines _wait_status
fi

run_case "pm-bash: mkfs → deny" 2 "$PMBASHHOOK" \
Expand Down