rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/worker.py ADDED
@@ -0,0 +1,1275 @@
1
+ """Named agents that keep running after the terminal closes.
2
+
3
+ A worker is an ordinary saved session in a process of its own. It is reached
4
+ through files under the home folder rather than a socket, so sending to it or
5
+ scheduling for it works whether or not it is up, and needs no open port. With
6
+ nobody there to ask, anything needing approval is refused unless the worker was
7
+ started with --auto-approve.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import json
15
+ import math
16
+ import os
17
+ import re
18
+ import shutil
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
28
+
29
+ BEAT_SECONDS = 2.0
30
+ POLL_SECONDS = 0.5
31
+ #: Without a beat for this long, a process with that pid is not this worker.
32
+ STALE_SECONDS = 30.0
33
+ INPUT_TIMEOUT = 60.0
34
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
35
+
36
+
37
+ def root() -> Path:
38
+ from rlmagent.plugins import rlm_agent_home
39
+
40
+ return rlm_agent_home() / "workers"
41
+
42
+
43
+ def folder(name: str) -> Path:
44
+ if not _NAME.match(name):
45
+ raise ValueError(f"not a worker name: {name!r}")
46
+ return root() / name
47
+
48
+
49
+ def _write(path: Path, value: dict) -> None:
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ staged = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}")
52
+ staged.write_text(json.dumps(value), encoding="utf-8")
53
+ os.replace(staged, path)
54
+
55
+
56
+ def _read(path: Path) -> dict | None:
57
+ try:
58
+ return json.loads(path.read_text(encoding="utf-8"))
59
+ except (OSError, ValueError):
60
+ return None
61
+
62
+
63
+ def _pid_alive(pid: int) -> bool:
64
+ if os.name == "nt":
65
+ import ctypes
66
+
67
+ handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
68
+ if not handle:
69
+ return False
70
+ code = ctypes.c_ulong()
71
+ ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
72
+ ctypes.windll.kernel32.CloseHandle(handle)
73
+ return code.value == 259 # STILL_ACTIVE
74
+ try:
75
+ os.kill(pid, 0)
76
+ except ProcessLookupError:
77
+ return False
78
+ except PermissionError:
79
+ return True
80
+ # A child that exited but was never reaped (a long-lived watcher's recovered
81
+ # supervisor, say) still answers signal 0; it is not running.
82
+ import psutil
83
+
84
+ try:
85
+ return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
86
+ except psutil.NoSuchProcess:
87
+ return False
88
+ except psutil.Error:
89
+ return True
90
+
91
+
92
+ def state_of(name: str) -> dict:
93
+ """What is on record for a worker, with `alive` worked out rather than trusted."""
94
+ meta = _read(folder(name) / "meta.json") or {}
95
+ beat = meta.get("beat", 0)
96
+ meta["alive"] = bool(
97
+ meta.get("pid") and _pid_alive(meta["pid"]) and time.time() - beat < STALE_SECONDS
98
+ )
99
+ meta["name"] = name
100
+ return meta
101
+
102
+
103
+ def _supervised(meta: dict) -> bool:
104
+ pid = meta.get("supervisor_pid")
105
+ return bool(pid and _pid_alive(pid))
106
+
107
+
108
+ def send(name: str, text: str, source: str = "user", *, receipt_owner: str | None = None) -> str:
109
+ message_id = f"{time.time_ns()}-{uuid.uuid4().hex}"
110
+ message = {"text": text, "source": source, "time": time.time()}
111
+ if receipt_owner is not None:
112
+ message["receipt_owner"] = receipt_owner
113
+ _write(folder(name) / "inbox" / f"{message_id}.json", message)
114
+ return message_id
115
+
116
+
117
+ def schedule(name: str, text: str, due: float | None = None, every: float | None = None,
118
+ *, cron: str | None = None, timezone: str | None = None) -> dict:
119
+ if cron is not None:
120
+ if due is not None or every is not None:
121
+ raise ValueError("cron cannot be combined with a start time or interval")
122
+ from rlmagent.cron import next_run
123
+
124
+ timezone = "UTC" if timezone is None else timezone
125
+ due = next_run(cron, timezone, time.time())
126
+ elif due is None or timezone is not None:
127
+ raise ValueError("give a due time or cron expression; timezone is only for cron")
128
+ job = {"id": uuid.uuid4().hex[:8], "text": text, "due": due, "every": every,
129
+ "status": "waiting", "runs": 0, "last_run": None}
130
+ if cron is not None:
131
+ job.update(cron=cron, timezone=timezone)
132
+ _write(folder(name) / "jobs" / f"{job['id']}.json", job)
133
+ return job
134
+
135
+
136
+ def job_time(job: dict) -> str:
137
+ if timezone := job.get("timezone"):
138
+ try:
139
+ zone = ZoneInfo(timezone)
140
+ except ZoneInfoNotFoundError:
141
+ return datetime.fromtimestamp(job["due"], UTC).isoformat(timespec="minutes")
142
+ return datetime.fromtimestamp(job["due"], zone).isoformat(timespec="minutes")
143
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(job["due"]))
144
+
145
+
146
+ def jobs_of(name: str) -> list[dict]:
147
+ jobs = [_read(path) for path in (folder(name) / "jobs").glob("*.json")]
148
+ return sorted((job for job in jobs if job), key=lambda job: job["due"])
149
+
150
+
151
+ def log_line(name: str, kind: str, text: str, **details) -> None:
152
+ path = folder(name) / "log.jsonl"
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ with path.open("a", encoding="utf-8") as stream:
155
+ stream.write(json.dumps({"time": time.time(), "kind": kind, "text": text, **details},
156
+ ensure_ascii=False) + "\n")
157
+
158
+
159
+ async def _request_input(name: str, text: str, *, approval: bool, choices=(), multiple=False):
160
+ request_id = uuid.uuid4().hex
161
+ base = folder(name) / "input"
162
+ request = base / f"{request_id}.request.json"
163
+ response = base / f"{request_id}.response.json"
164
+ expires = time.time() + INPUT_TIMEOUT
165
+ _write(request, {"pid": os.getpid(), "expires": expires, "approval": approval})
166
+ options = "".join(f"\n{index}. {choice}" for index, choice in enumerate(choices, 1))
167
+ hint = "once|always|auto|deny" if approval else "TEXT"
168
+ try:
169
+ log_line(name, "input", f"{text}{options}\n/answer {request_id} {hint}")
170
+ deadline = time.monotonic() + INPUT_TIMEOUT
171
+ while time.monotonic() < deadline and not (folder(name) / "stop").exists():
172
+ answer = _read(response)
173
+ if answer is not None:
174
+ value = answer.get("value")
175
+ if not isinstance(value, str) or not value:
176
+ return None
177
+ if approval:
178
+ return value if value in ("once", "always", "auto", "deny") else None
179
+ picks = [part.strip() for part in value.split(",")]
180
+ if choices and all(p.isascii() and p.isdecimal() and len(p) <= 8
181
+ and 1 <= int(p) <= len(choices) for p in picks):
182
+ if multiple:
183
+ return [choices[int(p) - 1] for p in picks]
184
+ if len(picks) == 1:
185
+ return choices[int(picks[0]) - 1]
186
+ return value
187
+ await asyncio.sleep(0.15)
188
+ return None
189
+ finally:
190
+ request.unlink(missing_ok=True)
191
+ response.unlink(missing_ok=True)
192
+
193
+
194
+ def answer_input(name: str, command: str) -> str | None:
195
+ """Intercept client input replies before they can enter the prompt queue."""
196
+ parts = command.split(maxsplit=2)
197
+ if not parts or parts[0] != "/answer":
198
+ return None
199
+ if len(parts) != 3 or not re.fullmatch(r"[a-f0-9]{32}", parts[1]):
200
+ return "Use /answer REQUEST_ID TEXT."
201
+ _, request_id, value = parts
202
+ base = folder(name) / "input"
203
+ request = _read(base / f"{request_id}.request.json")
204
+ meta = state_of(name)
205
+ if (not request or not meta["alive"] or request.get("pid") != meta.get("pid")
206
+ or time.time() >= request.get("expires", 0)):
207
+ return "That input request is no longer active."
208
+ if request.get("approval") and value not in ("once", "always", "auto", "deny"):
209
+ return "Choose once, always, auto, or deny."
210
+ from rlmagent.harness.session.lease import SessionBusy, acquire
211
+
212
+ response = base / f"{request_id}.response.json"
213
+ try:
214
+ with acquire(base, "answer"):
215
+ if not (base / f"{request_id}.request.json").is_file():
216
+ return "That input request is no longer active."
217
+ if response.exists():
218
+ return "That input request already has an answer."
219
+ _write(response, {"value": value})
220
+ if not (base / f"{request_id}.request.json").is_file():
221
+ response.unlink(missing_ok=True)
222
+ except SessionBusy:
223
+ return "Another client is submitting an answer. Retry if the request is still active."
224
+ return "Answer sent to the worker."
225
+
226
+
227
+ def interrupt(name: str) -> bool:
228
+ meta = state_of(name)
229
+ turn_id = meta.get("turn_id")
230
+ if not meta["alive"] or not isinstance(turn_id, str) or not re.fullmatch(r"[a-f0-9]{32}", turn_id):
231
+ return False
232
+ path = folder(name) / "interrupts" / turn_id
233
+ path.parent.mkdir(parents=True, exist_ok=True)
234
+ path.touch()
235
+ return True
236
+
237
+
238
+ def _enable_interactive_input(session, name: str) -> None:
239
+ from rlmagent.agents.approval import DENY
240
+
241
+ approver = session.agent_tree.approver
242
+
243
+ async def ask(request):
244
+ return await _request_input(name, request.describe(), approval=True) or DENY
245
+
246
+ async def question(text, choices=None, multiple=False):
247
+ return await _request_input(name, text, approval=False, choices=choices or (), multiple=multiple)
248
+
249
+ if approver.policy == "deny":
250
+ approver.policy = "ask"
251
+ approver.ask = ask
252
+ approver.question = question
253
+
254
+
255
+ def _take_inbox(name: str, *, limit: int | None = None) -> list[dict]:
256
+ taken = []
257
+ base = folder(name)
258
+ active = base / "in-flight"
259
+ active.mkdir(parents=True, exist_ok=True)
260
+ for path in sorted((base / "inbox").glob("*.json")):
261
+ claimed = active / path.name
262
+ try:
263
+ path.replace(claimed)
264
+ except FileNotFoundError:
265
+ continue
266
+ message = _read(claimed)
267
+ if message:
268
+ message["id"] = path.stem
269
+ taken.append(message)
270
+ if len(taken) == limit:
271
+ break
272
+ else:
273
+ _finish_message(name, path.stem, "failed")
274
+ return taken
275
+
276
+
277
+ def _finish_message(name: str, message_id: str, status: str) -> None:
278
+ base = folder(name)
279
+ receipt = base / "receipts" / f"{message_id}.json"
280
+ active = base / "in-flight" / f"{message_id}.json"
281
+ if not receipt.exists():
282
+ message = _read(active) or {}
283
+ record = {"id": message_id, "status": status, "time": time.time()}
284
+ if "receipt_owner" in message:
285
+ record["receipt_owner"] = message["receipt_owner"]
286
+ _write(receipt, record)
287
+ active.unlink(missing_ok=True)
288
+
289
+
290
+ def receipt_of(name: str, message_id: str, *, owner: str | None = None) -> dict:
291
+ if re.fullmatch(r"[0-9]+-[0-9a-f]{6,32}", message_id) is None:
292
+ raise ValueError("invalid worker message ID")
293
+ base = folder(name)
294
+ filename = f"{message_id}.json"
295
+ if owner is not None:
296
+ for directory, state in (("inbox", "queued"), ("in-flight", "in-flight"),
297
+ ("receipts", "unknown")):
298
+ message = _read(base / directory / filename) or {}
299
+ if message.get("receipt_owner") == owner:
300
+ return {"id": message_id, "status": message.get("status", state)}
301
+ return {"id": message_id, "status": "unknown"}
302
+ if record := _read(base / "receipts" / filename):
303
+ record.pop("receipt_owner", None)
304
+ return record
305
+ return {"id": message_id,
306
+ "status": ("queued" if (base / "inbox" / filename).is_file()
307
+ else "in-flight" if (base / "in-flight" / filename).is_file() else "unknown")}
308
+
309
+
310
+ def _take_due(name: str, now: float, *, limit: int | None = None) -> list[dict]:
311
+ """Due jobs, marked as started first: a crash must not run one a second time."""
312
+ from rlmagent.harness.session.lease import SessionBusy, acquire
313
+
314
+ directory = folder(name) / "jobs"
315
+ taken = []
316
+ for job in jobs_of(name):
317
+ if job["status"] != "waiting" or job["due"] > now:
318
+ continue
319
+ path = directory / f"{job['id']}.json"
320
+ try:
321
+ with acquire(directory, job["id"]):
322
+ job = _read(path)
323
+ if job is None or job["status"] != "waiting" or job["due"] > now:
324
+ continue
325
+ if expression := job.get("cron"):
326
+ from rlmagent.cron import next_run
327
+
328
+ try:
329
+ job["due"] = next_run(expression, job["timezone"], now)
330
+ except ValueError as problem:
331
+ job.update(status="failed", error=str(problem))
332
+ _write(path, job)
333
+ continue
334
+ elif job["every"]:
335
+ job["due"] = now + job["every"]
336
+ else:
337
+ job["status"] = "started"
338
+ job["runs"] += 1
339
+ job["last_run"] = now
340
+ _write(path, job)
341
+ except SessionBusy:
342
+ continue
343
+ taken.append(job)
344
+ if len(taken) == limit:
345
+ break
346
+ return taken
347
+
348
+
349
+ def _finish_job(name: str, job: dict, status: str) -> None:
350
+ if job["every"] or job.get("cron"):
351
+ return
352
+ from rlmagent.harness.session.lease import SessionBusy, acquire
353
+
354
+ directory = folder(name) / "jobs"
355
+ path = directory / f"{job['id']}.json"
356
+ try:
357
+ with acquire(directory, job["id"]):
358
+ if path.is_file():
359
+ _write(path, {**job, "status": status})
360
+ except SessionBusy:
361
+ pass # The job is being removed by another process.
362
+
363
+
364
+ async def _handle(session, name: str, text: str) -> bool:
365
+ from rlmagent.harness.contracts.stream import (
366
+ MessageEndEvent, MessageUpdateEvent, ToolRunEndEvent, ToolRunStartEvent, ToolRunUpdateEvent,
367
+ )
368
+ from rlmagent.harness.contracts.transcript import ModelEntry
369
+
370
+ log_line(name, "prompt", text)
371
+ if text.startswith("/"):
372
+ response = await session.handle_command(text)
373
+ if response is not None:
374
+ log_line(name, "reply", str(response))
375
+ return True
376
+ streamed = 0
377
+ async for event in session.submit(text):
378
+ if isinstance(event, MessageUpdateEvent) and isinstance(event.message, ModelEntry):
379
+ full = event.message.text
380
+ if len(full) > streamed:
381
+ log_line(name, "model_delta", full[streamed:])
382
+ streamed = len(full)
383
+ elif isinstance(event, MessageEndEvent) and isinstance(event.message, ModelEntry):
384
+ log_line(name, "model_end", event.message.text)
385
+ streamed = 0
386
+ elif isinstance(event, ToolRunStartEvent):
387
+ log_line(name, "tool_start", event.tool_name, call_id=event.tool_call_id, args=event.args)
388
+ elif isinstance(event, ToolRunUpdateEvent):
389
+ log_line(name, "tool_update", event.partial_result.text)
390
+ elif isinstance(event, ToolRunEndEvent):
391
+ log_line(name, "tool_end", event.result.text, call_id=event.tool_call_id,
392
+ is_error=event.is_error)
393
+ answer = next(
394
+ (m for m in reversed(list(session.transcript)) if getattr(m, "role", "") == "assistant"),
395
+ None,
396
+ )
397
+ problem = getattr(answer, "error_message", None)
398
+ if problem:
399
+ log_line(name, "error", str(problem))
400
+ return False
401
+ log_line(name, "reply", getattr(answer, "text", "") if answer else "")
402
+ return True
403
+
404
+
405
+ def _can_passivate(session) -> bool:
406
+ jobs = getattr(session, "jobs", None)
407
+ if jobs is not None and any(not job.summary()["done"] for job in jobs.jobs.values()):
408
+ return False
409
+ tree = getattr(session, "agent_tree", None)
410
+ settled = ("done", "failed", "cancelled")
411
+ if any(child.status not in settled for child in getattr(tree, "records", {}).values()):
412
+ return False
413
+ background = getattr(getattr(session, "backend", None), "background", None)
414
+ if background is not None:
415
+ with background.lock:
416
+ return all(job.status in (*settled, "idle") for job in background.jobs.values())
417
+ return True
418
+
419
+
420
+ async def serve(name: str, ns: argparse.Namespace) -> int:
421
+ """The loop inside the detached process."""
422
+ from rlmagent.runtime import build_session
423
+
424
+ ns.print_mode = True
425
+ base = folder(name)
426
+ from rlmagent.cli.main import get_version
427
+
428
+ meta = {"pid": os.getpid(), "cwd": os.getcwd(), "started": time.time(), "beat": time.time(),
429
+ "state": "starting", "session": None, "error": None, "version": get_version()}
430
+ incident_id = os.environ.get("RLM_AGENT_WORKER_INCIDENT", "")
431
+ if re.fullmatch(r"[a-f0-9]{32}", incident_id):
432
+ meta["session"] = ns.resume
433
+ restart_incident = base / "incidents" / f"{incident_id}.json"
434
+ else:
435
+ restart_incident = None
436
+ supervisor = os.environ.get("RLM_AGENT_WORKER_SUPERVISOR_PID")
437
+ if supervisor:
438
+ meta["supervisor_pid"] = int(supervisor)
439
+ launch_id = os.environ.get("RLM_AGENT_WORKER_LAUNCH_ID")
440
+ if launch_id:
441
+ meta["launch_id"] = launch_id
442
+ _write(base / "meta.json", meta)
443
+ idle_timeout = getattr(ns, "idle_passivate", 0)
444
+ try:
445
+ if getattr(ns, "supervised", False):
446
+ session_id = name.removeprefix("session-")
447
+ if session_id == name or ns.no_session or (ns.resume and ns.resume != session_id):
448
+ raise ValueError("supervised workers require a matching saved session ID")
449
+ session = await build_session(ns, session_id=session_id)
450
+ if not ns.resume:
451
+ try:
452
+ await session._write_header()
453
+ except BaseException:
454
+ await session.shutdown()
455
+ raise
456
+ else:
457
+ session = await build_session(ns)
458
+ if idle_timeout and session._pending_header is not None:
459
+ try:
460
+ await session._write_header()
461
+ except BaseException:
462
+ await session.shutdown()
463
+ raise
464
+ except BaseException as failure: # noqa: BLE001 - the starter is waiting to hear why
465
+ message = getattr(failure, "code", None) if isinstance(failure, SystemExit) else failure
466
+ _write(base / "meta.json", {**meta, "state": "failed", "pid": None, "error": str(message)})
467
+ log_line(name, "error", f"could not start: {message}")
468
+ return 1
469
+ meta.update(session=session.session_id, state="idle", model=session.model)
470
+ if getattr(ns, "supervised", False):
471
+ for stale in (base / "input").glob("*.json"):
472
+ stale.unlink(missing_ok=True)
473
+ _enable_interactive_input(session, name)
474
+ history = base / "history.json"
475
+ if (_read(history) or {}).get("session") != session.session_id:
476
+ log = base / "log.jsonl"
477
+ _write(history, {"session": session.session_id,
478
+ "log_offset": log.stat().st_size if log.exists() else 0,
479
+ "entries": [entry.model_dump(mode="json") for entry in session.transcript]})
480
+ for path in (base / "in-flight").glob("*.json"):
481
+ _finish_message(name, path.stem, "interrupted")
482
+ for job in jobs_of(name):
483
+ if job["status"] == "started":
484
+ _finish_job(name, job, "interrupted")
485
+ pilot = getattr(session, "autopilot", None)
486
+ if pilot is not None:
487
+ pilot.durable = True
488
+ pilot.load()
489
+ net = getattr(session, "agentnet", None)
490
+ if net is not None:
491
+ net.leave() # a worker is reached through its worker folder, not as a session
492
+ net.worker = name
493
+
494
+ _settle_incident(restart_incident, "recovered", pid=meta["pid"],
495
+ supervisor_pid=meta.get("supervisor_pid"))
496
+
497
+ async def beat():
498
+ while True:
499
+ if turn_id := meta.get("turn_id"):
500
+ request = base / "interrupts" / turn_id
501
+ if request.exists():
502
+ request.unlink(missing_ok=True)
503
+ session.abort()
504
+ log_line(name, "note", "Interrupt requested.")
505
+ meta["beat"] = time.time()
506
+ _write(base / "meta.json", meta)
507
+ await asyncio.sleep(BEAT_SECONDS)
508
+
509
+ beating = asyncio.create_task(beat())
510
+ log_line(name, "note", f"started in {os.getcwd()}, session {session.session_id}")
511
+ idle_since = time.monotonic()
512
+ passive = False
513
+ try:
514
+ while not (base / "stop").exists():
515
+ if (root() / "admission-paused").exists():
516
+ meta["state"] = "paused"
517
+ idle_since = time.monotonic()
518
+ await asyncio.sleep(POLL_SECONDS)
519
+ continue
520
+ meta["state"] = "idle"
521
+ # Claim only the task we are about to run so a crash leaves the rest queued.
522
+ due = _take_due(name, time.time(), limit=1)
523
+ work = [(job["text"], job, None) for job in due]
524
+ if not work:
525
+ work = [(message["text"], None, message["id"])
526
+ for message in _take_inbox(name, limit=1)]
527
+ continuing = False
528
+ if not work and pilot is not None and (prompt := pilot.next_prompt()):
529
+ continuing = True
530
+ work = [(prompt, None, None)]
531
+ if pilot is not None and pilot.stopped_because and not pilot.autonomous:
532
+ log_line(name, "note", f"autonomous mode stopped: {pilot.stopped_because}")
533
+ pilot.stopped_because = ""
534
+ pilot.save()
535
+ if (idle_timeout and not work and time.monotonic() - idle_since >= idle_timeout
536
+ and _can_passivate(session)):
537
+ passive = True
538
+ break
539
+ for text, job, message_id in work:
540
+ if (base / "stop").exists():
541
+ if job is not None:
542
+ _finish_job(name, job, "interrupted")
543
+ if message_id is not None:
544
+ _finish_message(name, message_id, "interrupted")
545
+ continue
546
+ meta["state"] = "busy"
547
+ turn_id = meta["turn_id"] = uuid.uuid4().hex
548
+ meta["job_id"] = job["id"] if job is not None else None
549
+ meta["message_id"] = message_id
550
+ _write(base / "meta.json", meta)
551
+ tracked = pilot is not None and pilot.autonomous and not text.startswith("/")
552
+ if tracked:
553
+ pilot.start_worker_turn(continuation=continuing)
554
+ try:
555
+ outcome = "done" if await _handle(session, name, text) else "failed"
556
+ except Exception as failure: # noqa: BLE001 - one bad task must not end the worker
557
+ log_line(name, "error", f"{type(failure).__name__}: {failure}")
558
+ outcome = "failed"
559
+ if job is not None:
560
+ _finish_job(name, job, outcome)
561
+ if message_id is not None:
562
+ _finish_message(name, message_id, outcome)
563
+ if tracked:
564
+ if outcome == "failed" and pilot.autonomous:
565
+ pilot.stop_autonomous("the worker turn failed")
566
+ pilot.finish_worker_turn()
567
+ meta["state"] = "idle"
568
+ for key in ("turn_id", "job_id", "message_id"):
569
+ meta.pop(key, None)
570
+ _write(base / "meta.json", meta)
571
+ (base / "interrupts" / turn_id).unlink(missing_ok=True)
572
+ idle_since = time.monotonic()
573
+ await asyncio.sleep(POLL_SECONDS)
574
+ finally:
575
+ beating.cancel()
576
+ await session.shutdown()
577
+ state = "passive" if passive and not (base / "stop").exists() else "stopped"
578
+ _write(base / "meta.json", {**meta, "state": state, "pid": None})
579
+ if state == "stopped":
580
+ (base / "stop").unlink(missing_ok=True)
581
+ log_line(name, "note", state)
582
+ return 0
583
+
584
+
585
+ def _spawn(name: str, run_args: list[str], *, recovery_cwd: str | None = None,
586
+ launch_id: str | None = None) -> subprocess.Popen:
587
+ base = folder(name)
588
+ base.mkdir(parents=True, exist_ok=True)
589
+ if recovery_cwd is None:
590
+ (base / "meta.json").unlink(missing_ok=True)
591
+ (base / "stop").unlink(missing_ok=True)
592
+ options: dict = {}
593
+ if os.name == "nt":
594
+ options["creationflags"] = (
595
+ subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
596
+ )
597
+ else:
598
+ options["start_new_session"] = True
599
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
600
+ if launch_id:
601
+ env["RLM_AGENT_WORKER_LAUNCH_ID"] = launch_id
602
+ with (base / "output.log").open("ab") as output:
603
+ return subprocess.Popen(
604
+ [sys.executable, "-m", "rlmagent.cli.main", "worker", "supervise", name, *run_args],
605
+ stdin=subprocess.DEVNULL, stdout=output, stderr=output,
606
+ cwd=recovery_cwd or os.getcwd(),
607
+ env=env, **options,
608
+ )
609
+
610
+
611
+ def _supervise(name: str, run_args: list[str]) -> int:
612
+ """Restart a failed worker using its last saved session."""
613
+ from rlmagent.harness.session.lease import SessionBusy, acquire
614
+
615
+ try:
616
+ lease = acquire(folder(name), "supervisor")
617
+ except SessionBusy:
618
+ return 1
619
+ with lease:
620
+ return _supervise_locked(name, run_args)
621
+
622
+
623
+ def _supervise_locked(name: str, run_args: list[str]) -> int:
624
+ base = folder(name)
625
+ failures = 0
626
+ initial_args = run_args
627
+ incident = None
628
+ saved_session = None
629
+ while not (base / "stop").exists():
630
+ started = time.monotonic()
631
+ env = {**os.environ, "RLM_AGENT_WORKER_SUPERVISOR_PID": str(os.getpid())}
632
+ env.pop("RLM_AGENT_WORKER_INCIDENT", None)
633
+ if incident is not None:
634
+ env["RLM_AGENT_WORKER_INCIDENT"] = incident.stem
635
+ try:
636
+ status = subprocess.run(
637
+ [sys.executable, "-m", "rlmagent.cli.main", "worker", "serve", name, *run_args],
638
+ check=False, env=env,
639
+ ).returncode
640
+ except OSError:
641
+ if incident is not None:
642
+ _settle_incident(incident, "spawn_failed")
643
+ raise
644
+ meta = _read(base / "meta.json") or {}
645
+ saved_session = meta.get("session") or saved_session
646
+ stopped = status == 0 or (base / "stop").exists()
647
+ if incident is not None:
648
+ _settle_incident(incident, "stopped" if stopped else "restart_failed",
649
+ restart_exit_status=status)
650
+ if stopped:
651
+ return status
652
+ incident = _record_incident(name, meta, "worker_exit", exit_status=status,
653
+ session=saved_session)
654
+ if not saved_session or "--no-session" in initial_args:
655
+ _settle_incident(incident, "restart_unavailable")
656
+ return status
657
+ failures = 0 if time.monotonic() - started >= 60 else failures + 1
658
+ if failures >= 5:
659
+ _settle_incident(incident, "restart_limit")
660
+ return status
661
+ delay = min(15, 2 ** failures)
662
+ _write(base / "meta.json", {**meta, "state": "recovering", "pid": None, "session": saved_session,
663
+ "supervisor_pid": os.getpid()})
664
+ log_line(name, "note", f"worker exited with status {status}; recovering in {delay}s")
665
+ deadline = time.monotonic() + delay
666
+ while time.monotonic() < deadline:
667
+ if (base / "stop").exists():
668
+ _write(base / "meta.json", {**meta, "state": "stopped", "pid": None,
669
+ "supervisor_pid": None})
670
+ _settle_incident(incident, "stopped")
671
+ return 0
672
+ time.sleep(0.2)
673
+ run_args = [*initial_args, "--resume", saved_session]
674
+ if incident is not None:
675
+ _settle_incident(incident, "stopped")
676
+ return 0
677
+
678
+
679
+ def _start(name: str, run_args: list[str], wait: float = 180.0) -> int:
680
+ from rlmagent.harness.session.lease import SessionBusy, acquire
681
+
682
+ try:
683
+ lease = acquire(folder(name), "start")
684
+ except SessionBusy:
685
+ print(f"Worker {name} is already starting.", file=sys.stderr)
686
+ return 1
687
+ with lease:
688
+ return _start_locked(name, run_args, wait)
689
+
690
+
691
+ def _start_locked(name: str, run_args: list[str], wait: float) -> int:
692
+ from rlmagent.cli.main import _build_run_parser
693
+ from rlmagent.runtime import settle_project_trust
694
+
695
+ current = state_of(name)
696
+ if current["alive"] or _supervised(current):
697
+ print(f"Worker {name} is already running.", file=sys.stderr)
698
+ return 1
699
+ ns = _build_run_parser().parse_args(run_args)
700
+ if not math.isfinite(ns.idle_passivate) or ns.idle_passivate < 0:
701
+ raise ValueError("idle passivation must be finite and nonnegative")
702
+ if ns.idle_passivate and ns.no_session:
703
+ raise ValueError("idle passivation requires a saved session")
704
+ # Asked here, with a person present. The worker has nobody to ask.
705
+ outcome = asyncio.run(settle_project_trust(ns))
706
+ if not (ns.approve or ns.no_approve):
707
+ run_args = [*run_args, "--approve" if outcome.trusted else "--no-approve"]
708
+ _write(folder(name) / "launch.json", {"cwd": os.getcwd(), "argv": run_args})
709
+ _spawn(name, run_args)
710
+ limit = time.monotonic() + wait
711
+ while time.monotonic() < limit:
712
+ meta = _read(folder(name) / "meta.json") or {}
713
+ if meta.get("state") == "failed":
714
+ print(f"Worker {name} failed to start: {meta.get('error')}", file=sys.stderr)
715
+ return 1
716
+ if meta.get("session"):
717
+ print(f"Worker {name} running, pid {meta['pid']}, session {meta['session']}.")
718
+ return 0
719
+ time.sleep(0.2)
720
+ print(f"Worker {name} did not start in {wait:.0f}s; see {folder(name) / 'output.log'}.",
721
+ file=sys.stderr)
722
+ return 1
723
+
724
+
725
+ def _stop(name: str, wait: float) -> int:
726
+ meta = state_of(name)
727
+ if meta.get("state") == "passive":
728
+ (folder(name) / "stop").touch()
729
+ print(f"Worker {name} stopped.")
730
+ return 0
731
+ if meta.get("state") == "recovering" and _supervised(meta):
732
+ (folder(name) / "stop").touch()
733
+ print(f"Worker {name} will stop instead of restarting.")
734
+ return 0
735
+ if not meta["alive"]:
736
+ print(f"Worker {name} is not running.")
737
+ return 0
738
+ stop = folder(name) / "stop"
739
+ stop.touch()
740
+ limit = time.monotonic() + wait
741
+ while time.monotonic() < limit:
742
+ if not state_of(name)["alive"]:
743
+ print(f"Worker {name} stopped.")
744
+ return 0
745
+ time.sleep(0.2)
746
+ # Still beating, so the pid is still this worker.
747
+ if state_of(name)["alive"]:
748
+ os.kill(meta["pid"], signal.SIGTERM)
749
+ print(f"Worker {name} did not stop in {wait:.0f}s and was ended; its current task is lost.")
750
+ return 0
751
+
752
+
753
+ def _list() -> int:
754
+ names = _worker_names()
755
+ if not names:
756
+ print("No workers.")
757
+ for name in names:
758
+ meta = state_of(name)
759
+ recovering = meta.get("state") == "recovering" and _supervised(meta)
760
+ passive = meta.get("state") == "passive" and not (folder(name) / "stop").exists()
761
+ state = meta.get("state", "?") if meta["alive"] or recovering or passive else "not running"
762
+ waiting = sum(job["status"] == "waiting" for job in jobs_of(name))
763
+ print(f"{name} {state} session {meta.get('session')} {meta.get('cwd', '')} "
764
+ f"{waiting} job(s) waiting")
765
+ return 0
766
+
767
+
768
+ def _worker_names() -> list[str]:
769
+ if not root().is_dir():
770
+ return []
771
+ return sorted(path.name for path in root().iterdir()
772
+ if path.is_dir() and _NAME.fullmatch(path.name))
773
+
774
+
775
+ def _stop_all(wait: float) -> int:
776
+ if not math.isfinite(wait) or wait < 0:
777
+ raise ValueError("wait must be finite and nonnegative")
778
+ targets = []
779
+ for name in _worker_names():
780
+ meta = state_of(name)
781
+ if meta.get("state") not in ("starting", "idle", "busy", "paused", "recovering", "passive"):
782
+ continue
783
+ (folder(name) / "stop").touch()
784
+ targets.append(name)
785
+ deadline = time.monotonic() + wait
786
+ while targets:
787
+ targets = [name for name in targets
788
+ if (meta := state_of(name))["alive"] or _supervised(meta)]
789
+ if not targets:
790
+ break
791
+ if time.monotonic() >= deadline:
792
+ print("Still stopping: " + ", ".join(targets), file=sys.stderr)
793
+ return 1
794
+ time.sleep(0.2)
795
+ print("Worker fleet stopped.")
796
+ return 0
797
+
798
+
799
+ def _record_incident(name: str, current: dict, reason: str, *,
800
+ incident_id: str | None = None, **details) -> Path | None:
801
+ base = folder(name)
802
+ incident_id = incident_id or uuid.uuid4().hex
803
+ path = base / "incidents" / f"{incident_id}.json"
804
+ record = {
805
+ "id": incident_id, "reason": reason, "session": current.get("session"),
806
+ "recorded_at": time.time(), "status": "recovering", "last_heartbeat": current.get("beat"),
807
+ "previous_state": current.get("state"), "previous_pid": current.get("pid"),
808
+ "previous_supervisor_pid": current.get("supervisor_pid"),
809
+ "turn_id": current.get("turn_id"), "job_id": current.get("job_id"),
810
+ "message_id": current.get("message_id"),
811
+ "in_flight_messages": sorted(p.stem for p in (base / "in-flight").glob("*.json")),
812
+ "started_jobs": [job["id"] for job in jobs_of(name) if job["status"] == "started"],
813
+ **details,
814
+ }
815
+ try:
816
+ _write(path, record)
817
+ except OSError as problem:
818
+ print(f"Worker {name} could not save its incident: {problem}", file=sys.stderr)
819
+ return None
820
+ return path
821
+
822
+
823
+ def _settle_incident(path: Path | None, status: str, **details) -> None:
824
+ if path is None:
825
+ return
826
+ record = _read(path)
827
+ if record is None or record.get("status") != "recovering":
828
+ return
829
+ try:
830
+ _write(path, {**record, **details, "status": status, "finished_at": time.time()})
831
+ except OSError as problem:
832
+ print(f"Could not finish worker incident {path.stem}: {problem}", file=sys.stderr)
833
+
834
+
835
+ def _recover_worker(name: str, wait: float = 180.0, *, wake: bool = False) -> int:
836
+ from rlmagent.harness.session.lease import SessionBusy, acquire
837
+
838
+ base = folder(name)
839
+ try:
840
+ lease = acquire(base, "start")
841
+ except SessionBusy:
842
+ return 0
843
+ with lease:
844
+ current = state_of(name)
845
+ if (root() / "admission-paused").exists() or not _can_recover(base, current, wake=wake):
846
+ return 0
847
+ launch = _read(base / "launch.json")
848
+ if not launch:
849
+ print(f"Worker {name} lacks saved launch settings.", file=sys.stderr)
850
+ return 1
851
+ run_args = list(launch["argv"])
852
+ if "--no-session" in run_args:
853
+ return 0
854
+ if "--resume" in run_args:
855
+ index = run_args.index("--resume")
856
+ del run_args[index:index + 2]
857
+ session_id = current["session"]
858
+ launch_id = uuid.uuid4().hex
859
+ reason = "idle_wake" if current["state"] == "passive" else "fleet_recovery"
860
+ incident_path = _record_incident(name, current, reason, incident_id=launch_id)
861
+ try:
862
+ supervisor = _spawn(name, [*run_args, "--resume", session_id],
863
+ recovery_cwd=launch["cwd"], launch_id=launch_id)
864
+ except OSError:
865
+ _settle_incident(incident_path, "spawn_failed")
866
+ raise
867
+ deadline = time.monotonic() + wait
868
+ while time.monotonic() < deadline:
869
+ meta = state_of(name)
870
+ if meta["alive"] and meta.get("session") == session_id and meta.get("launch_id") == launch_id:
871
+ _settle_incident(incident_path, "recovered", pid=meta["pid"],
872
+ supervisor_pid=meta.get("supervisor_pid"))
873
+ print(f"Recovered worker {name}, session {session_id}.")
874
+ return 0
875
+ if supervisor.poll() is not None:
876
+ break
877
+ time.sleep(0.2)
878
+ _settle_incident(incident_path, "exited" if supervisor.poll() is not None else "readiness_timeout")
879
+ print(f"Worker {name} did not recover. See {base / 'output.log'}.", file=sys.stderr)
880
+ return 1
881
+
882
+
883
+ def _can_recover(base: Path, current: dict, *, wake: bool = False) -> bool:
884
+ state = current.get("state")
885
+ if not current.get("session") or (base / "stop").exists():
886
+ return False
887
+ if any(_pid_alive(pid) for pid in (current.get("pid"), current.get("supervisor_pid")) if pid):
888
+ return False
889
+ if state != "passive":
890
+ return state in ("starting", "idle", "busy", "paused", "recovering")
891
+ now = time.time()
892
+ return wake or any((base / "inbox").glob("*.json")) or any(
893
+ job["status"] == "waiting" and job["due"] <= now for job in jobs_of(base.name)
894
+ )
895
+
896
+
897
+ def _watch_fleet(stop: bool = False) -> int:
898
+ """Recover saved workers on startup and after subsequent process losses."""
899
+ from concurrent.futures import ThreadPoolExecutor
900
+
901
+ from rlmagent.harness.session.lease import SessionBusy, acquire
902
+
903
+ stop_file = root() / "fleet-watch.stop"
904
+ if stop:
905
+ root().mkdir(parents=True, exist_ok=True)
906
+ stop_file.touch()
907
+ return 0
908
+ try:
909
+ lease = acquire(root(), "fleet-watch")
910
+ except SessionBusy:
911
+ print("A fleet watcher is already running.", file=sys.stderr)
912
+ return 1
913
+ pending = {}
914
+ retry_after = {}
915
+ with lease, ThreadPoolExecutor(max_workers=4) as pool:
916
+ stop_file.unlink(missing_ok=True)
917
+ print("Watching the worker fleet.", flush=True)
918
+ try:
919
+ while not stop_file.exists():
920
+ now = time.monotonic()
921
+ for name, future in list(pending.items()):
922
+ if not future.done():
923
+ continue
924
+ del pending[name]
925
+ try:
926
+ if future.result():
927
+ retry_after[name] = now + STALE_SECONDS
928
+ else:
929
+ retry_after.pop(name, None)
930
+ except Exception as error:
931
+ retry_after[name] = now + STALE_SECONDS
932
+ print(f"Worker {name} recovery failed: {error}", file=sys.stderr)
933
+ for name in _worker_names():
934
+ if len(pending) == 4 or (root() / "admission-paused").exists():
935
+ break
936
+ if name in pending or now < retry_after.get(name, 0):
937
+ continue
938
+ if _can_recover(folder(name), state_of(name)):
939
+ pending[name] = pool.submit(_recover_worker, name)
940
+ time.sleep(POLL_SECONDS)
941
+ except KeyboardInterrupt:
942
+ pass
943
+ return 0
944
+
945
+
946
+ def _show(record: dict) -> None:
947
+ if record["kind"] in ("model_delta", "model_end", "tool_start", "tool_update", "tool_end"):
948
+ return
949
+ stamp = time.strftime("%H:%M:%S", time.localtime(record["time"]))
950
+ print(f"[{stamp} {record['kind']}] {record['text']}", flush=True)
951
+
952
+
953
+ class WorkerLog:
954
+ """Read only complete log records and keep a cursor for the next poll."""
955
+
956
+ def __init__(self, name: str, *, recent_only: bool = False, start_at: int = 0):
957
+ self.path = folder(name) / "log.jsonl"
958
+ self.position = start_at
959
+ self.recent_only = recent_only
960
+ self.identity = None
961
+
962
+ def read(self) -> list[dict]:
963
+ records = []
964
+ try:
965
+ stream = self.path.open("rb")
966
+ except FileNotFoundError:
967
+ return records
968
+ with stream:
969
+ stat = os.fstat(stream.fileno())
970
+ identity = (stat.st_dev, stat.st_ino)
971
+ if (self.identity is not None and identity != self.identity) or stat.st_size < self.position:
972
+ self.position = 0
973
+ if self.identity is None and not self.position and self.recent_only and stat.st_size > 131_072:
974
+ stream.seek(stat.st_size - 131_072)
975
+ stream.readline()
976
+ self.position = stream.tell()
977
+ self.identity = identity
978
+ stream.seek(self.position)
979
+ for line in stream:
980
+ if not line.endswith(b"\n"):
981
+ break
982
+ self.position += len(line)
983
+ try:
984
+ record = json.loads(line)
985
+ except (ValueError, UnicodeError):
986
+ continue
987
+ if isinstance(record, dict) and {"time", "kind", "text"} <= record.keys():
988
+ records.append(record)
989
+ return records
990
+
991
+
992
+ def _follow(name: str, stop: threading.Event, tail: int | None) -> None:
993
+ cursor = WorkerLog(name)
994
+ rows = [row for row in cursor.read()
995
+ if row["kind"] not in ("model_delta", "model_end", "tool_start", "tool_update", "tool_end")]
996
+ for row in rows[-tail:] if tail else rows:
997
+ _show(row)
998
+ while not stop.is_set():
999
+ for row in cursor.read():
1000
+ _show(row)
1001
+ stop.wait(0.3)
1002
+
1003
+
1004
+ def _attach(name: str, *, tui: bool = False) -> int:
1005
+ state = state_of(name)
1006
+ if state.get("state") == "passive":
1007
+ deadline = time.monotonic() + 5
1008
+ while state.get("state") == "passive" and _supervised(state) and time.monotonic() < deadline:
1009
+ time.sleep(0.1)
1010
+ state = state_of(name)
1011
+ if state.get("state") == "passive" and _recover_worker(name, wake=True):
1012
+ return 1
1013
+ if tui:
1014
+ from rlmagent.tui.worker_attach import WorkerAttachApp
1015
+
1016
+ WorkerAttachApp(name).run()
1017
+ return 0
1018
+ if not state_of(name)["alive"]:
1019
+ print(f"Worker {name} is not running; what you send waits for its next start.",
1020
+ file=sys.stderr)
1021
+ done = threading.Event()
1022
+ follower = threading.Thread(target=_follow, args=(name, done, 20), daemon=True)
1023
+ follower.start()
1024
+ print("Attached. Lines you type go to the worker. /detach leaves it running.", flush=True)
1025
+ try:
1026
+ while True:
1027
+ try:
1028
+ line = sys.stdin.readline()
1029
+ except KeyboardInterrupt:
1030
+ if interrupt(name):
1031
+ print("Interrupt sent to the worker.", flush=True)
1032
+ continue
1033
+ break
1034
+ if not line:
1035
+ break
1036
+ text = line.strip()
1037
+ if text in ("/detach", "/quit", "/exit", "/q"):
1038
+ break
1039
+ if text:
1040
+ result = answer_input(name, text)
1041
+ if result is None:
1042
+ send(name, text)
1043
+ else:
1044
+ print(result, flush=True)
1045
+ except KeyboardInterrupt:
1046
+ pass
1047
+ done.set()
1048
+ # A follower still printing while the interpreter shuts down aborts the
1049
+ # process, so it finishes its current line before this one exits.
1050
+ follower.join(timeout=5)
1051
+ return 0
1052
+
1053
+
1054
+ def handle_session(argv: list[str]) -> int:
1055
+ """Give a saved conversation a detached process and a stable terminal address."""
1056
+ from rlmagent import agentnet
1057
+ from rlmagent.cli.main import _build_run_parser
1058
+
1059
+ action = argv[0]
1060
+ parser = argparse.ArgumentParser(prog=f"rlm-agent session {action}")
1061
+ parser.add_argument("session_id")
1062
+ if action == "attach":
1063
+ parser.add_argument("--tui", action="store_true", help="Open the Textual session view.")
1064
+ if action == "detach":
1065
+ args, run_args = parser.parse_known_args(argv[1:])
1066
+ else:
1067
+ args, run_args = parser.parse_args(argv[1:]), []
1068
+ session_id = args.session_id
1069
+ name = f"session-{session_id}"
1070
+ if not _NAME.fullmatch(name):
1071
+ parser.error("invalid session ID")
1072
+ if action == "attach":
1073
+ state = state_of(name)
1074
+ saved = state.get("session") == session_id
1075
+ if not state["alive"] and not (saved and (args.tui or state.get("state") == "passive")):
1076
+ parser.error("session is offline; start it with session detach first")
1077
+ return _attach(name, tui=args.tui)
1078
+
1079
+ ns = _build_run_parser().parse_args(run_args)
1080
+ if ns.resume or ns.no_session or ns.contract or ns.prompt:
1081
+ parser.error("give the saved session ID followed by provider and runtime options")
1082
+ registered = agentnet._read(agentnet._home() / f"s-{session_id[:8]}" / "meta.json")
1083
+ if registered and registered.get("pid") and _pid_alive(registered["pid"]):
1084
+ parser.error("close the session's current terminal before detaching it")
1085
+ return _start(name, [*run_args, "--resume", session_id])
1086
+
1087
+
1088
+ def start_interactive(run_args: list[str], *, session_id: str | None, tui: bool) -> int:
1089
+ session_id = session_id or uuid.uuid4().hex[:12]
1090
+ name = f"session-{session_id}"
1091
+ state = state_of(name)
1092
+ if state.get("state") == "passive" and not (folder(name) / "stop").exists():
1093
+ return _attach(name, tui=tui)
1094
+ if state["alive"]:
1095
+ if state.get("session") != session_id:
1096
+ raise ValueError(f"Worker {name} owns a different session.")
1097
+ else:
1098
+ if tui:
1099
+ from rlmagent.cli.main import _build_run_parser
1100
+ from rlmagent.runtime import settle_project_trust
1101
+ from rlmagent.tui.app import setup_if_needed
1102
+ from rlmagent.tui.trust_dialog import ask_in_dialog
1103
+
1104
+ async def prepare():
1105
+ ns = _build_run_parser().parse_args(run_args)
1106
+ trust = await settle_project_trust(ns, ask=ask_in_dialog)
1107
+ return trust.trusted if await setup_if_needed(ns) else None
1108
+
1109
+ trusted = asyncio.run(prepare())
1110
+ if trusted is None:
1111
+ return 1
1112
+ ns = _build_run_parser().parse_args(run_args)
1113
+ if not (ns.approve or ns.no_approve):
1114
+ run_args = [*run_args, "--approve" if trusted else "--no-approve"]
1115
+ if "--supervised" not in run_args:
1116
+ run_args = [*run_args, "--supervised"]
1117
+ status = _start(name, run_args)
1118
+ if status:
1119
+ return status
1120
+ return _attach(name, tui=tui)
1121
+
1122
+
1123
+ def _when(moment: str) -> float:
1124
+ return datetime.fromisoformat(moment).timestamp()
1125
+
1126
+
1127
+ def handle(argv: list[str]) -> int:
1128
+ parser = argparse.ArgumentParser(prog="rlm-agent worker")
1129
+ sub = parser.add_subparsers(dest="action", required=True)
1130
+ start = sub.add_parser("start", help="Start a named worker in this folder. Run flags follow.")
1131
+ start.add_argument("name")
1132
+ sub.add_parser("list", help="List workers.")
1133
+ sub.add_parser("incidents", help="Show worker exits and recovery outcomes.").add_argument("name")
1134
+ recover = sub.add_parser("recover", help="Recover interrupted saved workers.")
1135
+ recover.add_argument("name", nargs="?")
1136
+ watch = sub.add_parser("watch", help="Monitor the fleet and recover interrupted saved workers.")
1137
+ watch.add_argument("--stop", action="store_true", help="Stop the fleet watcher.")
1138
+ service = sub.add_parser("service", help="Manage the user-level fleet watcher service.")
1139
+ service.add_argument("operation", choices=("install", "status", "start", "stop", "uninstall"))
1140
+ stop_all = sub.add_parser("stop-all", help="Ask the worker fleet to finish and stop.")
1141
+ stop_all.add_argument("--wait", type=float, default=60.0)
1142
+ for action, text in (("attach", "Follow a worker and type to it."),
1143
+ ("jobs", "List a worker's scheduled jobs."),
1144
+ ("remove", "Delete a stopped worker's folder.")):
1145
+ worker_action = sub.add_parser(action, help=text)
1146
+ worker_action.add_argument("name")
1147
+ if action == "attach":
1148
+ worker_action.add_argument("--tui", action="store_true", help="Open the Textual worker view.")
1149
+ stop = sub.add_parser("stop", help="Stop a worker after its current task.")
1150
+ stop.add_argument("name")
1151
+ stop.add_argument("--wait", type=float, default=60.0)
1152
+ tell = sub.add_parser("send", help="Queue a prompt for a worker.")
1153
+ tell.add_argument("name")
1154
+ tell.add_argument("text")
1155
+ receipt = sub.add_parser("receipt", help="Show the outcome of an inbox message.")
1156
+ receipt.add_argument("name")
1157
+ receipt.add_argument("message_id")
1158
+ log = sub.add_parser("log", help="Show a worker's log.")
1159
+ log.add_argument("name")
1160
+ log.add_argument("--follow", action="store_true")
1161
+ log.add_argument("--tail", type=int, default=None)
1162
+ plan = sub.add_parser("schedule", help="Send a prompt later, once or repeatedly.")
1163
+ plan.add_argument("name")
1164
+ plan.add_argument("text")
1165
+ moment = plan.add_mutually_exclusive_group(required=True)
1166
+ moment.add_argument("--in", dest="delay", type=float, metavar="SECONDS")
1167
+ moment.add_argument("--at", metavar="ISO-TIME", help="For example 2027-01-02T09:00.")
1168
+ moment.add_argument("--cron", metavar="EXPRESSION",
1169
+ help="Wall-clock recurrence, such as @hourly or five numeric fields.")
1170
+ plan.add_argument("--every", type=float, metavar="SECONDS")
1171
+ plan.add_argument("--timezone", metavar="IANA-ZONE", help="Timezone for --cron (default UTC).")
1172
+ cancel = sub.add_parser("unschedule", help="Remove a scheduled job.")
1173
+ cancel.add_argument("name")
1174
+ cancel.add_argument("job")
1175
+
1176
+ if argv[:1] == ["supervise"]:
1177
+ return _supervise(argv[1], argv[2:])
1178
+ if argv[:1] == ["serve"]:
1179
+ from rlmagent.cli.main import _build_run_parser
1180
+
1181
+ return asyncio.run(serve(argv[1], _build_run_parser().parse_args(argv[2:])))
1182
+ if argv[:1] == ["start"]:
1183
+ ns, run_args = start_parser_split(argv[1:])
1184
+ return _start(ns, run_args)
1185
+
1186
+ ns = parser.parse_args(argv)
1187
+ try:
1188
+ if ns.action == "list":
1189
+ return _list()
1190
+ if ns.action == "incidents":
1191
+ paths = (folder(ns.name) / "incidents").glob("*.json")
1192
+ records = [value for path in paths if (value := _read(path)) is not None]
1193
+ print(json.dumps(sorted(records, key=lambda record: record.get("recorded_at", 0))))
1194
+ return 0
1195
+ if ns.action == "recover":
1196
+ names = [ns.name] if ns.name else _worker_names()
1197
+ results = [_recover_worker(name) for name in names]
1198
+ return max(results, default=0)
1199
+ if ns.action == "watch":
1200
+ return _watch_fleet(ns.stop)
1201
+ if ns.action == "service":
1202
+ from rlmagent.worker_service import manage
1203
+
1204
+ return manage(ns.operation)
1205
+ if ns.action == "stop-all":
1206
+ return _stop_all(ns.wait)
1207
+ if ns.action == "stop":
1208
+ return _stop(ns.name, ns.wait)
1209
+ if ns.action == "send":
1210
+ message_id = send(ns.name, ns.text)
1211
+ print(f"Queued for {ns.name}. Message {message_id}.")
1212
+ return 0
1213
+ if ns.action == "receipt":
1214
+ print(json.dumps(receipt_of(ns.name, ns.message_id)))
1215
+ return 0
1216
+ if ns.action == "attach":
1217
+ return _attach(ns.name, tui=ns.tui)
1218
+ if ns.action == "log":
1219
+ done = threading.Event()
1220
+ if ns.follow:
1221
+ try:
1222
+ _follow(ns.name, done, ns.tail)
1223
+ except KeyboardInterrupt:
1224
+ pass
1225
+ else:
1226
+ done.set()
1227
+ _follow(ns.name, done, ns.tail)
1228
+ return 0
1229
+ if ns.action == "schedule":
1230
+ due = None
1231
+ if ns.delay is not None:
1232
+ due = time.time() + ns.delay
1233
+ elif ns.at is not None:
1234
+ due = _when(ns.at)
1235
+ job = schedule(ns.name, ns.text, due, ns.every, cron=ns.cron, timezone=ns.timezone)
1236
+ print(f"Job {job['id']} will run at {job_time(job)}.")
1237
+ if not state_of(ns.name)["alive"]:
1238
+ print(f"Worker {ns.name} is not running; the job waits until it starts.")
1239
+ return 0
1240
+ if ns.action == "jobs":
1241
+ for job in jobs_of(ns.name):
1242
+ cadence = f" cron {job['cron']} ({job['timezone']})" if job.get("cron") else ""
1243
+ error = f" {job['error']}" if job.get("error") else ""
1244
+ print(f"{job['id']} {job['status']} {job_time(job)} runs {job['runs']} "
1245
+ f"{job['text'][:60]}{cadence}{error}")
1246
+ return 0
1247
+ if ns.action == "unschedule":
1248
+ from rlmagent.harness.session.lease import SessionBusy, acquire
1249
+
1250
+ if re.fullmatch(r"[a-f0-9]{8}", ns.job) is None:
1251
+ raise ValueError("invalid worker job ID")
1252
+ directory = folder(ns.name) / "jobs"
1253
+ try:
1254
+ with acquire(directory, ns.job):
1255
+ (directory / f"{ns.job}.json").unlink(missing_ok=True)
1256
+ except SessionBusy:
1257
+ raise ValueError("worker is taking this job; retry unschedule") from None
1258
+ return 0
1259
+ if ns.action == "remove":
1260
+ meta = state_of(ns.name)
1261
+ if meta["alive"] or _supervised(meta):
1262
+ print(f"Worker {ns.name} is running; stop it first.", file=sys.stderr)
1263
+ return 1
1264
+ shutil.rmtree(folder(ns.name), ignore_errors=True)
1265
+ return 0
1266
+ except (OSError, ValueError) as problem:
1267
+ print(f"rlm-agent: {problem}", file=sys.stderr)
1268
+ return 2
1269
+ return 2
1270
+
1271
+
1272
+ def start_parser_split(rest: list[str]) -> tuple[str, list[str]]:
1273
+ if not rest:
1274
+ raise SystemExit("usage: rlm-agent worker start NAME [run flags]")
1275
+ return rest[0], rest[1:]