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/job_runner.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Detached shell controller. Descriptors arrive on stdin, never through argv."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import codecs
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
spec = json.loads(sys.stdin.readline())
|
|
16
|
+
meta, log = Path(spec["metadata"]), Path(spec["log"])
|
|
17
|
+
stop = meta.with_suffix(".stop")
|
|
18
|
+
import psutil
|
|
19
|
+
state = dict(spec["state"], controller=os.getpid(),
|
|
20
|
+
controller_started=psutil.Process().create_time(), heartbeat=time.time(),
|
|
21
|
+
pid=None, ended=None,
|
|
22
|
+
exit=None, timed_out=False, stopped=False, interrupted=False,
|
|
23
|
+
total_bytes=0, truncated=False)
|
|
24
|
+
lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
def save():
|
|
27
|
+
with lock:
|
|
28
|
+
state["heartbeat"] = time.time()
|
|
29
|
+
stage = meta.with_suffix(".tmp")
|
|
30
|
+
stage.write_text(json.dumps(state), encoding="utf-8")
|
|
31
|
+
# Windows refuses the swap while a reader has the file open; that
|
|
32
|
+
# reader is gone in moments, and giving up here would abandon the
|
|
33
|
+
# job's process with its state half written.
|
|
34
|
+
for attempt in range(50):
|
|
35
|
+
try:
|
|
36
|
+
stage.replace(meta)
|
|
37
|
+
return
|
|
38
|
+
except PermissionError:
|
|
39
|
+
if attempt == 49:
|
|
40
|
+
raise
|
|
41
|
+
time.sleep(0.02)
|
|
42
|
+
|
|
43
|
+
proc = None
|
|
44
|
+
try:
|
|
45
|
+
proc = subprocess.Popen(spec["argv"], cwd=spec["cwd"], env=spec["env"],
|
|
46
|
+
stdin=subprocess.PIPE if spec.get("keep_stdin_open") else subprocess.DEVNULL,
|
|
47
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
48
|
+
**({} if os.name == "nt" else {"start_new_session": True}))
|
|
49
|
+
state["pid"] = proc.pid
|
|
50
|
+
save()
|
|
51
|
+
|
|
52
|
+
def drain():
|
|
53
|
+
stored = 0
|
|
54
|
+
capped = False
|
|
55
|
+
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
56
|
+
with log.open("wb") as out:
|
|
57
|
+
while chunk := proc.stdout.read1(8192):
|
|
58
|
+
remaining = 0 if capped else max(0, spec["max_bytes"] - stored)
|
|
59
|
+
shown = chunk[:remaining]
|
|
60
|
+
decoder.decode(shown, final=False)
|
|
61
|
+
out.write(shown)
|
|
62
|
+
stored += len(shown)
|
|
63
|
+
if remaining and len(chunk) >= remaining:
|
|
64
|
+
# The disk prefix must end at a complete Unicode character.
|
|
65
|
+
pending = len(decoder.getstate()[0])
|
|
66
|
+
if pending:
|
|
67
|
+
stored -= pending
|
|
68
|
+
out.truncate(stored)
|
|
69
|
+
capped = True
|
|
70
|
+
out.flush()
|
|
71
|
+
with lock:
|
|
72
|
+
state["total_bytes"] += len(chunk)
|
|
73
|
+
state["truncated"] = state["total_bytes"] > stored
|
|
74
|
+
|
|
75
|
+
reader = threading.Thread(target=drain, daemon=True)
|
|
76
|
+
reader.start()
|
|
77
|
+
deadline = time.monotonic() + spec["timeout"] if spec["timeout"] else None
|
|
78
|
+
while proc.poll() is None:
|
|
79
|
+
expired = deadline is not None and time.monotonic() >= deadline
|
|
80
|
+
owner_gone = False
|
|
81
|
+
if not state["persistent"]:
|
|
82
|
+
try:
|
|
83
|
+
owner = psutil.Process(spec["owner"]["pid"])
|
|
84
|
+
owner_gone = abs(owner.create_time() - spec["owner"]["started"]) > 0.01
|
|
85
|
+
except psutil.NoSuchProcess:
|
|
86
|
+
owner_gone = True
|
|
87
|
+
if stop.exists() or expired or owner_gone:
|
|
88
|
+
state["stopped"] = stop.exists() or owner_gone
|
|
89
|
+
state["interrupted"] = owner_gone
|
|
90
|
+
state["timed_out"] = expired
|
|
91
|
+
if os.name == "nt":
|
|
92
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
93
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
94
|
+
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
95
|
+
else:
|
|
96
|
+
import signal
|
|
97
|
+
try:
|
|
98
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
99
|
+
except ProcessLookupError:
|
|
100
|
+
pass
|
|
101
|
+
proc.wait()
|
|
102
|
+
break
|
|
103
|
+
save()
|
|
104
|
+
time.sleep(0.1)
|
|
105
|
+
reader.join(timeout=2)
|
|
106
|
+
state["exit"] = proc.returncode
|
|
107
|
+
except BaseException as exc:
|
|
108
|
+
state["error"] = str(exc)
|
|
109
|
+
state["exit"] = -1
|
|
110
|
+
finally:
|
|
111
|
+
state["ended"] = time.time()
|
|
112
|
+
save()
|
|
113
|
+
stop.unlink(missing_ok=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
rlmagent/jobs.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Shell jobs and services the harness owns, and watches that wait for something to change.
|
|
2
|
+
|
|
3
|
+
A job runs in the background with its output kept in a file, so a cell can start it,
|
|
4
|
+
carry on, and read what is new from a cursor. Jobs belong to the session, not to a
|
|
5
|
+
kernel: a sandbox that restarts or a lost variable does not lose them. A service is a
|
|
6
|
+
job expected to keep running (a dev server); starting one can wait for a line that says
|
|
7
|
+
it is ready.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from rlmness.environment import execution_environment
|
|
25
|
+
|
|
26
|
+
from rlmagent.subscriptions import Subscriptions
|
|
27
|
+
|
|
28
|
+
MAX_READ = 64 * 1024
|
|
29
|
+
MAX_JOBS = 32
|
|
30
|
+
MAX_LOG = 8 * 1024 * 1024
|
|
31
|
+
POLL = 0.2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Job:
|
|
36
|
+
id: str
|
|
37
|
+
command: str
|
|
38
|
+
log: Path
|
|
39
|
+
service: bool = False
|
|
40
|
+
name: str = ""
|
|
41
|
+
started: float = field(default_factory=time.time)
|
|
42
|
+
ended: float | None = None
|
|
43
|
+
exit: int | None = None
|
|
44
|
+
timed_out: bool = False
|
|
45
|
+
stopped: bool = False
|
|
46
|
+
proc: subprocess.Popen | None = None
|
|
47
|
+
persistent: bool = False
|
|
48
|
+
total_bytes: int = 0
|
|
49
|
+
truncated: bool = False
|
|
50
|
+
interrupted: bool = False
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def metadata(self):
|
|
54
|
+
return self.log.with_suffix(".json")
|
|
55
|
+
|
|
56
|
+
def refresh(self):
|
|
57
|
+
if not self.metadata.exists():
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
state = json.loads(self.metadata.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return # caught mid-update; the next refresh reads the new state
|
|
63
|
+
if state.get("ended") is None and time.time() - state.get("heartbeat", 0) > 3:
|
|
64
|
+
import psutil
|
|
65
|
+
try:
|
|
66
|
+
controller = psutil.Process(state["controller"])
|
|
67
|
+
alive = abs(controller.create_time() - state["controller_started"]) < 0.01
|
|
68
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError):
|
|
69
|
+
alive = False
|
|
70
|
+
if not alive:
|
|
71
|
+
state.update(ended=time.time(), exit=-1, interrupted=True)
|
|
72
|
+
for key in ("ended", "exit", "timed_out", "stopped", "total_bytes", "truncated",
|
|
73
|
+
"interrupted"):
|
|
74
|
+
if key in state:
|
|
75
|
+
setattr(self, key, state[key])
|
|
76
|
+
if self.proc is not None:
|
|
77
|
+
self.proc.poll()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def done(self) -> bool:
|
|
81
|
+
return self.ended is not None
|
|
82
|
+
|
|
83
|
+
def summary(self) -> dict:
|
|
84
|
+
self.refresh()
|
|
85
|
+
return {"id": self.id, "name": self.name, "command": self.command,
|
|
86
|
+
"service": self.service, "done": self.done, "exit": self.exit,
|
|
87
|
+
"timed_out": self.timed_out, "stopped": self.stopped,
|
|
88
|
+
"seconds": round((self.ended or time.time()) - self.started, 1),
|
|
89
|
+
"bytes": self.log.stat().st_size if self.log.exists() else 0,
|
|
90
|
+
"total_bytes": self.total_bytes, "truncated": self.truncated,
|
|
91
|
+
"interrupted": self.interrupted,
|
|
92
|
+
"persistent": self.persistent}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bash() -> str:
|
|
96
|
+
found = shutil.which("bash")
|
|
97
|
+
if found is None and os.name == "nt":
|
|
98
|
+
candidate = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
|
|
99
|
+
if candidate.is_file():
|
|
100
|
+
found = str(candidate)
|
|
101
|
+
if found is None:
|
|
102
|
+
raise OSError("Bash is required for shell jobs")
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Jobs:
|
|
107
|
+
"""The session's background jobs. `approve(command)` raises when a command is refused."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, cwd: str, folder: Path, approve=None, check=None, *, env=None,
|
|
110
|
+
max_log_bytes: int = MAX_LOG, shell_access="host", shell_network=False) -> None:
|
|
111
|
+
self.cwd = cwd
|
|
112
|
+
self.folder = folder
|
|
113
|
+
self.approve = approve # async (command) -> None, raises when refused
|
|
114
|
+
self.check = check # (command) -> reason or None, for policy refusals
|
|
115
|
+
self.jobs: dict[str, Job] = {}
|
|
116
|
+
self.subscriptions = Subscriptions(folder / ".watches", self._watch_snapshot)
|
|
117
|
+
self.child_snapshot = None
|
|
118
|
+
self.env = dict(env or {})
|
|
119
|
+
self.shell_access, self.shell_network = shell_access, shell_network
|
|
120
|
+
self.max_log_bytes = max(4, int(max_log_bytes))
|
|
121
|
+
for path in self.folder.glob("*.json"):
|
|
122
|
+
try:
|
|
123
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
key = state["id"]
|
|
125
|
+
if not re.fullmatch(r"[a-f0-9]{8}", key):
|
|
126
|
+
continue
|
|
127
|
+
job = Job(key, state["command"], self.folder / f"{key}.log",
|
|
128
|
+
state["service"], state["name"], started=state["started"],
|
|
129
|
+
persistent=state.get("persistent", False))
|
|
130
|
+
job.refresh()
|
|
131
|
+
self.jobs[key] = job
|
|
132
|
+
except (OSError, ValueError, KeyError):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
def _get(self, job_id: str) -> Job:
|
|
136
|
+
job = self.jobs.get(job_id) or next(
|
|
137
|
+
(j for j in self.jobs.values() if j.name and j.name == job_id), None)
|
|
138
|
+
if job is None:
|
|
139
|
+
raise ValueError(f"no job {job_id!r}; jobs() lists them")
|
|
140
|
+
job.refresh()
|
|
141
|
+
return job
|
|
142
|
+
|
|
143
|
+
async def _launch(self, command: str, timeout: float | None, service: bool, name: str,
|
|
144
|
+
persistent: bool = False, argv=None, env=None) -> Job:
|
|
145
|
+
if self.check is not None and (reason := self.check(command)):
|
|
146
|
+
raise ValueError(reason)
|
|
147
|
+
if self.approve is not None:
|
|
148
|
+
await self.approve(command)
|
|
149
|
+
if sum(not j.summary()["done"] for j in self.jobs.values()) >= MAX_JOBS:
|
|
150
|
+
raise ValueError(f"{MAX_JOBS} jobs are already running; stop some first")
|
|
151
|
+
job_id = uuid.uuid4().hex[:8]
|
|
152
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
job = Job(job_id, command, self.folder / f"{job_id}.log", service, name,
|
|
154
|
+
persistent=persistent)
|
|
155
|
+
job.log.touch()
|
|
156
|
+
isolated = self.shell_access == "workspace"
|
|
157
|
+
target_argv = argv
|
|
158
|
+
if isolated:
|
|
159
|
+
from rlmagent.shell_sandbox import plan
|
|
160
|
+
target_argv = await asyncio.to_thread(plan, self.cwd, command, argv=argv,
|
|
161
|
+
env={**self.env, **(env or {})}, network=self.shell_network)
|
|
162
|
+
else:
|
|
163
|
+
target_argv = argv or [_bash(), "--noprofile", "--norc", "-c", command]
|
|
164
|
+
import psutil
|
|
165
|
+
spec = {"metadata": str(job.metadata.resolve()), "log": str(job.log.resolve()),
|
|
166
|
+
"owner": {"pid": os.getpid(), "started": psutil.Process().create_time()},
|
|
167
|
+
"cwd": self.cwd, "argv": target_argv, "keep_stdin_open": isolated,
|
|
168
|
+
"env": execution_environment({"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1",
|
|
169
|
+
**self.env, **(env or {})}),
|
|
170
|
+
"timeout": timeout, "max_bytes": self.max_log_bytes,
|
|
171
|
+
"state": {"id": job.id, "command": command, "service": service,
|
|
172
|
+
"name": name, "started": job.started, "persistent": persistent}}
|
|
173
|
+
options = ({"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS}
|
|
174
|
+
if os.name == "nt" else {"start_new_session": True})
|
|
175
|
+
runner = str(Path(__file__).with_name("job_runner.py"))
|
|
176
|
+
job.proc = subprocess.Popen([sys.executable, "-I", runner],
|
|
177
|
+
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
178
|
+
env=execution_environment(), **options)
|
|
179
|
+
job.proc.stdin.write((json.dumps(spec) + "\n").encode("utf-8"))
|
|
180
|
+
job.proc.stdin.close()
|
|
181
|
+
self.jobs[job_id] = job
|
|
182
|
+
while not job.metadata.exists():
|
|
183
|
+
if job.proc.poll() is not None:
|
|
184
|
+
raise RuntimeError("job controller failed before accepting the command")
|
|
185
|
+
await asyncio.sleep(0.02)
|
|
186
|
+
return job
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _kill(job: Job) -> None:
|
|
190
|
+
job.refresh()
|
|
191
|
+
if not job.done:
|
|
192
|
+
job.metadata.with_suffix(".stop").touch()
|
|
193
|
+
|
|
194
|
+
# -- functions agents call ---------------------------------------------
|
|
195
|
+
|
|
196
|
+
async def job_start(self, command: str = "", timeout: float | None = None, name: str = "",
|
|
197
|
+
*, argv=None, env=None) -> dict:
|
|
198
|
+
"""Run a shell command in the background; returns {"id", ...}. Read its output with
|
|
199
|
+
job_read(id, cursor) and wait for it with job_wait(id)."""
|
|
200
|
+
if argv is not None:
|
|
201
|
+
if not isinstance(argv, list) or not argv or not all(isinstance(x, str) for x in argv):
|
|
202
|
+
raise ValueError("argv must be a nonempty list of strings")
|
|
203
|
+
command = subprocess.list2cmdline(argv)
|
|
204
|
+
job = await self._launch(str(command), float(timeout) if timeout else None, False, name,
|
|
205
|
+
argv=argv, env=env)
|
|
206
|
+
return job.summary()
|
|
207
|
+
|
|
208
|
+
async def job_read(self, job_id: str, cursor: int = 0) -> dict:
|
|
209
|
+
"""Output written since cursor (a byte offset): {"text", "cursor", "done", "exit"}.
|
|
210
|
+
Pass the returned cursor next time to get only what is new."""
|
|
211
|
+
job = self._get(job_id)
|
|
212
|
+
with open(job.log, "rb") as f:
|
|
213
|
+
offset = max(0, min(int(cursor), job.log.stat().st_size))
|
|
214
|
+
f.seek(offset)
|
|
215
|
+
data = f.read(MAX_READ)
|
|
216
|
+
# Keep an incomplete trailing UTF-8 sequence for the next read. Invalid bytes
|
|
217
|
+
# within output remain visible as replacement characters.
|
|
218
|
+
import codecs
|
|
219
|
+
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
220
|
+
text = decoder.decode(data, final=job.done and offset + len(data) == job.log.stat().st_size)
|
|
221
|
+
pending = decoder.getstate()[0]
|
|
222
|
+
used = len(data) - len(pending)
|
|
223
|
+
return {"text": text, "cursor": offset + used,
|
|
224
|
+
"more": offset + used < job.log.stat().st_size, "done": job.done, "exit": job.exit,
|
|
225
|
+
"timed_out": job.timed_out}
|
|
226
|
+
|
|
227
|
+
async def job_wait(self, job_id: str, timeout: float = 300.0) -> dict:
|
|
228
|
+
"""Wait until a job ends, at most timeout seconds; returns its summary."""
|
|
229
|
+
job = self._get(job_id)
|
|
230
|
+
deadline = time.monotonic() + max(0, float(timeout))
|
|
231
|
+
while not job.done and time.monotonic() < deadline:
|
|
232
|
+
await asyncio.sleep(POLL)
|
|
233
|
+
job.refresh()
|
|
234
|
+
return job.summary()
|
|
235
|
+
|
|
236
|
+
async def job_stop(self, job_id: str) -> dict:
|
|
237
|
+
"""Stop a job or service and everything it started."""
|
|
238
|
+
job = self._get(job_id)
|
|
239
|
+
job.stopped = True
|
|
240
|
+
self._kill(job)
|
|
241
|
+
return await self.job_wait(job.id, 30)
|
|
242
|
+
|
|
243
|
+
async def jobs_list(self) -> list[dict]:
|
|
244
|
+
"""Every job and service of this session, running or finished."""
|
|
245
|
+
return [job.summary() for job in self.jobs.values()]
|
|
246
|
+
|
|
247
|
+
async def service_start(self, name: str, command: str, ready: str | None = None,
|
|
248
|
+
wait: float = 60.0, *, persist: bool = False, env=None) -> dict:
|
|
249
|
+
"""Start a long-running process such as a dev server, under a name. With ready, a
|
|
250
|
+
regular expression, wait up to wait seconds for a line that matches it."""
|
|
251
|
+
for job in self.jobs.values():
|
|
252
|
+
job.refresh()
|
|
253
|
+
if job.name == name and not job.done:
|
|
254
|
+
raise ValueError(f"a service named {name!r} is up; stop it with job_stop first")
|
|
255
|
+
job = await self._launch(str(command), None, True, str(name), persist, env=env)
|
|
256
|
+
summary = job.summary()
|
|
257
|
+
if ready:
|
|
258
|
+
pattern = re.compile(ready)
|
|
259
|
+
deadline = time.monotonic() + float(wait)
|
|
260
|
+
while time.monotonic() < deadline and not job.done:
|
|
261
|
+
job.refresh()
|
|
262
|
+
if pattern.search(job.log.read_text(encoding="utf-8", errors="replace")):
|
|
263
|
+
summary = job.summary()
|
|
264
|
+
summary["ready"] = True
|
|
265
|
+
return summary
|
|
266
|
+
await asyncio.sleep(POLL)
|
|
267
|
+
summary = job.summary()
|
|
268
|
+
summary["ready"] = False
|
|
269
|
+
return summary
|
|
270
|
+
|
|
271
|
+
async def watch(self, job: str | None = None, cursor: int = 0, path: str | None = None,
|
|
272
|
+
inbox=None, timeout: float = 300.0) -> dict:
|
|
273
|
+
"""Wait until something changes: a job writes past cursor or ends (job=ID), a file or
|
|
274
|
+
folder changes (path=...), or a message arrives (inbox). Returns what happened, or
|
|
275
|
+
{"event": "timeout"}."""
|
|
276
|
+
deadline = time.monotonic() + float(timeout)
|
|
277
|
+
target = None
|
|
278
|
+
before = None
|
|
279
|
+
if path is not None:
|
|
280
|
+
target = (Path(self.cwd) / path).resolve()
|
|
281
|
+
before = _stamp(target)
|
|
282
|
+
tracked = self._get(job) if job is not None else None
|
|
283
|
+
while time.monotonic() < deadline:
|
|
284
|
+
if tracked is not None:
|
|
285
|
+
tracked.refresh()
|
|
286
|
+
size = tracked.log.stat().st_size if tracked.log.exists() else 0
|
|
287
|
+
if size > int(cursor):
|
|
288
|
+
return {"event": "output", "job": tracked.id, "start": int(cursor), "end": size}
|
|
289
|
+
if tracked.done:
|
|
290
|
+
return {"event": "ended", "job": tracked.id, "exit": tracked.exit}
|
|
291
|
+
if target is not None:
|
|
292
|
+
now = _stamp(target)
|
|
293
|
+
if now != before:
|
|
294
|
+
changed = sorted(k for k in set(now) | set(before or {})
|
|
295
|
+
if (now or {}).get(k) != (before or {}).get(k))
|
|
296
|
+
return {"event": "path", "paths": changed[:200],
|
|
297
|
+
"truncated": len(changed) > 200}
|
|
298
|
+
if inbox is not None and (messages := inbox()):
|
|
299
|
+
return {"event": "message", "messages": messages}
|
|
300
|
+
await asyncio.sleep(POLL)
|
|
301
|
+
return {"event": "timeout"}
|
|
302
|
+
|
|
303
|
+
def _watch_snapshot(self, kind, target):
|
|
304
|
+
if kind == "child":
|
|
305
|
+
if self.child_snapshot is None:
|
|
306
|
+
raise ValueError("no child watch source is attached")
|
|
307
|
+
return target, self.child_snapshot(target)
|
|
308
|
+
if kind == "job":
|
|
309
|
+
job = self._get(target)
|
|
310
|
+
return job.id, {key: value for key, value in job.summary().items()
|
|
311
|
+
if key in {"bytes", "total_bytes", "done", "exit"}}
|
|
312
|
+
root = Path(self.cwd).resolve()
|
|
313
|
+
absolute = (root / target).resolve()
|
|
314
|
+
if not absolute.is_relative_to(root):
|
|
315
|
+
raise ValueError(f"{target} is outside the workspace")
|
|
316
|
+
storage = str(self.subscriptions.storage.resolve())
|
|
317
|
+
return absolute.relative_to(root).as_posix(), {
|
|
318
|
+
Path(name).relative_to(root).as_posix(): stamp
|
|
319
|
+
for name, stamp in _stamp(absolute, follow_files=False).items() if name != storage
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async def close(self) -> None:
|
|
323
|
+
await self.subscriptions.close()
|
|
324
|
+
for job in self.jobs.values():
|
|
325
|
+
if not job.persistent:
|
|
326
|
+
self._kill(job)
|
|
327
|
+
for job in self.jobs.values():
|
|
328
|
+
if not job.persistent:
|
|
329
|
+
await self.job_wait(job.id, 30)
|
|
330
|
+
|
|
331
|
+
def functions(self, inbox=None) -> dict:
|
|
332
|
+
async def watch(job=None, cursor=0, path=None, messages=False, timeout=300.0):
|
|
333
|
+
"""Wait for a change: job=ID (new output past cursor, or its end), path=PATH (a
|
|
334
|
+
file or folder changing), messages=True (another agent writing to you)."""
|
|
335
|
+
return await self.watch(job, cursor, path, inbox if messages else None, timeout)
|
|
336
|
+
|
|
337
|
+
return {"job_start": self.job_start, "job_read": self.job_read,
|
|
338
|
+
"job_wait": self.job_wait, "job_stop": self.job_stop, "jobs": self.jobs_list,
|
|
339
|
+
"service_start": self.service_start, "watch": watch,
|
|
340
|
+
**self.subscriptions.functions()}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _stamp(target: Path, *, follow_files=True) -> dict:
|
|
344
|
+
"""Modification times under a path (one level of files for a folder, skipping noise)."""
|
|
345
|
+
if target.is_file():
|
|
346
|
+
return {str(target): target.stat().st_mtime_ns}
|
|
347
|
+
if not target.is_dir():
|
|
348
|
+
return {}
|
|
349
|
+
stamps = {}
|
|
350
|
+
for folder, dirs, files in os.walk(target):
|
|
351
|
+
dirs[:] = [d for d in dirs if d not in {".git", "node_modules", ".venv", "__pycache__"}]
|
|
352
|
+
for name in files:
|
|
353
|
+
full = Path(folder) / name
|
|
354
|
+
if not follow_files and full.is_symlink():
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
stamps[str(full)] = full.stat().st_mtime_ns
|
|
358
|
+
except OSError:
|
|
359
|
+
pass
|
|
360
|
+
if len(stamps) > 5000:
|
|
361
|
+
return stamps
|
|
362
|
+
return stamps
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = ["Job", "Jobs"]
|