Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 62c9f67093 Materialize instances when a quest definition is created
create_quest_definition persisted the rule, definition, assignees and
windows but never called regenerate_for_definition, unlike edit, assign,
unassign and activate. A new task therefore had no quest_instances rows
and stayed off the panel board until an unrelated write regenerated it.

Call regenerate_for_definition after the DAO write, using the host
clock and the default horizon, and add a test that a created weekly
definition has only today-or-later instances.

Update test fixtures that relied on create writing no instances: close
the seeded one-day rules with end_date, clear the table before the gated
materialize walk, and adjust the admin regenerate tests to the new
create behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:17:56 -04:00
7 changed files with 83 additions and 18 deletions
@@ -235,6 +235,11 @@ async def create_quest_definition(
transaction, so the returned bundle is a consistent creation transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing. failed create persists nothing.
After the write, :func:`~.materialize.regenerate_for_definition`
materializes the new definition's instances over the rolling horizon
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
board without waiting for an unrelated write.
""" """
name = _validate_text(title, "title", required=True) name = _validate_text(title, "title", required=True)
assert name is not None assert name is not None
@@ -269,6 +274,9 @@ async def create_quest_definition(
) from error ) from error
raise raise
decoded_rule = schedule_rule_from_storage(storage) decoded_rule = schedule_rule_from_storage(storage)
await regenerate_for_definition(database, snapshot.definition.id)
return CreatedQuestDefinition( return CreatedQuestDefinition(
definition=snapshot.definition, definition=snapshot.definition,
rule=decoded_rule, rule=decoded_rule,
+12 -10
View File
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
client = admin_household.client client = admin_household.client
database = admin_household.database database = admin_household.database
before = await _instance_count(database) # The seed's creates materialized the horizon BEFORE Ada's custody
assert before > 0 # the seed already materialized a few days # pattern existed, so the first household walk may legitimately
# drop her away-day rows; only the second run must be stable.
assert await _instance_count(database) > 0
first = await client.post( first = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers() "/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates(
assert body["scope"] == "household" assert body["scope"] == "household"
assert body["definition_id"] is None assert body["definition_id"] is None
assert body["child_id"] is None assert body["child_id"] is None
assert body["count"] >= before assert body["count"] > 0
after_first = await _instance_count(database) after_first = await _instance_count(database)
assert after_first >= before
second = await client.post( second = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers() "/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
) -> None: ) -> None:
"""The definition scope rebuilds that definition's horizon, twice, stably. """The definition scope rebuilds that definition's horizon, twice, stably.
Creating a definition through the admin route materializes NOTHING Creating a definition through the admin route already materializes
(the core's create path does not regenerate); the scoped regenerate its horizon (the core's create path regenerates); the scoped
call is what materializes its instances, and re-running it leaves regenerate call rebuilds the same rows, and re-running it leaves
the row count exactly where the first run left it. the row count exactly where the create left it.
""" """
client = admin_api.client client = admin_api.client
database = admin_api.database database = admin_api.database
ada = await _create_child(client, "Ada") ada = await _create_child(client, "Ada")
definition_id = await _create_daily_definition(client, [ada]) definition_id = await _create_daily_definition(client, [ada])
assert await _instance_count(database) == 0 created_count = await _instance_count(database)
assert created_count > 0
first = await client.post( first = await client.post(
"/api/v1/admin/regenerate", "/api/v1/admin/regenerate",
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
assert body["child_id"] is None assert body["child_id"] is None
assert body["count"] > 0 assert body["count"] > 0
after_first = await _instance_count(database) after_first = await _instance_count(database)
assert after_first == body["count"] assert after_first == body["count"] == created_count
second = await client.post( second = await client.post(
"/api/v1/admin/regenerate", "/api/v1/admin/regenerate",
+7 -2
View File
@@ -151,12 +151,17 @@ async def _seed_missed_household(
database, yesterday_iso, yesterday_iso, today=yesterday database, yesterday_iso, yesterday_iso, today=yesterday
) )
# Today's quest: created after the yesterday walk so it has no # Today's quest: created after the yesterday walk so it has no
# past-due instance, then materialized for today only. # past-due instance; ``end_date`` keeps it (and create's horizon
# regeneration) to today only.
await _quest_definitions.create_quest_definition( await _quest_definitions.create_quest_definition(
database, database,
"Recoverable chore", "Recoverable chore",
_recurrence.ScheduleRule.from_dict( _recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso} {
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
), ),
[ada.id], [ada.id],
[("afternoon", "17:00")], [("afternoon", "17:00")],
+8 -3
View File
@@ -149,13 +149,18 @@ async def _seed_household(
(today + datetime.timedelta(days=3)).isoformat(), (today + datetime.timedelta(days=3)).isoformat(),
today=today, today=today,
) )
# The stale quest is created AFTER the today walk, so its only # The stale quest's rule ends YESTERDAY, so create's own
# instance is the yesterday one written below. # regeneration writes nothing and its only instance is the
# yesterday one written below.
await _quest_definitions.create_quest_definition( await _quest_definitions.create_quest_definition(
database, database,
"Stale chore", "Stale chore",
_recurrence.ScheduleRule.from_dict( _recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": yesterday_iso} {
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
), ),
[ada.id], [ada.id],
[("morning", "09:00")], [("morning", "09:00")],
+8 -3
View File
@@ -102,15 +102,20 @@ async def _seed_missed_household(
database, "Old chore", rule, [ada.id], [("evening", "18:30")] database, "Old chore", rule, [ada.id], [("evening", "18:30")]
) )
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday) await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has # A quest due TODAY only (created after the yesterday walk so it
# no past-due instance): the post-downtime catch-up run's # has no past-due instance; ``end_date`` keeps create's horizon
# regeneration to today): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not # target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window. # inside the day-one sweep's window.
await create_quest_definition( await create_quest_definition(
database, database,
"Recoverable chore", "Recoverable chore",
ScheduleRule.from_dict( ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso} {
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
), ),
[ada.id], [ada.id],
[("afternoon", "17:00")], [("afternoon", "17:00")],
+4
View File
@@ -468,7 +468,11 @@ async def _materialize_with_change(
proceeds with the snapped presence). This deterministically proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can exercises the snapshot-to-insert gap that a config edit can
interleave into. interleave into.
``create_quest_definition`` already materializes its horizon, so the
table is cleared first: the gated walk must be the only writer.
""" """
await database.execute("DELETE FROM quest_instances")
original_upsert = QuestInstancesDao.upsert_if_valid original_upsert = QuestInstancesDao.upsert_if_valid
entered = asyncio.Event() entered = asyncio.Event()
release = asyncio.Event() release = asyncio.Event()
+36
View File
@@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times(
_with_db(tmp_path, "create-many.db")(_body) _with_db(tmp_path, "create-many.db")(_body)
def test_create_materializes_future_instances(tmp_path) -> None:
"""A fresh create regenerates its rolling horizon immediately.
Without this the new definition has no ``quest_instances`` rows until
some unrelated write regenerates it, so it never reaches the board.
"""
assert DEFAULT_HORIZON_DAYS >= 7
async def _body(database, children, rules, definitions, child):
today = _today_iso()
created = await create_quest_definition(
database,
"Take out bins",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=today,
),
[child.id],
["morning"],
)
rows = await database.fetch_all(
"SELECT child_id, due_date FROM quest_instances "
"WHERE definition_id = ?",
(created.definition.id,),
)
assert rows, "create materialized no instances"
assert {row[0] for row in rows} == {child.id}
for _child_id, due_date in rows:
assert due_date >= today
assert datetime.date.fromisoformat(due_date).weekday() == 0
return None
_with_db(tmp_path, "create-materializes.db")(_body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# create_quest_definition: argument rejections # create_quest_definition: argument rejections
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------