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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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:]
|