Skip to content

Commit ddc9339

Browse files
paddymulclaude
andcommitted
fix(#89): wire MCP recalc into companion invalidation, normalize recalc 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>
1 parent 69a4bb4 commit ddc9339

3 files changed

Lines changed: 53 additions & 14 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,19 @@ def _invalidate_reset_caches() -> None:
234234
cached_result_expr.cache_clear()
235235

236236

237+
def _recalc_sse_event(remap: dict, step: int | None) -> dict:
238+
"""The recalc SSE event, in one canonical shape: ``{kind, remap, step}``.
239+
240+
Both recalc emitters publish through this — the in-process ``/api/recalc``
241+
route and the cross-process ``/internal/notify`` path an MCP-driven recalc
242+
arrives on — so the event a frontend sees can't depend on which surface
243+
triggered it (the two used to drift: the notify path republished the raw
244+
``payload.model_dump()``, burying ``remap`` under ``extra`` and dropping
245+
``step``).
246+
"""
247+
return {"kind": "recalc", "remap": remap, "step": step}
248+
249+
237250
def _annotate_entries(project: str, entries: list[dict]) -> list[dict]:
238251
"""Add alias/version fields and sort: named entries (by alias) first, then scratch."""
239252
aliases = load_aliases(project)
@@ -1220,7 +1233,7 @@ async def api_recalc(project: str, payload: dict | None = None):
12201233
_invalidate_reset_caches()
12211234
if buckaroo:
12221235
buckaroo.reload_project_sessions(project)
1223-
await publish({"kind": "recalc", "remap": report.remap, "step": report.checkpoint_step})
1236+
await publish(_recalc_sse_event(report.remap, report.checkpoint_step))
12241237
return asdict(report)
12251238

12261239
# ------------------------------------------------------------------
@@ -1239,18 +1252,37 @@ async def notify(payload: NotifyPayload):
12391252
payload.kind,
12401253
getattr(payload, "hash", None),
12411254
)
1242-
if payload.kind in ("post_processing_changed", "summary_stat_changed", "display_changed", "project_reset"):
1243-
if payload.kind == "project_reset":
1244-
# Out-of-process CLI `reset-to` ran reset_to in the CLI process, so
1245-
# this long-lived companion's result/compare LRUs are still warm and
1246-
# may point at snapshots the prune deleted. Clear them here, matching
1247-
# the in-server /api/reset path (#80). Not gated on `buckaroo`: the
1248-
# LRUs are process memory independent of the buckaroo subprocess.
1255+
if payload.kind in (
1256+
"post_processing_changed",
1257+
"summary_stat_changed",
1258+
"display_changed",
1259+
"project_reset",
1260+
"recalc",
1261+
):
1262+
if payload.kind in ("project_reset", "recalc"):
1263+
# project_reset: out-of-process CLI `reset-to` ran reset_to in the
1264+
# CLI process, so this long-lived companion's result/compare LRUs
1265+
# are still warm and may point at snapshots the prune deleted.
1266+
# recalc: an MCP-driven catalog_recalc (arriving here via _notify,
1267+
# not the in-process /api/recalc route) re-pointed aliases and
1268+
# rebuilt entries. Either way, clear the LRUs and reload sessions —
1269+
# the same cleanup /api/recalc and /api/reset do — so the MCP path
1270+
# doesn't leave the viewer serving stale state (#80's communication
1271+
# gap). Not gated on `buckaroo`: the LRUs are process memory
1272+
# independent of the buckaroo subprocess.
12491273
_invalidate_reset_caches()
12501274
if buckaroo:
12511275
reloaded = buckaroo.reload_project_sessions(project_name)
12521276
log.info("reloaded klasses in %d buckaroo session(s) for project %s", reloaded, project_name)
1253-
await publish(payload.model_dump())
1277+
# Republish a recalc in the canonical {kind, remap, step} shape so the
1278+
# cross-process notify path emits the identical event /api/recalc does;
1279+
# other kinds pass through their raw payload unchanged.
1280+
if payload.kind == "recalc":
1281+
extra = payload.extra or {}
1282+
event = _recalc_sse_event(extra.get("remap", {}), extra.get("step"))
1283+
else:
1284+
event = payload.model_dump()
1285+
await publish(event)
12541286
return {"ok": True, "subscribers": len(subscribers), "project": project_name}
12551287

12561288
@app.get("/api/projects")

src/tallyman_mcp/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,10 @@ def catalog_recalc(roots: list[str] | None = None, dry_run: bool = True) -> dict
941941
}
942942
report = recalc(project, roots, dry_run=dry_run)
943943
if not dry_run and report.remap:
944-
_notify("recalc", extra={"remap": report.remap})
944+
# Forward both the remap and the checkpoint step so the companion can
945+
# republish the same normalized {kind, remap, step} SSE event the
946+
# in-process /api/recalc route emits (see app._recalc_sse_event).
947+
_notify("recalc", extra={"remap": report.remap, "step": report.checkpoint_step})
945948
return asdict(report)
946949

947950

src/tallyman_xorq/recalc.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
from tallyman_core import aliases
4444
from tallyman_core.entry_config import carry_forward_entry_config
45-
from tallyman_core.paths import entry_dir, project_dir
45+
from tallyman_core.paths import entry_dir, entry_manifest_path, project_dir
4646
from tallyman_xorq.build import BuildError, build_and_persist
4747
from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone
4848
from tallyman_xorq.portable import PLACEHOLDER
@@ -125,9 +125,13 @@ def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcRe
125125
``reset_to`` undoes atomically. Stops at the first build failure (the prefix
126126
stays committed); never raises on a build error.
127127
"""
128-
# Drop roots that name no on-disk entry (a caller mistake) so the walk never
129-
# indexes a missing verdict; an all-bogus root set yields an empty report.
130-
roots = [r for r in roots if entry_dir(project, r).exists()]
128+
# Drop roots that name no live entry (a caller mistake, or a crashed
129+
# mid-build leftover dir) so the walk never indexes a missing verdict; an
130+
# all-bogus root set yields an empty report. Gate on the manifest, not the
131+
# directory: `verdicts` (and the cone) are keyed by manifest-bearing entries
132+
# (scan/list_entries), so a dir-without-manifest root would otherwise enter
133+
# the cone and KeyError the walk at verdicts[hash].
134+
roots = [r for r in roots if entry_manifest_path(project, r).exists()]
131135
try:
132136
cone = descendant_cone(project, roots)
133137
except DependencyCycleError as exc:

0 commit comments

Comments
 (0)