Skip to content

Scheduled refresh: bound the tagging step, say what it costs, and scan runs first - #12

Merged
mgoldey merged 2 commits into
mainfrom
feat/scheduled-refresh
Sep 17, 2026
Merged

mgoldey merged 2 commits into
mainfrom
feat/scheduled-refresh

Conversation

@mgoldey

@mgoldey mgoldey commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Implements docs/superpowers/specs/2026-08-23-scheduled-refresh-design.md,
which has sat at status: proposed since 2026-08-23, and flips it to
accepted with both open questions resolved in the document.

The failure it prevents was live

attest tag in the generated refresh script was unbounded. At the measured
2.3s/item a 1000-item backlog is a ~40-minute run against an hourly tick — so
with flock -n, every subsequent wakeup logs SKIP: previous run still holding lock, and the ingest half, the deterministic part that must succeed, never
runs either.
A slow best-effort step starving a fast mandatory one.

Three changes

Bound the tagging step. The script now calls attest tag --limit "$TAG_BUDGET", computed in-script from three named variables that each carry
their measurement in a comment rather than arriving as a bare integer:

TAG_SECONDS_PER_ITEM=2.3   # measured on this machine (see design spec)
TAG_BUDGET_MULTIPLIER=0.5  # leave the next tick a free lock even at full budget
TAG_INTERVAL_SECONDS=3600  # must match the cron schedule above

782 items at hourly — half the interval, so a run that hits its budget still
leaves the next tick a free lock. REFRESH_INTERVAL_SECONDS is new and
step_schedule uses it too, so the interval and the "17 * * * *" cron literal
cannot drift apart.

Say what a run will cost, before it runs. run_tagging printed nothing
until it finished. It now announces to stderr — deliberately, because the
refresh script redirects stdout to /dev/null, so stderr is the only place a
cron run's output survives.

The estimate is derived, not the constant. estimate_seconds_per_item reads a
50-row trailing window of item_features.tagged_at, takes consecutive deltas,
and excludes any delta more than 10x the median as a boundary between two
separate runs rather than a slow item. That exclusion is the whole trick: one
hour-long cron gap left in would swamp every genuine ~2s per-item delta.

MEASURED against the live database's 11,972 tagged rows: 2.558 s/item
close to but independent of the hardcoded 2.3, which is the point, since a 12B
model on a slower box is a different number. Edge cases verified: an injected
hour-long gap still yields 2.000; identical timestamps and a single row fall
back to the named constant rather than dividing by zero.

Scan runs first. attest runs scan is deterministic, needs no model, and
completes in ~1s on 1045 runs — yet it was absent from the refresh while both
model-dependent steps were present. It now runs first, ahead of ingest, gated on
RESEARCH_ROOT being set and pointing at something that exists.

The spec's "must succeed" and "skip when unconfigured" are reconciled by making
the gate the escape hatch rather than the exit code: unconfigured skips
silently, configured-and-broken is fatal exactly like ingest, because it is
exactly as deterministic and model-free.

Resolved open questions

  • Per-item estimate source. item_features.tagged_at deltas, no new schema.
    The spec proposed created_at, which does not exist — the real column is
    tagged_at TEXT NOT NULL DEFAULT (datetime('now')), and the wording is
    corrected in the document.
  • Prompting for RESEARCH_ROOT during install. Declined. ddd560b exists
    precisely because --check reported BROKEN over optional wiring a self-hoster
    never asked for; a prompt for an optional env var repeats that mistake in a
    different costume. Discoverability is still a real problem — a line in
    --check output would inform without nagging — but that is a separate change.

Verification

  • tests/test_install.py test_install_e2e.py test_install_skills.py test_features.py test_docs_site.py test_architecture.py169 passed, 1 skipped
  • Estimator checked against 11,972 live rows and six synthetic edge cases
  • ruff format, ruff check, ty, uv.lock, complexity ratchet, bandit,
    xenon all Passed; the full-suite pytest hook was killed by host memory
    pressure mid-run rather than by a failure, so the scoped run above stands in
  • tests/test_golden_paths.py::test_every_output_block_of_an_offline_path_is_real
    was reported as a pre-existing failure during development. It is not: 13
    passed
    on a clean tree and 13 passed with these changes applied. The
    original failure was host load (15–49 load average), which is exactly the trap
    docs/measurement-lessons.md warns about.

🤖 Generated with Claude Code

mgoldey and others added 2 commits September 17, 2026 07:46
…n runs first

Implements docs/superpowers/specs/2026-08-23-scheduled-refresh-design.md, which
has sat at `status: proposed` since 2026-08-23. The failure it names was live:
`attest tag` in the generated refresh script was UNBOUNDED, so at the measured
2.3s/item a 1000-item backlog is a ~40-minute run against an hourly tick. With
`flock -n`, every subsequent wakeup then logs "SKIP: previous run still holding
lock" -- and the ingest half, the deterministic part that must succeed, never
runs either. A slow best-effort step starving a fast mandatory one.

BOUND IT. The script now calls `attest tag --limit "$TAG_BUDGET"`, computed
in-script from three named shell variables that each carry their measurement in
a comment rather than arriving as a bare integer:

    TAG_SECONDS_PER_ITEM=2.3   TAG_BUDGET_MULTIPLIER=0.5   TAG_INTERVAL_SECONDS=3600

At hourly and 2.3s/item that is 782 items -- half the interval, so a run that
hits its budget still leaves the next tick a free lock. Untagged items are
picked up next pass, which is already how the script treats tag failures.
`REFRESH_INTERVAL_SECONDS` is new and `step_schedule` uses it too, so the
interval and the "17 * * * *" cron literal cannot drift apart.

SAY WHAT IT COSTS. `run_tagging` printed nothing until it finished, and a
40-minute silent command is the classic abandonment point. It now announces the
run to STDERR -- deliberately, because the refresh script redirects stdout to
/dev/null, so stderr is the only place a cron run's output survives.

The estimate is DERIVED, not the constant: `estimate_seconds_per_item` reads a
50-row trailing window of `item_features.tagged_at`, takes consecutive deltas,
and excludes any delta more than 10x the median as a boundary between two
separate RUNS rather than a slow item. That exclusion is the whole trick -- one
hour-long cron gap left in would swamp every genuine ~2s per-item delta.
MEASURED against the live database's 11,972 tagged rows: 2.558 s/item, close to
but independent of the hardcoded 2.3, which is the point (a 12B model on a
slower box is a different number). Edge cases verified: an injected hour-long
gap still yields 2.000, identical timestamps and a single row fall back to the
named constant rather than dividing by zero.

SCAN RUNS FIRST. `attest runs scan` is deterministic, needs no model, and
completes in ~1s on 1045 runs -- yet it was absent from the refresh while both
model-dependent steps were present. It now runs FIRST, ahead of ingest, so the
cheapest and most reliable step cannot be starved by anything after it, gated
on RESEARCH_ROOT being set and pointing at something that exists.

The spec's "must succeed" and "skip when unconfigured" are reconciled by making
the GATE the escape hatch rather than the exit code: unconfigured skips
silently, but configured-and-broken is fatal exactly like ingest, because it is
exactly as deterministic and model-free.

Spec flipped to `accepted` with both open questions resolved in the document.
The per-item estimate comes from `item_features.tagged_at` deltas and needs no
new schema -- the spec proposed `created_at`, which does not exist; the real
column is `tagged_at TEXT NOT NULL DEFAULT (datetime('now'))`, and the wording
is corrected. Prompting for RESEARCH_ROOT during install is DECLINED: commit
ddd560b exists precisely because `--check` reported BROKEN over optional wiring
a self-hoster never asked for, and a prompt for an optional env var repeats
that mistake in a different costume. Discoverability is still a real problem,
but a prompt is the wrong instrument for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sible

Three reviews independently called the run ledger the strongest thing in the
tool: it needs no model, completes in about a second on a real corpus, and is
the half of the two-tier promise the README leads with. It is also gated behind
an environment variable most users will never discover. Nothing in `attest
install --check` mentioned it, so a reader with a working feed had no way to
learn the capability existed.

    [skipped] ledger: set RESEARCH_ROOT to a directory of projects to enable
              `attest runs scan` (no model needed)

SKIPPED, and never BROKEN. This matters more than the wording. `--check` exits
nonzero iff some step is BROKEN, and ddd560b exists precisely because
`skill_copy` reported BROKEN over hermes-agent wiring a self-hoster never asked
for, forcing exit 1 on an install that was fine. An optional capability that
fails the doctor is that same mistake in a different costume -- so this line
informs and costs nothing: a user who only wants the feed still gets exit 0.

This is also why it is a report rather than the install-time prompt the
scheduled-refresh spec considered. That spec declined the prompt for the same
reason and recorded the decision; discoverability was the part it left open,
and naming the variable in a line that cannot nag is the instrument that fits.

Three states, each distinguishable so a reader can tell "never configured" from
"configured, now wrong":

    unset   -> [skipped] naming the variable and what it unlocks
    real    -> [ok] ledger: RESEARCH_ROOT=<path>
    missing -> [skipped] ledger: RESEARCH_ROOT=<path> does not exist

A stale RESEARCH_ROOT from a moved workspace is a stale setting, not a broken
install, so it is SKIPPED too.

Reuses `ledger.workspace_root()` rather than reading the variable again --
that function already carries the "deliberately no default" reasoning, and a
second resolution path could disagree with the one `attest runs scan` uses.
Placed after first_data, the other purely-local step, and before the
model-dependent ones, because it belongs with the tier that works when Ollama
does not. It takes no `check` parameter: there is nothing here that could
mutate anything, and accepting a flag it would ignore would imply otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mgoldey
mgoldey merged commit a954546 into main Sep 17, 2026
7 checks passed
@mgoldey
mgoldey deleted the feat/scheduled-refresh branch September 17, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant