agentcap-cli 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/PKG-INFO +7 -1
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/README.md +6 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/pyproject.toml +1 -1
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/__init__.py +1 -1
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/cli.py +61 -16
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/license.py +15 -8
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/proxy.py +100 -4
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/runner.py +32 -15
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_cli.py +134 -8
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_license.py +39 -0
- agentcap_cli-0.1.3/tests/test_proxy_cancel.py +98 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_runner.py +54 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/.gitignore +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/LICENSE +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/landing/README.md +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/adapters.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/alerts.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/clamp.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/data/pricing.json +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/demo.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/devtools/__init__.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/devtools/fake_upstream.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/estimate.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/ledger.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/loop.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/pricing.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/upstreams.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/src/agentcap/usage.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/license_support.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/proxy_support.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_adapters.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_alerts.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_clamp.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_estimate.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_ledger.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_loop.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_pricing.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_core.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_extras.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_proxy_streaming.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_upstreams.py +0 -0
- {agentcap_cli-0.1.2 → agentcap_cli-0.1.3}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentcap-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit.
|
|
5
5
|
Project-URL: Homepage, https://agentcap.launchbench.studio
|
|
6
6
|
Project-URL: Repository, https://github.com/Blitz00X/agentcap
|
|
@@ -71,6 +71,8 @@ pipx install agentcap-cli
|
|
|
71
71
|
pip install agentcap-cli
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
|
|
75
|
+
|
|
74
76
|
From a checkout:
|
|
75
77
|
|
|
76
78
|
```bash
|
|
@@ -187,6 +189,8 @@ Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google'
|
|
|
187
189
|
usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
|
|
188
190
|
tested end-to-end against a local fake API only, not yet with real paid keys.
|
|
189
191
|
|
|
192
|
+
**Platforms:** Linux and macOS. Windows: not yet (use WSL2).
|
|
193
|
+
|
|
190
194
|
## How it works
|
|
191
195
|
|
|
192
196
|
1. `agentcap run` starts the proxy on a random free port, bound **only** to
|
|
@@ -348,6 +352,8 @@ Pick what matches your setup — these tools can also be combined.
|
|
|
348
352
|
|
|
349
353
|
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
|
|
350
354
|
|
|
355
|
+
After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
|
|
356
|
+
|
|
351
357
|
The Pro licence key is separate from the source licence. How it is checked:
|
|
352
358
|
|
|
353
359
|
- On activation the key is validated against Polar's public licence API
|
|
@@ -41,6 +41,8 @@ pipx install agentcap-cli
|
|
|
41
41
|
pip install agentcap-cli
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
No pipx? macOS: `brew install pipx && pipx ensurepath`; Linux: `python3 -m pip install --user pipx && python3 -m pipx ensurepath`.
|
|
45
|
+
|
|
44
46
|
From a checkout:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
@@ -157,6 +159,8 @@ Tested with a real Gemini API key on Gemini CLI 0.66 (spend metered from Google'
|
|
|
157
159
|
usageMetadata, output clamped to the cap); Gemini CLI 0.58 and Codex CLI 0.163 are
|
|
158
160
|
tested end-to-end against a local fake API only, not yet with real paid keys.
|
|
159
161
|
|
|
162
|
+
**Platforms:** Linux and macOS. Windows: not yet (use WSL2).
|
|
163
|
+
|
|
160
164
|
## How it works
|
|
161
165
|
|
|
162
166
|
1. `agentcap run` starts the proxy on a random free port, bound **only** to
|
|
@@ -318,6 +322,8 @@ Pick what matches your setup — these tools can also be combined.
|
|
|
318
322
|
|
|
319
323
|
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Kutay Karahan.
|
|
320
324
|
|
|
325
|
+
After purchase, copy your license key from your Polar order (purchase email / Polar customer portal), then run `agentcap license activate "YOUR_LICENSE_KEY"` and check with `agentcap license status` (expect `tier: pro`).
|
|
326
|
+
|
|
321
327
|
The Pro licence key is separate from the source licence. How it is checked:
|
|
322
328
|
|
|
323
329
|
- On activation the key is validated against Polar's public licence API
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentcap-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "API-key spend guard for unattended Codex CLI and Gemini CLI runs: hard per-run cap, kills the process at the limit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -33,11 +33,33 @@ from .upstreams import UpstreamConfigError, resolve_upstreams
|
|
|
33
33
|
|
|
34
34
|
DEFAULT_SERVE_PORT = 8787
|
|
35
35
|
|
|
36
|
+
WINDOWS_UNSUPPORTED = (
|
|
37
|
+
"agentcap: Windows is not supported yet (process-group stop is POSIX-only). "
|
|
38
|
+
"Use WSL2, macOS or Linux."
|
|
39
|
+
)
|
|
40
|
+
PRO_CAP_MESSAGE = (
|
|
41
|
+
"agentcap: --hour-cap/--day-cap require an active Pro license; "
|
|
42
|
+
"command was not started. Run `agentcap license status`, then "
|
|
43
|
+
'`agentcap license activate "YOUR_LICENSE_KEY"`.'
|
|
44
|
+
)
|
|
45
|
+
LICENSE_USAGE_MESSAGE = (
|
|
46
|
+
'Usage: agentcap license activate "YOUR_LICENSE_KEY" '
|
|
47
|
+
"(copy the key from your Polar purchase / customer portal)"
|
|
48
|
+
)
|
|
49
|
+
LICENSE_INVALID_MESSAGE = (
|
|
50
|
+
"License key not found or no longer valid. Check the copied key; "
|
|
51
|
+
"if it still fails, email kutaykarahan6@gmail.com."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _windows_unsupported() -> bool:
|
|
56
|
+
return os.name == "nt" or sys.platform.startswith("win")
|
|
57
|
+
|
|
36
58
|
|
|
37
59
|
def _build_parser() -> argparse.ArgumentParser:
|
|
38
60
|
parser = argparse.ArgumentParser(
|
|
39
61
|
prog="agentcap",
|
|
40
|
-
description="Local
|
|
62
|
+
description="Local USD spend cap for AI agent runs.",
|
|
41
63
|
)
|
|
42
64
|
parser.add_argument("--version", action="version", version=f"agentcap {__version__}")
|
|
43
65
|
subparsers = parser.add_subparsers(dest="subcommand")
|
|
@@ -166,19 +188,41 @@ def _license_manager() -> LicenseManager:
|
|
|
166
188
|
return LicenseManager()
|
|
167
189
|
|
|
168
190
|
|
|
191
|
+
def _license_persist_error(exc: OSError, path) -> str:
|
|
192
|
+
detail = exc.strerror or str(exc)
|
|
193
|
+
return (
|
|
194
|
+
"agentcap: license validated, but could not save activation to "
|
|
195
|
+
f"{path}: {detail}. Check write permission and free disk space."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
169
199
|
def _license(args: argparse.Namespace) -> int:
|
|
200
|
+
manager = _license_manager()
|
|
170
201
|
if args.action == "activate":
|
|
171
202
|
if not args.key:
|
|
172
|
-
print(
|
|
203
|
+
print(LICENSE_USAGE_MESSAGE, file=sys.stderr)
|
|
173
204
|
return 2
|
|
174
|
-
|
|
205
|
+
try:
|
|
206
|
+
status = manager.activate(args.key)
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
print(_license_persist_error(exc, manager.path), file=sys.stderr)
|
|
209
|
+
return 1
|
|
175
210
|
print(f"tier: {status.tier}")
|
|
176
211
|
print(f"valid: {status.valid}")
|
|
177
212
|
print(f"reason: {status.reason}")
|
|
213
|
+
if (
|
|
214
|
+
not status.valid
|
|
215
|
+
and "could not reach the license server" not in status.reason
|
|
216
|
+
):
|
|
217
|
+
print(LICENSE_INVALID_MESSAGE, file=sys.stderr)
|
|
178
218
|
return 0 if status.valid else 1
|
|
179
219
|
|
|
180
220
|
if args.action == "deactivate":
|
|
181
|
-
|
|
221
|
+
try:
|
|
222
|
+
manager.clear()
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
print(_license_persist_error(exc, manager.path), file=sys.stderr)
|
|
225
|
+
return 1
|
|
182
226
|
print("license cache cleared")
|
|
183
227
|
return 0
|
|
184
228
|
|
|
@@ -189,6 +233,7 @@ def _license(args: argparse.Namespace) -> int:
|
|
|
189
233
|
print(f"tier: {status.tier}")
|
|
190
234
|
print(f"valid: {status.valid}")
|
|
191
235
|
print(f"reason: {status.reason}")
|
|
236
|
+
print(f"cache path: {manager.path}")
|
|
192
237
|
return 0
|
|
193
238
|
|
|
194
239
|
|
|
@@ -219,6 +264,9 @@ def _upstream_overrides() -> dict[str, str] | None:
|
|
|
219
264
|
|
|
220
265
|
|
|
221
266
|
def _run(args: argparse.Namespace) -> int:
|
|
267
|
+
if _windows_unsupported():
|
|
268
|
+
print(WINDOWS_UNSUPPORTED, file=sys.stderr)
|
|
269
|
+
return 2
|
|
222
270
|
argv = list(args.argv)
|
|
223
271
|
if argv and argv[0] == "--":
|
|
224
272
|
argv = argv[1:]
|
|
@@ -244,18 +292,12 @@ def _run(args: argparse.Namespace) -> int:
|
|
|
244
292
|
return 2
|
|
245
293
|
|
|
246
294
|
active_features = set(features(status_from_environment().tier))
|
|
247
|
-
if
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
if day_cap is not None and "day_cap" not in active_features:
|
|
254
|
-
print(
|
|
255
|
-
"agentcap: --day-cap requires AgentCap Pro; ignoring it on the free tier.",
|
|
256
|
-
file=sys.stderr,
|
|
257
|
-
)
|
|
258
|
-
day_cap = None
|
|
295
|
+
if (
|
|
296
|
+
(hour_cap is not None and "hour_cap" not in active_features)
|
|
297
|
+
or (day_cap is not None and "day_cap" not in active_features)
|
|
298
|
+
):
|
|
299
|
+
print(PRO_CAP_MESSAGE, file=sys.stderr)
|
|
300
|
+
return 2
|
|
259
301
|
|
|
260
302
|
try:
|
|
261
303
|
runner = Runner(
|
|
@@ -327,6 +369,9 @@ def _serve(args: argparse.Namespace) -> int:
|
|
|
327
369
|
|
|
328
370
|
# ------------------------------------------------------------------------ demo
|
|
329
371
|
def _demo(_args: argparse.Namespace) -> int:
|
|
372
|
+
if _windows_unsupported():
|
|
373
|
+
print(WINDOWS_UNSUPPORTED, file=sys.stderr)
|
|
374
|
+
return 2
|
|
330
375
|
from .demo import run_demo
|
|
331
376
|
|
|
332
377
|
return run_demo()
|
|
@@ -569,16 +569,23 @@ class LicenseManager:
|
|
|
569
569
|
|
|
570
570
|
if status.valid:
|
|
571
571
|
if self.persists:
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
572
|
+
try:
|
|
573
|
+
self._write(
|
|
574
|
+
effective_key,
|
|
575
|
+
status,
|
|
576
|
+
now,
|
|
577
|
+
activation_id,
|
|
578
|
+
last_attempt_at=now,
|
|
579
|
+
)
|
|
580
|
+
except OSError:
|
|
581
|
+
# A read-only cache must not break a validated run.
|
|
582
|
+
pass
|
|
579
583
|
elif same_key is not None and self.persists:
|
|
580
584
|
# Definitive rejection: make sure a stale Pro cache cannot survive.
|
|
581
|
-
|
|
585
|
+
try:
|
|
586
|
+
self.clear()
|
|
587
|
+
except OSError:
|
|
588
|
+
pass
|
|
582
589
|
return status
|
|
583
590
|
|
|
584
591
|
def _offline(
|
|
@@ -8,8 +8,10 @@ Design rules that this module enforces:
|
|
|
8
8
|
the request (HTTP 403/400) instead of forwarding it unmetered.
|
|
9
9
|
* Credentials (``Authorization``, ``x-goog-api-key``, ``?key=``) are forwarded
|
|
10
10
|
untouched but never parsed, logged, stored or emitted in events.
|
|
11
|
-
* Failures to meter
|
|
12
|
-
|
|
11
|
+
* Failures to meter settle the reservation conservatively: a mid-stream
|
|
12
|
+
disconnect or a missing usage block charges the whole reservation, while a
|
|
13
|
+
disconnect before upstream headers charges only the estimated input (output
|
|
14
|
+
was never delivered).
|
|
13
15
|
|
|
14
16
|
Endpoint contract::
|
|
15
17
|
|
|
@@ -112,6 +114,10 @@ class _Settlement:
|
|
|
112
114
|
requested_tier: str | None = None
|
|
113
115
|
multiplier: Decimal = Decimal("1")
|
|
114
116
|
now: datetime | None = None
|
|
117
|
+
#: Padded input-token estimate used to reserve this request. If the client
|
|
118
|
+
#: disconnects before the upstream answers, this is the part the provider
|
|
119
|
+
#: may already have billed.
|
|
120
|
+
input_tokens: int = 0
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
@dataclass(frozen=True)
|
|
@@ -851,6 +857,7 @@ class _Proxy:
|
|
|
851
857
|
requested_tier=requested_tier,
|
|
852
858
|
multiplier=tier_multiplier,
|
|
853
859
|
now=now,
|
|
860
|
+
input_tokens=input_tokens,
|
|
854
861
|
)
|
|
855
862
|
|
|
856
863
|
if streaming:
|
|
@@ -966,9 +973,18 @@ class _Proxy:
|
|
|
966
973
|
"agentcap: upstream request failed; reservation released.",
|
|
967
974
|
"agentcap_upstream_error",
|
|
968
975
|
)
|
|
976
|
+
except BaseException:
|
|
977
|
+
# The client went away (or the handler was cancelled) while waiting
|
|
978
|
+
# for the upstream: charge only the input the provider may already
|
|
979
|
+
# have billed, then let the cancellation propagate.
|
|
980
|
+
await self._abort_response(response)
|
|
981
|
+
self._settle_client_disconnect(
|
|
982
|
+
reservation_id, reserved, settlement, model, provider
|
|
983
|
+
)
|
|
984
|
+
raise
|
|
969
985
|
finally:
|
|
970
986
|
if response is not None:
|
|
971
|
-
with contextlib.suppress(
|
|
987
|
+
with contextlib.suppress(BaseException):
|
|
972
988
|
await response.aclose()
|
|
973
989
|
|
|
974
990
|
assert response is not None
|
|
@@ -1006,6 +1022,7 @@ class _Proxy:
|
|
|
1006
1022
|
kind: str,
|
|
1007
1023
|
provider: str,
|
|
1008
1024
|
) -> Response:
|
|
1025
|
+
response: httpx.Response | None = None
|
|
1009
1026
|
try:
|
|
1010
1027
|
response = await self._client.send(upstream, stream=True)
|
|
1011
1028
|
except httpx.HTTPError:
|
|
@@ -1015,6 +1032,14 @@ class _Proxy:
|
|
|
1015
1032
|
"agentcap: upstream request failed; reservation released.",
|
|
1016
1033
|
"agentcap_upstream_error",
|
|
1017
1034
|
)
|
|
1035
|
+
except BaseException:
|
|
1036
|
+
# Cancelled (or the client disconnected) before the upstream
|
|
1037
|
+
# produced headers. Abort and charge the estimated input only.
|
|
1038
|
+
await self._abort_response(response)
|
|
1039
|
+
self._settle_client_disconnect(
|
|
1040
|
+
reservation_id, reserved, settlement, model, provider
|
|
1041
|
+
)
|
|
1042
|
+
raise
|
|
1018
1043
|
|
|
1019
1044
|
if response.status_code >= 400:
|
|
1020
1045
|
# Errors are not streams; drain, release, and replay as a response.
|
|
@@ -1027,8 +1052,14 @@ class _Proxy:
|
|
|
1027
1052
|
"agentcap: upstream request failed; reservation released.",
|
|
1028
1053
|
"agentcap_upstream_error",
|
|
1029
1054
|
)
|
|
1055
|
+
except BaseException:
|
|
1056
|
+
await self._abort_response(response)
|
|
1057
|
+
self._settle_client_disconnect(
|
|
1058
|
+
reservation_id, reserved, settlement, model, provider
|
|
1059
|
+
)
|
|
1060
|
+
raise
|
|
1030
1061
|
finally:
|
|
1031
|
-
with contextlib.suppress(
|
|
1062
|
+
with contextlib.suppress(BaseException):
|
|
1032
1063
|
await response.aclose()
|
|
1033
1064
|
self._release_quietly(reservation_id)
|
|
1034
1065
|
return Response(
|
|
@@ -1105,6 +1136,71 @@ class _Proxy:
|
|
|
1105
1136
|
except Exception:
|
|
1106
1137
|
pass
|
|
1107
1138
|
|
|
1139
|
+
@staticmethod
|
|
1140
|
+
async def _abort_response(response: httpx.Response | None) -> None:
|
|
1141
|
+
"""Close an in-flight upstream response without hiding cancellation."""
|
|
1142
|
+
|
|
1143
|
+
if response is None:
|
|
1144
|
+
return
|
|
1145
|
+
with contextlib.suppress(BaseException):
|
|
1146
|
+
await response.aclose()
|
|
1147
|
+
|
|
1148
|
+
def _input_only_cost(self, settlement: _Settlement) -> Decimal | None:
|
|
1149
|
+
"""Cost of the prompt alone: provider may have billed it before close."""
|
|
1150
|
+
|
|
1151
|
+
try:
|
|
1152
|
+
charged = pricing_cost(
|
|
1153
|
+
settlement.price,
|
|
1154
|
+
settlement.input_tokens,
|
|
1155
|
+
0,
|
|
1156
|
+
0,
|
|
1157
|
+
now=settlement.now,
|
|
1158
|
+
cache_write=settlement.cache_write,
|
|
1159
|
+
)
|
|
1160
|
+
if settlement.multiplier != 1:
|
|
1161
|
+
charged = (charged * settlement.multiplier).quantize(
|
|
1162
|
+
MICRO_USD, rounding=ROUND_CEILING
|
|
1163
|
+
)
|
|
1164
|
+
return charged
|
|
1165
|
+
except Exception:
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
def _settle_client_disconnect(
|
|
1169
|
+
self,
|
|
1170
|
+
reservation_id: str,
|
|
1171
|
+
reserved: Decimal,
|
|
1172
|
+
settlement: _Settlement,
|
|
1173
|
+
model: str,
|
|
1174
|
+
provider: str,
|
|
1175
|
+
) -> None:
|
|
1176
|
+
"""Settle a reservation after a client disconnect / cancellation.
|
|
1177
|
+
|
|
1178
|
+
Output is assumed to be zero because the connection was closed; the
|
|
1179
|
+
provider may still have billed the input. The reservation must never
|
|
1180
|
+
be left open, so a fallback charge (the full reservation) is used if
|
|
1181
|
+
the input cost cannot be computed, and a failed settle is released.
|
|
1182
|
+
"""
|
|
1183
|
+
|
|
1184
|
+
charged = self._input_only_cost(settlement)
|
|
1185
|
+
if charged is None:
|
|
1186
|
+
charged = reserved
|
|
1187
|
+
try:
|
|
1188
|
+
self.ledger.settle(reservation_id, charged)
|
|
1189
|
+
except Exception:
|
|
1190
|
+
self._release_quietly(reservation_id)
|
|
1191
|
+
self._emit(
|
|
1192
|
+
{
|
|
1193
|
+
"type": "client_disconnect",
|
|
1194
|
+
"reserved_usd": str(reserved),
|
|
1195
|
+
"charged_usd": str(charged),
|
|
1196
|
+
}
|
|
1197
|
+
)
|
|
1198
|
+
try:
|
|
1199
|
+
self._check_thresholds()
|
|
1200
|
+
except Exception:
|
|
1201
|
+
pass
|
|
1202
|
+
self._trip_if_cap_reached(model, provider)
|
|
1203
|
+
|
|
1108
1204
|
def _settle_actual(
|
|
1109
1205
|
self,
|
|
1110
1206
|
reservation_id: str,
|
|
@@ -192,6 +192,9 @@ class Runner:
|
|
|
192
192
|
self.kill_reason: str | None = None
|
|
193
193
|
self._kill_lock = threading.Lock()
|
|
194
194
|
self.child: subprocess.Popen | None = None
|
|
195
|
+
#: Process-group id captured at spawn, so the group can still be
|
|
196
|
+
#: signalled after the leader has exited (orphaned grandchildren).
|
|
197
|
+
self._pgid: int | None = None
|
|
195
198
|
self.summary: RunSummary | None = None
|
|
196
199
|
|
|
197
200
|
self.alerts = alerts or dispatcher_from_env(
|
|
@@ -361,28 +364,41 @@ class Runner:
|
|
|
361
364
|
def _spawn(
|
|
362
365
|
self, argv: Sequence[str] | None = None, env: Mapping[str, str] | None = None
|
|
363
366
|
) -> subprocess.Popen:
|
|
364
|
-
|
|
367
|
+
child = self.popen_factory(
|
|
365
368
|
self.argv if argv is None else argv,
|
|
366
369
|
self.child_env() if env is None else env,
|
|
367
370
|
)
|
|
371
|
+
# Capture the group now: once the leader exits, os.getpgid(pid) fails
|
|
372
|
+
# even though the group (and its children) may still be alive.
|
|
373
|
+
try:
|
|
374
|
+
self._pgid = os.getpgid(child.pid)
|
|
375
|
+
except (ProcessLookupError, OSError):
|
|
376
|
+
self._pgid = None
|
|
377
|
+
return child
|
|
368
378
|
|
|
369
379
|
def _signal_group(self, sig: int) -> None:
|
|
370
380
|
child = self.child
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
pgid = self._pgid
|
|
382
|
+
if pgid is None and child is not None and child.poll() is None:
|
|
383
|
+
try:
|
|
384
|
+
pgid = os.getpgid(child.pid)
|
|
385
|
+
except (ProcessLookupError, OSError):
|
|
386
|
+
pgid = None
|
|
387
|
+
|
|
388
|
+
if pgid is not None and pgid != os.getpgrp():
|
|
389
|
+
try:
|
|
379
390
|
os.killpg(pgid, sig)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
391
|
+
return
|
|
392
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
# A custom popen factory may not have created a new session; never
|
|
396
|
+
# signal our own process group, and only signal a live leader.
|
|
397
|
+
if child is not None and child.poll() is None:
|
|
398
|
+
try:
|
|
383
399
|
child.send_signal(sig)
|
|
384
|
-
|
|
385
|
-
|
|
400
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
401
|
+
pass
|
|
386
402
|
|
|
387
403
|
def _terminate(self) -> None:
|
|
388
404
|
self._signal_group(signal.SIGTERM)
|
|
@@ -391,9 +407,10 @@ class Runner:
|
|
|
391
407
|
return
|
|
392
408
|
try:
|
|
393
409
|
child.wait(timeout=self.term_timeout)
|
|
394
|
-
return
|
|
395
410
|
except subprocess.TimeoutExpired:
|
|
396
411
|
pass
|
|
412
|
+
# Even if the leader exited within the grace period, grandchildren may
|
|
413
|
+
# still be running in its process group: SIGKILL the saved group too.
|
|
397
414
|
self._signal_group(signal.SIGKILL)
|
|
398
415
|
try:
|
|
399
416
|
child.wait(timeout=5.0)
|
|
@@ -69,16 +69,22 @@ def test_run_cap_comes_from_env(fake_runner, monkeypatch):
|
|
|
69
69
|
assert fake_runner.instances[1].kwargs["cap"] == Decimal("0.25")
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def
|
|
72
|
+
def test_free_tier_refuses_hour_and_day_caps_without_starting(fake_runner, capsys):
|
|
73
73
|
assert cli.main(
|
|
74
74
|
["run", "--hour-cap", "1", "--day-cap", "2", "--", "true"]
|
|
75
|
-
) ==
|
|
76
|
-
|
|
77
|
-
assert runner.kwargs["hour_cap"] is None
|
|
78
|
-
assert runner.kwargs["day_cap"] is None
|
|
75
|
+
) == 2
|
|
76
|
+
assert fake_runner.instances == []
|
|
79
77
|
err = capsys.readouterr().err
|
|
80
|
-
assert "--hour-cap
|
|
81
|
-
assert "
|
|
78
|
+
assert "--hour-cap/--day-cap require an active Pro license" in err
|
|
79
|
+
assert "command was not started" in err
|
|
80
|
+
assert 'agentcap license activate "YOUR_LICENSE_KEY"' in err
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("flag", ["--hour-cap", "--day-cap"])
|
|
84
|
+
def test_free_tier_refuses_each_pro_cap_individually(fake_runner, capsys, flag):
|
|
85
|
+
assert cli.main(["run", flag, "1", "--", "true"]) == 2
|
|
86
|
+
assert fake_runner.instances == []
|
|
87
|
+
assert "require an active Pro license" in capsys.readouterr().err
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
def test_pro_license_enables_hour_and_day_caps(fake_runner, tmp_path, capsys):
|
|
@@ -234,7 +240,127 @@ def test_license_deactivate_clears_local_cache(monkeypatch, tmp_path, capsys):
|
|
|
234
240
|
def test_license_activate_without_key_fails(monkeypatch, tmp_path, capsys):
|
|
235
241
|
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(tmp_path / "license.json"))
|
|
236
242
|
assert cli.main(["license", "activate"]) == 2
|
|
237
|
-
|
|
243
|
+
err = capsys.readouterr().err
|
|
244
|
+
assert 'Usage: agentcap license activate "YOUR_LICENSE_KEY"' in err
|
|
245
|
+
assert "Polar purchase / customer portal" in err
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_license_activate_invalid_key_prints_actionable_message(
|
|
249
|
+
monkeypatch, tmp_path, capsys
|
|
250
|
+
):
|
|
251
|
+
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(tmp_path / "license.json"))
|
|
252
|
+
|
|
253
|
+
class DenyingPolar:
|
|
254
|
+
persist = True
|
|
255
|
+
|
|
256
|
+
def activate(self, key):
|
|
257
|
+
return LicenseStatus(False, "free", "license key not found"), None
|
|
258
|
+
|
|
259
|
+
def validate(self, key, activation_id=None):
|
|
260
|
+
return LicenseStatus(False, "free", "license key not found")
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(license_module, "PolarLicenseValidator", DenyingPolar)
|
|
263
|
+
|
|
264
|
+
assert cli.main(["license", "activate", "wrong-key"]) == 1
|
|
265
|
+
captured = capsys.readouterr()
|
|
266
|
+
assert "tier: free" in captured.out
|
|
267
|
+
assert "License key not found or no longer valid" in captured.err
|
|
268
|
+
assert "kutaykarahan6@gmail.com" in captured.err
|
|
269
|
+
assert "wrong-key" not in captured.out + captured.err
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_license_status_prints_cache_path_but_never_the_key(
|
|
273
|
+
monkeypatch, tmp_path, capsys
|
|
274
|
+
):
|
|
275
|
+
path = tmp_path / "license.json"
|
|
276
|
+
write_pro_cache(path)
|
|
277
|
+
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
278
|
+
|
|
279
|
+
assert cli.main(["license", "status"]) == 0
|
|
280
|
+
out = capsys.readouterr().out
|
|
281
|
+
assert f"cache path: {path}" in out
|
|
282
|
+
assert "polar-key" not in out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_license_activate_save_error_is_reported_without_traceback(
|
|
286
|
+
monkeypatch, tmp_path, capsys
|
|
287
|
+
):
|
|
288
|
+
path = tmp_path / "license.json"
|
|
289
|
+
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
290
|
+
|
|
291
|
+
class GrantingPolar:
|
|
292
|
+
persist = True
|
|
293
|
+
|
|
294
|
+
def activate(self, key):
|
|
295
|
+
return LicenseStatus(True, "pro", "license valid"), "act_123"
|
|
296
|
+
|
|
297
|
+
def validate(self, key, activation_id=None):
|
|
298
|
+
return LicenseStatus(True, "pro", "license valid")
|
|
299
|
+
|
|
300
|
+
monkeypatch.setattr(license_module, "PolarLicenseValidator", GrantingPolar)
|
|
301
|
+
|
|
302
|
+
def fail_write(*args, **kwargs):
|
|
303
|
+
raise OSError(28, "No space left on device")
|
|
304
|
+
|
|
305
|
+
monkeypatch.setattr(license_module.LicenseManager, "_write", fail_write)
|
|
306
|
+
|
|
307
|
+
assert cli.main(["license", "activate", "polar-key-secret"]) == 1
|
|
308
|
+
captured = capsys.readouterr()
|
|
309
|
+
assert captured.err.startswith("agentcap: license validated, but could not save"
|
|
310
|
+
" activation to ")
|
|
311
|
+
assert str(path) in captured.err
|
|
312
|
+
assert "No space left on device" in captured.err
|
|
313
|
+
assert "Check write permission and free disk space." in captured.err
|
|
314
|
+
assert "Traceback" not in captured.err
|
|
315
|
+
assert "polar-key-secret" not in captured.out + captured.err
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_license_deactivate_clear_error_is_reported_without_traceback(
|
|
319
|
+
monkeypatch, tmp_path, capsys
|
|
320
|
+
):
|
|
321
|
+
path = tmp_path / "license.json"
|
|
322
|
+
monkeypatch.setenv("AGENTCAP_LICENSE_FILE", str(path))
|
|
323
|
+
|
|
324
|
+
def fail_clear(self):
|
|
325
|
+
raise OSError(13, "Permission denied")
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr(license_module.LicenseManager, "clear", fail_clear)
|
|
328
|
+
|
|
329
|
+
assert cli.main(["license", "deactivate"]) == 1
|
|
330
|
+
captured = capsys.readouterr()
|
|
331
|
+
assert "license validated, but could not save activation" in captured.err
|
|
332
|
+
assert str(path) in captured.err
|
|
333
|
+
assert "Permission denied" in captured.err
|
|
334
|
+
assert "Traceback" not in captured.err
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@pytest.mark.parametrize("platform_name", ["nt"])
|
|
338
|
+
def test_run_refuses_windows_before_starting(
|
|
339
|
+
monkeypatch, fake_runner, capsys, platform_name
|
|
340
|
+
):
|
|
341
|
+
monkeypatch.setattr(cli.os, "name", platform_name)
|
|
342
|
+
assert cli.main(["run", "--", "true"]) == 2
|
|
343
|
+
assert fake_runner.instances == []
|
|
344
|
+
err = capsys.readouterr().err
|
|
345
|
+
assert "Windows is not supported yet" in err
|
|
346
|
+
assert "Use WSL2, macOS or Linux." in err
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_run_refuses_windows_when_only_sys_platform_is_win(
|
|
350
|
+
monkeypatch, fake_runner, capsys
|
|
351
|
+
):
|
|
352
|
+
monkeypatch.setattr(cli.sys, "platform", "win32")
|
|
353
|
+
assert cli.main(["run", "--", "true"]) == 2
|
|
354
|
+
assert fake_runner.instances == []
|
|
355
|
+
assert "Windows is not supported yet" in capsys.readouterr().err
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_demo_refuses_windows_before_starting(monkeypatch, capsys):
|
|
359
|
+
monkeypatch.setattr(cli.os, "name", "nt")
|
|
360
|
+
assert cli.main(["demo"]) == 2
|
|
361
|
+
captured = capsys.readouterr()
|
|
362
|
+
assert "Windows is not supported yet" in captured.err
|
|
363
|
+
assert "SIMULATED DEMO" not in captured.out
|
|
238
364
|
|
|
239
365
|
|
|
240
366
|
def test_license_activate_network_failure_exits_one_without_traceback(
|
|
@@ -810,6 +810,45 @@ def test_clear_removes_license(tmp_path):
|
|
|
810
810
|
assert manager.tier() == "free"
|
|
811
811
|
|
|
812
812
|
|
|
813
|
+
def test_activate_propagates_oserror_when_activation_cannot_be_saved(
|
|
814
|
+
tmp_path, monkeypatch
|
|
815
|
+
):
|
|
816
|
+
class PersistingValidator:
|
|
817
|
+
persist = True
|
|
818
|
+
|
|
819
|
+
def activate(self, key):
|
|
820
|
+
return LicenseStatus(True, "pro", "static license accepted"), None
|
|
821
|
+
|
|
822
|
+
def validate(self, key, activation_id=None):
|
|
823
|
+
return LicenseStatus(True, "pro", "static license accepted")
|
|
824
|
+
|
|
825
|
+
manager = LicenseManager(
|
|
826
|
+
PersistingValidator(),
|
|
827
|
+
tmp_path / "license.json",
|
|
828
|
+
clock=FakeClock(),
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
def fail_write(*args, **kwargs):
|
|
832
|
+
raise OSError(28, "No space left on device")
|
|
833
|
+
|
|
834
|
+
monkeypatch.setattr(manager, "_write", fail_write)
|
|
835
|
+
with pytest.raises(OSError) as excinfo:
|
|
836
|
+
manager.activate("secret-key")
|
|
837
|
+
assert excinfo.value.strerror == "No space left on device"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def test_clear_propagates_oserror(tmp_path):
|
|
841
|
+
# A directory cannot be unlinked with Path.unlink(), which raises OSError
|
|
842
|
+
# (IsADirectoryError/PermissionError depending on platform).
|
|
843
|
+
manager = LicenseManager(
|
|
844
|
+
StaticValidator(LicenseStatus(True, "pro", "static license accepted")),
|
|
845
|
+
tmp_path,
|
|
846
|
+
clock=FakeClock(),
|
|
847
|
+
)
|
|
848
|
+
with pytest.raises(OSError):
|
|
849
|
+
manager.clear()
|
|
850
|
+
|
|
851
|
+
|
|
813
852
|
def test_key_never_appears_in_reasons():
|
|
814
853
|
clock = FakeClock()
|
|
815
854
|
scenarios = [
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Client disconnects must never leave a reservation open."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from agentcap.estimate import (
|
|
12
|
+
apply_input_safety_factor,
|
|
13
|
+
estimate_input_tokens,
|
|
14
|
+
)
|
|
15
|
+
from agentcap.ledger import Caps, Ledger
|
|
16
|
+
from agentcap.pricing import cost as pricing_cost
|
|
17
|
+
from agentcap.pricing import load_pricing
|
|
18
|
+
from agentcap.proxy import ProxyConfig, create_app
|
|
19
|
+
|
|
20
|
+
from proxy_support import D, costs, openai_chat_body
|
|
21
|
+
|
|
22
|
+
STORE = load_pricing()
|
|
23
|
+
CHAT_URL = "/openai/v1/chat/completions"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SlowTransport(httpx.AsyncBaseTransport):
|
|
27
|
+
"""Blocks after the reservation is made, before upstream headers arrive."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.started = asyncio.Event()
|
|
31
|
+
self.release = asyncio.Event()
|
|
32
|
+
|
|
33
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
34
|
+
self.started.set()
|
|
35
|
+
await self.release.wait()
|
|
36
|
+
return httpx.Response(
|
|
37
|
+
200,
|
|
38
|
+
json={
|
|
39
|
+
"choices": [],
|
|
40
|
+
"usage": {"prompt_tokens": 1000, "completion_tokens": 500},
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _expected_input_only(body: dict) -> Decimal:
|
|
46
|
+
price = STORE.get("openai/gpt-4o-mini")
|
|
47
|
+
input_tokens = apply_input_safety_factor(
|
|
48
|
+
estimate_input_tokens(body), Decimal("1.25")
|
|
49
|
+
)
|
|
50
|
+
return pricing_cost(price, input_tokens, 0, 0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("stream", [False, True])
|
|
54
|
+
def test_cancelled_request_settles_input_only_and_leaves_no_reservation(
|
|
55
|
+
tmp_path, stream
|
|
56
|
+
):
|
|
57
|
+
body = openai_chat_body(max_completion_tokens=200, stream=stream)
|
|
58
|
+
expected = _expected_input_only(body)
|
|
59
|
+
|
|
60
|
+
async def scenario():
|
|
61
|
+
transport = SlowTransport()
|
|
62
|
+
ledger = Ledger(tmp_path / f"cancel-{stream}.db")
|
|
63
|
+
events: list[dict] = []
|
|
64
|
+
config = ProxyConfig(run_id="run-1", caps=Caps(run_usd=D("1")))
|
|
65
|
+
app = create_app(
|
|
66
|
+
config,
|
|
67
|
+
ledger,
|
|
68
|
+
upstream_transport=transport,
|
|
69
|
+
events=events.append,
|
|
70
|
+
)
|
|
71
|
+
proxy = app.state.proxy
|
|
72
|
+
async with httpx.AsyncClient(
|
|
73
|
+
transport=httpx.ASGITransport(app=app), base_url="http://proxy"
|
|
74
|
+
) as client:
|
|
75
|
+
task = asyncio.create_task(client.post(CHAT_URL, json=body))
|
|
76
|
+
await asyncio.wait_for(transport.started.wait(), timeout=5)
|
|
77
|
+
task.cancel()
|
|
78
|
+
with pytest.raises(asyncio.CancelledError):
|
|
79
|
+
await task
|
|
80
|
+
await app.state.http_client.aclose()
|
|
81
|
+
return ledger, events, proxy
|
|
82
|
+
|
|
83
|
+
ledger, events, proxy = asyncio.run(scenario())
|
|
84
|
+
|
|
85
|
+
summary = ledger.summary("run-1")
|
|
86
|
+
assert summary["open"] == 0
|
|
87
|
+
assert summary["settled"] == 1
|
|
88
|
+
assert summary["actual_usd"] == expected
|
|
89
|
+
assert ledger.spent("run-1") == expected
|
|
90
|
+
|
|
91
|
+
disconnect = costs(events, "client_disconnect")
|
|
92
|
+
assert len(disconnect) == 1
|
|
93
|
+
assert Decimal(disconnect[0]["charged_usd"]) == expected
|
|
94
|
+
assert Decimal(disconnect[0]["reserved_usd"]) >= expected
|
|
95
|
+
|
|
96
|
+
# The input-only charge is far below the cap: no false kill.
|
|
97
|
+
assert proxy.killed is False
|
|
98
|
+
assert "cap_exceeded" not in [event["type"] for event in events]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import io
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
9
10
|
from decimal import Decimal
|
|
@@ -372,3 +373,56 @@ def test_cap_from_env(monkeypatch):
|
|
|
372
373
|
assert cap_from_env() == Decimal("0.25")
|
|
373
374
|
monkeypatch.setenv("AGENTCAP_CAP", "not-money")
|
|
374
375
|
assert cap_from_env() == Decimal("5")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _process_alive(pid: int) -> bool:
|
|
379
|
+
try:
|
|
380
|
+
os.kill(pid, 0)
|
|
381
|
+
except ProcessLookupError:
|
|
382
|
+
return False
|
|
383
|
+
except PermissionError:
|
|
384
|
+
return True
|
|
385
|
+
# A zombie is already dead for our purposes; the important thing is that
|
|
386
|
+
# it no longer runs (and will be reaped by init/subreaper).
|
|
387
|
+
try:
|
|
388
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
389
|
+
except OSError:
|
|
390
|
+
return True
|
|
391
|
+
return stat.split()[2] != "Z"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@pytest.mark.skipif(os.name == "nt", reason="process groups are POSIX-only")
|
|
395
|
+
def test_terminate_kills_grandchild_after_leader_already_exited(tmp_path):
|
|
396
|
+
"""The saved PGID must still kill descendants when the leader is gone."""
|
|
397
|
+
|
|
398
|
+
pidfile = tmp_path / "grandchild.pid"
|
|
399
|
+
leader = (
|
|
400
|
+
"import subprocess, sys, time\n"
|
|
401
|
+
"child = subprocess.Popen([\n"
|
|
402
|
+
" sys.executable, '-c',\n"
|
|
403
|
+
" 'import signal, time; "
|
|
404
|
+
"signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)',\n"
|
|
405
|
+
"])\n"
|
|
406
|
+
f"open({str(pidfile)!r}, 'w').write(str(child.pid))\n"
|
|
407
|
+
"time.sleep(0.3)\n"
|
|
408
|
+
"sys.exit(0)\n"
|
|
409
|
+
)
|
|
410
|
+
runner, _ = _runner(tmp_path, [sys.executable, "-c", "pass"])
|
|
411
|
+
runner.child = runner._spawn([sys.executable, "-c", leader], env=dict(os.environ))
|
|
412
|
+
|
|
413
|
+
runner.child.wait(timeout=5)
|
|
414
|
+
assert runner.child.poll() is not None
|
|
415
|
+
assert runner._pgid is not None
|
|
416
|
+
|
|
417
|
+
deadline = time.monotonic() + 5
|
|
418
|
+
while time.monotonic() < deadline and not pidfile.exists():
|
|
419
|
+
time.sleep(0.02)
|
|
420
|
+
grandchild = int(pidfile.read_text())
|
|
421
|
+
assert _process_alive(grandchild)
|
|
422
|
+
|
|
423
|
+
runner._terminate()
|
|
424
|
+
|
|
425
|
+
deadline = time.monotonic() + 5
|
|
426
|
+
while time.monotonic() < deadline and _process_alive(grandchild):
|
|
427
|
+
time.sleep(0.02)
|
|
428
|
+
assert not _process_alive(grandchild)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|