Skip to content

feat(scheduling): systemd --user backend for the 11 remaining launchd-only installers - #1698

Open
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:feat/systemd-user-scheduling
Open

feat(scheduling): systemd --user backend for the 11 remaining launchd-only installers#1698
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:feat/systemd-user-scheduling

Conversation

@elhoim

@elhoim elhoim commented Jul 29, 2026

Copy link
Copy Markdown

Takes the advice on #1653 and builds on #1692: a systemd --user backend for every remaining launchd-only installer, in the shape you asked for.

The standing rule here is that platform support should be as universal as possible but strictly additive: a second backend beside the first, never a change to the path the primary install already runs. #1692 (systemd --user beside launchd for WorkSweep) is exactly that shape.

So this is #1650 and #1653 redone. Those routed launchd's jobs through Pulse cron, which risked two schedulers owning one job on macOS. Here launchd keeps owning the job on darwin and systemd owns it on linux, so no install ever has two.

352 insertions, 3 deletions across the eleven installers. Each of those three deletions is a single line replaced by its platform-branching form (if (arg === "--uninstall") uninstall() in the two Conduit installers, function main()async function main() in CommitmentSweep). Nine of eleven files have zero deletions. Every installer gains one if (systemd.isLinux()) return linuxMain(arg); line and two functions below its existing code.

Covered: BookmarkSweep, HealthSync, BlogDiscovery, CodexUpdate, CommitmentSweep, UsageAggregator, ConveyorRunner, ConveyorWatcher, DerivedSync, Conduit, ConduitInsight. WorkSweep is deliberately excluded#1692 already ported it.

Four shapes, not one

The eleven jobs use four different launchd mechanisms, each needing its own systemd form:

launchd systemd jobs
StartInterval N + RunAtLoad .timer OnBootSec=2min, OnUnitActiveSec=N bookmarksweep 1800s, healthsync 3600s, conduit (config), conduit.insight 3600s
StartCalendarInterval H:M .timer OnCalendar=*-*-* H:M:00 blogdiscovery 4:30, codexupdate 4:00, commitmentsweep 7:00, usage-aggregator 3:30
KeepAlive + ThrottleInterval .service Restart=always, RestartSec=N conveyor-runner, conveyor-watcher
WatchPaths [dirs] .path PathModified= per dir derivedsync

Timers carry Persistent=true, the closest systemd has to launchd firing a missed calendar job on wake. It is not identical, and the difference is worth stating: RunAtLoad fires on every load, Persistent only when the window was genuinely missed. For sweeps and syncs that are safe late and never early, that is the better direction.

Why a shared helper instead of unit templates

#1692 shipped a .service.template + .timer.template pair beside its plist. That is right for one job. Across eleven it becomes ~20 near-identical template files, each able to drift from the installer that materializes it. So lib/SystemdUser.ts generates the unit text from a small typed spec, and each installer declares only what is genuinely its own: label, command, log path, schedule. Adding the twelfth job is a ten-line branch rather than two new files.

If you would rather have one idiom than two, WorkSweep could move onto the same helper in a follow-up. This PR does not touch it.

A bug this turned up

systemctl --user needs XDG_RUNTIME_DIR to reach the user bus, and a non-interactive shell does not set it — which is exactly how an installer runs when driven from a script, a cron job, or a non-login SSH session. Without it every call fails:

Failed to connect to user scope bus via local transport:
$DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined

…while the user manager is running perfectly well. The helper now resolves /run/user/<uid> itself and passes it to every systemctl and loginctl call. This is the same class as the process.env.USER issue a review caught on #1692: a variable that is set in a terminal and absent everywhere else. enable-linger here uses id -un, inheriting that fix rather than repeating the bug.

install() also preflights is-system-running, so a host with no user session (some containers) gets a diagnosis instead of a raw dbus error.

Testing evidence

Verified on Linux (Ubuntu, systemd 259). Every command below was run with XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS explicitly unset, to prove the fix above.

1. All eleven specs build for real (--status constructs the spec — which bun, config reads, path resolution — and only reads systemd state):

✓ InstallBookmarkSweep.ts   not installed (no …/com.lifeos.bookmarksweep.timer)
✓ InstallHealthSync.ts      not installed (no …/com.lifeos.healthsync.timer)
✓ InstallBlogDiscovery.ts   not installed (no …/com.lifeos.blogdiscovery.timer)
✓ InstallCodexUpdate.ts     not installed (no …/com.lifeos.codexupdate.timer)
✓ InstallCommitmentSweep.ts not installed (no …/com.lifeos.commitmentsweep.timer)
✓ InstallUsageAggregator.ts not installed (no …/com.lifeos.usage-aggregator.timer)
✓ InstallConveyorRunner.ts  not installed (no …/com.lifeos.conveyor-runner.service)
✓ InstallConveyorWatcher.ts not installed (no …/com.lifeos.conveyor-watcher.service)
✓ InstallDerivedSync.ts     not installed (no …/com.lifeos.derivedsync.path)
✓ InstallConduit.ts         not installed (no …/com.lifeos.conduit.timer)
✓ InstallConduitInsight.ts  not installed (no …/com.lifeos.conduit.insight.timer)
── specs built cleanly: 11  faults: 0 ──

Note each shape resolves to the right primary unit: .timer for interval/calendar, .service for the KeepAlive daemons, .path for the watcher.

2. systemd's own parser accepts all four shapes (systemd-analyze --user verify, no warnings):

✓ verify.interval    clean  (2 units)
✓ verify.calendar    clean  (2 units)
✓ verify.daemon      clean  (1 unit)
✓ verify.watch       clean  (2 units)
── shapes failing systemd-analyze: 0 ──

3. A real end-to-end install, with the schedule confirmed by systemd (usage-aggregator, chosen because a 03:30 daily timer cannot fire mid-test):

$ bun InstallUsageAggregator.ts
[InstallUsageAggregator] wrote ~/.config/systemd/user/com.lifeos.usage-aggregator.service
[InstallUsageAggregator] wrote ~/.config/systemd/user/com.lifeos.usage-aggregator.timer
[InstallUsageAggregator] systemd unit enabled — com.lifeos.usage-aggregator.timer active

$ bun InstallUsageAggregator.ts --status
[InstallUsageAggregator] active: active · enabled: enabled
[InstallUsageAggregator] next run: Thu 2026-07-30 03:30:00 UTC  5h 55min

03:30 matches the plist's StartCalendarInterval Hour=3 Minute=30 exactly.

4. Full install → status → uninstall cycle per shape, driven through the real helper with /bin/true and sleep so no actual LifeOS job runs:

✓ interval  install=true status=true uninstall=true filesRemoved=true
✓ daemon    install=true status=true uninstall=true filesRemoved=true
✓ watch     install=true status=true uninstall=true filesRemoved=true
── shapes failing the full cycle: 0 ──

Everything was uninstalled afterwards; systemctl --user list-timers shows 0 lifeos timers and no unit files remain.

Not run: the darwin path, for lack of a macOS host. That is also the reason it is untouched — the three deletions above are the entire blast radius, and none changes what launchd does.

…-only installers

Follows the shape set by the WorkSweep systemd PR (danielmiessler#1692) and applies it to
every other scheduled LifeOS job. Each installer gains a
`process.platform === "linux"` branch; the launchd path is untouched, so a
macOS install behaves exactly as before. 352 insertions, 3 deletions, and each
of those three is one line replaced by its platform-branching form.

This is danielmiessler#1650 and danielmiessler#1653 redone in the shape the review asked for. Those two
routed launchd's jobs through Pulse cron, which risked two schedulers owning
one job on macOS. Here launchd keeps owning the job on darwin and systemd owns
it on linux, so no install ever has two.

Covered (11): BookmarkSweep, HealthSync, BlogDiscovery, CodexUpdate,
CommitmentSweep, UsageAggregator, ConveyorRunner, ConveyorWatcher, DerivedSync,
Conduit, ConduitInsight. WorkSweep is deliberately excluded — already ported.

FOUR SHAPES, NOT ONE

The jobs use four different launchd scheduling mechanisms, each needing its own
systemd form:

  StartInterval N + RunAtLoad   → .timer  OnBootSec=2min, OnUnitActiveSec=N
  StartCalendarInterval H:M     → .timer  OnCalendar=*-*-* H:M:00
  KeepAlive + ThrottleInterval  → .service Restart=always, RestartSec=N
  WatchPaths [dirs]             → .path   PathModified= per directory

Timers carry Persistent=true, the closest systemd has to launchd firing a
missed calendar job on wake. Not identical: RunAtLoad fires on every load,
Persistent only when the window was actually missed. For sweeps and syncs that
are safe late and never early, that is the better direction.

WHY A SHARED HELPER RATHER THAN UNIT TEMPLATES

danielmiessler#1692 shipped a .service.template + .timer.template pair beside its plist,
which is right for one job. Across eleven it would be ~20 near-identical
template files, each able to drift from the installer that materializes it. So
lib/SystemdUser.ts generates unit text from a small typed spec and each
installer declares only what is its own: label, command, log path, schedule.
Adding the twelfth job is a ten-line branch. WorkSweep could migrate onto the
same helper later to collapse the two idioms; this PR does not touch it.

A BUG THIS FOUND

`systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus, and a
non-interactive shell does not set it — which is exactly how an installer runs
from a script, a cron job, or a non-login SSH session. Without it every call
fails with "Failed to connect to user scope bus ... $DBUS_SESSION_BUS_ADDRESS
and $XDG_RUNTIME_DIR not defined" while the user manager is running fine. The
helper now resolves /run/user/<uid> itself and passes it to every systemctl and
loginctl call. Same class as the process.env.USER issue a review caught on
danielmiessler#1692: a variable that is set in a terminal and absent everywhere else.

install() also preflights `is-system-running` so a host with no user session
(some containers) reports a diagnosis instead of a raw dbus error.

loginctl enable-linger uses `id -un`, not process.env.USER, inheriting danielmiessler#1692's
fix rather than repeating its bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant