From 549519a5b063ffcadc7d44e5159791d3b01a2998 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 14 Jul 2026 04:45:07 +0530 Subject: [PATCH 1/4] fix: refuse scheduled automation reconcile when launchd is unavailable --- .../integrations/automation/scheduler/launchd.py | 6 ++++++ src/codealmanac/services/automation/ports.py | 3 +++ src/codealmanac/services/automation/service.py | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/src/codealmanac/integrations/automation/scheduler/launchd.py b/src/codealmanac/integrations/automation/scheduler/launchd.py index 979ddee0..7894f822 100644 --- a/src/codealmanac/integrations/automation/scheduler/launchd.py +++ b/src/codealmanac/integrations/automation/scheduler/launchd.py @@ -1,6 +1,7 @@ import os import plistlib import subprocess +import sys from dataclasses import dataclass from datetime import timedelta from pathlib import Path @@ -24,6 +25,11 @@ class LaunchdInspection: class LaunchdSchedulerAdapter: + def unavailable_reason(self) -> str | None: + if sys.platform != "darwin": + return "scheduled automation is macOS-only for now (needs launchd)" + return None + def install(self, job: ScheduledJob) -> ScheduledJobStatus: job.plist_path.parent.mkdir(parents=True, exist_ok=True) job.stdout_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/codealmanac/services/automation/ports.py b/src/codealmanac/services/automation/ports.py index aadf9bcc..17e57455 100644 --- a/src/codealmanac/services/automation/ports.py +++ b/src/codealmanac/services/automation/ports.py @@ -12,3 +12,6 @@ def uninstall(self, job: ScheduledJob) -> bool: def status(self, job: ScheduledJob) -> ScheduledJobStatus: """Read persisted scheduler state for one job.""" + + def unavailable_reason(self) -> str | None: + """Return why this scheduler cannot run here, or None when available.""" diff --git a/src/codealmanac/services/automation/service.py b/src/codealmanac/services/automation/service.py index 634ec466..8ee6fc42 100644 --- a/src/codealmanac/services/automation/service.py +++ b/src/codealmanac/services/automation/service.py @@ -1,5 +1,6 @@ from pathlib import Path +from codealmanac.core.errors import ExecutionFailed from codealmanac.services.automation.jobs import ( AutomationJobFactory, default_job_for_task, @@ -36,6 +37,9 @@ def reconcile_task( request: ReconcileAutomationTaskRequest, ) -> AutomationTaskApplyResult: job = job_from_reconcile_request(self.jobs, request) + reason = self.scheduler.unavailable_reason() + if reason is not None: + raise ExecutionFailed(f"cannot apply scheduled automation: {reason}") if request.enabled: self.scheduler.install(job) changed = True @@ -54,6 +58,8 @@ def remove_all( request: RemoveAllAutomationRequest, ) -> AutomationRemoveResult: tasks = tuple(AutomationTask) + if self.scheduler.unavailable_reason() is not None: + return AutomationRemoveResult(tasks=tasks, removed=()) removed: list[Path] = [] for task in tasks: job = default_job_for_task( From 7712504c36ac05eec82dee5f063866f43206f3ad Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 14 Jul 2026 04:45:07 +0530 Subject: [PATCH 2/4] fix: setup skips scheduled automation cleanly on unsupported platforms --- src/codealmanac/cli/render/setup/result.py | 14 ++++++++++++++ src/codealmanac/services/config/models.py | 1 + src/codealmanac/services/config/service.py | 14 ++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/codealmanac/cli/render/setup/result.py b/src/codealmanac/cli/render/setup/result.py index ad65b4c9..ebdfa93d 100644 --- a/src/codealmanac/cli/render/setup/result.py +++ b/src/codealmanac/cli/render/setup/result.py @@ -137,6 +137,13 @@ def product_update_step(result: SetupResult) -> SetupStep: def automation_step(result: SetupResult, task: AutomationTask, label: str) -> SetupStep: + if result.config_update.automation_error is not None: + return SetupStep( + label, + "unsupported", + result.config_update.automation_error, + warning=True, + ) applied = {item.task: item for item in result.config_update.automation} item = applied.get(task) if item is None: @@ -147,6 +154,13 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se def wiki_maintenance_step(result: SetupResult) -> SetupStep: + if result.config_update.automation_error is not None: + return SetupStep( + "Wiki maintenance", + "manual", + result.config_update.automation_error, + warning=True, + ) if len(result.config_update.automation) == 0: return SetupStep("Wiki maintenance", "manual", "no schedules installed") installed = {item.task for item in result.config_update.automation if item.enabled} diff --git a/src/codealmanac/services/config/models.py b/src/codealmanac/services/config/models.py index 99c27e06..ddede7c3 100644 --- a/src/codealmanac/services/config/models.py +++ b/src/codealmanac/services/config/models.py @@ -173,6 +173,7 @@ class ConfigUpdateResult(CodeAlmanacModel): path: str entries: tuple["ConfigEntry", ...] automation: tuple[AutomationTaskApplyResult, ...] + automation_error: str | None = None class ConfigEntry(CodeAlmanacModel): diff --git a/src/codealmanac/services/config/service.py b/src/codealmanac/services/config/service.py index ac42db9d..bd39ce2d 100644 --- a/src/codealmanac/services/config/service.py +++ b/src/codealmanac/services/config/service.py @@ -1,7 +1,7 @@ from datetime import timedelta from pathlib import Path -from codealmanac.core.errors import ValidationFailed +from codealmanac.core.errors import ExecutionFailed, ValidationFailed from codealmanac.core.paths import normalize_path from codealmanac.services.automation.defaults import duration_text from codealmanac.services.automation.models import ( @@ -77,11 +77,21 @@ def update(self, request: UpdateUserConfigRequest) -> ConfigUpdateResult: path = normalize_path(self.user_config_path) self.store.set_values(path, user_config_updates(request)) config = self.load_user() - applied = self.reconcile_all(config.automation, request) + # update() is setup's bulk onboarding call. A scheduler failure here + # (e.g. no launchd on this platform) should not abort instruction and + # harness config that already succeeded; set()/apply() are explicit, + # single-purpose commands and keep propagating scheduler failures. + automation_error = None + try: + applied = self.reconcile_all(config.automation, request) + except ExecutionFailed as error: + applied = () + automation_error = str(error) return ConfigUpdateResult( path=path.as_posix(), entries=config_entries(config), automation=applied, + automation_error=automation_error, ) def apply(self, request: ApplyConfigRequest) -> ConfigApplyResult: From ed89be5f649814cd86338f35e8511ec9fd74cdcb Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 14 Jul 2026 04:45:08 +0530 Subject: [PATCH 3/4] test: cover automation platform guard for setup and config commands --- tests/test_automation_service.py | 3 ++ tests/test_cli.py | 86 ++++++++++++++++++++++++++++++++ tests/test_config_service.py | 65 +++++++++++++++++++++++- tests/test_setup_service.py | 42 ++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/tests/test_automation_service.py b/tests/test_automation_service.py index 37f86f99..ff38fb52 100644 --- a/tests/test_automation_service.py +++ b/tests/test_automation_service.py @@ -33,6 +33,9 @@ def __init__(self): self.uninstalled: list[ScheduledJob] = [] self.loaded: set[Path] = set() + def unavailable_reason(self) -> str | None: + return None + def install(self, job: ScheduledJob) -> ScheduledJobStatus: self.installed.append(job) self.loaded.add(job.plist_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index 72feae92..e8ef3062 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -204,6 +204,9 @@ def __init__(self): self.installed: list[ScheduledJob] = [] self.uninstalled: list[ScheduledJob] = [] + def unavailable_reason(self) -> str | None: + return None + def install(self, job: ScheduledJob) -> ScheduledJobStatus: self.installed.append(job) return ScheduledJobStatus( @@ -229,6 +232,22 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus: ) +class CliUnavailableSchedulerAdapter: + def unavailable_reason(self) -> str | None: + return "scheduled automation is macOS-only for now (needs launchd)" + + def install(self, job: ScheduledJob) -> ScheduledJobStatus: + raise AssertionError("scheduler.install should not be called when unavailable") + + def uninstall(self, job: ScheduledJob) -> bool: + raise AssertionError( + "scheduler.uninstall should not be called when unavailable" + ) + + def status(self, job: ScheduledJob) -> ScheduledJobStatus: + raise AssertionError("scheduler.status is not exercised in this test") + + class CliUpdateMetadataProvider: def __init__(self, metadata: PackageInstallMetadata): self.metadata = metadata @@ -399,6 +418,73 @@ def test_cli_setup_and_uninstall_codex_instructions( ) +def test_cli_setup_yes_skips_automation_cleanly_off_macos( + isolated_home: Path, + monkeypatch, + capsys, +): + scheduler = CliUnavailableSchedulerAdapter() + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + scheduler=scheduler, + harness_adapters=(CliNoopHarnessAdapter(),), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + exit_code = main(["setup", "--yes", "--target", "codex"]) + + captured = capsys.readouterr() + agents_path = isolated_home / ".codex/AGENTS.md" + assert exit_code == 0 + assert captured.err == "" + assert "unsupported" in captured.out + assert "macOS-only" in captured.out + assert CODEALMANAC_START in agents_path.read_text(encoding="utf-8") + assert not (isolated_home / "Library/LaunchAgents").exists() + + +def test_cli_config_set_automation_fails_cleanly_off_macos( + isolated_home: Path, + monkeypatch, + capsys, +): + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + scheduler=CliUnavailableSchedulerAdapter(), + harness_adapters=(CliNoopHarnessAdapter(),), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + exit_code = main(["config", "set", "automation.sync.every", "8h"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.out == "" + assert "codealmanac: cannot apply scheduled automation" in captured.err + assert "macOS-only" in captured.err + assert not (isolated_home / "Library/LaunchAgents").exists() + + +def test_cli_uninstall_succeeds_even_when_scheduler_unavailable( + isolated_home: Path, + monkeypatch, + capsys, +): + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + scheduler=CliUnavailableSchedulerAdapter(), + harness_adapters=(CliNoopHarnessAdapter(),), + package_uninstaller=CliPackageUninstaller(), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + exit_code = main(["uninstall", "--yes"]) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "CodeAlmanac uninstall" in captured.out + + def test_cli_uninstall_without_yes_is_non_destructive_in_noninteractive_shell( isolated_home: Path, monkeypatch, diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 18c997a0..a5b978d0 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -6,10 +6,15 @@ from codealmanac.app import create_app from codealmanac.core.errors import ExecutionFailed, ValidationFailed from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus -from codealmanac.services.config.models import ConfigKey +from codealmanac.services.config.models import ( + AutomationConfig, + ConfigKey, + HarnessConfig, +) from codealmanac.services.config.requests import ( ApplyConfigRequest, SetConfigValueRequest, + UpdateUserConfigRequest, ) from codealmanac.services.harnesses.models import HarnessKind from codealmanac.settings import AppConfig @@ -20,6 +25,9 @@ def __init__(self): self.installed: list[ScheduledJob] = [] self.uninstalled: list[ScheduledJob] = [] + def unavailable_reason(self) -> str | None: + return None + def install(self, job: ScheduledJob) -> ScheduledJobStatus: self.installed.append(job) return scheduler_status(job, installed=True) @@ -32,6 +40,11 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus: return scheduler_status(job, installed=False) +class UnavailableScheduler(FakeScheduler): + def unavailable_reason(self) -> str | None: + return "scheduled automation is macOS-only for now (needs launchd)" + + class FailOnceScheduler(FakeScheduler): def __init__(self, fail_task: str): super().__init__() @@ -235,6 +248,56 @@ def test_scheduler_failure_keeps_desired_toml_for_retry( ] +def test_config_set_fails_cleanly_when_scheduler_unavailable( + isolated_home: Path, +) -> None: + scheduler = UnavailableScheduler() + app = config_app(isolated_home, scheduler) + + with pytest.raises(ExecutionFailed, match="macOS-only"): + app.config.set(set_request(ConfigKey.AUTOMATION_SYNC_EVERY, "8h")) + + assert app.config.load_user().automation.sync.every == timedelta(hours=8) + assert scheduler.installed == [] + assert scheduler.uninstalled == [] + + +def test_config_apply_fails_cleanly_when_scheduler_unavailable( + isolated_home: Path, +) -> None: + scheduler = UnavailableScheduler() + app = config_app(isolated_home, scheduler) + + with pytest.raises(ExecutionFailed, match="macOS-only"): + app.config.apply(ApplyConfigRequest(home=isolated_home)) + + assert scheduler.installed == [] + assert scheduler.uninstalled == [] + + +def test_config_update_skips_automation_cleanly_when_scheduler_unavailable( + isolated_home: Path, +) -> None: + scheduler = UnavailableScheduler() + app = config_app(isolated_home, scheduler) + + result = app.config.update( + UpdateUserConfigRequest( + auto_commit=False, + harness=HarnessConfig(), + automation=AutomationConfig(), + home=isolated_home, + ) + ) + + assert result.automation == () + assert result.automation_error is not None + assert "macOS-only" in result.automation_error + assert scheduler.installed == [] + assert scheduler.uninstalled == [] + assert app.config.load_user().auto_commit is False + + def test_config_apply_rejects_invalid_toml_without_scheduler_calls( isolated_home: Path, ) -> None: diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index d930d901..507b719a 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -24,6 +24,7 @@ ReconcileAutomationTaskRequest, RemoveAllAutomationRequest, ) +from codealmanac.services.automation.service import AutomationService from codealmanac.services.config.service import ConfigService from codealmanac.services.config.store import ConfigStore from codealmanac.services.harnesses.models import HarnessKind, HarnessReadiness @@ -210,6 +211,29 @@ def test_setup_can_skip_sync_automation(home: Path): ) +def test_setup_skips_automation_cleanly_when_scheduler_unavailable(home: Path): + automation = AutomationService(UnavailableSchedulerAdapter()) + config = ConfigService( + ConfigStore(), + home / ".codealmanac/config.toml", + automation, + ) + service = SetupService( + FileInstructionInstaller(home), + automation, + FilesystemGlobalStateRemover(home / ".codealmanac"), + FakePackageUninstaller(skipped_package_result()), + config=config, + ) + + result = service.run(RunSetupRequest()) + + assert result.config_update.automation == () + assert result.config_update.automation_error is not None + assert "macOS-only" in result.config_update.automation_error + assert result.changes[0].changed is True + + def test_uninstall_removes_automation_by_default(home: Path): automation = FakeSetupAutomationManager(home) @@ -408,6 +432,24 @@ def readiness(self, kind: HarnessKind) -> HarnessReadiness: ) +class UnavailableSchedulerAdapter: + def unavailable_reason(self) -> str | None: + return "scheduled automation is macOS-only for now (needs launchd)" + + def install(self, job): + raise AssertionError( + "scheduler.install should not be called when unavailable" + ) + + def uninstall(self, job): + raise AssertionError( + "scheduler.uninstall should not be called when unavailable" + ) + + def status(self, job): + raise AssertionError("scheduler.status is not exercised in this test") + + class FakeSetupAutomationManager: def __init__(self, home: Path): self.home = home From 452604ab23103dda470a3cc977dfc70fbd4a9dbc Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 14 Jul 2026 04:45:08 +0530 Subject: [PATCH 4/4] docs: note scheduled automation degrades gracefully off macOS --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6e3eefda..2c5ff7fb 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,11 @@ Setup installs agent instructions for your chosen tools and three local macOS These schedules run locally in the background. Use `codealmanac automation status` to see what is installed. +On platforms other than macOS, `setup` still installs agent instructions and +config but reports scheduled automation as unsupported instead of installing +schedules; `codealmanac config set automation.*`/`config apply` fail with a +clear error and change nothing. + If you don't have Codex or prefer Claude, use `--runner claude`. `--target` only chooses which global agent instruction files to install; it does