agentic-runner 3.6.0__tar.gz → 3.7.0__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.
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/PKG-INFO +1 -1
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/activities.py +12 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/service.py +78 -1
- agentic_runner-3.7.0/src/agentic_runner/usage_windows.py +529 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/acp_runtime.py +15 -1
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/agent_runtime.py +4 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_outcome.py +5 -5
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/.gitignore +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/LICENSE +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/README.md +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/pyproject.toml +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -118,6 +118,7 @@ from agentic_runner.message_store import MessageStore, WorkflowSignaller
|
|
|
118
118
|
from agentic_runner.registration import RunnerRegistrationError
|
|
119
119
|
from agentic_runner.runtime import verifier_command
|
|
120
120
|
from agentic_runner.runtime.verifier_command import CommandValidationError
|
|
121
|
+
from agentic_runner.usage_windows import UsageWindows
|
|
121
122
|
from agentic_runner.workers._runtime_support import (
|
|
122
123
|
RESERVED_DIRECTIVE_ENV,
|
|
123
124
|
bound_text,
|
|
@@ -174,6 +175,7 @@ from agentic_runner_contracts.activity_io import (
|
|
|
174
175
|
GitHubCallError,
|
|
175
176
|
GitHubCallFailure,
|
|
176
177
|
HarnessHold,
|
|
178
|
+
HarnessHoldKind,
|
|
177
179
|
LearningDirectiveInput,
|
|
178
180
|
LearningDirectiveOutput,
|
|
179
181
|
MemberDirectiveInput,
|
|
@@ -1616,10 +1618,13 @@ class RunnerRalphActivities:
|
|
|
1616
1618
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1617
1619
|
attempt_records: AttemptRecords | None = None,
|
|
1618
1620
|
directives_in_flight: DirectivesInFlight | None = None,
|
|
1621
|
+
usage_windows: UsageWindows | None = None,
|
|
1619
1622
|
) -> None:
|
|
1620
1623
|
self._fastapi_client = fastapi_client
|
|
1621
1624
|
# Read by the harness self-test, which never runs beside a Contract's Directive.
|
|
1622
1625
|
self._directives_in_flight = directives_in_flight or DirectivesInFlight()
|
|
1626
|
+
# Local-agents 10: None off a user-hosted Runner, which reports no usage windows.
|
|
1627
|
+
self._usage_windows = usage_windows
|
|
1623
1628
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
1624
1629
|
# wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
|
|
1625
1630
|
# so a real monotonic clock is allowed here (it must never be used in the
|
|
@@ -5273,6 +5278,13 @@ class RunnerRalphActivities:
|
|
|
5273
5278
|
error=result.error,
|
|
5274
5279
|
now=datetime.now(UTC),
|
|
5275
5280
|
)
|
|
5281
|
+
if self._usage_windows is not None and runtime_state.contract_id:
|
|
5282
|
+
self._usage_windows.after_directive(
|
|
5283
|
+
runtime_state.contract_id,
|
|
5284
|
+
runtime_state.cli_kind,
|
|
5285
|
+
result.usage_windows,
|
|
5286
|
+
usage_limit=hold is not None and hold.kind == HarnessHoldKind.USAGE_LIMIT,
|
|
5287
|
+
)
|
|
5276
5288
|
if hold is None:
|
|
5277
5289
|
return None
|
|
5278
5290
|
await self._fastapi_client.append_evidence(
|
|
@@ -98,6 +98,12 @@ from agentic_runner.sealed_box import (
|
|
|
98
98
|
)
|
|
99
99
|
from agentic_runner.tiny_http import HttpRequest, read_request, write_json
|
|
100
100
|
from agentic_runner.triage_activities import RunnerTriageActivities
|
|
101
|
+
from agentic_runner.usage_windows import (
|
|
102
|
+
ClaudeUsageScreenProbe,
|
|
103
|
+
CodexRateLimitsProbe,
|
|
104
|
+
UsageWindowProbe,
|
|
105
|
+
UsageWindows,
|
|
106
|
+
)
|
|
101
107
|
from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
|
|
102
108
|
from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
|
|
103
109
|
from agentic_runner.workers.agent_runtime import AgentRuntime
|
|
@@ -581,6 +587,8 @@ class ControlPlaneStream:
|
|
|
581
587
|
runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
|
|
582
588
|
contracts: ContractIsolation | None = None
|
|
583
589
|
self_tests: HarnessSelfTests | None = None
|
|
590
|
+
# Local-agents 10: each subscription's usage windows; None off a user-hosted Runner.
|
|
591
|
+
usage_windows: UsageWindows | None = None
|
|
584
592
|
# Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
|
|
585
593
|
llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
|
|
586
594
|
# Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
|
|
@@ -653,6 +661,11 @@ class ControlPlaneStream:
|
|
|
653
661
|
if self.self_tests is not None
|
|
654
662
|
else None
|
|
655
663
|
),
|
|
664
|
+
usage_windows=(
|
|
665
|
+
self.usage_windows.windows(contract_id, cli_kind)
|
|
666
|
+
if self.usage_windows is not None and not delivered
|
|
667
|
+
else None
|
|
668
|
+
),
|
|
656
669
|
)
|
|
657
670
|
|
|
658
671
|
async def directive_token(self, directive_id: str) -> str:
|
|
@@ -1016,6 +1029,9 @@ async def _serve_registered(
|
|
|
1016
1029
|
served=runtimes,
|
|
1017
1030
|
monotonic=time.monotonic,
|
|
1018
1031
|
)
|
|
1032
|
+
usage_windows = build_usage_windows(
|
|
1033
|
+
host_party=state.host_party, runtimes=runtimes, isolation=isolation, in_flight=in_flight
|
|
1034
|
+
)
|
|
1019
1035
|
stream = ControlPlaneStream(
|
|
1020
1036
|
client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
|
|
1021
1037
|
state=state,
|
|
@@ -1043,6 +1059,7 @@ async def _serve_registered(
|
|
|
1043
1059
|
runtimes=runtimes,
|
|
1044
1060
|
contracts=isolation,
|
|
1045
1061
|
self_tests=self_tests,
|
|
1062
|
+
usage_windows=usage_windows,
|
|
1046
1063
|
)
|
|
1047
1064
|
stream.sources = UserSourcePoller(
|
|
1048
1065
|
stream=SignedIntakeStream(stream.client, state),
|
|
@@ -1104,6 +1121,7 @@ async def _serve_registered(
|
|
|
1104
1121
|
messages=messages,
|
|
1105
1122
|
tool_servers=tool_servers,
|
|
1106
1123
|
sign_ins=sign_ins,
|
|
1124
|
+
usage_windows=usage_windows,
|
|
1107
1125
|
# The wake signal rides the connection this process polls with: one
|
|
1108
1126
|
# namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
|
|
1109
1127
|
signaller=TemporalWorkflowSignaller(client),
|
|
@@ -1121,13 +1139,20 @@ async def _serve_registered(
|
|
|
1121
1139
|
self_test = asyncio.create_task(
|
|
1122
1140
|
_self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
|
|
1123
1141
|
)
|
|
1142
|
+
sweeps = [beat, poll, self_test]
|
|
1143
|
+
if usage_windows is not None:
|
|
1144
|
+
sweeps.append(
|
|
1145
|
+
asyncio.create_task(
|
|
1146
|
+
_usage_windows_forever(usage_windows, isolation, proxy, SELF_TEST_SWEEP_SECONDS)
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1124
1149
|
readiness.ready = True
|
|
1125
1150
|
await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
|
|
1126
1151
|
try:
|
|
1127
1152
|
await run_worker_until_terminated(worker, stop=stop)
|
|
1128
1153
|
finally:
|
|
1129
1154
|
readiness.ready = False
|
|
1130
|
-
for task in
|
|
1155
|
+
for task in sweeps:
|
|
1131
1156
|
task.cancel()
|
|
1132
1157
|
with contextlib.suppress(asyncio.CancelledError):
|
|
1133
1158
|
await task
|
|
@@ -1227,6 +1252,56 @@ async def _self_test_forever(
|
|
|
1227
1252
|
await asyncio.sleep(interval)
|
|
1228
1253
|
|
|
1229
1254
|
|
|
1255
|
+
def build_usage_windows(
|
|
1256
|
+
*,
|
|
1257
|
+
host_party: str,
|
|
1258
|
+
runtimes: Mapping[str, AgentRuntime],
|
|
1259
|
+
isolation: ContractIsolation,
|
|
1260
|
+
in_flight: DirectivesInFlight,
|
|
1261
|
+
) -> UsageWindows | None:
|
|
1262
|
+
"""A probe per served harness, or None off a user-hosted Runner (local-agents 10).
|
|
1263
|
+
|
|
1264
|
+
Subscriptions follow the person (local-agents 04): only a user-hosted Runner runs one,
|
|
1265
|
+
so only there is a usage window anyone's to read.
|
|
1266
|
+
"""
|
|
1267
|
+
|
|
1268
|
+
if host_party != "user":
|
|
1269
|
+
return None
|
|
1270
|
+
probes: dict[str, UsageWindowProbe] = {
|
|
1271
|
+
"codex_cli": CodexRateLimitsProbe(),
|
|
1272
|
+
"claude_code": ClaudeUsageScreenProbe(),
|
|
1273
|
+
}
|
|
1274
|
+
return UsageWindows(
|
|
1275
|
+
probes={kind: probe for kind, probe in probes.items() if kind in runtimes},
|
|
1276
|
+
sandbox_for=lambda contract_id, cli_kind: isolation.existing_sandbox(
|
|
1277
|
+
contract_id, runtime_kind=cli_kind
|
|
1278
|
+
),
|
|
1279
|
+
in_flight=in_flight,
|
|
1280
|
+
monotonic=time.monotonic,
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
async def _usage_windows_forever(
|
|
1285
|
+
usage_windows: UsageWindows, isolation: ContractIsolation, proxy: LlmProxy, interval: float
|
|
1286
|
+
) -> None:
|
|
1287
|
+
"""Each signed-in harness root's usage windows, whenever its probe falls due
|
|
1288
|
+
(local-agents 10). A root with a delivered key runs on the proxy, not a subscription."""
|
|
1289
|
+
|
|
1290
|
+
while True:
|
|
1291
|
+
delivered = {
|
|
1292
|
+
(str(slot.contract_id), slot.runtime_kind)
|
|
1293
|
+
for slot in proxy.slots.statuses()
|
|
1294
|
+
if slot.present
|
|
1295
|
+
}
|
|
1296
|
+
try:
|
|
1297
|
+
await usage_windows.run_due(
|
|
1298
|
+
[root for root in isolation.harness_roots() if root not in delivered]
|
|
1299
|
+
)
|
|
1300
|
+
except Exception as error: # noqa: BLE001 - a lost sweep is retried on the next one
|
|
1301
|
+
_logger.warning("usage window sweep failed: %s", error)
|
|
1302
|
+
await asyncio.sleep(interval)
|
|
1303
|
+
|
|
1304
|
+
|
|
1230
1305
|
def _activities(
|
|
1231
1306
|
fastapi: RunnerFastApiClient,
|
|
1232
1307
|
*,
|
|
@@ -1245,6 +1320,7 @@ def _activities(
|
|
|
1245
1320
|
signaller: TemporalWorkflowSignaller | None = None,
|
|
1246
1321
|
tool_servers: ToolServerHealthLog | None = None,
|
|
1247
1322
|
sign_ins: ClaudeSignIns | None = None,
|
|
1323
|
+
usage_windows: UsageWindows | None = None,
|
|
1248
1324
|
) -> list[Callable[..., Any]]:
|
|
1249
1325
|
github_client = GitHubAppClient(
|
|
1250
1326
|
app_id=os.getenv("GITHUB_APP_ID"),
|
|
@@ -1292,6 +1368,7 @@ def _activities(
|
|
|
1292
1368
|
tool_server_health=tool_servers,
|
|
1293
1369
|
attempt_records=attempt_records,
|
|
1294
1370
|
directives_in_flight=directives_in_flight,
|
|
1371
|
+
usage_windows=usage_windows,
|
|
1295
1372
|
)
|
|
1296
1373
|
return [
|
|
1297
1374
|
*ralph.activity_callables(),
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""How much of each subscription usage window a Contract has used (local-agents 10).
|
|
2
|
+
|
|
3
|
+
Asked of the vendor's own binary, as the Contract's uid in its harness root -- never of the
|
|
4
|
+
vendor's usage endpoint with the person's bearer (Anthropic's ``api/oauth/usage``,
|
|
5
|
+
OpenAI's ``wham/usage``), which would put that bearer in the Runner's hands. No adapter
|
|
6
|
+
spends model tokens. One adapter per harness behind one port (PRD decision 11):
|
|
7
|
+
|
|
8
|
+
- **Codex:** ``account/rateLimits/read`` over ``codex app-server``. codex-acp keeps rate
|
|
9
|
+
limits in its session state and says nothing structured about them over ACP.
|
|
10
|
+
- **Claude Code:** the rate-limit events the CLI emits from its responses' headers, which
|
|
11
|
+
claude-agent-acp (pinned 0.88.0, ``acp-agent.js``) forwards as a ``usage_update`` whose
|
|
12
|
+
``_meta["_claude/rateLimit"]`` is the CLI's ``rate_limit_info``. The ACP runtime reads
|
|
13
|
+
them off the Directive's own stream, and a Contract whose stream has carried them is
|
|
14
|
+
never probed. Until then, the ``/usage`` screen under a PTY, as Paperclip reads it
|
|
15
|
+
(``claude-local/src/server/quota.ts``).
|
|
16
|
+
|
|
17
|
+
At most once per interval per Contract, never while it has a Directive running, and again
|
|
18
|
+
after a Directive that stopped on its usage limit. A probe that fails or times out reports
|
|
19
|
+
nothing until one succeeds; it never touches a Directive.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
import fcntl
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import pty
|
|
32
|
+
import re
|
|
33
|
+
import signal
|
|
34
|
+
import struct
|
|
35
|
+
import subprocess
|
|
36
|
+
import termios
|
|
37
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
38
|
+
from datetime import UTC, datetime
|
|
39
|
+
from typing import Any, Final, Protocol
|
|
40
|
+
|
|
41
|
+
from agentic_runner.harness_self_test import DirectivesInFlight, SandboxFor
|
|
42
|
+
from agentic_runner.workers._runtime_support import terminate_process_tree
|
|
43
|
+
from agentic_runner.workers.claude_sign_in import launcher_argv
|
|
44
|
+
from agentic_runner.workers.contract_isolation import ContractIsolationError, DirectiveSandbox
|
|
45
|
+
from agentic_runner.workers.harness_outcome import next_wall_clock, wall_clock_zone
|
|
46
|
+
from agentic_runner_contracts.runner_registration import UsageWindow
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"USAGE_PROBE_INTERVAL_SECONDS",
|
|
50
|
+
"ClaudeUsageScreenProbe",
|
|
51
|
+
"CodexRateLimitsProbe",
|
|
52
|
+
"UsageWindowProbe",
|
|
53
|
+
"UsageWindows",
|
|
54
|
+
"claude_rate_limit_windows",
|
|
55
|
+
"claude_usage_screen_windows",
|
|
56
|
+
"codex_rate_limit_windows",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
USAGE_PROBE_INTERVAL_SECONDS: Final = 15 * 60
|
|
60
|
+
_PROBE_TIMEOUT_SECONDS: Final = 20.0
|
|
61
|
+
_NAME: Final = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
62
|
+
|
|
63
|
+
_logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UsageWindowProbe(Protocol):
|
|
67
|
+
"""One harness's way of saying how much of each window is used, run in ``sandbox``."""
|
|
68
|
+
|
|
69
|
+
async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UsageWindows:
|
|
73
|
+
"""The latest windows per ``(contract_id, cli_kind)``, and when the next probe is due."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
probes: Mapping[str, UsageWindowProbe],
|
|
79
|
+
sandbox_for: SandboxFor,
|
|
80
|
+
in_flight: DirectivesInFlight,
|
|
81
|
+
monotonic: Callable[[], float],
|
|
82
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
83
|
+
interval_seconds: float = USAGE_PROBE_INTERVAL_SECONDS,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._probes = dict(probes)
|
|
86
|
+
self._sandbox_for = sandbox_for
|
|
87
|
+
self._in_flight = in_flight
|
|
88
|
+
self._monotonic = monotonic
|
|
89
|
+
self._clock = clock
|
|
90
|
+
self._interval = interval_seconds
|
|
91
|
+
self._windows: dict[tuple[str, str], list[UsageWindow]] = {}
|
|
92
|
+
self._last_run: dict[tuple[str, str], float] = {}
|
|
93
|
+
self._streamed: set[tuple[str, str]] = set()
|
|
94
|
+
|
|
95
|
+
def windows(self, contract_id: str, cli_kind: str) -> list[UsageWindow] | None:
|
|
96
|
+
"""``None`` until a window is known; a window whose reset has passed is dropped,
|
|
97
|
+
since its percentage no longer describes anything."""
|
|
98
|
+
|
|
99
|
+
now = self._clock()
|
|
100
|
+
current = [
|
|
101
|
+
window
|
|
102
|
+
for window in self._windows.get((contract_id, cli_kind), [])
|
|
103
|
+
if window.resets_at is None or window.resets_at > now
|
|
104
|
+
]
|
|
105
|
+
return current or None
|
|
106
|
+
|
|
107
|
+
def after_directive(
|
|
108
|
+
self,
|
|
109
|
+
contract_id: str,
|
|
110
|
+
cli_kind: str,
|
|
111
|
+
streamed: Sequence[UsageWindow],
|
|
112
|
+
*,
|
|
113
|
+
usage_limit: bool,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""What a subscription Directive's own stream said, and whether issue 08 found it
|
|
116
|
+
stopped on its usage limit -- which makes the next probe due at once."""
|
|
117
|
+
|
|
118
|
+
key = (contract_id, cli_kind)
|
|
119
|
+
if streamed:
|
|
120
|
+
self._windows[key] = list(streamed)
|
|
121
|
+
self._streamed.add(key)
|
|
122
|
+
self._last_run[key] = self._monotonic()
|
|
123
|
+
if usage_limit:
|
|
124
|
+
self._last_run.pop(key, None)
|
|
125
|
+
|
|
126
|
+
async def run_due(self, targets: Sequence[tuple[str, str]]) -> None:
|
|
127
|
+
wanted = set(targets)
|
|
128
|
+
for gone in (self._windows.keys() | self._last_run.keys() | self._streamed) - wanted:
|
|
129
|
+
self._forget(gone)
|
|
130
|
+
for contract_id, cli_kind in targets:
|
|
131
|
+
key = (contract_id, cli_kind)
|
|
132
|
+
probe = self._probes.get(cli_kind)
|
|
133
|
+
if probe is None or key in self._streamed:
|
|
134
|
+
continue
|
|
135
|
+
last = self._last_run.get(key)
|
|
136
|
+
if last is not None and self._monotonic() - last < self._interval:
|
|
137
|
+
continue
|
|
138
|
+
if self._in_flight.busy(contract_id):
|
|
139
|
+
# Not stamped: it runs on the first sweep after the Directive ends.
|
|
140
|
+
continue
|
|
141
|
+
self._last_run[key] = self._monotonic()
|
|
142
|
+
self._windows[key] = await self._probe(probe, contract_id, cli_kind)
|
|
143
|
+
|
|
144
|
+
def _forget(self, key: tuple[str, str]) -> None:
|
|
145
|
+
self._windows.pop(key, None)
|
|
146
|
+
self._last_run.pop(key, None)
|
|
147
|
+
self._streamed.discard(key)
|
|
148
|
+
|
|
149
|
+
async def _probe(
|
|
150
|
+
self, probe: UsageWindowProbe, contract_id: str, cli_kind: str
|
|
151
|
+
) -> list[UsageWindow]:
|
|
152
|
+
try:
|
|
153
|
+
sandbox = self._sandbox_for(contract_id, cli_kind)
|
|
154
|
+
if sandbox is None:
|
|
155
|
+
return []
|
|
156
|
+
return await probe(sandbox)
|
|
157
|
+
except (
|
|
158
|
+
ContractIsolationError,
|
|
159
|
+
OSError,
|
|
160
|
+
TimeoutError,
|
|
161
|
+
subprocess.SubprocessError,
|
|
162
|
+
ValueError,
|
|
163
|
+
) as error:
|
|
164
|
+
# The type only: a harness's error text can carry the account it signed in as.
|
|
165
|
+
_logger.warning(
|
|
166
|
+
"usage window probe for %s/%s failed: %s",
|
|
167
|
+
contract_id,
|
|
168
|
+
cli_kind,
|
|
169
|
+
error.__class__.__name__,
|
|
170
|
+
)
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ Codex
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CodexRateLimitsProbe:
|
|
178
|
+
"""``account/rateLimits/read`` over ``codex app-server``, as the Contract's uid.
|
|
179
|
+
|
|
180
|
+
The app-server protocol is JSON-RPC without the ``jsonrpc`` member, one object per
|
|
181
|
+
line, and interleaves notifications with the responses (codex-cli 0.159.2).
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
argv: Sequence[str] = ("codex", "app-server"),
|
|
188
|
+
timeout_seconds: float = _PROBE_TIMEOUT_SECONDS,
|
|
189
|
+
) -> None:
|
|
190
|
+
self._argv = list(argv)
|
|
191
|
+
self._timeout = timeout_seconds
|
|
192
|
+
|
|
193
|
+
async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]:
|
|
194
|
+
process = await asyncio.create_subprocess_exec(
|
|
195
|
+
*self._argv,
|
|
196
|
+
cwd=sandbox.home_dir,
|
|
197
|
+
env=_harness_env(sandbox, "CODEX_HOME"),
|
|
198
|
+
stdin=asyncio.subprocess.PIPE,
|
|
199
|
+
stdout=asyncio.subprocess.PIPE,
|
|
200
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
201
|
+
start_new_session=True,
|
|
202
|
+
limit=1024 * 1024,
|
|
203
|
+
**sandbox.spawn_kwargs(),
|
|
204
|
+
)
|
|
205
|
+
assert process.stdin is not None and process.stdout is not None
|
|
206
|
+
stdin, stdout = process.stdin, process.stdout
|
|
207
|
+
|
|
208
|
+
async def request(request_id: int, method: str, params: object) -> Mapping[str, Any]:
|
|
209
|
+
stdin.write(_line({"id": request_id, "method": method, "params": params}))
|
|
210
|
+
await stdin.drain()
|
|
211
|
+
while line := await stdout.readline():
|
|
212
|
+
with contextlib.suppress(ValueError):
|
|
213
|
+
message = json.loads(line)
|
|
214
|
+
if isinstance(message, dict) and message.get("id") == request_id:
|
|
215
|
+
return message
|
|
216
|
+
raise ConnectionError("codex app-server closed its stdout")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
async with asyncio.timeout(self._timeout):
|
|
220
|
+
await request(
|
|
221
|
+
1, "initialize", {"clientInfo": {"name": "agentic-runner", "version": "1"}}
|
|
222
|
+
)
|
|
223
|
+
stdin.write(_line({"method": "initialized"}))
|
|
224
|
+
answer = await request(2, "account/rateLimits/read", None)
|
|
225
|
+
finally:
|
|
226
|
+
await terminate_process_tree(process)
|
|
227
|
+
# An error -- ``-32600`` "codex account authentication required" when the root has
|
|
228
|
+
# no login -- has no ``result``, and so no windows.
|
|
229
|
+
return codex_rate_limit_windows(answer.get("result"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_CODEX_WINDOW_NAMES: Final[Mapping[int, str]] = {300: "five_hour", 10_080: "seven_day"}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def codex_rate_limit_windows(result: object) -> list[UsageWindow]:
|
|
236
|
+
"""``rateLimits.primary``/``secondary`` from a ``GetAccountRateLimitsResponse``.
|
|
237
|
+
|
|
238
|
+
Named by their duration where it is a window Codex is known to have, else by slot.
|
|
239
|
+
``usedPercent`` is already a percentage, ``resetsAt`` epoch seconds.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
snapshot = result.get("rateLimits") if isinstance(result, Mapping) else None
|
|
243
|
+
if not isinstance(snapshot, Mapping):
|
|
244
|
+
return []
|
|
245
|
+
windows: list[UsageWindow] = []
|
|
246
|
+
for slot in ("primary", "secondary"):
|
|
247
|
+
window = snapshot.get(slot)
|
|
248
|
+
if not isinstance(window, Mapping):
|
|
249
|
+
continue
|
|
250
|
+
used = _number(window.get("usedPercent"))
|
|
251
|
+
if used is None:
|
|
252
|
+
continue
|
|
253
|
+
minutes = window.get("windowDurationMins")
|
|
254
|
+
name = _CODEX_WINDOW_NAMES.get(minutes, slot) if isinstance(minutes, int) else slot
|
|
255
|
+
windows.append(
|
|
256
|
+
UsageWindow(
|
|
257
|
+
name=name, used_percent=_clamped(used), resets_at=_epoch(window.get("resetsAt"))
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return windows
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------ Claude Code
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def claude_rate_limit_windows(info: object) -> list[UsageWindow]:
|
|
267
|
+
"""The windows in one ``rate_limit_info`` (claude 2.1.295's ``rate_limit_event``).
|
|
268
|
+
|
|
269
|
+
``unifiedWindows`` carries every window on every event; without it, the top-level
|
|
270
|
+
fields describe the one currently limiting. ``utilization`` is a fraction of the window
|
|
271
|
+
and can run past 1.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
if not isinstance(info, Mapping):
|
|
275
|
+
return []
|
|
276
|
+
unified = info.get("unifiedWindows")
|
|
277
|
+
if isinstance(unified, Mapping) and unified:
|
|
278
|
+
pairs = list(unified.items())
|
|
279
|
+
else:
|
|
280
|
+
pairs = [(info.get("rateLimitType"), info)]
|
|
281
|
+
windows: list[UsageWindow] = []
|
|
282
|
+
for name, window in pairs:
|
|
283
|
+
if not isinstance(name, str) or not _NAME.match(name) or not isinstance(window, Mapping):
|
|
284
|
+
continue
|
|
285
|
+
utilization = _number(window.get("utilization"))
|
|
286
|
+
if utilization is None:
|
|
287
|
+
continue
|
|
288
|
+
windows.append(
|
|
289
|
+
UsageWindow(
|
|
290
|
+
name=name,
|
|
291
|
+
used_percent=_clamped(utilization * 100),
|
|
292
|
+
resets_at=_epoch(window.get("resetsAt")),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return windows
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class ClaudeUsageScreenProbe:
|
|
299
|
+
"""``/usage`` typed into an interactive ``claude`` on a PTY, as the Contract's uid.
|
|
300
|
+
|
|
301
|
+
Behind the sign-in launcher, so the CLI reads the Contract's own login and the Runner
|
|
302
|
+
reads nothing under the harness root. Nothing is prompted: ``/usage`` is a local
|
|
303
|
+
command and the session is killed once the screen has drawn, or at the timeout.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
*,
|
|
309
|
+
argv: Sequence[str] = ("claude",),
|
|
310
|
+
timeout_seconds: float = _PROBE_TIMEOUT_SECONDS,
|
|
311
|
+
settle_seconds: float = 2.0,
|
|
312
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
313
|
+
) -> None:
|
|
314
|
+
self._argv = list(argv)
|
|
315
|
+
self._timeout = timeout_seconds
|
|
316
|
+
self._settle = settle_seconds
|
|
317
|
+
self._clock = clock
|
|
318
|
+
|
|
319
|
+
async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]:
|
|
320
|
+
master_fd, slave_fd = pty.openpty()
|
|
321
|
+
try:
|
|
322
|
+
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 200, 0, 0))
|
|
323
|
+
os.set_blocking(master_fd, False)
|
|
324
|
+
process = await asyncio.create_subprocess_exec(
|
|
325
|
+
*launcher_argv(sandbox.harness_config_dir, self._argv),
|
|
326
|
+
cwd=sandbox.home_dir,
|
|
327
|
+
env=_harness_env(sandbox, "CLAUDE_CONFIG_DIR") | {"TERM": "xterm-256color"},
|
|
328
|
+
stdin=slave_fd,
|
|
329
|
+
stdout=slave_fd,
|
|
330
|
+
stderr=slave_fd,
|
|
331
|
+
start_new_session=True,
|
|
332
|
+
**sandbox.spawn_kwargs(),
|
|
333
|
+
)
|
|
334
|
+
except BaseException:
|
|
335
|
+
os.close(master_fd)
|
|
336
|
+
raise
|
|
337
|
+
finally:
|
|
338
|
+
os.close(slave_fd)
|
|
339
|
+
screen = bytearray()
|
|
340
|
+
windows: list[UsageWindow] = []
|
|
341
|
+
try:
|
|
342
|
+
with contextlib.suppress(TimeoutError):
|
|
343
|
+
async with asyncio.timeout(self._timeout):
|
|
344
|
+
# The prompt must be up before it can take a command.
|
|
345
|
+
await asyncio.sleep(self._settle)
|
|
346
|
+
_drain(master_fd, screen)
|
|
347
|
+
os.write(master_fd, b"/usage")
|
|
348
|
+
# Ink reads a command and its Enter as one paste and drops the Enter.
|
|
349
|
+
await asyncio.sleep(0.15)
|
|
350
|
+
os.write(master_fd, b"\r")
|
|
351
|
+
while not _screen_complete(windows):
|
|
352
|
+
await asyncio.sleep(0.25)
|
|
353
|
+
_drain(master_fd, screen)
|
|
354
|
+
windows = claude_usage_screen_windows(
|
|
355
|
+
screen.decode("utf-8", errors="replace"), self._clock()
|
|
356
|
+
)
|
|
357
|
+
finally:
|
|
358
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
359
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
360
|
+
with contextlib.suppress(TimeoutError):
|
|
361
|
+
await asyncio.wait_for(process.wait(), timeout=2)
|
|
362
|
+
os.close(master_fd)
|
|
363
|
+
return windows
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
_SCREEN_LABELS: Final[Mapping[str, str]] = {
|
|
367
|
+
"currentsession": "five_hour",
|
|
368
|
+
"currentweekallmodels": "seven_day",
|
|
369
|
+
"currentweeksonnetonly": "seven_day_sonnet",
|
|
370
|
+
"currentweeksonnet": "seven_day_sonnet",
|
|
371
|
+
"currentweekopusonly": "seven_day_opus",
|
|
372
|
+
"currentweekopus": "seven_day_opus",
|
|
373
|
+
}
|
|
374
|
+
_ANSI: Final = re.compile(
|
|
375
|
+
r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC, e.g. a hyperlink
|
|
376
|
+
r"|\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])" # CSI and the two-byte escapes
|
|
377
|
+
)
|
|
378
|
+
_PERCENT: Final = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%\s*(used|left|remaining|available)?", re.I)
|
|
379
|
+
# ``Resets 5pm (America/Chicago)``, ``Resets Mar 18 at 7:59am (America/Chicago)``; Ink can
|
|
380
|
+
# drop the spaces between the parts.
|
|
381
|
+
_RESET: Final = re.compile(
|
|
382
|
+
r"^resets\s*(?:(?P<month>[a-z]{3})[a-z]*\s*(?P<day>\d{1,2}),?\s*(?:at\s*)?)?"
|
|
383
|
+
r"(?P<clock>\d{1,2}(?::\d{2})?\s*[ap]\.?\s*m\.?)\s*(?:\((?P<zone>[^)]+)\))?",
|
|
384
|
+
re.I,
|
|
385
|
+
)
|
|
386
|
+
_MONTHS: Final = (
|
|
387
|
+
"jan",
|
|
388
|
+
"feb",
|
|
389
|
+
"mar",
|
|
390
|
+
"apr",
|
|
391
|
+
"may",
|
|
392
|
+
"jun",
|
|
393
|
+
"jul",
|
|
394
|
+
"aug",
|
|
395
|
+
"sep",
|
|
396
|
+
"oct",
|
|
397
|
+
"nov",
|
|
398
|
+
"dec",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def claude_usage_screen_windows(text: str, now: datetime) -> list[UsageWindow]:
|
|
403
|
+
"""The windows on the latest ``/usage`` screen in a PTY capture.
|
|
404
|
+
|
|
405
|
+
Each label is followed by its ``N% used`` line and its ``Resets …`` line. A window the
|
|
406
|
+
screen draws without a percentage -- ``Extra usage``, or one still loading -- is left
|
|
407
|
+
out, and so is a capture with no such screen at all.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
lines = [line.strip() for line in _clean(text).split("\n")]
|
|
411
|
+
starts = [i for i, line in enumerate(lines) if _label(line) == "currentsession"]
|
|
412
|
+
if not starts:
|
|
413
|
+
return []
|
|
414
|
+
windows: dict[str, UsageWindow] = {}
|
|
415
|
+
name: str | None = None
|
|
416
|
+
used: float | None = None
|
|
417
|
+
for line in lines[starts[-1] :]:
|
|
418
|
+
label = _label(line)
|
|
419
|
+
if label in _SCREEN_LABELS:
|
|
420
|
+
name, used = _SCREEN_LABELS[label], None
|
|
421
|
+
elif name is None or not line:
|
|
422
|
+
continue
|
|
423
|
+
elif used is None and (percent := _PERCENT.search(line)):
|
|
424
|
+
value = float(percent.group(1))
|
|
425
|
+
remaining = (percent.group(2) or "").lower() in {"left", "remaining", "available"}
|
|
426
|
+
used = _clamped(100 - value if remaining else value)
|
|
427
|
+
windows[name] = UsageWindow(name=name, used_percent=used)
|
|
428
|
+
elif used is not None and (reset := _RESET.match(line)):
|
|
429
|
+
windows[name] = UsageWindow(
|
|
430
|
+
name=name, used_percent=used, resets_at=_screen_reset(reset, now)
|
|
431
|
+
)
|
|
432
|
+
name = None
|
|
433
|
+
return list(windows.values())
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _screen_complete(windows: Sequence[UsageWindow]) -> bool:
|
|
437
|
+
names = {window.name for window in windows}
|
|
438
|
+
return "five_hour" in names and "seven_day" in names
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _screen_reset(match: re.Match[str], now: datetime) -> datetime | None:
|
|
442
|
+
clock, zone = match.group("clock"), match.group("zone")
|
|
443
|
+
if zone:
|
|
444
|
+
clock = f"{clock} ({zone})"
|
|
445
|
+
if match.group("month") is None:
|
|
446
|
+
at = next_wall_clock(clock, now)
|
|
447
|
+
return datetime.fromisoformat(at) if at else None
|
|
448
|
+
month = match.group("month").lower()
|
|
449
|
+
if month not in _MONTHS:
|
|
450
|
+
return None
|
|
451
|
+
# The time on the screen's date: the next such time after the date's midnight.
|
|
452
|
+
local_now = now.astimezone(wall_clock_zone(zone))
|
|
453
|
+
try:
|
|
454
|
+
midnight = local_now.replace(
|
|
455
|
+
month=_MONTHS.index(month) + 1,
|
|
456
|
+
day=int(match.group("day")),
|
|
457
|
+
hour=0,
|
|
458
|
+
minute=0,
|
|
459
|
+
second=0,
|
|
460
|
+
microsecond=0,
|
|
461
|
+
)
|
|
462
|
+
except ValueError:
|
|
463
|
+
return None
|
|
464
|
+
if midnight.date() < local_now.date():
|
|
465
|
+
# A reset is never in the past: ``Jan 2`` seen on Dec 30 is next year's.
|
|
466
|
+
midnight = midnight.replace(year=midnight.year + 1)
|
|
467
|
+
at = next_wall_clock(clock, midnight)
|
|
468
|
+
return datetime.fromisoformat(at) if at else None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _clean(text: str) -> str:
|
|
472
|
+
kept: list[str] = []
|
|
473
|
+
for char in text:
|
|
474
|
+
if char == "\b":
|
|
475
|
+
if kept:
|
|
476
|
+
kept.pop()
|
|
477
|
+
else:
|
|
478
|
+
kept.append(char)
|
|
479
|
+
return _ANSI.sub("", "".join(kept)).replace("\x00", "").replace("\r", "\n")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _label(line: str) -> str:
|
|
483
|
+
return re.sub(r"[^a-z0-9]+", "", line.lower())
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _drain(master_fd: int, screen: bytearray) -> None:
|
|
487
|
+
with contextlib.suppress(BlockingIOError, OSError):
|
|
488
|
+
while chunk := os.read(master_fd, 65_536):
|
|
489
|
+
screen.extend(chunk)
|
|
490
|
+
# The latest screen is at the end; a CLI that redraws forever is bounded here.
|
|
491
|
+
del screen[:-262_144]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ------------------------------------------------------------------ shared
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _harness_env(sandbox: DirectiveSandbox, root_env: str) -> dict[str, str]:
|
|
498
|
+
"""The four names every probe gets and nothing else: no bearer, no API key, no proxy."""
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
"PATH": os.environ.get("PATH") or os.defpath,
|
|
502
|
+
"HOME": str(sandbox.home_dir),
|
|
503
|
+
"TMPDIR": str(sandbox.tmp_dir),
|
|
504
|
+
root_env: str(sandbox.harness_config_dir),
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _line(message: Mapping[str, object]) -> bytes:
|
|
509
|
+
return (json.dumps(message) + "\n").encode()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _number(value: object) -> float | None:
|
|
513
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
514
|
+
return None
|
|
515
|
+
return float(value) if math.isfinite(value) else None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _clamped(percent: float) -> float:
|
|
519
|
+
return round(min(100.0, max(0.0, percent)), 1)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _epoch(value: object) -> datetime | None:
|
|
523
|
+
seconds = _number(value)
|
|
524
|
+
if seconds is None or seconds <= 0:
|
|
525
|
+
return None
|
|
526
|
+
try:
|
|
527
|
+
return datetime.fromtimestamp(seconds, UTC)
|
|
528
|
+
except (OverflowError, OSError, ValueError):
|
|
529
|
+
return None
|
|
@@ -61,6 +61,7 @@ from agentic_runner.attempts import (
|
|
|
61
61
|
)
|
|
62
62
|
from agentic_runner.callback import CALLBACK_SOCKET_ENV, CALLBACK_TOKEN_ENV, call
|
|
63
63
|
from agentic_runner.llm_proxy import PROXY_API_KEY_ENV, PROXY_BASE_URL_ENV
|
|
64
|
+
from agentic_runner.usage_windows import claude_rate_limit_windows
|
|
64
65
|
from agentic_runner.workers._runtime_support import (
|
|
65
66
|
apply_extra_env,
|
|
66
67
|
bound_text,
|
|
@@ -85,6 +86,7 @@ from agentic_runner.workers.command_policy import CommandPolicy, evaluate_comman
|
|
|
85
86
|
from agentic_runner.workers.contract_isolation import DirectiveSandbox
|
|
86
87
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
87
88
|
from agentic_runner.workers.settings import WorkerSettings
|
|
89
|
+
from agentic_runner_contracts.runner_registration import UsageWindow
|
|
88
90
|
|
|
89
91
|
__all__ = ["ACP_BRIDGES", "AcpBridge", "AcpRuntime"]
|
|
90
92
|
|
|
@@ -218,6 +220,7 @@ class _Turn:
|
|
|
218
220
|
output: list[str] = field(default_factory=list)
|
|
219
221
|
notes: list[str] = field(default_factory=list)
|
|
220
222
|
held: bool = False
|
|
223
|
+
usage_windows: tuple[UsageWindow, ...] = ()
|
|
221
224
|
|
|
222
225
|
|
|
223
226
|
class AcpRuntime:
|
|
@@ -594,6 +597,7 @@ class AcpRuntime:
|
|
|
594
597
|
request, directive_workspace_id, command_hash, guard_mode, notes
|
|
595
598
|
),
|
|
596
599
|
model_usage=model_usage,
|
|
600
|
+
usage_windows=turn.usage_windows,
|
|
597
601
|
)
|
|
598
602
|
|
|
599
603
|
def _evidence(
|
|
@@ -883,7 +887,17 @@ def _offers_mode(session: Mapping[str, Any], mode_id: str) -> bool:
|
|
|
883
887
|
|
|
884
888
|
def _collect_update(params: Mapping[str, Any], turn: _Turn) -> None:
|
|
885
889
|
update = params.get("update")
|
|
886
|
-
if not isinstance(update, Mapping)
|
|
890
|
+
if not isinstance(update, Mapping):
|
|
891
|
+
return
|
|
892
|
+
if update.get("sessionUpdate") == "usage_update":
|
|
893
|
+
# claude-agent-acp's relay of the CLI's rate-limit event; the latest one wins.
|
|
894
|
+
meta = update.get("_meta")
|
|
895
|
+
if isinstance(meta, Mapping) and (
|
|
896
|
+
windows := claude_rate_limit_windows(meta.get("_claude/rateLimit"))
|
|
897
|
+
):
|
|
898
|
+
turn.usage_windows = tuple(windows)
|
|
899
|
+
return
|
|
900
|
+
if update.get("sessionUpdate") != "agent_message_chunk":
|
|
887
901
|
return
|
|
888
902
|
content = update.get("content")
|
|
889
903
|
if isinstance(content, Mapping) and content.get("type") == "text":
|
|
@@ -20,6 +20,7 @@ from typing import Protocol, runtime_checkable
|
|
|
20
20
|
|
|
21
21
|
from agentic_runner.workers.contract_isolation import DirectiveSandbox
|
|
22
22
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
23
|
+
from agentic_runner_contracts.runner_registration import UsageWindow
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class AuthMode(StrEnum):
|
|
@@ -131,6 +132,9 @@ class DirectiveResult:
|
|
|
131
132
|
# The harness's own per-model usage, where its protocol carries one (ACP). Empty means
|
|
132
133
|
# the caller reads usage off ``stdout`` as before.
|
|
133
134
|
model_usage: tuple[ModelUsage, ...] = ()
|
|
135
|
+
# The subscription usage windows the harness's own stream last reported, where it
|
|
136
|
+
# carries them (claude-agent-acp's rate-limit events, local-agents 10).
|
|
137
|
+
usage_windows: tuple[UsageWindow, ...] = ()
|
|
134
138
|
|
|
135
139
|
|
|
136
140
|
# The permission mode of a runtime whose guard configuration is incomplete: it refuses
|
|
@@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
23
23
|
from agentic_runner.workers.harness_usage import extract_claude_result
|
|
24
24
|
from agentic_runner_contracts.activity_io import HarnessHold, HarnessHoldKind
|
|
25
25
|
|
|
26
|
-
__all__ = ["classify_harness_outcome"]
|
|
26
|
+
__all__ = ["classify_harness_outcome", "next_wall_clock", "wall_clock_zone"]
|
|
27
27
|
|
|
28
28
|
_CLAUDE_CODE: Final[str] = "claude_code"
|
|
29
29
|
_CODEX_CLI: Final[str] = "codex_cli"
|
|
@@ -121,7 +121,7 @@ def classify_harness_outcome(
|
|
|
121
121
|
clock = reset.search(text, limit.end())
|
|
122
122
|
return HarnessHold(
|
|
123
123
|
kind=HarnessHoldKind.USAGE_LIMIT,
|
|
124
|
-
retry_not_before=
|
|
124
|
+
retry_not_before=next_wall_clock(clock.group("clock"), now) if clock else None,
|
|
125
125
|
)
|
|
126
126
|
|
|
127
127
|
|
|
@@ -185,7 +185,7 @@ def _joined(*parts: str) -> str:
|
|
|
185
185
|
return "\n".join(part.strip() for part in parts if part and part.strip())
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
def
|
|
188
|
+
def next_wall_clock(text: str, now: datetime) -> str | None:
|
|
189
189
|
match = _CLOCK_RE.match(text.strip())
|
|
190
190
|
if match is None:
|
|
191
191
|
return None
|
|
@@ -193,14 +193,14 @@ def _next_wall_clock(text: str, now: datetime) -> str | None:
|
|
|
193
193
|
if not 1 <= hour <= 12 or not 0 <= minute <= 59:
|
|
194
194
|
return None
|
|
195
195
|
hour = hour % 12 + (12 if match.group("half").lower() == "p" else 0)
|
|
196
|
-
local_now = now.astimezone(
|
|
196
|
+
local_now = now.astimezone(wall_clock_zone(match.group("zone")))
|
|
197
197
|
candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
198
198
|
if candidate <= local_now:
|
|
199
199
|
candidate += timedelta(days=1)
|
|
200
200
|
return candidate.astimezone(UTC).isoformat()
|
|
201
201
|
|
|
202
202
|
|
|
203
|
-
def
|
|
203
|
+
def wall_clock_zone(name: str | None) -> tzinfo | None:
|
|
204
204
|
"""The zone the harness named, or None -- ``astimezone``'s "this host's own"."""
|
|
205
205
|
|
|
206
206
|
if not name:
|
|
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
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
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
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/test_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
{agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|