agentic-runner 3.3.0__tar.gz → 3.4.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.3.0 → agentic_runner-3.4.0}/PKG-INFO +1 -1
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/activities.py +115 -1
- agentic_runner-3.4.0/src/agentic_runner/cli_floor.py +33 -0
- agentic_runner-3.4.0/src/agentic_runner/harness_self_test.py +162 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/service.py +81 -16
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/agent_runtime.py +20 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/claude_runtime.py +12 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/codex_runtime.py +16 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_isolation.py +32 -0
- agentic_runner-3.4.0/src/agentic_runner/workers/harness_outcome.py +213 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workstation.py +4 -20
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/.gitignore +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/LICENSE +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/README.md +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/pyproject.toml +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/skills.py +0 -0
|
@@ -31,6 +31,7 @@ import tempfile
|
|
|
31
31
|
import time
|
|
32
32
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
33
33
|
from dataclasses import asdict, dataclass, field, fields, replace
|
|
34
|
+
from datetime import UTC, datetime
|
|
34
35
|
from pathlib import Path
|
|
35
36
|
from typing import Any, NoReturn, Protocol
|
|
36
37
|
from uuid import UUID
|
|
@@ -75,6 +76,7 @@ from agentic_runner.credentials import (
|
|
|
75
76
|
UnresolvableCredentialReferenceError,
|
|
76
77
|
)
|
|
77
78
|
from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
|
|
79
|
+
from agentic_runner.harness_self_test import DirectivesInFlight
|
|
78
80
|
from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
|
|
79
81
|
from agentic_runner.hooks import (
|
|
80
82
|
HOOK_EVIDENCE_SOURCE,
|
|
@@ -140,6 +142,7 @@ from agentic_runner.workers.fastapi_client import (
|
|
|
140
142
|
WorkerFastApiClientError,
|
|
141
143
|
directive_token,
|
|
142
144
|
)
|
|
145
|
+
from agentic_runner.workers.harness_outcome import classify_harness_outcome
|
|
143
146
|
from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
|
|
144
147
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
145
148
|
from agentic_runner.workers.skills import (
|
|
@@ -168,6 +171,7 @@ from agentic_runner_contracts.activity_io import (
|
|
|
168
171
|
FixDirectiveOutput,
|
|
169
172
|
GitHubCallError,
|
|
170
173
|
GitHubCallFailure,
|
|
174
|
+
HarnessHold,
|
|
171
175
|
LearningDirectiveInput,
|
|
172
176
|
LearningDirectiveOutput,
|
|
173
177
|
MemberDirectiveInput,
|
|
@@ -1442,6 +1446,7 @@ DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
|
1442
1446
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1443
1447
|
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
1444
1448
|
HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
|
|
1449
|
+
HARNESS_HOLD_SOURCE = "runner.harness_hold"
|
|
1445
1450
|
|
|
1446
1451
|
|
|
1447
1452
|
class _EvidenceWriter(Protocol):
|
|
@@ -1555,8 +1560,11 @@ class RunnerRalphActivities:
|
|
|
1555
1560
|
egress_resolver: Resolver | None = None,
|
|
1556
1561
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1557
1562
|
attempt_records: AttemptRecords | None = None,
|
|
1563
|
+
directives_in_flight: DirectivesInFlight | None = None,
|
|
1558
1564
|
) -> None:
|
|
1559
1565
|
self._fastapi_client = fastapi_client
|
|
1566
|
+
# Read by the harness self-test, which never runs beside a Contract's Directive.
|
|
1567
|
+
self._directives_in_flight = directives_in_flight or DirectivesInFlight()
|
|
1560
1568
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
1561
1569
|
# wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
|
|
1562
1570
|
# so a real monotonic clock is allowed here (it must never be used in the
|
|
@@ -2018,6 +2026,31 @@ class RunnerRalphActivities:
|
|
|
2018
2026
|
auth_mode=attempt.auth_mode,
|
|
2019
2027
|
)
|
|
2020
2028
|
plan = _take_plan(checkout_workspace)
|
|
2029
|
+
harness_hold = await self._harness_hold(
|
|
2030
|
+
runtime_state=runtime_state,
|
|
2031
|
+
work_record_id=request.work_record_id,
|
|
2032
|
+
directive_number=request.directive_number,
|
|
2033
|
+
result=codex_result,
|
|
2034
|
+
auth_mode=attempt.auth_mode,
|
|
2035
|
+
)
|
|
2036
|
+
if harness_hold is not None:
|
|
2037
|
+
return BranchPullRequestOutput(
|
|
2038
|
+
repository=request.repository,
|
|
2039
|
+
branch_name=request.branch_name,
|
|
2040
|
+
base_ref=request.base_ref,
|
|
2041
|
+
pr_number=0,
|
|
2042
|
+
pr_url="",
|
|
2043
|
+
branch_created=branch.created,
|
|
2044
|
+
pr_created=False,
|
|
2045
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
2046
|
+
usage=await self._directive_usage(
|
|
2047
|
+
work_record_id=request.work_record_id,
|
|
2048
|
+
directive_number=request.directive_number,
|
|
2049
|
+
),
|
|
2050
|
+
workspace_path=str(workspace_path),
|
|
2051
|
+
grant_snapshot=dict(request.grant_snapshot),
|
|
2052
|
+
harness_hold=harness_hold,
|
|
2053
|
+
)
|
|
2021
2054
|
if codex_result.exit_code != 0:
|
|
2022
2055
|
if _is_guard_mode_refusal(codex_result):
|
|
2023
2056
|
# The runtime refused this Directive (guard-mode). Return a flagged
|
|
@@ -2475,6 +2508,7 @@ class RunnerRalphActivities:
|
|
|
2475
2508
|
scratch_root = sandbox.tmp_dir if sandbox is not None else Path(tempfile.gettempdir())
|
|
2476
2509
|
attempt_dir = prepare_attempt_dir(scratch_root / f"attempt-{key}", uid)
|
|
2477
2510
|
async with contextlib.AsyncExitStack() as stack:
|
|
2511
|
+
stack.enter_context(self._directives_in_flight.running(facts.contract_id))
|
|
2478
2512
|
stack.callback(shutil.rmtree, attempt_dir, ignore_errors=True)
|
|
2479
2513
|
callback_env: dict[str, str] = {}
|
|
2480
2514
|
if self._socket_dir is not None:
|
|
@@ -4484,7 +4518,13 @@ class RunnerRalphActivities:
|
|
|
4484
4518
|
guard_mode_refused=True,
|
|
4485
4519
|
workspace_path=request.workspace_path,
|
|
4486
4520
|
)
|
|
4487
|
-
if
|
|
4521
|
+
if (
|
|
4522
|
+
consumed
|
|
4523
|
+
and self._message_store is not None
|
|
4524
|
+
and not ran.guard_mode_refused
|
|
4525
|
+
# A held turn never read its Messages; the wake after the hold must see them.
|
|
4526
|
+
and ran.harness_hold is None
|
|
4527
|
+
):
|
|
4488
4528
|
self._message_store.mark_consumed(
|
|
4489
4529
|
contract_id=contract[0],
|
|
4490
4530
|
work_record_id=request.work_record_id,
|
|
@@ -4616,6 +4656,13 @@ class RunnerRalphActivities:
|
|
|
4616
4656
|
result=result,
|
|
4617
4657
|
auth_mode=attempt.auth_mode,
|
|
4618
4658
|
)
|
|
4659
|
+
harness_hold = await self._harness_hold(
|
|
4660
|
+
runtime_state=state,
|
|
4661
|
+
work_record_id=request.work_record_id,
|
|
4662
|
+
directive_number=request.directive_number,
|
|
4663
|
+
result=result,
|
|
4664
|
+
auth_mode=attempt.auth_mode,
|
|
4665
|
+
)
|
|
4619
4666
|
usage = await self._directive_usage(
|
|
4620
4667
|
work_record_id=request.work_record_id,
|
|
4621
4668
|
directive_number=request.directive_number,
|
|
@@ -4652,6 +4699,7 @@ class RunnerRalphActivities:
|
|
|
4652
4699
|
usage=usage,
|
|
4653
4700
|
outcome=LEARNING_FAILED,
|
|
4654
4701
|
summary=f"the Learner's runtime exited {result.exit_code}",
|
|
4702
|
+
harness_hold=harness_hold,
|
|
4655
4703
|
)
|
|
4656
4704
|
return self._learning_output(
|
|
4657
4705
|
request,
|
|
@@ -4668,6 +4716,7 @@ class RunnerRalphActivities:
|
|
|
4668
4716
|
usage: DirectiveUsage | None = None,
|
|
4669
4717
|
outcome: str = LEARNING_PROPOSED,
|
|
4670
4718
|
summary: str = "",
|
|
4719
|
+
harness_hold: HarnessHold | None = None,
|
|
4671
4720
|
) -> LearningDirectiveOutput:
|
|
4672
4721
|
return LearningDirectiveOutput(
|
|
4673
4722
|
work_record_id=request.work_record_id,
|
|
@@ -4676,6 +4725,7 @@ class RunnerRalphActivities:
|
|
|
4676
4725
|
usage=usage or DirectiveUsage(),
|
|
4677
4726
|
outcome=outcome,
|
|
4678
4727
|
summary=summary,
|
|
4728
|
+
harness_hold=harness_hold,
|
|
4679
4729
|
)
|
|
4680
4730
|
|
|
4681
4731
|
def _learner_transcript(self, state: _RuntimeContextState, work_record_id: str) -> str:
|
|
@@ -4875,6 +4925,30 @@ class RunnerRalphActivities:
|
|
|
4875
4925
|
)
|
|
4876
4926
|
# Taken before the commit below so it never lands on the branch (PRD 59).
|
|
4877
4927
|
plan = _take_plan(state.workspace_path)
|
|
4928
|
+
harness_hold = await self._harness_hold(
|
|
4929
|
+
runtime_state=state,
|
|
4930
|
+
work_record_id=request.work_record_id,
|
|
4931
|
+
directive_number=request.directive_number,
|
|
4932
|
+
result=codex_result,
|
|
4933
|
+
auth_mode=attempt.auth_mode,
|
|
4934
|
+
)
|
|
4935
|
+
if harness_hold is not None:
|
|
4936
|
+
return FixDirectiveOutput(
|
|
4937
|
+
work_record_id=request.work_record_id,
|
|
4938
|
+
repository=request.repository,
|
|
4939
|
+
pr_number=request.pr_number,
|
|
4940
|
+
directive_number=request.directive_number,
|
|
4941
|
+
branch_head_sha="",
|
|
4942
|
+
summary=f"held: the harness reported {harness_hold.kind.value}",
|
|
4943
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
4944
|
+
usage=await self._directive_usage(
|
|
4945
|
+
work_record_id=request.work_record_id,
|
|
4946
|
+
directive_number=request.directive_number,
|
|
4947
|
+
),
|
|
4948
|
+
workspace_path=str(state.workspace_path),
|
|
4949
|
+
grant_snapshot=state.grant_snapshot_payload,
|
|
4950
|
+
harness_hold=harness_hold,
|
|
4951
|
+
)
|
|
4878
4952
|
if codex_result.exit_code != 0:
|
|
4879
4953
|
if _is_guard_mode_refusal(codex_result):
|
|
4880
4954
|
return FixDirectiveOutput(
|
|
@@ -5114,6 +5188,46 @@ class RunnerRalphActivities:
|
|
|
5114
5188
|
}
|
|
5115
5189
|
)
|
|
5116
5190
|
|
|
5191
|
+
async def _harness_hold(
|
|
5192
|
+
self,
|
|
5193
|
+
*,
|
|
5194
|
+
runtime_state: _RuntimeContextState,
|
|
5195
|
+
work_record_id: str,
|
|
5196
|
+
directive_number: int,
|
|
5197
|
+
result: DirectiveResult,
|
|
5198
|
+
auth_mode: AuthMode,
|
|
5199
|
+
) -> HarnessHold | None:
|
|
5200
|
+
"""A subscription harness that stopped on its usage limit or sign-in, recorded
|
|
5201
|
+
once (local-agents 08). An ``api_key`` Directive's limits are the proxy's, so it is
|
|
5202
|
+
never classified. The Evidence names the kind and the reset, never the output."""
|
|
5203
|
+
|
|
5204
|
+
if auth_mode != AuthMode.SUBSCRIPTION:
|
|
5205
|
+
return None
|
|
5206
|
+
hold = classify_harness_outcome(
|
|
5207
|
+
runtime_state.cli_kind,
|
|
5208
|
+
exit_code=result.exit_code,
|
|
5209
|
+
stdout=result.stdout,
|
|
5210
|
+
stderr=result.stderr,
|
|
5211
|
+
error=result.error,
|
|
5212
|
+
now=datetime.now(UTC),
|
|
5213
|
+
)
|
|
5214
|
+
if hold is None:
|
|
5215
|
+
return None
|
|
5216
|
+
await self._fastapi_client.append_evidence(
|
|
5217
|
+
work_record_id,
|
|
5218
|
+
source=HARNESS_HOLD_SOURCE,
|
|
5219
|
+
payload={
|
|
5220
|
+
"event": "directive.harness_hold",
|
|
5221
|
+
"kind": hold.kind.value,
|
|
5222
|
+
"cli_kind": runtime_state.cli_kind,
|
|
5223
|
+
"retry_not_before": hold.retry_not_before,
|
|
5224
|
+
"directive_number": directive_number,
|
|
5225
|
+
"agent_id": runtime_state.agent_id,
|
|
5226
|
+
"contract_id": runtime_state.contract_id,
|
|
5227
|
+
},
|
|
5228
|
+
)
|
|
5229
|
+
return hold
|
|
5230
|
+
|
|
5117
5231
|
@activity.defn(name="wipe_contract_residue")
|
|
5118
5232
|
async def wipe_contract_residue(self, request: ContractResidueInput) -> ContractResidueOutput:
|
|
5119
5233
|
"""Delete everything one Contract left on this Runner and retire its uid (17 A6).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Which executable each Agent Runtime CLI is, and the version floor it must clear."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
__all__ = ["CLI_FLOORS", "CLI_NAMES", "meets_floor", "parse_cli_version"]
|
|
10
|
+
|
|
11
|
+
# What the Profile's `cli_kind` runs, by executable name on PATH.
|
|
12
|
+
CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
|
|
13
|
+
# The versions the Runner image is built and tested against (Dockerfile.runner's
|
|
14
|
+
# CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
|
|
15
|
+
# floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
|
|
16
|
+
CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
|
|
17
|
+
|
|
18
|
+
_VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_cli_version(output: str) -> str | None:
|
|
22
|
+
match = _VERSION.search(output)
|
|
23
|
+
return None if match is None else match.group(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def meets_floor(name: str, version: str) -> bool:
|
|
27
|
+
"""Whether ``version`` of the executable ``name`` clears the Runner's floor for it."""
|
|
28
|
+
|
|
29
|
+
return _version_tuple(version) >= _version_tuple(CLI_FLOORS[name])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
33
|
+
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""A harness's ``--version``, run as each Contract would run it (local-agents 17).
|
|
2
|
+
|
|
3
|
+
What the heartbeat's ``cli`` attestation says is that the Runner's own uid can start a
|
|
4
|
+
CLI. Whether a Contract can is a different fact: its uid, its harness root and its
|
|
5
|
+
sandbox floor all stand between the two. So the self-test spawns exactly what a
|
|
6
|
+
Directive spawns, minus the prompt, and keeps only the verdict -- never the output, which
|
|
7
|
+
on a harness root can carry an account name.
|
|
8
|
+
|
|
9
|
+
At most once per interval per Contract and never while that Contract has a Directive
|
|
10
|
+
running: the harness root is the Directive's while it runs, and a concurrent spawn
|
|
11
|
+
under the same rlimit floor would count against the Directive's process ceiling.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Awaitable, Callable, Collection, Iterator, Sequence
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Final
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
from agentic_runner.cli_floor import CLI_NAMES, meets_floor, parse_cli_version
|
|
26
|
+
from agentic_runner.workers._runtime_support import SubprocessResult, run_subprocess_exec
|
|
27
|
+
from agentic_runner.workers.contract_isolation import ContractIsolationError, DirectiveSandbox
|
|
28
|
+
from agentic_runner_contracts.runner_registration import HarnessSelfTest, SelfTestFailure
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SELF_TEST_INTERVAL_SECONDS",
|
|
32
|
+
"DirectivesInFlight",
|
|
33
|
+
"HarnessSelfTests",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SELF_TEST_INTERVAL_SECONDS: Final = 15 * 60
|
|
37
|
+
_TIMEOUT_SECONDS: Final = 10
|
|
38
|
+
_OUTPUT_LIMIT_BYTES: Final = 4096
|
|
39
|
+
|
|
40
|
+
# Where each harness reads its config root from; the same variable its runtime sets.
|
|
41
|
+
_HARNESS_ROOT_ENV: Final = {"codex_cli": "CODEX_HOME", "claude_code": "CLAUDE_CONFIG_DIR"}
|
|
42
|
+
|
|
43
|
+
SandboxFor = Callable[[str, str], DirectiveSandbox | None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DirectivesInFlight:
|
|
47
|
+
"""Which Contracts have a Directive running in this process right now."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._running: Counter[str] = Counter()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def running(self, contract_id: str | None) -> Iterator[None]:
|
|
54
|
+
key = contract_id or ""
|
|
55
|
+
self._running[key] += 1
|
|
56
|
+
try:
|
|
57
|
+
yield
|
|
58
|
+
finally:
|
|
59
|
+
self._running[key] -= 1
|
|
60
|
+
if self._running[key] <= 0:
|
|
61
|
+
del self._running[key]
|
|
62
|
+
|
|
63
|
+
def busy(self, contract_id: str) -> bool:
|
|
64
|
+
return self._running[contract_id] > 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HarnessSelfTests:
|
|
68
|
+
"""The latest self-test per ``(contract_id, cli_kind)``, and when the next one is due."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
sandbox_for: SandboxFor,
|
|
74
|
+
in_flight: DirectivesInFlight,
|
|
75
|
+
served: Collection[str],
|
|
76
|
+
run: Callable[..., Awaitable[SubprocessResult]] = run_subprocess_exec,
|
|
77
|
+
monotonic: Callable[[], float],
|
|
78
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
79
|
+
interval_seconds: float = SELF_TEST_INTERVAL_SECONDS,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._sandbox_for = sandbox_for
|
|
82
|
+
self._in_flight = in_flight
|
|
83
|
+
self._served = frozenset(served)
|
|
84
|
+
self._run = run
|
|
85
|
+
self._monotonic = monotonic
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._interval = interval_seconds
|
|
88
|
+
self._results: dict[tuple[str, str], HarnessSelfTest] = {}
|
|
89
|
+
self._last_run: dict[tuple[str, str], float] = {}
|
|
90
|
+
|
|
91
|
+
def result(self, contract_id: UUID | str, cli_kind: str) -> HarnessSelfTest | None:
|
|
92
|
+
return self._results.get((str(contract_id), cli_kind))
|
|
93
|
+
|
|
94
|
+
async def run_due(self, targets: Sequence[tuple[str, str]]) -> None:
|
|
95
|
+
for gone in (self._results.keys() | self._last_run.keys()) - set(targets):
|
|
96
|
+
self._forget(gone)
|
|
97
|
+
for contract_id, cli_kind in targets:
|
|
98
|
+
key = (contract_id, cli_kind)
|
|
99
|
+
last = self._last_run.get(key)
|
|
100
|
+
if last is not None and self._monotonic() - last < self._interval:
|
|
101
|
+
continue
|
|
102
|
+
# Checked before the spawn only: a Directive that starts during the (at most
|
|
103
|
+
# _TIMEOUT_SECONDS) spawn overlaps it. Accepted -- `--version` neither reads
|
|
104
|
+
# nor writes the harness root's state and holds one process slot briefly.
|
|
105
|
+
if self._in_flight.busy(contract_id):
|
|
106
|
+
# Not stamped: it runs on the first sweep after the Directive ends.
|
|
107
|
+
continue
|
|
108
|
+
result = await self._self_test(contract_id, cli_kind)
|
|
109
|
+
if result is None:
|
|
110
|
+
self._forget(key)
|
|
111
|
+
continue
|
|
112
|
+
self._results[key] = result
|
|
113
|
+
self._last_run[key] = self._monotonic()
|
|
114
|
+
|
|
115
|
+
def _forget(self, key: tuple[str, str]) -> None:
|
|
116
|
+
self._results.pop(key, None)
|
|
117
|
+
self._last_run.pop(key, None)
|
|
118
|
+
|
|
119
|
+
async def _self_test(self, contract_id: str, cli_kind: str) -> HarnessSelfTest | None:
|
|
120
|
+
"""``None`` when the harness root is gone, wiped since the sweep listed it."""
|
|
121
|
+
|
|
122
|
+
name = CLI_NAMES.get(cli_kind)
|
|
123
|
+
if name is None or cli_kind not in self._served:
|
|
124
|
+
return self._failed(SelfTestFailure.NOT_FOUND)
|
|
125
|
+
try:
|
|
126
|
+
sandbox = self._sandbox_for(contract_id, cli_kind)
|
|
127
|
+
except (ContractIsolationError, OSError):
|
|
128
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
129
|
+
if sandbox is None:
|
|
130
|
+
return None
|
|
131
|
+
env = {
|
|
132
|
+
"PATH": os.environ.get("PATH") or os.defpath,
|
|
133
|
+
"HOME": str(sandbox.home_dir),
|
|
134
|
+
"TMPDIR": str(sandbox.tmp_dir),
|
|
135
|
+
_HARNESS_ROOT_ENV[cli_kind]: str(sandbox.harness_config_dir),
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
answer = await self._run(
|
|
139
|
+
argv=[name, "--version"],
|
|
140
|
+
cwd=sandbox.home_dir,
|
|
141
|
+
env=env,
|
|
142
|
+
stdin=None,
|
|
143
|
+
timeout_seconds=_TIMEOUT_SECONDS,
|
|
144
|
+
output_limit_bytes=_OUTPUT_LIMIT_BYTES,
|
|
145
|
+
sandbox=sandbox,
|
|
146
|
+
)
|
|
147
|
+
except TimeoutError:
|
|
148
|
+
return self._failed(SelfTestFailure.TIMEOUT)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return self._failed(SelfTestFailure.NOT_FOUND)
|
|
151
|
+
except (OSError, subprocess.SubprocessError):
|
|
152
|
+
# SubprocessError: the sandbox floor's own preexec hook refused the spawn.
|
|
153
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
154
|
+
version = parse_cli_version(answer.stdout or answer.stderr)
|
|
155
|
+
if answer.exit_code != 0 or version is None:
|
|
156
|
+
return self._failed(SelfTestFailure.SPAWN_FAILED)
|
|
157
|
+
if not meets_floor(name, version):
|
|
158
|
+
return self._failed(SelfTestFailure.BELOW_FLOOR)
|
|
159
|
+
return HarnessSelfTest(ok=True, at=self._clock())
|
|
160
|
+
|
|
161
|
+
def _failed(self, reason: SelfTestFailure) -> HarnessSelfTest:
|
|
162
|
+
return HarnessSelfTest(ok=False, at=self._clock(), reason=reason)
|
|
@@ -56,6 +56,7 @@ from agentic_runner.credentials import (
|
|
|
56
56
|
HostCredentialStore,
|
|
57
57
|
)
|
|
58
58
|
from agentic_runner.device_login_activities import ContractDeviceLoginActivities
|
|
59
|
+
from agentic_runner.harness_self_test import DirectivesInFlight, HarnessSelfTests
|
|
59
60
|
from agentic_runner.heartbeat_link import WAKE_DIVERGENCE, HeartbeatLink
|
|
60
61
|
from agentic_runner.hooks import AttemptFacts, HookName, HookRunner
|
|
61
62
|
from agentic_runner.integrations.git.workspace import LocalGitWorkspace
|
|
@@ -168,6 +169,8 @@ TEMPORAL_TLS_ENV = "AGENTIC_TEMPORAL_TLS"
|
|
|
168
169
|
# glances at their inbox; anything tighter spends IMAP logins, not attention.
|
|
169
170
|
SOURCE_POLL_SECONDS_ENV = "AGENTIC_RUNNER_SOURCE_POLL_SECONDS"
|
|
170
171
|
DEFAULT_SOURCE_POLL_SECONDS = 60.0
|
|
172
|
+
# How often due harness self-tests are looked for; each is due every 15 minutes at most.
|
|
173
|
+
SELF_TEST_SWEEP_SECONDS = 60.0
|
|
171
174
|
# The Recipient Key is normally *generated here*, once per installation, and kept in the
|
|
172
175
|
# state directory (PRD issue 48, 22 A4). These two exist for an installer that mints it
|
|
173
176
|
# outside this process and are read only when both are set; the Helm chart takes the
|
|
@@ -558,6 +561,11 @@ class ControlPlaneStream:
|
|
|
558
561
|
messages: MessageStore | None = None
|
|
559
562
|
# Console-v2 issue 29: the latest start of each Runner-hosted Tool Server.
|
|
560
563
|
tool_servers: ToolServerHealthLog | None = None
|
|
564
|
+
# Local-agents 17: each runtime's descriptor, the harness roots on disk and the last
|
|
565
|
+
# self-test under each.
|
|
566
|
+
runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
|
|
567
|
+
contracts: ContractIsolation | None = None
|
|
568
|
+
self_tests: HarnessSelfTests | None = None
|
|
561
569
|
# Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
|
|
562
570
|
llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
|
|
563
571
|
_delivered: set[tuple[str, str]] = field(default_factory=set)
|
|
@@ -589,21 +597,44 @@ class ControlPlaneStream:
|
|
|
589
597
|
def _harnesses(self) -> list[HarnessVersion]:
|
|
590
598
|
"""Per Contract: the CLI's version and whether it runs on a delivered key (22 A8)
|
|
591
599
|
or on the login its funder created in the harness root -- read off the slot, never
|
|
592
|
-
off the root, which the Runner does not open.
|
|
600
|
+
off the root, which the Runner does not open. A Contract with a harness root and no
|
|
601
|
+
slot is listed too, so a login-only Contract gets its descriptor (local-agents 17).
|
|
602
|
+
"""
|
|
593
603
|
|
|
594
604
|
if self.attestation is None:
|
|
595
605
|
return []
|
|
596
606
|
versions = {cli.cli_kind: cli.version for cli in self.attestation.clis}
|
|
607
|
+
slots = {
|
|
608
|
+
(str(slot.contract_id), slot.runtime_kind): slot.present
|
|
609
|
+
for slot in self.proxy.slots.statuses()
|
|
610
|
+
}
|
|
611
|
+
roots = self.contracts.harness_roots() if self.contracts is not None else []
|
|
597
612
|
return [
|
|
598
|
-
|
|
599
|
-
contract_id=
|
|
600
|
-
cli_kind=slot.runtime_kind,
|
|
601
|
-
version=versions.get(slot.runtime_kind, "unknown"),
|
|
602
|
-
auth_mode="api_key" if slot.present else "login",
|
|
613
|
+
self._harness(
|
|
614
|
+
contract_id, cli_kind, versions, delivered=slots.get((contract_id, cli_kind), False)
|
|
603
615
|
)
|
|
604
|
-
for
|
|
616
|
+
for contract_id, cli_kind in sorted(set(slots) | set(roots))
|
|
605
617
|
]
|
|
606
618
|
|
|
619
|
+
def _harness(
|
|
620
|
+
self, contract_id: str, cli_kind: str, versions: Mapping[str, str], *, delivered: bool
|
|
621
|
+
) -> HarnessVersion:
|
|
622
|
+
runtime = self.runtimes.get(cli_kind)
|
|
623
|
+
capabilities = runtime.capabilities() if runtime is not None else None
|
|
624
|
+
return HarnessVersion(
|
|
625
|
+
contract_id=UUID(contract_id),
|
|
626
|
+
cli_kind=cli_kind,
|
|
627
|
+
version=versions.get(cli_kind, "unknown"),
|
|
628
|
+
auth_mode="api_key" if delivered else "login",
|
|
629
|
+
auth_modes=(sorted(capabilities.auth_modes) if capabilities is not None else None),
|
|
630
|
+
permission_mode=capabilities.permission_mode if capabilities is not None else None,
|
|
631
|
+
self_test=(
|
|
632
|
+
self.self_tests.result(contract_id, cli_kind)
|
|
633
|
+
if self.self_tests is not None
|
|
634
|
+
else None
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
|
|
607
638
|
async def directive_token(self, directive_id: str) -> str:
|
|
608
639
|
"""One activity execution's control-plane token, by Directive id (PRD issue 63)."""
|
|
609
640
|
|
|
@@ -929,6 +960,17 @@ async def _serve_registered(
|
|
|
929
960
|
load = _LoadInterceptor()
|
|
930
961
|
proxy = LlmProxy(slots=SlotStore(), ceilings=CeilingStore(), outbox=UsageOutbox())
|
|
931
962
|
credentials = build_credential_resolver(config, host_store=host_store)
|
|
963
|
+
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
964
|
+
runtimes = build_agent_runtimes(settings, attestation.clis if attestation is not None else [])
|
|
965
|
+
in_flight = DirectivesInFlight()
|
|
966
|
+
self_tests = HarnessSelfTests(
|
|
967
|
+
sandbox_for=lambda contract_id, cli_kind: isolation.existing_sandbox(
|
|
968
|
+
contract_id, runtime_kind=cli_kind
|
|
969
|
+
),
|
|
970
|
+
in_flight=in_flight,
|
|
971
|
+
served=runtimes,
|
|
972
|
+
monotonic=time.monotonic,
|
|
973
|
+
)
|
|
932
974
|
stream = ControlPlaneStream(
|
|
933
975
|
client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
|
|
934
976
|
state=state,
|
|
@@ -953,6 +995,9 @@ async def _serve_registered(
|
|
|
953
995
|
attestation=attestation,
|
|
954
996
|
lifecycle=lifecycle,
|
|
955
997
|
heartbeat_stamp=state_dir / HEARTBEAT_STAMP_NAME,
|
|
998
|
+
runtimes=runtimes,
|
|
999
|
+
contracts=isolation,
|
|
1000
|
+
self_tests=self_tests,
|
|
956
1001
|
)
|
|
957
1002
|
stream.sources = UserSourcePoller(
|
|
958
1003
|
stream=SignedIntakeStream(stream.client, state),
|
|
@@ -1001,12 +1046,13 @@ async def _serve_registered(
|
|
|
1001
1046
|
settings=settings,
|
|
1002
1047
|
config=config,
|
|
1003
1048
|
state=state,
|
|
1004
|
-
|
|
1049
|
+
isolation=isolation,
|
|
1050
|
+
agent_runtimes=runtimes,
|
|
1051
|
+
directives_in_flight=in_flight,
|
|
1005
1052
|
hooks=hooks,
|
|
1006
1053
|
proxy=proxy,
|
|
1007
1054
|
credentials=credentials,
|
|
1008
1055
|
link=link,
|
|
1009
|
-
attestation=attestation,
|
|
1010
1056
|
token_source=stream,
|
|
1011
1057
|
messages=messages,
|
|
1012
1058
|
tool_servers=tool_servers,
|
|
@@ -1024,13 +1070,16 @@ async def _serve_registered(
|
|
|
1024
1070
|
_heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
|
|
1025
1071
|
)
|
|
1026
1072
|
poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
|
|
1073
|
+
self_test = asyncio.create_task(
|
|
1074
|
+
_self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
|
|
1075
|
+
)
|
|
1027
1076
|
readiness.ready = True
|
|
1028
1077
|
await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
|
|
1029
1078
|
try:
|
|
1030
1079
|
await run_worker_until_terminated(worker, stop=stop)
|
|
1031
1080
|
finally:
|
|
1032
1081
|
readiness.ready = False
|
|
1033
|
-
for task in (beat, poll):
|
|
1082
|
+
for task in (beat, poll, self_test):
|
|
1034
1083
|
task.cancel()
|
|
1035
1084
|
with contextlib.suppress(asyncio.CancelledError):
|
|
1036
1085
|
await task
|
|
@@ -1112,18 +1161,36 @@ async def _poll_sources_forever(poller: UserSourcePoller, interval: float) -> No
|
|
|
1112
1161
|
await poller.poll()
|
|
1113
1162
|
|
|
1114
1163
|
|
|
1164
|
+
async def _self_test_forever(
|
|
1165
|
+
self_tests: HarnessSelfTests, isolation: ContractIsolation, interval: float
|
|
1166
|
+
) -> None:
|
|
1167
|
+
"""Each harness root's self-test at start, then whenever it falls due (local-agents 17).
|
|
1168
|
+
|
|
1169
|
+
Only roots already on disk: a Contract's uid is allocated by its first Directive,
|
|
1170
|
+
which records the allocation, and a self-test must not be the call that does it.
|
|
1171
|
+
"""
|
|
1172
|
+
|
|
1173
|
+
while True:
|
|
1174
|
+
try:
|
|
1175
|
+
await self_tests.run_due(isolation.harness_roots())
|
|
1176
|
+
except Exception as error: # noqa: BLE001 - a lost sweep is retried on the next one
|
|
1177
|
+
_logger.warning("harness self-test sweep failed: %s", error)
|
|
1178
|
+
await asyncio.sleep(interval)
|
|
1179
|
+
|
|
1180
|
+
|
|
1115
1181
|
def _activities(
|
|
1116
1182
|
fastapi: RunnerFastApiClient,
|
|
1117
1183
|
*,
|
|
1118
1184
|
settings: WorkerSettings,
|
|
1119
1185
|
config: RunnerConfig,
|
|
1120
1186
|
state: RunnerState,
|
|
1121
|
-
|
|
1187
|
+
isolation: ContractIsolation,
|
|
1188
|
+
agent_runtimes: Mapping[str, AgentRuntime],
|
|
1189
|
+
directives_in_flight: DirectivesInFlight,
|
|
1122
1190
|
hooks: HookRunner,
|
|
1123
1191
|
proxy: LlmProxy,
|
|
1124
1192
|
credentials: CredentialResolver,
|
|
1125
1193
|
link: HeartbeatLink,
|
|
1126
|
-
attestation: HostAttestation | None,
|
|
1127
1194
|
token_source: DirectiveTokenSource | None = None,
|
|
1128
1195
|
messages: MessageStore | None = None,
|
|
1129
1196
|
signaller: TemporalWorkflowSignaller | None = None,
|
|
@@ -1136,7 +1203,6 @@ def _activities(
|
|
|
1136
1203
|
api_base_url=os.getenv("GITHUB_API_BASE_URL", "https://api.github.com"),
|
|
1137
1204
|
)
|
|
1138
1205
|
static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
|
|
1139
|
-
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
1140
1206
|
attempt_records = AttemptRecords(config.state_dir, workspace_root=settings.WORKSPACE_ROOT)
|
|
1141
1207
|
# Only the dead go: a live record is a harness this process lost on restart, still
|
|
1142
1208
|
# writing its Workspace, and the next attempt must keep being refused (RR-05).
|
|
@@ -1144,9 +1210,7 @@ def _activities(
|
|
|
1144
1210
|
ralph = RunnerRalphActivities(
|
|
1145
1211
|
fastapi,
|
|
1146
1212
|
runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
|
|
1147
|
-
agent_runtimes=
|
|
1148
|
-
settings, attestation.clis if attestation is not None else []
|
|
1149
|
-
),
|
|
1213
|
+
agent_runtimes=agent_runtimes,
|
|
1150
1214
|
git_workspace=LocalGitWorkspace(
|
|
1151
1215
|
git_token=static_git_token,
|
|
1152
1216
|
git_token_provider=(
|
|
@@ -1177,6 +1241,7 @@ def _activities(
|
|
|
1177
1241
|
workflow_signaller=signaller,
|
|
1178
1242
|
tool_server_health=tool_servers,
|
|
1179
1243
|
attempt_records=attempt_records,
|
|
1244
|
+
directives_in_flight=directives_in_flight,
|
|
1180
1245
|
)
|
|
1181
1246
|
return [
|
|
1182
1247
|
*ralph.activity_callables(),
|
|
@@ -101,6 +101,24 @@ class DirectiveResult:
|
|
|
101
101
|
evidence: DirectiveEvidence
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
# The permission mode of a runtime whose guard configuration is incomplete: it refuses
|
|
105
|
+
# every Directive, so there is no mode it passes.
|
|
106
|
+
REFUSED_PERMISSION_MODE = "refused"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class RuntimeCapabilities:
|
|
111
|
+
"""What a runtime declares about itself on the heartbeat (local-agents 17).
|
|
112
|
+
|
|
113
|
+
``permission_mode`` is the fixed permission and approval mode the runtime passes its
|
|
114
|
+
harness, as a short patterned string. It is reported, never the gate: the Runner's
|
|
115
|
+
own floor is (ADR-0011 §12).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
auth_modes: frozenset[AuthMode]
|
|
119
|
+
permission_mode: str
|
|
120
|
+
|
|
121
|
+
|
|
104
122
|
@runtime_checkable
|
|
105
123
|
class AgentRuntime(Protocol):
|
|
106
124
|
"""The pluggable engine an Agent uses to execute one Directive per step."""
|
|
@@ -111,6 +129,8 @@ class AgentRuntime(Protocol):
|
|
|
111
129
|
# counts as "an API key is present" when the Runner chooses the mode.
|
|
112
130
|
host_api_key: bool
|
|
113
131
|
|
|
132
|
+
def capabilities(self) -> RuntimeCapabilities: ...
|
|
133
|
+
|
|
114
134
|
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult: ...
|
|
115
135
|
|
|
116
136
|
|
|
@@ -19,11 +19,13 @@ from agentic_runner.workers._runtime_support import (
|
|
|
19
19
|
workspace_id,
|
|
20
20
|
)
|
|
21
21
|
from agentic_runner.workers.agent_runtime import (
|
|
22
|
+
REFUSED_PERMISSION_MODE,
|
|
22
23
|
AuthMode,
|
|
23
24
|
DirectiveEvidence,
|
|
24
25
|
DirectiveRequest,
|
|
25
26
|
DirectiveResult,
|
|
26
27
|
ResumableAgentRuntime,
|
|
28
|
+
RuntimeCapabilities,
|
|
27
29
|
)
|
|
28
30
|
from agentic_runner.workers.contract_isolation import DirectiveSandbox
|
|
29
31
|
from agentic_runner.workers.mcp_config import claude_mcp_config
|
|
@@ -75,6 +77,16 @@ class ClaudeRuntime(ResumableAgentRuntime):
|
|
|
75
77
|
self._runner = runner or run_subprocess_exec
|
|
76
78
|
self.host_api_key = bool(settings.ANTHROPIC_API_KEY)
|
|
77
79
|
|
|
80
|
+
def capabilities(self) -> RuntimeCapabilities:
|
|
81
|
+
return RuntimeCapabilities(
|
|
82
|
+
auth_modes=self.auth_modes,
|
|
83
|
+
permission_mode=(
|
|
84
|
+
"permission=skip;output=json"
|
|
85
|
+
if _guard_mode(self._settings) is not None
|
|
86
|
+
else REFUSED_PERMISSION_MODE
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
78
90
|
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult:
|
|
79
91
|
workspace_path = request.workspace_path.resolve(strict=False)
|
|
80
92
|
workspace_root = self._settings.WORKSPACE_ROOT.resolve(strict=False)
|
|
@@ -23,11 +23,13 @@ from agentic_runner.workers._runtime_support import (
|
|
|
23
23
|
workspace_id,
|
|
24
24
|
)
|
|
25
25
|
from agentic_runner.workers.agent_runtime import (
|
|
26
|
+
REFUSED_PERMISSION_MODE,
|
|
26
27
|
AuthMode,
|
|
27
28
|
DirectiveEvidence,
|
|
28
29
|
DirectiveRequest,
|
|
29
30
|
DirectiveResult,
|
|
30
31
|
ResumableAgentRuntime,
|
|
32
|
+
RuntimeCapabilities,
|
|
31
33
|
)
|
|
32
34
|
from agentic_runner.workers.command_policy import CODEX_EPHEMERAL_CREDENTIALS_OVERRIDE
|
|
33
35
|
from agentic_runner.workers.contract_isolation import DirectiveSandbox
|
|
@@ -109,6 +111,20 @@ class CodexRuntime(ResumableAgentRuntime):
|
|
|
109
111
|
self._settings = settings
|
|
110
112
|
self._runner = runner or run_subprocess_exec
|
|
111
113
|
|
|
114
|
+
def capabilities(self) -> RuntimeCapabilities:
|
|
115
|
+
# Only the guarded configurations are named: anything else refuses every Directive,
|
|
116
|
+
# and a free-form setting would not fit the heartbeat's pattern.
|
|
117
|
+
guarded = _guard_mode(self._settings) is not None
|
|
118
|
+
return RuntimeCapabilities(
|
|
119
|
+
auth_modes=self.auth_modes,
|
|
120
|
+
permission_mode=(
|
|
121
|
+
f"sandbox={self._settings.CODEX_SANDBOX_MODE};"
|
|
122
|
+
f"approval={self._settings.CODEX_ASK_FOR_APPROVAL}"
|
|
123
|
+
if guarded
|
|
124
|
+
else REFUSED_PERMISSION_MODE
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
112
128
|
async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult:
|
|
113
129
|
workspace_path = request.workspace_path.resolve(strict=False)
|
|
114
130
|
workspace_root = self._settings.WORKSPACE_ROOT.resolve(strict=False)
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
@@ -311,6 +311,23 @@ class ContractIsolation:
|
|
|
311
311
|
gid=uid,
|
|
312
312
|
)
|
|
313
313
|
|
|
314
|
+
def existing_sandbox(
|
|
315
|
+
self, contract_id: str | None, *, runtime_kind: str
|
|
316
|
+
) -> DirectiveSandbox | None:
|
|
317
|
+
"""``sandbox`` only for a harness root already on disk, else ``None``.
|
|
318
|
+
|
|
319
|
+
For a caller that must never be the one to create a Contract's tree or allocate
|
|
320
|
+
its uid -- a terminated Contract's wipe may have just retired both. Synchronous
|
|
321
|
+
on purpose: no await may fall between the check and ``sandbox``, or a wipe on the
|
|
322
|
+
same event loop could land in between.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
if not self.harness_config_dir(contract_id, runtime_kind).is_dir():
|
|
326
|
+
return None
|
|
327
|
+
if self._can_separate_uids and not self.has_uid(contract_id):
|
|
328
|
+
return None
|
|
329
|
+
return self.sandbox(contract_id, runtime_kind=runtime_kind)
|
|
330
|
+
|
|
314
331
|
def prepare_workspace(self, contract_id: str | None, work_record_id: str) -> Path:
|
|
315
332
|
"""Create ``{contract_id}/{work_record_id}`` 0700, owned by the Contract's uid."""
|
|
316
333
|
|
|
@@ -431,6 +448,21 @@ class ContractIsolation:
|
|
|
431
448
|
held.append((contract_dir.name, entry.name))
|
|
432
449
|
return held
|
|
433
450
|
|
|
451
|
+
def harness_roots(self) -> list[tuple[str, str]]:
|
|
452
|
+
"""Every ``(contract_id, runtime_kind)`` harness root already on this Runner."""
|
|
453
|
+
|
|
454
|
+
if not self._workspace_root.is_dir():
|
|
455
|
+
return []
|
|
456
|
+
roots: list[tuple[str, str]] = []
|
|
457
|
+
for contract_dir in sorted(self._workspace_root.iterdir()):
|
|
458
|
+
harness_dir = contract_dir / _HARNESS_DIR
|
|
459
|
+
if not _is_uuid(contract_dir.name) or not harness_dir.is_dir():
|
|
460
|
+
continue
|
|
461
|
+
for entry in sorted(harness_dir.iterdir()):
|
|
462
|
+
if entry.is_dir() and _RUNTIME_KIND_RE.fullmatch(entry.name):
|
|
463
|
+
roots.append((contract_dir.name, entry.name))
|
|
464
|
+
return roots
|
|
465
|
+
|
|
434
466
|
def remove_workspace(self, contract_id: str | None, work_record_id: str) -> bool:
|
|
435
467
|
"""Delete one Work Record's Workspace, leaving the Contract's tree standing."""
|
|
436
468
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Recognise a subscription harness that stopped on its person's account (local-agents 08).
|
|
2
|
+
|
|
3
|
+
A usage limit or an expired sign-in is not something a fix Directive can repair, so the
|
|
4
|
+
Runner names it on the Directive's output (``HarnessHold``) and issue 09 holds instead of
|
|
5
|
+
failing. Pure text parsing, no I/O, re-implemented from the behaviour Paperclip's
|
|
6
|
+
``claude-local`` and ``codex-local`` adapters classify (research 01 §1, at ``6bc830b``) and
|
|
7
|
+
from the ACP spike's captures (local-agents 02: ``xLIM``, ``xEXP``, ``cEXP``).
|
|
8
|
+
|
|
9
|
+
A miss costs what it cost before this module -- a generic failure and a fix Directive --
|
|
10
|
+
while a false match holds a Contract that could have run. So every matcher is narrow: it
|
|
11
|
+
reads a failed turn only, and only the harness's structured error fields and stderr,
|
|
12
|
+
never the model's own messages, which may well talk about usage limits or sign-ins.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from datetime import UTC, datetime, timedelta, tzinfo
|
|
20
|
+
from typing import Any, Final
|
|
21
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
22
|
+
|
|
23
|
+
from agentic_runner.workers.harness_usage import extract_claude_result
|
|
24
|
+
from agentic_runner_contracts.activity_io import HarnessHold, HarnessHoldKind
|
|
25
|
+
|
|
26
|
+
__all__ = ["classify_harness_outcome"]
|
|
27
|
+
|
|
28
|
+
_CLAUDE_CODE: Final[str] = "claude_code"
|
|
29
|
+
_CODEX_CLI: Final[str] = "codex_cli"
|
|
30
|
+
|
|
31
|
+
# The ACP runtime raises a bridge's JSON-RPC error as ``ACP error <code>: <message>
|
|
32
|
+
# <data as sorted JSON>`` into ``DirectiveResult.error`` (local-agents 12). ``-32000`` is
|
|
33
|
+
# ACP's "authentication required", which both bridges answer for an expired or absent
|
|
34
|
+
# sign-in (``xEXP``, ``cEXP``); codex-acp marks a usage limit as ``-32603`` with
|
|
35
|
+
# ``codexErrorInfo: usageLimitExceeded`` and no reset time (``xLIM``).
|
|
36
|
+
_ACP_ERROR_RE: Final = re.compile(r"ACP error (-?\d+):")
|
|
37
|
+
_ACP_AUTH_REQUIRED: Final[int] = -32000
|
|
38
|
+
_ACP_INTERNAL_ERROR: Final[int] = -32603
|
|
39
|
+
_CODEX_USAGE_LIMIT_INFO_RE: Final = re.compile(r'"codexErrorInfo":\s*"usageLimitExceeded"')
|
|
40
|
+
|
|
41
|
+
_APOSTROPHE: Final[str] = "['’]"
|
|
42
|
+
|
|
43
|
+
_CODEX_USAGE_LIMIT_RE: Final = re.compile(
|
|
44
|
+
rf"you{_APOSTROPHE}ve hit your usage limit|usage limit (?:reached|exceeded)",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
_CODEX_SIGN_IN_RE: Final = re.compile(
|
|
48
|
+
r"refresh[_\s-]?token[_\s-]?(?:reused|expired|invalidated|revoked)"
|
|
49
|
+
r"|refresh token (?:has )?(?:already been used|expired|been invalidated|been revoked)"
|
|
50
|
+
r"|\binvalid_grant\b"
|
|
51
|
+
r"|(?:oauth|refresh|access[_\s-]?token|bearer).{0,80}(?:\b401\b|unauthori[sz]ed)"
|
|
52
|
+
r"|(?:\b401\b|unauthori[sz]ed).{0,80}(?:oauth|refresh|access[_\s-]?token|bearer)",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
_CODEX_RESET_RE: Final = re.compile(r"try again at\s+(?P<clock>[^.!\n]+)", re.IGNORECASE)
|
|
56
|
+
|
|
57
|
+
_CLAUDE_USAGE_LIMIT_RE: Final = re.compile(
|
|
58
|
+
rf"you{_APOSTROPHE}ve hit your (?:\w+ )?limit"
|
|
59
|
+
rf"|you{_APOSTROPHE}re out of extra usage"
|
|
60
|
+
r"|session limit (?:reached|exceeded)"
|
|
61
|
+
r"|(?:5[-\s]?hour|weekly|claude usage|usage) limit reached",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
_CLAUDE_SIGN_IN_RE: Final = re.compile(
|
|
65
|
+
r"not logged in|please log in|please run (?:`?claude login`?|/login)"
|
|
66
|
+
r"|login required|authentication required"
|
|
67
|
+
r"|invalid api key.{0,120}(?:/login|claude login|log in)"
|
|
68
|
+
r"|authentication[_\s-](?:failed|error)|failed to authenticate"
|
|
69
|
+
r"|invalid bearer token"
|
|
70
|
+
r"|(?:invalid|expired|revoked).{0,40}(?:bearer|oauth|access) token"
|
|
71
|
+
r"|(?:bearer|oauth|access) token.{0,40}(?:invalid|expired|revoked)",
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
_CLAUDE_RESET_RE: Final = re.compile(r"\bresets?\s+(?:at\s+)?(?P<clock>[^\n]+)", re.IGNORECASE)
|
|
75
|
+
|
|
76
|
+
# ``4pm``, ``11:31 PM``, ``3:15 AM (UTC)``, ``4:30 PM (America/Chicago)``.
|
|
77
|
+
_CLOCK_RE: Final = re.compile(
|
|
78
|
+
r"^(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<half>[ap])\.?\s*m\.?"
|
|
79
|
+
r"(?:\s*\((?P<zone>[^)]+)\))?",
|
|
80
|
+
re.IGNORECASE,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def classify_harness_outcome(
|
|
85
|
+
cli_kind: str,
|
|
86
|
+
*,
|
|
87
|
+
exit_code: int,
|
|
88
|
+
stdout: str,
|
|
89
|
+
stderr: str,
|
|
90
|
+
error: str = "",
|
|
91
|
+
now: datetime,
|
|
92
|
+
) -> HarnessHold | None:
|
|
93
|
+
"""``usage_limit`` (with ``retry_not_before`` when the harness named a reset),
|
|
94
|
+
``sign_in_required``, or None for every other outcome, a successful turn included.
|
|
95
|
+
|
|
96
|
+
``now`` is when the turn ended, timezone-aware: a reset is printed as a wall-clock
|
|
97
|
+
time, so it is the next such time after ``now`` -- in the zone the harness named, or
|
|
98
|
+
the Runner host's own, where the harness that printed it ran.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
if exit_code == 0:
|
|
102
|
+
return None
|
|
103
|
+
acp_hold = _acp_hold(error)
|
|
104
|
+
if acp_hold is not None:
|
|
105
|
+
return acp_hold
|
|
106
|
+
if cli_kind == _CLAUDE_CODE:
|
|
107
|
+
text = _joined(error, stderr, *_claude_error_fields(stdout))
|
|
108
|
+
usage_limit, sign_in, reset = _CLAUDE_USAGE_LIMIT_RE, _CLAUDE_SIGN_IN_RE, _CLAUDE_RESET_RE
|
|
109
|
+
elif cli_kind == _CODEX_CLI:
|
|
110
|
+
text = _joined(error, stderr, *_codex_error_events(stdout))
|
|
111
|
+
usage_limit, sign_in, reset = _CODEX_USAGE_LIMIT_RE, _CODEX_SIGN_IN_RE, _CODEX_RESET_RE
|
|
112
|
+
else:
|
|
113
|
+
return None
|
|
114
|
+
# A sign-in is checked first: a harness that cannot authenticate never reached the
|
|
115
|
+
# account whose limit it might also mention.
|
|
116
|
+
if sign_in.search(text):
|
|
117
|
+
return HarnessHold(kind=HarnessHoldKind.SIGN_IN_REQUIRED)
|
|
118
|
+
limit = usage_limit.search(text)
|
|
119
|
+
if limit is None:
|
|
120
|
+
return None
|
|
121
|
+
clock = reset.search(text, limit.end())
|
|
122
|
+
return HarnessHold(
|
|
123
|
+
kind=HarnessHoldKind.USAGE_LIMIT,
|
|
124
|
+
retry_not_before=_next_wall_clock(clock.group("clock"), now) if clock else None,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _acp_hold(error: str) -> HarnessHold | None:
|
|
129
|
+
match = _ACP_ERROR_RE.search(error)
|
|
130
|
+
if match is None:
|
|
131
|
+
return None
|
|
132
|
+
code = int(match.group(1))
|
|
133
|
+
if code == _ACP_AUTH_REQUIRED:
|
|
134
|
+
return HarnessHold(kind=HarnessHoldKind.SIGN_IN_REQUIRED)
|
|
135
|
+
if code == _ACP_INTERNAL_ERROR and _CODEX_USAGE_LIMIT_INFO_RE.search(error):
|
|
136
|
+
return HarnessHold(kind=HarnessHoldKind.USAGE_LIMIT)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _codex_error_events(stdout: str) -> list[str]:
|
|
141
|
+
"""The messages of ``codex exec --json``'s ``error`` and ``turn.failed`` events."""
|
|
142
|
+
|
|
143
|
+
messages: list[str] = []
|
|
144
|
+
for event in _json_lines(stdout):
|
|
145
|
+
if event.get("type") == "error":
|
|
146
|
+
messages.append(str(event.get("message") or ""))
|
|
147
|
+
elif event.get("type") == "turn.failed":
|
|
148
|
+
failure = event.get("error")
|
|
149
|
+
if isinstance(failure, dict):
|
|
150
|
+
messages.append(str(failure.get("message") or ""))
|
|
151
|
+
return messages
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _claude_error_fields(stdout: str) -> list[str]:
|
|
155
|
+
"""A failed ``result`` message's text, ``error`` and ``errors`` -- never an assistant
|
|
156
|
+
message, which is the model talking."""
|
|
157
|
+
|
|
158
|
+
result = extract_claude_result(stdout)
|
|
159
|
+
if result is None or not result.get("is_error"):
|
|
160
|
+
return []
|
|
161
|
+
fields = [str(result.get("result") or ""), str(result.get("error") or "")]
|
|
162
|
+
errors = result.get("errors")
|
|
163
|
+
if isinstance(errors, list):
|
|
164
|
+
for entry in errors:
|
|
165
|
+
if isinstance(entry, dict):
|
|
166
|
+
fields.append(str(entry.get("message") or entry.get("error") or ""))
|
|
167
|
+
else:
|
|
168
|
+
fields.append(str(entry))
|
|
169
|
+
return fields
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _json_lines(stdout: str) -> list[dict[str, Any]]:
|
|
173
|
+
events: list[dict[str, Any]] = []
|
|
174
|
+
for line in stdout.splitlines():
|
|
175
|
+
try:
|
|
176
|
+
event = json.loads(line)
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
179
|
+
if isinstance(event, dict):
|
|
180
|
+
events.append(event)
|
|
181
|
+
return events
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _joined(*parts: str) -> str:
|
|
185
|
+
return "\n".join(part.strip() for part in parts if part and part.strip())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _next_wall_clock(text: str, now: datetime) -> str | None:
|
|
189
|
+
match = _CLOCK_RE.match(text.strip())
|
|
190
|
+
if match is None:
|
|
191
|
+
return None
|
|
192
|
+
hour, minute = int(match.group("hour")), int(match.group("minute") or 0)
|
|
193
|
+
if not 1 <= hour <= 12 or not 0 <= minute <= 59:
|
|
194
|
+
return None
|
|
195
|
+
hour = hour % 12 + (12 if match.group("half").lower() == "p" else 0)
|
|
196
|
+
local_now = now.astimezone(_zone(match.group("zone")))
|
|
197
|
+
candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
198
|
+
if candidate <= local_now:
|
|
199
|
+
candidate += timedelta(days=1)
|
|
200
|
+
return candidate.astimezone(UTC).isoformat()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _zone(name: str | None) -> tzinfo | None:
|
|
204
|
+
"""The zone the harness named, or None -- ``astimezone``'s "this host's own"."""
|
|
205
|
+
|
|
206
|
+
if not name:
|
|
207
|
+
return None
|
|
208
|
+
if name.strip().upper() in {"UTC", "GMT"}:
|
|
209
|
+
return UTC
|
|
210
|
+
try:
|
|
211
|
+
return ZoneInfo(name.strip())
|
|
212
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
213
|
+
return None
|
|
@@ -45,6 +45,7 @@ from pydantic import BaseModel, ConfigDict
|
|
|
45
45
|
|
|
46
46
|
from agentic_runner import service
|
|
47
47
|
from agentic_runner.build import build_id
|
|
48
|
+
from agentic_runner.cli_floor import CLI_FLOORS, CLI_NAMES, meets_floor, parse_cli_version
|
|
48
49
|
from agentic_runner.host_store import open_workstation_store
|
|
49
50
|
from agentic_runner.lifecycle import LifecycleOutbox
|
|
50
51
|
from agentic_runner.registration import load_state
|
|
@@ -86,15 +87,7 @@ __all__ = [
|
|
|
86
87
|
|
|
87
88
|
ROOT_ENV: Final = "AGENTIC_RUNNER_WORKSTATION_ROOT"
|
|
88
89
|
|
|
89
|
-
# What the Profile's `cli_kind` runs, by executable name on PATH.
|
|
90
|
-
CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
|
|
91
|
-
# The versions the Runner image is built and tested against (Dockerfile.runner's
|
|
92
|
-
# CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
|
|
93
|
-
# floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
|
|
94
|
-
CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
|
|
95
|
-
|
|
96
90
|
_ORG = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
|
97
|
-
_VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
|
|
98
91
|
_LABEL_PREFIX: Final = "agentic-runner"
|
|
99
92
|
|
|
100
93
|
Run = Callable[..., subprocess.CompletedProcess[str]]
|
|
@@ -293,10 +286,6 @@ def require_any_cli(path: str) -> dict[str, str]:
|
|
|
293
286
|
return found
|
|
294
287
|
|
|
295
288
|
|
|
296
|
-
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
297
|
-
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
|
|
298
|
-
|
|
299
|
-
|
|
300
289
|
def cli_versions(found: Mapping[str, str], *, run: Run = subprocess.run) -> list[CliVersion]:
|
|
301
290
|
"""Each CLI's ``--version``, the only thing the Runner asks a CLI about itself.
|
|
302
291
|
|
|
@@ -315,16 +304,11 @@ def cli_versions(found: Mapping[str, str], *, run: Run = subprocess.run) -> list
|
|
|
315
304
|
)
|
|
316
305
|
except (OSError, subprocess.TimeoutExpired):
|
|
317
306
|
continue
|
|
318
|
-
|
|
319
|
-
if
|
|
307
|
+
version = parse_cli_version(answer.stdout or answer.stderr)
|
|
308
|
+
if version is None:
|
|
320
309
|
continue
|
|
321
|
-
version = match.group(1)
|
|
322
310
|
versions.append(
|
|
323
|
-
CliVersion(
|
|
324
|
-
cli_kind=cli_kind,
|
|
325
|
-
version=version,
|
|
326
|
-
meets_floor=_version_tuple(version) >= _version_tuple(CLI_FLOORS[name]),
|
|
327
|
-
)
|
|
311
|
+
CliVersion(cli_kind=cli_kind, version=version, meets_floor=meets_floor(name, version))
|
|
328
312
|
)
|
|
329
313
|
return versions
|
|
330
314
|
|
|
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.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.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.3.0 → agentic_runner-3.4.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.3.0 → agentic_runner-3.4.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.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|