Skip to content

Commit 924ac2d

Browse files
committed
Add forced one-shot scheduler runs
Allow codex-bg once --force to bypass workspace and plugin due intervals for smoke tests without bypassing dedupe, issue age filters, rate limits, or dry-run safety. Also allow the Codex pre-screener to run from its temporary artifact directory by passing --skip-git-repo-check. Validation: .venv/bin/pre-commit run --all-files; .venv/bin/ruff check .; .venv/bin/bandit -c pyproject.toml -r src; git ls-files -z | xargs -0 .venv/bin/detect-secrets-hook --baseline .secrets.baseline; .venv/bin/pip-audit -r requirements-dev.txt; .venv/bin/actionlint; .venv/bin/zizmor --offline .github; PYTHONPATH=src python3 -m unittest discover -s tests; python3 -m compileall -q src tests; .venv/bin/python -m build; deployed to /home/rger/proj/codex-bg-test and ran forced dry-run.
1 parent 16048c6 commit 924ac2d

7 files changed

Lines changed: 55 additions & 11 deletions

File tree

‎src/codex_bg/cli.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def main(argv: list[str] | None = None) -> int:
2020
subparser.add_argument("--config", default=argparse.SUPPRESS)
2121
subparser.add_argument("--debug", action="store_true", default=argparse.SUPPRESS)
2222
subparser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
23+
if command == "once":
24+
subparser.add_argument("--force", action="store_true")
2325
args = parser.parse_args(argv)
2426

2527
app = load_config(args.config)
@@ -32,7 +34,7 @@ def main(argv: list[str] | None = None) -> int:
3234
scheduler.run_forever()
3335
return 0
3436
if args.command == "once":
35-
print(json.dumps(scheduler.once(), indent=2, sort_keys=True))
37+
print(json.dumps(scheduler.once(force=args.force), indent=2, sort_keys=True))
3638
return 0
3739
if args.command == "status":
3840
print(json.dumps(scheduler.status(), indent=2, sort_keys=True))

‎src/codex_bg/prescreen_codex.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def screen(self, context: PreScreenContext, request: ScreeningRequest) -> Screen
4343
"exec",
4444
"--sandbox",
4545
"read-only",
46+
"--skip-git-repo-check",
4647
"--json",
4748
"-o",
4849
str(output_file),

‎src/codex_bg/scheduler.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,22 +67,23 @@ def run_forever(self) -> None:
6767
for thread in threads:
6868
thread.join(timeout=5)
6969

70-
def once(self) -> dict[str, Any]:
71-
self.debug("running one scheduler cycle")
72-
refreshed = self.refresh_workspaces()
73-
generated = self.generate_events()
70+
def once(self, *, force: bool = False) -> dict[str, Any]:
71+
suffix = " with forced event generation" if force else ""
72+
self.debug(f"running one scheduler cycle{suffix}")
73+
refreshed = self.refresh_workspaces(force=force)
74+
generated = self.generate_events(force=force)
7475
worked = self.work_one()
7576
self.debug(f"cycle complete: refreshed={refreshed} generated={generated} worked={worked}")
7677
return {"refreshed": refreshed, "generated": generated, "worked": worked}
7778

78-
def refresh_workspaces(self) -> int:
79+
def refresh_workspaces(self, *, force: bool = False) -> int:
7980
self.debug("refreshing due shared workspaces")
80-
return self.workspace_manager.refresh_due_workspaces()
81+
return self.workspace_manager.refresh_due_workspaces(force=force)
8182

82-
def generate_events(self) -> int:
83+
def generate_events(self, *, force: bool = False) -> int:
8384
count = 0
8485
for loaded in self.plugins.values():
85-
if not self._plugin_due(loaded.config):
86+
if not force and not self._plugin_due(loaded.config):
8687
self.debug(f"plugin {loaded.config.name} is not due")
8788
continue
8889
self.debug(f"generating events with plugin {loaded.config.name}")

‎src/codex_bg/workspace.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ def prepare(self, task: Task) -> Path | None:
3434
raise WorkspaceError(f"unknown workspace_key: {task.workspace_key}")
3535
return self._prepare_workspace(workspace, force_refresh=True)
3636

37-
def refresh_due_workspaces(self) -> int:
37+
def refresh_due_workspaces(self, *, force: bool = False) -> int:
3838
refreshed = 0
3939
self.app.workspace_root.mkdir(parents=True, exist_ok=True)
4040
for workspace in self.app.workspaces.values():
4141
if not workspace.repo:
4242
continue
4343
path = self._workspace_path(workspace)
44-
if not self._refresh_due(path):
44+
if not force and not self._refresh_due(path):
4545
self.debug(f"workspace {workspace.key} refresh not due")
4646
continue
4747
self._prepare_workspace(workspace, force_refresh=True)

‎tests/test_cli.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ def test_dry_run_is_accepted_after_subcommand(self) -> None:
4747
app = scheduler.call_args.args[0]
4848
self.assertTrue(app.dry_run)
4949

50+
def test_force_is_accepted_for_once(self) -> None:
51+
with tempfile.TemporaryDirectory() as tmp:
52+
config = Path(tmp) / "scheduler.toml"
53+
config.write_text("plugins = []\n", encoding="utf-8")
54+
with patch("codex_bg.cli.Scheduler") as scheduler, patch("sys.stdout", io.StringIO()):
55+
scheduler.return_value.once.return_value = {"generated": 0, "worked": False}
56+
57+
code = cli.main(["once", "--config", str(config), "--force"])
58+
59+
self.assertEqual(code, 0)
60+
scheduler.return_value.once.assert_called_once_with(force=True)
61+
5062

5163
if __name__ == "__main__":
5264
unittest.main()

‎tests/test_prescreen_codex.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def test_uses_default_small_model_when_no_model_is_configured(self) -> None:
3636

3737
self.assertTrue(result.allowed)
3838
self.assertIn("--model", runner.calls[0])
39+
self.assertIn("--skip-git-repo-check", runner.calls[0])
3940
self.assertEqual(runner.calls[0][runner.calls[0].index("--model") + 1], "gpt-5.4-mini")
4041
self.assertIn('model_reasoning_effort="medium"', runner.calls[0])
4142

‎tests/test_scheduler.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ def test_zero_interval_plugin_runs_every_cycle(self) -> None:
161161

162162
self.assertEqual(plugin.generate_calls, 2)
163163

164+
def test_force_once_runs_plugin_even_when_not_due(self) -> None:
165+
with tempfile.TemporaryDirectory() as tmp:
166+
plugin = MemoryPlugin()
167+
module = types.ModuleType("test_memory_plugin_force")
168+
module.create_plugin = lambda config: plugin
169+
import sys
170+
171+
sys.modules["test_memory_plugin_force"] = module
172+
app = AppConfig(
173+
database_path=Path(tmp) / "state.sqlite3",
174+
workdir_root=Path(tmp) / "workdirs",
175+
codex=CodexConfig(),
176+
plugins=[
177+
PluginConfig(
178+
name="memory",
179+
module="test_memory_plugin_force",
180+
interval_seconds=900,
181+
)
182+
],
183+
)
184+
scheduler = Scheduler(app, store=Store(app.database_path), runner=FakeRunner()) # type: ignore[arg-type]
185+
186+
scheduler.once()
187+
scheduler.once(force=True)
188+
189+
self.assertEqual(plugin.generate_calls, 2)
190+
164191
def test_failed_scheduled_plugin_run_records_attempt_for_backoff(self) -> None:
165192
with tempfile.TemporaryDirectory() as tmp:
166193
plugin = FailingPlugin()

0 commit comments

Comments
 (0)