fix(scheduler): daily slot off-plan + cross-midnight resume (#8)
- Cross-midnight: unfinished yesterday seals ASAP with yesterday's stat_date; force seal ignores today's snapshot_at gate when stat_date < today. - Off-plan already_completed: compare completion started_at (not finished_at) to today's planned slot; earlier → still run at slot for all daily tasks. - Cold start: bootstrap task:state from task:last / seed markers. - ensure_pending: do not glue new due onto a different day's unfinished state. - run_if_needed(force): keep_scraped True for seal, False for CLI --force. - Scheduler due_reader: pick up disk due_at each loop (seed→evening seal).
This commit is contained in:
1 parent
dcf37c4a1d
commit
f6bef59e07
7 files changed
+630
-24
No files matched your search
@@ -805,6 +805,134 @@ def test_daytime_seed_marker_does_not_force_without_slot():
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def test_off_plan_completion_uses_started_at_not_finished_at():
|
||||
"""规则2:完成记录 started_at 早于今日计划点 → off-plan;禁用 finished_at。"""
|
||||
from datetime import datetime
|
||||
from reaction_board.timeutil import SHANGHAI_TZ
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
assert run_cli(["init", "--config", str(path)])[0] == 0
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
slot = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
# 白天开始、晚上才结束:started_at < slot,finished_at > slot
|
||||
day_start = datetime(2026, 11, 3, 10, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
day_end = datetime(2026, 11, 3, 23, 30, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
repo.kv_set(
|
||||
"task:last:snapshot",
|
||||
json.dumps({
|
||||
"name": "snapshot",
|
||||
"ok": True,
|
||||
"skipped": False,
|
||||
"started_at": int(day_start),
|
||||
"finished_at": int(day_end),
|
||||
"error": None,
|
||||
"summary": {"stat_date": "2026-11-03"},
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
assert cli._last_success_started_at(repo, "snapshot") == float(int(day_start))
|
||||
assert cli._off_plan_completion(repo, "snapshot", "23:15", slot) is True
|
||||
# 若误用 finished_at 会判成计划内;started_at 必须仍为 off-plan
|
||||
assert day_end >= slot
|
||||
assert day_start < slot
|
||||
|
||||
# 计划内:started_at == slot → 不可 off-plan
|
||||
repo.kv_set(
|
||||
"task:last:snapshot",
|
||||
json.dumps({
|
||||
"name": "snapshot",
|
||||
"ok": True,
|
||||
"skipped": False,
|
||||
"started_at": int(slot),
|
||||
"finished_at": int(slot + 600),
|
||||
"error": None,
|
||||
"summary": {"stat_date": "2026-11-03"},
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
assert cli._off_plan_completion(repo, "snapshot", "23:15", slot) is False
|
||||
assert cli._off_plan_completion(
|
||||
repo, "snapshot", "23:15", slot + 60,
|
||||
) is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cross_midnight_force_seal_ignores_todays_slot_gate():
|
||||
"""规则1:昨天 seed 未完成,跨日后即便未到今日 23:15 也 force。"""
|
||||
from datetime import datetime
|
||||
from reaction_board.timeutil import SHANGHAI_TZ
|
||||
|
||||
now = datetime(2026, 11, 3, 0, 10, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
assert cli._reached_daily_slot("23:15", now) is False
|
||||
assert cli._should_force_daily_seal(
|
||||
seeded=True,
|
||||
stat_date="2026-11-02",
|
||||
today="2026-11-03",
|
||||
slot_hhmm="23:15",
|
||||
now=now,
|
||||
) is True
|
||||
# 同日白天仍不得 force
|
||||
day = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
assert cli._should_force_daily_seal(
|
||||
seeded=True,
|
||||
stat_date="2026-11-03",
|
||||
today="2026-11-03",
|
||||
slot_hhmm="23:15",
|
||||
now=day,
|
||||
) is False
|
||||
night = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
assert cli._should_force_daily_seal(
|
||||
seeded=True,
|
||||
stat_date="2026-11-03",
|
||||
today="2026-11-03",
|
||||
slot_hhmm="23:15",
|
||||
now=night,
|
||||
) is True
|
||||
|
||||
|
||||
def test_initial_daily_bootstraps_failed_last_without_state():
|
||||
"""A: 无 state、仅 task:last 失败 → 启动 next_run 立刻可补,不退墙钟。"""
|
||||
from datetime import datetime
|
||||
from reaction_board.timeutil import SHANGHAI_TZ, next_daily_run
|
||||
from reaction_board.task_state import STATUS_FAILED, load_state
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
assert run_cli(["init", "--config", str(path)])[0] == 0
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
now = datetime(2026, 11, 3, 18, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
repo.kv_set(
|
||||
"task:last:snapshot",
|
||||
json.dumps({
|
||||
"name": "snapshot",
|
||||
"ok": False,
|
||||
"skipped": False,
|
||||
"started_at": int(now - 900),
|
||||
"finished_at": int(now - 800),
|
||||
"error": "net",
|
||||
"summary": {"stat_date": "2026-11-03"},
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
wall = next_daily_run(now, "23:15")
|
||||
nxt = cli._initial_daily_next_run(
|
||||
repo, "snapshot", "23:15", now=now,
|
||||
)
|
||||
assert nxt <= now
|
||||
assert nxt != wall
|
||||
state = load_state(repo, "snapshot")
|
||||
assert state is not None
|
||||
assert state.status == STATUS_FAILED
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main_test():
|
||||
test_init_and_status()
|
||||
test_missing_config()
|
||||
@@ -830,6 +958,9 @@ def main_test():
|
||||
test_reached_daily_slot_gates_force_seed()
|
||||
test_restart_after_seed_schedules_snapshot_at_wall_clock()
|
||||
test_daytime_seed_marker_does_not_force_without_slot()
|
||||
test_off_plan_completion_uses_started_at_not_finished_at()
|
||||
test_cross_midnight_force_seal_ignores_todays_slot_gate()
|
||||
test_initial_daily_bootstraps_failed_last_without_state()
|
||||
print("OK: CLI init/sync/snapshot/snapshot-seed/auth-check/run/status/backfill/错误路径测试全部通过")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user