agent-control-panel 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.
@@ -0,0 +1,102 @@
1
+ """Agent Control Panel SDK: fail-open reporting and remote control for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from ._content import MAX_FULL_CHARS, extract_text
8
+ from ._http import assert_secure_url
9
+ from ._transport import Request, Response, Transport
10
+ from ._version import __version__
11
+ from ._webhook import WebhookResult
12
+ from .client import AcpClient
13
+ from .errors import AgentPausedError, AgentStatus
14
+ from .signature import (
15
+ MAX_SKEW_SECONDS,
16
+ NONCE_HEADER,
17
+ PROTOCOL_HEADER,
18
+ SIG_HEADER,
19
+ TS_HEADER,
20
+ HeadersLike,
21
+ NonceCache,
22
+ SignedPayload,
23
+ VerifyResult,
24
+ sign_payload,
25
+ verify_signature,
26
+ )
27
+ from .tokens import TokenUsage, extract_tokens
28
+
29
+ PACKAGE_NAME = "agent-control-panel"
30
+
31
+
32
+ def create_client(
33
+ *,
34
+ url: str,
35
+ api_key: str,
36
+ agent_id: str,
37
+ webhook_secret: str,
38
+ webhook_secret_prev: str | None = None,
39
+ heartbeat_seconds: float = 30.0,
40
+ flush_seconds: float = 5.0,
41
+ flush_at: int = 50,
42
+ max_buffer: int = 200,
43
+ logger: logging.Logger | None = None,
44
+ disable_control: bool = False,
45
+ allow_insecure_http: bool = False,
46
+ capture_content: bool = True,
47
+ transport: Transport | None = None,
48
+ ) -> AcpClient:
49
+ """Create a client for one agent. Call ``start()`` (or use ``with``) for heartbeats and flushes.
50
+
51
+ ``capture_content=False`` sends metadata only (status, tokens, timings, model, errors): no user input,
52
+ summaries, full input or answer text, whatever ``report_run`` or ``wrap_agent_run`` is given.
53
+
54
+ Raises ``ValueError`` for a missing credential or an insecure URL; nothing else in the client raises
55
+ into your code except ``AgentPausedError`` from ``wrap_agent_run``.
56
+ """
57
+ return AcpClient(
58
+ url=url,
59
+ api_key=api_key,
60
+ agent_id=agent_id,
61
+ webhook_secret=webhook_secret,
62
+ webhook_secret_prev=webhook_secret_prev,
63
+ heartbeat_seconds=heartbeat_seconds,
64
+ flush_seconds=flush_seconds,
65
+ flush_at=flush_at,
66
+ max_buffer=max_buffer,
67
+ logger=logger,
68
+ disable_control=disable_control,
69
+ allow_insecure_http=allow_insecure_http,
70
+ capture_content=capture_content,
71
+ transport=transport,
72
+ )
73
+
74
+
75
+ __all__ = [
76
+ "MAX_FULL_CHARS",
77
+ "MAX_SKEW_SECONDS",
78
+ "NONCE_HEADER",
79
+ "PACKAGE_NAME",
80
+ "PROTOCOL_HEADER",
81
+ "SIG_HEADER",
82
+ "TS_HEADER",
83
+ "AcpClient",
84
+ "AgentPausedError",
85
+ "AgentStatus",
86
+ "HeadersLike",
87
+ "NonceCache",
88
+ "Request",
89
+ "Response",
90
+ "SignedPayload",
91
+ "TokenUsage",
92
+ "Transport",
93
+ "VerifyResult",
94
+ "WebhookResult",
95
+ "__version__",
96
+ "assert_secure_url",
97
+ "create_client",
98
+ "extract_text",
99
+ "extract_tokens",
100
+ "sign_payload",
101
+ "verify_signature",
102
+ ]
@@ -0,0 +1,138 @@
1
+ """Run content: the answer text of LLM responses, and cutting content to what ACP stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ MAX_FULL_CHARS = 100_000
10
+ """What ACP stores per full input/output field, in UTF-16 code units (JavaScript string length)."""
11
+ MAX_SUMMARY_CHARS = 2_000
12
+ """What ACP stores per summary field."""
13
+
14
+
15
+ def _read(obj: Any, key: str) -> Any:
16
+ """A key or plain attribute. Never calls anything: a callable value (a method) counts as absent."""
17
+ try:
18
+ value = obj.get(key) if isinstance(obj, Mapping) else getattr(obj, key, None)
19
+ except Exception:
20
+ return None
21
+ return None if callable(value) else value
22
+
23
+
24
+ def _text(value: Any) -> str | None:
25
+ return value if isinstance(value, str) and value else None
26
+
27
+
28
+ def _is_object(value: Any) -> bool:
29
+ return value is not None and not isinstance(value, (str, bytes, bytearray, int, float, bool, list, tuple))
30
+
31
+
32
+ def _join_text(parts: Any) -> str | None:
33
+ if not isinstance(parts, (list, tuple)):
34
+ return None
35
+ texts = [p if isinstance(p, str) else _text(_read(p, "text")) if _is_object(p) else None for p in parts]
36
+ found = [t for t in texts if t is not None]
37
+ return "".join(found) if found else None
38
+
39
+
40
+ def _extract(result: Any) -> str | None:
41
+ if isinstance(result, str):
42
+ return result or None
43
+ if not _is_object(result):
44
+ return None
45
+ output_text = _text(_read(result, "output_text"))
46
+ if output_text:
47
+ return output_text
48
+ choices = _read(result, "choices")
49
+ if isinstance(choices, (list, tuple)) and choices:
50
+ message = _read(choices[0], "message") if _is_object(choices[0]) else None
51
+ content = _read(message, "content") if _is_object(message) else None
52
+ text = _text(content) or _join_text(content)
53
+ if text:
54
+ return text
55
+ content = _read(result, "content")
56
+ if isinstance(content, (list, tuple)):
57
+ blocks = [b for b in content if _is_object(b) and _read(b, "type") == "text"]
58
+ text = _join_text(blocks)
59
+ if text:
60
+ return text
61
+ response = _read(result, "response")
62
+ candidates = _read(result, "candidates")
63
+ if not isinstance(candidates, (list, tuple)) and _is_object(response):
64
+ candidates = _read(response, "candidates")
65
+ if isinstance(candidates, (list, tuple)) and candidates and _is_object(candidates[0]):
66
+ body = _read(candidates[0], "content")
67
+ text = _join_text(_read(body, "parts")) if _is_object(body) else None
68
+ if text:
69
+ return text
70
+ return _text(_read(result, "text"))
71
+
72
+
73
+ def extract_text(result: Any) -> str | None:
74
+ """The answer text of a common LLM client response (a dict or an SDK object), or None.
75
+
76
+ - a string result is the text itself
77
+ - OpenAI Chat Completions: ``choices[0].message.content`` (a string or text parts)
78
+ - OpenAI Responses API: ``output_text``
79
+ - Anthropic Messages: ``content`` blocks of type ``text``
80
+ - Google Gemini: ``candidates[0].content.parts[].text`` (also under ``response``)
81
+ - ``text`` (Vercel AI SDK shape)
82
+
83
+ Only reads keys and attributes; never calls a method on the result. Never raises.
84
+ """
85
+ try:
86
+ return _extract(result)
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def cut_utf16(text: str, limit: int) -> tuple[str, bool]:
92
+ """Cut ``text`` to ``limit`` UTF-16 code units (how JavaScript and the ACP server count length)."""
93
+ if len(text) * 2 <= limit or len(text.encode("utf-16-le")) // 2 <= limit:
94
+ return text, False
95
+ units = 0
96
+ for index, char in enumerate(text):
97
+ units += 2 if ord(char) > 0xFFFF else 1
98
+ if units > limit:
99
+ return text[:index], True
100
+ return text, False
101
+
102
+
103
+ def clamp_full(value: Any) -> tuple[str | None, bool]:
104
+ """Serialise a full-content field like ``JSON.stringify`` and cut it to ACP's limit.
105
+
106
+ A value that cannot be serialised (circular, NaN) is left out: (None, False).
107
+ """
108
+ if value is None:
109
+ return None, False
110
+ if isinstance(value, str):
111
+ text = value
112
+ else:
113
+ try:
114
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False, default=str)
115
+ except (TypeError, ValueError, RecursionError):
116
+ return None, False
117
+ return cut_utf16(text, MAX_FULL_CHARS)
118
+
119
+
120
+ def clamp_summary(value: str | None) -> str | None:
121
+ return None if value is None else cut_utf16(value, MAX_SUMMARY_CHARS)[0]
122
+
123
+
124
+ def run_row(fields: dict[str, Any], capture_content: bool) -> dict[str, Any]:
125
+ """The wire row for one run: content cut (and flagged) or, with capture off, left out; None dropped."""
126
+ content = ("input_summary", "output_summary", "user_input")
127
+ row = {k: v for k, v in fields.items() if k not in ("full_input", "full_output")}
128
+ if not capture_content:
129
+ for key in content:
130
+ row.pop(key, None)
131
+ full_in, in_cut = clamp_full(fields.get("full_input")) if capture_content else (None, False)
132
+ full_out, out_cut = clamp_full(fields.get("full_output")) if capture_content else (None, False)
133
+ row.update(full_input=full_in, full_output=full_out)
134
+ if in_cut:
135
+ row["full_input_truncated"] = True
136
+ if out_cut:
137
+ row["full_output_truncated"] = True
138
+ return {k: v for k, v in row.items() if v is not None}
@@ -0,0 +1,202 @@
1
+ """Control state: status, holds, model override, prompt cache and the webhook nonce cache.
2
+
3
+ Same rules as the Node SDK (``adoptControlState`` / ``applyCommandFrom``):
4
+ - only an explicit resume/restart clears paused/stopped;
5
+ - a hold ACP set is adopted from, and released by, the heartbeat reply; a hold this app set locally never is;
6
+ - a heartbeat reply sent before the last applied command is ignored (``command_seq``);
7
+ - a model in the heartbeat reply is adopted unless the app set its own; a missing model is no instruction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import threading
14
+ import time
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal
18
+
19
+ from .errors import AgentPausedError, AgentStatus
20
+ from .signature import HeadersLike, NonceCache, VerifyResult, verify_signature
21
+
22
+ Origin = Literal["acp", "local"]
23
+
24
+ PROMPT_CACHE_SECONDS = 60.0
25
+ MAX_MODEL_LENGTH = 120
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CachedPrompt:
30
+ prompt: str
31
+ version: int
32
+ composition_hash: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HeartbeatSnapshot:
37
+ seq: int
38
+ status: AgentStatus
39
+ model: str | None
40
+
41
+
42
+ class ControlState:
43
+ def __init__(self, agent_id: str, disable_control: bool, logger: logging.Logger) -> None:
44
+ self.agent_id = agent_id
45
+ self.disable_control = disable_control
46
+ self._log = logger
47
+ self._lock = threading.Lock()
48
+ self._status: AgentStatus = "idle"
49
+ self._hold_origin: Origin | None = None
50
+ self._command_seq = 0
51
+ self._model_override: str | None = None
52
+ self._model_origin: Origin | None = None
53
+ self._prompt: CachedPrompt | None = None
54
+ self._prompt_cached_at: float | None = None
55
+ self._active_runs = 0
56
+ self._nonces = NonceCache()
57
+
58
+ def reset_lock_after_fork(self) -> None:
59
+ self._lock = threading.Lock()
60
+
61
+ @property
62
+ def status(self) -> AgentStatus:
63
+ with self._lock:
64
+ return self._status
65
+
66
+ @property
67
+ def model_override(self) -> str | None:
68
+ with self._lock:
69
+ return self._model_override
70
+
71
+ @property
72
+ def prompt(self) -> CachedPrompt | None:
73
+ with self._lock:
74
+ return self._prompt
75
+
76
+ # --- runs -------------------------------------------------------------------------------------------
77
+
78
+ def begin_run(self) -> None:
79
+ with self._lock:
80
+ if self._status in ("paused", "stopped"):
81
+ raise AgentPausedError(self._status)
82
+ self._active_runs += 1
83
+ self._status = "running"
84
+
85
+ def end_run(self) -> None:
86
+ with self._lock:
87
+ self._active_runs = max(0, self._active_runs - 1)
88
+ if self._active_runs == 0 and self._status == "running":
89
+ self._status = "idle"
90
+
91
+ # --- commands ---------------------------------------------------------------------------------------
92
+
93
+ def apply(self, command: str, payload: Mapping[str, Any] | None, origin: Origin) -> str | None:
94
+ if self.disable_control:
95
+ self._log.info("control ignored (disable_control): %s", command[:40])
96
+ return "ignored"
97
+ with self._lock:
98
+ if command not in ("ping", "verify"):
99
+ self._command_seq += 1
100
+ if command == "pause":
101
+ self._status, self._hold_origin = "paused", origin
102
+ return "paused"
103
+ if command == "resume":
104
+ self._status, self._hold_origin = "idle", None
105
+ return "resumed"
106
+ if command == "stop":
107
+ self._status, self._hold_origin = "stopped", origin
108
+ return "stopped"
109
+ if command == "restart":
110
+ self._status, self._hold_origin = "idle", None
111
+ return "restarted"
112
+ if command == "update_model":
113
+ model = payload.get("model") if payload else None
114
+ if isinstance(model, str) and model:
115
+ self._model_override, self._model_origin = model, origin
116
+ return f"model now {self._model_override if self._model_override is not None else 'null'}"
117
+ if command == "update_prompt":
118
+ prompt = payload.get("prompt") if payload else None
119
+ if isinstance(prompt, str) and prompt:
120
+ # The pushed text lacks the agent's skills (ACP composes them into GET /api/prompts), so
121
+ # it is only the fallback: the cache is marked stale and the next fetch_prompt() pulls
122
+ # the composed prompt.
123
+ sent_version = payload.get("version") if payload else None
124
+ version = (self._prompt.version if self._prompt else 0) + 1
125
+ if isinstance(sent_version, int) and not isinstance(sent_version, bool):
126
+ version = sent_version
127
+ self._prompt = CachedPrompt(prompt=prompt, version=version)
128
+ self._prompt_cached_at = None
129
+ return "prompt updated"
130
+ if command in ("ping", "verify"):
131
+ # Handshake / no-op: never touches agent status.
132
+ return "pong"
133
+ return None
134
+
135
+ # --- heartbeat --------------------------------------------------------------------------------------
136
+
137
+ def heartbeat_snapshot(self) -> HeartbeatSnapshot:
138
+ with self._lock:
139
+ return HeartbeatSnapshot(self._command_seq, self._status, self._model_override)
140
+
141
+ def adopt(self, reply: Any, seq_at_send: int) -> None:
142
+ if self.disable_control:
143
+ return
144
+ control = reply.get("control") if isinstance(reply, dict) else None
145
+ agents = control.get("agents") if isinstance(control, dict) else None
146
+ entry = agents.get(self.agent_id) if isinstance(agents, dict) else None
147
+ if not isinstance(entry, dict):
148
+ return
149
+ with self._lock:
150
+ if seq_at_send != self._command_seq:
151
+ return
152
+ model = entry.get("model")
153
+ if (
154
+ isinstance(model, str)
155
+ and model
156
+ and len(model) <= MAX_MODEL_LENGTH
157
+ and self._model_origin != "local"
158
+ and model != self._model_override
159
+ ):
160
+ self._model_override, self._model_origin = model, "acp"
161
+ self._log.info("model from ACP: %s", model)
162
+ status = entry.get("status")
163
+ if not isinstance(status, str):
164
+ return
165
+ if status in ("paused", "stopped"):
166
+ if self._hold_origin == "local" or self._status == status:
167
+ return
168
+ self._status = "paused" if status == "paused" else "stopped"
169
+ self._hold_origin = "acp"
170
+ self._log.info("control state from ACP: %s", status)
171
+ elif self._hold_origin == "acp":
172
+ self._status, self._hold_origin = "idle", None
173
+ self._log.info("control state from ACP: released")
174
+
175
+ # --- webhooks ---------------------------------------------------------------------------------------
176
+
177
+ def verify_webhook(
178
+ self, headers: HeadersLike, raw_body: bytes, secrets: tuple[str, str | None]
179
+ ) -> VerifyResult:
180
+ # Checking and recording the nonce under one lock: two server threads must not both accept a replay.
181
+ with self._lock:
182
+ result = verify_signature(
183
+ secrets=secrets, raw_body=raw_body, headers=headers, seen_nonce=self._nonces.has
184
+ )
185
+ if result.ok and result.nonce:
186
+ self._nonces.add(result.nonce)
187
+ return result
188
+
189
+ # --- prompt cache -----------------------------------------------------------------------------------
190
+
191
+ def fresh_prompt(self) -> CachedPrompt | None:
192
+ with self._lock:
193
+ if self._prompt is None or self._prompt_cached_at is None:
194
+ return None
195
+ if time.monotonic() - self._prompt_cached_at >= PROMPT_CACHE_SECONDS:
196
+ return None
197
+ return self._prompt
198
+
199
+ def store_prompt(self, prompt: CachedPrompt) -> None:
200
+ with self._lock:
201
+ self._prompt = prompt
202
+ self._prompt_cached_at = time.monotonic()
@@ -0,0 +1,99 @@
1
+ """Talking to ACP: URL rules, fail-open requests and the warnings that tell users what to fix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from typing import Any
9
+ from urllib.parse import urljoin, urlsplit, urlunsplit
10
+
11
+ from ._transport import Request, Response, Transport
12
+
13
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
14
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
15
+
16
+
17
+ def assert_secure_url(url: str, allow_insecure_http: bool = False) -> None:
18
+ """Raise unless ``url`` is https, http to a loopback host, or http with the explicit dev opt-out."""
19
+ parts = urlsplit(url)
20
+ if not parts.scheme or not parts.netloc or not parts.hostname:
21
+ raise ValueError(f"create_client: url is not a valid URL: {url}")
22
+ if parts.scheme == "https":
23
+ return
24
+ if parts.scheme != "http":
25
+ raise ValueError(f"create_client: url must use https: (got {parts.scheme}:)")
26
+ if parts.hostname.lower() in LOOPBACK_HOSTS or allow_insecure_http:
27
+ return
28
+ raise ValueError(
29
+ f'create_client: refusing insecure http:// url for host "{parts.hostname}" - the API key would be '
30
+ "sent in cleartext. Use https://, or set allow_insecure_http=True for local development only."
31
+ )
32
+
33
+
34
+ def clean(text: str, limit: int = 200) -> str:
35
+ """Safe to log: no control characters (no forged log lines), bounded length."""
36
+ return _CONTROL_CHARS.sub(" ", text)[:limit]
37
+
38
+
39
+ class Api:
40
+ def __init__(self, url: str, api_key: str, transport: Transport, logger: logging.Logger) -> None:
41
+ self.url = url
42
+ self._api_key = api_key
43
+ self._transport = transport
44
+ self._log = logger
45
+ parts = urlsplit(url)
46
+ self._display_url = urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
47
+
48
+ def warn_status(self, label: str, path: str, res: Response, suffix: str = "") -> None:
49
+ if 300 <= res.status < 400:
50
+ if res.location:
51
+ target = clean(urljoin(self.url + path, res.location))
52
+ moved_to = target[: -len(path)] if target.endswith(path) else target
53
+ self._log.warning(
54
+ "%s -> %d: ACP moved to %s; update your url (currently %s). Redirects are not "
55
+ "followed, so the API key is never sent to another host.%s",
56
+ label, res.status, moved_to, self._display_url, suffix,
57
+ ) # fmt: skip
58
+ else:
59
+ self._log.warning(
60
+ "%s -> %d: redirect without a Location; check your url.%s", label, res.status, suffix
61
+ )
62
+ elif res.status == 401:
63
+ self._log.warning(
64
+ "%s -> 401: ACP rejected the API key. Check ACP_API_KEY is the full key shown once when it "
65
+ "was created (not revoked); create a new key on the app page if unsure.%s",
66
+ label, suffix,
67
+ ) # fmt: skip
68
+ else:
69
+ self._log.warning("%s -> %d%s", label, res.status, suffix)
70
+
71
+ def post(self, path: str, body: bytes, timeout: float) -> tuple[bool, Any]:
72
+ """Fail-open POST: (accepted, parsed JSON reply or None). Never raises."""
73
+ try:
74
+ res = self._transport(
75
+ Request("POST", self.url + path, body, {"Content-Type": "application/json"}, timeout)
76
+ )
77
+ except Exception as err:
78
+ self._log.warning("%s failed (continuing anyway): %s", path, err)
79
+ return False, None
80
+ if not 200 <= res.status < 300:
81
+ self.warn_status(path, path, res)
82
+ return False, None
83
+ try:
84
+ return True, json.loads(res.body) if res.body else None
85
+ except ValueError:
86
+ return True, None
87
+
88
+ def get_json(self, label: str, path: str, timeout: float, suffix: str) -> Any:
89
+ """GET with the API key header; the parsed JSON reply, or None (warned). Never raises."""
90
+ try:
91
+ res = self._transport(
92
+ Request("GET", self.url + path, None, {"x-api-key": self._api_key}, timeout)
93
+ )
94
+ if 200 <= res.status < 300:
95
+ return json.loads(res.body)
96
+ self.warn_status(label, path, res, suffix)
97
+ except Exception as err:
98
+ self._log.warning("%s failed%s: %s", label, suffix, err)
99
+ return None
@@ -0,0 +1,131 @@
1
+ """Background threads for heartbeats and flushes, a time-bounded stop (also at exit), and fork safety."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ import os
8
+ import threading
9
+ import time
10
+ import weakref
11
+
12
+ from ._control import ControlState
13
+ from ._reporter import Reporter
14
+
15
+ _instances: weakref.WeakSet[Background] = weakref.WeakSet()
16
+
17
+
18
+ class Background:
19
+ """Daemon threads never keep the process alive; ``stop()`` and interpreter exit flush with a deadline."""
20
+
21
+ _control: ControlState
22
+ _reporter: Reporter
23
+ _log: logging.Logger
24
+
25
+ def __init__(self, heartbeat_seconds: float, flush_seconds: float) -> None:
26
+ self._heartbeat_seconds = heartbeat_seconds
27
+ self._flush_seconds = flush_seconds
28
+ self._state_lock = threading.Lock()
29
+ self._closing = threading.Event()
30
+ self._wake = threading.Event()
31
+ self._started = False
32
+ self._restart_after_fork = False
33
+ self._threads: list[threading.Thread] = []
34
+ _instances.add(self)
35
+
36
+ def _heartbeat(self) -> None:
37
+ raise NotImplementedError
38
+
39
+ def start(self) -> None:
40
+ """Start heartbeats and periodic flushes on daemon threads (they never keep the process alive)."""
41
+ with self._state_lock:
42
+ if self._started:
43
+ return
44
+ self._started = True
45
+ self._closing.clear()
46
+ self._wake.clear()
47
+ self._threads = [
48
+ threading.Thread(target=self._heartbeat_loop, name="acp-heartbeat", daemon=True),
49
+ threading.Thread(target=self._flush_loop, name="acp-flush", daemon=True),
50
+ ]
51
+ for thread in self._threads:
52
+ thread.start()
53
+ atexit.register(self._atexit)
54
+
55
+ def stop(self, timeout: float = 10.0) -> None:
56
+ """Stop the background threads and send what is buffered, giving up after ``timeout`` seconds."""
57
+ deadline = time.monotonic() + timeout
58
+ with self._state_lock:
59
+ self._started = False
60
+ threads, self._threads = self._threads, []
61
+ self._closing.set()
62
+ self._wake.set()
63
+ atexit.unregister(self._atexit)
64
+ # On a helper thread so that nothing (a hung connection, a slow DNS lookup) holds stop() or the
65
+ # interpreter's exit past the timeout.
66
+ flushed: list[bool] = []
67
+ finisher = threading.Thread(
68
+ target=lambda: flushed.append(self._reporter.flush(deadline)), daemon=True
69
+ )
70
+ try:
71
+ finisher.start()
72
+ except RuntimeError: # threads can no longer be started (interpreter shutting down)
73
+ flushed.append(self._reporter.flush(deadline))
74
+ else:
75
+ finisher.join(max(0.0, deadline - time.monotonic()))
76
+ if flushed != [True]:
77
+ self._log.warning("stop: buffered runs could not be sent within %.1f s", timeout)
78
+ for thread in threads:
79
+ if thread is not threading.current_thread():
80
+ thread.join(max(0.0, min(1.0, deadline - time.monotonic())))
81
+
82
+ def flush(self) -> None:
83
+ """Send buffered runs now (blocking). Never raises."""
84
+ self._reporter.flush()
85
+
86
+ def _atexit(self) -> None:
87
+ self.stop(timeout=5.0)
88
+
89
+ def _after_fork_in_child(self) -> None:
90
+ self._state_lock = threading.Lock()
91
+ self._closing = threading.Event()
92
+ self._wake = threading.Event()
93
+ self._control.reset_lock_after_fork()
94
+ self._reporter.reset_after_fork()
95
+ self._restart_after_fork = self._started
96
+ self._started = False
97
+ self._threads = []
98
+
99
+ def _resume_after_fork(self) -> None:
100
+ if self._restart_after_fork:
101
+ self._restart_after_fork = False
102
+ self.start()
103
+
104
+ def _heartbeat_loop(self) -> None:
105
+ while True:
106
+ self._heartbeat()
107
+ if self._closing.wait(self._heartbeat_seconds):
108
+ return
109
+
110
+ def _flush_loop(self) -> None:
111
+ while not self._closing.is_set():
112
+ self._wake.wait(self._flush_seconds)
113
+ self._wake.clear()
114
+ if self._closing.is_set():
115
+ return
116
+ self._reporter.flush()
117
+
118
+ def _wake_flusher(self) -> None:
119
+ if any(t.is_alive() for t in self._threads):
120
+ self._wake.set()
121
+ else:
122
+ threading.Thread(target=self._reporter.flush, name="acp-flush-once", daemon=True).start()
123
+
124
+
125
+ def _after_fork_in_child() -> None:
126
+ for instance in list(_instances):
127
+ instance._after_fork_in_child()
128
+
129
+
130
+ if hasattr(os, "register_at_fork"):
131
+ os.register_at_fork(after_in_child=_after_fork_in_child)