monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,76 @@
1
+ """Run identifiers, scratch directory layout, and completed-run cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import secrets
7
+ import shutil
8
+ import time
9
+ from datetime import UTC, datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ _CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+
14
+
15
+ def _encode_ulid_payload(payload: bytes) -> str:
16
+ """Encode 16 bytes into a Crockford-base32 ULID string (uppercase length 26)."""
17
+ if len(payload) != 16:
18
+ msg = "ULID payload must be 16 bytes (48-bit timestamp + 80-bit randomness)."
19
+ raise ValueError(msg)
20
+ buffer = int.from_bytes(payload, "big")
21
+ chars: list[str] = []
22
+ for _ in range(26):
23
+ chars.append(_CROCKFORD_ALPHABET[buffer & 0x1F])
24
+ buffer >>= 5
25
+ return "".join(reversed(chars))
26
+
27
+
28
+ def make_run_id() -> str:
29
+ """Return a new ULID string (26 Crockford base32 chars)."""
30
+ timestamp_ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF
31
+ suffix = secrets.token_bytes(10)
32
+ payload = timestamp_ms.to_bytes(6, "big") + suffix
33
+ return _encode_ulid_payload(payload)
34
+
35
+
36
+ async def create_scratch_dir(run_id: str, base: Path) -> Path:
37
+ """Create ``base/{run_id}/`` with required subdirectories."""
38
+ root = base / run_id
39
+ await asyncio.to_thread(root.mkdir, parents=True, exist_ok=False)
40
+ input_dir = root / "input"
41
+ promotions = root / "memory-promotions"
42
+ await asyncio.to_thread(input_dir.mkdir, parents=True, exist_ok=True)
43
+ await asyncio.to_thread(promotions.mkdir, parents=True, exist_ok=True)
44
+ return root
45
+
46
+
47
+ def _mtime_utc(child: Path) -> datetime:
48
+ st = child.stat()
49
+ return datetime.fromtimestamp(st.st_mtime, tz=UTC)
50
+
51
+
52
+ async def cleanup_old_runs(base: Path, older_than_days: int = 7) -> int:
53
+ """Delete eligible run directories older than cutoff. Returns number deleted."""
54
+ if older_than_days < 1:
55
+ msg = "older_than_days must be >= 1"
56
+ raise ValueError(msg)
57
+
58
+ now = datetime.now(tz=UTC)
59
+ cutoff = now - timedelta(days=older_than_days)
60
+
61
+ deleted = 0
62
+
63
+ def scan_children() -> list[Path]:
64
+ return sorted(p for p in base.iterdir() if p.is_dir())
65
+
66
+ for child in await asyncio.to_thread(scan_children):
67
+ marker = child / "output.json"
68
+ if not await asyncio.to_thread(marker.exists):
69
+ continue
70
+ dir_mtime = await asyncio.to_thread(_mtime_utc, child)
71
+ if not (dir_mtime < cutoff):
72
+ continue
73
+ await asyncio.to_thread(shutil.rmtree, child, False)
74
+ deleted += 1
75
+
76
+ return deleted
@@ -0,0 +1,435 @@
1
+ """Durable scheduled agent loop records (prompt-first /loop-style automation)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from typing import cast
10
+
11
+ import aiosqlite
12
+
13
+ _SCHEDULED_LOOP_COLUMNS: tuple[str, ...] = (
14
+ "loop_id",
15
+ "session_id",
16
+ "status",
17
+ "prompt",
18
+ "interval_ms",
19
+ "max_ticks",
20
+ "max_runtime_ms",
21
+ "skip_if_busy",
22
+ "tick_index",
23
+ "next_tick_at_ms",
24
+ "started_at_ms",
25
+ "last_tick_at_ms",
26
+ "last_error",
27
+ "stop_reason",
28
+ "tick_in_flight",
29
+ "worker_id",
30
+ "claimed_at_ms",
31
+ )
32
+
33
+ _LOOP_STATUSES = frozenset({"active", "paused", "completed", "failed"})
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ScheduledLoopRow:
38
+ """One row from the ``scheduled_loops`` table."""
39
+
40
+ loop_id: str
41
+ session_id: str
42
+ status: str
43
+ prompt: str
44
+ interval_ms: int
45
+ max_ticks: int | None
46
+ max_runtime_ms: int | None
47
+ skip_if_busy: bool
48
+ tick_index: int
49
+ next_tick_at_ms: int
50
+ started_at_ms: int
51
+ last_tick_at_ms: int | None
52
+ last_error: str | None
53
+ stop_reason: str | None
54
+ tick_in_flight: bool
55
+ worker_id: str | None = None
56
+ claimed_at_ms: int | None = None
57
+
58
+
59
+ def _optional_int_field(raw: object) -> int | None:
60
+ if raw is None:
61
+ return None
62
+ if isinstance(raw, bool):
63
+ return None
64
+ if isinstance(raw, int):
65
+ return raw
66
+ if isinstance(raw, float):
67
+ return int(raw)
68
+ if isinstance(raw, str) and raw.strip():
69
+ try:
70
+ return int(raw)
71
+ except ValueError:
72
+ return None
73
+ return None
74
+
75
+
76
+ def _bool_field(raw: object, *, default: bool = False) -> bool:
77
+ if isinstance(raw, bool):
78
+ return raw
79
+ if isinstance(raw, int):
80
+ return bool(raw)
81
+ if isinstance(raw, str):
82
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
83
+ return default
84
+
85
+
86
+ def doc_to_scheduled_loop_row(loop_id: str, data: dict[str, object]) -> ScheduledLoopRow:
87
+ """Map a Firestore document (or JSON blob) to :class:`ScheduledLoopRow`."""
88
+ return ScheduledLoopRow(
89
+ loop_id=loop_id,
90
+ session_id=str(data.get("session_id", "")),
91
+ status=str(data.get("status", "")),
92
+ prompt=str(data.get("prompt", "")),
93
+ interval_ms=int(cast(int, data.get("interval_ms", 0))),
94
+ max_ticks=_optional_int_field(data.get("max_ticks")),
95
+ max_runtime_ms=_optional_int_field(data.get("max_runtime_ms")),
96
+ skip_if_busy=_bool_field(data.get("skip_if_busy"), default=True),
97
+ tick_index=int(cast(int, data.get("tick_index", 0))),
98
+ next_tick_at_ms=int(cast(int, data.get("next_tick_at_ms", 0))),
99
+ started_at_ms=int(cast(int, data.get("started_at_ms", 0))),
100
+ last_tick_at_ms=_optional_int_field(data.get("last_tick_at_ms")),
101
+ last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
102
+ stop_reason=str(data["stop_reason"]) if data.get("stop_reason") is not None else None,
103
+ tick_in_flight=_bool_field(data.get("tick_in_flight")),
104
+ worker_id=str(data["worker_id"]) if data.get("worker_id") is not None else None,
105
+ claimed_at_ms=_optional_int_field(data.get("claimed_at_ms")),
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ScheduledLoopCreate:
111
+ """Arguments for registering a new scheduled loop."""
112
+
113
+ prompt: str
114
+ interval_ms: int
115
+ session_id: str = "loop-main"
116
+ loop_id: str | None = None
117
+ max_ticks: int | None = None
118
+ max_runtime_ms: int | None = None
119
+ skip_if_busy: bool = True
120
+ unbounded: bool = False
121
+
122
+
123
+ def validate_loop_guards(
124
+ *,
125
+ max_ticks: int | None,
126
+ max_runtime_ms: int | None,
127
+ unbounded: bool,
128
+ ) -> None:
129
+ """Require an explicit stop guard unless the operator opts into unbounded loops."""
130
+ if unbounded:
131
+ return
132
+ if max_ticks is None and max_runtime_ms is None:
133
+ raise ValueError(
134
+ "scheduled loops require max_ticks, max_runtime, or unbounded=true"
135
+ )
136
+
137
+
138
+ def _row_from_tuple(row: tuple[object, ...]) -> ScheduledLoopRow:
139
+ d = dict(zip(_SCHEDULED_LOOP_COLUMNS, row, strict=True))
140
+ return ScheduledLoopRow(
141
+ loop_id=str(d["loop_id"]),
142
+ session_id=str(d["session_id"]),
143
+ status=str(d["status"]),
144
+ prompt=str(d["prompt"]),
145
+ interval_ms=int(cast(int, d["interval_ms"])),
146
+ max_ticks=int(cast(int, d["max_ticks"])) if d["max_ticks"] is not None else None,
147
+ max_runtime_ms=(
148
+ int(cast(int, d["max_runtime_ms"])) if d["max_runtime_ms"] is not None else None
149
+ ),
150
+ skip_if_busy=bool(int(cast(int, d["skip_if_busy"]))),
151
+ tick_index=int(cast(int, d["tick_index"])),
152
+ next_tick_at_ms=int(cast(int, d["next_tick_at_ms"])),
153
+ started_at_ms=int(cast(int, d["started_at_ms"])),
154
+ last_tick_at_ms=(
155
+ int(cast(int, d["last_tick_at_ms"])) if d["last_tick_at_ms"] is not None else None
156
+ ),
157
+ last_error=str(d["last_error"]) if d["last_error"] is not None else None,
158
+ stop_reason=str(d["stop_reason"]) if d["stop_reason"] is not None else None,
159
+ tick_in_flight=bool(int(cast(int, d["tick_in_flight"]))),
160
+ worker_id=str(d["worker_id"]) if d["worker_id"] is not None else None,
161
+ claimed_at_ms=(
162
+ int(cast(int, d["claimed_at_ms"])) if d["claimed_at_ms"] is not None else None
163
+ ),
164
+ )
165
+
166
+
167
+ def _loop_id_from_create(spec: ScheduledLoopCreate) -> str:
168
+ if spec.loop_id and spec.loop_id.strip():
169
+ return spec.loop_id.strip()
170
+ return f"loop-{uuid.uuid4().hex[:12]}"
171
+
172
+
173
+ def format_tick_prompt(row: ScheduledLoopRow) -> str:
174
+ """Wrap the stored user prompt with tick metadata for each scheduled invocation."""
175
+ max_label = str(row.max_ticks) if row.max_ticks is not None else "∞"
176
+ tick_num = row.tick_index + 1
177
+ header = (
178
+ f"[SCHEDULED TICK {tick_num}/{max_label} · loop_id={row.loop_id} · "
179
+ f"session={row.session_id}]"
180
+ )
181
+ return f"{header}\n\n{row.prompt.strip()}"
182
+
183
+
184
+ class SQLiteScheduledLoopStore:
185
+ """SQLite persistence for scheduled agent loops."""
186
+
187
+ def __init__(self, conn: aiosqlite.Connection) -> None:
188
+ self._conn = conn
189
+
190
+ async def create(self, spec: ScheduledLoopCreate) -> ScheduledLoopRow:
191
+ loop_id = _loop_id_from_create(spec)
192
+ existing = await self.get(loop_id)
193
+ if existing is not None:
194
+ raise ValueError(f"scheduled loop already exists: {loop_id}")
195
+ validate_loop_guards(
196
+ max_ticks=spec.max_ticks,
197
+ max_runtime_ms=spec.max_runtime_ms,
198
+ unbounded=spec.unbounded,
199
+ )
200
+ now_ms = int(time.time() * 1000)
201
+ await self._conn.execute(
202
+ """
203
+ INSERT INTO scheduled_loops(
204
+ loop_id, session_id, status, prompt, interval_ms,
205
+ max_ticks, max_runtime_ms, skip_if_busy, tick_index,
206
+ next_tick_at_ms, started_at_ms, last_tick_at_ms,
207
+ last_error, stop_reason, tick_in_flight, worker_id, claimed_at_ms
208
+ ) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, 0, ?, ?, NULL, NULL, NULL, 0, NULL, NULL)
209
+ """,
210
+ (
211
+ loop_id,
212
+ spec.session_id.strip() or "loop-main",
213
+ spec.prompt.strip(),
214
+ spec.interval_ms,
215
+ spec.max_ticks,
216
+ spec.max_runtime_ms,
217
+ 1 if spec.skip_if_busy else 0,
218
+ now_ms,
219
+ now_ms,
220
+ ),
221
+ )
222
+ await self._conn.commit()
223
+ row = await self.get(loop_id)
224
+ if row is None:
225
+ raise RuntimeError("failed to read scheduled loop after insert")
226
+ return row
227
+
228
+ async def get(self, loop_id: str) -> ScheduledLoopRow | None:
229
+ columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
230
+ cursor = await self._conn.execute(
231
+ f"SELECT {columns} FROM scheduled_loops WHERE loop_id = ?",
232
+ (loop_id,),
233
+ )
234
+ row = await cursor.fetchone()
235
+ await cursor.close()
236
+ if row is None:
237
+ return None
238
+ return _row_from_tuple(tuple(row))
239
+
240
+ async def list_all(self) -> list[ScheduledLoopRow]:
241
+ columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
242
+ cursor = await self._conn.execute(
243
+ f"SELECT {columns} FROM scheduled_loops ORDER BY started_at_ms DESC"
244
+ )
245
+ rows = await cursor.fetchall()
246
+ await cursor.close()
247
+ return [_row_from_tuple(tuple(r)) for r in rows]
248
+
249
+ async def list_due(self, now_ms: int) -> list[ScheduledLoopRow]:
250
+ columns = ", ".join(_SCHEDULED_LOOP_COLUMNS)
251
+ cursor = await self._conn.execute(
252
+ f"""
253
+ SELECT {columns} FROM scheduled_loops
254
+ WHERE status = 'active'
255
+ AND tick_in_flight = 0
256
+ AND next_tick_at_ms <= ?
257
+ ORDER BY next_tick_at_ms ASC
258
+ """,
259
+ (now_ms,),
260
+ )
261
+ rows = await cursor.fetchall()
262
+ await cursor.close()
263
+ return [_row_from_tuple(tuple(r)) for r in rows]
264
+
265
+ async def claim_tick(self, loop_id: str, worker_id: str) -> ScheduledLoopRow | None:
266
+ """Atomically mark a due loop as in-flight; returns None if not claimable."""
267
+ now_ms = int(time.time() * 1000)
268
+ cursor = await self._conn.execute(
269
+ """
270
+ UPDATE scheduled_loops
271
+ SET tick_in_flight = 1, worker_id = ?, claimed_at_ms = ?
272
+ WHERE loop_id = ?
273
+ AND status = 'active'
274
+ AND tick_in_flight = 0
275
+ AND next_tick_at_ms <= ?
276
+ """,
277
+ (worker_id, now_ms, loop_id, now_ms),
278
+ )
279
+ await self._conn.commit()
280
+ if cursor.rowcount != 1:
281
+ return None
282
+ return await self.get(loop_id)
283
+
284
+ async def release_stale_claims(self, stale_after_ms: int) -> int:
285
+ """Reset in-flight ticks with no heartbeat after ``stale_after_ms``."""
286
+ cutoff = int(time.time() * 1000) - stale_after_ms
287
+ cursor = await self._conn.execute(
288
+ """
289
+ UPDATE scheduled_loops
290
+ SET tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL,
291
+ last_error = COALESCE(last_error, 'stale tick claim released')
292
+ WHERE tick_in_flight = 1
293
+ AND claimed_at_ms IS NOT NULL
294
+ AND claimed_at_ms < ?
295
+ """,
296
+ (cutoff,),
297
+ )
298
+ await self._conn.commit()
299
+ return int(cursor.rowcount)
300
+
301
+ async def renew_tick_claim(self, loop_id: str, worker_id: str) -> bool:
302
+ """Extend the in-flight claim lease for a worker that is still executing a tick."""
303
+ now_ms = int(time.time() * 1000)
304
+ cursor = await self._conn.execute(
305
+ """
306
+ UPDATE scheduled_loops
307
+ SET claimed_at_ms = ?
308
+ WHERE loop_id = ?
309
+ AND worker_id = ?
310
+ AND tick_in_flight = 1
311
+ """,
312
+ (now_ms, loop_id, worker_id),
313
+ )
314
+ await self._conn.commit()
315
+ return cursor.rowcount == 1
316
+
317
+ async def complete_tick(
318
+ self,
319
+ loop_id: str,
320
+ *,
321
+ worker_id: str,
322
+ error: str | None = None,
323
+ ) -> ScheduledLoopRow | None:
324
+ row = await self.get(loop_id)
325
+ if row is None or not row.tick_in_flight or row.worker_id != worker_id:
326
+ return None
327
+ now_ms = int(time.time() * 1000)
328
+ tick_index = row.tick_index + 1
329
+ stop_reason: str | None = None
330
+ status = row.status
331
+ if error:
332
+ status = "failed"
333
+ stop_reason = "tick_error"
334
+ elif row.max_ticks is not None and tick_index >= row.max_ticks:
335
+ status = "completed"
336
+ stop_reason = "max_ticks"
337
+ elif row.max_runtime_ms is not None and (now_ms - row.started_at_ms) >= row.max_runtime_ms:
338
+ status = "completed"
339
+ stop_reason = "max_runtime"
340
+ next_tick = now_ms + row.interval_ms if status == "active" else row.next_tick_at_ms
341
+ await self._conn.execute(
342
+ """
343
+ UPDATE scheduled_loops
344
+ SET tick_index = ?, last_tick_at_ms = ?, last_error = ?,
345
+ status = ?, stop_reason = ?, next_tick_at_ms = ?,
346
+ tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
347
+ WHERE loop_id = ? AND worker_id = ? AND tick_in_flight = 1
348
+ """,
349
+ (
350
+ tick_index,
351
+ now_ms,
352
+ error,
353
+ status,
354
+ stop_reason,
355
+ next_tick,
356
+ loop_id,
357
+ worker_id,
358
+ ),
359
+ )
360
+ await self._conn.commit()
361
+ return await self.get(loop_id)
362
+
363
+ async def defer_tick(self, loop_id: str, *, worker_id: str, reason: str) -> None:
364
+ """Release claim and push next tick forward (e.g. session busy)."""
365
+ row = await self.get(loop_id)
366
+ if row is None or row.worker_id != worker_id:
367
+ return
368
+ now_ms = int(time.time() * 1000)
369
+ await self._conn.execute(
370
+ """
371
+ UPDATE scheduled_loops
372
+ SET tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL,
373
+ next_tick_at_ms = ?, last_error = ?
374
+ WHERE loop_id = ? AND worker_id = ?
375
+ """,
376
+ (now_ms + row.interval_ms, reason, loop_id, worker_id),
377
+ )
378
+ await self._conn.commit()
379
+
380
+ async def set_status(self, loop_id: str, status: str, *, stop_reason: str | None = None) -> bool:
381
+ if status not in _LOOP_STATUSES:
382
+ raise ValueError(f"invalid loop status: {status}")
383
+ cursor = await self._conn.execute(
384
+ """
385
+ UPDATE scheduled_loops
386
+ SET status = ?, stop_reason = COALESCE(?, stop_reason),
387
+ tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
388
+ WHERE loop_id = ?
389
+ """,
390
+ (status, stop_reason, loop_id),
391
+ )
392
+ await self._conn.commit()
393
+ return cursor.rowcount == 1
394
+
395
+ async def pause(self, loop_id: str) -> bool:
396
+ return await self.set_status(loop_id, "paused")
397
+
398
+ async def resume(self, loop_id: str) -> bool:
399
+ row = await self.get(loop_id)
400
+ if row is None:
401
+ return False
402
+ now_ms = int(time.time() * 1000)
403
+ cursor = await self._conn.execute(
404
+ """
405
+ UPDATE scheduled_loops
406
+ SET status = 'active', stop_reason = NULL, next_tick_at_ms = ?,
407
+ tick_in_flight = 0, worker_id = NULL, claimed_at_ms = NULL
408
+ WHERE loop_id = ? AND status = 'paused'
409
+ """,
410
+ (now_ms, loop_id),
411
+ )
412
+ await self._conn.commit()
413
+ return cursor.rowcount == 1
414
+
415
+ async def stop(self, loop_id: str, *, stop_reason: str = "manual") -> bool:
416
+ return await self.set_status(loop_id, "completed", stop_reason=stop_reason)
417
+
418
+ def row_to_json(self, row: ScheduledLoopRow) -> dict[str, object]:
419
+ return {
420
+ "loop_id": row.loop_id,
421
+ "session_id": row.session_id,
422
+ "status": row.status,
423
+ "prompt": row.prompt,
424
+ "interval_ms": row.interval_ms,
425
+ "max_ticks": row.max_ticks,
426
+ "max_runtime_ms": row.max_runtime_ms,
427
+ "skip_if_busy": row.skip_if_busy,
428
+ "tick_index": row.tick_index,
429
+ "next_tick_at_ms": row.next_tick_at_ms,
430
+ "started_at_ms": row.started_at_ms,
431
+ "last_tick_at_ms": row.last_tick_at_ms,
432
+ "last_error": row.last_error,
433
+ "stop_reason": row.stop_reason,
434
+ "tick_in_flight": row.tick_in_flight,
435
+ }
@@ -0,0 +1,94 @@
1
+ """SQLite durable per-session turn locks for multi-replica gateway deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+
8
+ import aiosqlite
9
+
10
+ _DEFAULT_STALE_MS = 600_000
11
+
12
+
13
+ def session_turn_stale_ms() -> int:
14
+ raw = os.environ.get("MONKEYBOT_SESSION_TURN_STALE_MS", "").strip()
15
+ if not raw:
16
+ return _DEFAULT_STALE_MS
17
+ try:
18
+ return max(1, int(raw))
19
+ except ValueError:
20
+ return _DEFAULT_STALE_MS
21
+
22
+
23
+ class SQLiteSessionTurnLockStore:
24
+ """SQLite-backed exclusive turn lock per session."""
25
+
26
+ def __init__(self, conn: aiosqlite.Connection) -> None:
27
+ self._conn = conn
28
+
29
+ async def release_stale_claims(self, stale_after_ms: int) -> int:
30
+ cutoff = int(time.time() * 1000) - stale_after_ms
31
+ cursor = await self._conn.execute(
32
+ """
33
+ UPDATE session_turn_locks
34
+ SET request_id = NULL, claimed_at_ms = NULL
35
+ WHERE request_id IS NOT NULL
36
+ AND claimed_at_ms IS NOT NULL
37
+ AND claimed_at_ms < ?
38
+ """,
39
+ (cutoff,),
40
+ )
41
+ await self._conn.commit()
42
+ return int(cursor.rowcount)
43
+
44
+ async def try_acquire(self, session_id: str, request_id: str) -> bool:
45
+ await self.release_stale_claims(session_turn_stale_ms())
46
+ now_ms = int(time.time() * 1000)
47
+ cursor = await self._conn.execute(
48
+ """
49
+ UPDATE session_turn_locks
50
+ SET request_id = ?, claimed_at_ms = ?
51
+ WHERE session_id = ? AND request_id IS NULL
52
+ """,
53
+ (request_id, now_ms, session_id),
54
+ )
55
+ if cursor.rowcount == 1:
56
+ await self._conn.commit()
57
+ return True
58
+ try:
59
+ await self._conn.execute(
60
+ """
61
+ INSERT INTO session_turn_locks (session_id, request_id, claimed_at_ms)
62
+ VALUES (?, ?, ?)
63
+ """,
64
+ (session_id, request_id, now_ms),
65
+ )
66
+ await self._conn.commit()
67
+ return True
68
+ except aiosqlite.IntegrityError:
69
+ await self._conn.rollback()
70
+ return False
71
+
72
+ async def release(self, session_id: str, request_id: str) -> None:
73
+ await self._conn.execute(
74
+ """
75
+ UPDATE session_turn_locks
76
+ SET request_id = NULL, claimed_at_ms = NULL
77
+ WHERE session_id = ? AND request_id = ?
78
+ """,
79
+ (session_id, request_id),
80
+ )
81
+ await self._conn.commit()
82
+
83
+ async def is_busy(self, session_id: str) -> bool:
84
+ cursor = await self._conn.execute(
85
+ """
86
+ SELECT 1 FROM session_turn_locks
87
+ WHERE session_id = ? AND request_id IS NOT NULL
88
+ LIMIT 1
89
+ """,
90
+ (session_id,),
91
+ )
92
+ row = await cursor.fetchone()
93
+ await cursor.close()
94
+ return row is not None