Skip to content

Commit 98e987d

Browse files
authored
Merge pull request #277 from bogdanbaciu21/fix-57-unchanged-staging
fix(sleep): stage only documents changed by an accepted cycle
2 parents a71face + 6da5ec1 commit 98e987d

2 files changed

Lines changed: 126 additions & 2 deletions

File tree

‎skillopt_sleep/cycle.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,8 +988,24 @@ def run_sleep_cycle(
988988
adopted_paths: List[str] = []
989989
if not dry_run:
990990
_progress(cfg, "staging start")
991-
proposed_skill = result.new_skill if (cfg.get("evolve_skill") and result.accepted) else None
992-
proposed_memory = result.new_memory if (cfg.get("evolve_memory") and result.accepted) else None
991+
proposed_skill = (
992+
result.new_skill
993+
if (
994+
cfg.get("evolve_skill")
995+
and result.accepted
996+
and result.new_skill != skill
997+
)
998+
else None
999+
)
1000+
proposed_memory = (
1001+
result.new_memory
1002+
if (
1003+
cfg.get("evolve_memory")
1004+
and result.accepted
1005+
and result.new_memory != memory
1006+
)
1007+
else None
1008+
)
9931009
skill_proposals, skip_notes = _skill_proposals_from_groups(
9941010
cfg,
9951011
group_outcomes,

‎tests/test_sleep_engine.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,114 @@ def test_cycle_pins_the_exact_managed_skill_and_memory_bytes_it_read(self):
15641564
os.path.realpath(memory_path),
15651565
)
15661566

1567+
def _assert_only_changed_documents_are_staged(
1568+
self, new_skill, new_memory, expect_skill, expect_memory
1569+
):
1570+
from skillopt_sleep.consolidate import ConsolidationResult
1571+
1572+
skill = "# managed baseline\nrule\n"
1573+
memory = "# memory baseline\npreference\n"
1574+
with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home:
1575+
target = os.path.join(proj, ".agents", "skills", "taste", "SKILL.md")
1576+
memory_path = os.path.join(proj, "CLAUDE.md")
1577+
os.makedirs(os.path.dirname(target), exist_ok=True)
1578+
with open(target, "w", encoding="utf-8") as handle:
1579+
handle.write(skill)
1580+
with open(memory_path, "w", encoding="utf-8") as handle:
1581+
handle.write(memory)
1582+
cfg = load_config(
1583+
invoked_project=proj,
1584+
projects="invoked",
1585+
backend="mock",
1586+
claude_home=os.path.join(home, ".claude"),
1587+
target_skill_path=target,
1588+
auto_adopt=False,
1589+
)
1590+
applied = []
1591+
if new_skill != skill:
1592+
applied.append(EditRecord("skill", "add", "sharpened rule"))
1593+
if new_memory != memory:
1594+
applied.append(EditRecord("memory", "add", "learned preference"))
1595+
result = ConsolidationResult(
1596+
accepted=True,
1597+
gate_action="accept_new_best",
1598+
baseline_score=0.1,
1599+
candidate_score=0.2,
1600+
new_skill=new_skill,
1601+
new_memory=new_memory,
1602+
applied_edits=applied,
1603+
rejected_edits=[],
1604+
holdout_baseline=0.1,
1605+
holdout_candidate=0.2,
1606+
)
1607+
tasks = assign_splits(
1608+
researcher_persona(), holdout_fraction=0.34, seed=42
1609+
)
1610+
1611+
with mock.patch(
1612+
"skillopt_sleep.cycle.dream_consolidate",
1613+
return_value=result,
1614+
):
1615+
outcome = run_sleep_cycle(cfg, seed_tasks=tasks)
1616+
1617+
# Staging never edits the live documents; adoption stays explicit.
1618+
with open(target, encoding="utf-8") as handle:
1619+
self.assertEqual(handle.read(), skill)
1620+
with open(memory_path, encoding="utf-8") as handle:
1621+
self.assertEqual(handle.read(), memory)
1622+
1623+
with open(
1624+
os.path.join(outcome.staging_dir, "manifest.json"),
1625+
encoding="utf-8",
1626+
) as handle:
1627+
manifest = json.load(handle)
1628+
# Manifest flags and artifact presence have to agree; a flag without
1629+
# its file (or a file without its flag) would break adoption.
1630+
self.assertEqual(manifest["has_managed_skill"], expect_skill)
1631+
self.assertEqual(manifest["has_managed_memory"], expect_memory)
1632+
self.assertEqual(
1633+
os.path.exists(
1634+
os.path.join(outcome.staging_dir, "proposed_SKILL.md")
1635+
),
1636+
expect_skill,
1637+
)
1638+
self.assertEqual(
1639+
os.path.exists(
1640+
os.path.join(outcome.staging_dir, "proposed_CLAUDE.md")
1641+
),
1642+
expect_memory,
1643+
)
1644+
1645+
def test_cycle_stages_only_documents_that_changed(self):
1646+
# The staging contract is byte/text equality, not semantic or whitespace
1647+
# normalized comparison: an accepted cycle proposes a document only when it
1648+
# actually rewrote it. Covered for every shape an accepted result can take,
1649+
# so a symmetric regression on the skill side cannot hide behind the
1650+
# memory-only case.
1651+
skill = "# managed baseline\nrule\n"
1652+
memory = "# memory baseline\npreference\n"
1653+
new_skill = skill + "prefer the shortest reproduction\n"
1654+
new_memory = memory + "learned preference\n"
1655+
cases = (
1656+
("neither_changed", skill, memory, False, False),
1657+
("skill_only", new_skill, memory, True, False),
1658+
("memory_only", skill, new_memory, False, True),
1659+
("both_changed", new_skill, new_memory, True, True),
1660+
# Whitespace-only is a real change under a byte-equality contract, so it
1661+
# is a positive case. If this ever fails, the comparison has started
1662+
# normalizing and the documented contract has silently moved.
1663+
("whitespace_only_skill", skill + "\n", memory, True, False),
1664+
("whitespace_only_memory", skill, memory + " \n", False, True),
1665+
)
1666+
for name, candidate_skill, candidate_memory, expect_skill, expect_memory in cases:
1667+
with self.subTest(case=name):
1668+
self._assert_only_changed_documents_are_staged(
1669+
candidate_skill,
1670+
candidate_memory,
1671+
expect_skill,
1672+
expect_memory,
1673+
)
1674+
15671675
def test_managed_skill_change_during_consolidation_refuses_the_night(self):
15681676
from skillopt_sleep.consolidate import ConsolidationResult
15691677
from skillopt_sleep.staging import StagingError, latest_staging

0 commit comments

Comments
 (0)