Skip to content

Fresh start runbook

ops/capture/fresh_start.py is a one-off, owner-run script (planner spec §4.1) that clears the stale capture backlog in a single pass: every cap_items row still stuck in raw/triage older than 30 days, and every open/snoozed reply_owed/commitment follow-up whose source is older than 30 days, except the ~20 most important follow-ups, which stay open for review. Nothing is deleted — items and follow-ups move to state='archived', which stays searchable and excluded from the active views.

It is a single stdlib file (like ops/brain-routine/apply_backlog_triage.py) so it can run from the host with no repo checkout: scp it to the box, or pipe it over ssh ... python3 - < fresh_start.py from a shell that has it locally.

Prerequisite

The archived-state migrations must already be deployed before running this script: modules/capture/schema/pb_migrations/16_cap_items_archived.js (adds archived to cap_items.state) and modules/conversation/schema/pb_migrations/16_followups_archived.js (adds archived to followups.state), plus the follow-up cron change that makes it treat archived like done — skip it, never re-open it (conversation_agent/followups.py, the state in ("done", "archived") / state in ("skipped", "archived") checks). Running this script against a box still on the old cron will have the very next hourly pass flip archived follow-ups back to open/done, undoing the cleanup silently.

Rebuild and install the iOS/macOS app from this branch first, too. An older app build filters follow-ups only on state != 'done' and maps any state it does not know to open, so every follow-up this script archives would reappear in the app as open work.

Post-merge order

  1. Box deploy — automatic when the branch merges to main (migrations and the follow-up cron land together).
  2. App rebuild — build and install the iOS and macOS apps from main.
  3. Set "Your names in group chats" in Settings › Follow-up rules, so the group-thread rule knows when you are addressed.
  4. Fresh start dry run, review the counts.
  5. Fresh start --apply.
  6. Entity-name cleanup dry run (see the last section below), review the counts.
  7. Entity-name cleanup --apply, then copy its undo file out of the container.

Commands

Copy the script to a durable directory on the box first — /tmp can be cleared by the OS or a reboot, which would strand an undo file mid-review:

ssh mailserver 'mkdir -p /root/fresh-start'
scp ops/capture/fresh_start.py mailserver:/root/fresh-start/

# dry run — no writes, prints counts only
ssh mailserver 'cd /root/fresh-start && python3 fresh_start.py --env-file /opt/jarvis/.env'

# apply — writes the undo file FIRST, then archives (default undo path is
# ./fresh-start-undo-<timestamp>.jsonl in the cwd above; pass --undo-file
# only to name it explicitly, and never point it at a path that already exists)
ssh mailserver 'cd /root/fresh-start && python3 fresh_start.py --apply --env-file /opt/jarvis/.env'

# undo — restores every row the apply touched (only rows still archived with
# a matching tag; anything the pipeline already touched again is skipped)
ssh mailserver 'cd /root/fresh-start && python3 fresh_start.py --undo fresh-start-undo-<timestamp>.jsonl --env-file /opt/jarvis/.env'

--days (default 30) and --keep (default 20) override the cutoff and the kept-follow-up count if a different pass is ever needed. --env-file reads only DEV_USER_EMAIL / DEV_USER_PASSWORD out of /opt/jarvis/.env — never shell-source that file directly (see brain-routine.md for why).

If --apply refuses with "undo file already exists" (exit code 2), it means a previous pass's undo file is still sitting at that path — nothing was written or patched. Either restore/clear the old one first (once you are sure it's no longer needed) or pass a different --undo-file; never delete an undo file you have not yet reviewed.

Expected dry-run output

Counts only — never names, handles, titles or bodies:

{"mode": "dry-run", "items": 42, "items_by_state": {"raw": 30, "triage": 12},
 "followups_archive": 18, "followups_keep": 20}
dry-run: nothing written.

An --apply run prints the same shape with "mode": "apply", followed by the tally line (items_archived, followups_archived, failed, and the undo_file path it wrote to).

The fresh start does not apply the keep-list. It archives every raw/triage cap_items row older than the cutoff, including invoices, receipts and bank-sender mail that a routing pass never picked up — those rows are not exempted. They stay searchable in archived state. --undo replays a whole undo file, so to bring back a single item, copy its line (find it by id) from the undo JSONL into a new file and run --undo on that new file.

The undo file

--apply writes the undo JSONL in full before the first PATCH (using "create, don't overwrite" semantics — it refuses to touch a path that already exists, so a re-run after a partial failure can never clobber the only undo for the first pass). It defaults to ./fresh-start-undo-<timestamp>.jsonl unless --undo-file names a path. Keep it until the owner is confident the pass was correct — --undo needs the exact file it wrote, and it only restores a row that is still exactly where the fresh start left it (state archived, and — for cap_items — the same archived_by tag); anything the pipeline already re-routed or a later pass already touched is left alone and counted as skipped.

After a retry (an --apply that refused an existing path and was re-run with a different --undo-file, or a second pass) there can be several undo files. Each covers only the rows its own run touched: replay each one you want reverted, one --undo per file.

Each line is one JSON object holding the fields needed to restore the row: collection, id, state, and — for cap_items — the pre-patch links dict plus the run's tag. Treat this file as sensitive. The links dict is whatever the row already carried — project/identity slugs (which can be client or contact names), a person entity id, and a free-text rationale from the original routing decision can all appear in it. It never holds a message's title or body (those aren't part of links), but it is not just harmless ids — delete it once the owner has confirmed the pass was correct and no longer needs the undo.

After the fresh start: entity name cleanup

Once the backlog is archived, the owner runs the companion one-off inside the running scheduler container (jarvis-scheduler, see compose.yaml) to normalize any entity display names left over from before the 2026-08-27 RFC 2047 fix:

ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.clean_entity_names'                 # dry run
ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.clean_entity_names --apply'          # writes ./entity-names-undo-<ts>.jsonl inside the container
ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.clean_entity_names --undo PATH'      # restore from an undo file

Run the dry run first, review the counts, then --apply. The scheduler container only mounts the vault volume — there is no separate persistent data directory to point --undo-file at — so immediately after --apply, copy the undo file out to the host before it can be lost to a container restart or rebuild:

ssh mailserver 'docker cp jarvis-scheduler:/app/entity-names-undo-<ts>.jsonl /root/fresh-start/'

Restore with --undo PATH the same way as fresh_start.py, after copying the file back into the container (docker cp /root/fresh-start/<file> jarvis-scheduler:/app/).

Noise backfill

The deterministic noise gate (capture_agent.noise.classify, planner slice 1) only runs at ingest, on newly polled email. It never swept the raw/triage backlog that predates it. Run this companion one-off, also inside jarvis-scheduler, to apply the same rules to that backlog and archive whatever matches. Run it right after a brain-routine pass, ideally in a quiet window: the backfill re-checks each row right before writing it and skips anything the brain routine already routed or skipped out from under it, but a quiet window keeps the skipped count low and the sweep more useful:

ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.noise_backfill'                 # dry run
ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.noise_backfill --apply'          # writes ./noise-backfill-undo-<ts>.jsonl inside the container
ssh mailserver 'docker exec jarvis-scheduler python -m capture_agent.noise_backfill --undo PATH'      # restore from an undo file

Run the dry run first and review by_rule before --apply. As with the entity name cleanup, copy the undo file out of the container immediately after --apply:

ssh mailserver 'docker cp jarvis-scheduler:/app/noise-backfill-undo-<ts>.jsonl /root/fresh-start/'

Only sender, text, and attachment rules can fire here. signal rules (newsletter/auto-submitted/bulk) key off email headers, and stored cap_items never carry those headers — the dry-run and --apply summaries both say so via signal_rules_skipped: true, so a lower archive count than expected here is expected, not a bug.

Treat this undo file as sensitive, too. Like the entity-name-cleanup undo, each line holds the row's previous links dict — project/identity slugs and routing rationale that can name a client or contact — not just a bare id. Delete it once the pass is confirmed correct and no longer needed.

Restore with --undo PATH the same way as fresh_start.py, after copying the file back into the container (docker cp /root/fresh-start/<file> jarvis-scheduler:/app/).