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.
Files changed (56) hide show
  1. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/PKG-INFO +1 -1
  2. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/activities.py +171 -26
  4. agentic_runner-2.7.0/src/agentic_runner/attempts.py +258 -0
  5. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/runtime/verifier_command.py +35 -5
  6. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/service.py +6 -0
  7. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/_runtime_support.py +23 -8
  8. agentic_runner-2.7.0/src/agentic_runner/workers/skills.py +164 -0
  9. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/.gitignore +0 -0
  10. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/LICENSE +0 -0
  11. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/README.md +0 -0
  12. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/pyproject.toml +0 -0
  13. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/callback.py +0 -0
  14. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/child_watcher.py +0 -0
  15. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/cli.py +0 -0
  16. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/config.py +0 -0
  17. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/credentials.py +0 -0
  18. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/device_login_activities.py +0 -0
  19. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/egress.py +0 -0
  20. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/heartbeat_link.py +0 -0
  21. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/hooks.py +0 -0
  22. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/host_store.py +0 -0
  23. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/__init__.py +0 -0
  24. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  25. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  26. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  27. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  28. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  29. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  30. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  31. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  32. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  33. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/lifecycle.py +0 -0
  34. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/llm_proxy.py +0 -0
  35. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/mcp.py +0 -0
  36. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/message_store.py +0 -0
  37. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/py.typed +0 -0
  38. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  39. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/registration.py +0 -0
  40. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/runtime/__init__.py +0 -0
  41. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/sealed_box.py +0 -0
  42. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/tiny_http.py +0 -0
  43. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/triage_activities.py +0 -0
  44. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/user_sources.py +0 -0
  45. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/__init__.py +0 -0
  46. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  47. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  48. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  49. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/command_policy.py +0 -0
  50. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  51. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  52. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  53. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  54. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  55. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workers/settings.py +0 -0
  56. {agentic_runner-2.6.0 → agentic_runner-2.7.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 2.6.0
3
+ Version: 2.7.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__ = "2.6.0"
10
+ __version__ = "2.7.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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
- return _with_question(await self._run_branch_pr_directive(request, asked), asked)
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=_directive_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: subprocess.run inline would block the worker
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
- return await self._run_fix_directive(request)
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
- ran = await self._run_in_place_directive(
4075
- request,
4076
- _InPlaceDirective(
4077
- lifecycle_source="ralph.execute_member_directive",
4078
- run_source="ralph.member_directive.codex_run",
4079
- push_source="ralph.member_directive.git_commit_push",
4080
- commit_message=(
4081
- f"feat(task): directive {request.directive_number} by Agent "
4082
- f"{request.agent_id} for work record {request.work_record_id}"
4083
- ),
4084
- push_summary=(
4085
- f"Push Directive {request.directive_number} ({request.role or 'member'}"
4086
- f", woken by {request.wake_reason or 'pending messages'}) to "
4087
- f"{request.repository}."
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
- prompt=prompt,
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
- return await self._run_learning_directive(request)
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=_learning_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)
@@ -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
- return subprocess.run(
107
+ refuse_if_prior_attempt_alive()
108
+ with subprocess.Popen(
97
109
  list(argv),
98
110
  cwd=cwd,
99
111
  env=env,
100
- timeout=timeout_seconds,
101
- check=False,
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(),
@@ -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
- stdout_bytes, stderr_bytes, stdout_truncated, stderr_truncated = await asyncio.wait_for(
125
- _collect_process_output(process, stdin=stdin, output_limit_bytes=output_limit_bytes),
126
- timeout=timeout_seconds,
127
- )
128
- except TimeoutError:
129
- await _terminate_process_tree(process)
130
- raise
131
- except asyncio.CancelledError:
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