agentic-runner 2.6.0__tar.gz → 2.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/PKG-INFO +1 -1
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/activities.py +171 -26
- agentic_runner-2.7.0/src/agentic_runner/attempts.py +258 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/runtime/verifier_command.py +35 -5
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/service.py +6 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/_runtime_support.py +23 -8
- agentic_runner-2.7.0/src/agentic_runner/workers/skills.py +164 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/.gitignore +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/LICENSE +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/README.md +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/pyproject.toml +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -38,6 +38,7 @@ from uuid import UUID
|
|
|
38
38
|
from temporalio import activity
|
|
39
39
|
from temporalio.exceptions import ApplicationError
|
|
40
40
|
|
|
41
|
+
from agentic_runner.attempts import AttemptRecords, PriorAttemptAliveError, fence_work_record
|
|
41
42
|
from agentic_runner.callback import (
|
|
42
43
|
CALLBACK_TOKEN_ENV,
|
|
43
44
|
AnnotateRequest,
|
|
@@ -128,6 +129,14 @@ from agentic_runner.workers.fastapi_client import (
|
|
|
128
129
|
)
|
|
129
130
|
from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
|
|
130
131
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
132
|
+
from agentic_runner.workers.skills import (
|
|
133
|
+
SkillDelivery,
|
|
134
|
+
delivery_for,
|
|
135
|
+
first_digest_mismatch,
|
|
136
|
+
remove_skills,
|
|
137
|
+
skills_preamble,
|
|
138
|
+
write_skills,
|
|
139
|
+
)
|
|
131
140
|
from agentic_runner_contracts.activity_io import (
|
|
132
141
|
LEARNER_CHILDREN_BUDGET_BYTES,
|
|
133
142
|
LEARNING_FAILED,
|
|
@@ -237,6 +246,7 @@ from agentic_runner_contracts.routing import (
|
|
|
237
246
|
from agentic_runner_contracts.runtime_context import (
|
|
238
247
|
EGRESS_ALLOW_LIST_KEY,
|
|
239
248
|
McpServerSpec,
|
|
249
|
+
SkillVersionSpec,
|
|
240
250
|
WorkerRuntimeContext,
|
|
241
251
|
WorkerRuntimeContextResolver,
|
|
242
252
|
work_branch_name,
|
|
@@ -490,6 +500,8 @@ class _RuntimeContextState:
|
|
|
490
500
|
experience: str = ""
|
|
491
501
|
experience_lesson_ids: tuple[str, ...] = ()
|
|
492
502
|
experience_truncated: bool = False
|
|
503
|
+
# Console-v2 issue 28: the Skill versions attached to the Agent.
|
|
504
|
+
skills: tuple[SkillVersionSpec, ...] = ()
|
|
493
505
|
workspace_path: Path | None = None
|
|
494
506
|
branch_head_sha: str | None = None
|
|
495
507
|
# The Autonomy Policy's tier source (PRD issue 14) -- see WorkerRuntimeContext for
|
|
@@ -532,6 +544,9 @@ class _DirectiveAttempt:
|
|
|
532
544
|
# PRD issue 58: what config assembly wrote for the CLI, and where it may reach.
|
|
533
545
|
mcp_servers: tuple[McpServerEntry, ...] | None = None
|
|
534
546
|
egress_allow_list: tuple[str, ...] = ()
|
|
547
|
+
# Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
|
|
548
|
+
# write them into. Prefixed to the Directive prompt; empty when there is none.
|
|
549
|
+
skills_preamble: str = ""
|
|
535
550
|
|
|
536
551
|
async def run_environment_hook(self) -> None:
|
|
537
552
|
self.extra_env.update(await self.hooks.environment())
|
|
@@ -1321,8 +1336,10 @@ def wipe_contract_residue(
|
|
|
1321
1336
|
|
|
1322
1337
|
# Where a Runner-side routing refusal lands in the trail (PRD issue 42, 17 A11).
|
|
1323
1338
|
_ROUTING_EVIDENCE_SOURCE = "runner.routing"
|
|
1339
|
+
PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE = "runner.prior_attempt_alive"
|
|
1324
1340
|
DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
1325
1341
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1342
|
+
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
1326
1343
|
|
|
1327
1344
|
|
|
1328
1345
|
class _EvidenceWriter(Protocol):
|
|
@@ -1435,6 +1452,7 @@ class RunnerRalphActivities:
|
|
|
1435
1452
|
mcp_spawner: Spawner | None = None,
|
|
1436
1453
|
egress_resolver: Resolver | None = None,
|
|
1437
1454
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1455
|
+
attempt_records: AttemptRecords | None = None,
|
|
1438
1456
|
) -> None:
|
|
1439
1457
|
self._fastapi_client = fastapi_client
|
|
1440
1458
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
@@ -1494,6 +1512,9 @@ class RunnerRalphActivities:
|
|
|
1494
1512
|
self._mcp_spawner = mcp_spawner
|
|
1495
1513
|
self._egress_resolver = egress_resolver
|
|
1496
1514
|
self._tool_server_health = tool_server_health
|
|
1515
|
+
# The retry fence (runner-repo 05). None fences nothing: a process that never
|
|
1516
|
+
# registered has no state directory to keep the records in.
|
|
1517
|
+
self._attempt_records = attempt_records
|
|
1497
1518
|
|
|
1498
1519
|
def activity_callables(self) -> list[Callable[..., Any]]:
|
|
1499
1520
|
"""Return decorated activity callables for Temporal worker registration."""
|
|
@@ -1561,6 +1582,37 @@ class RunnerRalphActivities:
|
|
|
1561
1582
|
else "runner"
|
|
1562
1583
|
)
|
|
1563
1584
|
|
|
1585
|
+
@contextlib.asynccontextmanager
|
|
1586
|
+
async def _fenced(self, work_record_id: str) -> AsyncIterator[None]:
|
|
1587
|
+
"""Refuse this attempt's spawns while an earlier attempt's group lives (RR-05).
|
|
1588
|
+
|
|
1589
|
+
The refusal is retryable on purpose: the earlier attempt either finishes or is
|
|
1590
|
+
killed, and the next retry then finds the Workspace free.
|
|
1591
|
+
"""
|
|
1592
|
+
|
|
1593
|
+
if self._attempt_records is None:
|
|
1594
|
+
yield
|
|
1595
|
+
return
|
|
1596
|
+
attempt = activity.info().attempt if activity.in_activity() else 1
|
|
1597
|
+
try:
|
|
1598
|
+
with fence_work_record(self._attempt_records, work_record_id, attempt=attempt):
|
|
1599
|
+
yield
|
|
1600
|
+
except PriorAttemptAliveError as refusal:
|
|
1601
|
+
await _raise_recorded(
|
|
1602
|
+
refusal,
|
|
1603
|
+
self._fastapi_client,
|
|
1604
|
+
work_record_id,
|
|
1605
|
+
source=PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE,
|
|
1606
|
+
actor=self._actor(),
|
|
1607
|
+
payload={
|
|
1608
|
+
"event": "directive.prior_attempt_alive",
|
|
1609
|
+
"work_record_id": work_record_id,
|
|
1610
|
+
"attempt": attempt,
|
|
1611
|
+
"prior_attempt": refusal.prior.attempt,
|
|
1612
|
+
"prior_pgid": refusal.prior.pgid,
|
|
1613
|
+
},
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1564
1616
|
@activity.defn(name="create_or_update_branch_pr")
|
|
1565
1617
|
async def create_or_update_branch_pr(
|
|
1566
1618
|
self,
|
|
@@ -1570,7 +1622,9 @@ class RunnerRalphActivities:
|
|
|
1570
1622
|
await self._assert_routed(request)
|
|
1571
1623
|
asked: list[QuestionAsked] = []
|
|
1572
1624
|
try:
|
|
1573
|
-
|
|
1625
|
+
async with self._fenced(request.work_record_id):
|
|
1626
|
+
ran = await self._run_branch_pr_directive(request, asked)
|
|
1627
|
+
return _with_question(ran, asked)
|
|
1574
1628
|
except HookRefusedError:
|
|
1575
1629
|
# A Runner Hook at or before `pre_runtime` exited non-zero, `pre_directive`
|
|
1576
1630
|
# being the reject gate (PRD issue 45). Refusing the Directive is the point,
|
|
@@ -1754,7 +1808,8 @@ class RunnerRalphActivities:
|
|
|
1754
1808
|
extra_env=attempt.env,
|
|
1755
1809
|
mcp_servers=attempt.mcp_servers,
|
|
1756
1810
|
egress_allow_list=attempt.egress_allow_list,
|
|
1757
|
-
prompt=
|
|
1811
|
+
prompt=attempt.skills_preamble
|
|
1812
|
+
+ _directive_prompt(
|
|
1758
1813
|
completion_criteria=runtime_state.completion_criteria,
|
|
1759
1814
|
pr_body=request.pr_body,
|
|
1760
1815
|
persona_preamble=_persona_prompt_preamble(
|
|
@@ -2219,6 +2274,7 @@ class RunnerRalphActivities:
|
|
|
2219
2274
|
work_record_id, source=source, payload=dict(payload)
|
|
2220
2275
|
)
|
|
2221
2276
|
|
|
2277
|
+
await self._refuse_skill_digest_mismatch(work_record_id, state)
|
|
2222
2278
|
credentials = await self._resolve_credentials(work_record_id, state)
|
|
2223
2279
|
if credentials is not None:
|
|
2224
2280
|
# Names only, at the moment they resolved (22 A1's Evidence): a support read
|
|
@@ -2282,6 +2338,13 @@ class RunnerRalphActivities:
|
|
|
2282
2338
|
credentials=credentials,
|
|
2283
2339
|
env=callback_env,
|
|
2284
2340
|
)
|
|
2341
|
+
delivered_preamble = await self._deliver_skills(
|
|
2342
|
+
stack,
|
|
2343
|
+
work_record_id=work_record_id,
|
|
2344
|
+
state=state,
|
|
2345
|
+
sandbox=sandbox,
|
|
2346
|
+
directive_number=directive_number,
|
|
2347
|
+
)
|
|
2285
2348
|
session = await stack.enter_async_context(
|
|
2286
2349
|
DirectiveHookSession(
|
|
2287
2350
|
hooks=self._hooks,
|
|
@@ -2303,8 +2366,85 @@ class RunnerRalphActivities:
|
|
|
2303
2366
|
credentials=credentials,
|
|
2304
2367
|
mcp_servers=mcp_servers,
|
|
2305
2368
|
egress_allow_list=egress_allow_list,
|
|
2369
|
+
skills_preamble=delivered_preamble,
|
|
2306
2370
|
)
|
|
2307
2371
|
|
|
2372
|
+
async def _refuse_skill_digest_mismatch(
|
|
2373
|
+
self, work_record_id: str, state: _RuntimeContextState
|
|
2374
|
+
) -> None:
|
|
2375
|
+
"""A Skill whose body is not the version that was published fails the Directive
|
|
2376
|
+
non-retryably (console-v2 issue 28): a retry would fetch the same body, and
|
|
2377
|
+
running on it would hand the Agent prompt material no person reviewed."""
|
|
2378
|
+
|
|
2379
|
+
mismatch = first_digest_mismatch(state.skills)
|
|
2380
|
+
if mismatch is None:
|
|
2381
|
+
return
|
|
2382
|
+
await _raise_recorded(
|
|
2383
|
+
ApplicationError(
|
|
2384
|
+
f"Skill {mismatch.slug} version {mismatch.version} does not match its sha256",
|
|
2385
|
+
non_retryable=True,
|
|
2386
|
+
),
|
|
2387
|
+
self._fastapi_client,
|
|
2388
|
+
work_record_id,
|
|
2389
|
+
source=SKILLS_EVIDENCE_SOURCE,
|
|
2390
|
+
actor=self._actor(),
|
|
2391
|
+
payload={
|
|
2392
|
+
"event": "skills.digest_mismatch",
|
|
2393
|
+
"agent_id": state.agent_id,
|
|
2394
|
+
"slug": mismatch.slug,
|
|
2395
|
+
"version": mismatch.version,
|
|
2396
|
+
"sha256": mismatch.sha256,
|
|
2397
|
+
},
|
|
2398
|
+
)
|
|
2399
|
+
|
|
2400
|
+
async def _deliver_skills(
|
|
2401
|
+
self,
|
|
2402
|
+
stack: contextlib.AsyncExitStack,
|
|
2403
|
+
*,
|
|
2404
|
+
work_record_id: str,
|
|
2405
|
+
state: _RuntimeContextState,
|
|
2406
|
+
sandbox: DirectiveSandbox | None,
|
|
2407
|
+
directive_number: int,
|
|
2408
|
+
) -> str:
|
|
2409
|
+
"""Put the attached Skills where this runtime reads them; return any preamble.
|
|
2410
|
+
|
|
2411
|
+
Written files are removed on the attempt's exit, success or failure. A Runner
|
|
2412
|
+
with no Contract sandbox has no harness root it owns, so it falls back to the
|
|
2413
|
+
preamble rather than writing into a shared ``CODEX_HOME``.
|
|
2414
|
+
"""
|
|
2415
|
+
|
|
2416
|
+
if not state.skills:
|
|
2417
|
+
return ""
|
|
2418
|
+
delivery = delivery_for(state.cli_kind)
|
|
2419
|
+
if delivery is SkillDelivery.DIRECTORY and sandbox is None:
|
|
2420
|
+
delivery = SkillDelivery.PROMPT_PREAMBLE
|
|
2421
|
+
preamble = ""
|
|
2422
|
+
if delivery is SkillDelivery.DIRECTORY:
|
|
2423
|
+
assert sandbox is not None
|
|
2424
|
+
# Registered before the write, so a write that fails half-way is cleaned too.
|
|
2425
|
+
stack.push_async_callback(
|
|
2426
|
+
remove_skills, sandbox.harness_config_dir, state.skills, uid=sandbox.uid
|
|
2427
|
+
)
|
|
2428
|
+
await write_skills(sandbox.harness_config_dir, state.skills, uid=sandbox.uid)
|
|
2429
|
+
else:
|
|
2430
|
+
preamble = skills_preamble(state.skills)
|
|
2431
|
+
await self._fastapi_client.append_evidence(
|
|
2432
|
+
work_record_id,
|
|
2433
|
+
source=SKILLS_EVIDENCE_SOURCE,
|
|
2434
|
+
payload={
|
|
2435
|
+
"event": "skills.delivered",
|
|
2436
|
+
"directive_number": directive_number,
|
|
2437
|
+
"agent_id": state.agent_id,
|
|
2438
|
+
"cli_kind": state.cli_kind,
|
|
2439
|
+
"delivery": delivery.value,
|
|
2440
|
+
"skills": [
|
|
2441
|
+
{"slug": skill.slug, "version": skill.version, "sha256": skill.sha256}
|
|
2442
|
+
for skill in state.skills
|
|
2443
|
+
],
|
|
2444
|
+
},
|
|
2445
|
+
)
|
|
2446
|
+
return preamble
|
|
2447
|
+
|
|
2308
2448
|
async def _plan_mcp(
|
|
2309
2449
|
self,
|
|
2310
2450
|
work_record_id: str,
|
|
@@ -2956,6 +3096,7 @@ class RunnerRalphActivities:
|
|
|
2956
3096
|
experience=runtime_context.experience,
|
|
2957
3097
|
experience_lesson_ids=tuple(runtime_context.experience_lesson_ids),
|
|
2958
3098
|
experience_truncated=runtime_context.experience_truncated,
|
|
3099
|
+
skills=tuple(runtime_context.skills),
|
|
2959
3100
|
workspace_path=resolved_workspace,
|
|
2960
3101
|
branch_head_sha=branch_head_sha,
|
|
2961
3102
|
product_id=runtime_context.product_id,
|
|
@@ -3811,7 +3952,7 @@ class RunnerRalphActivities:
|
|
|
3811
3952
|
async def run_verifier(self, request: VerifierRunInput) -> VerifierRunOutput:
|
|
3812
3953
|
"""Run product-owned verifier config and record bounded redacted evidence."""
|
|
3813
3954
|
await self._assert_routed(request)
|
|
3814
|
-
async with _liveness_heartbeats():
|
|
3955
|
+
async with _liveness_heartbeats(), self._fenced(request.work_record_id):
|
|
3815
3956
|
if self._runtime_dependencies_ready():
|
|
3816
3957
|
state = await self._runtime_state(
|
|
3817
3958
|
request.work_record_id, workspace_path=request.workspace_path
|
|
@@ -3909,7 +4050,7 @@ class RunnerRalphActivities:
|
|
|
3909
4050
|
sandbox=verifier_sandbox,
|
|
3910
4051
|
)
|
|
3911
4052
|
try:
|
|
3912
|
-
# Run in a thread:
|
|
4053
|
+
# Run in a thread: waiting on the verifier inline would block the worker
|
|
3913
4054
|
# event loop for up to the verifier timeout, starving the liveness
|
|
3914
4055
|
# heartbeats above (and every other activity on this pod).
|
|
3915
4056
|
result = await asyncio.to_thread(
|
|
@@ -3981,7 +4122,8 @@ class RunnerRalphActivities:
|
|
|
3981
4122
|
repair a verifier failure, then re-push. Exactly one runtime turn (ADR-0007)."""
|
|
3982
4123
|
await self._assert_routed(request)
|
|
3983
4124
|
try:
|
|
3984
|
-
|
|
4125
|
+
async with self._fenced(request.work_record_id):
|
|
4126
|
+
return await self._run_fix_directive(request)
|
|
3985
4127
|
except HookRefusedError as refusal:
|
|
3986
4128
|
# Same posture as the first Directive: a hook refusal ends the loop
|
|
3987
4129
|
# attributably instead of raising into an endless retry (PRD issue 45).
|
|
@@ -4071,25 +4213,26 @@ class RunnerRalphActivities:
|
|
|
4071
4213
|
)
|
|
4072
4214
|
|
|
4073
4215
|
try:
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4216
|
+
async with self._fenced(request.work_record_id):
|
|
4217
|
+
ran = await self._run_in_place_directive(
|
|
4218
|
+
request,
|
|
4219
|
+
_InPlaceDirective(
|
|
4220
|
+
lifecycle_source="ralph.execute_member_directive",
|
|
4221
|
+
run_source="ralph.member_directive.codex_run",
|
|
4222
|
+
push_source="ralph.member_directive.git_commit_push",
|
|
4223
|
+
commit_message=(
|
|
4224
|
+
f"feat(task): directive {request.directive_number} by Agent "
|
|
4225
|
+
f"{request.agent_id} for work record {request.work_record_id}"
|
|
4226
|
+
),
|
|
4227
|
+
push_summary=(
|
|
4228
|
+
f"Push Directive {request.directive_number} ({request.role or 'member'}"
|
|
4229
|
+
f", woken by {request.wake_reason or 'pending messages'}) to "
|
|
4230
|
+
f"{request.repository}."
|
|
4231
|
+
),
|
|
4232
|
+
prompt=prompt,
|
|
4233
|
+
push_when_clean=False,
|
|
4088
4234
|
),
|
|
4089
|
-
|
|
4090
|
-
push_when_clean=False,
|
|
4091
|
-
),
|
|
4092
|
-
)
|
|
4235
|
+
)
|
|
4093
4236
|
except HookRefusedError as refusal:
|
|
4094
4237
|
return MemberDirectiveOutput(
|
|
4095
4238
|
work_record_id=request.work_record_id,
|
|
@@ -4163,7 +4306,8 @@ class RunnerRalphActivities:
|
|
|
4163
4306
|
summary="directive unavailable: runtime dependencies missing",
|
|
4164
4307
|
)
|
|
4165
4308
|
try:
|
|
4166
|
-
|
|
4309
|
+
async with self._fenced(request.work_record_id):
|
|
4310
|
+
return await self._run_learning_directive(request)
|
|
4167
4311
|
except HookRefusedError as refusal:
|
|
4168
4312
|
return self._learning_output(
|
|
4169
4313
|
request,
|
|
@@ -4211,7 +4355,8 @@ class RunnerRalphActivities:
|
|
|
4211
4355
|
extra_env=attempt.env,
|
|
4212
4356
|
mcp_servers=attempt.mcp_servers,
|
|
4213
4357
|
egress_allow_list=attempt.egress_allow_list,
|
|
4214
|
-
prompt=
|
|
4358
|
+
prompt=attempt.skills_preamble
|
|
4359
|
+
+ _learning_prompt(
|
|
4215
4360
|
request,
|
|
4216
4361
|
base_branch=state.base_branch or request.base_ref,
|
|
4217
4362
|
transcript=self._learner_transcript(state, request.work_record_id),
|
|
@@ -4446,7 +4591,7 @@ class RunnerRalphActivities:
|
|
|
4446
4591
|
extra_env=attempt.env,
|
|
4447
4592
|
mcp_servers=attempt.mcp_servers,
|
|
4448
4593
|
egress_allow_list=attempt.egress_allow_list,
|
|
4449
|
-
prompt=await kind.prompt(state, prompt_notes),
|
|
4594
|
+
prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
|
|
4450
4595
|
base_branch=request.base_ref,
|
|
4451
4596
|
work_branch=state.work_branch,
|
|
4452
4597
|
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""One live process group per Work Record, fenced across activity attempts (runner-repo 05).
|
|
2
|
+
|
|
3
|
+
A lost heartbeat makes Temporal schedule a retry while the first attempt's coroutine,
|
|
4
|
+
and the harness it spawned, may still be running in this same process. Both would then
|
|
5
|
+
write one Workspace, which ADR-0015 §2 and ADR-0012 assume never happens. So every process
|
|
6
|
+
group a Work Record's Directive, verifier run or Runner Hook spawns is recorded as
|
|
7
|
+
``{pgid, start_time, attempt}`` under the Runner's state directory, and the next spawn
|
|
8
|
+
for that Work Record is refused while the recorded group's leader is still alive.
|
|
9
|
+
|
|
10
|
+
Liveness is pid *plus* start time, as Paperclip fences its restarts: a pid alone is
|
|
11
|
+
recycled, and a record naming a recycled pid must not block a Work Record forever. The
|
|
12
|
+
record lives in the state directory, never the Workspace, because the Agent writes the
|
|
13
|
+
Workspace and could forge or delete its own fence there.
|
|
14
|
+
|
|
15
|
+
The binding is a context variable rather than a parameter: the spawn sites sit behind
|
|
16
|
+
the Agent Runtime port, the Runner Hook runner and the verifier's command runner, and
|
|
17
|
+
none of them knows which Work Record it serves. ``asyncio.to_thread`` copies the
|
|
18
|
+
context, so the verifier's thread sees the activity's binding too.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextvars import ContextVar
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Final
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ATTEMPTS_DIR",
|
|
37
|
+
"AttemptRecord",
|
|
38
|
+
"AttemptRecords",
|
|
39
|
+
"PriorAttemptAliveError",
|
|
40
|
+
"fence_work_record",
|
|
41
|
+
"process_start_time",
|
|
42
|
+
"recorded_group",
|
|
43
|
+
"refuse_if_prior_attempt_alive",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
ATTEMPTS_DIR: Final[str] = "attempts"
|
|
47
|
+
_DIR_MODE: Final[int] = 0o700
|
|
48
|
+
_FILE_MODE: Final[int] = 0o600
|
|
49
|
+
# A Work Record id names a file here, so it must not be able to name one anywhere else.
|
|
50
|
+
_SAFE_ID: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]*")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class AttemptRecord:
|
|
55
|
+
pgid: int
|
|
56
|
+
start_time: str
|
|
57
|
+
attempt: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PriorAttemptAliveError(Exception):
|
|
61
|
+
"""A spawn was refused: an earlier attempt's process group for this Work Record lives."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, work_record_id: str, prior: AttemptRecord) -> None:
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"work record {work_record_id}: attempt {prior.attempt} still runs "
|
|
66
|
+
f"(process group {prior.pgid})"
|
|
67
|
+
)
|
|
68
|
+
self.work_record_id = work_record_id
|
|
69
|
+
self.prior = prior
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def process_start_time(pid: int) -> str | None:
|
|
73
|
+
"""When ``pid`` started, in the platform's own units, or None if no such process.
|
|
74
|
+
|
|
75
|
+
Only ever compared for equality with an earlier reading of the same pid on the same
|
|
76
|
+
host, so the units need not agree across platforms: clock ticks since boot on Linux,
|
|
77
|
+
the ``lstart`` text on macOS (one-second resolution, which a pid recycled within the
|
|
78
|
+
same second would defeat -- not a case a harness spawn produces).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if sys.platform == "linux":
|
|
82
|
+
return _linux_start_time(pid)
|
|
83
|
+
return _ps_start_time(pid)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _linux_start_time(pid: int) -> str | None:
|
|
87
|
+
try:
|
|
88
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace")
|
|
89
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
90
|
+
return None
|
|
91
|
+
# Field 2 is the command name in parentheses and may itself hold spaces or ')', so
|
|
92
|
+
# the fields are counted from after its *last* ')': field 3 is index 0, 22 is 19.
|
|
93
|
+
return stat[stat.rindex(")") + 2 :].split()[19]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ps_start_time(pid: int) -> str | None:
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
started = completed.stdout.strip()
|
|
104
|
+
return started if completed.returncode == 0 and started else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AttemptRecords:
|
|
108
|
+
"""The ``attempts/`` directory of one Runner's state directory."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, state_dir: Path, *, workspace_root: Path) -> None:
|
|
111
|
+
self._dir = state_dir / ATTEMPTS_DIR
|
|
112
|
+
if self._dir.resolve().is_relative_to(workspace_root.resolve()):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"attempt records at {self._dir} would sit under the workspace root "
|
|
115
|
+
f"{workspace_root}, where an Agent could forge them"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def path(self, work_record_id: str) -> Path:
|
|
119
|
+
if not _SAFE_ID.fullmatch(work_record_id):
|
|
120
|
+
raise ValueError(f"work record id is not a file name: {work_record_id!r}")
|
|
121
|
+
return self._dir / f"{work_record_id}.json"
|
|
122
|
+
|
|
123
|
+
def refuse_if_alive(self, work_record_id: str) -> None:
|
|
124
|
+
"""Raise if a recorded group for this Work Record lives; clear a stale record."""
|
|
125
|
+
|
|
126
|
+
path = self.path(work_record_id)
|
|
127
|
+
record = _read(path)
|
|
128
|
+
if record is None:
|
|
129
|
+
return
|
|
130
|
+
if _alive(record):
|
|
131
|
+
raise PriorAttemptAliveError(work_record_id, record)
|
|
132
|
+
_unlink_if(path, record)
|
|
133
|
+
|
|
134
|
+
def record(self, work_record_id: str, *, pgid: int, attempt: int) -> AttemptRecord | None:
|
|
135
|
+
"""Claim the Work Record for a just-spawned group; None if it already exited.
|
|
136
|
+
|
|
137
|
+
Exclusive create, so two attempts that both passed the pre-spawn check in the
|
|
138
|
+
same instant cannot both hold the claim: the second finds the first's record
|
|
139
|
+
and is refused.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
start_time = process_start_time(pgid)
|
|
143
|
+
if start_time is None:
|
|
144
|
+
return None
|
|
145
|
+
record = AttemptRecord(pgid=pgid, start_time=start_time, attempt=attempt)
|
|
146
|
+
path = self.path(work_record_id)
|
|
147
|
+
self._dir.mkdir(mode=_DIR_MODE, parents=True, exist_ok=True)
|
|
148
|
+
while True:
|
|
149
|
+
try:
|
|
150
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, _FILE_MODE)
|
|
151
|
+
except FileExistsError:
|
|
152
|
+
self.refuse_if_alive(work_record_id)
|
|
153
|
+
continue
|
|
154
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
155
|
+
json.dump(asdict(record), handle)
|
|
156
|
+
return record
|
|
157
|
+
|
|
158
|
+
def release(self, work_record_id: str, record: AttemptRecord) -> None:
|
|
159
|
+
"""Remove our own record once its group leader has exited.
|
|
160
|
+
|
|
161
|
+
Kept while the leader lives -- a cancel whose kill did not land -- so the next
|
|
162
|
+
attempt is still refused rather than let in beside it.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
if not _alive(record):
|
|
166
|
+
_unlink_if(self.path(work_record_id), record)
|
|
167
|
+
|
|
168
|
+
def sweep(self) -> None:
|
|
169
|
+
"""At Runner start: drop records whose process is gone, keep the live ones.
|
|
170
|
+
|
|
171
|
+
A workstation Runner restarted without its children leaves them running in the
|
|
172
|
+
Workspace; their records stay so the check still refuses until they exit.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
if not self._dir.is_dir():
|
|
176
|
+
return
|
|
177
|
+
for path in self._dir.glob("*.json"):
|
|
178
|
+
record = _read(path)
|
|
179
|
+
if record is None or not _alive(record):
|
|
180
|
+
path.unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _read(path: Path) -> AttemptRecord | None:
|
|
184
|
+
try:
|
|
185
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
return AttemptRecord(
|
|
187
|
+
pgid=int(payload["pgid"]),
|
|
188
|
+
start_time=str(payload["start_time"]),
|
|
189
|
+
attempt=int(payload["attempt"]),
|
|
190
|
+
)
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
return None
|
|
193
|
+
except (ValueError, KeyError, TypeError):
|
|
194
|
+
# Half-written by a Runner that died mid-write: it fences nothing it can name.
|
|
195
|
+
return AttemptRecord(pgid=0, start_time="", attempt=0)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _alive(record: AttemptRecord) -> bool:
|
|
199
|
+
return record.pgid > 0 and process_start_time(record.pgid) == record.start_time
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _unlink_if(path: Path, record: AttemptRecord) -> None:
|
|
203
|
+
# Another attempt may have replaced a stale record between our read and this unlink;
|
|
204
|
+
# only the record we judged is ours to remove.
|
|
205
|
+
if _read(path) == record:
|
|
206
|
+
path.unlink(missing_ok=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class _Fence:
|
|
211
|
+
records: AttemptRecords
|
|
212
|
+
work_record_id: str
|
|
213
|
+
attempt: int
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_FENCE: ContextVar[_Fence | None] = ContextVar("agentic_runner_attempt_fence", default=None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@contextlib.contextmanager
|
|
220
|
+
def fence_work_record(
|
|
221
|
+
records: AttemptRecords, work_record_id: str, *, attempt: int
|
|
222
|
+
) -> Iterator[None]:
|
|
223
|
+
"""Fence every process group spawned inside this block to ``work_record_id``."""
|
|
224
|
+
|
|
225
|
+
token = _FENCE.set(_Fence(records=records, work_record_id=work_record_id, attempt=attempt))
|
|
226
|
+
try:
|
|
227
|
+
yield
|
|
228
|
+
finally:
|
|
229
|
+
_FENCE.reset(token)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def refuse_if_prior_attempt_alive() -> None:
|
|
233
|
+
"""Called before a spawn; a no-op outside a fenced block."""
|
|
234
|
+
|
|
235
|
+
fence = _FENCE.get()
|
|
236
|
+
if fence is not None:
|
|
237
|
+
fence.records.refuse_if_alive(fence.work_record_id)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@contextlib.contextmanager
|
|
241
|
+
def recorded_group(pgid: int) -> Iterator[None]:
|
|
242
|
+
"""Hold the Work Record's record for a spawned group while the block waits on it.
|
|
243
|
+
|
|
244
|
+
Raises :class:`PriorAttemptAliveError` on entry if another attempt claimed the Work
|
|
245
|
+
Record since the pre-spawn check; the caller kills the group it just spawned.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
fence = _FENCE.get()
|
|
249
|
+
record = (
|
|
250
|
+
None
|
|
251
|
+
if fence is None
|
|
252
|
+
else fence.records.record(fence.work_record_id, pgid=pgid, attempt=fence.attempt)
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
yield
|
|
256
|
+
finally:
|
|
257
|
+
if fence is not None and record is not None:
|
|
258
|
+
fence.records.release(fence.work_record_id, record)
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
8
|
+
import signal
|
|
7
9
|
import subprocess
|
|
8
10
|
import time
|
|
9
11
|
from collections.abc import Callable, Mapping
|
|
@@ -11,6 +13,11 @@ from dataclasses import dataclass
|
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Protocol
|
|
13
15
|
|
|
16
|
+
from agentic_runner.attempts import (
|
|
17
|
+
PriorAttemptAliveError,
|
|
18
|
+
recorded_group,
|
|
19
|
+
refuse_if_prior_attempt_alive,
|
|
20
|
+
)
|
|
14
21
|
from agentic_runner_contracts.redaction import redact_secret_like_text
|
|
15
22
|
|
|
16
23
|
DEFAULT_OUTPUT_LIMIT_BYTES = 16_384
|
|
@@ -91,21 +98,44 @@ def _subprocess_runner(
|
|
|
91
98
|
that cannot separate uids still verifies, and they arrive together from
|
|
92
99
|
``DirectiveSandbox.spawn_kwargs`` — the uid drop in subprocess's own C fork-exec path,
|
|
93
100
|
only the rlimits in the Python hook.
|
|
101
|
+
|
|
102
|
+
Its own session, killed as a group on timeout, like a Directive: the test suite
|
|
103
|
+
forks workers into the Workspace, and the attempt fence (``attempts.py``) records
|
|
104
|
+
the group by its pgid.
|
|
94
105
|
"""
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
refuse_if_prior_attempt_alive()
|
|
108
|
+
with subprocess.Popen(
|
|
97
109
|
list(argv),
|
|
98
110
|
cwd=cwd,
|
|
99
111
|
env=env,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
capture_output=True,
|
|
112
|
+
stdout=subprocess.PIPE,
|
|
113
|
+
stderr=subprocess.PIPE,
|
|
103
114
|
text=True,
|
|
115
|
+
start_new_session=True,
|
|
104
116
|
preexec_fn=preexec_fn,
|
|
105
117
|
user=user,
|
|
106
118
|
group=group,
|
|
107
119
|
extra_groups=extra_groups,
|
|
108
|
-
)
|
|
120
|
+
) as process:
|
|
121
|
+
try:
|
|
122
|
+
with recorded_group(process.pid):
|
|
123
|
+
try:
|
|
124
|
+
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
_kill_group(process)
|
|
127
|
+
raise
|
|
128
|
+
except PriorAttemptAliveError:
|
|
129
|
+
_kill_group(process)
|
|
130
|
+
raise
|
|
131
|
+
return subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _kill_group(process: subprocess.Popen[str]) -> None:
|
|
135
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
136
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
137
|
+
process.kill()
|
|
138
|
+
process.communicate()
|
|
109
139
|
|
|
110
140
|
|
|
111
141
|
def run(
|
|
@@ -44,6 +44,7 @@ from temporalio.worker import (
|
|
|
44
44
|
|
|
45
45
|
from agentic_runner import __version__ as runner_version
|
|
46
46
|
from agentic_runner.activities import RunnerRalphActivities
|
|
47
|
+
from agentic_runner.attempts import AttemptRecords
|
|
47
48
|
from agentic_runner.child_watcher import install_stop_tolerant_child_watcher
|
|
48
49
|
from agentic_runner.config import RunnerConfig
|
|
49
50
|
from agentic_runner.config import load as load_config
|
|
@@ -1021,6 +1022,10 @@ def _activities(
|
|
|
1021
1022
|
)
|
|
1022
1023
|
static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
|
|
1023
1024
|
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
1025
|
+
attempt_records = AttemptRecords(config.state_dir, workspace_root=settings.WORKSPACE_ROOT)
|
|
1026
|
+
# Only the dead go: a live record is a harness this process lost on restart, still
|
|
1027
|
+
# writing its Workspace, and the next attempt must keep being refused (RR-05).
|
|
1028
|
+
attempt_records.sweep()
|
|
1024
1029
|
ralph = RunnerRalphActivities(
|
|
1025
1030
|
fastapi,
|
|
1026
1031
|
runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
|
|
@@ -1056,6 +1061,7 @@ def _activities(
|
|
|
1056
1061
|
message_store=messages,
|
|
1057
1062
|
workflow_signaller=signaller,
|
|
1058
1063
|
tool_server_health=tool_servers,
|
|
1064
|
+
attempt_records=attempt_records,
|
|
1059
1065
|
)
|
|
1060
1066
|
return [
|
|
1061
1067
|
*ralph.activity_callables(),
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
@@ -19,6 +19,11 @@ from dataclasses import dataclass
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Final
|
|
21
21
|
|
|
22
|
+
from agentic_runner.attempts import (
|
|
23
|
+
PriorAttemptAliveError,
|
|
24
|
+
recorded_group,
|
|
25
|
+
refuse_if_prior_attempt_alive,
|
|
26
|
+
)
|
|
22
27
|
from agentic_runner.callback import CALLBACK_SOCKET_ENV, CALLBACK_TOKEN_ENV
|
|
23
28
|
from agentic_runner.egress import EGRESS_ENV_NAMES
|
|
24
29
|
from agentic_runner.llm_proxy import PROXY_ENV_NAMES
|
|
@@ -110,6 +115,7 @@ async def run_subprocess_exec(
|
|
|
110
115
|
output_limit_bytes: int,
|
|
111
116
|
sandbox: DirectiveSandbox | None = None,
|
|
112
117
|
) -> SubprocessResult:
|
|
118
|
+
refuse_if_prior_attempt_alive()
|
|
113
119
|
process = await asyncio.create_subprocess_exec(
|
|
114
120
|
*argv,
|
|
115
121
|
cwd=cwd,
|
|
@@ -121,14 +127,23 @@ async def run_subprocess_exec(
|
|
|
121
127
|
**({} if sandbox is None else sandbox.spawn_kwargs()),
|
|
122
128
|
)
|
|
123
129
|
try:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
with recorded_group(process.pid):
|
|
131
|
+
try:
|
|
132
|
+
(
|
|
133
|
+
stdout_bytes,
|
|
134
|
+
stderr_bytes,
|
|
135
|
+
stdout_truncated,
|
|
136
|
+
stderr_truncated,
|
|
137
|
+
) = await asyncio.wait_for(
|
|
138
|
+
_collect_process_output(
|
|
139
|
+
process, stdin=stdin, output_limit_bytes=output_limit_bytes
|
|
140
|
+
),
|
|
141
|
+
timeout=timeout_seconds,
|
|
142
|
+
)
|
|
143
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
144
|
+
await _terminate_process_tree(process)
|
|
145
|
+
raise
|
|
146
|
+
except PriorAttemptAliveError:
|
|
132
147
|
await _terminate_process_tree(process)
|
|
133
148
|
raise
|
|
134
149
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The Agent's attached Skills, delivered to its Agent Runtime for one Directive.
|
|
2
|
+
|
|
3
|
+
Console-v2 issue 28, ADR-0017 §1. Where each runtime reads a Skill was measured against the
|
|
4
|
+
Runner's own argv, not assumed (local-agents 02 asked the same question over ACP):
|
|
5
|
+
|
|
6
|
+
- **Codex** (codex-cli 0.159.2): ``$CODEX_HOME/skills/<slug>/SKILL.md`` is advertised to the
|
|
7
|
+
model under ``codex exec``, with and without ``--ignore-user-config``. The Runner writes
|
|
8
|
+
there, in the Contract's harness root, and removes it after the Directive.
|
|
9
|
+
- **Claude Code** (2.1.292): the Runner runs ``claude --print --bare``, and ``--bare`` drops
|
|
10
|
+
the Skill tool. ``$CLAUDE_CONFIG_DIR/skills/<slug>/SKILL.md`` is then neither advertised
|
|
11
|
+
nor resolved by ``/<slug>``, and ``--tools …,Skill`` does not bring the tool back. Claude
|
|
12
|
+
Code therefore gets the bodies as a prompt preamble. Dropping ``--bare`` would also
|
|
13
|
+
re-enable the Workspace's hooks and ``CLAUDE.md``, which is not this module's call.
|
|
14
|
+
|
|
15
|
+
The harness root is per Contract, not per Directive, so two concurrent Directives of one
|
|
16
|
+
Contract share its ``skills`` directory.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
from agentic_runner_contracts.runtime_context import SkillVersionSpec
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SKILLS_DIR",
|
|
34
|
+
"SkillDelivery",
|
|
35
|
+
"SkillDeliveryError",
|
|
36
|
+
"delivery_for",
|
|
37
|
+
"first_digest_mismatch",
|
|
38
|
+
"remove_skills",
|
|
39
|
+
"skills_preamble",
|
|
40
|
+
"write_skills",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
SKILLS_DIR: Final[str] = "skills"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SkillDelivery(StrEnum):
|
|
47
|
+
DIRECTORY = "directory"
|
|
48
|
+
PROMPT_PREAMBLE = "prompt_preamble"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DELIVERY_BY_CLI_KIND: Final[dict[str, SkillDelivery]] = {
|
|
52
|
+
"codex_cli": SkillDelivery.DIRECTORY,
|
|
53
|
+
"claude_code": SkillDelivery.PROMPT_PREAMBLE,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SkillDeliveryError(RuntimeError):
|
|
58
|
+
"""Raised when the Skills could not be written into, or removed from, the harness root."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def delivery_for(cli_kind: str) -> SkillDelivery:
|
|
62
|
+
return _DELIVERY_BY_CLI_KIND.get(cli_kind, SkillDelivery.PROMPT_PREAMBLE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def first_digest_mismatch(skills: Sequence[SkillVersionSpec]) -> SkillVersionSpec | None:
|
|
66
|
+
for skill in skills:
|
|
67
|
+
if hashlib.sha256(skill.body.encode("utf-8")).hexdigest() != skill.sha256:
|
|
68
|
+
return skill
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def skills_preamble(skills: Sequence[SkillVersionSpec]) -> str:
|
|
73
|
+
"""The bodies, for a runtime with no skills directory. Empty Skills, empty preamble,
|
|
74
|
+
so a Directive without Skills keeps a byte-identical prompt."""
|
|
75
|
+
|
|
76
|
+
if not skills:
|
|
77
|
+
return ""
|
|
78
|
+
sections = "".join(
|
|
79
|
+
f"Skill {skill.slug} (version {skill.version}):\n{skill.body.strip()}\n\n"
|
|
80
|
+
for skill in skills
|
|
81
|
+
)
|
|
82
|
+
return (
|
|
83
|
+
"The Skills below are attached to you. Follow each one where it applies.\n\n"
|
|
84
|
+
f"{sections}"
|
|
85
|
+
"The pipeline rules take precedence over the Skills above: edit files only, do not "
|
|
86
|
+
"run git commit or git push, and do not open a pull request — the pipeline commits, "
|
|
87
|
+
"pushes, and manages the PR.\n\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Both run as the Contract's uid, never the Runner's. The Contract owns its harness root, so
|
|
92
|
+
# an Agent could have left a symlink there; a write or an rmtree by the Runner -- with
|
|
93
|
+
# CAP_CHOWN and CAP_DAC_OVERRIDE -- would follow it out of the tree. As the Contract's uid
|
|
94
|
+
# it can only reach what the Contract already could.
|
|
95
|
+
_WRITE_SCRIPT: Final[str] = """
|
|
96
|
+
import json, os, sys
|
|
97
|
+
root, skills = json.load(sys.stdin)
|
|
98
|
+
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
99
|
+
for slug, body in skills:
|
|
100
|
+
directory = os.path.join(root, slug)
|
|
101
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
102
|
+
path = os.path.join(directory, "SKILL.md")
|
|
103
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
|
|
104
|
+
with os.fdopen(os.open(path, flags, 0o600), "w", encoding="utf-8") as handle:
|
|
105
|
+
handle.write(body)
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
_REMOVE_SCRIPT: Final[str] = """
|
|
109
|
+
import json, os, shutil, sys
|
|
110
|
+
root, slugs = json.load(sys.stdin)
|
|
111
|
+
for slug in slugs:
|
|
112
|
+
shutil.rmtree(os.path.join(root, slug), ignore_errors=True)
|
|
113
|
+
left = [slug for slug in slugs if os.path.lexists(os.path.join(root, slug))]
|
|
114
|
+
if left:
|
|
115
|
+
sys.exit("not removed: " + ", ".join(left))
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _skills_root(harness_config_dir: Path) -> str:
|
|
120
|
+
return str(harness_config_dir / SKILLS_DIR)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def write_skills(
|
|
124
|
+
harness_config_dir: Path, skills: Sequence[SkillVersionSpec], *, uid: int | None
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Write each Skill as ``<harness>/skills/<slug>/SKILL.md``, as the Contract's uid."""
|
|
127
|
+
|
|
128
|
+
await _run_as(
|
|
129
|
+
uid,
|
|
130
|
+
_WRITE_SCRIPT,
|
|
131
|
+
[_skills_root(harness_config_dir), [[skill.slug, skill.body] for skill in skills]],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def remove_skills(
|
|
136
|
+
harness_config_dir: Path, skills: Sequence[SkillVersionSpec], *, uid: int | None
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Remove what :func:`write_skills` wrote. The ``skills`` directory itself stays:
|
|
139
|
+
Codex keeps its own ``.system`` Skills there."""
|
|
140
|
+
|
|
141
|
+
await _run_as(
|
|
142
|
+
uid,
|
|
143
|
+
_REMOVE_SCRIPT,
|
|
144
|
+
[_skills_root(harness_config_dir), [skill.slug for skill in skills]],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _run_as(uid: int | None, script: str, payload: object) -> None:
|
|
149
|
+
identity: dict[str, object] = (
|
|
150
|
+
{} if uid is None else {"user": uid, "group": uid, "extra_groups": []}
|
|
151
|
+
)
|
|
152
|
+
process = await asyncio.create_subprocess_exec(
|
|
153
|
+
sys.executable,
|
|
154
|
+
"-I",
|
|
155
|
+
"-c",
|
|
156
|
+
script,
|
|
157
|
+
stdin=asyncio.subprocess.PIPE,
|
|
158
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
159
|
+
stderr=asyncio.subprocess.PIPE,
|
|
160
|
+
**identity, # type: ignore[arg-type]
|
|
161
|
+
)
|
|
162
|
+
_, stderr = await process.communicate(json.dumps(payload).encode("utf-8"))
|
|
163
|
+
if process.returncode != 0:
|
|
164
|
+
raise SkillDeliveryError(stderr.decode("utf-8", errors="replace").strip()[-500:])
|
|
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-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|