From 5ddeb93033177b98f87e46f1d99cdc2f9c81ff5a Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Fri, 27 Feb 2026 15:57:09 -0600 Subject: [PATCH] Fix 1: AutoCora prioritized sweep and KV-free result polling Replace single-day task filter with multi-pass sweep when no explicit target_date: (1) due today, (2) overdue + current month tag, (3) last month tag, (4) look-ahead 2 days. Deduplicate across passes. Remove KV store from submit (dedup by job file existence) and result poller (scan results/ folder directly, move to processed/ after handling). Scheduler auto-submit no longer passes explicit target_date. Co-Authored-By: Claude Opus 4.6 --- cheddahbot/scheduler.py | 63 ++++----- cheddahbot/tools/autocora.py | 254 +++++++++++++++++++++++------------ tests/test_autocora.py | 251 +++++++++++++++++++--------------- 3 files changed, 329 insertions(+), 239 deletions(-) diff --git a/cheddahbot/scheduler.py b/cheddahbot/scheduler.py index d35c9a9..833d010 100644 --- a/cheddahbot/scheduler.py +++ b/cheddahbot/scheduler.py @@ -612,69 +612,52 @@ class Scheduler: self._interruptible_wait(interval, self._force_autocora) def _auto_submit_cora_jobs(self): - """Auto-submit AutoCora jobs for tasks due today.""" + """Auto-submit AutoCora jobs using multi-pass sweep (no explicit date).""" from .tools.autocora import submit_autocora_jobs if not self.config.clickup.api_token: return - today = datetime.now(UTC).strftime("%Y-%m-%d") ctx = { "config": self.config, "db": self.db, "agent": self.agent, } - result = submit_autocora_jobs(target_date=today, ctx=ctx) - log.info("AutoCora auto-submit (%s): %s", today, result) + result = submit_autocora_jobs(ctx=ctx) + log.info("AutoCora auto-submit (sweep): %s", result) def _poll_autocora_results(self): - """Check for completed AutoCora results and update ClickUp tasks.""" + """Check for completed AutoCora results and update ClickUp tasks. + + Scans the results folder for .result files. Each file contains JSON + with task_ids and status. After processing, moves the file to + results/processed/ to prevent re-processing. + """ from .tools.autocora import _parse_result autocora = self.config.autocora results_dir = Path(autocora.results_dir) - # Find submitted jobs in KV - kv_entries = self.db.kv_scan("autocora:job:") - submitted = [] - for key, value in kv_entries: - try: - state = json.loads(value) - if state.get("status") == "submitted": - submitted.append((key, state)) - except json.JSONDecodeError: - continue - - if not submitted: - return - if not results_dir.exists(): log.debug("AutoCora results dir does not exist: %s", results_dir) return + result_files = list(results_dir.glob("*.result")) + if not result_files: + return + client = self._get_clickup_client() if self.config.clickup.api_token else None + processed_dir = results_dir / "processed" - for kv_key, state in submitted: - job_id = state.get("job_id", "") - if not job_id: - continue - - result_path = results_dir / f"{job_id}.result" - if not result_path.exists(): - continue - + for result_path in result_files: raw = result_path.read_text(encoding="utf-8").strip() result_data = _parse_result(raw) - task_ids = result_data.get("task_ids") or state.get("task_ids", []) + task_ids = result_data.get("task_ids", []) status = result_data.get("status", "UNKNOWN") - keyword = state.get("keyword", "") + keyword = result_data.get("keyword", result_path.stem) if status == "SUCCESS": - state["status"] = "completed" - state["completed_at"] = datetime.now(UTC).isoformat() - self.db.kv_set(kv_key, json.dumps(state)) - if client and task_ids: for tid in task_ids: client.update_task_status(tid, autocora.success_status) @@ -690,11 +673,6 @@ class Scheduler: elif status == "FAILURE": reason = result_data.get("reason", "unknown error") - state["status"] = "failed" - state["error"] = reason - state["completed_at"] = datetime.now(UTC).isoformat() - self.db.kv_set(kv_key, json.dumps(state)) - if client and task_ids: for tid in task_ids: client.update_task_status(tid, autocora.error_status) @@ -710,6 +688,13 @@ class Scheduler: log.info("AutoCora result for '%s': %s", keyword, status) + # Move result file to processed/ + processed_dir.mkdir(exist_ok=True) + try: + result_path.rename(processed_dir / result_path.name) + except OSError as e: + log.warning("Could not move result file %s: %s", result_path.name, e) + # ── Folder Watcher ── def _folder_watch_loop(self): diff --git a/cheddahbot/tools/autocora.py b/cheddahbot/tools/autocora.py index 4f68cf7..3b8dcbc 100644 --- a/cheddahbot/tools/autocora.py +++ b/cheddahbot/tools/autocora.py @@ -52,15 +52,15 @@ def _get_clickup_client(ctx: dict): def _find_qualifying_tasks(client, config, target_date: str, categories: list[str]): - """Find 'to do' tasks in cora_categories due on target_date. + """Find 'to do' tasks in cora_categories due on target_date (single day). + Used when target_date is explicitly provided. Returns list of ClickUpTask objects. """ space_id = config.clickup.space_id if not space_id: return [] - # Parse target date to filter by due_date range (full day) try: dt = datetime.strptime(target_date, "%Y-%m-%d").replace(tzinfo=UTC) except ValueError: @@ -78,10 +78,8 @@ def _find_qualifying_tasks(client, config, target_date: str, categories: list[st qualifying = [] for task in tasks: - # Must be in one of the cora categories if task.task_type not in categories: continue - # Must have a due_date within the target day if not task.due_date: continue try: @@ -95,6 +93,130 @@ def _find_qualifying_tasks(client, config, target_date: str, categories: list[st return qualifying +def _find_qualifying_tasks_sweep(client, config, categories: list[str]): + """Multi-pass sweep for qualifying tasks when no explicit date is given. + + Pass 1: Tasks due today + Pass 2: Overdue tasks tagged with current month (e.g. "feb26") + Pass 3: Tasks tagged with last month (e.g. "jan26"), still "to do" + Pass 4: Tasks due in next 2 days (look-ahead) + + Deduplicates across passes by task ID. + Returns list of ClickUpTask objects. + """ + space_id = config.clickup.space_id + if not space_id: + return [] + + now = datetime.now(UTC) + today_start_ms = int( + now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000 + ) + today_end_ms = today_start_ms + 24 * 60 * 60 * 1000 + lookahead_end_ms = today_start_ms + 3 * 24 * 60 * 60 * 1000 # +2 days + + # Current and last month tags (e.g. "feb26", "jan26") + current_month_tag = now.strftime("%b%y").lower() + # Go back one month + if now.month == 1: + last_month = now.replace(year=now.year - 1, month=12) + else: + last_month = now.replace(month=now.month - 1) + last_month_tag = last_month.strftime("%b%y").lower() + + # Fetch all "to do" tasks with due dates up to lookahead + all_tasks = client.get_tasks_from_space( + space_id, + statuses=["to do"], + due_date_lt=lookahead_end_ms, + ) + + # Filter to cora categories + cora_tasks = [t for t in all_tasks if t.task_type in categories] + + seen_ids: set[str] = set() + qualifying: list = [] + + def _add(task): + if task.id not in seen_ids: + seen_ids.add(task.id) + qualifying.append(task) + + # Pass 1: Due today + for task in cora_tasks: + if not task.due_date: + continue + try: + due_ms = int(task.due_date) + except (ValueError, TypeError): + continue + if today_start_ms <= due_ms < today_end_ms: + _add(task) + + # Pass 2: Overdue + tagged with current month + for task in cora_tasks: + if not task.due_date: + continue + try: + due_ms = int(task.due_date) + except (ValueError, TypeError): + continue + if due_ms < today_start_ms and current_month_tag in task.tags: + _add(task) + + # Pass 3: Tagged with last month, still "to do" + for task in cora_tasks: + if last_month_tag in task.tags: + _add(task) + + # Pass 4: Look-ahead (due in next 2 days, excluding today which was pass 1) + for task in cora_tasks: + if not task.due_date: + continue + try: + due_ms = int(task.due_date) + except (ValueError, TypeError): + continue + if today_end_ms <= due_ms < lookahead_end_ms: + _add(task) + + log.info( + "AutoCora sweep: %d qualifying tasks " + "(today=%d, overdue+month=%d, last_month=%d, lookahead=%d)", + len(qualifying), + sum(1 for t in qualifying if _is_due_today(t, today_start_ms, today_end_ms)), + sum(1 for t in qualifying if _is_overdue_with_tag(t, today_start_ms, current_month_tag)), + sum(1 for t in qualifying if last_month_tag in t.tags), + sum(1 for t in qualifying if _is_lookahead(t, today_end_ms, lookahead_end_ms)), + ) + + return qualifying + + +def _is_due_today(task, start_ms, end_ms) -> bool: + try: + due = int(task.due_date) + return start_ms <= due < end_ms + except (ValueError, TypeError): + return False + + +def _is_overdue_with_tag(task, today_start_ms, tag) -> bool: + try: + due = int(task.due_date) + return due < today_start_ms and tag in task.tags + except (ValueError, TypeError): + return False + + +def _is_lookahead(task, today_end_ms, lookahead_end_ms) -> bool: + try: + due = int(task.due_date) + return today_end_ms <= due < lookahead_end_ms + except (ValueError, TypeError): + return False + + def _find_all_todo_tasks(client, config, categories: list[str]): """Find ALL 'to do' tasks in cora_categories (no date filter). @@ -165,7 +287,8 @@ def _group_by_keyword(tasks, all_tasks): @tool( "submit_autocora_jobs", - "Submit Cora SEO report jobs for ClickUp tasks due on a given date. " + "Submit Cora SEO report jobs for ClickUp tasks. Uses a multi-pass sweep " + "(today, overdue, last month, look-ahead) unless a specific date is given. " "Writes job JSON files to the AutoCora shared folder queue.", category="autocora", ) @@ -173,32 +296,33 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str: """Submit AutoCora jobs for qualifying ClickUp tasks. Args: - target_date: Date to check (YYYY-MM-DD). Defaults to today. + target_date: Date to check (YYYY-MM-DD). Empty = multi-pass sweep. ctx: Injected context with config, db, etc. """ if not ctx: return "Error: context not available" config = ctx["config"] - db = ctx["db"] autocora = config.autocora if not autocora.enabled: return "AutoCora is disabled in config." - if not target_date: - target_date = datetime.now(UTC).strftime("%Y-%m-%d") - if not config.clickup.api_token: return "Error: ClickUp API token not configured" client = _get_clickup_client(ctx) - # Find qualifying tasks (due on target_date, in cora_categories, status "to do") - qualifying = _find_qualifying_tasks(client, config, target_date, autocora.cora_categories) + # Find qualifying tasks — sweep or single-day + if target_date: + qualifying = _find_qualifying_tasks(client, config, target_date, autocora.cora_categories) + label = target_date + else: + qualifying = _find_qualifying_tasks_sweep(client, config, autocora.cora_categories) + label = "sweep" if not qualifying: - return f"No qualifying tasks found for {target_date}." + return f"No qualifying tasks found ({label})." # Find ALL to-do tasks in cora categories for sibling keyword matching all_todo = _find_all_todo_tasks(client, config, autocora.cora_categories) @@ -217,19 +341,13 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str: skipped = [] for kw_lower, group in groups.items(): - # Check KV for existing submission - kv_key = f"autocora:job:{kw_lower}" - existing = db.kv_get(kv_key) - if existing: - try: - state = json.loads(existing) - if state.get("status") == "submitted": - skipped.append(group["keyword"]) - continue - except json.JSONDecodeError: - pass + # Check if a job file already exists for this keyword (dedup by file) + existing_jobs = list(jobs_dir.glob(f"job-*-{_slugify(group['keyword'])}*.json")) + if existing_jobs: + skipped.append(group["keyword"]) + continue - # Write job file + # Write job file (contains task_ids for the result poller) job_id = _make_job_id(group["keyword"]) job_data = { "keyword": group["keyword"], @@ -239,17 +357,6 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str: job_path = jobs_dir / f"{job_id}.json" job_path.write_text(json.dumps(job_data, indent=2), encoding="utf-8") - # Track in KV - kv_state = { - "status": "submitted", - "job_id": job_id, - "keyword": group["keyword"], - "url": group["url"], - "task_ids": group["task_ids"], - "submitted_at": datetime.now(UTC).isoformat(), - } - db.kv_set(kv_key, json.dumps(kv_state)) - # Move ClickUp tasks to "automation underway" for tid in group["task_ids"]: client.update_task_status(tid, "automation underway") @@ -258,13 +365,13 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str: log.info("Submitted AutoCora job: %s → %s", group["keyword"], job_id) # Build response - lines = [f"AutoCora submission for {target_date}:"] + lines = [f"AutoCora submission ({label}):"] if submitted: lines.append(f"\nSubmitted {len(submitted)} job(s):") for kw in submitted: lines.append(f" - {kw}") if skipped: - lines.append(f"\nSkipped {len(skipped)} (already submitted):") + lines.append(f"\nSkipped {len(skipped)} (job file already exists):") for kw in skipped: lines.append(f" - {kw}") if alerts: @@ -278,77 +385,51 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str: @tool( "poll_autocora_results", "Poll the AutoCora results folder for completed Cora SEO report jobs. " - "Updates ClickUp task statuses based on results.", + "Scans for .result files, reads task_ids from the JSON, updates ClickUp, " + "then moves the result file to a processed/ subfolder.", category="autocora", ) def poll_autocora_results(ctx: dict | None = None) -> str: """Poll for AutoCora results and update ClickUp tasks. - Args: - ctx: Injected context with config, db, etc. + Scans the results folder for .result files. Each result file is JSON + containing {status, task_ids, keyword, ...}. After processing, the + result file is moved to results/processed/ to avoid re-processing. """ if not ctx: return "Error: context not available" config = ctx["config"] - db = ctx["db"] autocora = config.autocora if not autocora.enabled: return "AutoCora is disabled in config." - # Find all submitted jobs in KV - kv_entries = db.kv_scan("autocora:job:") - submitted = [] - for key, value in kv_entries: - try: - state = json.loads(value) - if state.get("status") == "submitted": - submitted.append((key, state)) - except json.JSONDecodeError: - continue - - if not submitted: - return "No pending AutoCora jobs to check." - results_dir = Path(autocora.results_dir) if not results_dir.exists(): return f"Results directory does not exist: {results_dir}" + # Scan for .result files + result_files = list(results_dir.glob("*.result")) + if not result_files: + return "No result files found in results folder." + client = None if config.clickup.api_token: client = _get_clickup_client(ctx) + processed_dir = results_dir / "processed" processed = [] - still_pending = [] - for kv_key, state in submitted: - job_id = state.get("job_id", "") - if not job_id: - continue - - result_path = results_dir / f"{job_id}.result" - if not result_path.exists(): - still_pending.append(state.get("keyword", job_id)) - continue - - # Read and parse result + for result_path in result_files: raw = result_path.read_text(encoding="utf-8").strip() result_data = _parse_result(raw) - # Get task_ids: prefer result file, fall back to KV - task_ids = result_data.get("task_ids") or state.get("task_ids", []) - + task_ids = result_data.get("task_ids", []) status = result_data.get("status", "UNKNOWN") - keyword = state.get("keyword", "") + keyword = result_data.get("keyword", result_path.stem) if status == "SUCCESS": - # Update KV - state["status"] = "completed" - state["completed_at"] = datetime.now(UTC).isoformat() - db.kv_set(kv_key, json.dumps(state)) - - # Update ClickUp tasks if client and task_ids: for tid in task_ids: client.update_task_status(tid, autocora.success_status) @@ -359,12 +440,6 @@ def poll_autocora_results(ctx: dict | None = None) -> str: elif status == "FAILURE": reason = result_data.get("reason", "unknown error") - state["status"] = "failed" - state["error"] = reason - state["completed_at"] = datetime.now(UTC).isoformat() - db.kv_set(kv_key, json.dumps(state)) - - # Update ClickUp tasks if client and task_ids: for tid in task_ids: client.update_task_status(tid, autocora.error_status) @@ -378,16 +453,19 @@ def poll_autocora_results(ctx: dict | None = None) -> str: else: processed.append(f"UNKNOWN: {keyword} (status={status})") + # Move result file to processed/ so it's not re-processed + processed_dir.mkdir(exist_ok=True) + try: + result_path.rename(processed_dir / result_path.name) + except OSError as e: + log.warning("Could not move result file %s: %s", result_path.name, e) + # Build response lines = ["AutoCora poll results:"] if processed: lines.append(f"\nProcessed {len(processed)} result(s):") for p in processed: lines.append(f" - {p}") - if still_pending: - lines.append(f"\nStill pending ({len(still_pending)}):") - for kw in still_pending: - lines.append(f" - {kw}") return "\n".join(lines) diff --git a/tests/test_autocora.py b/tests/test_autocora.py index 8294ff5..9cffaa4 100644 --- a/tests/test_autocora.py +++ b/tests/test_autocora.py @@ -12,6 +12,7 @@ import pytest from cheddahbot.config import AutoCoraConfig, ClickUpConfig, Config from cheddahbot.tools.autocora import ( + _find_qualifying_tasks_sweep, _group_by_keyword, _make_job_id, _parse_result, @@ -36,6 +37,7 @@ class FakeTask: task_type: str = "Content Creation" due_date: str = "" custom_fields: dict[str, Any] = field(default_factory=dict) + tags: list[str] = field(default_factory=list) @pytest.fixture() @@ -147,11 +149,12 @@ class TestGroupByKeyword: assert len(groups) == 0 assert any("missing Keyword" in a for a in alerts) - def test_missing_imsurl(self): + def test_missing_imsurl_uses_fallback(self): + """Missing IMSURL gets a fallback blank URL.""" tasks = [FakeTask(id="t1", name="No URL", custom_fields={"Keyword": "test"})] groups, alerts = _group_by_keyword(tasks, tasks) - assert len(groups) == 0 - assert any("missing IMSURL" in a for a in alerts) + assert len(groups) == 1 + assert groups["test"]["url"] == "https://seotoollab.com/blank.html" def test_sibling_tasks(self): """Tasks sharing a keyword from all_tasks should be included.""" @@ -219,8 +222,8 @@ class TestSubmitAutocoraJobs: assert job_data["url"] == "http://example.com" assert job_data["task_ids"] == ["t1"] - def test_submit_tracks_kv(self, ctx, monkeypatch): - """KV store tracks submitted jobs.""" + def test_submit_writes_job_with_task_ids(self, ctx, monkeypatch): + """Job file contains task_ids for the result poller.""" task = FakeTask( id="t1", name="Test", @@ -236,14 +239,14 @@ class TestSubmitAutocoraJobs: submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) - raw = ctx["db"].kv_get("autocora:job:test keyword") - assert raw is not None - state = json.loads(raw) - assert state["status"] == "submitted" - assert "t1" in state["task_ids"] + jobs_dir = Path(ctx["config"].autocora.jobs_dir) + job_files = list(jobs_dir.glob("job-*.json")) + assert len(job_files) == 1 + data = json.loads(job_files[0].read_text()) + assert "t1" in data["task_ids"] def test_duplicate_prevention(self, ctx, monkeypatch): - """Already-submitted keywords are skipped.""" + """Already-submitted keywords are skipped (job file exists).""" task = FakeTask( id="t1", name="Test", @@ -260,7 +263,7 @@ class TestSubmitAutocoraJobs: # First submit submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) - # Second submit — should skip + # Second submit — should skip (job file already exists) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "Skipped 1" in result @@ -282,8 +285,8 @@ class TestSubmitAutocoraJobs: result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "missing Keyword" in result - def test_missing_imsurl_alert(self, ctx, monkeypatch): - """Tasks without IMSURL field produce alerts.""" + def test_missing_imsurl_uses_fallback(self, ctx, monkeypatch): + """Tasks without IMSURL use fallback URL and still submit.""" task = FakeTask( id="t1", name="No URL Task", @@ -298,7 +301,7 @@ class TestSubmitAutocoraJobs: ) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) - assert "missing IMSURL" in result + assert "Submitted 1 job" in result # --------------------------------------------------------------------------- @@ -312,33 +315,18 @@ class TestPollAutocoraResults: result = poll_autocora_results(ctx=ctx) assert "disabled" in result.lower() - def test_no_pending(self, ctx): + def test_no_result_files(self, ctx): result = poll_autocora_results(ctx=ctx) - assert "No pending" in result + assert "No result files" in result def test_success_json(self, ctx, monkeypatch): - """JSON SUCCESS result updates KV and ClickUp.""" - db = ctx["db"] + """JSON SUCCESS result updates ClickUp and moves result file.""" results_dir = Path(ctx["config"].autocora.results_dir) - # Set up submitted job in KV - job_id = "job-123-test" - kv_key = "autocora:job:test keyword" - db.kv_set( - kv_key, - json.dumps({ - "status": "submitted", - "job_id": job_id, - "keyword": "test keyword", - "task_ids": ["t1", "t2"], - }), - ) + # Write result file directly (no KV needed) + result_data = {"status": "SUCCESS", "task_ids": ["t1", "t2"], "keyword": "test keyword"} + (results_dir / "job-123-test.result").write_text(json.dumps(result_data)) - # Write result file - result_data = {"status": "SUCCESS", "task_ids": ["t1", "t2"]} - (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) - - # Mock ClickUp client mock_client = MagicMock() monkeypatch.setattr( "cheddahbot.tools.autocora._get_clickup_client", lambda ctx: mock_client @@ -347,39 +335,27 @@ class TestPollAutocoraResults: result = poll_autocora_results(ctx=ctx) assert "SUCCESS: test keyword" in result - # Verify KV updated - state = json.loads(db.kv_get(kv_key)) - assert state["status"] == "completed" - # Verify ClickUp calls assert mock_client.update_task_status.call_count == 2 mock_client.update_task_status.assert_any_call("t1", "running cora") mock_client.update_task_status.assert_any_call("t2", "running cora") assert mock_client.add_comment.call_count == 2 - def test_failure_json(self, ctx, monkeypatch): - """JSON FAILURE result updates KV and ClickUp with error.""" - db = ctx["db"] - results_dir = Path(ctx["config"].autocora.results_dir) + # Verify result file moved to processed/ + assert not (results_dir / "job-123-test.result").exists() + assert (results_dir / "processed" / "job-123-test.result").exists() - job_id = "job-456-fail" - kv_key = "autocora:job:fail keyword" - db.kv_set( - kv_key, - json.dumps({ - "status": "submitted", - "job_id": job_id, - "keyword": "fail keyword", - "task_ids": ["t3"], - }), - ) + def test_failure_json(self, ctx, monkeypatch): + """JSON FAILURE result updates ClickUp with error.""" + results_dir = Path(ctx["config"].autocora.results_dir) result_data = { "status": "FAILURE", "reason": "Cora not running", "task_ids": ["t3"], + "keyword": "fail keyword", } - (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) + (results_dir / "job-456-fail.result").write_text(json.dumps(result_data)) mock_client = MagicMock() monkeypatch.setattr( @@ -390,31 +366,14 @@ class TestPollAutocoraResults: assert "FAILURE: fail keyword" in result assert "Cora not running" in result - state = json.loads(db.kv_get(kv_key)) - assert state["status"] == "failed" - assert state["error"] == "Cora not running" - mock_client.update_task_status.assert_called_once_with("t3", "error") def test_legacy_plain_text(self, ctx, monkeypatch): - """Legacy plain-text SUCCESS result still works.""" - db = ctx["db"] + """Legacy plain-text SUCCESS result still works (keyword from filename).""" results_dir = Path(ctx["config"].autocora.results_dir) - job_id = "job-789-legacy" - kv_key = "autocora:job:legacy kw" - db.kv_set( - kv_key, - json.dumps({ - "status": "submitted", - "job_id": job_id, - "keyword": "legacy kw", - "task_ids": ["t5"], - }), - ) - # Legacy format — plain text, no JSON - (results_dir / f"{job_id}.result").write_text("SUCCESS") + (results_dir / "job-789-legacy-kw.result").write_text("SUCCESS") mock_client = MagicMock() monkeypatch.setattr( @@ -422,31 +381,17 @@ class TestPollAutocoraResults: ) result = poll_autocora_results(ctx=ctx) - assert "SUCCESS: legacy kw" in result + assert "SUCCESS:" in result - # task_ids come from KV fallback - mock_client.update_task_status.assert_called_once_with("t5", "running cora") + # No task_ids in legacy format, so no ClickUp calls + mock_client.update_task_status.assert_not_called() - def test_task_ids_from_result_preferred(self, ctx, monkeypatch): - """task_ids from result file take precedence over KV.""" - db = ctx["db"] + def test_task_ids_from_result_file(self, ctx, monkeypatch): + """task_ids from result file drive ClickUp updates.""" results_dir = Path(ctx["config"].autocora.results_dir) - job_id = "job-100-pref" - kv_key = "autocora:job:pref kw" - db.kv_set( - kv_key, - json.dumps({ - "status": "submitted", - "job_id": job_id, - "keyword": "pref kw", - "task_ids": ["old_t1"], # KV has old IDs - }), - ) - - # Result has updated task_ids - result_data = {"status": "SUCCESS", "task_ids": ["new_t1", "new_t2"]} - (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) + result_data = {"status": "SUCCESS", "task_ids": ["new_t1", "new_t2"], "keyword": "pref kw"} + (results_dir / "job-100-pref.result").write_text(json.dumps(result_data)) mock_client = MagicMock() monkeypatch.setattr( @@ -455,25 +400,107 @@ class TestPollAutocoraResults: poll_autocora_results(ctx=ctx) - # Should use result file task_ids, not KV calls = [c.args for c in mock_client.update_task_status.call_args_list] assert ("new_t1", "running cora") in calls assert ("new_t2", "running cora") in calls - assert ("old_t1", "running cora") not in calls - def test_still_pending(self, ctx): - """Jobs without result files show as still pending.""" - db = ctx["db"] - db.kv_set( - "autocora:job:waiting", - json.dumps({ - "status": "submitted", - "job_id": "job-999-wait", - "keyword": "waiting", - "task_ids": ["t99"], - }), + +# --------------------------------------------------------------------------- +# Sweep tests +# --------------------------------------------------------------------------- + + +class TestFindQualifyingTasksSweep: + """Test the multi-pass sweep logic.""" + + def _make_client(self, tasks): + client = MagicMock() + client.get_tasks_from_space.return_value = tasks + return client + + def _make_config(self): + config = MagicMock() + config.clickup.space_id = "sp1" + return config + + def test_finds_tasks_due_today(self): + from datetime import UTC, datetime + + now = datetime.now(UTC) + today_ms = int(now.replace(hour=12).timestamp() * 1000) + task = FakeTask(id="t1", name="Today", due_date=str(today_ms)) + client = self._make_client([task]) + config = self._make_config() + + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + assert any(t.id == "t1" for t in result) + + def test_finds_overdue_with_month_tag(self): + from datetime import UTC, datetime + + now = datetime.now(UTC) + month_tag = now.strftime("%b%y").lower() + # Due 3 days ago + overdue_ms = int((now.timestamp() - 3 * 86400) * 1000) + task = FakeTask( + id="t2", name="Overdue", due_date=str(overdue_ms), tags=[month_tag] ) + client = self._make_client([task]) + config = self._make_config() - result = poll_autocora_results(ctx=ctx) - assert "Still pending" in result - assert "waiting" in result + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + assert any(t.id == "t2" for t in result) + + def test_finds_last_month_tagged(self): + from datetime import UTC, datetime + + now = datetime.now(UTC) + if now.month == 1: + last = now.replace(year=now.year - 1, month=12) + else: + last = now.replace(month=now.month - 1) + last_tag = last.strftime("%b%y").lower() + # No due date needed for month-tag pass + task = FakeTask(id="t3", name="Last Month", tags=[last_tag]) + client = self._make_client([task]) + config = self._make_config() + + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + assert any(t.id == "t3" for t in result) + + def test_finds_lookahead(self): + from datetime import UTC, datetime + + now = datetime.now(UTC) + tomorrow_ms = int((now.timestamp() + 36 * 3600) * 1000) + task = FakeTask(id="t4", name="Tomorrow", due_date=str(tomorrow_ms)) + client = self._make_client([task]) + config = self._make_config() + + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + assert any(t.id == "t4" for t in result) + + def test_deduplicates_across_passes(self): + from datetime import UTC, datetime + + now = datetime.now(UTC) + month_tag = now.strftime("%b%y").lower() + today_ms = int(now.replace(hour=12).timestamp() * 1000) + # Task is due today AND has month tag — should only appear once + task = FakeTask( + id="t5", name="Multi", due_date=str(today_ms), tags=[month_tag] + ) + client = self._make_client([task]) + config = self._make_config() + + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + ids = [t.id for t in result] + assert ids.count("t5") == 1 + + def test_empty_space_id(self): + config = self._make_config() + config.clickup.space_id = "" + client = self._make_client([]) + + result = _find_qualifying_tasks_sweep(client, config, ["Content Creation"]) + assert result == []