Skip to content

feat(#89): reactive recalc (stage 2/3) — cone walk, replay, alias re-point + surfaces - #128

Merged
paddymul merged 2 commits into
mainfrom
feat/89-reactive-recalc
Jun 19, 2026
Merged

feat(#89): reactive recalc (stage 2/3) — cone walk, replay, alias re-point + surfaces#128
paddymul merged 2 commits into
mainfrom
feat/89-reactive-recalc

Conversation

@paddymul

Copy link
Copy Markdown
Contributor

The second landable increment of the reactive consumer (epic #89): the half
that acts. Stage 1 (PR #126) flags an entry as stale relative to its recorded
inputs but never recomputes; this walks the dependency cone and rebuilds it. The
hard prerequisites it rested on are all merged — #115 (cold-read content
faithfulness), #124 (#80/#96 LRU invalidation on reset), #121 (#88 determinism
attribution).

Design + mechanism: plans/recalc-mechanism.md (new), against
plans/reactive-staleness-recalc.md (Stage 2/3) and the ADR (#51).

What lands

  • tallyman_xorq/recalc.pyrecalc(project, roots, *, dry_run) walks the
    roots' topologically ordered descendant_cone and replays each entry's recipe
    through build_and_persist (the primitive catalog_revise already uses). A
    followed parent re-resolves to the advanced alias head (io.from_catalog
    get_alias), so re-pointing a parent's alias before its children replay
    propagates the change down the cone. The walk is idempotent: an unchanged input
    replays to the same content_hash and build_and_persist early-returns, so an
    untouched node — and a hash-pinned (follow=False) child, whose literal-hash
    recipe re-resolves to the same parent — is a no-op with no alias move.
  • MCP surfacescatalog_scan_staleness (read-only) and catalog_recalc
    (dry_run defaults true; roots=None defaults to the directly-stale set).
  • Companion surfacesGET /{project}/api/staleness (the scan-on-load
    surface) and POST /{project}/api/recalc (preview + commit), which invalidates
    the companion caches, reloads buckaroo sessions, and publishes a recalc event.

Cascade failure + transaction

Stop-and-report (the plan's default): a build error halts the walk, leaves the
already-rebuilt prefix committed, and is flagged in the report rather than raised.
One checkpoint per walk (only when something changed), so a recalc is a single
revision reset_to undoes atomically. Checkpointing is arg-aware — recalc
self-checkpoints and both surfaces are exempt from their generic per-op checkpoint
— so a dry run takes zero revisions and a real run exactly one (neither
double-commits nor misses).

TDD

Red→green. First commit is the failing tests (the recalc module and the new
tools/routes are absent, so collection errors); the fix commit follows. Full
suite green locally: 566 passed.

Decision defaults exercised (confirm)

  • Trigger — scan-on-load (passive flag) + explicit recalc; no
    auto-cascade-on-revise.
  • Cascade failure — stop-and-report.
  • off-mode source axisunknown (Stage 1).
  • Pinned child of an advanced alias — stays on its pin (replay is a no-op).

Note: the cheap-chain source leak

A cheap child inlines its parent's recipe, so the parent's source is recorded in
the child's own manifest.sources. A source edit therefore makes such a child
directly stale, not merely transitively. Expected (the #74 reconstruction
model); recalc rebuilds it either way. The pure transitive case shows up across an
expensive, snapshot-baked intermediate. Tests cover both.

Not in this PR

The React badge/affordance rendering (the backend /api/staleness data + entry
annotation are ready for it), and deletion-side semantics (archive / delete cone /
multi-parent unbind — ADR Q3/Q4).

🤖 Generated with Claude Code

paddymul and others added 2 commits June 18, 2026 16:40
Cone-walk recompute (tallyman_xorq.recalc) and its MCP/companion surfaces,
test-first. Red until the implementation lands: test_recalc imports the
absent recalc module, test_recalc_surfaces imports the not-yet-added
catalog_recalc / catalog_scan_staleness tools and /api/recalc, /api/staleness
routes.

Covers: dry-run preview builds nothing; a source-drifted root rebuilds and the
cone re-points; a followed-alias advance cascades to its follower; pinned
children hold; nothing-stale is a no-op; a build failure stops the walk with the
prefix committed; a cycle is reported not hung; the surfaces are read-only/one-
revision as designed.

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

Stage 2/3 of the reactive consumer. Stage 1 (staleness detection, PR #126)
flags an entry as stale relative to its recorded inputs but never acts; this is
the half that acts.

recalc.py: recalc(project, roots, *, dry_run) walks the roots' topologically
ordered descendant cone and replays each entry's recipe through
build_and_persist. A followed parent re-resolves to the advanced alias head
(get_alias), so re-pointing a parent's alias before its children replay
propagates the change down the cone; an unchanged input replays to the same
content_hash and build_and_persist early-returns, so the walk is idempotent and
a hash-pinned child is a no-op. Cascade-failure policy is stop-and-report: a
build error halts the walk, leaves the rebuilt prefix committed, and is flagged
in the report rather than raised. One checkpoint per walk (only when something
changed), so a recalc is a single revision reset_to undoes atomically.

Surfaces (thin; logic stays in recalc.py / staleness.py):
- MCP: catalog_scan_staleness (read-only) and catalog_recalc (dry_run defaults
  true). Both in _NO_CHECKPOINT — recalc self-checkpoints, arg-aware.
- Companion: GET /{project}/api/staleness (scan-on-load) and
  POST /{project}/api/recalc (preview + commit), the recalc path exempt from the
  checkpoint middleware so the op is exactly one revision.

plans/recalc-mechanism.md documents the mechanism, the idempotence argument, the
cheap-chain source-leak nuance, and the decision-gate defaults
(stop-and-report; scan-on-load + explicit recalc; off-mode=unknown; pin stays).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@paddymul
paddymul merged commit 9617ed6 into main Jun 19, 2026
4 checks passed
paddymul added a commit that referenced this pull request Jun 22, 2026
…lc SSE shape, guard manifest-less roots (#128)

Three review findings on PR #128:

1. /internal/notify now handles kind="recalc" like project_reset — invalidate
   the result/compare LRUs and reload buckaroo sessions. An MCP-driven recalc
   (the primary path) arrives via _notify, so without this the companion kept
   serving stale viewer state that only the in-process /api/recalc route cleaned
   up (#80's communication-gap class).

2. Both recalc emitters publish through a single _recalc_sse_event(remap, step)
   helper, so the SSE event is the same {kind, remap, step} shape regardless of
   surface. The MCP _notify now forwards report.checkpoint_step too; the notify
   handler republishes normalized instead of the raw payload.model_dump() that
   buried remap under extra and dropped step.

3. recalc()'s root guard checks the manifest, not the directory. verdicts (and
   the cone) are keyed by manifest-bearing entries, so a dir-without-manifest
   root (a crashed mid-build leftover) used to enter the cone and KeyError the
   walk at verdicts[hash]; it is now dropped and the run reports clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 22, 2026
fix(#89): recalc review fixes (#128) — notify reload, SSE shape, manifest-less root guard
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