{"record":{"id":"9bdedd95574224e5","repo":"langchain-ai/deepagents","slug":"cron-retention-window-cannot-be-negative","errorCode":null,"errorMessage":"cron retention window cannot be negative","messagePattern":"cron retention window cannot be negative","errorType":"validation","errorClass":"CronJobError","httpStatus":null,"severity":"error","filePath":"libs/talon/deepagents_talon/cron/jobs.py","lineNumber":601,"sourceCode":"        *,\n        retain_for: timedelta,\n        now: datetime | None = None,\n    ) -> list[CronJob]:\n        \"\"\"Delete completed jobs older than the retention window.\n\n        Args:\n            retain_for: Duration to keep disabled jobs after completion.\n            now: Current timestamp override for deterministic tests.\n\n        Returns:\n            Removed job records.\n\n        Raises:\n            CronJobError: If `retain_for` is negative.\n        \"\"\"\n        if retain_for < timedelta(0):\n            msg = \"cron retention window cannot be negative\"\n            raise CronJobError(msg)\n\n        cutoff = _coerce_utc(now) - retain_for\n        kept: list[CronJob] = []\n        removed: list[CronJob] = []\n        for job in self.list_jobs():\n            reference = job.last_run_at or job.created_at\n            if not job.enabled and job.next_run_at is None and reference <= cutoff:\n                removed.append(job)\n            else:\n                kept.append(job)\n        if removed:\n            self._write_jobs(kept)\n        return removed\n\n    def _read_jobs(self) -> list[CronJob]:\n        self._ensure_store()\n        if not self.path.exists():\n            return []","sourceCodeStart":583,"sourceCodeEnd":619,"githubUrl":"https://github.com/langchain-ai/deepagents/blob/a1af029e6e73cb17c36bff823d227747b28e91e1/libs/talon/deepagents_talon/cron/jobs.py#L583-L619","documentation":"CronJobStore.prune_completed deletes disabled, completed jobs older than a retention window. A negative retain_for timedelta is rejected up front with CronJobError because it would make the cutoff in the future and silently delete jobs that are not actually expired.","triggerScenarios":"Calling store.prune_completed(retain_for=timedelta(days=-1)) or computing retain_for from a configuration value that can go negative (e.g., expiry_ts - now where expiry already passed).","commonSituations":"Config-driven retention where an env/config value is parsed with the wrong sign; deriving the window by subtracting timestamps in the wrong order; a caller passing timedelta(0) is fine, but a typo like timedelta(hours=-1) from arithmetic is common.","solutions":["Pass a non-negative timedelta, e.g. timedelta(days=7) or timedelta(0).","Clamp computed windows: retain_for = max(computed, timedelta(0)) before calling.","Fix the config/source value that produced the negative duration and re-run cleanup."],"exampleFix":"// before\nremoved = store.prune_completed(retain_for=timedelta(seconds=expiry_ts - now_ts))\n// after\nwindow = timedelta(seconds=max(0, expiry_ts - now_ts))\nremoved = store.prune_completed(retain_for=window)","handlingStrategy":"validation","validationCode":"if retain_for < timedelta(0):\n    raise ValueError(\"retain_for must be >= 0\")","typeGuard":"def is_non_negative(delta: timedelta) -> bool:\n    return delta >= timedelta(0)","tryCatchPattern":"try:\n    removed = store.prune_completed(retain_for=retain_for)\nexcept CronJobError as exc:\n    logger.error(\"bad retention window: %s\", exc)\n    removed = store.prune_completed(retain_for=timedelta(0))","preventionTips":["Clamp derived windows with max(computed, timedelta(0)).","Validate retention config at startup, before any cleanup runs.","Watch subtraction order when computing windows from timestamps."],"tags":["cron","validation","argument-error","retention"],"backgroundTag":"invalid-argument-value","analyzedSha":"a1af029e6e73cb17c36bff823d227747b28e91e1","analyzedAt":"2026-08-29T11:43:24.718Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}