fix(rules): the staleness signal must not wait for prior art to match (#3244)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
CI caught two things, and the first is the feature not working rather than a test being wrong. THE SIGNAL WAS GATED ON A COINCIDENCE. build_write_path_hint returns early when no prior art, stamp, divergence or derive matched, and that guard sat ABOVE the new arm — so a session whose rules had changed was told only if the file it happened to be editing also matched something else. A staleness signal that fires on that coincidence is not a staleness signal. The arm now runs above the guard, collecting into its own list that `lines` is seeded from, and the guard accounts for it. The standing-rule arm (milestone 307) is deliberately LEFT below that guard, and this is a finding rather than a fix: it has the same gating and probably should not, but it runs a SEMANTIC search, so lifting it would put an embedding query on every write in every session. That is a cost decision, not a bug fix, and not this task's to make. THE MARKER MUST NOT BREAK THE PAYLOAD IT DECORATES. rules_etag is computed on the SessionStart path, where `max()` raising costs the whole context payload — every rule title, the project, all of it — to save a hint. A row with no usable timestamp is now skipped and a set with none degrades to a count-only marker, which still catches a rule added or deleted and only loses edits. That is the right way round to lose information. CI found it because build_session_context's tests pass MagicMock rules and `max()` over those raises TypeError. Also: list_always_on_rules on an install with no always-on rulebooks returns `rules_etag: "empty|0"`. Its exact-dict test is updated rather than loosened — the key being present on an empty install is the behaviour, not noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1449,8 +1449,20 @@ def rules_etag(rules: list) -> str:
|
||||
"""
|
||||
if not rules:
|
||||
return _ETAG_EMPTY
|
||||
newest = max(r.updated_at for r in rules)
|
||||
return f"{newest.isoformat()}|{len(rules)}"
|
||||
# A decoration must not be able to break what it decorates. This is
|
||||
# computed on the SessionStart path, where raising would cost the whole
|
||||
# context payload to save a hint — so a row with no usable timestamp is
|
||||
# skipped rather than compared, and a set with none degrades to a
|
||||
# count-only marker instead of failing. Count-only still catches a rule
|
||||
# added or deleted; it just cannot see an edit, which is the right way
|
||||
# round to lose information.
|
||||
stamps = [
|
||||
r.updated_at for r in rules
|
||||
if isinstance(getattr(r, "updated_at", None), datetime)
|
||||
]
|
||||
if not stamps:
|
||||
return f"unknown|{len(rules)}"
|
||||
return f"{max(stamps).isoformat()}|{len(rules)}"
|
||||
|
||||
|
||||
async def rules_etag_for(user_id: int, project_id: int = 0) -> str:
|
||||
|
||||
Reference in New Issue
Block a user