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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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"]
|