pcli-agent 0.1.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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Cross-platform fallback sandbox: process + filesystem containment via a
|
|
2
|
+
cwd jail, a scrubbed environment, resource limits where the OS supports them,
|
|
3
|
+
and a wall-clock timeout as the universal safety net.
|
|
4
|
+
|
|
5
|
+
This backend does NOT provide network isolation on any platform — that
|
|
6
|
+
requires Docker. Guardrails should treat network-sensitive tool calls as
|
|
7
|
+
always-ask when this backend is active.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pcli.sandbox.base import (
|
|
21
|
+
ExecRequest,
|
|
22
|
+
ExecResult,
|
|
23
|
+
Sandbox,
|
|
24
|
+
SandboxCapabilities,
|
|
25
|
+
SandboxSecurityError,
|
|
26
|
+
)
|
|
27
|
+
from pcli.sandbox.limits import is_posix, kill_process_tree, make_posix_preexec_fn
|
|
28
|
+
|
|
29
|
+
_MAX_BACKGROUND_JOB_LIFETIME_S = 2 * 60 * 60
|
|
30
|
+
_BACKGROUND_DRAIN_CHUNK_BYTES = 4096
|
|
31
|
+
|
|
32
|
+
_ENV_ALLOW_NAMES = {
|
|
33
|
+
"PATH",
|
|
34
|
+
"HOME",
|
|
35
|
+
"USERPROFILE",
|
|
36
|
+
"SYSTEMROOT",
|
|
37
|
+
"SYSTEMDRIVE",
|
|
38
|
+
"TEMP",
|
|
39
|
+
"TMP",
|
|
40
|
+
"LANG",
|
|
41
|
+
"LC_ALL",
|
|
42
|
+
"PATHEXT",
|
|
43
|
+
"COMSPEC",
|
|
44
|
+
"PYTHONIOENCODING",
|
|
45
|
+
}
|
|
46
|
+
_ENV_DENY_SUBSTRINGS = ("_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scrub_env(extra: dict[str, str]) -> dict[str, str]:
|
|
50
|
+
env: dict[str, str] = {
|
|
51
|
+
name: value for name, value in os.environ.items() if name.upper() in _ENV_ALLOW_NAMES
|
|
52
|
+
}
|
|
53
|
+
for key, value in extra.items():
|
|
54
|
+
if any(pattern in key.upper() for pattern in _ENV_DENY_SUBSTRINGS):
|
|
55
|
+
continue
|
|
56
|
+
env[key] = value
|
|
57
|
+
return env
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_relative_to(path: Path, other: Path) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
path.relative_to(other)
|
|
63
|
+
return True
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _truncate(data: bytes, max_bytes: int) -> str:
|
|
69
|
+
if len(data) <= max_bytes:
|
|
70
|
+
return data.decode(errors="replace")
|
|
71
|
+
return data[:max_bytes].decode(errors="replace") + "\n[...output truncated...]"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _pump_stream(stream: asyncio.StreamReader, chunks: list[bytes]) -> None:
|
|
75
|
+
"""Reads a stream into `chunks` (owned by the caller) as data arrives,
|
|
76
|
+
rather than accumulating in a local variable the way proc.communicate()
|
|
77
|
+
does — a coroutine cancelled mid-read (our own timeout, or the caller
|
|
78
|
+
cancelling the awaiting task) loses whatever's only in its own locals,
|
|
79
|
+
but `chunks` already holds everything read up to that point. Capping to
|
|
80
|
+
max_output_bytes is left to _truncate() on the joined result, same as
|
|
81
|
+
before this pumped-into-a-list approach replaced proc.communicate()."""
|
|
82
|
+
while True:
|
|
83
|
+
chunk = await stream.read(_BACKGROUND_DRAIN_CHUNK_BYTES)
|
|
84
|
+
if not chunk:
|
|
85
|
+
break
|
|
86
|
+
chunks.append(chunk)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class BackgroundJob:
|
|
91
|
+
"""A command started via RestrictedSubprocessSandbox.start_background:
|
|
92
|
+
runs independently of any single tool call, with its stdout/stderr
|
|
93
|
+
continuously drained into memory (capped at max_output_bytes) so
|
|
94
|
+
read_background can report on it without blocking. stdout_read_offset/
|
|
95
|
+
stderr_read_offset track how much of each stream a caller has already
|
|
96
|
+
consumed (see read_background) — new reads only return what's arrived
|
|
97
|
+
since the last one, rather than re-sending everything or silently
|
|
98
|
+
dropping whatever fell outside a fixed tail window."""
|
|
99
|
+
|
|
100
|
+
id: str
|
|
101
|
+
command: str
|
|
102
|
+
started_at: float
|
|
103
|
+
proc: asyncio.subprocess.Process
|
|
104
|
+
stdout_chunks: list[bytes] = field(default_factory=list)
|
|
105
|
+
stderr_chunks: list[bytes] = field(default_factory=list)
|
|
106
|
+
stdout_bytes: int = 0
|
|
107
|
+
stderr_bytes: int = 0
|
|
108
|
+
stdout_read_offset: int = 0
|
|
109
|
+
stderr_read_offset: int = 0
|
|
110
|
+
exit_code: int | None = None
|
|
111
|
+
finished_at: float | None = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def running(self) -> bool:
|
|
115
|
+
return self.exit_code is None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RestrictedSubprocessSandbox(Sandbox):
|
|
119
|
+
name = "subprocess"
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
allowed_roots: list[Path] | None = None,
|
|
125
|
+
max_output_bytes: int = 2_000_000,
|
|
126
|
+
cpu_seconds: int | None = 30,
|
|
127
|
+
memory_bytes: int | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
# memory_bytes defaults to None (no RLIMIT_AS): confirmed real case
|
|
130
|
+
# - RLIMIT_AS bounds *virtual* address space, not actual usage, and
|
|
131
|
+
# Go's runtime (kubectl, terraform, most Go-based CLIs - exactly
|
|
132
|
+
# what toolbox exists to wrap) routinely reserves well past a few
|
|
133
|
+
# hundred MB of virtual space at startup regardless of real RSS, so
|
|
134
|
+
# the old 512MB default made ordinary tool invocations fail
|
|
135
|
+
# outright. RLIMIT_RSS is advisory/unenforced on modern Linux, and
|
|
136
|
+
# RLIMIT_DATA only bounds sbrk allocation, which mmap-based
|
|
137
|
+
# allocators (Go's included) bypass - neither is a real fix. This
|
|
138
|
+
# backend's actual safety net is (and always was, per limits.py's
|
|
139
|
+
# own docstring) the wall-clock timeout + kill_process_tree, the
|
|
140
|
+
# same on every platform; cpu_seconds doesn't share this false-
|
|
141
|
+
# positive problem (a large virtual reservation burns no CPU time)
|
|
142
|
+
# and stays on by default. Both are still constructor overrides -
|
|
143
|
+
# see Settings.sandbox_memory_limit_bytes/sandbox_cpu_limit_s
|
|
144
|
+
# (agent/runtime.py) - for anyone who deliberately wants a memory
|
|
145
|
+
# cap, e.g. on a constrained VM.
|
|
146
|
+
self._allowed_roots = [p.expanduser().resolve() for p in (allowed_roots or [Path.cwd()])]
|
|
147
|
+
self._max_output_bytes = max_output_bytes
|
|
148
|
+
self._cpu_seconds = cpu_seconds
|
|
149
|
+
self._memory_bytes = memory_bytes
|
|
150
|
+
self._background_jobs: dict[str, BackgroundJob] = {}
|
|
151
|
+
|
|
152
|
+
def capabilities(self) -> SandboxCapabilities:
|
|
153
|
+
posix = is_posix()
|
|
154
|
+
return SandboxCapabilities(
|
|
155
|
+
supports_network_isolation=False,
|
|
156
|
+
supports_memory_limit=posix,
|
|
157
|
+
supports_cpu_limit=posix,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _validate_cwd(self, cwd: Path) -> Path:
|
|
161
|
+
resolved = cwd.expanduser().resolve()
|
|
162
|
+
for root in self._allowed_roots:
|
|
163
|
+
if resolved == root or _is_relative_to(resolved, root):
|
|
164
|
+
return resolved
|
|
165
|
+
raise SandboxSecurityError(f"cwd '{resolved}' is outside all allowed roots")
|
|
166
|
+
|
|
167
|
+
def _popen_kwargs(self) -> dict:
|
|
168
|
+
if is_posix():
|
|
169
|
+
return {
|
|
170
|
+
"preexec_fn": make_posix_preexec_fn(
|
|
171
|
+
cpu_seconds=self._cpu_seconds, memory_bytes=self._memory_bytes
|
|
172
|
+
),
|
|
173
|
+
"start_new_session": True,
|
|
174
|
+
}
|
|
175
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
176
|
+
|
|
177
|
+
async def _start_process(
|
|
178
|
+
self, command: list[str] | str, cwd: Path, env: dict[str, str], *, needs_stdin: bool
|
|
179
|
+
) -> asyncio.subprocess.Process:
|
|
180
|
+
popen_kwargs = self._popen_kwargs()
|
|
181
|
+
stdin_mode = asyncio.subprocess.PIPE if needs_stdin else asyncio.subprocess.DEVNULL
|
|
182
|
+
if isinstance(command, str):
|
|
183
|
+
return await asyncio.create_subprocess_shell(
|
|
184
|
+
command,
|
|
185
|
+
cwd=str(cwd),
|
|
186
|
+
env=env,
|
|
187
|
+
stdin=stdin_mode,
|
|
188
|
+
stdout=asyncio.subprocess.PIPE,
|
|
189
|
+
stderr=asyncio.subprocess.PIPE,
|
|
190
|
+
**popen_kwargs,
|
|
191
|
+
)
|
|
192
|
+
return await asyncio.create_subprocess_exec(
|
|
193
|
+
*command,
|
|
194
|
+
cwd=str(cwd),
|
|
195
|
+
env=env,
|
|
196
|
+
stdin=stdin_mode,
|
|
197
|
+
stdout=asyncio.subprocess.PIPE,
|
|
198
|
+
stderr=asyncio.subprocess.PIPE,
|
|
199
|
+
**popen_kwargs,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def execute(self, request: ExecRequest) -> ExecResult:
|
|
203
|
+
cwd = self._validate_cwd(request.cwd)
|
|
204
|
+
env = _scrub_env(request.env)
|
|
205
|
+
proc = await self._start_process(
|
|
206
|
+
request.command, cwd, env, needs_stdin=request.stdin is not None
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
timed_out = False
|
|
210
|
+
stdin_bytes = request.stdin.encode() if request.stdin is not None else None
|
|
211
|
+
stdout_chunks: list[bytes] = []
|
|
212
|
+
stderr_chunks: list[bytes] = []
|
|
213
|
+
# Pumped via separate tasks (not proc.communicate()) specifically so
|
|
214
|
+
# a timeout/cancellation below still leaves whatever was already
|
|
215
|
+
# read sitting in stdout_chunks/stderr_chunks — communicate() would
|
|
216
|
+
# discard it all, since its accumulated bytes live in a coroutine
|
|
217
|
+
# local that's thrown away when the coroutine is cancelled.
|
|
218
|
+
stdout_task = asyncio.ensure_future(_pump_stream(proc.stdout, stdout_chunks))
|
|
219
|
+
stderr_task = asyncio.ensure_future(_pump_stream(proc.stderr, stderr_chunks))
|
|
220
|
+
try:
|
|
221
|
+
if stdin_bytes is not None:
|
|
222
|
+
proc.stdin.write(stdin_bytes)
|
|
223
|
+
await proc.stdin.drain()
|
|
224
|
+
if proc.stdin is not None:
|
|
225
|
+
proc.stdin.close()
|
|
226
|
+
await asyncio.wait_for(
|
|
227
|
+
asyncio.gather(stdout_task, stderr_task, proc.wait()),
|
|
228
|
+
timeout=request.timeout_s,
|
|
229
|
+
)
|
|
230
|
+
except TimeoutError:
|
|
231
|
+
timed_out = True
|
|
232
|
+
kill_process_tree(proc.pid)
|
|
233
|
+
stdout_task.cancel()
|
|
234
|
+
stderr_task.cancel()
|
|
235
|
+
try:
|
|
236
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
237
|
+
except TimeoutError:
|
|
238
|
+
pass
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
# Distinct from the TimeoutError case above: this fires when the
|
|
241
|
+
# *caller* (e.g. the TUI's Esc+Esc turn cancellation) cancels the
|
|
242
|
+
# awaiting task, not when our own wait_for's timeout expires.
|
|
243
|
+
# Without this, the subprocess is silently orphaned — cancelling
|
|
244
|
+
# the Python await here does nothing to the OS process itself.
|
|
245
|
+
kill_process_tree(proc.pid)
|
|
246
|
+
stdout_task.cancel()
|
|
247
|
+
stderr_task.cancel()
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
stdout_bytes = b"".join(stdout_chunks)
|
|
251
|
+
stderr_bytes = b"".join(stderr_chunks)
|
|
252
|
+
if timed_out:
|
|
253
|
+
note = b"[pcli] command timed out and was killed"
|
|
254
|
+
stderr_bytes = stderr_bytes + b"\n" + note if stderr_bytes else note
|
|
255
|
+
|
|
256
|
+
return ExecResult(
|
|
257
|
+
stdout=_truncate(stdout_bytes, self._max_output_bytes),
|
|
258
|
+
stderr=_truncate(stderr_bytes, self._max_output_bytes),
|
|
259
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
260
|
+
timed_out=timed_out,
|
|
261
|
+
backend_used=self.name,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# --- Background jobs: run_shell_background/read_background_output/
|
|
265
|
+
# stop_background_process (tools/builtin/shell_tool.py) sit on top of
|
|
266
|
+
# these. Deliberately not part of the Sandbox ABC (see plan) - kept here
|
|
267
|
+
# so DockerSandbox/NullSandbox don't need to implement something they
|
|
268
|
+
# can't meaningfully support yet; callers duck-type-check
|
|
269
|
+
# isinstance(sandbox, RestrictedSubprocessSandbox).
|
|
270
|
+
|
|
271
|
+
async def _pump_background_stream(
|
|
272
|
+
self, stream: asyncio.StreamReader, job: BackgroundJob, attr: str
|
|
273
|
+
) -> None:
|
|
274
|
+
chunks: list[bytes] = getattr(job, f"{attr}_chunks")
|
|
275
|
+
while True:
|
|
276
|
+
chunk = await stream.read(_BACKGROUND_DRAIN_CHUNK_BYTES)
|
|
277
|
+
if not chunk:
|
|
278
|
+
break
|
|
279
|
+
current = getattr(job, f"{attr}_bytes")
|
|
280
|
+
if current < self._max_output_bytes:
|
|
281
|
+
chunks.append(chunk[: self._max_output_bytes - current])
|
|
282
|
+
setattr(job, f"{attr}_bytes", current + len(chunk))
|
|
283
|
+
|
|
284
|
+
async def _drain_background(self, job: BackgroundJob) -> None:
|
|
285
|
+
await asyncio.gather(
|
|
286
|
+
self._pump_background_stream(job.proc.stdout, job, "stdout"),
|
|
287
|
+
self._pump_background_stream(job.proc.stderr, job, "stderr"),
|
|
288
|
+
)
|
|
289
|
+
job.exit_code = await job.proc.wait()
|
|
290
|
+
job.finished_at = time.monotonic()
|
|
291
|
+
|
|
292
|
+
def _sweep_background_jobs(self, *, keep_finished: int = 20) -> None:
|
|
293
|
+
now = time.monotonic()
|
|
294
|
+
for job in list(self._background_jobs.values()):
|
|
295
|
+
if job.running and now - job.started_at > _MAX_BACKGROUND_JOB_LIFETIME_S:
|
|
296
|
+
kill_process_tree(job.proc.pid)
|
|
297
|
+
finished = sorted(
|
|
298
|
+
(j for j in self._background_jobs.values() if not j.running),
|
|
299
|
+
key=lambda j: j.finished_at or 0.0,
|
|
300
|
+
)
|
|
301
|
+
while len(self._background_jobs) > keep_finished and finished:
|
|
302
|
+
del self._background_jobs[finished.pop(0).id]
|
|
303
|
+
|
|
304
|
+
async def start_background(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
command: list[str] | str,
|
|
308
|
+
cwd: Path,
|
|
309
|
+
env: dict[str, str] | None = None,
|
|
310
|
+
max_jobs: int = 5,
|
|
311
|
+
) -> str:
|
|
312
|
+
self._sweep_background_jobs()
|
|
313
|
+
running = sum(1 for job in self._background_jobs.values() if job.running)
|
|
314
|
+
if running >= max_jobs:
|
|
315
|
+
raise SandboxSecurityError(
|
|
316
|
+
f"Already {running} background job(s) running (max {max_jobs}) — stop one "
|
|
317
|
+
"with stop_background_process before starting another."
|
|
318
|
+
)
|
|
319
|
+
resolved_cwd = self._validate_cwd(cwd)
|
|
320
|
+
scrubbed_env = _scrub_env(env or {})
|
|
321
|
+
proc = await self._start_process(command, resolved_cwd, scrubbed_env, needs_stdin=False)
|
|
322
|
+
job_id = uuid.uuid4().hex[:8]
|
|
323
|
+
command_str = command if isinstance(command, str) else " ".join(command)
|
|
324
|
+
job = BackgroundJob(id=job_id, command=command_str, started_at=time.monotonic(), proc=proc)
|
|
325
|
+
self._background_jobs[job_id] = job
|
|
326
|
+
asyncio.create_task(self._drain_background(job))
|
|
327
|
+
return job_id
|
|
328
|
+
|
|
329
|
+
def get_background(self, job_id: str) -> BackgroundJob | None:
|
|
330
|
+
return self._background_jobs.get(job_id)
|
|
331
|
+
|
|
332
|
+
def read_background(
|
|
333
|
+
self, job_id: str, *, max_chars: int = 4000, reset: bool = False
|
|
334
|
+
) -> tuple[BackgroundJob, str, str, bool] | None:
|
|
335
|
+
"""Returns (job, new_stdout, new_stderr, has_more): only the text
|
|
336
|
+
that arrived since the caller's last read (or from the start, if
|
|
337
|
+
reset or this is the first read), capped to max_chars per stream
|
|
338
|
+
per call — offset-based rather than tail-based, so nothing in the
|
|
339
|
+
middle is silently dropped between polls on a chatty command; a
|
|
340
|
+
caller wanting more just calls again (has_more says so) instead of
|
|
341
|
+
requesting a bigger max_chars, so one poll can't flood context."""
|
|
342
|
+
job = self._background_jobs.get(job_id)
|
|
343
|
+
if job is None:
|
|
344
|
+
return None
|
|
345
|
+
if reset:
|
|
346
|
+
job.stdout_read_offset = 0
|
|
347
|
+
job.stderr_read_offset = 0
|
|
348
|
+
full_stdout = b"".join(job.stdout_chunks).decode(errors="replace")
|
|
349
|
+
full_stderr = b"".join(job.stderr_chunks).decode(errors="replace")
|
|
350
|
+
new_stdout = full_stdout[job.stdout_read_offset :]
|
|
351
|
+
new_stderr = full_stderr[job.stderr_read_offset :]
|
|
352
|
+
stdout_chunk = new_stdout[:max_chars]
|
|
353
|
+
stderr_chunk = new_stderr[:max_chars]
|
|
354
|
+
job.stdout_read_offset += len(stdout_chunk)
|
|
355
|
+
job.stderr_read_offset += len(stderr_chunk)
|
|
356
|
+
has_more = len(new_stdout) > len(stdout_chunk) or len(new_stderr) > len(stderr_chunk)
|
|
357
|
+
return job, stdout_chunk, stderr_chunk, has_more
|
|
358
|
+
|
|
359
|
+
async def stop_background(self, job_id: str) -> bool:
|
|
360
|
+
job = self._background_jobs.get(job_id)
|
|
361
|
+
if job is None:
|
|
362
|
+
return False
|
|
363
|
+
if job.running:
|
|
364
|
+
kill_process_tree(job.proc.pid)
|
|
365
|
+
try:
|
|
366
|
+
await asyncio.wait_for(job.proc.wait(), timeout=5)
|
|
367
|
+
except TimeoutError:
|
|
368
|
+
pass
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
async def kill_all_background_jobs(self) -> None:
|
|
372
|
+
"""Called from ChatScreen.on_unmount so a background job never
|
|
373
|
+
outlives the TUI process it was started from."""
|
|
374
|
+
for job in list(self._background_jobs.values()):
|
|
375
|
+
if job.running:
|
|
376
|
+
kill_process_tree(job.proc.pid)
|
|
File without changes
|
pcli/scheduler/daemon.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The long-running loop behind `pcli schedule run` - the third kind of
|
|
2
|
+
daemon alongside `pcli telegram`'s (telegram/daemon.py) and the browser
|
|
3
|
+
session's own lazy lifecycle. Ticks on a plain poll interval (no OS-level
|
|
4
|
+
cron integration, no extra process) - see scheduler/models.py's ScheduleJob
|
|
5
|
+
for what a job stores and scheduler/runner.py's run_task_once for how one
|
|
6
|
+
actually executes, shared with `pcli run`.
|
|
7
|
+
|
|
8
|
+
croniter is imported lazily inside the two functions that need it, not at
|
|
9
|
+
this module's top level, so `import pcli.scheduler.daemon` itself doesn't
|
|
10
|
+
require the `schedule` extra to be installed - only actually computing a
|
|
11
|
+
next-run time does (mirrors browser/session.py's lazy playwright import and
|
|
12
|
+
telegram/sender.py's lazy python-telegram-bot import)."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pcli.agent.headless import HeadlessTurnResult
|
|
23
|
+
from pcli.config.settings import Settings
|
|
24
|
+
from pcli.scheduler.models import ScheduleJob
|
|
25
|
+
from pcli.scheduler.runner import ScheduledSessionNotFoundError, run_task_once
|
|
26
|
+
from pcli.scheduler.store import read_schedule, update_job
|
|
27
|
+
from pcli.scheduler.triggers import compute_path_signature, get_git_head_sha
|
|
28
|
+
from pcli.session.store import SessionStore
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
OnTickCallback = Callable[[], None]
|
|
33
|
+
OnJobRunCallback = Callable[[ScheduleJob, "HeadlessTurnResult | None", Exception | None], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compute_next_run(cron: str, *, base: datetime) -> datetime:
|
|
37
|
+
from croniter import croniter
|
|
38
|
+
|
|
39
|
+
return croniter(cron, base).get_next(datetime)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def run_scheduler_daemon(
|
|
43
|
+
settings: Settings,
|
|
44
|
+
*,
|
|
45
|
+
poll_interval_s: int = 30,
|
|
46
|
+
on_tick: OnTickCallback | None = None,
|
|
47
|
+
on_job_run: OnJobRunCallback | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Runs until cancelled (Ctrl+C from `pcli schedule run`, or the caller
|
|
50
|
+
cancelling the task). Each tick re-reads schedule.json fresh (see
|
|
51
|
+
scheduler/store.py's read_schedule), so `pcli schedule add`/`remove`/
|
|
52
|
+
`enable`/`disable` run from another process take effect on the very
|
|
53
|
+
next tick without restarting this daemon. Jobs run sequentially within
|
|
54
|
+
a tick - simple, predictable, and avoids overlapping runs of possibly-
|
|
55
|
+
related scheduled tasks competing for the same sandbox/session."""
|
|
56
|
+
store = SessionStore()
|
|
57
|
+
cwd = Path.cwd()
|
|
58
|
+
while True:
|
|
59
|
+
await _run_due_jobs(settings, store, cwd, on_job_run=on_job_run)
|
|
60
|
+
if on_tick is not None:
|
|
61
|
+
on_tick()
|
|
62
|
+
await asyncio.sleep(poll_interval_s)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cron_due(job: ScheduleJob, now: datetime) -> bool:
|
|
66
|
+
if job.next_run_at is None:
|
|
67
|
+
assert job.cron is not None # enforced by ScheduleJob's validator
|
|
68
|
+
job.next_run_at = compute_next_run(job.cron, base=now)
|
|
69
|
+
update_job(job)
|
|
70
|
+
return False
|
|
71
|
+
return job.next_run_at <= now
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _current_event_signature(job: ScheduleJob, cwd: Path) -> str | None:
|
|
75
|
+
if job.trigger == "file_change":
|
|
76
|
+
assert job.watch_path is not None # enforced by ScheduleJob's validator
|
|
77
|
+
return compute_path_signature(Path(job.watch_path))
|
|
78
|
+
assert job.trigger == "git_commit"
|
|
79
|
+
repo = Path(job.watch_git_repo) if job.watch_git_repo else cwd
|
|
80
|
+
return get_git_head_sha(repo, job.watch_git_branch)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _event_due(job: ScheduleJob, cwd: Path) -> bool:
|
|
84
|
+
signature = _current_event_signature(job, cwd)
|
|
85
|
+
if signature is None:
|
|
86
|
+
# A git lookup failed this poll (repo not there yet, git missing,
|
|
87
|
+
# ...) - already logged in triggers.py. Just skip and retry next
|
|
88
|
+
# tick rather than marking the job an error.
|
|
89
|
+
return False
|
|
90
|
+
if job.last_seen_state is None:
|
|
91
|
+
# First time this job's been seen by any poll - baseline without
|
|
92
|
+
# firing, the same "compute and skip" first-tick behavior cron
|
|
93
|
+
# jobs get above, so adding a job against an already-existing
|
|
94
|
+
# file/commit doesn't fire immediately.
|
|
95
|
+
job.last_seen_state = signature
|
|
96
|
+
update_job(job)
|
|
97
|
+
return False
|
|
98
|
+
return signature != job.last_seen_state
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rebaseline_event_job(job: ScheduleJob, cwd: Path) -> None:
|
|
102
|
+
"""Re-reads the current signature *after* the job's own run finished
|
|
103
|
+
and stores that as the new baseline - deliberately not the signature
|
|
104
|
+
that triggered the run. Otherwise a task that itself edits the watched
|
|
105
|
+
path, or commits to the watched branch (a very plausible case - e.g. a
|
|
106
|
+
task that auto-commits its own changes), would see its own output as
|
|
107
|
+
"one more change" on the very next poll and fire again indefinitely.
|
|
108
|
+
Baselining after the run means only changes introduced *after* the
|
|
109
|
+
job's own run completed count as the next trigger."""
|
|
110
|
+
signature = _current_event_signature(job, cwd)
|
|
111
|
+
if signature is not None:
|
|
112
|
+
job.last_seen_state = signature
|
|
113
|
+
update_job(job)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _run_due_jobs(
|
|
117
|
+
settings: Settings,
|
|
118
|
+
store: SessionStore,
|
|
119
|
+
cwd: Path,
|
|
120
|
+
*,
|
|
121
|
+
on_job_run: OnJobRunCallback | None,
|
|
122
|
+
) -> None:
|
|
123
|
+
now = datetime.now(UTC)
|
|
124
|
+
for job in read_schedule().jobs:
|
|
125
|
+
if not job.enabled:
|
|
126
|
+
continue
|
|
127
|
+
is_due = _cron_due(job, now) if job.trigger == "cron" else _event_due(job, cwd)
|
|
128
|
+
if not is_due:
|
|
129
|
+
continue
|
|
130
|
+
await _run_one_job(job, settings, store, cwd, on_job_run=on_job_run)
|
|
131
|
+
if job.trigger != "cron":
|
|
132
|
+
_rebaseline_event_job(job, cwd)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _run_one_job(
|
|
136
|
+
job: ScheduleJob,
|
|
137
|
+
settings: Settings,
|
|
138
|
+
store: SessionStore,
|
|
139
|
+
cwd: Path,
|
|
140
|
+
*,
|
|
141
|
+
on_job_run: OnJobRunCallback | None,
|
|
142
|
+
) -> None:
|
|
143
|
+
task = job.task
|
|
144
|
+
if job.task_file:
|
|
145
|
+
task = Path(job.task_file).read_text(encoding="utf-8")
|
|
146
|
+
assert task is not None # ScheduleJob guarantees exactly one of task/task_file is set
|
|
147
|
+
|
|
148
|
+
job_settings = settings
|
|
149
|
+
if job.max_cost_usd is not None:
|
|
150
|
+
# A local override for just this job's own runs, same reasoning as
|
|
151
|
+
# `pcli run --max-cost` (cli.py's run_command) - never mutates the
|
|
152
|
+
# shared settings object other jobs/the daemon loop itself use.
|
|
153
|
+
job_settings = settings.model_copy(update={"max_session_cost_usd": job.max_cost_usd})
|
|
154
|
+
if job.audit:
|
|
155
|
+
job_settings = job_settings.model_copy(update={"audit_mode_enabled": True})
|
|
156
|
+
|
|
157
|
+
result: HeadlessTurnResult | None = None
|
|
158
|
+
error: Exception | None = None
|
|
159
|
+
try:
|
|
160
|
+
result = await run_task_once(
|
|
161
|
+
task,
|
|
162
|
+
settings=job_settings,
|
|
163
|
+
store=store,
|
|
164
|
+
cwd=cwd,
|
|
165
|
+
session_id=job.session_id,
|
|
166
|
+
quiet=job.quiet,
|
|
167
|
+
headed=job.headed,
|
|
168
|
+
notify_telegram_flag=job.notify_telegram,
|
|
169
|
+
)
|
|
170
|
+
job.last_status = "ok"
|
|
171
|
+
job.last_error = None
|
|
172
|
+
except ScheduledSessionNotFoundError as exc:
|
|
173
|
+
error = exc
|
|
174
|
+
job.last_status = "error"
|
|
175
|
+
job.last_error = f"session '{job.session_id}' no longer exists"
|
|
176
|
+
logger.error(
|
|
177
|
+
"Scheduled job %s (%s): %s", job.id, job.name or job.cron or job.trigger, job.last_error
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc: # one job failing must not kill the daemon loop
|
|
180
|
+
error = exc
|
|
181
|
+
job.last_status = "error"
|
|
182
|
+
job.last_error = str(exc)
|
|
183
|
+
logger.exception(
|
|
184
|
+
"Scheduled job %s (%s) failed", job.id, job.name or job.cron or job.trigger
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
job.last_run_at = datetime.now(UTC)
|
|
188
|
+
if job.trigger == "cron":
|
|
189
|
+
assert job.cron is not None # enforced by ScheduleJob's validator
|
|
190
|
+
job.next_run_at = compute_next_run(job.cron, base=job.last_run_at)
|
|
191
|
+
update_job(job)
|
|
192
|
+
|
|
193
|
+
if on_job_run is not None:
|
|
194
|
+
on_job_run(job, result, error)
|
pcli/scheduler/models.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Data model for `pcli schedule`'s crontab-like recurring task scheduling -
|
|
2
|
+
see scheduler/store.py for persistence, scheduler/runner.py for how a job
|
|
3
|
+
actually executes (shared with `pcli run`), and scheduler/daemon.py for the
|
|
4
|
+
loop that decides when a job is due."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, model_validator
|
|
12
|
+
|
|
13
|
+
from pcli.util.ids import new_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _utcnow() -> datetime:
|
|
17
|
+
return datetime.now(UTC)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScheduleJob(BaseModel):
|
|
21
|
+
id: str = Field(default_factory=lambda: new_id("job_"))
|
|
22
|
+
name: str = ""
|
|
23
|
+
trigger: Literal["cron", "file_change", "git_commit"] = "cron"
|
|
24
|
+
"""Defaults to "cron" so every job already persisted in an existing
|
|
25
|
+
schedule.json (written before this field existed) still validates and
|
|
26
|
+
behaves exactly as before - see the model_validator below for what
|
|
27
|
+
each trigger kind requires."""
|
|
28
|
+
cron: str | None = None
|
|
29
|
+
"""Standard 5-field cron expression (minute hour day month weekday),
|
|
30
|
+
e.g. "*/15 * * * *". Required (and validated via croniter.is_valid
|
|
31
|
+
before a job is ever saved - see scheduler/store.py's add_job) when
|
|
32
|
+
trigger == "cron"; otherwise unused."""
|
|
33
|
+
watch_path: str | None = None
|
|
34
|
+
"""trigger == "file_change" only: a file or directory (watched
|
|
35
|
+
recursively) whose contents/mtimes are polled for changes - see
|
|
36
|
+
scheduler/triggers.py's compute_path_signature."""
|
|
37
|
+
watch_git_repo: str | None = None
|
|
38
|
+
"""trigger == "git_commit" only: path to the repo to watch, defaulting
|
|
39
|
+
to the daemon's own cwd if unset."""
|
|
40
|
+
watch_git_branch: str | None = None
|
|
41
|
+
"""trigger == "git_commit" only: branch to watch, defaulting to
|
|
42
|
+
whatever's currently checked out (bare HEAD) if unset."""
|
|
43
|
+
last_seen_state: str | None = None
|
|
44
|
+
"""trigger in ("file_change", "git_commit") only: the signature/commit
|
|
45
|
+
SHA last observed by the daemon (scheduler/triggers.py), used to
|
|
46
|
+
detect a change on the next poll. None means "never polled yet" - the
|
|
47
|
+
daemon baselines it on first sight without firing, the same way a
|
|
48
|
+
cron job's unset next_run_at is computed-then-skipped on first sight
|
|
49
|
+
rather than firing immediately."""
|
|
50
|
+
task: str | None = None
|
|
51
|
+
task_file: str | None = None
|
|
52
|
+
"""Exactly one of task/task_file is set - mirrors `pcli run`'s own
|
|
53
|
+
--task/--task-file mutual exclusivity."""
|
|
54
|
+
session_id: str | None = None
|
|
55
|
+
"""Append to this one continuing session on every run, or None for a
|
|
56
|
+
fresh session each time - mirrors `pcli run --session`."""
|
|
57
|
+
headed: bool = False
|
|
58
|
+
notify_telegram: bool = False
|
|
59
|
+
quiet: bool = True
|
|
60
|
+
max_cost_usd: float | None = None
|
|
61
|
+
"""Per-job hard spend cap (USD), overriding Settings.max_session_cost_usd
|
|
62
|
+
for just this job's own runs - mirrors `pcli run --max-cost`, applied the
|
|
63
|
+
same way (scheduler/daemon.py's _run_one_job): never persisted to
|
|
64
|
+
config.toml, never affects other jobs or the TUI. None (the default)
|
|
65
|
+
means this job falls back to whatever max_session_cost_usd is already
|
|
66
|
+
configured (unset by default - no cap)."""
|
|
67
|
+
audit: bool = False
|
|
68
|
+
"""Per-job override of Settings.audit_mode_enabled, applied the same
|
|
69
|
+
way as max_cost_usd above - mirrors `pcli run --audit`. False (the
|
|
70
|
+
default) means this job falls back to whatever audit_mode_enabled is
|
|
71
|
+
already configured (off by default)."""
|
|
72
|
+
enabled: bool = True
|
|
73
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
74
|
+
next_run_at: datetime | None = None
|
|
75
|
+
"""Computed lazily by the daemon on first seeing a job with this unset
|
|
76
|
+
(or after it fires) via croniter(cron, base=...).get_next(datetime) -
|
|
77
|
+
not computed at job-creation time so a job added while the daemon isn't
|
|
78
|
+
running doesn't carry a stale "next run" from whenever `schedule add`
|
|
79
|
+
happened to execute."""
|
|
80
|
+
last_run_at: datetime | None = None
|
|
81
|
+
last_status: str | None = None
|
|
82
|
+
""""ok" or "error" after the most recent run; None if it has never run."""
|
|
83
|
+
last_error: str | None = None
|
|
84
|
+
|
|
85
|
+
@model_validator(mode="after")
|
|
86
|
+
def _validate_trigger_fields(self) -> ScheduleJob:
|
|
87
|
+
if self.trigger == "cron" and not self.cron:
|
|
88
|
+
raise ValueError("trigger 'cron' requires cron to be set.")
|
|
89
|
+
if self.trigger == "file_change" and not self.watch_path:
|
|
90
|
+
raise ValueError("trigger 'file_change' requires watch_path to be set.")
|
|
91
|
+
# "git_commit" has no required field: watch_git_repo/watch_git_branch
|
|
92
|
+
# both default sensibly (daemon's cwd / whatever's checked out).
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ScheduleStore(BaseModel):
|
|
97
|
+
jobs: list[ScheduleJob] = Field(default_factory=list)
|