From d2f1078d3e55868d571de395c3f0fc5bab899736 Mon Sep 17 00:00:00 2001 From: Fiber Date: Sat, 17 Oct 2026 15:20:55 +0800 Subject: [PATCH] fix(goals): expire internal questions during daily maintenance --- README.md | 8 +- src/iris/goals.py | 73 +++++- src/iris/maintenance.py | 21 +- tests/test_question_goal_expiry.py | 350 +++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 tests/test_question_goal_expiry.py diff --git a/README.md b/README.md index 7f44e1c6..0c40add1 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,12 @@ PUT 的活动经去除首尾空白后,按区分大小写的完整字符串判 目标属于角色,所有入口共享,入口只记录产生位置;它是待办意图,不是当前活动、记忆或 persona。学习仍可同时保存承诺记忆与内部目标,目标本身不加入学习材料,也不会因聊天中的含糊说法自动完成。询问为 `kind=question`,没有截止时间或提醒,宿主可直接注入,并决定何时问。 +学习产生的内部询问(`origin=internal`、`kind=question`)超过保留天数仍为 `open` 时,由每日维护自动结束为 `abandoned`,`closed_by=maintenance`。保留天数使用运行设置 `goals.internal_question_expiry_days`,缺省 7,须为 1—365 的整数;当前只可通过 `Goals.configure_question_expiry(days)` 内部配置,管理设置接口与页面尚未开放。已有设置保存会保留此键。 + +天数按维护任务开始时的角色时区计算自然日:创建当天为第 0 天,第 N 天仍保留,进入第 N+1 天后的首次维护才结束。例如 N=7、10 月 10 日创建,10 月 17 日仍保留,10 月 18 日起可结束。续跑使用该维护任务的开始时间与时区判断到期,结束时间记录实际执行时间;写入前核对修订号,冲突留到下一次维护。此清理不依赖模型整理/目标依据复核开关,不发通知或提醒,也不改变承诺记忆。维护报告列出数量与目标 ID,目标修订记录固定原因 `question_expired` 及状态变化,不复制正文。合并来源节点跳过,保留目标按自身来源与创建时间判断;“可能重复”仍需按原规则处理。 + +宿主或管理员创建的询问,以及所有 `kind=normal` 目标,不适用自动到期结束。宿主接口字段保持不变,可通过原有目标状态查询观察结束结果。 + | 方法与路径 | 内容 | | --- | --- | | `GET /api/v1/goals` | `state`、`kind`、`overdue`、`due_soon` 筛选,`limit`(1—100,默认 30)、`offset` 分页 | @@ -475,7 +481,7 @@ PUT 的活动经去除首尾空白后,按区分大小写的完整字符串判 合并保留较早目标,原目标及其依据保留可追溯,占位的 `merged_into` 指向保留项;来源消息、同证据角色记忆与未取提醒归入保留项。明确截止时间补入空值;两个明确提前量不同则采用较早提醒(较大分钟数),原值仍在被合并项中。合并后的同阶段待取提醒只留一条,其余保留为取消记录。相同 `host_key` 的重试返回首次创建的完整回执快照,即使后来目标已修改;读取最新状态用列表。修改已合并 ID 返回 409 `goal_merged` 和 `canonical_id`,需要明确选择保留项再提交。 -有期限的普通目标在临近、到期时发布提醒;创建/修改时已经错过提醒时刻则立即发布一条,未来的到期提醒仍保留。服务恢复时同一目标错过的阶段只发布一条即时提醒,不逐条补发。提前量为 0 时,临近和到期同刻,合为一条。过期后的周期提醒按角色时区每天最多一次,内容请宿主选择放弃或修改期限;停机多日只生成当前一天的一条。系统不自动失败、放弃或顺延。 +有期限的普通目标在临近、到期时发布提醒;创建/修改时已经错过提醒时刻则立即发布一条,未来的到期提醒仍保留。服务恢复时同一目标错过的阶段只发布一条即时提醒,不逐条补发。提前量为 0 时,临近和到期同刻,合为一条。过期后的周期提醒按角色时区每天最多一次,内容请宿主选择放弃或修改期限;停机多日只生成当前一天的一条。除上述学习产生的内部询问到期规则外,系统不自动失败、放弃或顺延。 通知的 `id` 是实际发布时分配的单调游标,未来计划不提前占用游标。目标提醒每条有 `kind=goal_reminder`、`reminder_kind=soon/due/overdue/immediate`、`goal_id`、`content`、发布时的期限 `deadline_at`、计划/发布时间、`status=pending/taken/cancelled` 及取走/取消时间。取走仅表示宿主读到;相同游标可重放已取记录,方便恢复丢失的 HTTP 响应,宿主按通知 ID 去重。取走不等于送达,也不完成目标。修改期限/提前量或完成/放弃会更新未来计划、取消未取的目标提醒;已取记录和去重结果通知保留。默认提前量变动重排使用默认值的未来目标,保留已过期的待取提醒。 diff --git a/src/iris/goals.py b/src/iris/goals.py index 8780c8b7..b9093ff7 100644 --- a/src/iris/goals.py +++ b/src/iris/goals.py @@ -29,6 +29,8 @@ from .state import local_time, role_zone DEFAULTS = {'default_reminder_minutes': 60, 'overdue_reminders': True} +QUESTION_EXPIRY_KEY = 'internal_question_expiry_days' +DEFAULT_QUESTION_EXPIRY_DAYS = 7 ALL_ENTRIES = Scope(kind='all') REVIEW_RETRY_DELAYS = (30, 120, 600) @@ -49,11 +51,26 @@ def __init__(self, canonical_id): super().__init__('目标已合并') -def goal_settings(conn): +def _stored_goal_settings(conn): row = conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone() return {**DEFAULTS, **(json.loads(row[0]) if row else {})} +def goal_settings(conn): + # Keep the existing settings API projection until its separate rollout. + return {key: value for key, value in _stored_goal_settings(conn).items() if key != QUESTION_EXPIRY_KEY} + + +def _question_expiry_days(value): + if type(value) is not int or not 1 <= value <= 365: + raise GoalError(QUESTION_EXPIRY_KEY, '内部询问保留天数须为 1—365 的整数') + return value + + +def question_expiry_days(conn): + return _question_expiry_days(_stored_goal_settings(conn).get(QUESTION_EXPIRY_KEY, DEFAULT_QUESTION_EXPIRY_DAYS)) + + def _current(value=None): return (value or utc_now()).astimezone(timezone.utc) @@ -342,6 +359,45 @@ def _basis_observations(conn, goal_id): yield row['memory_id'],row['memory_revision'],observed,changes +def expire_internal_question(conn, current, *, goal_id, expected_revision, as_of, zone): + """Close one expired question in the maintenance item's write transaction. + + Creation day is day 0 in the run's role timezone; day N remains open. + The run's cutoff is stable across restarts, while closed_at is execution time. + """ + if not conn.in_transaction: + raise ValueError('question expiry requires a write transaction') + row = _row(conn, goal_id) + if row['revision'] != expected_revision: + raise GoalConflict() + if (row['origin'] != 'internal' or row['kind'] != 'question' + or row['state'] != 'open' or row['merged_into'] is not None): + return False + days = question_expiry_days(conn) + created = datetime.fromisoformat(row['created_at']) + if created.tzinfo is None: + raise GoalError('created_at', '创建时间须带时区') + age = (as_of.astimezone(zone).date() - created.astimezone(zone).date()).days + if age <= days: + return False + current = _current(current) + before = _snapshot(conn, goal_id) + changed = conn.execute("""UPDATE goals SET state='abandoned',closed_at=?,closed_by='maintenance', + revision=revision+1,updated_at=? WHERE id=? AND revision=? AND state='open' AND merged_into IS NULL""", + (current.isoformat(), current.isoformat(), goal_id, expected_revision)).rowcount + if changed != 1: + raise GoalConflict() + # A pending/in-flight dedup review cannot revive or merge this closed goal. + # Cancellation does not publish a notification or alter duplicate receipts. + _cancel_review(conn, goal_id, current, {'status':'created','target_id':None,'reason':'goal_closed'}) + sid = _record_snapshot(conn, goal_id, before, actor='maintenance', action='expire_question', + reason='question_expired', current=current) + operation(conn, 'goal_question_expired', 'goal', goal_id, + {'revision_before':expected_revision, 'snapshot_id':sid, QUESTION_EXPIRY_KEY:days}, + actor='maintenance', stamp=current.isoformat()) + return True + + def review_goal_basis(conn, current, *, goal_id=None, expected_revision=None): """Review current basis inside the caller's write transaction; no model/history reads. @@ -1381,6 +1437,17 @@ def configure_dedup(self, *, pending_fallback_minutes=None, possible_min_similar actor=actor,stamp=_current(self.clock()).isoformat()) return options + def configure_question_expiry(self, days, *, actor='admin'): + """Internal configuration; HTTP/settings UI exposure is a separate change.""" + days = _question_expiry_days(days) + with self.store.write() as conn: + settings = _stored_goal_settings(conn) + settings[QUESTION_EXPIRY_KEY] = days + conn.execute("INSERT OR REPLACE INTO runtime_settings VALUES('goals',?)", (dumps(settings),)) + operation(conn, 'settings_goals', 'settings', 'goals', {QUESTION_EXPIRY_KEY:days}, + actor=actor, stamp=_current(self.clock()).isoformat()) + return days + def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor='admin'): if default_reminder_minutes is not None: _lead(default_reminder_minutes) @@ -1388,7 +1455,7 @@ def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor= raise GoalError('overdue_reminders','过期提醒开关须为布尔值') with self.store.write() as conn: current=_current(self.clock()) - settings=goal_settings(conn) + settings=_stored_goal_settings(conn) changes={k:v for k,v in {'default_reminder_minutes':default_reminder_minutes,'overdue_reminders':overdue_reminders}.items() if v is not None and v!=settings[k]} settings.update(changes) conn.execute("INSERT INTO runtime_settings(key,value_json) VALUES('goals',?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json",(dumps(settings),)) @@ -1409,4 +1476,4 @@ def configure(self,*,default_reminder_minutes=None,overdue_reminders=None,actor= for row in _rows(conn,"state='open' AND merged_into IS NULL AND deadline_at IS NOT NULL AND deadline_at<=?",(current.isoformat(),)): _next_overdue(conn,row,current) operation(conn,'settings_goals','settings','goals',changes,actor=actor,stamp=current.isoformat()) - return settings + return goal_settings(conn) diff --git a/src/iris/maintenance.py b/src/iris/maintenance.py index 75ca0214..15085430 100644 --- a/src/iris/maintenance.py +++ b/src/iris/maintenance.py @@ -235,11 +235,11 @@ def _candidates(self, run, phase): from .consolidation import run_settings with self.store.read() as conn: settings = run_settings(conn, run['id']) - if not settings['enabled'] or not settings['goal_review_enabled']: - return [] + basis_review = settings['enabled'] and settings['goal_review_enabled'] return [dict(r) for r in conn.execute("""SELECT id,id AS cursor_id,revision,CAST(id AS TEXT) AS item_key - FROM goals WHERE id>? AND id<=? AND state='open' AND merged_into IS NULL ORDER BY id LIMIT 100""", - (run['cursor_id'],run['goal_through']))] + FROM goals WHERE id>? AND id<=? AND state='open' AND merged_into IS NULL + AND (? OR (origin='internal' AND kind='question')) ORDER BY id LIMIT 100""", + (run['cursor_id'],run['goal_through'],basis_review))] if phase in ("decay", "expiry"): where = "m.id>? AND m.id<=? AND m.lifecycle!='deleted'" params = [run["cursor_id"], run["memory_through"]] @@ -324,8 +324,15 @@ def _apply(self, conn, run, phase, candidate): deleted = delete_unreferenced_file(self.store, conn, mid, current) return ("media_deleted", None, {}) if deleted else ("skipped", "media_referenced_or_not_due", {}) if phase == "goals": - from .goals import review_goal_basis, GoalConflict + from .goals import expire_internal_question, review_goal_basis, GoalConflict + from .consolidation import run_settings try: + if expire_internal_question(conn, executed_at, goal_id=mid, expected_revision=candidate['revision'], + as_of=current, zone=ZoneInfo(run['timezone'])): + return 'questions_expired', 'question_expired', {} + settings = run_settings(conn, run['id']) + if not settings['enabled'] or not settings['goal_review_enabled']: + return 'checked', None, {} # Include the review's changes, report and cursor in this one # small transaction. The standalone paging helper cannot commit # our cursor/report atomically, so use its single-item API. @@ -424,7 +431,7 @@ def _apply(self, conn, run, phase, candidate): def _summary(items, progress): result = {name: {"count": 0, "memory_ids": [], "object_ids": []} for name in ( "decayed", "checked", "forgotten", "restored", "deleted", "dependencies_weakened", - "messages_deleted", "batches_retried", "skipped", "failed")} + "messages_deleted", "batches_retried", "questions_expired", "skipped", "failed")} for item in items: names = [] if item["outcome"] in ("checked", "skipped") else [item["outcome"]] transition = item["details"].get("transition") @@ -471,6 +478,8 @@ def report(self, run_id): items.append(item) report["items"] = items report["summary"] = self._summary(items, progress) + expired = report['summary']['questions_expired'] + report['question_expiry'] = {'count':expired['count'], 'goal_ids':expired['object_ids']} model = conn.execute("SELECT * FROM consolidation_runs WHERE run_id=?", (run_id,)).fetchone() if model: rows = conn.execute("SELECT outcome,reason,details_json FROM consolidation_run_work WHERE run_id=?", (run_id,)).fetchall() diff --git a/tests/test_question_goal_expiry.py b/tests/test_question_goal_expiry.py new file mode 100644 index 00000000..b25af0fa --- /dev/null +++ b/tests/test_question_goal_expiry.py @@ -0,0 +1,350 @@ +"""MM-04: deterministic expiry of learning-created questions, without model calls.""" +import json +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest +from fastapi.testclient import TestClient + +from conftest import authorize_host, login_admin +from iris.api import create_app +from iris.db import Store +from iris.goals import Goals, GoalError +from iris.maintenance import Maintenance, PHASES + + +KEY = 'internal_question_expiry_days' + + +class Clock: + def __init__(self, value='2026-10-18T04:00:00+00:00'): + self.value = datetime.fromisoformat(value) + + def __call__(self): + return self.value + + +def create(store, *, created='2026-10-10T01:00:00+00:00', origin='internal', + kind='question', content='想了解小林的露营经历'): + store.set_setting('goal_dedup_judge', {'enabled': False, 'method': 'A'}) + return Goals(store, clock=Clock(created)).create( + content=content, origin=origin, kind=kind, actor='learning' if origin == 'internal' else origin) + + +def start(store, clock, *, enabled=False, review=True): + # Isolate the goal-review phase so unrelated memory decay/model work cannot + # change the promise-memory and notification assertions below. + store.set_setting('consolidation', {'enabled': enabled, 'goal_review_enabled': review}) + engine = Maintenance(store, clock=clock) + rid = engine.request() + with store.write() as conn: + conn.execute('UPDATE maintenance_runs SET phase=? WHERE id=?', (PHASES.index('goals'), rid)) + return engine, rid + + +def finish(store, clock, **settings): + engine, rid = start(store, clock, **settings) + engine.run(rid) + return engine.report(rid) + + +def row(store, gid): + with store.read() as conn: + return dict(conn.execute('SELECT * FROM goals WHERE id=?', (gid,)).fetchone()) + + +def expirations(store): + with store.read() as conn: + return [dict(r) for r in conn.execute("SELECT * FROM goal_revisions WHERE reason='question_expired' ORDER BY id")] + + +def test_expiry_closes_question_and_reports_only_metadata(store): + goal = create(store)['goal'] + before = row(store, goal['id']) + clock = Clock() + report = finish(store, clock) + after = row(store, goal['id']) + assert after['state'] == 'abandoned' + assert after['closed_by'] == 'maintenance' + assert after['closed_at'] == clock().isoformat() + assert after['revision'] == before['revision'] + 1 + assert after['content'] == before['content'] + assert after['created_at'] == before['created_at'] + audit, = expirations(store) + assert audit['action'] == 'expire_question' and audit['actor'] == 'maintenance' + assert (audit['revision_before'], audit['revision_after']) == (before['revision'], after['revision']) + assert json.loads(audit['before_json']) == {'state': 'open', 'closed_at': None, 'closed_by': None} + assert json.loads(audit['after_json']) == { + 'state': 'abandoned', 'closed_at': clock().isoformat(), 'closed_by': 'maintenance'} + assert before['content'] not in json.dumps(audit, ensure_ascii=False) + assert report['question_expiry'] == {'count': 1, 'goal_ids': [goal['id']]} + assert report['summary']['questions_expired']['object_ids'] == [goal['id']] + item, = [i for i in report['items'] if i['outcome'] == 'questions_expired'] + assert item['reason'] == 'question_expired' + assert before['content'] not in json.dumps(item, ensure_ascii=False) + + +@pytest.mark.parametrize('zone,created', [ + ('Asia/Shanghai', '2026-10-10T15:59:59+00:00'), + ('America/Los_Angeles', '2026-10-11T06:59:59+00:00'), + ('America/New_York', '2026-10-31T23:50:00-04:00'), +]) +@pytest.mark.parametrize('days', [1, 7, 365]) +def test_day_n_retained_then_local_day_n_plus_one_expires(store, zone, created, days): + store.set_setting('timezone', zone) + store.set_setting('goals', {KEY: days}) + goal = create(store, created=created)['goal'] + local = datetime.fromisoformat(created).astimezone(ZoneInfo(zone)) + boundary = (local.replace(hour=0, minute=0, second=0, microsecond=0) + + timedelta(days=days + 1)).astimezone(timezone.utc) + clock = Clock((boundary - timedelta(microseconds=1)).isoformat()) + assert finish(store, clock)['question_expiry']['count'] == 0 + assert row(store, goal['id'])['state'] == 'open' + clock.value = boundary + assert finish(store, clock)['question_expiry']['goal_ids'] == [goal['id']] + assert row(store, goal['id'])['state'] == 'abandoned' + + +@pytest.mark.parametrize('origin,kind', [ + ('host', 'question'), ('admin', 'question'), + ('internal', 'normal'), ('host', 'normal'), ('admin', 'normal'), +]) +def test_other_origins_and_normal_goals_are_unchanged(store, origin, kind): + goal = create(store, origin=origin, kind=kind)['goal'] + before = row(store, goal['id']) + assert finish(store, Clock())['question_expiry']['count'] == 0 + assert row(store, goal['id']) == before + assert expirations(store) == [] + + +@pytest.mark.parametrize('state', ['completed', 'abandoned']) +def test_already_closed_questions_are_unchanged(store, state): + goal = create(store)['goal'] + Goals(store, clock=Clock()).update(goal['id'], expected_revision=goal['revision'], state=state) + before = row(store, goal['id']) + assert finish(store, Clock())['question_expiry']['count'] == 0 + assert row(store, goal['id']) == before + assert expirations(store) == [] + + +@pytest.mark.parametrize('origin', ['internal', 'host', 'admin']) +def test_merged_source_skipped_and_canonical_uses_own_origin_and_created_at(store, origin): + target = create(store, created='2026-10-01T00:00:00+00:00', origin=origin)['goal'] + source = create(store, created='2026-10-17T00:00:00+00:00') + assert source['dedup']['status'] == 'merged' + source_before = row(store, source['submitted_id']) + target_before = row(store, target['id']) + report = finish(store, Clock()) + expected = [target['id']] if origin == 'internal' else [] + assert report['question_expiry']['goal_ids'] == expected + assert [r['goal_id'] for r in expirations(store)] == expected + assert row(store, source['submitted_id']) == source_before + if origin != 'internal': + assert row(store, target['id']) == target_before + + +def test_revision_conflict_skips_until_next_run(store): + goal = create(store)['goal'] + engine, rid = start(store, Clock()) + with store.read() as conn: + run = dict(conn.execute('SELECT * FROM maintenance_runs WHERE id=?', (rid,)).fetchone()) + candidate, = engine._candidates(run, 'goals') + changed = Goals(store, clock=Clock()).update(goal['id'], expected_revision=goal['revision'], + content='想了解小林这次露营的地点', actor='admin') + engine._process_item(run, 'goals', candidate) + engine.run(rid) + assert row(store, goal['id'])['state'] == 'open' + assert expirations(store) == [] + assert engine.report(rid)['summary']['skipped']['reasons']['revision_conflict'] == 1 + assert finish(store, Clock())['question_expiry']['count'] == 1 + assert expirations(store)[0]['revision_before'] == changed['revision'] + + +@pytest.mark.parametrize('crash_after_commit', [False, True]) +def test_restart_resumes_atomic_goal_audit_and_cursor(tmp_path, monkeypatch, crash_after_commit): + path = tmp_path / 'restart.db' + clock = Clock() + original_store = Store(path) + try: + goal = create(original_store)['goal'] + engine, rid = start(original_store, clock) + if crash_after_commit: + original = engine._process_item + def interrupt(*args): + original(*args) + raise KeyboardInterrupt() + monkeypatch.setattr(engine, '_process_item', interrupt) + else: + def interrupt(*args): + raise KeyboardInterrupt() + monkeypatch.setattr(engine, '_record', interrupt) + with pytest.raises(KeyboardInterrupt): + engine.run(rid) + assert len(expirations(original_store)) == int(crash_after_commit) + assert row(original_store, goal['id'])['state'] == ('abandoned' if crash_after_commit else 'open') + finally: + original_store.close() + restarted = Store(path) + try: + engine = Maintenance(restarted, clock=clock) + engine.run(rid) + engine.run(rid) + assert engine.report(rid)['question_expiry']['goal_ids'] == [goal['id']] + assert finish(restarted, clock)['question_expiry']['count'] == 0 + assert len(expirations(restarted)) == 1 + finally: + restarted.close() + + +def test_run_cutoff_and_timezone_stay_fixed_across_resume(store): + store.set_setting('timezone', 'America/Los_Angeles') + goal = create(store, created='2026-10-10T23:30:00-07:00')['goal'] + clock = Clock('2026-10-18T06:59:59+00:00') + engine, rid = start(store, clock) + clock.value += timedelta(days=1) + store.set_setting('timezone', 'Asia/Shanghai') + engine.run(rid) + assert row(store, goal['id'])['state'] == 'open' + assert finish(store, clock)['question_expiry']['count'] == 1 + assert row(store, goal['id'])['closed_at'] == clock().isoformat() + + +def test_request_goal_bound_excludes_later_creation(store): + first = create(store)['goal'] + engine, rid = start(store, Clock()) + later = create(store, content='想了解小陈的陶艺作品')['goal'] + engine.run(rid) + assert engine.report(rid)['question_expiry']['goal_ids'] == [first['id']] + assert row(store, later['id'])['state'] == 'open' + assert finish(store, Clock())['question_expiry']['goal_ids'] == [later['id']] + + +@pytest.mark.parametrize('enabled,review', [(False, True), (True, False), (True, True)]) +def test_expiry_does_not_depend_on_model_or_basis_review_switch(store, enabled, review): + goal = create(store)['goal'] + assert finish(store, Clock(), enabled=enabled, review=review)['question_expiry']['goal_ids'] == [goal['id']] + + +def test_expiry_preserves_promise_memory_duplicates_and_notifications(store): + from test_retrieval import put + goal = create(store)['goal'] + other = create(store, content='想了解小陈的陶艺作品')['goal'] + mid = put(store, '我答应替小林照顾植物') + with store.write() as conn: + conn.execute('INSERT INTO goal_memories VALUES(?,?,1)', (goal['id'], mid)) + conn.execute("INSERT INTO goal_duplicates(goal_a,goal_b,status,created_at) VALUES(?,?,'possible',?)", + (goal['id'], other['id'], Clock()().isoformat())) + tables = ('memories', 'memory_revisions', 'goal_memories', 'goal_duplicates', 'notifications', 'goal_reminder_plans') + def snapshot(): + with store.read() as conn: + return {name: [tuple(r) for r in conn.execute('SELECT * FROM ' + name)] for name in tables} + before = snapshot() + finish(store, Clock(), enabled=True) + Goals(store, clock=Clock()).generate_notifications() + assert row(store, goal['id'])['state'] == 'abandoned' + assert snapshot() == before + + +@pytest.mark.parametrize('stored', [None, {}, {'overdue_reminders': False}]) +def test_missing_setting_uses_seven_days_without_persisting_default(store, stored): + from iris.goals import question_expiry_days + if stored is not None: + store.set_setting('goals', stored) + with store.read() as conn: + before = conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone() + assert question_expiry_days(conn) == 7 + after = conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone() + assert before == after + + +@pytest.mark.parametrize('value', [0, 366, -1, True, False, 7.0, '7', None]) +def test_setting_rejects_invalid_write_and_stored_values(store, value): + from iris.goals import question_expiry_days + with pytest.raises(GoalError) as caught: + Goals(store).configure_question_expiry(value) + assert caught.value.field == KEY + with store.read() as conn: + assert question_expiry_days(conn) == 7 + store.set_setting('goals', {KEY: value}) + with store.read() as conn, pytest.raises(GoalError): + question_expiry_days(conn) + + +@pytest.mark.parametrize('days', [1, 7, 365]) +def test_setting_write_persists_and_existing_admin_patch_preserves_internal_key(store, days): + from iris.goals import question_expiry_days + Goals(store).configure_question_expiry(days) + with TestClient(create_app(store=store, configs={}), base_url='http://127.0.0.1', + client=('127.0.0.1', 1000)) as client: + client.app.state.scheduler.stop() + login_admin(client) + response = client.patch('/admin/api/settings/goals', json={'default_reminder_minutes': 90, 'overdue_reminders': False}) + assert response.status_code == 200 + assert response.json()['goals'] == {'default_reminder_minutes': 90, 'overdue_reminders': False} + assert client.patch('/admin/api/settings/goals', json={KEY: days}).status_code == 400 + with store.read() as conn: + assert question_expiry_days(conn) == days + assert json.loads(conn.execute("SELECT value_json FROM runtime_settings WHERE key='goals'").fetchone()[0]) == { + KEY: days, 'default_reminder_minutes': 90, 'overdue_reminders': False} + + +def test_host_goal_fields_unchanged_when_expired(store): + goal = create(store)['goal'] + with TestClient(create_app(store=store, configs={}), base_url='http://127.0.0.1', + client=('127.0.0.1', 1000)) as client: + client.app.state.scheduler.stop() + authorize_host(client) + before = client.get('/api/v1/goals').json()['items'][0] + finish(store, Clock()) + after = client.get('/api/v1/goals?state=abandoned').json()['items'][0] + assert before.keys() == after.keys() + assert after['id'] == goal['id'] and after['state'] == 'abandoned' + assert after['closed_by'] == 'maintenance' + assert client.get('/api/v1/goals?state=open').json()['items'] == [] + + +def test_expiry_cancels_inflight_dedup_without_publishing_or_merging(store): + from test_goal_dedup_apply import Judge + from iris.goals import write_learning_goal + store.set_setting('goal_dedup_judge', {'enabled': True, 'method': 'C', 'budget_seconds': 1}) + clock = Clock('2026-10-10T01:00:00+00:00') + judge = Judge(store) + goals = Goals(store, clock=clock, gateway=judge) + target = goals.create(content='想了解小林的露营经历', kind='question')['goal'] + with store.write() as conn: + conn.execute("INSERT INTO entries(id,name,platform,kind) VALUES('A','A','test','group')") + new = write_learning_goal(conn, content='想了解小林的露营经验', kind='question', deadline=None, + entry_id='A', evidence=[], current=clock()) + gid = new['submitted_id'] + assert new['dedup']['status'] == 'pending' + clock.value = Clock()() + judge.hook = lambda: finish(store, clock) + result = goals.review(gid) + assert result['goal']['state'] == 'abandoned' + assert row(store, gid)['merged_into'] is None + assert row(store, target['id'])['state'] == 'open' + with store.read() as conn: + assert conn.execute('SELECT state FROM goal_dedup_jobs WHERE goal_id=?', (gid,)).fetchone()[0] == 'cancelled' + assert conn.execute('SELECT COUNT(*) FROM notifications').fetchone()[0] == 0 + assert goals.pending_reviews() == [] and goals.pull()['items'] == [] + + +def test_invalid_stored_setting_fails_safely_without_closing(store): + goal = create(store)['goal'] + store.set_setting('goals', {KEY: 0}) + report = finish(store, Clock()) + assert row(store, goal['id'])['state'] == 'open' + assert expirations(store) == [] + assert report['question_expiry']['count'] == 0 + assert report['summary']['failed']['object_ids'] == [goal['id']] + + +def test_scheduled_full_maintenance_expires_old_internal_question(store): + goal = create(store)['goal'] + clock = Clock() + store.set_setting('consolidation', {'enabled': False}) + engine = Maintenance(store, clock=clock) + rid = engine.request(trigger='scheduled', schedule_key='2026-10-18') + engine.run(rid) + assert engine.report(rid)['state'] == 'completed' + assert engine.report(rid)['question_expiry']['goal_ids'] == [goal['id']]