CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
218 lines
9.0 KiB
Python
218 lines
9.0 KiB
Python
"""Entries in the rule exclusion ledger age out (#3751).
|
|
|
|
WHY THIS EXISTS
|
|
|
|
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
|
/clear. This covers the case with no event at all: a long session where a rule
|
|
was named two hundred turns ago and has simply fallen out of attention. Same
|
|
argument #3702 made about tiers (present in context and salient at the
|
|
moment are different properties), applied to time instead. The tier itself is
|
|
gone since milestone 394; the distinction it taught is what survives.
|
|
|
|
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
|
|
|
Not the TTL's value. 2700 is a judgement made to be revised once there is
|
|
telemetry to revise it with, and a test asserting `== 2700` would turn a
|
|
legitimate tuning change into a red build — which is how a number nobody may
|
|
touch gets one. The tests read the constant out of the shell and assert the
|
|
PROPERTY around it, so any TTL works and only a broken comparison fails.
|
|
|
|
Runs the real shell, like `test_session_context_ledger` and the after-write
|
|
hook's tests, and deliberately with no SCRIBE_URL/SCRIBE_TOKEN: ageing is
|
|
local, keyless and networkless, and must work on an instance that is
|
|
unreachable or was never configured.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
|
|
DEFS = PLUGIN / "scribe_defs.sh"
|
|
|
|
|
|
def _ttl() -> int:
|
|
"""The TTL as the shell defines it — never a copy of the number."""
|
|
m = re.search(r"^_SCRIBE_RULE_TTL=(\d+)", DEFS.read_text(), re.M)
|
|
assert m, "_SCRIBE_RULE_TTL is gone from scribe_defs.sh"
|
|
return int(m.group(1))
|
|
|
|
|
|
def _live(ledger: Path) -> list[str]:
|
|
"""`scribe_rules_live` against a real ledger, as the hooks call it."""
|
|
for tool in ("bash", "awk"):
|
|
if shutil.which(tool) is None:
|
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
|
proc = subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]}, # no credentials — see the docstring
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = proc.stdout.strip()
|
|
return out.split(",") if out else []
|
|
|
|
|
|
def _write_ledger(ledger: Path, entries: list[tuple[str, int | None]]) -> None:
|
|
"""A ledger written in AGES rather than absolute stamps.
|
|
|
|
Named for what it is: `tests/helpers._now` is a datetime for record
|
|
fixtures and this file wants the shell's epoch, so they are deliberately
|
|
not the same helper and deliberately not the same name. `time.time()`
|
|
rather than shelling out to `date` — both read the one system clock, so
|
|
the subprocess bought nothing.
|
|
"""
|
|
now = int(time.time())
|
|
ledger.write_text("".join(
|
|
f"{rid}\n" if ago is None else f"{rid}\t{now - ago}\n"
|
|
for rid, ago in entries
|
|
))
|
|
|
|
|
|
def test_an_old_entry_ages_out_and_a_recent_one_does_not(tmp_path):
|
|
"""BOTH DIRECTIONS IN ONE ASSERTION (rule 167), because either half alone
|
|
passes against a broken implementation.
|
|
|
|
A test that only checks the old id is gone passes against a helper that
|
|
returns nothing at all — which would drop every exclusion and bring back
|
|
the repetition the ledger exists to prevent. A test that only checks the
|
|
recent id survives passes against the flat `tr '\\n' ','` read this
|
|
replaces, i.e. against the defect itself.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", ttl + 600), ("168", 60)])
|
|
|
|
assert _live(ledger) == ["168"], (
|
|
f"expected the entry {ttl + 600}s old to age out and the 60s-old one "
|
|
f"to survive (TTL {ttl}s). An empty list means nothing is excluded any "
|
|
f"more; both ids means nothing ages."
|
|
)
|
|
|
|
|
|
def test_a_repeatedly_surfaced_rule_appears_once_in_the_exclusion_list(tmp_path):
|
|
"""List hygiene, and it is not cosmetic.
|
|
|
|
The ledger is append-only, so a rule surfaced five times has five lines.
|
|
The output is spliced straight into `&exclude_rule_ids=` and parsed by the
|
|
server as a list; duplicates make that list grow without bound over a long
|
|
session, and a trailing or doubled comma is an empty element the parser has
|
|
to decide what to do with. Neither is visible from the hint.
|
|
|
|
(There was a boundary test here — `ttl` vs `ttl + 1`. It was racy by
|
|
construction: a ledger written at T is read at T+n, so the entry ages by
|
|
however long the subprocess took and the two cases swap. A one-second
|
|
distinction on a 45-minute window is also not a behaviour anyone can
|
|
observe, so it was pinning a flake rather than a property.)
|
|
"""
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", 60), ("168", 30), ("156", 10), ("156", 5)])
|
|
|
|
live = _live(ledger)
|
|
assert live == ["156", "168"], (
|
|
f"got {live}: an id repeated in the ledger must appear once in the "
|
|
f"exclusion list, in first-seen order"
|
|
)
|
|
raw = subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]},
|
|
).stdout.strip()
|
|
assert ",," not in raw and not raw.endswith(","), (
|
|
f"the exclusion list has an empty element: {raw!r}"
|
|
)
|
|
|
|
|
|
def test_a_rule_that_ages_out_and_returns_does_not_ping_pong(tmp_path):
|
|
"""THE FAILURE THIS COSTS THE MOST TO GET WRONG.
|
|
|
|
The ledger is append-only, so a rule that ages out, gets surfaced fresh and
|
|
is appended again has TWO lines. An implementation reading the FIRST leaves
|
|
it permanently expired — and it then re-announces itself on every single
|
|
call for the rest of the session. The mechanism meant to quieten things
|
|
becomes the loudest thing in the hint.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", ttl + 600)])
|
|
|
|
subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; printf \'156\\n\' | scribe_rules_append "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]},
|
|
)
|
|
|
|
assert _live(ledger) == ["156"], (
|
|
"a re-surfaced rule read as still expired; the most recent entry for "
|
|
"an id must win, or it re-announces itself on every call from here on"
|
|
)
|
|
|
|
|
|
def test_a_bare_id_from_the_old_format_is_treated_as_live(tmp_path):
|
|
"""The compatibility decision, asserted rather than described.
|
|
|
|
Every session in flight when this ships has a ledger of bare ids. Reading
|
|
unknown as EXPIRED would make all of them re-announce every rule they had
|
|
already been told, all at once — the exact noise this feature exists to
|
|
prevent, delivered by the feature itself on the day it ships.
|
|
|
|
Unknown means "not measured", never "old" — the same discipline the
|
|
nullable retrieval_logs columns use. Those entries never age, which is
|
|
bounded, because the session ends.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
ledger.write_text("156\n168\n")
|
|
assert _live(ledger) == ["156", "168"]
|
|
|
|
# And a bare id keeps its own meaning next to a stale stamped one, rather
|
|
# than the file falling back to one format or the other wholesale.
|
|
_write_ledger(ledger, [("9", None), ("156", ttl + 600)])
|
|
assert _live(ledger) == ["9"]
|
|
|
|
|
|
def test_a_missing_or_empty_ledger_excludes_nothing(tmp_path):
|
|
"""Fail-open, like every other path in these hooks: no ledger means no
|
|
exclusions, not an error and not a stray comma the server would parse as
|
|
an id."""
|
|
assert _live(tmp_path / "never-written.rules.ids") == []
|
|
empty = tmp_path / "empty.rules.ids"
|
|
empty.write_text("")
|
|
assert _live(empty) == []
|
|
|
|
|
|
def test_both_hooks_read_the_ledger_through_the_shared_helper():
|
|
"""Structural, because the defect this would reintroduce is invisible.
|
|
|
|
Two hooks share one ledger so a rule named by one arm is not re-offered by
|
|
the other. If either goes back to reading the file flat, that hook stops
|
|
ageing while its sibling keeps ageing — the two disagree about which rules
|
|
are live, and nothing in the output says so.
|
|
|
|
Pinned on the flat-read SHAPE rather than on a helper name: a hook that
|
|
stops calling the helper but still ages correctly some other way is not a
|
|
defect, and a rename of the helper is not one either.
|
|
"""
|
|
for name in ("scribe_prior_art.sh", "scribe_tool_rules.sh"):
|
|
src = (PLUGIN / name).read_text()
|
|
rule_reads = [
|
|
ln for ln in src.splitlines()
|
|
if "rulefile" in ln and "tr " in ln and r"'\n' ','" in ln
|
|
]
|
|
assert not rule_reads, (
|
|
f"{name} reads the rule ledger flat again: {rule_reads}. That "
|
|
f"hook's exclusions would stop ageing while its sibling's keep "
|
|
f"ageing, and the two would disagree silently about which rules "
|
|
f"this session still holds."
|
|
)
|
|
assert "scribe_rules_live" in src, (
|
|
f"{name} no longer reads the rule ledger through the shared "
|
|
f"helper, so the two hooks can drift on the ledger format"
|
|
)
|