codee-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,199 @@
1
+ import sqlite3
2
+ from datetime import datetime, timedelta, timezone
3
+ from pathlib import Path
4
+
5
+ from codee_main_context.context import CodeeMainContext
6
+
7
+ from codee.lib import runs_db
8
+
9
+
10
+ def _ctx(data_dir: Path) -> CodeeMainContext:
11
+ return CodeeMainContext(data_dir=data_dir)
12
+
13
+
14
+ def _bad_ctx(tmp_path: Path) -> CodeeMainContext:
15
+ # A data_dir that doesn't exist -> sqlite can't open the file -> must be swallowed.
16
+ return _ctx(tmp_path / "missing" / "deeper")
17
+
18
+
19
+ def _db_file(data_dir: Path) -> Path:
20
+ return data_dir / "codee.db"
21
+
22
+
23
+ def test_round_trip_newest_first(tmp_path):
24
+ ctx = _ctx(tmp_path)
25
+ runs_db.record_run("skill-a", "cron", "sid-1", "succeeded",
26
+ started_at="2026-06-27T10:00:00+00:00", main_context=ctx)
27
+ runs_db.record_run("skill-b", "email", "sid-2", "failed", error="boom",
28
+ started_at="2026-06-27T11:00:00+00:00", main_context=ctx)
29
+
30
+ rows = runs_db.recent_runs(main_context=ctx)
31
+ assert [r["skill_name"] for r in rows] == ["skill-b", "skill-a"] # newest first
32
+ assert rows[0]["trigger_type"] == "email"
33
+ assert rows[0]["status"] == "failed"
34
+ assert rows[0]["error"] == "boom"
35
+ assert rows[0]["session_id"] == "sid-2"
36
+
37
+
38
+ def test_empty_db_returns_empty_list(tmp_path):
39
+ assert runs_db.recent_runs(main_context=_ctx(tmp_path)) == []
40
+
41
+
42
+ def test_limit_and_offset_page_through_runs(tmp_path):
43
+ ctx = _ctx(tmp_path)
44
+ for i in range(5):
45
+ runs_db.record_run(f"skill-{i}", "cron", f"sid-{i}", "succeeded",
46
+ started_at=f"2026-06-27T1{i}:00:00+00:00", main_context=ctx)
47
+
48
+ page1 = runs_db.recent_runs(2, main_context=ctx)
49
+ page2 = runs_db.recent_runs(2, 2, main_context=ctx)
50
+ page3 = runs_db.recent_runs(2, 4, main_context=ctx)
51
+
52
+ assert [r["skill_name"] for r in page1] == ["skill-4", "skill-3"]
53
+ assert [r["skill_name"] for r in page2] == ["skill-2", "skill-1"]
54
+ assert [r["skill_name"] for r in page3] == ["skill-0"]
55
+ assert runs_db.recent_runs(2, 5, main_context=ctx) == []
56
+
57
+
58
+ def test_record_run_never_raises_on_bad_path(tmp_path):
59
+ # An unopenable db path must be swallowed (FR-009).
60
+ runs_db.record_run("skill", "cron", "sid", "succeeded",
61
+ main_context=_bad_ctx(tmp_path))
62
+
63
+
64
+ def test_main_context_is_required():
65
+ # The whole module keys off main_context.data_dir; a missing context must fail
66
+ # loudly at the call site instead of being swallowed as a logging miss.
67
+ for call in (lambda: runs_db.record_run("s", "cron", "sid", "succeeded"),
68
+ lambda: runs_db.recent_runs(),
69
+ lambda: runs_db.start_job("sid", "m")):
70
+ try:
71
+ call()
72
+ except TypeError:
73
+ continue
74
+ raise AssertionError("main_context must be a required keyword argument")
75
+
76
+
77
+ # ---------------------------------------------------------------- counts() (US1)
78
+ def _ago(hours):
79
+ return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
80
+
81
+
82
+ def test_counts_total_and_last_24h(tmp_path):
83
+ ctx = _ctx(tmp_path)
84
+ # Two inside the 24h window, one well outside, one exactly at the boundary (excluded, strict >).
85
+ runs_db.record_run("a", "cron", "s1", "succeeded", started_at=_ago(1), main_context=ctx)
86
+ runs_db.record_run("b", "cron", "s2", "succeeded", started_at=_ago(23), main_context=ctx)
87
+ runs_db.record_run("c", "cron", "s3", "succeeded", started_at=_ago(48), main_context=ctx)
88
+ assert runs_db.counts(ctx) == {"total": 3, "last_24h": 2}
89
+
90
+
91
+ def test_counts_empty_db_zeros(tmp_path):
92
+ assert runs_db.counts(_ctx(tmp_path)) == {"total": 0, "last_24h": 0}
93
+
94
+
95
+ def test_counts_never_raises_on_bad_path(tmp_path):
96
+ assert runs_db.counts(_bad_ctx(tmp_path)) == {"total": 0, "last_24h": 0}
97
+
98
+
99
+ def test_runs_by_hour_buckets(tmp_path):
100
+ ctx = _ctx(tmp_path)
101
+ runs_db.record_run("a", "cron", "s1", "succeeded", started_at=_ago(2), main_context=ctx)
102
+ runs_db.record_run("b", "cron", "s2", "succeeded", started_at=_ago(2.1), main_context=ctx)
103
+ runs_db.record_run("c", "cron", "s3", "succeeded", started_at=_ago(48), main_context=ctx) # out of window
104
+
105
+ hourly = runs_db.runs_by_hour(ctx)
106
+ assert len(hourly) == 24 # always 24 buckets
107
+ # oldest-first: buckets are the last 24 hour labels in ascending time order
108
+ now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
109
+ expected = [(now - timedelta(hours=h)).strftime("%H:00") for h in range(23, -1, -1)]
110
+ assert [h["hour"] for h in hourly] == expected
111
+ assert sum(h["runs"] for h in hourly) == 2 # 48h-old run excluded; the two ~2h-old runs counted
112
+
113
+
114
+ def test_runs_by_hour_empty_and_bad_path(tmp_path):
115
+ bad = _bad_ctx(tmp_path)
116
+ assert all(h["runs"] == 0 for h in runs_db.runs_by_hour(bad))
117
+ assert len(runs_db.runs_by_hour(bad)) == 24
118
+
119
+
120
+ # ---------------------------------------------------------------- message column (US2)
121
+ def test_message_round_trip(tmp_path):
122
+ ctx = _ctx(tmp_path)
123
+ runs_db.record_run("a", "cron", "s1", "succeeded", message="hello", main_context=ctx)
124
+ runs_db.record_run("b", "email", "s2", "succeeded", message="", main_context=ctx)
125
+ runs_db.record_run("c", "aws-sqs", "s3", "succeeded", main_context=ctx) # message defaults None
126
+ by_skill = {r["skill_name"]: r["message"] for r in runs_db.recent_runs(main_context=ctx)}
127
+ assert by_skill == {"a": "hello", "b": "", "c": None}
128
+
129
+
130
+ # ---------------------------------------------------------------- active_jobs
131
+ def test_active_job_lifecycle(tmp_path):
132
+ ctx = _ctx(tmp_path)
133
+ jid = runs_db.start_job("sid-1", "/task-developer NIM-1", main_context=ctx)
134
+ jobs = runs_db.active_jobs(ctx)
135
+ assert len(jobs) == 1
136
+ assert jobs[0]["session_id"] == "sid-1"
137
+ assert jobs[0]["message"] == "/task-developer NIM-1"
138
+ assert jobs[0]["elapsed"] >= 0
139
+
140
+ runs_db.finish_job(jid, main_context=ctx)
141
+ assert runs_db.active_jobs(ctx) == []
142
+
143
+
144
+ def test_active_jobs_elapsed_and_order(tmp_path):
145
+ ctx = _ctx(tmp_path)
146
+ runs_db.start_job("young", "b", started_at=_ago(0.01), main_context=ctx)
147
+ runs_db.start_job("old", "a", started_at=_ago(1), main_context=ctx)
148
+ jobs = runs_db.active_jobs(ctx)
149
+ assert [j["session_id"] for j in jobs] == ["young", "old"] # youngest first
150
+ assert jobs[1]["elapsed"] >= 3500 # ~1h old
151
+
152
+
153
+ def test_clear_active_jobs_purges_stale(tmp_path):
154
+ ctx = _ctx(tmp_path)
155
+ runs_db.start_job("s1", "m1", main_context=ctx)
156
+ runs_db.start_job("s2", "m2", main_context=ctx)
157
+ runs_db.clear_active_jobs(ctx)
158
+ assert runs_db.active_jobs(ctx) == []
159
+
160
+
161
+ def test_active_jobs_never_raise_on_bad_path(tmp_path):
162
+ bad = _bad_ctx(tmp_path)
163
+ assert runs_db.active_jobs(bad) == []
164
+ assert runs_db.start_job("s", "m", main_context=bad) is None
165
+ runs_db.finish_job(None, main_context=bad) # no-op, no raise
166
+ runs_db.clear_active_jobs(bad)
167
+
168
+
169
+ def test_fmt_elapsed():
170
+ assert runs_db.fmt_elapsed(45) == "45s"
171
+ assert runs_db.fmt_elapsed(125) == "2m 5s"
172
+ assert runs_db.fmt_elapsed(3700) == "1h 1m"
173
+
174
+
175
+ def test_migration_adds_message_column_without_data_loss(tmp_path):
176
+ ctx = _ctx(tmp_path)
177
+ # Build an old-schema DB (spec 001, no message column) with one row.
178
+ with sqlite3.connect(_db_file(tmp_path)) as conn:
179
+ conn.execute(
180
+ """CREATE TABLE runs (
181
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
182
+ skill_name TEXT NOT NULL, trigger_type TEXT NOT NULL,
183
+ session_id TEXT NOT NULL, status TEXT NOT NULL,
184
+ error TEXT, started_at TEXT NOT NULL)"""
185
+ )
186
+ conn.execute(
187
+ "INSERT INTO runs (skill_name, trigger_type, session_id, status, started_at)"
188
+ " VALUES ('old', 'cron', 'sid-old', 'succeeded', '2026-06-27T10:00:00+00:00')"
189
+ )
190
+
191
+ runs_db.init(ctx) # must not raise; must add the column
192
+ rows = runs_db.recent_runs(main_context=ctx)
193
+ assert len(rows) == 1
194
+ assert rows[0]["skill_name"] == "old"
195
+ assert rows[0]["message"] is None # pre-feature row reads as NULL
196
+
197
+ # New writes carry message; the upgraded DB round-trips it.
198
+ runs_db.record_run("new", "cron", "sid-new", "succeeded", message="m", main_context=ctx)
199
+ assert runs_db.recent_runs(main_context=ctx)[0]["message"] == "m"
@@ -0,0 +1,485 @@
1
+ import contextlib
2
+ import io
3
+ import json
4
+ import tempfile
5
+ import unittest
6
+ import sys
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).parent))
11
+
12
+ from codee_main_context.context import CodeeMainContext
13
+
14
+ from codee.lib.trigger_aws_sqs_skills import (
15
+ AwsSqsMessage,
16
+ find_aws_sqs_triggered_skills,
17
+ render_aws_sqs_prompt,
18
+ trigger_aws_sqs_skills,
19
+ )
20
+ from codee.lib.trigger_cron_skills import (
21
+ _cron_matches,
22
+ _find_scheduled_skills,
23
+ _latest_due,
24
+ _parse_skill_file,
25
+ request_force_run,
26
+ trigger_cron_skills,
27
+ )
28
+
29
+
30
+ def _ctx(data_dir: Path) -> CodeeMainContext:
31
+ return CodeeMainContext(data_dir=data_dir)
32
+
33
+
34
+ class FakeSqsMessageSource:
35
+ def __init__(self, messages):
36
+ self.messages = list(messages)
37
+ self.deleted = []
38
+ self.received_skills = []
39
+
40
+ def receive(self, skill):
41
+ self.received_skills.append(skill)
42
+ if not self.messages:
43
+ return None
44
+ return self.messages.pop(0)
45
+
46
+ def delete(self, message):
47
+ self.deleted.append(message)
48
+
49
+
50
+ class CronSkillTests(unittest.TestCase):
51
+ def test_parse_skill_file_strips_frontmatter(self):
52
+ metadata, body = _parse_skill_file(
53
+ "---\n"
54
+ "name: Check stale feature flags\n"
55
+ "description: Runs every day.\n"
56
+ "cron: 0 0 * * *\n"
57
+ "---\n"
58
+ "\n"
59
+ "# Skill Body\n"
60
+ "Do the scheduled work.\n"
61
+ )
62
+
63
+ self.assertEqual(metadata["cron"], "0 0 * * *")
64
+ self.assertEqual(metadata["name"], "Check stale feature flags")
65
+ self.assertEqual(body, "# Skill Body\nDo the scheduled work.")
66
+
67
+ def test_cron_matches_steps_and_lists(self):
68
+ tick = datetime(2026, 6, 7, 10, 15)
69
+
70
+ self.assertTrue(_cron_matches("*/5 10 * * *", tick))
71
+ self.assertTrue(_cron_matches("10,15 9-11 * * *", tick))
72
+ self.assertFalse(_cron_matches("*/10 10 * * *", tick))
73
+
74
+ def test_find_scheduled_skills_reads_skill_directories(self):
75
+ with tempfile.TemporaryDirectory() as temp_dir:
76
+ skills_dir = Path(temp_dir)
77
+ skill_dir = skills_dir / "daily-check"
78
+ skill_dir.mkdir()
79
+ (skill_dir / "SKILL.md").write_text(
80
+ "---\n"
81
+ "name: Daily Check\n"
82
+ "disable-model-invocation: true\n"
83
+ "cron: 0 0 * * *\n"
84
+ "---\n\n"
85
+ "Run the daily check.\n"
86
+ )
87
+
88
+ skills = _find_scheduled_skills(skills_dir)
89
+
90
+ self.assertEqual(len(skills), 1)
91
+ self.assertEqual(skills[0].name, "Daily Check")
92
+ self.assertEqual(skills[0].body, "Run the daily check.")
93
+
94
+ def test_find_scheduled_skills_requires_disable_model_invocation(self):
95
+ with tempfile.TemporaryDirectory() as temp_dir:
96
+ skills_dir = Path(temp_dir)
97
+ skill_dir = skills_dir / "daily-check"
98
+ skill_dir.mkdir()
99
+ (skill_dir / "SKILL.md").write_text(
100
+ "---\nname: Daily Check\ncron: 0 0 * * *\n---\n\nRun the daily check.\n"
101
+ )
102
+
103
+ output = io.StringIO()
104
+ with contextlib.redirect_stdout(output):
105
+ skills = _find_scheduled_skills(skills_dir)
106
+
107
+ self.assertEqual(skills, [])
108
+ self.assertIn("ERROR", output.getvalue())
109
+ self.assertIn("disable-model-invocation: true", output.getvalue())
110
+
111
+ def test_find_aws_sqs_triggered_skills_reads_frontmatter(self):
112
+ with tempfile.TemporaryDirectory() as temp_dir:
113
+ skills_dir = Path(temp_dir)
114
+ skill_dir = skills_dir / "sqs-check"
115
+ skill_dir.mkdir()
116
+ (skill_dir / "SKILL.md").write_text(
117
+ "---\n"
118
+ "name: SQS Check\n"
119
+ "disable-model-invocation: true\n"
120
+ "x-codee-trigger: aws-sqs\n"
121
+ "x-codee-aws-sqs-queue: codee-queue\n"
122
+ "---\n\n"
123
+ "Process this content: {CONTENT}\n"
124
+ )
125
+
126
+ skills = find_aws_sqs_triggered_skills(skills_dir)
127
+
128
+ self.assertEqual(len(skills), 1)
129
+ self.assertEqual(skills[0].name, "SQS Check")
130
+ self.assertEqual(skills[0].queue, "codee-queue")
131
+ self.assertEqual(skills[0].body, "Process this content: {CONTENT}")
132
+
133
+ def test_find_aws_sqs_triggered_skills_requires_queue(self):
134
+ with tempfile.TemporaryDirectory() as temp_dir:
135
+ skills_dir = Path(temp_dir)
136
+ skill_dir = skills_dir / "sqs-check"
137
+ skill_dir.mkdir()
138
+ (skill_dir / "SKILL.md").write_text(
139
+ "---\n"
140
+ "name: SQS Check\n"
141
+ "disable-model-invocation: true\n"
142
+ "x-codee-trigger: aws-sqs\n"
143
+ "---\n\n"
144
+ "Process one SQS message.\n"
145
+ )
146
+
147
+ output = io.StringIO()
148
+ with contextlib.redirect_stdout(output):
149
+ skills = find_aws_sqs_triggered_skills(skills_dir)
150
+
151
+ self.assertEqual(skills, [])
152
+ self.assertIn("x-codee-aws-sqs-queue", output.getvalue())
153
+
154
+ def test_render_aws_sqs_prompt_replaces_content_placeholder(self):
155
+ self.assertEqual(
156
+ render_aws_sqs_prompt("Handle this:\n{CONTENT}", "message body"),
157
+ "Handle this:\nmessage body",
158
+ )
159
+
160
+ def test_render_aws_sqs_prompt_appends_content_without_placeholder(self):
161
+ self.assertEqual(
162
+ render_aws_sqs_prompt("Handle this message.", "message body"),
163
+ "Handle this message.\n\nmessage body",
164
+ )
165
+
166
+ def test_reconcile_runs_due_skill_once_per_minute(self):
167
+ calls = []
168
+
169
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
170
+ calls.append((message, session_id))
171
+ return "done"
172
+
173
+ with tempfile.TemporaryDirectory() as temp_dir:
174
+ root = Path(temp_dir)
175
+ skills_dir = root / "skills"
176
+ state_file = root / "state.json"
177
+ skill_dir = skills_dir / "daily-check"
178
+ skill_dir.mkdir(parents=True)
179
+ (skill_dir / "SKILL.md").write_text(
180
+ "---\n"
181
+ "name: Daily Check\n"
182
+ "disable-model-invocation: true\n"
183
+ "cron: 15 10 * * *\n"
184
+ "---\n\n"
185
+ "Run the daily check.\n"
186
+ )
187
+ tick = datetime(2026, 6, 7, 10, 15, 30)
188
+
189
+ trigger_cron_skills(run_claude, now=tick, skills_dir=skills_dir,
190
+ state_file=state_file, main_context=_ctx(root))
191
+ trigger_cron_skills(run_claude, now=tick, skills_dir=skills_dir,
192
+ state_file=state_file, main_context=_ctx(root))
193
+
194
+ self.assertEqual(len(calls), 1)
195
+ self.assertEqual(calls[0][0], "Run the daily check.")
196
+ self.assertTrue(calls[0][1])
197
+
198
+ def test_reconcile_retries_when_run_fails(self):
199
+ # A failing run (e.g. over usage limit) must NOT mark the slot done, so
200
+ # the next tick within the catch-up window runs it again.
201
+ calls = []
202
+
203
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
204
+ calls.append(session_id)
205
+ if len(calls) == 1:
206
+ raise RuntimeError("over limit")
207
+ return "done"
208
+
209
+ with tempfile.TemporaryDirectory() as temp_dir:
210
+ root = Path(temp_dir)
211
+ skills_dir = root / "skills"
212
+ state_file = root / "state.json"
213
+ skill_dir = skills_dir / "daily-check"
214
+ skill_dir.mkdir(parents=True)
215
+ (skill_dir / "SKILL.md").write_text(
216
+ "---\n"
217
+ "name: Daily Check\n"
218
+ "disable-model-invocation: true\n"
219
+ "cron: 15 10 * * *\n"
220
+ "---\n\n"
221
+ "Run the daily check.\n"
222
+ )
223
+ tick = datetime(2026, 6, 7, 10, 15, 30)
224
+
225
+ trigger_cron_skills(run_claude, now=tick, skills_dir=skills_dir,
226
+ state_file=state_file, main_context=_ctx(root))
227
+ # nothing recorded yet -> retried on the next tick same minute
228
+ self.assertFalse(state_file.exists())
229
+ trigger_cron_skills(run_claude, now=tick, skills_dir=skills_dir,
230
+ state_file=state_file, main_context=_ctx(root))
231
+
232
+ self.assertEqual(len(calls), 2)
233
+ self.assertTrue(state_file.exists())
234
+
235
+ def test_latest_due_finds_missed_fire_within_window(self):
236
+ # cron fires at 12:05; a tick lands at 12:10 (exact minute was skipped)
237
+ cron = "5 12 * * *"
238
+ tick = datetime(2026, 6, 7, 12, 10)
239
+ due = _latest_due(cron, tick, timedelta(hours=24))
240
+ self.assertEqual(due, datetime(2026, 6, 7, 12, 5))
241
+
242
+ def test_latest_due_returns_none_outside_window(self):
243
+ # weekly Monday-midnight cron, tick is days later, window only 24h
244
+ cron = "0 0 * * 1"
245
+ tick = datetime(2026, 6, 7, 12, 10) # a Sunday
246
+ self.assertIsNone(_latest_due(cron, tick, timedelta(hours=24)))
247
+
248
+ def test_reconcile_catches_up_missed_minute(self):
249
+ calls = []
250
+
251
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
252
+ calls.append((message, session_id))
253
+ return "done"
254
+
255
+ with tempfile.TemporaryDirectory() as temp_dir:
256
+ root = Path(temp_dir)
257
+ skills_dir = root / "skills"
258
+ state_file = root / "state.json"
259
+ skill_dir = skills_dir / "daily-check"
260
+ skill_dir.mkdir(parents=True)
261
+ (skill_dir / "SKILL.md").write_text(
262
+ "---\n"
263
+ "name: Daily Check\n"
264
+ "disable-model-invocation: true\n"
265
+ "cron: 5 12 * * *\n"
266
+ "---\n\n"
267
+ "Run the daily check.\n"
268
+ )
269
+
270
+ # 12:00 tick: not yet due, nothing runs.
271
+ trigger_cron_skills(
272
+ run_claude,
273
+ now=datetime(2026, 6, 7, 12, 0),
274
+ skills_dir=skills_dir,
275
+ state_file=state_file,
276
+ main_context=_ctx(root),
277
+ )
278
+ self.assertEqual(calls, [])
279
+
280
+ # 12:10 tick: the 12:05 fire was missed while busy -> catch up now.
281
+ trigger_cron_skills(
282
+ run_claude,
283
+ now=datetime(2026, 6, 7, 12, 10),
284
+ skills_dir=skills_dir,
285
+ state_file=state_file,
286
+ main_context=_ctx(root),
287
+ )
288
+ self.assertEqual(len(calls), 1)
289
+
290
+ # A later tick the same day must not re-run the same occurrence.
291
+ trigger_cron_skills(
292
+ run_claude,
293
+ now=datetime(2026, 6, 7, 12, 20),
294
+ skills_dir=skills_dir,
295
+ state_file=state_file,
296
+ main_context=_ctx(root),
297
+ )
298
+ self.assertEqual(len(calls), 1)
299
+
300
+ state = json.loads(state_file.read_text())
301
+ self.assertEqual(list(state.values()), ["2026-06-07T12:05"])
302
+
303
+ def test_reconcile_does_not_backrun_on_first_sight(self):
304
+ # Starting the listener after a past fire must not retroactively run it;
305
+ # it should only seed a baseline so future misses are caught.
306
+ calls = []
307
+
308
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
309
+ calls.append((message, session_id))
310
+ return "done"
311
+
312
+ with tempfile.TemporaryDirectory() as temp_dir:
313
+ root = Path(temp_dir)
314
+ skills_dir = root / "skills"
315
+ state_file = root / "state.json"
316
+ skill_dir = skills_dir / "daily-check"
317
+ skill_dir.mkdir(parents=True)
318
+ (skill_dir / "SKILL.md").write_text(
319
+ "---\n"
320
+ "name: Daily Check\n"
321
+ "disable-model-invocation: true\n"
322
+ "cron: 0 0 * * *\n"
323
+ "---\n\n"
324
+ "Run the daily check.\n"
325
+ )
326
+
327
+ # First ever tick lands at 23:00, hours after the 00:00 fire.
328
+ trigger_cron_skills(
329
+ run_claude,
330
+ now=datetime(2026, 6, 7, 23, 0),
331
+ skills_dir=skills_dir,
332
+ state_file=state_file,
333
+ main_context=_ctx(root),
334
+ )
335
+ self.assertEqual(calls, [])
336
+
337
+ state = json.loads(state_file.read_text())
338
+ self.assertEqual(list(state.values()), ["2026-06-07T00:00"])
339
+
340
+ def test_force_run_fires_off_schedule_then_clears(self):
341
+ calls = []
342
+
343
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
344
+ calls.append(session_id)
345
+ return "done"
346
+
347
+ with tempfile.TemporaryDirectory() as temp_dir:
348
+ root = Path(temp_dir)
349
+ skills_dir = root / "skills"
350
+ state_file = root / "state.json"
351
+ # request_force_run derives this path from main_context.data_dir.
352
+ force_file = root / "cron_skill_force.json"
353
+ skill_dir = skills_dir / "daily-check"
354
+ skill_dir.mkdir(parents=True)
355
+ skill_path = skill_dir / "SKILL.md"
356
+ skill_path.write_text(
357
+ "---\n"
358
+ "name: Daily Check\n"
359
+ "disable-model-invocation: true\n"
360
+ "cron: 0 0 * * *\n"
361
+ "---\n\n"
362
+ "Run the daily check.\n"
363
+ )
364
+
365
+ # 15:30 is nowhere near the 00:00 cron, but a force request must run it.
366
+ request_force_run(skill_path, _ctx(root))
367
+ off_schedule = datetime(2026, 6, 7, 15, 30)
368
+ trigger_cron_skills(run_claude, now=off_schedule, skills_dir=skills_dir,
369
+ state_file=state_file, force_file=force_file,
370
+ main_context=_ctx(root))
371
+ self.assertEqual(len(calls), 1)
372
+
373
+ # Force is one-shot: a later off-schedule tick must not re-run it.
374
+ trigger_cron_skills(run_claude, now=datetime(2026, 6, 7, 15, 31),
375
+ skills_dir=skills_dir, state_file=state_file,
376
+ force_file=force_file, main_context=_ctx(root))
377
+ self.assertEqual(len(calls), 1)
378
+ self.assertEqual(json.loads(force_file.read_text()), [])
379
+
380
+ def test_force_run_stays_queued_when_run_fails(self):
381
+ calls = []
382
+
383
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
384
+ calls.append(session_id)
385
+ raise RuntimeError("over limit")
386
+
387
+ with tempfile.TemporaryDirectory() as temp_dir:
388
+ root = Path(temp_dir)
389
+ skills_dir = root / "skills"
390
+ state_file = root / "state.json"
391
+ # request_force_run derives this path from main_context.data_dir.
392
+ force_file = root / "cron_skill_force.json"
393
+ skill_dir = skills_dir / "daily-check"
394
+ skill_dir.mkdir(parents=True)
395
+ skill_path = skill_dir / "SKILL.md"
396
+ skill_path.write_text(
397
+ "---\nname: Daily Check\ndisable-model-invocation: true\n"
398
+ "cron: 0 0 * * *\n---\n\nRun the daily check.\n"
399
+ )
400
+
401
+ request_force_run(skill_path, _ctx(root))
402
+ trigger_cron_skills(run_claude, now=datetime(2026, 6, 7, 15, 30),
403
+ skills_dir=skills_dir, state_file=state_file,
404
+ force_file=force_file, main_context=_ctx(root))
405
+
406
+ # Still queued so the next tick retries it.
407
+ key = json.loads(force_file.read_text())
408
+ self.assertEqual(len(key), 1)
409
+ self.assertEqual(len(calls), 1)
410
+
411
+ def test_reconcile_passes_the_skill_model_to_the_agent(self):
412
+ calls = []
413
+
414
+ def run_claude(message: str, session_id: str, model: str = "") -> str:
415
+ calls.append(model)
416
+ return "done"
417
+
418
+ with tempfile.TemporaryDirectory() as temp_dir:
419
+ root = Path(temp_dir)
420
+ skills_dir = root / "skills"
421
+ for slug, model_line in (("with-model", "model: claude-opus-5\n"),
422
+ ("without-model", "")):
423
+ skill_dir = skills_dir / slug
424
+ skill_dir.mkdir(parents=True)
425
+ (skill_dir / "SKILL.md").write_text(
426
+ "---\n"
427
+ f"name: {slug}\n"
428
+ "disable-model-invocation: true\n"
429
+ "cron: 15 10 * * *\n"
430
+ f"{model_line}"
431
+ "---\n\n"
432
+ "Run it.\n"
433
+ )
434
+ tick = datetime(2026, 6, 7, 10, 15)
435
+
436
+ trigger_cron_skills(run_claude, now=tick, skills_dir=skills_dir,
437
+ state_file=root / "state.json",
438
+ main_context=_ctx(root))
439
+
440
+ # Sorted by directory name, so the skill declaring a model comes first.
441
+ self.assertEqual(calls, ["claude-opus-5", ""])
442
+
443
+ def test_reconcile_runs_one_aws_sqs_message_per_tick(self):
444
+ calls = []
445
+ message = AwsSqsMessage(
446
+ content="payload",
447
+ queue_url="https://sqs.example/queue",
448
+ receipt_handle="receipt",
449
+ )
450
+ sqs_source = FakeSqsMessageSource([message])
451
+
452
+ def run_claude(user_message: str, session_id: str, model: str = "") -> str:
453
+ calls.append((user_message, session_id))
454
+ return "done"
455
+
456
+ with tempfile.TemporaryDirectory() as temp_dir:
457
+ root = Path(temp_dir)
458
+ skills_dir = root / "skills"
459
+ skill_dir = skills_dir / "sqs-check"
460
+ skill_dir.mkdir(parents=True)
461
+ (skill_dir / "SKILL.md").write_text(
462
+ "---\n"
463
+ "name: SQS Check\n"
464
+ "disable-model-invocation: true\n"
465
+ "x-codee-trigger: aws-sqs\n"
466
+ "x-codee-aws-sqs-queue: codee-queue\n"
467
+ "---\n\n"
468
+ "Process this content: {CONTENT}\n"
469
+ )
470
+
471
+ trigger_aws_sqs_skills(
472
+ run_claude,
473
+ skills_dir=skills_dir,
474
+ sqs_message_source=sqs_source,
475
+ main_context=_ctx(root),
476
+ )
477
+
478
+ self.assertEqual(len(calls), 1)
479
+ self.assertEqual(calls[0][0], "Process this content: payload")
480
+ self.assertTrue(calls[0][1])
481
+ self.assertEqual(sqs_source.deleted, [message])
482
+
483
+
484
+ if __name__ == "__main__":
485
+ unittest.main()