· 7 min read
A pentest you run once is a photograph. A pentest you run on a schedule is a monitor. Darkmoon ships a scheduler for exactly that reason, and this article describes it as it is implemented in the platform’s API, not as a roadmap. The short version: a background task wakes up every 60 seconds, finds the campaigns that are due, and launches the same autonomous orchestrator the dashboard uses, with a recurrence you choose.
How a schedule is stored
Scheduled campaigns are persisted as JSON and managed through a small CRUD API. Creating one takes a target plus the same options a manual run accepts, and a couple of scheduling fields: when it should first run, and how often it should repeat.
| Field | Meaning |
|---|---|
| scheduled_at | ISO-8601 datetime for the first execution |
| recurrence | none, daily, weekly or monthly |
| recurrence_interval | Run every N periods, e.g. every 2 weeks |
| enabled | Whether the schedule is active |
| target / focus / severity / format … | The same campaign options a manual run takes |
| remediate / git_repo / credential_id | Optional: enable the remediation phase (Pro) |
The scheduling fields sit alongside the full campaign definition, so a schedule is just a saved run with a clock attached. The record also tracks last_run, next_run and a run_count, so you can see a schedule’s history at a glance.
The loop that fires them
The scheduler is a long-lived asyncio task started from the API’s lifespan. Every 60 seconds it reads the schedules, compares each one’s next_run to the current time, and for any that are due and enabled, it launches the campaign without blocking the loop.
async def scheduler_loop():
while True:
schedules = _read_schedules()
now = datetime.now(timezone.utc)
for entry in schedules:
if not entry.get("enabled", True):
continue
next_run = datetime.fromisoformat(entry["next_run"])
if next_run <= now:
asyncio.create_task(_trigger_campaign(entry))
entry["last_run"] = now.isoformat()
entry["run_count"] = entry.get("run_count", 0) + 1
nxt = _compute_next_run(entry)
entry["next_run"] = nxt # None for a one-shot
entry["enabled"] = nxt is not None or entry["enabled"]
await asyncio.sleep(60)Two design choices are worth calling out. First, the campaign is fired as its own task, so a slow run never stalls the polling loop or delays other due schedules. Second, a schedule with a recurrence of none is a genuine one-shot: after it fires, its next run is cleared and it disables itself, so it cannot run twice.
What "launch a campaign" actually means
When a schedule is due, the scheduler builds the same CLI prompt a manual run would, and spawns the orchestrator exactly as the interactive runner does:
opencode run --agent pentest --format json "TARGET: ... FOCUS=... SEVERITY=..."The output is streamed line by line into a per-run JSONL log, beginning with a run_started event that records the command and the schedule id, and ending with a run_completed or run_error event. That log is the single source of truth for the run’s state, and it is the same format the live dashboard reads, so a scheduled run is indistinguishable from a manual one once it is underway. From there the orchestrator dispatches its specialist agents, pushes findings as it goes, and finalizes to a report.
Recurrence, precisely
- daily advances the next run by
recurrence_intervaldays. - weekly advances it by that many weeks.
- monthly advances it by 30 days per interval. This is a deliberate approximation: the scheduler adds 30 days rather than tracking calendar months, so "monthly" means "every 30 days". If exact calendar-day alignment matters to you, prefer a weekly cadence or set the datetime explicitly.
Honest limits
The scheduler is a single-node background loop with a 60-second resolution, so a campaign fires within a minute of its due time, not to the second. Schedules are persisted to a JSON file, which is simple and auditable but is node-local state, not a distributed job queue. And a scheduled run inherits every constraint of a manual run, including the credential-gated, manual-only nature of any dispatch that needs secrets. None of that is a limitation for the intended use, recurring validation of a known target, but it is worth stating plainly.
Where this fits
Scheduled runs are the "time-driven" half of continuous validation. The "event-driven" half is wiring a run into your pipeline so a deploy triggers a test; that is covered in autonomous penetration testing in your CI/CD pipeline. Both produce the same live-updating dashboard view, described in watching findings land as the agent works, and the same deterministic report.
Darkmoon is our open source project (GPL-3.0): github.com/ASCIT31/Dark-Moon, docs.