pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/jobs.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""Session-scoped registry of shell jobs that outlive the agent run.
|
|
2
|
+
|
|
3
|
+
One execution mechanism, two ways to wait. Every command is launched the same
|
|
4
|
+
way -- a detached supervisor in its own session, writing a combined output log
|
|
5
|
+
and an atomic status file -- so "foreground" and "background" differ only in
|
|
6
|
+
whether the caller waits for the result, not in how the process runs. That is
|
|
7
|
+
what makes the interesting transitions cheap: a wait can be abandoned when the
|
|
8
|
+
user interrupts with a follow-up without disturbing the command, and a
|
|
9
|
+
backgrounded job can be waited on later without re-running anything.
|
|
10
|
+
|
|
11
|
+
Ownership is deliberately here rather than in the tool call: the tool call is
|
|
12
|
+
the thing that gets cancelled. A registry that outlives it can still say what
|
|
13
|
+
is running, deliver exits without the model polling, and clean up logs.
|
|
14
|
+
|
|
15
|
+
The registry also outlives pcode. Its state lives under the XDG state dir, one
|
|
16
|
+
directory per pcode process holding the job logs and a `registry.json`, so a
|
|
17
|
+
later pcode can adopt whatever a dead one left running instead of the user
|
|
18
|
+
hunting for it with `ps`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
from contextvars import ContextVar
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# Logs of finished jobs are kept so the model can re-read output it already
|
|
39
|
+
# summarized, but not forever: each job owns a directory, and a long session
|
|
40
|
+
# runs hundreds. Evicting the oldest finished job bounds the disk cost without
|
|
41
|
+
# ever deleting a log while its command is still writing to it.
|
|
42
|
+
RETAINED_FINISHED_JOBS = 50
|
|
43
|
+
|
|
44
|
+
# The model-visible tail of a job's log. Matches the window Harness used, which
|
|
45
|
+
# is sized for a tool result rather than for the whole log.
|
|
46
|
+
OUTPUT_TAIL_BYTES = 16_000
|
|
47
|
+
|
|
48
|
+
# A stop is SIGTERM first, so a server can release its port and a migration can
|
|
49
|
+
# roll back; only a command that ignores it for this long is killed.
|
|
50
|
+
STOP_GRACE_SECONDS = 2.0
|
|
51
|
+
|
|
52
|
+
_SUPERVISOR = Path(__file__).with_name("_job_supervisor.py")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Command-preview keys for a watched job (`/jobs watch`), so the preview can
|
|
56
|
+
# be shown for it even when the model's own commands are hidden.
|
|
57
|
+
WATCHED_PREFIX = "job:"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def jobs_root() -> Path:
|
|
61
|
+
"""Where every pcode process keeps its job logs and registry record."""
|
|
62
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
63
|
+
return state / "pcode" / "jobs"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_duration(seconds: float) -> str:
|
|
67
|
+
if seconds < 1:
|
|
68
|
+
return f"{seconds * 1000:.0f}ms"
|
|
69
|
+
if seconds < 60:
|
|
70
|
+
return f"{seconds:.1f}s"
|
|
71
|
+
minutes, rest = divmod(int(seconds), 60)
|
|
72
|
+
if minutes < 60:
|
|
73
|
+
return f"{minutes}m{rest:02d}s"
|
|
74
|
+
hours, minutes = divmod(minutes, 60)
|
|
75
|
+
return f"{hours}h{minutes:02d}m"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _clip(text: str, limit: int) -> str:
|
|
79
|
+
text = " ".join(text.split())
|
|
80
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Job:
|
|
85
|
+
"""One launched command and everything known about it without re-reading disk."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
command: str
|
|
89
|
+
directory: Path
|
|
90
|
+
supervisor_pid: int
|
|
91
|
+
started_at: float
|
|
92
|
+
background: bool
|
|
93
|
+
# Why the model ran it, in its own words, for jobs it expects to come back
|
|
94
|
+
# to. Empty for ordinary foreground commands, where the command is read
|
|
95
|
+
# next to its own result and a label would only repeat it.
|
|
96
|
+
purpose: str = ""
|
|
97
|
+
pid: int | None = None
|
|
98
|
+
exit_code: int | None = None
|
|
99
|
+
ended_at: float | None = None
|
|
100
|
+
stopped: bool = False
|
|
101
|
+
# Set when a foreground wait was abandoned rather than completed, so the
|
|
102
|
+
# next report can say why the model is holding a handle it did not ask for.
|
|
103
|
+
detached: bool = False
|
|
104
|
+
# Inherited from a pcode process that exited while this command ran. The
|
|
105
|
+
# model of this session never launched it, so it is never woken for it.
|
|
106
|
+
adopted: bool = False
|
|
107
|
+
# True while a tool call is blocking on this job. The terminal uses it to
|
|
108
|
+
# tell "in the background" from "what the live row already shows".
|
|
109
|
+
waiting: bool = False
|
|
110
|
+
announced: set[str] = field(default_factory=set)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def output_path(self) -> Path:
|
|
114
|
+
return self.directory / "output.log"
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def status_path(self) -> Path:
|
|
118
|
+
return self.directory / "status.json"
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def running(self) -> bool:
|
|
122
|
+
return self.exit_code is None and not self.stopped
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def elapsed(self) -> float:
|
|
126
|
+
return (self.ended_at or time.time()) - self.started_at
|
|
127
|
+
|
|
128
|
+
def outcome(self) -> str:
|
|
129
|
+
if self.stopped:
|
|
130
|
+
return "stopped"
|
|
131
|
+
if self.exit_code is None:
|
|
132
|
+
return "running"
|
|
133
|
+
return f"exit {self.exit_code}"
|
|
134
|
+
|
|
135
|
+
def label(self) -> str:
|
|
136
|
+
"""The shortest honest name for this job: its purpose, else its command."""
|
|
137
|
+
return _clip(self.purpose or self.command, 80)
|
|
138
|
+
|
|
139
|
+
def summary(self) -> str:
|
|
140
|
+
"""One line for the inventory view, where you decide what to stop.
|
|
141
|
+
|
|
142
|
+
Unlike `label`, this keeps the command even when a purpose exists: a
|
|
143
|
+
stated intention is not evidence of what is actually running.
|
|
144
|
+
"""
|
|
145
|
+
detail = self.label()
|
|
146
|
+
if self.purpose:
|
|
147
|
+
detail += f" · {_clip(self.command, 60)}"
|
|
148
|
+
line = f"[{self.id}] {detail} → {self.outcome()} · {format_duration(self.elapsed)}"
|
|
149
|
+
if self.adopted:
|
|
150
|
+
line += " · adopted from an earlier pcode"
|
|
151
|
+
return line
|
|
152
|
+
|
|
153
|
+
def record(self) -> dict:
|
|
154
|
+
"""What a later pcode needs to adopt this job: where it is, not what it said."""
|
|
155
|
+
return {
|
|
156
|
+
"command": self.command,
|
|
157
|
+
"directory": str(self.directory),
|
|
158
|
+
"supervisor_pid": self.supervisor_pid,
|
|
159
|
+
"started_at": self.started_at,
|
|
160
|
+
"purpose": self.purpose,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class JobRegistry:
|
|
165
|
+
"""Launches jobs, tracks their exits, and owns their log directories."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
retain: int = RETAINED_FINISHED_JOBS,
|
|
171
|
+
state: Callable[[], Path] | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
self.jobs: dict[str, Job] = {}
|
|
174
|
+
self._retain = retain
|
|
175
|
+
# Resolved lazily: the state dir comes from the environment, which
|
|
176
|
+
# tests change after the process-wide registry has been created.
|
|
177
|
+
self._state = state
|
|
178
|
+
self._home: Path | None = None
|
|
179
|
+
self._counter = 0
|
|
180
|
+
self._waited: set[str] = set()
|
|
181
|
+
# Jobs sent SIGTERM, by the time they get SIGKILL instead.
|
|
182
|
+
self._terminating: dict[str, float] = {}
|
|
183
|
+
# Ctrl+C means "stop what you are doing"; a typed follow-up means "stop
|
|
184
|
+
# waiting, keep working". Only an abandoned wait consults this, so a
|
|
185
|
+
# job the model explicitly backgrounded is never caught by either.
|
|
186
|
+
self.cancel_policy = "detach"
|
|
187
|
+
# Bumped by `release_waits`. A wait that sees it change hands back a
|
|
188
|
+
# handle instead of blocking on to its timeout.
|
|
189
|
+
self.release_generation = 0
|
|
190
|
+
|
|
191
|
+
def launch(
|
|
192
|
+
self,
|
|
193
|
+
command: str,
|
|
194
|
+
*,
|
|
195
|
+
cwd: Path,
|
|
196
|
+
env: Mapping[str, str] | None = None,
|
|
197
|
+
background: bool = False,
|
|
198
|
+
purpose: str = "",
|
|
199
|
+
) -> Job:
|
|
200
|
+
self._counter += 1
|
|
201
|
+
home = self._directory()
|
|
202
|
+
if home is None:
|
|
203
|
+
directory = Path(tempfile.mkdtemp(prefix="pcode-job-"))
|
|
204
|
+
else:
|
|
205
|
+
directory = home / f"j{self._counter}"
|
|
206
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
try:
|
|
208
|
+
sandbox = COMMAND_SANDBOX.get()
|
|
209
|
+
prefix = [] if sandbox is None else sandbox(directory)
|
|
210
|
+
process = subprocess.Popen(
|
|
211
|
+
[*prefix, sys.executable, str(_SUPERVISOR), str(directory), command],
|
|
212
|
+
cwd=cwd,
|
|
213
|
+
env=None if env is None else dict(env),
|
|
214
|
+
stdin=subprocess.DEVNULL,
|
|
215
|
+
stdout=subprocess.DEVNULL,
|
|
216
|
+
stderr=subprocess.DEVNULL,
|
|
217
|
+
start_new_session=True,
|
|
218
|
+
)
|
|
219
|
+
except BaseException:
|
|
220
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
221
|
+
raise
|
|
222
|
+
# Reap the supervisor without tying the command's lifetime to this
|
|
223
|
+
# event loop: the thread is a daemon and only calls wait().
|
|
224
|
+
threading.Thread(target=process.wait, daemon=True).start()
|
|
225
|
+
job = Job(
|
|
226
|
+
id=f"j{self._counter}",
|
|
227
|
+
command=command,
|
|
228
|
+
directory=directory,
|
|
229
|
+
supervisor_pid=process.pid,
|
|
230
|
+
started_at=time.time(),
|
|
231
|
+
background=background,
|
|
232
|
+
purpose=" ".join(purpose.split()),
|
|
233
|
+
)
|
|
234
|
+
self.jobs[job.id] = job
|
|
235
|
+
self._save()
|
|
236
|
+
return job
|
|
237
|
+
|
|
238
|
+
def _directory(self) -> Path | None:
|
|
239
|
+
"""This process's own directory under the state root, or None when ephemeral."""
|
|
240
|
+
if self._state is None:
|
|
241
|
+
return None
|
|
242
|
+
if self._home is None:
|
|
243
|
+
self._home = self._state() / str(os.getpid())
|
|
244
|
+
self._home.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
return self._home
|
|
246
|
+
|
|
247
|
+
def home(self) -> Path | None:
|
|
248
|
+
"""Where this registry publishes its jobs (`registry.json`), or None when ephemeral."""
|
|
249
|
+
return self._directory()
|
|
250
|
+
|
|
251
|
+
def _save(self) -> None:
|
|
252
|
+
home = self._directory()
|
|
253
|
+
if home is None:
|
|
254
|
+
return
|
|
255
|
+
record = {
|
|
256
|
+
"owner_pid": os.getpid(),
|
|
257
|
+
"jobs": {job.id: job.record() for job in self.jobs.values()},
|
|
258
|
+
}
|
|
259
|
+
pending = home / "registry.tmp"
|
|
260
|
+
pending.write_text(json.dumps(record), encoding="utf-8")
|
|
261
|
+
pending.replace(home / "registry.json")
|
|
262
|
+
|
|
263
|
+
def adopt_orphans(self) -> list[Job]:
|
|
264
|
+
"""Take over the running jobs of pcode processes that are gone.
|
|
265
|
+
|
|
266
|
+
Finished orphans are only logs nobody will read; their directories go
|
|
267
|
+
with the dead process's record. A running one gets a fresh id here and
|
|
268
|
+
keeps its directory, because its supervisor is still writing there.
|
|
269
|
+
"""
|
|
270
|
+
if self._state is None:
|
|
271
|
+
return []
|
|
272
|
+
root = self._state()
|
|
273
|
+
if not root.is_dir():
|
|
274
|
+
return []
|
|
275
|
+
adopted = []
|
|
276
|
+
for home in sorted(root.iterdir()):
|
|
277
|
+
if home.name == str(os.getpid()) or not home.is_dir():
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
record = json.loads((home / "registry.json").read_text(encoding="utf-8"))
|
|
281
|
+
except (OSError, ValueError):
|
|
282
|
+
continue
|
|
283
|
+
owner = record.get("owner_pid") if isinstance(record, dict) else None
|
|
284
|
+
if isinstance(owner, int) and _alive(owner):
|
|
285
|
+
continue
|
|
286
|
+
entries = record.get("jobs", {})
|
|
287
|
+
kept = set()
|
|
288
|
+
for entry in entries.values() if isinstance(entries, dict) else ():
|
|
289
|
+
job = self._adopt(entry)
|
|
290
|
+
if job is not None:
|
|
291
|
+
adopted.append(job)
|
|
292
|
+
kept.add(job.directory.resolve())
|
|
293
|
+
for child in home.iterdir():
|
|
294
|
+
if child.is_dir() and child.resolve() not in kept:
|
|
295
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
296
|
+
elif child.is_file():
|
|
297
|
+
child.unlink(missing_ok=True)
|
|
298
|
+
_remove_if_empty(home)
|
|
299
|
+
if adopted:
|
|
300
|
+
self._save()
|
|
301
|
+
return adopted
|
|
302
|
+
|
|
303
|
+
def _adopt(self, entry: object) -> Job | None:
|
|
304
|
+
if not isinstance(entry, dict):
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
directory = Path(entry["directory"])
|
|
308
|
+
supervisor_pid = int(entry["supervisor_pid"])
|
|
309
|
+
command = str(entry["command"])
|
|
310
|
+
started_at = float(entry["started_at"])
|
|
311
|
+
except (KeyError, TypeError, ValueError):
|
|
312
|
+
return None
|
|
313
|
+
if not directory.is_dir() or not _alive(supervisor_pid):
|
|
314
|
+
return None
|
|
315
|
+
self._counter += 1
|
|
316
|
+
job = Job(
|
|
317
|
+
id=f"j{self._counter}",
|
|
318
|
+
command=command,
|
|
319
|
+
directory=directory,
|
|
320
|
+
supervisor_pid=supervisor_pid,
|
|
321
|
+
started_at=started_at,
|
|
322
|
+
# Nothing here is waiting on it, which is what background means.
|
|
323
|
+
background=True,
|
|
324
|
+
purpose=str(entry.get("purpose") or ""),
|
|
325
|
+
adopted=True,
|
|
326
|
+
)
|
|
327
|
+
status = self._read_status(job)
|
|
328
|
+
if status is not None:
|
|
329
|
+
if status.get("exit_code") is not None:
|
|
330
|
+
self._counter -= 1
|
|
331
|
+
return None
|
|
332
|
+
job.pid = status.get("pid")
|
|
333
|
+
self.jobs[job.id] = job
|
|
334
|
+
return job
|
|
335
|
+
|
|
336
|
+
def get(self, job_id: str) -> Job | None:
|
|
337
|
+
return self.jobs.get(job_id)
|
|
338
|
+
|
|
339
|
+
def running(self) -> list[Job]:
|
|
340
|
+
self.refresh()
|
|
341
|
+
return [job for job in self.jobs.values() if job.running]
|
|
342
|
+
|
|
343
|
+
def refresh(self) -> list[Job]:
|
|
344
|
+
"""Read published statuses; return the jobs that finished since last time.
|
|
345
|
+
|
|
346
|
+
Cheap enough to call on a UI tick: one small read per still-running job.
|
|
347
|
+
"""
|
|
348
|
+
self._escalate()
|
|
349
|
+
finished = []
|
|
350
|
+
for job in list(self.jobs.values()):
|
|
351
|
+
if not job.running:
|
|
352
|
+
continue
|
|
353
|
+
status = self._read_status(job)
|
|
354
|
+
if status is None:
|
|
355
|
+
continue
|
|
356
|
+
if job.pid is None:
|
|
357
|
+
job.pid = status.get("pid")
|
|
358
|
+
if status.get("exit_code") is None:
|
|
359
|
+
continue
|
|
360
|
+
job.exit_code = status["exit_code"]
|
|
361
|
+
job.ended_at = status.get("ended_at") or time.time()
|
|
362
|
+
finished.append(job)
|
|
363
|
+
if finished:
|
|
364
|
+
self._evict()
|
|
365
|
+
return finished
|
|
366
|
+
|
|
367
|
+
def _escalate(self) -> None:
|
|
368
|
+
"""SIGKILL a stopped job that outlived its SIGTERM grace period."""
|
|
369
|
+
for job_id, deadline in list(self._terminating.items()):
|
|
370
|
+
job = self.jobs.get(job_id)
|
|
371
|
+
status = self._read_status(job) if job is not None else None
|
|
372
|
+
if job is None or (status is not None and status.get("exit_code") is not None):
|
|
373
|
+
del self._terminating[job_id]
|
|
374
|
+
elif time.time() >= deadline:
|
|
375
|
+
_kill_session(job.supervisor_pid)
|
|
376
|
+
del self._terminating[job_id]
|
|
377
|
+
|
|
378
|
+
def _read_status(self, job: Job) -> dict | None:
|
|
379
|
+
try:
|
|
380
|
+
status = json.loads(job.status_path.read_text(encoding="utf-8"))
|
|
381
|
+
except (OSError, ValueError):
|
|
382
|
+
# Absent until the supervisor's first publication, and never
|
|
383
|
+
# partially readable: it is written to a temp file and renamed.
|
|
384
|
+
return None
|
|
385
|
+
return status if isinstance(status, dict) else None
|
|
386
|
+
|
|
387
|
+
def take_announcements(self, channel: str, job_id: str | None = None) -> list[Job]:
|
|
388
|
+
"""Finished jobs this channel has not reported yet, or just `job_id`.
|
|
389
|
+
|
|
390
|
+
Separate channels so the terminal and the model can each be told once.
|
|
391
|
+
Only jobs the model was handed a handle for are worth announcing to it;
|
|
392
|
+
a foreground command that completed inside its own tool call already
|
|
393
|
+
reported itself.
|
|
394
|
+
"""
|
|
395
|
+
self.refresh()
|
|
396
|
+
pending = [
|
|
397
|
+
job
|
|
398
|
+
for job in self.jobs.values()
|
|
399
|
+
if not job.running
|
|
400
|
+
and channel not in job.announced
|
|
401
|
+
and self.announceable(job)
|
|
402
|
+
and job_id in (None, job.id)
|
|
403
|
+
]
|
|
404
|
+
for job in pending:
|
|
405
|
+
job.announced.add(channel)
|
|
406
|
+
return pending
|
|
407
|
+
|
|
408
|
+
def announceable(self, job: Job) -> bool:
|
|
409
|
+
"""Whether anyone was left holding a handle, so its exit is news."""
|
|
410
|
+
return job.background or job.detached or job.stopped or job.id in self._waited
|
|
411
|
+
|
|
412
|
+
def mark_waited(self, job: Job) -> None:
|
|
413
|
+
"""Record that a wait returned a handle instead of a result."""
|
|
414
|
+
self._waited.add(job.id)
|
|
415
|
+
|
|
416
|
+
def release_waits(self) -> None:
|
|
417
|
+
"""End every in-flight wait now, without touching the commands.
|
|
418
|
+
|
|
419
|
+
A steering message is for the next model request, and a foreground
|
|
420
|
+
wait would otherwise hold that request back until its timeout. The
|
|
421
|
+
wait returns a handle, the job keeps running, and its exit is still
|
|
422
|
+
delivered; the model just hears from the user first.
|
|
423
|
+
"""
|
|
424
|
+
self.release_generation += 1
|
|
425
|
+
|
|
426
|
+
def read_output(self, job: Job, *, max_bytes: int = OUTPUT_TAIL_BYTES) -> tuple[str, bool]:
|
|
427
|
+
"""The tail of the job's log, and whether anything was dropped before it."""
|
|
428
|
+
try:
|
|
429
|
+
size = job.output_path.stat().st_size
|
|
430
|
+
with job.output_path.open("rb") as source:
|
|
431
|
+
source.seek(max(0, size - max_bytes))
|
|
432
|
+
data = source.read(max_bytes)
|
|
433
|
+
except OSError:
|
|
434
|
+
return "", False
|
|
435
|
+
return data.decode("utf-8", errors="replace"), size > len(data)
|
|
436
|
+
|
|
437
|
+
def stop(self, job: Job) -> bool:
|
|
438
|
+
"""Stop the job's whole process group. Returns whether it was running.
|
|
439
|
+
|
|
440
|
+
The group gets SIGTERM now and SIGKILL from a later `refresh` if it is
|
|
441
|
+
still there after the grace period. Nothing blocks on it: the caller
|
|
442
|
+
may be a cancellation handler on the event loop.
|
|
443
|
+
"""
|
|
444
|
+
return bool(self.stop_all([job]))
|
|
445
|
+
|
|
446
|
+
def stop_all(self, jobs: Iterable[Job] | None = None) -> list[Job]:
|
|
447
|
+
self.refresh()
|
|
448
|
+
stopped = []
|
|
449
|
+
for job in list(jobs if jobs is not None else self.jobs.values()):
|
|
450
|
+
if not job.running:
|
|
451
|
+
continue
|
|
452
|
+
_terminate_session(job.supervisor_pid)
|
|
453
|
+
job.stopped = True
|
|
454
|
+
job.ended_at = time.time()
|
|
455
|
+
self._terminating[job.id] = time.time() + STOP_GRACE_SECONDS
|
|
456
|
+
stopped.append(job)
|
|
457
|
+
if stopped:
|
|
458
|
+
self._save()
|
|
459
|
+
return stopped
|
|
460
|
+
|
|
461
|
+
def _evict(self) -> None:
|
|
462
|
+
finished = [job for job in self.jobs.values() if not job.running]
|
|
463
|
+
evicted = sorted(finished, key=lambda job: job.ended_at or 0)[: -self._retain or None]
|
|
464
|
+
for job in evicted:
|
|
465
|
+
shutil.rmtree(job.directory, ignore_errors=True)
|
|
466
|
+
_remove_if_empty(job.directory.parent)
|
|
467
|
+
del self.jobs[job.id]
|
|
468
|
+
if evicted:
|
|
469
|
+
self._save()
|
|
470
|
+
|
|
471
|
+
def reset(self) -> None:
|
|
472
|
+
"""Stop everything and forget it. For tests, which share the registry.
|
|
473
|
+
|
|
474
|
+
Deliberately harsher than `shutdown`: a test that leaked a process
|
|
475
|
+
should not leave it running for the next one.
|
|
476
|
+
"""
|
|
477
|
+
for job in self.jobs.values():
|
|
478
|
+
_kill_session(job.supervisor_pid)
|
|
479
|
+
shutil.rmtree(job.directory, ignore_errors=True)
|
|
480
|
+
self.jobs.clear()
|
|
481
|
+
self._waited.clear()
|
|
482
|
+
self._terminating.clear()
|
|
483
|
+
self._counter = 0
|
|
484
|
+
self.cancel_policy = "detach"
|
|
485
|
+
self.release_generation = 0
|
|
486
|
+
if self._home is not None:
|
|
487
|
+
shutil.rmtree(self._home, ignore_errors=True)
|
|
488
|
+
self._home = None
|
|
489
|
+
|
|
490
|
+
def shutdown(self) -> None:
|
|
491
|
+
"""Drop the logs of finished jobs; leave running ones and their logs alone.
|
|
492
|
+
|
|
493
|
+
A job that outlives the session is the point of the design, so this
|
|
494
|
+
must not kill anything. Its directory and the registry record stay so
|
|
495
|
+
the next pcode can adopt it; both go once nothing is left running.
|
|
496
|
+
"""
|
|
497
|
+
for job in list(self.jobs.values()):
|
|
498
|
+
if not job.running:
|
|
499
|
+
shutil.rmtree(job.directory, ignore_errors=True)
|
|
500
|
+
del self.jobs[job.id]
|
|
501
|
+
if self._home is None:
|
|
502
|
+
return
|
|
503
|
+
if self.jobs:
|
|
504
|
+
self._save()
|
|
505
|
+
else:
|
|
506
|
+
shutil.rmtree(self._home, ignore_errors=True)
|
|
507
|
+
self._home = None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _kill_session(pid: int) -> None:
|
|
511
|
+
_signal_session(pid, signal.SIGKILL)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _terminate_session(pid: int) -> None:
|
|
515
|
+
_signal_session(pid, signal.SIGTERM)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _signal_session(pid: int, signum: int) -> None:
|
|
519
|
+
if os.name == "nt": # pragma: no cover -- no Windows CI.
|
|
520
|
+
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True)
|
|
521
|
+
return
|
|
522
|
+
try:
|
|
523
|
+
os.killpg(pid, signum)
|
|
524
|
+
except (ProcessLookupError, PermissionError):
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _alive(pid: int) -> bool:
|
|
529
|
+
if pid <= 0:
|
|
530
|
+
return False
|
|
531
|
+
try:
|
|
532
|
+
os.kill(pid, 0)
|
|
533
|
+
except ProcessLookupError:
|
|
534
|
+
return False
|
|
535
|
+
except PermissionError:
|
|
536
|
+
return True
|
|
537
|
+
return True
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _remove_if_empty(directory: Path) -> None:
|
|
541
|
+
try:
|
|
542
|
+
directory.rmdir()
|
|
543
|
+
except OSError:
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# Set around one tool call by a sandboxing extension (the bundled `sandbox`):
|
|
548
|
+
# given a job's directory, the argv that runs its supervisor inside a sandbox.
|
|
549
|
+
# Wrapping the supervisor rather than the command keeps the command text the
|
|
550
|
+
# user and model see unchanged, and a stop still signals the whole group.
|
|
551
|
+
COMMAND_SANDBOX: ContextVar[Callable[[Path], list[str]] | None] = ContextVar(
|
|
552
|
+
"command_sandbox", default=None
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
_REGISTRY = JobRegistry(state=jobs_root)
|
|
556
|
+
_WORKER_REGISTRY: ContextVar[JobRegistry | None] = ContextVar("worker_job_registry", default=None)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@contextmanager
|
|
560
|
+
def isolated_registry():
|
|
561
|
+
"""Own an isolated worker's jobs; no process may outlive its task snapshot.
|
|
562
|
+
|
|
563
|
+
Shared workers retain the session registry. Isolated workers cannot inspect
|
|
564
|
+
or stop a parent's jobs, and are torn down before their Git result is recorded.
|
|
565
|
+
"""
|
|
566
|
+
jobs = JobRegistry()
|
|
567
|
+
token = _WORKER_REGISTRY.set(jobs)
|
|
568
|
+
try:
|
|
569
|
+
yield jobs
|
|
570
|
+
finally:
|
|
571
|
+
try:
|
|
572
|
+
jobs.reset()
|
|
573
|
+
finally:
|
|
574
|
+
_WORKER_REGISTRY.reset(token)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def registry() -> JobRegistry:
|
|
578
|
+
"""The process-wide registry.
|
|
579
|
+
|
|
580
|
+
Shared rather than per-run on purpose: a run is exactly the scope a job is
|
|
581
|
+
supposed to escape, and the worker sub-agent should see -- and be able to
|
|
582
|
+
stop -- the same jobs the parent started.
|
|
583
|
+
"""
|
|
584
|
+
return _WORKER_REGISTRY.get() or _REGISTRY
|