agentic-runner 3.17.0__tar.gz → 3.18.1__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 (77) hide show
  1. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/PKG-INFO +2 -2
  2. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/pyproject.toml +2 -2
  3. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/cli.py +45 -10
  5. agentic_runner-3.18.1/src/agentic_runner/launch.py +38 -0
  6. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/service.py +109 -20
  7. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workstation.py +48 -1
  8. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/.gitignore +0 -0
  9. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/LICENSE +0 -0
  10. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/README.md +0 -0
  11. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/activities.py +0 -0
  12. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/attempts.py +0 -0
  13. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/auth_mode.py +0 -0
  14. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/build.py +0 -0
  15. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/callback.py +0 -0
  16. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/child_watcher.py +0 -0
  17. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/cli_floor.py +0 -0
  18. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/config.py +0 -0
  19. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/credentials.py +0 -0
  20. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/device_login_activities.py +0 -0
  21. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/egress.py +0 -0
  22. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/harness_self_test.py +0 -0
  23. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/heartbeat_link.py +0 -0
  24. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/hooks.py +0 -0
  25. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/host_store.py +0 -0
  26. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/__init__.py +0 -0
  27. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  28. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  29. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  30. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  31. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  32. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  33. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  34. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  35. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  36. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  37. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  38. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/oauth_connectors.py +0 -0
  43. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/private_state.py +0 -0
  44. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/py.typed +0 -0
  45. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  46. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/registration.py +0 -0
  47. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/runtime/__init__.py +0 -0
  48. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  49. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/sealed_box.py +0 -0
  50. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/skill.py +0 -0
  51. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  52. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/__init__.py +0 -0
  53. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/__main__.py +0 -0
  54. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/control_plane.py +0 -0
  55. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/plugin.py +0 -0
  56. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/test_conformance.py +0 -0
  57. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/testing/workflow.py +0 -0
  58. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/tiny_http.py +0 -0
  59. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/triage_activities.py +0 -0
  60. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/usage_windows.py +0 -0
  61. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/user_sources.py +0 -0
  62. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/__init__.py +0 -0
  63. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  64. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/acp_runtime.py +0 -0
  65. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  66. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  67. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  68. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  69. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/command_policy.py +0 -0
  70. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
  71. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  72. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  73. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
  74. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  75. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  76. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/settings.py +0 -0
  77. {agentic_runner-3.17.0 → agentic_runner-3.18.1}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.17.0
3
+ Version: 3.18.1
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
7
  Requires-Python: >=3.12
8
- Requires-Dist: agentic-runner-contracts==3.17.0
8
+ Requires-Dist: agentic-runner-contracts==3.18.1
9
9
  Requires-Dist: cryptography<51,>=50
10
10
  Requires-Dist: httpx<1.0,>=0.27
11
11
  Requires-Dist: pydantic-settings<3.0,>=2.0
@@ -10,7 +10,7 @@ dependencies = [
10
10
  # Exactly this release's contracts: the two ship as one version, and an unpinned
11
11
  # requirement let `uv tool upgrade` keep an older contracts wheel the Runner cannot
12
12
  # import from (QTS-1473). `scripts/set-version.py` moves it with the version.
13
- "agentic-runner-contracts==3.17.0",
13
+ "agentic-runner-contracts==3.18.1",
14
14
  # Imported directly by `registration.py` (the Ed25519 identity that signs every
15
15
  # heartbeat); already present transitively through pyjwt[crypto], declared because
16
16
  # we import it.
@@ -31,7 +31,7 @@ testing = [
31
31
  ]
32
32
 
33
33
  [project.scripts]
34
- agentic-runner = "agentic_runner.cli:main"
34
+ agentic-runner = "agentic_runner.launch:main"
35
35
 
36
36
  [project.entry-points.pytest11]
37
37
  agentic_runner = "agentic_runner.testing.plugin"
@@ -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.17.0"
10
+ __version__ = "3.18.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -13,7 +13,8 @@ the Organisation's cap.
13
13
 
14
14
  ``install | start | stop | status <org>`` and ``credential set`` are the workstation
15
15
  Runner (PRD issue 47, ``workstation.py``): one login agent per Organisation, managed by
16
- the user who hosts it, with no ``sudo`` anywhere.
16
+ the user who hosts it, with no ``sudo`` anywhere. ``add <name>`` registers another Runner
17
+ beside them, reusing their control plane and ``PATH`` (ADR-0022 §21).
17
18
 
18
19
  ``sign-in --contract <id>`` signs one Contract's Claude Code in with Anthropic's own flow,
19
20
  in this terminal, as that Contract's uid in its harness root (QTS-1448). No code or token
@@ -179,6 +180,21 @@ def main(argv: Sequence[str] | None = None) -> int:
179
180
  )
180
181
  install.add_argument("--no-start", action="store_true", help="write the agent, do not start")
181
182
  install.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
183
+
184
+ add = subcommands.add_parser(
185
+ "add",
186
+ help="register another Runner under this install, with its own process, identity "
187
+ "and Recipient Key (one per Contract)",
188
+ )
189
+ add.add_argument("org", metavar="name", help="the new Runner's name on this machine")
190
+ add.add_argument(
191
+ "--beside",
192
+ default=None,
193
+ help="the installed Runner whose control plane and PATH to reuse; needed only when "
194
+ "the installed Runners talk to different control planes",
195
+ )
196
+ add.add_argument("--no-start", action="store_true", help="write the agent, do not start")
197
+ add.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
182
198
  for service_verb in ("start", "stop"):
183
199
  driven = subcommands.add_parser(
184
200
  service_verb, help=f"{service_verb} one Organisation's login agent"
@@ -312,7 +328,7 @@ def main(argv: Sequence[str] | None = None) -> int:
312
328
  if arguments.org:
313
329
  return workstation.run_process(_org_paths(arguments), login_agent=arguments.login_agent)
314
330
  return asyncio.run(service.run(attestation=_image_attestation()))
315
- if arguments.command in {"install", "start", "stop", "credential"}:
331
+ if arguments.command in {"install", "add", "start", "stop", "credential"}:
316
332
  return _workstation(arguments)
317
333
  if arguments.command == "recipient-key":
318
334
  api, kube_ns = in_cluster()
@@ -359,6 +375,8 @@ def _workstation(arguments: argparse.Namespace) -> int:
359
375
  try:
360
376
  if arguments.command == "install":
361
377
  return _install(arguments, paths)
378
+ if arguments.command == "add":
379
+ return _add(arguments, paths)
362
380
  if arguments.command == "start":
363
381
  workstation.start(paths)
364
382
  print(f"started {paths.org}")
@@ -422,11 +440,7 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
422
440
  except ValueError as error:
423
441
  print(str(error), file=sys.stderr)
424
442
  return 2
425
- # Pasted, never a flag: a flag is in the process table for every uid on the box.
426
- token = registration_token_from_env() or getpass.getpass(
427
- "Runner Registration Token (yours from /me/runners, or the Organisation Admin's for "
428
- "an Organisation Runner): "
429
- )
443
+ token = _registration_token()
430
444
  settings = workstation.WorkstationSettings(
431
445
  org=paths.org,
432
446
  control_plane_url=arguments.control_plane,
@@ -436,11 +450,19 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
436
450
  install_channel=workstation.install_channel(),
437
451
  acp_cli_kinds=acp_cli_kinds,
438
452
  )
453
+ return _register(paths, settings, token, start=not arguments.no_start)
454
+
455
+
456
+ def _register(
457
+ paths: workstation.OrgPaths,
458
+ settings: workstation.WorkstationSettings,
459
+ token: str,
460
+ *,
461
+ start: bool,
462
+ ) -> int:
439
463
  try:
440
464
  lines = asyncio.run(
441
- workstation.install(
442
- paths, settings, registration_token=token, start_service=not arguments.no_start
443
- )
465
+ workstation.install(paths, settings, registration_token=token, start_service=start)
444
466
  )
445
467
  except (workstation.CliMissingError, RunnerRegistrationError, RuntimeError) as error:
446
468
  print(f"install refused: {error}", file=sys.stderr)
@@ -450,6 +472,19 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
450
472
  return 0
451
473
 
452
474
 
475
+ def _add(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
476
+ settings = workstation.settings_to_add(paths, beside=arguments.beside)
477
+ return _register(paths, settings, _registration_token(), start=not arguments.no_start)
478
+
479
+
480
+ def _registration_token() -> str:
481
+ # Pasted, never a flag: a flag is in the process table for every uid on the box.
482
+ return registration_token_from_env() or getpass.getpass(
483
+ "Runner Registration Token (yours from /me/runners, or the Organisation Admin's for "
484
+ "an Organisation Runner): "
485
+ )
486
+
487
+
453
488
  def _acp_cli_kinds(flag: str | None, paths: workstation.OrgPaths) -> str:
454
489
  # A reinstall to pick up a new PATH or retry a token must not turn ACP off (QTS-1355).
455
490
  if flag is not None:
@@ -0,0 +1,38 @@
1
+ """The ``agentic-runner`` console script: a SIGTERM disposition before the CLI imports.
2
+
3
+ Importing the CLI takes seconds (Temporal, httpx, pydantic). In a container the Runner is
4
+ PID 1, and the kernel drops a signal PID 1 has no handler for, so a SIGTERM in that window
5
+ was lost and ``docker stop`` waited out its grace period to SIGKILL (QTS-1536). Nothing
6
+ has run yet, so it exits 0 -- what launchd's ``KeepAlive: SuccessfulExit: false`` reads
7
+ as a clean stop. ``agentic-runner run`` replaces this with its own handler before its
8
+ first await; every other subcommand gets the default disposition back, so a killed
9
+ callback such as ``ask`` never reads as one that succeeded.
10
+
11
+ Imports nothing but the standard library: anything heavier reopens the window.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import signal
18
+ import sys
19
+ from types import FrameType
20
+
21
+
22
+ def main() -> int:
23
+ signal.signal(signal.SIGTERM, _exit_before_start)
24
+ from agentic_runner.cli import main as cli_main
25
+
26
+ if sys.argv[1:2] != ["run"]:
27
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
28
+ return cli_main()
29
+
30
+
31
+ def _exit_before_start(signum: int, frame: FrameType | None) -> None:
32
+ # Not SystemExit: raised mid-import, it tears down half-initialised modules and the
33
+ # interpreter prints their errors. Nothing has written state yet to flush or close.
34
+ os._exit(0)
35
+
36
+
37
+ if __name__ == "__main__": # pragma: no cover - `runner_command`'s fallback without the script
38
+ raise SystemExit(main())
@@ -27,7 +27,7 @@ import signal
27
27
  import sys
28
28
  import tempfile
29
29
  import time
30
- from collections.abc import Awaitable, Callable, Mapping, Sequence
30
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
31
31
  from dataclasses import dataclass, field
32
32
  from datetime import UTC, datetime, timedelta
33
33
  from pathlib import Path
@@ -558,12 +558,14 @@ async def run_worker_until_terminated(
558
558
  Directive is mid-run. The chart's ``preStop`` hook (issue 02) signals termination as
559
559
  early as possible; this handler is what turns that into ``Worker.shutdown()`` -- stop
560
560
  polling for new work, wait for the in-flight activity, then let ``run()`` return.
561
+
562
+ A caller that passes ``stop`` owns SIGTERM already (``run`` installs it before it
563
+ starts up, QTS-1536); one set before the Worker polls never starts it.
561
564
  """
562
565
 
563
- loop = asyncio.get_running_loop()
564
- stop_event = stop or asyncio.Event()
565
- loop.add_signal_handler(signal.SIGTERM, stop_event.set)
566
- try:
566
+ if stop is not None and stop.is_set():
567
+ return
568
+ async with _sigterm_sets(stop) as stop_event:
567
569
  run_task = asyncio.ensure_future(worker.run())
568
570
  stop_task = asyncio.ensure_future(stop_event.wait())
569
571
  await asyncio.wait({run_task, stop_task}, return_when=asyncio.FIRST_COMPLETED)
@@ -573,10 +575,51 @@ async def run_worker_until_terminated(
573
575
  _logger.info("Worker received SIGTERM: draining in-flight activity before exit")
574
576
  await worker.shutdown()
575
577
  await run_task
578
+
579
+
580
+ @contextlib.asynccontextmanager
581
+ async def _sigterm_sets(stop: asyncio.Event | None) -> AsyncIterator[asyncio.Event]:
582
+ """``stop`` as given, or a new event SIGTERM sets for the length of the block."""
583
+
584
+ if stop is not None:
585
+ yield stop
586
+ return
587
+ loop = asyncio.get_running_loop()
588
+ stop_event = asyncio.Event()
589
+ loop.add_signal_handler(signal.SIGTERM, stop_event.set)
590
+ try:
591
+ yield stop_event
576
592
  finally:
577
593
  loop.remove_signal_handler(signal.SIGTERM)
578
594
 
579
595
 
596
+ class _StoppedBeforeStartError(Exception):
597
+ """SIGTERM (or ``stop``) arrived before the Worker polled: no Directive is in flight."""
598
+
599
+
600
+ async def _before_stop[T](awaitable: Awaitable[T], stop: asyncio.Event) -> T:
601
+ """``awaitable``'s result, unless ``stop`` is set first; then it is cancelled.
602
+
603
+ Start-up waits on the network -- registration, the first heartbeat, the Temporal
604
+ connection -- for as long as the far end takes to answer. A stop during that wait
605
+ must not wait with it.
606
+ """
607
+
608
+ work = asyncio.ensure_future(awaitable)
609
+ stopped = asyncio.ensure_future(stop.wait())
610
+ try:
611
+ await asyncio.wait({work, stopped}, return_when=asyncio.FIRST_COMPLETED)
612
+ finally:
613
+ stopped.cancel()
614
+ if not work.done():
615
+ work.cancel()
616
+ with contextlib.suppress(asyncio.CancelledError):
617
+ await work
618
+ if work.cancelled():
619
+ raise _StoppedBeforeStartError
620
+ return work.result()
621
+
622
+
580
623
  # ------------------------------------------------------------------ the stream
581
624
 
582
625
 
@@ -971,6 +1014,40 @@ async def run(
971
1014
  it says about itself. ``heartbeat_interval`` defaults to the cadence bootstrap gave.
972
1015
  """
973
1016
 
1017
+ loop = asyncio.get_running_loop()
1018
+ # Before the first await: in a container this process is PID 1, and the kernel drops
1019
+ # a signal PID 1 has no handler for, so a SIGTERM during start-up was lost and
1020
+ # `docker stop` waited out its grace period to SIGKILL (QTS-1536).
1021
+ stop = stop or asyncio.Event()
1022
+ loop.add_signal_handler(signal.SIGTERM, stop.set)
1023
+ try:
1024
+ return await _run(
1025
+ stop=stop,
1026
+ http_client=http_client,
1027
+ connect=connect,
1028
+ worker_factory=worker_factory,
1029
+ can_change_uid=can_change_uid,
1030
+ heartbeat_interval=heartbeat_interval,
1031
+ readiness=readiness,
1032
+ host_store=host_store,
1033
+ attestation=attestation,
1034
+ )
1035
+ finally:
1036
+ loop.remove_signal_handler(signal.SIGTERM)
1037
+
1038
+
1039
+ async def _run(
1040
+ *,
1041
+ stop: asyncio.Event,
1042
+ http_client: httpx.AsyncClient | None,
1043
+ connect: Connector,
1044
+ worker_factory: WorkerFactory,
1045
+ can_change_uid: bool | None,
1046
+ heartbeat_interval: float | None,
1047
+ readiness: Readiness | None,
1048
+ host_store: HostCredentialStore | None,
1049
+ attestation: HostAttestation | None,
1050
+ ) -> int:
974
1051
  install_stop_tolerant_child_watcher(asyncio.get_running_loop())
975
1052
  config = load_config()
976
1053
  mode = isolation_mode()
@@ -1094,15 +1171,19 @@ async def _serve(
1094
1171
  http_client: httpx.AsyncClient | None,
1095
1172
  connect: Connector,
1096
1173
  worker_factory: WorkerFactory,
1097
- stop: asyncio.Event | None,
1174
+ stop: asyncio.Event,
1098
1175
  heartbeat_interval: float | None,
1099
1176
  host_store: HostCredentialStore | None,
1100
1177
  attestation: HostAttestation | None,
1101
1178
  ) -> int:
1102
1179
  try:
1103
- state, outcome = await register(
1104
- state_dir=state_dir, can_separate_uids=can_change_uid, client=http_client
1180
+ state, outcome = await _before_stop(
1181
+ register(state_dir=state_dir, can_separate_uids=can_change_uid, client=http_client),
1182
+ stop,
1105
1183
  )
1184
+ except _StoppedBeforeStartError:
1185
+ print("stopped before registration completed", flush=True)
1186
+ return 0
1106
1187
  except RunnerRegistrationError as error:
1107
1188
  print(f"registration refused ({error.reason}): {error}", flush=True)
1108
1189
  if error.refuses_registration_token:
@@ -1148,6 +1229,12 @@ async def _serve(
1148
1229
  attestation=attestation,
1149
1230
  lifecycle=lifecycle,
1150
1231
  )
1232
+ except _StoppedBeforeStartError:
1233
+ # No beat with a 10 s timeout on the way out (`_report_stop`): the control plane
1234
+ # may be what start-up was waiting for. The next start carries the stop.
1235
+ lifecycle.add(LifecycleKind.STOP, datetime.now(UTC))
1236
+ print("stopped before the Worker started", flush=True)
1237
+ return 0
1151
1238
  finally:
1152
1239
  pidfile.unlink(missing_ok=True)
1153
1240
 
@@ -1166,7 +1253,7 @@ async def _serve_registered(
1166
1253
  http_client: httpx.AsyncClient | None,
1167
1254
  connect: Connector,
1168
1255
  worker_factory: WorkerFactory,
1169
- stop: asyncio.Event | None,
1256
+ stop: asyncio.Event,
1170
1257
  interval: float,
1171
1258
  host_store: HostCredentialStore | None,
1172
1259
  attestation: HostAttestation | None,
@@ -1240,21 +1327,24 @@ async def _serve_registered(
1240
1327
  # none, and the Temporal connection below cannot be opened without it.
1241
1328
  while True:
1242
1329
  try:
1243
- await link.exchange()
1330
+ await _before_stop(link.exchange(), stop)
1244
1331
  break
1332
+ except _StoppedBeforeStartError:
1333
+ raise
1245
1334
  except Exception as error: # noqa: BLE001 - keep trying until the control plane answers
1246
1335
  if _revoked(error):
1247
1336
  return 1
1248
1337
  _logger.warning("heartbeat failed before start: %s", error)
1249
- if stop is not None and stop.is_set():
1250
- return 1
1251
- await asyncio.sleep(interval)
1252
-
1253
- client = await connect(
1254
- temporal_address,
1255
- namespace=state.temporal_namespace,
1256
- api_key=stream.runner_token,
1257
- tls=os.environ.get(TEMPORAL_TLS_ENV, "").strip().lower() in {"1", "true", "yes"},
1338
+ await _before_stop(asyncio.sleep(interval), stop)
1339
+
1340
+ client = await _before_stop(
1341
+ connect(
1342
+ temporal_address,
1343
+ namespace=state.temporal_namespace,
1344
+ api_key=stream.runner_token,
1345
+ tls=os.environ.get(TEMPORAL_TLS_ENV, "").strip().lower() in {"1", "true", "yes"},
1346
+ ),
1347
+ stop,
1258
1348
  )
1259
1349
  hooks = build_hook_runner(config)
1260
1350
  async with (
@@ -1289,7 +1379,6 @@ async def _serve_registered(
1289
1379
  graceful_shutdown_timeout=graceful_shutdown_timeout(settings),
1290
1380
  max_concurrent_activities=state.max_concurrent_directives,
1291
1381
  )
1292
- stop = stop or asyncio.Event()
1293
1382
  revoked = asyncio.Event()
1294
1383
  beat = asyncio.create_task(
1295
1384
  _heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
@@ -7,6 +7,8 @@ precedent does on a developer machine and nothing a daemon would need:
7
7
  identity, namespace, Runner Token, hooks, credential store and Workspaces under
8
8
  ``<root>/<org>/``. A contractor with two clients runs two. There is no supervisor over
9
9
  N Organisations -- that is the "one Runner straddling Organisations" 12 A6 rejected.
10
+ ``add <name>`` puts another such process beside them, a personal Runner per Contract
11
+ (ADR-0022 §21), named freely rather than after its Organisation.
10
12
  * **A login agent, never a system daemon** (23 item 1): a LaunchAgent in
11
13
  ``~/Library/LaunchAgents`` on macOS, a systemd *user* unit (with linger) on Linux, a
12
14
  per-user logon task on Windows. No ``sudo`` anywhere: the login keychain is unreachable
@@ -81,6 +83,7 @@ __all__ = [
81
83
  "run_environment",
82
84
  "run_process",
83
85
  "service_definition",
86
+ "settings_to_add",
84
87
  "start",
85
88
  "status_lines",
86
89
  "stop",
@@ -387,7 +390,7 @@ def runner_command(org: str, root: Path) -> list[str]:
387
390
  # The console script as found, not resolved: Homebrew's `bin/agentic-runner` is a
388
391
  # symlink into a versioned Cellar path that the next `brew upgrade` deletes.
389
392
  script = shutil.which("agentic-runner")
390
- prefix = [script] if script else [sys.executable, "-m", "agentic_runner.cli"]
393
+ prefix = [script] if script else [sys.executable, "-m", "agentic_runner.launch"]
391
394
  return [*prefix, "run", "--org", org, "--root", str(root), "--login-agent"]
392
395
 
393
396
 
@@ -673,6 +676,50 @@ async def install(
673
676
  return lines
674
677
 
675
678
 
679
+ def settings_to_add(paths: OrgPaths, *, beside: str | None) -> WorkstationSettings:
680
+ """``add <name>``: what ``install`` registers another Runner with (ADR-0022 §21).
681
+
682
+ A personal Runner serves one Contract, so a person with three Contracts runs three. Each
683
+ is ``install``'s own process with its own state directory -- identity, Runner Token and
684
+ Recipient Key (§37) -- and only the settings an earlier ``install`` was told are copied,
685
+ so the command the console shows carries no Temporal address.
686
+ """
687
+
688
+ # Registered state, not workstation.json: ``install`` saves the settings before it
689
+ # registers, so a refused token leaves them behind and the retry must go through.
690
+ if load_state(paths.state_dir) is not None:
691
+ raise ValueError(
692
+ f"{paths.org!r} is already installed under {paths.root}; pick a new name for "
693
+ "the Runner you are adding"
694
+ )
695
+ return _settings_beside(paths.root, beside).model_copy(update={"org": paths.org})
696
+
697
+
698
+ def _settings_beside(root: Path, beside: str | None) -> WorkstationSettings:
699
+ if beside is not None:
700
+ return WorkstationSettings.load(OrgPaths(root=root, org=validate_org(beside)))
701
+ installed = [
702
+ WorkstationSettings.load(OrgPaths(root=root, org=file.parent.name))
703
+ for file in sorted(root.glob("*/workstation.json"), key=lambda file: file.stat().st_mtime)
704
+ ]
705
+ if not installed:
706
+ raise FileNotFoundError(
707
+ f"no Runner is installed under {root}; run `agentic-runner install` first"
708
+ )
709
+ endpoints = {
710
+ (settings.control_plane_url, settings.temporal_address, settings.temporal_tls)
711
+ for settings in installed
712
+ }
713
+ if len(endpoints) > 1:
714
+ names = ", ".join(sorted(settings.org for settings in installed))
715
+ raise ValueError(
716
+ f"the Runners under {root} ({names}) talk to different control planes; "
717
+ "name the one to add beside with --beside"
718
+ )
719
+ # The newest install's PATH and ACP kinds: the latest the person told this machine.
720
+ return installed[-1]
721
+
722
+
676
723
  def start(paths: OrgPaths, **driver: Any) -> None:
677
724
  _drive_service(paths, "start", **driver)
678
725
 
File without changes