Skip to content

Triggering workflows: watchers, cron, and polling daemons

State: open/active · Genre: analysis

Use case: triggered-recurring-runs RFC mapping: new Source: cross-repo survey of sksizer/* (2026-07-05), companion to the workflow-constellation RFC (PR #682).

Workflows do not only run on invocation — they are woken by file watchers with debounce, cron schedules, launchd/systemd timers, and hand-rolled polling daemons, each of which independently builds cursor state, rate limiting, PID/pause files, and idempotent-restart plumbing around the actual workflow. UC1-UC10 describe what runs; nothing in the taxonomy describes when and what wakes it.

rework_watch.py: a 529-line hand-rolled daemon — poll loop over open PRs, JSON cursor of last-seen comment IDs, per-PR rate limiting (3/hour with timestamp pruning), PID files, pause files, fork/setsid daemonization, stale-PID cleanup — all wrapped around dispatching one workflow.

Where: python/darkfactory/cli/rework_watch.py

Pain: The daemon’s state machine and rate caps are pure trigger infrastructure around one conceptual loop.

Per-vault reactive pipeline: recursive fs.watch with 300ms debounce and relevance filtering triggering re-validation pushed over SSE, with timer lifecycle and idempotent watcher restart hand-managed per vault.

Where: apps/web/src/daemon/watcher.ts · apps/web/src/daemon/runs.ts

Pain: Debounced event-triggered re-execution with per-vault re-entrancy is all hand plumbing.

A deterministic ‘orchestrate watch’ pending-work gate plus a never-exiting shell poll wrapper (stdout presence as the only event signal) implementing level-triggered wake beside a slow /loop cadence.

Where: plugin/lib/services/orchestrator/ops/watch.ts · plugin/skills/orchestrate/watch_loop.sh

Pain: Event-wake semantics improvised from a polling script because workflows have no trigger surface.

The product roadmap specifies scheduled/recurring workflow runs with a scheduler UI as a core feature of its planned YAML workflow engine.

Where: docs/ui/03-scheduler.md · docs/phases/01-polish-phase.md

Pain: Scheduling is planned as greenfield work atop an engine that also doesn’t exist yet.

6-hourly GitHub Actions cron chaining build → index → curl a deploy hook for the skill-directory indexer.

Where: .github/workflows/index-skills.yml

Pain: Trigger and pipeline welded together in CI YAML; no resume of a failed run.

launchd plists scheduling the Obsidian sync pipeline, with the interval switched by battery/AC power state; a periodic while-loop variant lives in the same script family.

Where: bin/mac/obsidian_interval.sh · bin/mac/templates/com.sizerllc.obsidiansync.plist.template · sync.sh

Pain: Scheduling logic scattered across plist templates and shell loops.

No current module covers this — a genuine taxonomy gap. Flowline need not ship a daemon, but a minimal trigger contract (evented/scheduled invocation + idempotent re-entry + cursor/rate-limit state riding on @flow/durable) would absorb the cursor-file, debounce-timer, and PID-file plumbing every trigger currently rebuilds.

Builds on: 12 Introduces: extension: flowline/trigger — watch(), cron(), poll()

What wakes a workflow, as library surface instead of ad-hoc daemons with hand-rolled debounce, PID files, and cursor state:

import { watch, cron, poll, fileCursor } from "flowline/trigger";
watch("docs/**/*.md", { debounce: 2_000 },
(paths) => run(rebuild, { seed: Changed.of(paths) }));
cron("*/15 * * * *",
() => runDurable(orchestrateTick, { runId: `tick-${isoNow()}` }));
poll({ every: 60_000, cursor: fileCursor(".sdlc/pr-cursors") },
(cursor) => run(checkPrs, { seed: Cursor.of(cursor) }));

Entirely an extension — the RFC’s taxonomy describes what runs, never what wakes it. Debounce, cursor persistence, overlap suppression (skip a tick if the previous one is still running), and pause/PID plumbing are the recurring hand-rolled parts.


← Back to Notes