diff --git a/.agents/skills/marketing-loops/references/loop-state.md b/.agents/skills/marketing-loops/references/loop-state.md new file mode 100644 index 0000000..c36a7b8 --- /dev/null +++ b/.agents/skills/marketing-loops/references/loop-state.md @@ -0,0 +1,67 @@ +# Loop State & Run Logging + +Idempotency is only real if the loop can remember what it already did between runs. This reference defines where that state lives and how to log runs — so loops don't double-act, re-nag the same people, or re-alert the same issue. + +## Where state lives + +Persist each loop's state in a file under `.agents/loops/` — the same `.agents/` convention this repo uses for `product-marketing.md` and `listening-sources.md`. One state file per loop: + +``` +.agents/loops/.json # the loop's memory +.agents/loops/.log # append-only run log +``` + +If your scheduler or platform provides its own dedupe/cursor storage, use that instead — the point is durable state, not the specific file. Never keep state only in memory; a loop that forgets on restart will repeat itself. + +## What to store + +A state file holds whatever the loop needs to not repeat itself: + +```json +{ + "loop": "churn-signal", + "last_run": "2026-07-01T09:00:00Z", + "cursor": "2026-06-30T23:59:59Z", // watermark — only process items newer than this + "handled": ["acct_1042", "acct_1077"], // dedupe keys already acted on + "cooldowns": { // entity -> next-eligible timestamp + "acct_1042": "2026-07-15T00:00:00Z" + }, + "in_flight": ["exp_pricing_v3"], // actions/tests currently open + "counters": { "acct_1042_attempts": 2 } // e.g. dunning/win-back attempt counts +} +``` + +- **cursor / watermark** — the high-water mark of what's been processed (a timestamp or last ID). The loop only looks at items past it. +- **handled** — dedupe keys for items already acted on, so re-runs skip them. +- **cooldowns** — per-entity suppression windows so you never re-contact someone inside the window. +- **in_flight** — open items (running tests, active interventions) so the loop doesn't start a conflicting one. +- **counters** — attempt counts that drive stop conditions (e.g., "after 2 win-back emails, stop"). + +Keep state small and prune it: expire old `handled`/`cooldown` entries once they're past their window. + +## Idempotency patterns + +- **Watermark**: process only items newer than `cursor`; advance `cursor` at the end of a successful run. Safe to re-run — it won't reprocess. +- **Dedupe set**: before acting on an item, check its key against `handled`; add it after acting. +- **Cooldown map**: before contacting an entity, check `cooldowns[entity]`; set it after contact. +- **In-flight guard**: before starting an action that shouldn't overlap (a test, an intervention), check `in_flight`. + +## Run logging + +Append one line per run, whether or not it acted. This is the audit trail and the vanity-loop detector. + +``` +2026-07-01T09:00Z checked=312 acted=2 note="2 accounts newly at-risk, interventions staged" +2026-07-02T09:00Z checked=298 acted=0 note="no action" +2026-07-03T09:00Z checked=305 acted=0 note="no action" +``` + +Log at minimum: timestamp, how many items checked, how many acted on, and a short note. Use it to answer two questions: +- **Is it a vanity loop?** If every run is `acted=0` for weeks and nobody misses it — or it acts every run (a sign it's chasing noise) — reconsider it. +- **Did it double-act?** Two runs acting on the same entity means the dedupe/cooldown state isn't working. + +## Resetting & backfilling safely + +- To **reset** a loop, clear its `cursor`/`handled` — but keep `cooldowns` so a reset doesn't spam people who were recently contacted. +- On **first run** (no state yet), set the watermark to "now" rather than processing all history, or you'll blast every historical item. If you genuinely want a backfill, do a dry run first (log what it *would* do, act on nothing) and respect cooldowns. +- Never log raw PII in state or run logs — use IDs or hashes (see `loop-guardrails.md`).