Loop State Targets Retention Design¶
Status: Approved — implemented
Date: 2026-07-17
Trigger: state-ci-sweeper.json accumulates pull_request:* rejected entries with no TTL; run-log already prunes at 30 days. Domain ledger state-ci-sweeper-run-ledger.json already prunes but at 7 days — align to 30.
Problem¶
.loop/state-*.jsontargetsare upsert-only. Closed or rejectedpull_request:*keys (and reject metadata on watch targets) remain forever..loop/state-ci-sweeper-run-ledger.json(runskeyed byworkflow_run_id) already drops aged entries inupdate_run_ledger.sh, but the cutoff is 7 days, not the same 30-day window as run-log / the state policy below.
Goals¶
- Align retention with run-log: 30 days.
- Never lose watch-target cursors (
last_sha,pending) onintegration:*(and other non-PR watch keys). - Keep in-flight work: open /
pending/ non-terminal target outcomes untouched. - Apply the same TTL to domain persistence ledgers (ci-sweeper run ledger today; future ledgers follow the same rule).
- Prune on existing write paths (no new cron-only job).
Non-Goals¶
- Deleting
integration:*(or other watch-branch) target keys. - Immediate delete on REJECT/merge (history retained up to 30 days).
- Changing circuit-breaker threshold (
consecutive_failures >= 3). - Changing
CI_SWEEPER_REJECT_RETRY_POLICYsemantics (only retention window). - Sharing one prune helper across packages in this change (optional follow-up).
Scope (artifacts)¶
| Artifact | Path (dogfood) | Prune today | This design |
|---|---|---|---|
| Loop state | .loop/state-<loop>.json → targets |
none | add 30-day rules below |
| Run log | .loop/loop-run-log.md |
30 days | unchanged (reference) |
| CI sweeper run ledger | .loop/state-ci-sweeper-run-ledger.json → runs |
7 days (all entries) | 30 days (all settled entries) |
Decisions¶
Shared¶
| Topic | Choice |
|---|---|
| TTL | 30 days (same window as run-log) |
| Clock | ISO timestamp; compare date prefix YYYY-MM-DD or full ISO ≥ cutoff (ledger already uses full ISO) |
Loop state targets¶
| Topic | Choice |
|---|---|
Watch keys (integration:*, …) |
Never delete the key. Always keep last_sha and pending. |
| Watch keys after terminal REJECT | After 30 days: clear reject history fields and reset consecutive_failures to 0 (cooldown). Keep cursor fields. |
pull_request:* |
Terminal + last_run older than 30 days + no pending → delete key. |
| In-flight | Any key with pending, or non-terminal outcome → keep as-is. |
| When to prune | Each loop-state-write before commit. |
| Clock field | last_run; missing → do not prune that key. |
Run ledger runs (ci-sweeper)¶
| Topic | Choice |
|---|---|
| Key | workflow_run_id (immutable; no “open pending” object) |
| What to prune | Entries with updated_at older than 30 days — all ledger outcomes (pr-created, rejected, watch, no-action). Each run id is finished once recorded; TTL is pure dedupe retention. |
| When to prune | Existing path: every update_run_ledger.sh write (already prunes; only change cutoff 7 → 30). |
| Reject retry | After prune, a vanished rejected entry may be retried under block/limited as if unseen — acceptable cooldown after 30 days (same spirit as state reject-field reset). |
Terminal vs non-terminal (state targets only)¶
Terminal (eligible for prune after 30 days):
rejectedpr-closed
Non-terminal (never prune solely by age):
pr-created(especially withpending)watchno-opon watch keys — watch keys are never deleted anyway- Any entry with a present
pendingobject — never delete key regardless ofoutcome
Rules (normative)¶
State targets¶
for each key, entry in targets:
if entry.pending exists:
keep # in-flight
continue
age_ok = last_run present AND last_run date < (now - 30 days)
if key matches /^pull_request:/:
if outcome in {rejected, pr-closed} AND age_ok:
delete targets[key]
else:
keep
else: # watch / integration:* (and any non-pull_request key)
keep key always
if outcome in {rejected, pr-closed} AND age_ok:
clear last_reject_reason, open_rejections
set consecutive_failures = 0
# leave last_sha, last_run, outcome
Run ledger runs¶
cutoff = now - 30 days # was 7 days in update_run_ledger.sh
for each run_id, entry in runs:
if entry.updated_at < cutoff:
delete runs[run_id]
else:
keep
Implementation note: today jq already does
.runs |= with_entries(select(.value.updated_at >= $cutoff)) — change cutoff only; keep behavior.
Architecture¶
loop-state-write (each write)
load state JSON
→ prune_targets_by_retention(state) # new pure helper
→ apply advance/pending/metadata/…
→ commit/push
update_run_ledger.sh (each ledger update)
merge run entry
→ prune runs by updated_at >= cutoff (30d) # existing; retune cutoff
Prefer a pure function under .github/actions/loop-state-write/lib/ for targets prune, callable from bats without git.
Ledger prune stays inside loop-ci-sweeper scripts/update_run_ledger.sh (and its bats). Update skill references/category-run-ledger.md to say 30 days.
Error handling¶
- Invalid
targets/runsobject → skip prune for that file; do not fail the write solely for prune. - Unparseable timestamps → keep entry (fail-safe).
- Unknown state outcomes → keep (only listed terminal outcomes prune keys / reject fields).
Testing¶
State prune_targets¶
| Case | Expectation |
|---|---|
integration:main old rejected, no pending |
Key remains; reject fields cleared; consecutive_failures=0; last_sha unchanged |
integration:main with pending |
Unchanged |
pull_request:355 rejected, last_run 31 days ago |
Key deleted |
pull_request:401 rejected, last_run 5 days ago |
Key kept |
pull_request:N with pending |
Key kept even if outcome terminal |
pull_request:N pr-closed, aged |
Key deleted |
Missing last_run |
Key kept |
Ledger (update_run_ledger / bats)¶
| Case | Expectation |
|---|---|
Entry updated_at 8 days ago |
Kept after change (was deleted under 7-day rule) |
Entry updated_at 31 days ago |
Deleted |
Fresh rejected / pr-created |
Kept |
| Existing bats asserting 7-day cutoff | Update expectations to 30 days |
Documentation updates (implementation phase)¶
- Loop Engineering Design / multi-branch state: 30-day prune for terminal
pull_request:*; watch keys permanent with reject-field cooldown. - CI Sweeper Workflow Design +
category-run-ledger.md: ledger retention 30 days (was 7). - Header comment in
update_run_ledger.sh(“Prune entries older than 7 days” → 30).
Open follow-ups (out of scope)¶
- Shared
prune_cutoff_datehelper between run-log, state-write, and ledger. - Explicit
mergedoutcome name for state targets. - Other future domain ledgers: default to the same 30-day
updated_atprune unless they gain apendingconcept.