crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,631 @@
1
+ """Runtime scheduler for persistent CrabCode jobs.
2
+
3
+ The manager is intentionally small and dependency-free. SQLite provides the
4
+ cross-process lease, while one asyncio task owns the local polling loop and
5
+ the bounded set of executions. A caller can inject an executor in tests or
6
+ for an embedding application; the default executor runs a detached
7
+ ``CoreSession``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import inspect
14
+ import os
15
+ import uuid
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Awaitable, Callable
20
+
21
+ from crabcode_core.logging_utils import get_logger
22
+ from crabcode_core.schedule.models import JobRun, JobStatus, RunStatus, ScheduleJob, ScheduleType
23
+ from crabcode_core.schedule.store import ScheduleStore
24
+ from crabcode_core.schedule.timing import compute_next_run, parse_iso_timestamp
25
+ from crabcode_core.types.config import ScheduleSettings
26
+ from crabcode_core.types.event import ScheduleRunEvent
27
+
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ScheduleExecutionResult:
33
+ """Normalized result returned by a scheduled-job executor."""
34
+
35
+ summary: str = ""
36
+ tokens_used: int = 0
37
+ session_id: str | None = None
38
+
39
+
40
+ ScheduleExecutor = Callable[[ScheduleJob], Awaitable[ScheduleExecutionResult | str | dict[str, Any] | None]]
41
+ EventSink = Callable[[ScheduleRunEvent], Awaitable[None] | None]
42
+
43
+
44
+ def _iso_now() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def _as_job(value: ScheduleJob | dict[str, Any]) -> ScheduleJob:
49
+ return value if isinstance(value, ScheduleJob) else ScheduleJob.model_validate(value)
50
+
51
+
52
+ class ScheduleManager:
53
+ """Manage scheduled jobs and execute them in the background."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ settings: ScheduleSettings | None = None,
59
+ cwd: str = ".",
60
+ session_id: str = "",
61
+ store: ScheduleStore | None = None,
62
+ event_sink: EventSink | None = None,
63
+ executor: ScheduleExecutor | None = None,
64
+ ) -> None:
65
+ self.settings = settings or ScheduleSettings()
66
+ self.cwd = os.path.abspath(cwd)
67
+ self.session_id = session_id
68
+ self._store = store or ScheduleStore(
69
+ Path(":memory:") if not self.settings.persist else None
70
+ )
71
+ self._owns_store = store is None
72
+ self._event_sink = event_sink
73
+ self._executor = executor or self._default_executor
74
+ self._owner = f"{os.getpid()}:{uuid.uuid4()}"
75
+ self._wake = asyncio.Event()
76
+ self._lifecycle_lock = asyncio.Lock()
77
+ self._close_task: asyncio.Task[None] | None = None
78
+ self._runner_task: asyncio.Task[None] | None = None
79
+ self._active_tasks: dict[str, asyncio.Task[None]] = {}
80
+ self._event_tasks: set[asyncio.Future[Any]] = set()
81
+ self._closing = False
82
+
83
+ @property
84
+ def store(self) -> ScheduleStore:
85
+ return self._store
86
+
87
+ @property
88
+ def running(self) -> bool:
89
+ return self._runner_task is not None and not self._runner_task.done()
90
+
91
+ def update_context(
92
+ self,
93
+ *,
94
+ cwd: str | None = None,
95
+ session_id: str | None = None,
96
+ settings: ScheduleSettings | None = None,
97
+ ) -> None:
98
+ """Refresh defaults used by jobs created after a session switch."""
99
+ if cwd is not None:
100
+ self.cwd = os.path.abspath(cwd)
101
+ if session_id is not None:
102
+ self.session_id = session_id
103
+ if settings is not None:
104
+ self.settings = settings
105
+ self._wake.set()
106
+
107
+ async def reconfigure(
108
+ self,
109
+ *,
110
+ settings: ScheduleSettings,
111
+ cwd: str | None = None,
112
+ session_id: str | None = None,
113
+ ) -> None:
114
+ """Apply project settings and start or suspend the runtime."""
115
+ if self._closing:
116
+ raise RuntimeError("ScheduleManager is closed")
117
+ self.update_context(cwd=cwd, session_id=session_id, settings=settings)
118
+ if settings.enabled:
119
+ await self.start()
120
+ else:
121
+ await self._stop_runtime()
122
+
123
+ async def start(self) -> None:
124
+ """Start polling and recover interrupted executions."""
125
+ if not self.settings.enabled:
126
+ return
127
+ async with self._lifecycle_lock:
128
+ if self._closing:
129
+ raise RuntimeError("ScheduleManager is closed")
130
+ if self.running:
131
+ return
132
+ for stale in self._store.get_stale_running_runs():
133
+ run = JobRun.model_validate(stale)
134
+ run.fail("Scheduler stopped before this run completed")
135
+ self._store.update_run(run)
136
+ self._runner_task = asyncio.create_task(
137
+ self._run_loop(),
138
+ name="crabcode-schedule-loop",
139
+ )
140
+ self._wake.set()
141
+
142
+ async def close(self) -> None:
143
+ """Stop polling, cancel executions, and release SQLite leases."""
144
+ async with self._lifecycle_lock:
145
+ if self._close_task is None:
146
+ if self._closing:
147
+ return
148
+ self._closing = True
149
+ self._close_task = asyncio.create_task(self._close_impl())
150
+ task = self._close_task
151
+ await asyncio.shield(task)
152
+
153
+ async def _close_impl(self) -> None:
154
+ await self._stop_runtime()
155
+ if self._owns_store:
156
+ self._store.close()
157
+
158
+ async def _stop_runtime(self) -> None:
159
+ """Stop local tasks without permanently closing the manager/store."""
160
+ async with self._lifecycle_lock:
161
+ runner = self._runner_task
162
+ self._runner_task = None
163
+ active = list(self._active_tasks.values())
164
+ event_tasks = list(self._event_tasks)
165
+ for task in [runner, *active, *event_tasks]:
166
+ if task is not None and not task.done():
167
+ task.cancel()
168
+ pending = [task for task in [runner, *active, *event_tasks] if task is not None]
169
+ if pending:
170
+ await asyncio.gather(*pending, return_exceptions=True)
171
+ try:
172
+ self._store.release_all_claims(self._owner)
173
+ except Exception:
174
+ logger.warning("Failed to release schedule leases", exc_info=True)
175
+
176
+ # ------------------------------------------------------------------
177
+ # Synchronous CRUD used by tools and HTTP adapters
178
+ # ------------------------------------------------------------------
179
+
180
+ def create_job(
181
+ self,
182
+ *,
183
+ name: str,
184
+ prompt: str,
185
+ schedule: str,
186
+ schedule_type: ScheduleType | str,
187
+ cwd: str | None = None,
188
+ enabled: bool = True,
189
+ max_runs: int | None = None,
190
+ next_run: str | None = None,
191
+ description: str = "",
192
+ tags: list[str] | None = None,
193
+ timeout: int | None = None,
194
+ model_profile: str | None = None,
195
+ session_id: str | None = None,
196
+ extra: dict[str, Any] | None = None,
197
+ ) -> ScheduleJob:
198
+ if not self.settings.enabled:
199
+ raise RuntimeError("Scheduling is disabled by configuration")
200
+ if not str(name).strip():
201
+ raise ValueError("name is required")
202
+ if not str(prompt).strip():
203
+ raise ValueError("prompt is required")
204
+ kind = ScheduleType(schedule_type)
205
+ if max_runs is not None and max_runs <= 0:
206
+ raise ValueError("max_runs must be greater than zero")
207
+ configured_max = self.settings.max_runs_per_job
208
+ if configured_max is not None:
209
+ max_runs = (
210
+ configured_max
211
+ if max_runs is None
212
+ else min(max_runs, configured_max)
213
+ )
214
+ if timeout is not None and timeout <= 0:
215
+ raise ValueError("timeout must be greater than zero")
216
+ if timeout is None:
217
+ timeout = self.settings.default_timeout
218
+
219
+ # Recompute rather than trusting a caller-provided timestamp. The
220
+ # latter is retained as an escape hatch for importing existing jobs,
221
+ # but malformed values should never enter the persistent queue.
222
+ computed = compute_next_run(schedule, kind)
223
+ if next_run is not None:
224
+ parse_iso_timestamp(next_run)
225
+ computed = next_run
226
+
227
+ job = ScheduleJob(
228
+ name=str(name).strip(),
229
+ prompt=str(prompt),
230
+ schedule=str(schedule).strip(),
231
+ schedule_type=kind,
232
+ cwd=os.path.abspath(cwd or self.cwd),
233
+ enabled=bool(enabled),
234
+ status=JobStatus.ACTIVE if enabled else JobStatus.PAUSED,
235
+ max_runs=max_runs,
236
+ next_run=computed,
237
+ description=description,
238
+ tags=list(tags or []),
239
+ timeout=timeout,
240
+ model_profile=model_profile,
241
+ session_id=session_id,
242
+ extra=dict(extra or {}),
243
+ )
244
+ self._store.upsert_schedule(job)
245
+ self._wake.set()
246
+ return job
247
+
248
+ def list_jobs(
249
+ self,
250
+ *,
251
+ status: str | None = None,
252
+ schedule_type: str | None = None,
253
+ enabled: bool | None = None,
254
+ limit: int = 100,
255
+ ) -> list[ScheduleJob]:
256
+ return [
257
+ _as_job(row)
258
+ for row in self._store.list_schedules(
259
+ status=status,
260
+ schedule_type=schedule_type,
261
+ enabled=enabled,
262
+ limit=max(1, min(int(limit), 1000)),
263
+ )
264
+ ]
265
+
266
+ def get_job(self, job_id: str) -> ScheduleJob | None:
267
+ row = self._store.resolve_schedule(job_id)
268
+ return _as_job(row) if row else None
269
+
270
+ def list_runs(
271
+ self,
272
+ job_id: str,
273
+ *,
274
+ status: str | None = None,
275
+ limit: int = 50,
276
+ ) -> list[dict[str, Any]]:
277
+ job = self.get_job(job_id)
278
+ if job is None:
279
+ return []
280
+ return self._store.list_runs(
281
+ job.id,
282
+ status=status,
283
+ limit=max(1, min(int(limit), 1000)),
284
+ )
285
+
286
+ def cancel_job(self, job_id: str) -> bool:
287
+ job = self.get_job(job_id)
288
+ if job is None:
289
+ return False
290
+ task = self._active_tasks.get(job.id)
291
+ if task is not None and not task.done():
292
+ task.cancel()
293
+ self._store.delete_schedule(job.id)
294
+ self._wake.set()
295
+ return True
296
+
297
+ def pause_job(self, job_id: str) -> ScheduleJob | None:
298
+ job = self.get_job(job_id)
299
+ if job is None:
300
+ return None
301
+ if job.status in {JobStatus.COMPLETED, JobStatus.DISABLED, JobStatus.ERROR}:
302
+ return job
303
+ task = self._active_tasks.get(job.id)
304
+ if task is not None and not task.done():
305
+ task.cancel()
306
+ job.enabled = False
307
+ job.status = JobStatus.PAUSED
308
+ self._store.upsert_schedule(job)
309
+ self._wake.set()
310
+ return job
311
+
312
+ def resume_job(self, job_id: str) -> ScheduleJob | None:
313
+ job = self.get_job(job_id)
314
+ if job is None:
315
+ return None
316
+ if job.status == JobStatus.COMPLETED:
317
+ return job
318
+ job.enabled = True
319
+ job.status = JobStatus.ACTIVE
320
+ job.next_run = compute_next_run(job.schedule, job.schedule_type)
321
+ self._store.upsert_schedule(job)
322
+ self._wake.set()
323
+ return job
324
+
325
+ async def trigger_job(self, job_id: str) -> bool:
326
+ """Run one job immediately without changing its recurring schedule."""
327
+ job = self.get_job(job_id)
328
+ if job is None or job.status == JobStatus.PAUSED:
329
+ return False
330
+ terminal = job.status in {JobStatus.ERROR, JobStatus.DISABLED}
331
+ if not terminal and (not job.enabled or job.status != JobStatus.ACTIVE):
332
+ return False
333
+ if not self._store.claim_schedule(
334
+ job.id,
335
+ self._owner,
336
+ lease_seconds=max(60, (job.timeout or self.settings.default_timeout) + 60),
337
+ include_terminal=terminal,
338
+ ):
339
+ return False
340
+ self._recover_job_runs(job.id)
341
+ task = asyncio.create_task(self._execute_claimed_job(job, manual=True))
342
+ self._active_tasks[job.id] = task
343
+ task.add_done_callback(lambda done: self._forget_task(job.id, done))
344
+ return True
345
+
346
+ # ------------------------------------------------------------------
347
+ # Polling and execution
348
+ # ------------------------------------------------------------------
349
+
350
+ async def _run_loop(self) -> None:
351
+ try:
352
+ while not self._closing:
353
+ try:
354
+ capacity = max(
355
+ 0,
356
+ max(1, int(self.settings.max_concurrent_jobs))
357
+ - len(self._active_tasks),
358
+ )
359
+ if capacity:
360
+ claimed = self._store.claim_due_schedules(
361
+ self._owner,
362
+ limit=capacity,
363
+ lease_seconds=max(
364
+ 60,
365
+ int(self.settings.default_timeout) + 60,
366
+ ),
367
+ )
368
+ for row in claimed:
369
+ job = _as_job(row)
370
+ self._recover_job_runs(job.id)
371
+ task = asyncio.create_task(self._execute_claimed_job(job))
372
+ self._active_tasks[job.id] = task
373
+ task.add_done_callback(
374
+ lambda done, jid=job.id: self._forget_task(jid, done)
375
+ )
376
+ if claimed:
377
+ continue
378
+
379
+ timeout = 30.0
380
+ next_wakeup = self._store.get_next_wakeup()
381
+ if next_wakeup is not None:
382
+ try:
383
+ delay = (
384
+ parse_iso_timestamp(next_wakeup)
385
+ - datetime.now(timezone.utc)
386
+ ).total_seconds()
387
+ timeout = max(0.01, min(timeout, delay))
388
+ except ValueError:
389
+ pass
390
+ try:
391
+ await asyncio.wait_for(self._wake.wait(), timeout=timeout)
392
+ except asyncio.TimeoutError:
393
+ pass
394
+ self._wake.clear()
395
+ except asyncio.CancelledError:
396
+ raise
397
+ except Exception:
398
+ logger.exception("Schedule polling iteration failed")
399
+ try:
400
+ await asyncio.wait_for(self._wake.wait(), timeout=5.0)
401
+ except asyncio.TimeoutError:
402
+ pass
403
+ self._wake.clear()
404
+ except asyncio.CancelledError:
405
+ raise
406
+
407
+ def _forget_task(self, job_id: str, task: asyncio.Task[None]) -> None:
408
+ if self._active_tasks.get(job_id) is task:
409
+ self._active_tasks.pop(job_id, None)
410
+ try:
411
+ task.result()
412
+ except asyncio.CancelledError:
413
+ pass
414
+ except Exception:
415
+ logger.warning("Scheduled job %s failed outside execution fence", job_id, exc_info=True)
416
+ self._wake.set()
417
+
418
+ def _recover_job_runs(self, job_id: str) -> None:
419
+ """Fail orphaned runs after this manager acquires their job lease."""
420
+ for stale in self._store.list_runs(
421
+ job_id,
422
+ status=RunStatus.RUNNING.value,
423
+ limit=1000,
424
+ ):
425
+ run = JobRun.model_validate(stale)
426
+ run.fail("Scheduler lease expired before this run completed")
427
+ self._store.update_run(run)
428
+
429
+ async def _execute_claimed_job(self, job: ScheduleJob, *, manual: bool = False) -> None:
430
+ run = JobRun(job_id=job.id)
431
+ self._store.record_run(run)
432
+ run.start()
433
+ self._store.update_run(run)
434
+ timeout = max(1, int(job.timeout or self.settings.default_timeout))
435
+ try:
436
+ self._store.renew_claim(job.id, self._owner, timeout + 60)
437
+ raw_result = await asyncio.wait_for(self._executor(job), timeout=timeout)
438
+ result = self._normalize_result(raw_result)
439
+ run.succeed(result.summary, result.tokens_used)
440
+ if result.session_id:
441
+ run.session_id = result.session_id
442
+ self._finish_job(job, run, success=True, manual=manual)
443
+ except asyncio.TimeoutError:
444
+ run.timeout()
445
+ self._finish_job(job, run, success=False, manual=manual)
446
+ except asyncio.CancelledError:
447
+ # Cancellation during manager shutdown leaves the occurrence due;
448
+ # restart recovery will mark this run failed and try it again.
449
+ if run.status == RunStatus.RUNNING:
450
+ run.fail("Scheduled job cancelled")
451
+ self._store.update_run(run)
452
+ self._store.release_claim(job.id, self._owner)
453
+ raise
454
+ except Exception as exc:
455
+ run.fail(str(exc))
456
+ self._finish_job(job, run, success=False, manual=manual)
457
+
458
+ @staticmethod
459
+ def _normalize_result(value: Any) -> ScheduleExecutionResult:
460
+ if isinstance(value, ScheduleExecutionResult):
461
+ return value
462
+ if isinstance(value, str):
463
+ return ScheduleExecutionResult(summary=value)
464
+ if isinstance(value, dict):
465
+ return ScheduleExecutionResult(
466
+ summary=str(value.get("summary") or value.get("result_summary") or ""),
467
+ tokens_used=int(value.get("tokens_used") or 0),
468
+ session_id=value.get("session_id"),
469
+ )
470
+ return ScheduleExecutionResult()
471
+
472
+ def _finish_job(
473
+ self,
474
+ original: ScheduleJob,
475
+ run: JobRun,
476
+ *,
477
+ success: bool,
478
+ manual: bool,
479
+ ) -> None:
480
+ self._store.update_run(run)
481
+ current_row = self._store.get_schedule(original.id)
482
+ if current_row is None:
483
+ return
484
+ current = _as_job(current_row)
485
+ current.run_count += 1
486
+ current.last_run = run.finished_at or _iso_now()
487
+ next_run: str | None = current.next_run
488
+ if current.schedule_type == ScheduleType.ONCE:
489
+ current.next_run = None
490
+ current.enabled = False
491
+ current.status = JobStatus.COMPLETED if success else JobStatus.ERROR
492
+ self._store.upsert_schedule(current)
493
+ elif manual:
494
+ # A manual trigger consumes a run count but leaves the scheduled
495
+ # occurrence untouched unless max_runs has now been reached.
496
+ if current.max_runs is not None and current.run_count >= current.max_runs:
497
+ current.status = JobStatus.COMPLETED
498
+ current.enabled = False
499
+ current.next_run = None
500
+ elif success and original.status in {
501
+ JobStatus.ERROR,
502
+ JobStatus.DISABLED,
503
+ }:
504
+ current.status = JobStatus.ACTIVE
505
+ current.enabled = True
506
+ current.next_run = compute_next_run(
507
+ current.schedule,
508
+ current.schedule_type,
509
+ )
510
+ self._store.upsert_schedule(current)
511
+ elif current.max_runs is not None and current.run_count >= current.max_runs:
512
+ current.next_run = None
513
+ current.enabled = False
514
+ current.status = JobStatus.COMPLETED
515
+ self._store.upsert_schedule(current)
516
+ else:
517
+ try:
518
+ previous = parse_iso_timestamp(next_run) if next_run else None
519
+ current.next_run = compute_next_run(
520
+ current.schedule,
521
+ current.schedule_type,
522
+ previous_run=previous,
523
+ )
524
+ current.status = JobStatus.ACTIVE
525
+ current.enabled = True
526
+ self._store.upsert_schedule(current)
527
+ except Exception as exc:
528
+ current.status = JobStatus.ERROR
529
+ current.enabled = False
530
+ current.next_run = None
531
+ current.extra["schedule_error"] = str(exc)
532
+ self._store.upsert_schedule(current)
533
+
534
+ self._store.release_claim(original.id, self._owner)
535
+ event = ScheduleRunEvent(
536
+ job_id=original.id,
537
+ run_id=run.id,
538
+ status=run.status.value,
539
+ duration_seconds=run.duration_seconds or 0.0,
540
+ error_message=run.error_message or "",
541
+ result_summary=run.result_summary or "",
542
+ next_run=(self._store.get_schedule(original.id) or {}).get("next_run"),
543
+ )
544
+ if self._event_sink is not None:
545
+ try:
546
+ outcome = self._event_sink(event)
547
+ if inspect.isawaitable(outcome):
548
+ task = asyncio.ensure_future(outcome)
549
+ self._event_tasks.add(task)
550
+ task.add_done_callback(self._finish_event_task)
551
+ except Exception:
552
+ logger.warning("Failed to publish schedule event", exc_info=True)
553
+
554
+ def _finish_event_task(self, task: asyncio.Future[Any]) -> None:
555
+ self._event_tasks.discard(task)
556
+ if task.cancelled():
557
+ return
558
+ try:
559
+ task.result()
560
+ except Exception:
561
+ logger.warning("Failed to publish schedule event", exc_info=True)
562
+
563
+ async def _default_executor(self, job: ScheduleJob) -> ScheduleExecutionResult:
564
+ """Execute a job in an isolated, non-interactive CoreSession."""
565
+ from crabcode_core.events import CoreSession
566
+ from crabcode_core.types.config import CrabCodeSettings
567
+ from crabcode_core.permissions.manager import PermissionMode
568
+ from crabcode_core.types.event import (
569
+ ErrorEvent,
570
+ StreamRetryEvent,
571
+ StreamTextEvent,
572
+ TurnCompleteEvent,
573
+ )
574
+
575
+ explicit = CrabCodeSettings()
576
+ explicit.schedule.enabled = False
577
+ if job.model_profile:
578
+ explicit.default_model = job.model_profile
579
+ settings = CrabCodeSettings()
580
+ settings._crabcode_explicit_settings = explicit
581
+ child = CoreSession(cwd=job.cwd or self.cwd, settings=settings)
582
+ output: list[str] = []
583
+ tokens = 0
584
+ errors: list[str] = []
585
+ try:
586
+ await child.initialize()
587
+ if child._permission_manager is not None and child._permission_manager.mode in {
588
+ PermissionMode.DEFAULT,
589
+ PermissionMode.AI_REVIEW,
590
+ }:
591
+ # A scheduler has no human response queue. Preserve explicit
592
+ # allow/deny rules but turn an unanswered prompt into a deny.
593
+ child._permission_manager.mode = PermissionMode.DONT_ASK
594
+ if job.session_id:
595
+ await child.resume(job.session_id)
596
+ async for event in child.send_message(job.prompt):
597
+ if isinstance(event, StreamTextEvent):
598
+ output.append(event.text)
599
+ elif isinstance(event, StreamRetryEvent):
600
+ remaining = max(0, int(event.discarded_text_chars))
601
+ while remaining and output:
602
+ chunk = output.pop()
603
+ if len(chunk) > remaining:
604
+ output.append(chunk[:-remaining])
605
+ remaining = 0
606
+ else:
607
+ remaining -= len(chunk)
608
+ elif isinstance(event, ErrorEvent):
609
+ errors.append(event.message)
610
+ elif isinstance(event, TurnCompleteEvent):
611
+ usage = event.usage or {}
612
+ tokens = int(
613
+ usage.get("total_tokens")
614
+ or (
615
+ usage.get("input_tokens", 0)
616
+ + usage.get("output_tokens", 0)
617
+ )
618
+ or tokens
619
+ )
620
+ if errors:
621
+ raise RuntimeError("; ".join(errors[-3:]))
622
+ return ScheduleExecutionResult(
623
+ summary="".join(output).strip()[-4000:],
624
+ tokens_used=tokens,
625
+ session_id=child.session_id or None,
626
+ )
627
+ finally:
628
+ await child.close()
629
+
630
+
631
+ __all__ = ["ScheduleExecutionResult", "ScheduleManager"]