Files
hermes-agent/tests/cron/test_scheduled_occurrence.py
T
Teknium 96a903c9d2 fix: keep ambiguous cron occurrence snapshots nullable
Canonicalize the accepted raw next_run_at value before fast-forward rather
than its timezone-interpreted datetime. Legacy naive values remain runnable
but cannot establish an exact UTC identity. Preserve repaired aware slots.

Extend the existing identity invariant with the naive-slot control, and use
real ledger creation in the provider ordering test instead of an invented
execution ID that cannot pass the owner-fenced occurrence setter.

Live validation: actual builtin script run was RED (invented UTC identity)
and is now GREEN (NULL identity). Repeated builtin/provider/worker rollback
A/B remains 2 writes on base versus 1 on head, with distinct/manual controls.
Canonical cron regression rerun is queued under the campaign lock.
2026-09-07 05:57:26 -07:00

160 lines
7.2 KiB
Python

"""Completed scheduled attempts survive a jobs.json rollback, not just a process restart."""
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
_FIRE = """
import json
import os
from pathlib import Path
import subprocess
import sys
from cron import jobs, scheduler
from cron.scheduler_provider import InProcessCronScheduler
if sys.argv[1] == 'builtin':
scheduler.tick(verbose=False, sync=True)
else:
provider = InProcessCronScheduler()
for job in jobs.load_jobs():
if sys.argv[1] != 'worker':
provider.fire_due(job['id'], force=sys.argv[1] == 'manual')
continue
from cron.executions import mark_execution_handoff_pending
claim = provider.claim_fire(job['id'])
if claim is None:
continue
mark_execution_handoff_pending(claim['execution_id'])
home = Path(os.environ['HERMES_HOME'])
payload = home / (claim['execution_id'] + '.json')
ack = home / (claim['execution_id'] + '.ready')
payload.write_text(json.dumps({'job': claim, 'profile_home': str(home),
'multiplex_active': False}))
subprocess.run([sys.executable, '-m', 'cron.scheduler',
'--external-worker-file', str(payload), '--ack-file', str(ack)],
stdin=subprocess.DEVNULL, check=True, timeout=60)
assert json.loads(ack.read_text())['pid'] != os.getpid()
"""
def _fire(home, mode):
env = {k: v for k, v in os.environ.items()
if not k.startswith(('HERMES_', '_HERMES_'))
and not k.endswith(('_API_KEY', '_TOKEN'))}
env['HERMES_HOME'] = str(home)
env['PYTHONPATH'] = str(Path(__file__).resolve().parents[2])
result = subprocess.run([sys.executable, '-c', _FIRE, mode], env=env,
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize('mode', ['builtin', 'provider', 'worker'])
def test_completed_occurrence_survives_restart_and_prestamp_rollback(tmp_path, mode):
from datetime import timedelta
from hermes_time import now
home = tmp_path / mode
cron = home / 'cron'
cron.mkdir(parents=True)
effect = home / 'effects.txt'
(home / 'scripts').mkdir()
script = home / 'scripts' / 'effect.py'
script.write_text(f"from pathlib import Path\np = Path({str(effect)!r})\n"
"with p.open('a') as f: f.write('effect\\n')\nprint('done')\n")
slot = (now() - timedelta(minutes=30)).isoformat()
job = {'id': 'occurrence', 'name': 'occurrence', 'prompt': '',
'schedule': {'kind': 'interval', 'minutes': 240},
'next_run_at': slot, 'enabled': True, 'state': 'scheduled',
'script': str(script), 'no_agent': True, 'deliver': 'local',
'repeat': {'times': None, 'completed': 0}}
snapshot = json.dumps({'jobs': [job]})
store = cron / 'jobs.json'
store.write_text(snapshot)
_fire(home, mode)
assert effect.read_text().splitlines() == ['effect']
store.write_text(snapshot) # no last_dispatch stamp, no post-completion fields
_fire(home, mode) # a fresh interpreter, same authoritative ledger
assert effect.read_text().splitlines() == ['effect'], 'completed occurrence executed twice'
# A different missed slot must remain runnable despite the completed row.
job['next_run_at'] = (now() - timedelta(minutes=10)).isoformat()
store.write_text(json.dumps({'jobs': [job]}))
_fire(home, mode)
assert len(effect.read_text().splitlines()) == 2
# Manual force is not a completion of the pending scheduled slot.
job['next_run_at'] = (now() - timedelta(minutes=5)).isoformat()
pending = json.dumps({'jobs': [job]})
store.write_text(pending)
_fire(home, 'manual')
store.write_text(pending)
_fire(home, mode)
assert len(effect.read_text().splitlines()) == 4
def test_ledger_migration_and_completion_identity(tmp_path, monkeypatch):
import sqlite3
from cron import executions, jobs
from cron.occurrences import completed_occurrence
from cron.scheduler_provider import InProcessCronScheduler
db = tmp_path / 'executions.db'
monkeypatch.setattr(executions, 'EXECUTIONS_FILE', db)
# Pre-migration schema, not initialized by the implementation under test.
with sqlite3.connect(db) as conn:
conn.execute('''CREATE TABLE executions (
id TEXT PRIMARY KEY, job_id TEXT NOT NULL, source TEXT NOT NULL,
process_id TEXT NOT NULL, pid INTEGER NOT NULL, process_started_at INTEGER,
status TEXT NOT NULL, claimed_at TEXT NOT NULL,
started_at TEXT, finished_at TEXT, error TEXT)''')
conn.execute("INSERT INTO executions VALUES "
"('legacy','job','builtin','old',-1,NULL,'completed',"
"'2026-01-01T00:00:00Z',NULL,NULL,NULL)")
slot = '2026-01-01T00:00:00+00:00'
job = {'id': 'job'}
assert not completed_occurrence(job, slot)
with sqlite3.connect(db) as conn:
assert conn.execute('SELECT id, scheduled_instant FROM executions').fetchall() == [('legacy', None)]
for status in ('failed', 'unknown', 'running', 'claimed'):
row = executions.create_execution('job', source='control', scheduled_instant=slot)
with sqlite3.connect(db) as conn:
conn.execute('UPDATE executions SET status=? WHERE id=?', (status, row['id']))
assert not completed_occurrence(job, slot)
row = executions.create_execution('job', source='builtin', scheduled_instant=slot)
executions.finish_execution(row['id'], success=True)
executions.create_execution('job', source='manual') # latest row is not the completed one
assert completed_occurrence(job, '2026-01-01T08:00:00+0800')
assert not completed_occurrence(job, '2026-01-01T00:00:01Z')
assert not completed_occurrence({'id': 'other'}, slot)
# Provider capture is before next_run_at advances; adoption cannot lose identity.
with jobs.use_cron_store(tmp_path / 'cron'):
stored = jobs.create_job(prompt='test', schedule='every 4h')
rows = jobs.load_jobs()
rows[0]['next_run_at'] = slot
jobs.save_jobs(rows)
claim = InProcessCronScheduler().claim_fire(stored['id'])
assert claim is not None
assert claim['_scheduled_instant'] == slot
assert claim['next_run_at'] != slot
assert '_scheduled_instant' not in jobs.load_jobs()[0]
executions.mark_execution_handoff_pending(claim['execution_id'])
adopted = executions.adopt_claimed_execution(claim['execution_id'])
assert adopted is not None
assert adopted['scheduled_instant'] == slot
# A runnable legacy wall-clock value cannot establish an exact UTC identity.
from datetime import timedelta
from hermes_time import now
naive = jobs.create_job(prompt='legacy', schedule='every 4h')
rows = jobs.load_jobs()
for item in rows:
if item['id'] == naive['id']:
item['next_run_at'] = (now() - timedelta(minutes=10)).replace(tzinfo=None).isoformat()
jobs.save_jobs(rows)
due = next(item for item in jobs.get_due_jobs() if item['id'] == naive['id'])
assert due['_scheduled_instant'] is None