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.
Files changed (68) hide show
  1. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/activities.py +115 -1
  4. agentic_runner-3.4.0/src/agentic_runner/cli_floor.py +33 -0
  5. agentic_runner-3.4.0/src/agentic_runner/harness_self_test.py +162 -0
  6. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/service.py +81 -16
  7. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/agent_runtime.py +20 -0
  8. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/claude_runtime.py +12 -0
  9. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/codex_runtime.py +16 -0
  10. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_isolation.py +32 -0
  11. agentic_runner-3.4.0/src/agentic_runner/workers/harness_outcome.py +213 -0
  12. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workstation.py +4 -20
  13. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/.gitignore +0 -0
  14. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/LICENSE +0 -0
  15. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/README.md +0 -0
  16. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/pyproject.toml +0 -0
  17. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/auth_mode.py +0 -0
  19. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/build.py +0 -0
  20. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/callback.py +0 -0
  21. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/child_watcher.py +0 -0
  22. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/cli.py +0 -0
  23. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/config.py +0 -0
  24. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/credentials.py +0 -0
  25. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/device_login_activities.py +0 -0
  26. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/egress.py +0 -0
  27. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/heartbeat_link.py +0 -0
  28. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/hooks.py +0 -0
  29. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/host_store.py +0 -0
  30. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/__init__.py +0 -0
  31. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  32. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  33. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  34. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  35. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  36. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  37. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  38. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  39. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  40. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/lifecycle.py +0 -0
  41. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/llm_proxy.py +0 -0
  42. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/mcp.py +0 -0
  43. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/message_store.py +0 -0
  44. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/private_state.py +0 -0
  45. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/py.typed +0 -0
  46. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  47. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/registration.py +0 -0
  48. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/__init__.py +0 -0
  49. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  50. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/sealed_box.py +0 -0
  51. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__init__.py +0 -0
  52. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__main__.py +0 -0
  53. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/control_plane.py +0 -0
  54. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/plugin.py +0 -0
  55. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  56. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/workflow.py +0 -0
  57. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/tiny_http.py +0 -0
  58. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/triage_activities.py +0 -0
  59. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/user_sources.py +0 -0
  60. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/__init__.py +0 -0
  61. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  62. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/command_policy.py +0 -0
  63. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  64. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  65. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  66. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  67. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/settings.py +0 -0
  68. {agentic_runner-3.3.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.3.0"
10
+ __version__ = "3.4.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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 consumed and self._message_store is not None and not ran.guard_mode_refused:
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
- HarnessVersion(
599
- contract_id=slot.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 slot in self.proxy.slots.statuses()
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
- can_change_uid=can_change_uid,
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
- can_change_uid: bool,
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=build_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)
@@ -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
- match = _VERSION.search(answer.stdout or answer.stderr)
319
- if match is None:
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