Skip to content

Commit 890a661

Browse files
paddymulclaude
andcommitted
fix(#89): address review findings 1-4 in auto-recalc surfaces
1. Companion POST /api/promote_diff double-committed: it self-checkpoints but was not in _checkpoint_exempt, so the dispatch middleware checkpointed it a second time (a trailing empty revision) — the same bug already fixed on the MCP side. Exempt the route so the promote + cascade land in exactly one revision, mirroring catalog_promote_diff. 2. Cascade failures were persisted with a hard-coded tool="catalog_recalc" even when driven by the revise auto-path. Thread the triggering tool through auto_recalc -> _replay_cone and the MCP/companion head-advance helpers so the forensic record names the tool that actually ran (catalog_revise, catalog_promote_diff, companion_revise, companion_promote_diff). 3. auto_recalc scanned staleness twice (once for verdicts, once inside the cone walk). _replay_cone now accepts the caller's verdicts so a revise scans once. classify_orphans reads the error log and alias heads once up front (errors_by_hash + _alias_heads_index) instead of once per orphan. 4. Corrected the stale backfill comment in the MCP dispatch decorator: only catalog_revise reaches it; catalog_promote_diff is exempt and stamps its own checkpoint step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 45ab28e commit 890a661

5 files changed

Lines changed: 104 additions & 33 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -367,10 +367,11 @@ async def publish(event: dict):
367367
for q in list(subscribers):
368368
await q.put(event)
369369

370-
async def _auto_recalc_after_head_advance(project: str, name: str) -> dict | None:
370+
async def _auto_recalc_after_head_advance(project: str, name: str, *, tool: str) -> dict | None:
371371
"""Cascade-recompute *name*'s stale followers after an in-browser head
372372
advance (PUT /api/code, promote_diff), in the SAME revision the checkpoint
373-
middleware commits — the walk takes no checkpoint of its own. Returns the
373+
middleware commits — the walk takes no checkpoint of its own. ``tool`` names
374+
the route so a persisted cascade failure is attributed to it. Returns the
374375
recalc sub-report (plus ``orphan_stale``), or ``None`` when the per-project
375376
``auto_recalc`` switch is off. On a real cascade, invalidates the companion
376377
caches, reloads buckaroo sessions, and publishes the canonical recalc SSE
@@ -382,7 +383,7 @@ async def _auto_recalc_after_head_advance(project: str, name: str) -> dict | Non
382383

383384
if not await run_in_threadpool(auto_recalc_enabled, project):
384385
return None
385-
report = await run_in_threadpool(auto_recalc, project, name)
386+
report = await run_in_threadpool(auto_recalc, project, name, tool=tool)
386387
if report.get("remap"):
387388
_invalidate_reset_caches()
388389
if buckaroo:
@@ -414,6 +415,8 @@ def _checkpoint_exempt(method: str, path: str) -> bool:
414415
return True # reset moves `current`; it is not a new step
415416
if path.endswith("/api/recalc"):
416417
return True # recalc self-checkpoints (one tx for the whole walk; dry-run takes none)
418+
if "/api/promote_diff/" in path:
419+
return True # promote_diff self-checkpoints (the promote + cascade as one tx)
417420
return False
418421

419422
@app.middleware("http")
@@ -931,7 +934,11 @@ def _resolve(v: int):
931934

932935
# D5 parity: re-pointing an existing alias cascades to its followers in the
933936
# same revision (the walk runs before this route's checkpoint).
934-
recalc_report = await _auto_recalc_after_head_advance(project, target_alias) if repointed else None
937+
recalc_report = (
938+
await _auto_recalc_after_head_advance(project, target_alias, tool="companion_promote_diff")
939+
if repointed
940+
else None
941+
)
935942

936943
checkpoint_catalog(project, f"tallyman: catalog_promote_diff {alias} V{a_idx}→V{b_idx}")
937944
await publish({"kind": "entry_added", "hash": result.content_hash})
@@ -1176,7 +1183,7 @@ async def put_code(project: str, alias: str, payload: dict):
11761183
# followers in the SAME revision — the checkpoint middleware commits the
11771184
# head advance + the cascade as one (the walk takes no checkpoint of its
11781185
# own). Off → today's no-cascade behavior. Parity with catalog_revise.
1179-
rep = await _auto_recalc_after_head_advance(project, alias)
1186+
rep = await _auto_recalc_after_head_advance(project, alias, tool="companion_revise")
11801187
if rep is not None:
11811188
reply["recalc"] = rep
11821189
return reply

src/tallyman_core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
write_display_klass,
2222
)
2323
from tallyman_core.entry_config import carry_forward_entry_config
24-
from tallyman_core.errors import clear_errors, error_for_hash, get_error, list_errors, record_error
24+
from tallyman_core.errors import clear_errors, error_for_hash, errors_by_hash, get_error, list_errors, record_error
2525
from tallyman_core.fsutil import atomic_write_text
2626
from tallyman_core.manifest import Manifest, read_manifest, write_manifest
2727
from tallyman_core.marimo_export import export_notebook_path, notebook_to_marimo
@@ -103,6 +103,7 @@
103103
"clear_errors",
104104
"data_dir",
105105
"error_for_hash",
106+
"errors_by_hash",
106107
"read_config",
107108
"set_auto_recalc",
108109
"write_config",

src/tallyman_core/errors.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,30 @@ def clear_errors(project: str) -> int:
7373
return n
7474

7575

76+
def errors_by_hash(project: str) -> dict[str, dict]:
77+
"""Map each entry hash to its most-recent recorded error, in ONE pass over the log.
78+
79+
The batch form of ``error_for_hash`` for the orphan-stale tie-back, which
80+
classifies many stale entries at once: read ``errors.jsonl`` a single time and
81+
keep the freshest error per hash, instead of re-scanning the whole log once per
82+
entry (the classify pass is O(orphans) lookups).
83+
"""
84+
out: dict[str, dict] = {}
85+
for row in list_errors(project, limit=_UNBOUNDED): # most-recent first
86+
h = row.get("hash")
87+
if h is not None and h not in out: # first seen wins → freshest failure
88+
out[h] = row
89+
return out
90+
91+
7692
def error_for_hash(project: str, content_hash: str) -> dict | None:
7793
"""The most recent recorded error carrying ``hash == content_hash``, or None.
7894
7995
Powers the orphan-stale tie-back: an entry that is still stale because a prior
8096
recalc/build of it failed is "explained by" that recorded error, not an
8197
unexplained invariant break. Most-recent-first so the freshest failure wins.
8298
"""
83-
for row in list_errors(project, limit=_UNBOUNDED):
84-
if row.get("hash") == content_hash:
85-
return row
86-
return None
99+
return errors_by_hash(project).get(content_hash)
87100

88101

89102
def get_error(project: str, error_id: str) -> dict | None:

src/tallyman_mcp/server.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,13 @@ def wrapped(*args, **kwargs):
168168
project = _resolve_active_project()
169169
if project:
170170
step = checkpoint_catalog(project, f"tallyman: {fn.__name__}")
171-
# An auto-path recalc sub-report (revise/promote_diff) is built by
172-
# the checkpoint-free walk *before* this per-op checkpoint exists,
173-
# so its checkpoint_step is None. This is the checkpoint that
174-
# commits the head advance + cascade as one revision; backfill the
175-
# real landed step the report couldn't yet know.
171+
# catalog_revise's auto-path recalc sub-report is built by the
172+
# checkpoint-free walk *before* this per-op checkpoint exists, so its
173+
# checkpoint_step is None. This is the checkpoint that commits the head
174+
# advance + cascade as one revision; backfill the real landed step the
175+
# report couldn't yet know. (catalog_promote_diff is _NO_CHECKPOINT-
176+
# exempt and self-checkpoints, so it stamps its own step and never
177+
# reaches here.)
176178
recalc_report = result.get("recalc") if isinstance(result, dict) else None
177179
if isinstance(recalc_report, dict) and recalc_report.get("checkpoint_step") is None:
178180
recalc_report["checkpoint_step"] = step
@@ -213,10 +215,11 @@ def _notify(kind: str, content_hash: str | None = None, **extra) -> None:
213215
print(f"[tallyman_mcp] notify failed ({kind}): {exc}", file=sys.stderr)
214216

215217

216-
def _auto_recalc_after_head_advance(project: str, name: str) -> dict | None:
218+
def _auto_recalc_after_head_advance(project: str, name: str, *, tool: str) -> dict | None:
217219
"""Cascade-recompute *name*'s stale followers right after an existing alias
218220
head advanced — shared by every MCP path that re-points an existing alias
219-
(``catalog_revise``, ``catalog_promote_diff``).
221+
(``catalog_revise``, ``catalog_promote_diff``). ``tool`` names that surface so
222+
a persisted cascade failure is attributed to the tool that actually ran.
220223
221224
The walk is **checkpoint-free**: it leaves the re-pointed dependents in the
222225
working tree for the surrounding op's single checkpoint (the dispatch
@@ -230,7 +233,7 @@ def _auto_recalc_after_head_advance(project: str, name: str) -> dict | None:
230233
return None
231234
from tallyman_xorq.recalc import auto_recalc # noqa: PLC0415
232235

233-
report = auto_recalc(project, name)
236+
report = auto_recalc(project, name, tool=tool)
234237
if report.get("remap"):
235238
# Flat kwargs: _notify packs **extra into the wire `extra`, and the
236239
# companion handler reads extra.remap / extra.step. A literal extra={...}
@@ -621,7 +624,7 @@ def catalog_revise(name: str, code: str, prompt: str = "") -> dict:
621624
# Auto-recalc: cascade-recompute name's stale followers in this op's single
622625
# checkpoint (the dispatch decorator commits the head advance + the cascade as
623626
# one revision). Off → the explicit scan→recalc path. See plans/auto-recalc-on-revise.md.
624-
recalc_report = _auto_recalc_after_head_advance(project, name)
627+
recalc_report = _auto_recalc_after_head_advance(project, name, tool="catalog_revise")
625628
if recalc_report is not None:
626629
out["recalc"] = recalc_report
627630
return out
@@ -913,7 +916,9 @@ def _resolve(v):
913916
# helper. A fresh target_alias (expect_exists=False) has no followers; only
914917
# the re-point branch can. The checkpoint-free walk runs BEFORE this op's
915918
# checkpoint so that one revision sweeps the promote + the cascade together.
916-
recalc_report = _auto_recalc_after_head_advance(project, target_alias) if repointed else None
919+
recalc_report = (
920+
_auto_recalc_after_head_advance(project, target_alias, tool="catalog_promote_diff") if repointed else None
921+
)
917922

918923
# promote_diff is checkpoint-exempt (self-checkpoints): THIS is the single
919924
# revision the promote + cascade land in. Stamp its step onto the recalc

src/tallyman_xorq/recalc.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
from tallyman_core import aliases
4545
from tallyman_core.entry_config import carry_forward_entry_config
46-
from tallyman_core.errors import error_for_hash, record_error
46+
from tallyman_core.errors import errors_by_hash, record_error
4747
from tallyman_core.paths import entry_dir, entry_manifest_path, project_dir
4848
from tallyman_xorq.build import BuildError, build_and_persist
4949
from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone
@@ -169,7 +169,13 @@ def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcRe
169169
return report
170170

171171

172-
def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
172+
def _replay_cone(
173+
project: str,
174+
roots: list[str],
175+
*,
176+
verdicts: dict[str, StaleVerdict] | None = None,
177+
tool: str = "catalog_recalc",
178+
) -> RecalcReport:
173179
"""Replay *roots* and their cone in dependency order, **without** taking a
174180
checkpoint — the reusable engine behind both the public ``recalc`` and the
175181
auto-recalc folded into ``catalog_revise``.
@@ -182,14 +188,20 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
182188
for the caller's single checkpoint to commit. Returns a report with
183189
``checkpoint_step=None`` — the *caller* owns the one checkpoint (``recalc``
184190
for an explicit run; the per-op decorator/middleware for an embedded one).
191+
192+
``verdicts`` lets a caller that already scanned (``auto_recalc``) hand them in
193+
so the walk doesn't re-scan; ``tool`` names the surface that triggered the
194+
walk, recorded on each persisted failure so the forensic record names the tool
195+
that actually ran (not always ``catalog_recalc``).
185196
"""
186197
roots = [r for r in roots if entry_manifest_path(project, r).exists()]
187198
try:
188199
cone = descendant_cone(project, roots)
189200
except DependencyCycleError as exc:
190201
return RecalcReport(project, roots, [], [], False, "cycle", error=str(exc))
191202

192-
verdicts = scan(project)
203+
if verdicts is None:
204+
verdicts = scan(project)
193205
remap: dict[str, str] = {}
194206
entries: list[RecalcEntry] = []
195207
status = "ok"
@@ -205,7 +217,7 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
205217
project,
206218
code=_recipe_code(project, old_hash),
207219
message="skipped: an upstream build failure halted the recalc walk",
208-
tool="catalog_recalc",
220+
tool=tool,
209221
hash=old_hash,
210222
)
211223
entries.append(_entry(old_hash, "skipped", verdict, project))
@@ -222,7 +234,7 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
222234
project,
223235
code=_recipe_code(project, old_hash),
224236
message=str(exc),
225-
tool="catalog_recalc",
237+
tool=tool,
226238
hash=old_hash,
227239
)
228240
entries.append(
@@ -272,22 +284,41 @@ def followers_of(project: str, name: str, verdicts: dict[str, StaleVerdict] | No
272284
)
273285

274286

275-
def _classify_orphan(project: str, content_hash: str, verdict: StaleVerdict) -> dict:
287+
def _alias_heads_index(project: str) -> dict[str, list[str]]:
288+
"""``{content_hash: [alias, ...]}`` over the current alias heads, built once.
289+
290+
The reverse of ``_aliases_heading`` for a batch caller (``classify_orphans``)
291+
so classifying N orphans loads ``aliases.jsonl`` a single time, not N times."""
292+
index: dict[str, list[str]] = {}
293+
for name, latest in aliases.load_aliases(project).items():
294+
index.setdefault(latest, []).append(name)
295+
for names in index.values():
296+
names.sort()
297+
return index
298+
299+
300+
def _classify_orphan(
301+
content_hash: str,
302+
verdict: StaleVerdict,
303+
errors_index: dict[str, dict],
304+
alias_index: dict[str, list[str]],
305+
) -> dict:
276306
"""Classify a stale entry an auto-recalc did *not* recompute: a self-referential
277307
pin (expected), explained by a recorded failure, or UNEXPLAINED.
278308
279309
Self-reference is checked first and only when it is the SOLE cause (every reason
280310
is the entry following its own head): an entry that is also source- or
281-
alias-drifted for a real reason still classifies normally."""
311+
alias-drifted for a real reason still classifies normally. ``errors_index`` /
312+
``alias_index`` are the prebuilt one-pass lookups (see ``classify_orphans``)."""
282313
if verdict.reasons and all(_is_self_ref(content_hash, r) for r in verdict.reasons):
283314
explanation = "self-referential revise pin (follows its own alias head); permanently stale by design — expected"
284-
elif (err := error_for_hash(project, content_hash)) is not None:
315+
elif (err := errors_index.get(content_hash)) is not None:
285316
explanation = f"explained by error {err['id']}"
286317
else:
287318
explanation = "UNEXPLAINED stale entry — staleness with no recorded recalc error; file a bug."
288319
return {
289320
"hash": content_hash,
290-
"alias": (_aliases_heading(project, content_hash) or [None])[0],
321+
"alias": (alias_index.get(content_hash) or [None])[0],
291322
"reasons": [asdict(r) for r in verdict.reasons],
292323
"explanation": explanation,
293324
}
@@ -303,12 +334,20 @@ def classify_orphans(
303334
rebuilt as *recomputed*, so only the leftovers are classified. The
304335
scan-on-load surfaces pass nothing: at scan time no cascade ran, so every
305336
directly-stale entry is an orphan to be accounted for (the D5 backstop —
306-
a project load surfaces orphans even when no revise has happened since)."""
337+
a project load surfaces orphans even when no revise has happened since).
338+
339+
The error store and alias heads are read once up front (``errors_by_hash`` /
340+
``_alias_heads_index``) and shared across all orphans, so the pass is one log
341+
read + one aliases read regardless of orphan count."""
307342
orphans = sorted(h for h, v in verdicts.items() if v.stale and h not in recomputed)
308-
return [_classify_orphan(project, h, verdicts[h]) for h in orphans]
343+
if not orphans:
344+
return []
345+
errors_index = errors_by_hash(project)
346+
alias_index = _alias_heads_index(project)
347+
return [_classify_orphan(h, verdicts[h], errors_index, alias_index) for h in orphans]
309348

310349

311-
def auto_recalc(project: str, name: str) -> dict:
350+
def auto_recalc(project: str, name: str, *, tool: str = "catalog_revise") -> dict:
312351
"""Recompute the followers a revise of alias *name* made stale, **without**
313352
taking a checkpoint, and account for every other stale entry it did not touch.
314353
@@ -318,14 +357,20 @@ def auto_recalc(project: str, name: str) -> dict:
318357
yet recalced, or an invariant break) — left untouched, logged at WARNING, and
319358
returned in ``orphan_stale`` classified against the error store (#6).
320359
360+
``tool`` names the surface that triggered this auto-recalc (``catalog_revise``,
361+
``catalog_promote_diff``, the companion routes); it is recorded on any persisted
362+
cascade failure so the forensic record names the tool that actually ran.
363+
321364
Returns the recalc report (``catalog_recalc``'s shape) plus ``orphan_stale``.
322365
The caller embeds this in its own transaction: it owns the single checkpoint
323366
and the cross-process notify / SSE publish.
324367
"""
325368
verdicts = scan(project)
326369
roots = followers_of(project, name, verdicts)
327370

328-
report = _replay_cone(project, roots) # checkpoint-free; caller commits
371+
# Reuse the verdicts just scanned (one scan per revise, not two) and tag any
372+
# persisted failure with the triggering tool.
373+
report = _replay_cone(project, roots, verdicts=verdicts, tool=tool) # checkpoint-free; caller commits
329374

330375
orphan_stale = classify_orphans(project, verdicts, recomputed=frozenset(report.cone))
331376
for o in orphan_stale:

0 commit comments

Comments
 (0)