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:
bot-ci committed 2026-11-03 02:35:13 +08:00
1 parent dcf37c4a1d
commit f6bef59e07
7 files changed
+630 -24

No files matched your search

+131
View File
@@ -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/错误路径测试全部通过")