agentic-runner 2.6.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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""The per-attempt callback socket (ADR-0013 §10, map ticket 26 §4, 25 §10).
|
|
2
|
+
|
|
3
|
+
Buildkite's Job API, narrowed to what ADR-0011 §9 can defend. For each Directive attempt
|
|
4
|
+
the Runner mints 32 random bytes as a bearer and opens a Unix socket **owned by the
|
|
5
|
+
Contract's uid** (map ticket 17 A11); the Agent Runtime subprocess receives the socket
|
|
6
|
+
path and the bearer in its environment and nothing else. ``agentic-runner annotate |
|
|
7
|
+
artifact upload | verb`` speak to it.
|
|
8
|
+
|
|
9
|
+
What makes that not a credential: the token's only audience is this Runner's own socket,
|
|
10
|
+
it is useless off the box, it dies with the attempt, and it is a *correlator* rather than
|
|
11
|
+
an authority — a ``verb`` call is one more entry point into the same Grant evaluation the
|
|
12
|
+
activity seams run, attributed to the Agent of that attempt, never a bypass (ADR-0011
|
|
13
|
+
§10). The same bearer is what the relocated LLM proxy accepts (PRD issue 43), so one
|
|
14
|
+
token per attempt serves both.
|
|
15
|
+
|
|
16
|
+
**Not offered**, deliberately: a `pipeline upload` analogue (an Agent rewriting its own
|
|
17
|
+
loop is the thing the platform owns), `meta-data`, and any route that returns a
|
|
18
|
+
credential — :data:`ROUTES` is the closed set and
|
|
19
|
+
``tests/unit/test_runner_callback_socket.py`` asserts no field in it is credential-shaped.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
from typing import Any, Final, Literal, Self
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
from agentic_runner.tiny_http import (
|
|
37
|
+
MAX_HEADER_BYTES,
|
|
38
|
+
HttpRequest,
|
|
39
|
+
bearer_matches,
|
|
40
|
+
read_request,
|
|
41
|
+
write_json,
|
|
42
|
+
)
|
|
43
|
+
from agentic_runner_contracts.channel_messages import (
|
|
44
|
+
MESSAGE_BODY_MAX_BYTES,
|
|
45
|
+
MessageEnvelope,
|
|
46
|
+
MessageKind,
|
|
47
|
+
MessageReference,
|
|
48
|
+
)
|
|
49
|
+
from agentic_runner_contracts.questions import QUESTION_TEXT_MAX
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"CALLBACK_SOCKET_ENV",
|
|
53
|
+
"CALLBACK_TOKEN_ENV",
|
|
54
|
+
"ROUTES",
|
|
55
|
+
"AnnotateRequest",
|
|
56
|
+
"AnnotateResponse",
|
|
57
|
+
"AskRequest",
|
|
58
|
+
"AskResponse",
|
|
59
|
+
"ArtifactRequest",
|
|
60
|
+
"ArtifactResponse",
|
|
61
|
+
"AttemptCallbackServer",
|
|
62
|
+
"CallbackError",
|
|
63
|
+
"CallbackHandlers",
|
|
64
|
+
"MessageListRequest",
|
|
65
|
+
"MessageListResponse",
|
|
66
|
+
"MessageSendRequest",
|
|
67
|
+
"MessageSendResponse",
|
|
68
|
+
"Route",
|
|
69
|
+
"VerbRequest",
|
|
70
|
+
"VerbResponse",
|
|
71
|
+
"call",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
CALLBACK_SOCKET_ENV: Final[str] = "AGENTIC_RUNNER_CALLBACK_SOCKET"
|
|
75
|
+
CALLBACK_TOKEN_ENV: Final[str] = "AGENTIC_RUNNER_CALLBACK_TOKEN"
|
|
76
|
+
|
|
77
|
+
_TOKEN_BYTES: Final[int] = 32
|
|
78
|
+
# `sockaddr_un.sun_path` is 108 bytes on Linux and 104 on macOS, and the failure mode of
|
|
79
|
+
# overrunning it is a truncated path that binds somewhere else entirely. The Workspace
|
|
80
|
+
# root plus a Contract id plus a Work Record id is already past it, which is why the
|
|
81
|
+
# socket lives in its own short directory rather than beside the checkout.
|
|
82
|
+
MAX_SOCKET_PATH_BYTES: Final[int] = 100
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AnnotateRequest(BaseModel):
|
|
86
|
+
context: str = Field(max_length=100)
|
|
87
|
+
body: str = Field(max_length=65_536)
|
|
88
|
+
style: str = Field(default="info", pattern=r"^(info|success|warning|error)$")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AnnotateResponse(BaseModel):
|
|
92
|
+
accepted: bool
|
|
93
|
+
context: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ArtifactRequest(BaseModel):
|
|
97
|
+
"""One file already in the Workspace, offered for upload by path.
|
|
98
|
+
|
|
99
|
+
Never file *contents*: the Runner reads the path itself as the Contract's data, which
|
|
100
|
+
keeps a multi-gigabyte upload off the socket and off the Runner's heap.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
path: str = Field(max_length=4096)
|
|
104
|
+
label: str = Field(default="", max_length=200)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ArtifactResponse(BaseModel):
|
|
108
|
+
accepted: bool
|
|
109
|
+
path: str
|
|
110
|
+
size_bytes: int
|
|
111
|
+
sha256: str
|
|
112
|
+
reason: str = ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class VerbRequest(BaseModel):
|
|
116
|
+
verb: str = Field(max_length=100)
|
|
117
|
+
resource: str = Field(max_length=400)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class VerbResponse(BaseModel):
|
|
121
|
+
"""The seam's own verdict. No token, no credential — only what was decided and why."""
|
|
122
|
+
|
|
123
|
+
verb: str
|
|
124
|
+
resource: str
|
|
125
|
+
decision: str
|
|
126
|
+
allowed: bool
|
|
127
|
+
reason: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MessageSendRequest(BaseModel):
|
|
131
|
+
"""``agentic-runner message send`` (PRD issue 52): the envelope minus what the Runner
|
|
132
|
+
fills in itself -- sender, Work Record, id and time are the attempt's, never the
|
|
133
|
+
caller's to claim."""
|
|
134
|
+
|
|
135
|
+
channel_id: str = Field(max_length=64)
|
|
136
|
+
kind: MessageKind = MessageKind.NOTE
|
|
137
|
+
body: str = Field(max_length=MESSAGE_BODY_MAX_BYTES)
|
|
138
|
+
references: list[MessageReference] = Field(default_factory=list, max_length=32)
|
|
139
|
+
# PRD issue 53: who this is for. A `result` with neither is addressed to the sender
|
|
140
|
+
# of the `message` it references, which the Runner resolves from its own store.
|
|
141
|
+
recipient_agent_id: str | None = Field(default=None, max_length=64)
|
|
142
|
+
recipient_role: str | None = Field(default=None, max_length=32)
|
|
143
|
+
# PRD issue 54: a `verdict` names the head commit it reviewed and carries one bit.
|
|
144
|
+
verdict: Literal["block", "clear"] | None = None
|
|
145
|
+
head_sha: str | None = Field(default=None, max_length=64)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class MessageSendResponse(BaseModel):
|
|
149
|
+
"""Whether the `channel.write` seam let it through, and the id it got if so."""
|
|
150
|
+
|
|
151
|
+
accepted: bool
|
|
152
|
+
decision: str
|
|
153
|
+
reason: str
|
|
154
|
+
message_id: str = ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MessageListRequest(BaseModel):
|
|
158
|
+
channel_id: str = Field(max_length=64)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MessageListResponse(BaseModel):
|
|
162
|
+
"""The Channel's Messages under `channel.read`; empty with the decision otherwise."""
|
|
163
|
+
|
|
164
|
+
allowed: bool
|
|
165
|
+
decision: str
|
|
166
|
+
reason: str
|
|
167
|
+
messages: list[MessageEnvelope] = Field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AskRequest(BaseModel):
|
|
171
|
+
"""``agentic-runner ask`` (PRD issue 60): the Question's text and nothing else --
|
|
172
|
+
the asking Agent, the Work Record and the addressee are the attempt's to know."""
|
|
173
|
+
|
|
174
|
+
text: str = Field(min_length=1, max_length=QUESTION_TEXT_MAX)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class AskResponse(BaseModel):
|
|
178
|
+
"""`work.ask`'s verdict. ``accepted`` means the Question stands: the Work Record holds
|
|
179
|
+
on it once this Directive ends, and the next Directive carries the answer (or the
|
|
180
|
+
fact that none came). A refusal leaves the Directive to carry on without it."""
|
|
181
|
+
|
|
182
|
+
accepted: bool
|
|
183
|
+
decision: str
|
|
184
|
+
reason: str
|
|
185
|
+
question_id: str = ""
|
|
186
|
+
addressed_to: str = ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class Route:
|
|
191
|
+
request: type[BaseModel]
|
|
192
|
+
response: type[BaseModel]
|
|
193
|
+
handler: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
ROUTES: Final[dict[tuple[str, str], Route]] = {
|
|
197
|
+
("POST", "/v0/annotate"): Route(AnnotateRequest, AnnotateResponse, "annotate"),
|
|
198
|
+
("POST", "/v0/artifact"): Route(ArtifactRequest, ArtifactResponse, "artifact"),
|
|
199
|
+
("POST", "/v0/verb"): Route(VerbRequest, VerbResponse, "verb"),
|
|
200
|
+
("POST", "/v0/message/send"): Route(MessageSendRequest, MessageSendResponse, "message_send"),
|
|
201
|
+
("POST", "/v0/message/list"): Route(MessageListRequest, MessageListResponse, "message_list"),
|
|
202
|
+
("POST", "/v0/ask"): Route(AskRequest, AskResponse, "ask"),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class CallbackHandlers:
|
|
208
|
+
"""What the Runner does when a Directive calls back. Injected by the activity."""
|
|
209
|
+
|
|
210
|
+
annotate: Callable[[AnnotateRequest], Awaitable[AnnotateResponse]]
|
|
211
|
+
artifact: Callable[[ArtifactRequest], Awaitable[ArtifactResponse]]
|
|
212
|
+
verb: Callable[[VerbRequest], Awaitable[VerbResponse]]
|
|
213
|
+
message_send: Callable[[MessageSendRequest], Awaitable[MessageSendResponse]]
|
|
214
|
+
message_list: Callable[[MessageListRequest], Awaitable[MessageListResponse]]
|
|
215
|
+
ask: Callable[[AskRequest], Awaitable[AskResponse]]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class CallbackError(RuntimeError):
|
|
219
|
+
"""A callback the Runner refused: the status and the body it answered with."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
222
|
+
super().__init__(f"callback refused ({status_code}): {detail}")
|
|
223
|
+
self.status_code = status_code
|
|
224
|
+
self.detail = detail
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AttemptCallbackServer:
|
|
228
|
+
"""One attempt's socket. Opened on enter, unlinked on exit; the token dies with it."""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
socket_path: Path,
|
|
234
|
+
handlers: CallbackHandlers,
|
|
235
|
+
uid: int | None = None,
|
|
236
|
+
token: str | None = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
if len(os.fsencode(socket_path)) > MAX_SOCKET_PATH_BYTES:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"callback socket path is longer than {MAX_SOCKET_PATH_BYTES} bytes: {socket_path}"
|
|
241
|
+
)
|
|
242
|
+
self._socket_path = socket_path
|
|
243
|
+
self._handlers = handlers
|
|
244
|
+
self._uid = uid
|
|
245
|
+
self._token = token or secrets.token_urlsafe(_TOKEN_BYTES)
|
|
246
|
+
self._server: asyncio.AbstractServer | None = None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def token(self) -> str:
|
|
250
|
+
return self._token
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def socket_path(self) -> Path:
|
|
254
|
+
return self._socket_path
|
|
255
|
+
|
|
256
|
+
def env(self) -> dict[str, str]:
|
|
257
|
+
"""The two names the Agent Runtime subprocess gains, and nothing else."""
|
|
258
|
+
|
|
259
|
+
return {CALLBACK_SOCKET_ENV: str(self._socket_path), CALLBACK_TOKEN_ENV: self._token}
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self) -> Self:
|
|
262
|
+
self._socket_path.unlink(missing_ok=True)
|
|
263
|
+
# `limit` is what actually bounds the request head: `readuntil` raises
|
|
264
|
+
# LimitOverrunError at the reader's own limit, so leaving it at the 64 KiB default
|
|
265
|
+
# would mean the 431 below never fires and an oversized head answered 500.
|
|
266
|
+
self._server = await asyncio.start_unix_server(
|
|
267
|
+
self._serve, path=str(self._socket_path), limit=MAX_HEADER_BYTES
|
|
268
|
+
)
|
|
269
|
+
# Order matters: 0600 before the chown, so there is no window in which the socket
|
|
270
|
+
# is both world-writable and owned by the Contract.
|
|
271
|
+
os.chmod(self._socket_path, 0o600)
|
|
272
|
+
if self._uid is not None:
|
|
273
|
+
os.chown(self._socket_path, self._uid, self._uid)
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(
|
|
277
|
+
self,
|
|
278
|
+
exc_type: type[BaseException] | None,
|
|
279
|
+
exc: BaseException | None,
|
|
280
|
+
traceback: TracebackType | None,
|
|
281
|
+
) -> None:
|
|
282
|
+
server, self._server = self._server, None
|
|
283
|
+
if server is not None:
|
|
284
|
+
server.close()
|
|
285
|
+
await server.wait_closed()
|
|
286
|
+
self._socket_path.unlink(missing_ok=True)
|
|
287
|
+
|
|
288
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
289
|
+
try:
|
|
290
|
+
status, payload = await self._handle(reader)
|
|
291
|
+
except Exception as error: # noqa: BLE001 - a handler fault answers 500, never kills the socket
|
|
292
|
+
status, payload = 500, {"detail": error.__class__.__name__}
|
|
293
|
+
await write_json(writer, status, payload)
|
|
294
|
+
writer.close()
|
|
295
|
+
|
|
296
|
+
async def _handle(self, reader: asyncio.StreamReader) -> tuple[int, dict[str, Any]]:
|
|
297
|
+
request = await read_request(reader)
|
|
298
|
+
if not isinstance(request, HttpRequest):
|
|
299
|
+
return request
|
|
300
|
+
if not bearer_matches(request.authorization, self._token):
|
|
301
|
+
return 401, {"detail": "a valid attempt bearer is required"}
|
|
302
|
+
route = ROUTES.get((request.method, request.target))
|
|
303
|
+
if route is None:
|
|
304
|
+
return 404, {"detail": "no such callback route"}
|
|
305
|
+
try:
|
|
306
|
+
parsed = route.request.model_validate_json(request.body or b"{}")
|
|
307
|
+
except ValueError as error:
|
|
308
|
+
return 422, {"detail": str(error)[:500]}
|
|
309
|
+
handler: Callable[[BaseModel], Awaitable[BaseModel]] = getattr(
|
|
310
|
+
self._handlers, route.handler
|
|
311
|
+
)
|
|
312
|
+
return 200, (await handler(parsed)).model_dump(mode="json")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def call(
|
|
316
|
+
*,
|
|
317
|
+
socket_path: str | Path,
|
|
318
|
+
token: str,
|
|
319
|
+
path: str,
|
|
320
|
+
payload: Mapping[str, Any],
|
|
321
|
+
timeout_seconds: float = 30.0,
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
"""One callback, over the attempt's own socket. Used by the ``agentic-runner`` CLI."""
|
|
324
|
+
|
|
325
|
+
transport = httpx.HTTPTransport(uds=str(socket_path))
|
|
326
|
+
with httpx.Client(transport=transport, timeout=timeout_seconds) as client:
|
|
327
|
+
response = client.post(
|
|
328
|
+
f"http://localhost{path}",
|
|
329
|
+
json=dict(payload),
|
|
330
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
331
|
+
)
|
|
332
|
+
if response.status_code != 200:
|
|
333
|
+
raise CallbackError(response.status_code, _detail(response))
|
|
334
|
+
decoded: dict[str, Any] = response.json()
|
|
335
|
+
return decoded
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _detail(response: httpx.Response) -> str:
|
|
339
|
+
try:
|
|
340
|
+
return str(response.json().get("detail", response.text))
|
|
341
|
+
except ValueError:
|
|
342
|
+
return response.text
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Keep a *stopped* child from wedging the Runner's event loop (QTS-1148).
|
|
2
|
+
|
|
3
|
+
Python 3.14's asyncio watches children without pidfd (macOS) with
|
|
4
|
+
``_ThreadedChildWatcher``: a thread blocks in ``waitid(WEXITED | WNOWAIT)``, then hands
|
|
5
|
+
the reap to the loop thread, which calls a blocking ``waitpid(pid, 0)``. macOS's
|
|
6
|
+
``waitid`` also returns for a child that is only stopped (``CLD_STOPPED``), so a
|
|
7
|
+
``SIGSTOP``/``SIGTSTP`` on any Runner spawn -- the Agent Runtime CLI, an MCP server, a
|
|
8
|
+
sign-in -- freezes the loop until that child exits: no heartbeats, no polling, no
|
|
9
|
+
cancellation, and the Directive timeout cannot fire either. CPython has not fixed this
|
|
10
|
+
(main still waits on ``WEXITED | WNOWAIT`` alone, as of 2026-10).
|
|
11
|
+
|
|
12
|
+
The fix sits in front of the watcher thread's wait rather than replacing the watcher: it
|
|
13
|
+
consumes stop and continue reports until the report is a real exit, then lets the stock
|
|
14
|
+
code run, which now returns at once. It reaches into asyncio's private watcher, so it
|
|
15
|
+
installs only when it finds exactly the class it was written against and otherwise
|
|
16
|
+
leaves the loop alone; ``tests/unit/test_runner_child_watcher.py`` fails on macOS if
|
|
17
|
+
the wedge comes back.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
from asyncio import unix_events
|
|
25
|
+
from typing import Any, Final
|
|
26
|
+
|
|
27
|
+
_EXIT_CODES: Final = frozenset({os.CLD_EXITED, os.CLD_KILLED, os.CLD_DUMPED})
|
|
28
|
+
# typeshed leaves ``os.waitid`` out on darwin although CPython has it there, and mypy
|
|
29
|
+
# checks against the host platform; a getattr keeps one spelling clean on both.
|
|
30
|
+
_waitid: Final[Any] = getattr(os, "waitid", None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install_stop_tolerant_child_watcher(loop: asyncio.AbstractEventLoop) -> bool:
|
|
34
|
+
"""Make ``loop``'s threaded child watcher wait past stops. Returns whether it did."""
|
|
35
|
+
|
|
36
|
+
threaded = getattr(unix_events, "_ThreadedChildWatcher", None)
|
|
37
|
+
watcher: Any = getattr(loop, "_watcher", None)
|
|
38
|
+
if threaded is None or type(watcher) is not threaded or _waitid is None:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
stock_do_waitpid = watcher._do_waitpid
|
|
42
|
+
|
|
43
|
+
def do_waitpid(
|
|
44
|
+
loop: asyncio.AbstractEventLoop, expected_pid: int, callback: Any, args: Any
|
|
45
|
+
) -> None:
|
|
46
|
+
_wait_until_exited(expected_pid)
|
|
47
|
+
stock_do_waitpid(loop, expected_pid, callback, args)
|
|
48
|
+
|
|
49
|
+
watcher._do_waitpid = do_waitpid
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wait_until_exited(pid: int) -> None:
|
|
54
|
+
while True:
|
|
55
|
+
try:
|
|
56
|
+
report = _waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT)
|
|
57
|
+
except ChildProcessError:
|
|
58
|
+
return
|
|
59
|
+
if report is None or report.si_code in _EXIT_CODES:
|
|
60
|
+
return
|
|
61
|
+
# Without consuming it, the same stop report comes straight back under WNOWAIT.
|
|
62
|
+
# WSTOPPED | WCONTINUED never matches an exit, so this cannot reap the child.
|
|
63
|
+
try:
|
|
64
|
+
_waitid(os.P_PID, pid, os.WSTOPPED | os.WCONTINUED | os.WNOHANG)
|
|
65
|
+
except ChildProcessError:
|
|
66
|
+
return
|