manifold-cli 0.0.39__py3-none-any.whl

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 (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,749 @@
1
+ """The `manifold policy` command group."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import functools
6
+ import os
7
+ import signal
8
+ import socket
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+ from uuid import uuid4
13
+
14
+ import typer
15
+ from pydantic import ValidationError
16
+ from rich.console import Console
17
+ from rich.markup import escape
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ from manifold_cli.bridge import run_bridge
22
+ from manifold_cli.clients.manifold import ManifoldApiClient
23
+ from manifold_cli.commands.organization import (
24
+ ORGANIZATION_CALL_ERRORS,
25
+ exit_for_call_error,
26
+ resolve_owner,
27
+ )
28
+ from manifold_cli.commands.table import catalog_table, print_table, terminal_width
29
+ from manifold_cli.dependencies import Dependencies, make_in_memory_runner_client
30
+ from manifold_cli.models.catalog import (
31
+ CatalogRegisterError,
32
+ InvalidImageError,
33
+ InvalidReferenceError,
34
+ Visibility,
35
+ join_versioned_ref,
36
+ split_ref,
37
+ split_versioned_ref,
38
+ )
39
+ from manifold_cli.models.errors import RunnerCredentialRejectedError
40
+ from manifold_cli.models.policy import PolicyNotFoundError
41
+ from manifold_cli.models.run import AssignmentDispatch
42
+ from manifold_cli.models.runner import (
43
+ MintedRunner,
44
+ RunnerNameTakenError,
45
+ RunnerSource,
46
+ )
47
+ from manifold_cli.models.runner_home import BoxFacts, PolicyTransport
48
+ from manifold_cli.runner.current_dispatch import CurrentDispatch
49
+ from manifold_cli.runner.daemon import (
50
+ BRIDGE_POLL_SECONDS,
51
+ POLL_TIMEOUT_SECONDS,
52
+ run_daemon,
53
+ )
54
+ from manifold_cli.runner.gpu import gpu_facts
55
+ from manifold_cli.runner.policy import run_served_policy_side
56
+ from manifold_cli.runner.process import AsyncioSpawner
57
+ from manifold_cli.runner.resource_usage import reporting_resource_usage
58
+ from manifold_cli.runner.served import ServedPolicy
59
+ from manifold_cli.runner.signature import read_signature
60
+ from manifold_cli.runner.source import serve_source
61
+ from manifold_cli.runner.stages import (
62
+ callout,
63
+ failed,
64
+ finished,
65
+ started,
66
+ stopped,
67
+ )
68
+ from manifold_cli.transport import DEFAULT_TRANSPORTS, TcpConnector, TransportError
69
+ from manifold_cli.transport.diagnostics import serve_echo
70
+ from manifold_cli.transport.tcp import split_host_port
71
+
72
+ DEFAULT_SERVE_TARGET = "tcp:127.0.0.1:0"
73
+
74
+ # Length of the random suffix added when the hostname is already taken.
75
+ SERVE_NAME_SUFFIX_LENGTH = 6
76
+
77
+
78
+ def register(app: typer.Typer, deps: Dependencies) -> None:
79
+ console = Console()
80
+ policy_app = typer.Typer(help="Serve and manage policies.", no_args_is_help=True)
81
+
82
+ @policy_app.command("list")
83
+ def list_policies() -> None:
84
+ """List the policies you can see."""
85
+ try:
86
+ policies = deps.policy_service.list_policies()
87
+ except ORGANIZATION_CALL_ERRORS as exc:
88
+ raise exit_for_call_error(console, exc) from exc
89
+ if len(policies) == 0:
90
+ console.print("There are no policies yet.")
91
+ return
92
+ print_table(console, catalog_table(policies, terminal_width(console)))
93
+
94
+ @policy_app.command()
95
+ def versions(
96
+ policy: Annotated[
97
+ str,
98
+ typer.Argument(
99
+ help="Policy identifier, or <org>/<identifier> to specify its owner."
100
+ ),
101
+ ],
102
+ ) -> None:
103
+ """List the versions of a policy, newest first."""
104
+ try:
105
+ owner, slug = split_ref(policy)
106
+ except InvalidReferenceError as exc:
107
+ console.print(f"[red]{escape(str(exc))}[/red]")
108
+ raise typer.Exit(1) from exc
109
+ # A qualified reference names its owner outright. A bare slug resolves
110
+ # the way `run submit` resolves one: the caller's own policy first, then
111
+ # a public policy of that slug.
112
+ list_versions = (
113
+ deps.policy_service.list_versions
114
+ if owner is not None
115
+ else deps.policy_service.list_own_or_public_versions
116
+ )
117
+ if owner is None:
118
+ try:
119
+ owner = resolve_owner(console, deps.organization_service)
120
+ except ORGANIZATION_CALL_ERRORS as exc:
121
+ raise exit_for_call_error(console, exc) from exc
122
+ try:
123
+ found = list_versions(slug, owner)
124
+ except PolicyNotFoundError as exc:
125
+ console.print(f"[red]{escape(str(exc))}[/red]")
126
+ raise typer.Exit(1) from exc
127
+ except ORGANIZATION_CALL_ERRORS as exc:
128
+ raise exit_for_call_error(console, exc) from exc
129
+ if len(found) == 0:
130
+ console.print(f"'{escape(policy)}' has no versions yet.")
131
+ return
132
+ table = Table(box=None, pad_edge=False)
133
+ table.add_column("Version")
134
+ table.add_column("Adapter", style="dim")
135
+ for version in found:
136
+ table.add_row(Text(version.version, style="bold"), version.adapter.value)
137
+ print_table(console, table)
138
+
139
+ @policy_app.command()
140
+ def init(
141
+ policy: Annotated[
142
+ str,
143
+ typer.Argument(
144
+ help="Policy identifier to register the image under, "
145
+ "or <org>/<identifier> to specify the organization that owns it."
146
+ ),
147
+ ],
148
+ image: Annotated[
149
+ str,
150
+ typer.Option(
151
+ "--image",
152
+ help="Container image, e.g. ghcr.io/bifrost/smolvla:0.1.0. "
153
+ "Its tag is the version this registers.",
154
+ ),
155
+ ],
156
+ minimum_gpu_memory_gb: Annotated[
157
+ int,
158
+ typer.Option(
159
+ "--minimum-gpu-memory-gb",
160
+ min=0,
161
+ help="Minimum memory required on one GPU. Set to 0 to allow "
162
+ "placement on runners without a GPU.",
163
+ ),
164
+ ],
165
+ env: Annotated[
166
+ list[str] | None,
167
+ typer.Option(
168
+ "--env",
169
+ help="Environment variable as KEY=VALUE. Repeat for each variable.",
170
+ ),
171
+ ] = None,
172
+ minimum_driver_major: Annotated[
173
+ int | None,
174
+ typer.Option(
175
+ "--minimum-driver-major",
176
+ min=1,
177
+ help="Minimum NVIDIA driver major version required on a "
178
+ "runner. Omit to leave the lower bound open.",
179
+ ),
180
+ ] = None,
181
+ maximum_driver_major: Annotated[
182
+ int | None,
183
+ typer.Option(
184
+ "--maximum-driver-major",
185
+ min=1,
186
+ help="Maximum NVIDIA driver major version allowed on a "
187
+ "runner. Omit to leave the upper bound open.",
188
+ ),
189
+ ] = None,
190
+ minimum_gpu_count: Annotated[
191
+ int | None,
192
+ typer.Option(
193
+ "--minimum-gpu-count",
194
+ min=0,
195
+ help="Minimum number of GPUs required on one runner. If omitted, "
196
+ "the default is 1, or 0 when --minimum-gpu-memory-gb is 0.",
197
+ ),
198
+ ] = None,
199
+ display_name: Annotated[
200
+ str | None,
201
+ typer.Option(
202
+ "--display-name",
203
+ help="Name shown in `policy list`. Left off, an existing "
204
+ "policy keeps the name it has and a new one uses its identifier.",
205
+ ),
206
+ ] = None,
207
+ visibility: Annotated[
208
+ Visibility | None,
209
+ typer.Option(
210
+ "--visibility",
211
+ help="Who can read the policy. Left off, an existing policy "
212
+ "keeps the visibility it has and a new one is visible to its "
213
+ "organization.",
214
+ ),
215
+ ] = None,
216
+ ) -> None:
217
+ """Register a container image as a version of a policy."""
218
+ environment = _parse_environment(env)
219
+ try:
220
+ owner, slug = split_ref(policy)
221
+ except InvalidReferenceError as exc:
222
+ console.print(f"[red]{escape(str(exc))}[/red]")
223
+ raise typer.Exit(1) from exc
224
+ try:
225
+ if owner is None:
226
+ owner = resolve_owner(console, deps.organization_service)
227
+ version = deps.policy_service.register_container_version(
228
+ slug,
229
+ owner,
230
+ image=image,
231
+ environment=environment,
232
+ minimum_gpu_memory_gb=minimum_gpu_memory_gb,
233
+ minimum_driver_major=minimum_driver_major,
234
+ maximum_driver_major=maximum_driver_major,
235
+ minimum_gpu_count=minimum_gpu_count,
236
+ display_name=display_name,
237
+ visibility=visibility,
238
+ )
239
+ except (
240
+ InvalidImageError,
241
+ CatalogRegisterError,
242
+ PolicyNotFoundError,
243
+ ) as exc:
244
+ console.print(f"[red]{escape(str(exc))}[/red]")
245
+ raise typer.Exit(1) from exc
246
+ except ORGANIZATION_CALL_ERRORS as exc:
247
+ raise exit_for_call_error(console, exc) from exc
248
+ console.print(
249
+ f"[green]✓ Registered[/green] [bold]{escape(policy)}[/bold] "
250
+ f"[bold]{escape(version.version)}[/bold] "
251
+ f"[dim]{version.adapter.value}[/dim]"
252
+ )
253
+ console.print(f" {escape(image)}")
254
+ console.print(
255
+ f"Run it with: manifold run submit {escape(policy)} <benchmark> --name NAME"
256
+ )
257
+
258
+ @policy_app.command()
259
+ def delete(
260
+ policy: Annotated[
261
+ str,
262
+ typer.Argument(
263
+ help="Policy to delete, as [<org>/]<identifier>[:<version>], for "
264
+ "example my-policy:0.0.1. With a version, only that "
265
+ "version is deleted."
266
+ ),
267
+ ],
268
+ ) -> None:
269
+ """Delete a policy, or one version of it.
270
+
271
+ A deleted policy or version is left out of listings and cannot be used
272
+ in new runs. `run get` still shows it on the runs that used it.
273
+ Registering it again restores it. A restored policy takes the display
274
+ name and visibility of that registration, not its earlier ones.
275
+ """
276
+ try:
277
+ owner, slug, version = split_versioned_ref(policy)
278
+ except InvalidReferenceError as exc:
279
+ console.print(f"[red]{escape(str(exc))}[/red]")
280
+ raise typer.Exit(1) from exc
281
+ try:
282
+ if owner is None:
283
+ owner = resolve_owner(console, deps.organization_service)
284
+ deps.policy_service.delete(slug, owner, version)
285
+ except PolicyNotFoundError as exc:
286
+ console.print(f"[red]{escape(str(exc))}[/red]")
287
+ raise typer.Exit(1) from exc
288
+ except ORGANIZATION_CALL_ERRORS as exc:
289
+ raise exit_for_call_error(console, exc) from exc
290
+ deleted = "Policy deleted" if version is None else "Policy version deleted"
291
+ console.print(
292
+ f"[green]✓ {deleted}[/green] "
293
+ f"[bold]{escape(join_versioned_ref(owner, slug, version))}[/bold]"
294
+ )
295
+
296
+ @policy_app.command(
297
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
298
+ )
299
+ def serve(
300
+ context: typer.Context,
301
+ policy: Annotated[
302
+ str | None,
303
+ typer.Argument(
304
+ help="Policy to serve, as [<org>/]<identifier>:<version>, for "
305
+ "example my-policy:0.0.1."
306
+ ),
307
+ ] = None,
308
+ echo: Annotated[
309
+ bool,
310
+ typer.Option("--echo", help="Serve a built-in echo policy (echoes bytes)."),
311
+ ] = False,
312
+ target: Annotated[
313
+ str,
314
+ typer.Option(
315
+ "--target",
316
+ help="Address for --echo to listen on, e.g. tcp:0.0.0.0:9000 or iroh.",
317
+ ),
318
+ ] = DEFAULT_SERVE_TARGET,
319
+ ) -> None:
320
+ """Serve a policy from this machine.
321
+
322
+ Put the command that starts your policy after `--`:
323
+
324
+ manifold policy serve pi05-libero:0.0.1 -- uv run serve.py
325
+
326
+ This starts the command and waits until your policy accepts
327
+ connections. Then it registers the policy version and this machine
328
+ with Manifold. The runs of this version that you submit go to this
329
+ machine, and the policy loads only once.
330
+
331
+ Press Ctrl-C to stop. This unregisters the machine and stops the
332
+ command.
333
+ """
334
+ if echo:
335
+ _serve_echo(console, target)
336
+ return
337
+ if policy is None:
338
+ console.print("[red]Name the policy to serve.[/red] For example:")
339
+ console.print(" manifold policy serve my-policy:0.0.1 -- COMMAND")
340
+ raise typer.Exit(1)
341
+ command = list(context.args)
342
+ if len(command) == 0:
343
+ console.print(
344
+ "[red]No command given.[/red] Put the command that starts your "
345
+ "policy after `--`, for example `-- uv run scripts/eval.py`."
346
+ )
347
+ raise typer.Exit(1)
348
+ _serve_policy(console, deps, policy, command=command)
349
+
350
+ @policy_app.command(hidden=True)
351
+ def bridge(
352
+ transport: Annotated[
353
+ str,
354
+ typer.Argument(
355
+ help="Listen target for our transport, e.g. tcp:0.0.0.0:9500 or iroh."
356
+ ),
357
+ ],
358
+ upstream: Annotated[
359
+ str,
360
+ typer.Option(
361
+ "--upstream", help="Local policy server to dial, as HOST:PORT."
362
+ ),
363
+ ],
364
+ ) -> None:
365
+ """Bridge a local policy server so it is reachable over our transport."""
366
+
367
+ async def run() -> None:
368
+ host, port = split_host_port(upstream)
369
+ listen_transport, rest = DEFAULT_TRANSPORTS.resolve(transport)
370
+ listener = await listen_transport.open_listener(rest)
371
+ console.print(
372
+ f"[green]Policy bridge up.[/green] "
373
+ f"Dial it at: [bold]{escape(listener.target)}[/bold]"
374
+ )
375
+ console.print(
376
+ f"Forwarding to policy server at [bold]{escape(host)}:{port}[/bold]."
377
+ )
378
+ console.print("Bridging... (press Ctrl-C to stop)")
379
+ await run_bridge(listener, TcpConnector(host, port))
380
+
381
+ try:
382
+ asyncio.run(run())
383
+ except KeyboardInterrupt:
384
+ console.print("\n[yellow]Policy bridge stopped.[/yellow]")
385
+ except (TransportError, OSError) as exc:
386
+ console.print(f"[red]Bridge failed:[/red] {escape(str(exc))}")
387
+ raise typer.Exit(1) from exc
388
+
389
+ app.add_typer(policy_app, name="policy")
390
+
391
+
392
+ def _serve_echo(console: Console, target: str) -> None:
393
+ """Serve a policy that echoes bytes back, to test the transport."""
394
+
395
+ async def run() -> None:
396
+ transport, rest = DEFAULT_TRANSPORTS.resolve(target)
397
+ listener = await transport.open_listener(rest)
398
+ console.print(
399
+ "[green]Echo policy serving.[/green] In another shell, reach it with:"
400
+ )
401
+ console.print(
402
+ f" [bold]manifold benchmark run {escape(listener.target)} --ping[/bold]"
403
+ )
404
+ console.print("Waiting for connections... (press Ctrl-C to stop)")
405
+ await serve_echo(listener, on_event=console.print)
406
+
407
+ try:
408
+ asyncio.run(run())
409
+ except KeyboardInterrupt:
410
+ console.print("\n[yellow]Policy server stopped.[/yellow]")
411
+ except (TransportError, OSError) as exc:
412
+ console.print(f"[red]Serve failed:[/red] {escape(str(exc))}")
413
+ raise typer.Exit(1) from exc
414
+
415
+
416
+ def _serve_policy(
417
+ console: Console,
418
+ deps: Dependencies,
419
+ policy: str,
420
+ *,
421
+ command: list[str],
422
+ ) -> None:
423
+ """Register the version, start the policy, then take runs once it is ready.
424
+
425
+ The version is registered first, so that a conflict fails before the
426
+ model loads, and so that a submit during the load says that the version
427
+ is not ready yet. Serve stops the policy process whenever it exits.
428
+ """
429
+ try:
430
+ owner, slug, version = split_versioned_ref(policy)
431
+ except InvalidReferenceError as exc:
432
+ console.print(f"[red]{escape(str(exc))}[/red]")
433
+ raise typer.Exit(1) from exc
434
+ if version is None:
435
+ console.print("[red]Name the version to serve.[/red] For example:")
436
+ console.print(f" manifold policy serve {escape(policy)}:0.0.1 -- COMMAND")
437
+ raise typer.Exit(1)
438
+ try:
439
+ if owner is None:
440
+ owner = resolve_owner(console, deps.organization_service)
441
+ deps.policy_service.register_served_version(slug, owner, version=version)
442
+ except (CatalogRegisterError, PolicyNotFoundError) as exc:
443
+ console.print(f"[red]{escape(str(exc))}[/red]")
444
+ raise typer.Exit(1) from exc
445
+ except ORGANIZATION_CALL_ERRORS as exc:
446
+ raise exit_for_call_error(console, exc) from exc
447
+ name = f"[bold]{escape(slug)}:{escape(version)}[/bold]"
448
+ console.print(finished(f"Registered {name}"))
449
+ # A shell without a terminal starts a background command with SIGINT
450
+ # ignored, and Python keeps that setting. Serve stops on SIGINT: on
451
+ # Ctrl-C, on `kill -INT`, and when the policy process exits. So it puts
452
+ # the default handler back.
453
+ if signal.getsignal(signal.SIGINT) == signal.SIG_IGN:
454
+ signal.signal(signal.SIGINT, signal.default_int_handler)
455
+ console.print(started(f"Starting {name}"))
456
+ try:
457
+ served = ServedPolicy.start(command)
458
+ except OSError as exc:
459
+ console.print(failed("Could not start your command", detail=escape(str(exc))))
460
+ raise typer.Exit(1) from exc
461
+ try:
462
+ _serve_started_policy(
463
+ console,
464
+ deps,
465
+ served,
466
+ policy,
467
+ owner=owner,
468
+ slug=slug,
469
+ version=version,
470
+ source=serve_source(command, Path.cwd()),
471
+ )
472
+ finally:
473
+ served.stop()
474
+
475
+
476
+ def _serve_started_policy(
477
+ console: Console,
478
+ deps: Dependencies,
479
+ served: ServedPolicy,
480
+ policy: str,
481
+ *,
482
+ owner: str,
483
+ slug: str,
484
+ version: str,
485
+ source: RunnerSource,
486
+ ) -> None:
487
+ """Wait until the policy is ready, register this machine, then take runs.
488
+
489
+ The runner is registered only after the policy accepts connections. So
490
+ when a submit finds a registered runner, the policy behind it is ready.
491
+ """
492
+ began = time.monotonic()
493
+ try:
494
+ ready = asyncio.run(served.wait_until_ready())
495
+ except TimeoutError as exc:
496
+ console.print(failed(escape(str(exc))))
497
+ raise typer.Exit(1) from exc
498
+ except KeyboardInterrupt:
499
+ console.print("\n" + stopped("Policy server stopped"))
500
+ return
501
+ loading_s = time.monotonic() - began
502
+ if not ready:
503
+ console.print(
504
+ failed(
505
+ f"Your policy process exited with code {served.exit_code} "
506
+ "before it was ready",
507
+ elapsed_s=loading_s,
508
+ )
509
+ )
510
+ raise typer.Exit(1)
511
+ console.print(
512
+ finished(
513
+ "Policy loaded",
514
+ elapsed_s=loading_s,
515
+ detail=f"Listening on {served.wire.address}",
516
+ )
517
+ )
518
+ try:
519
+ served_signature = asyncio.run(read_signature(served.wire))
520
+ except ValidationError as exc:
521
+ console.print(
522
+ failed(
523
+ "Your policy sent its serving script in the wrong format",
524
+ detail=escape(str(exc)),
525
+ )
526
+ )
527
+ raise typer.Exit(1) from exc
528
+ signature = None
529
+ if served_signature is not None:
530
+ signature = served_signature.signature
531
+ source = source.model_copy(
532
+ update={
533
+ "sdk_version": served_signature.sdk_version,
534
+ "script": served_signature.script,
535
+ }
536
+ )
537
+ try:
538
+ minted = _register_serving_runner(
539
+ console,
540
+ deps,
541
+ owner,
542
+ policy=slug,
543
+ version=version,
544
+ source=source,
545
+ signature=signature,
546
+ )
547
+ except (CatalogRegisterError, PolicyNotFoundError) as exc:
548
+ console.print(f"[red]{escape(str(exc))}[/red]")
549
+ raise typer.Exit(1) from exc
550
+ except ORGANIZATION_CALL_ERRORS as exc:
551
+ raise exit_for_call_error(console, exc) from exc
552
+ console.print(
553
+ finished(
554
+ "Registered this machine as runner "
555
+ f"[bold]{escape(minted.runner.name)}[/bold]"
556
+ )
557
+ )
558
+ console.print(
559
+ finished(f"Ready. Serving [bold]{escape(slug)}:{escape(version)}[/bold]")
560
+ )
561
+ console.print(
562
+ callout(
563
+ "To submit a run, in another shell:",
564
+ f" [bold]manifold run submit {escape(policy)} <benchmark> "
565
+ "--name NAME[/bold]",
566
+ )
567
+ )
568
+ console.print("[dim]Waiting for work... (press Ctrl-C to stop)[/dim]")
569
+ _run_until_stopped(console, deps, minted, served)
570
+
571
+
572
+ def _register_serving_runner(
573
+ console: Console,
574
+ deps: Dependencies,
575
+ owner: str,
576
+ *,
577
+ policy: str,
578
+ version: str,
579
+ source: RunnerSource,
580
+ signature: dict[str, Any] | None,
581
+ ) -> MintedRunner:
582
+ """Register this machine as a runner named after its hostname.
583
+
584
+ If the name is taken, retry once with a random suffix. The name is taken
585
+ when another `manifold policy serve` runs on this machine, when an earlier
586
+ one was killed before it unregistered, or when a runner in another
587
+ organization uses the name.
588
+ """
589
+ name = socket.gethostname() or "workstation"
590
+ gpu_devices = gpu_facts().devices
591
+ try:
592
+ return deps.runner_service.register_serving(
593
+ name,
594
+ owner,
595
+ policy=policy,
596
+ version=version,
597
+ gpu_devices=gpu_devices,
598
+ source=source,
599
+ signature=signature,
600
+ )
601
+ except RunnerNameTakenError:
602
+ unique = f"{name}-{uuid4().hex[:SERVE_NAME_SUFFIX_LENGTH]}"
603
+ return deps.runner_service.register_serving(
604
+ unique,
605
+ owner,
606
+ policy=policy,
607
+ version=version,
608
+ gpu_devices=gpu_devices,
609
+ source=source,
610
+ signature=signature,
611
+ )
612
+
613
+
614
+ def _run_until_stopped(
615
+ console: Console, deps: Dependencies, minted: MintedRunner, served: ServedPolicy
616
+ ) -> None:
617
+ """Run the runner loop until Ctrl-C or until the policy exits, then unregister.
618
+
619
+ When the policy process exits, serve sends itself SIGINT. The runner loop
620
+ may be waiting on a long poll, and SIGINT is what interrupts that wait.
621
+
622
+ Ctrl-C during a run cancels the run. When the policy process exited, the
623
+ policy side reports the exit instead.
624
+ """
625
+ client = make_in_memory_runner_client(minted.token, deps.manifold_api_url)
626
+ current_dispatch = CurrentDispatch()
627
+ try:
628
+ # A SIGINT outside the loop's waits for work and its sides raises
629
+ # KeyboardInterrupt. The policy may already have exited during
630
+ # registration, and then on_exit sends SIGINT immediately, so call
631
+ # `served.on_exit` inside the `suppress`.
632
+ with (
633
+ contextlib.suppress(KeyboardInterrupt),
634
+ reporting_resource_usage(
635
+ client=client,
636
+ current_dispatch=current_dispatch,
637
+ docker=None,
638
+ log=console.print,
639
+ ),
640
+ ):
641
+ served.on_exit(lambda _code: os.kill(os.getpid(), signal.SIGINT))
642
+ run_daemon(
643
+ client=client,
644
+ spawner=AsyncioSpawner(),
645
+ box=BoxFacts(
646
+ gpu_devices=[],
647
+ cache_dir=None,
648
+ advertise_host=None,
649
+ # IROH, because the benchmark runner may not be able to reach
650
+ # this machine.
651
+ # TODO: This fixes the transport when the runner is created.
652
+ # That only suits one-time runners like this one, which last
653
+ # for one CLI process. Do not do this for registered runners.
654
+ # Pick their transport when a run starts, so that a box can
655
+ # move to TCP once the fleet can reach it, for example over a
656
+ # VPN.
657
+ policy_transport=PolicyTransport.IROH,
658
+ ),
659
+ poll_timeout_s=POLL_TIMEOUT_SECONDS,
660
+ bridge_poll_s=BRIDGE_POLL_SECONDS,
661
+ current_dispatch=current_dispatch,
662
+ stop_signal=signal.SIGINT,
663
+ start_runner=_send_nothing,
664
+ stop_runner=_send_nothing,
665
+ stop_side=functools.partial(
666
+ _cancel_served_run, console=console, client=client, served=served
667
+ ),
668
+ # The runner copies its lines into the run's log, and the app shows
669
+ # when a run starts and ends. So the terminal skips them.
670
+ log=_discard,
671
+ policy_side=functools.partial(run_served_policy_side, served=served),
672
+ )
673
+ exit_code = served.exit_code
674
+ if exit_code is None:
675
+ console.print("\n" + stopped("Policy server stopped"))
676
+ except RunnerCredentialRejectedError as exc:
677
+ console.print(f"[red]{escape(str(exc))}[/red]")
678
+ console.print("This runner was revoked while serving.")
679
+ raise typer.Exit(1) from exc
680
+ finally:
681
+ # Serve is stopping, so a policy that exits from here on is expected.
682
+ served.ignore_exit()
683
+ _release(console, client)
684
+ client.close()
685
+ if exit_code is not None:
686
+ console.print(failed(f"Your policy process exited with code {exit_code}"))
687
+ raise typer.Exit(1)
688
+
689
+
690
+ def _send_nothing() -> None:
691
+ """Stand in for the start and stop requests. A serving runner does not
692
+ send them: placement chooses it by the version it serves."""
693
+
694
+
695
+ def _cancel_served_run(
696
+ dispatch: AssignmentDispatch,
697
+ *,
698
+ console: Console,
699
+ client: ManifoldApiClient,
700
+ served: ServedPolicy,
701
+ ) -> None:
702
+ """Cancel the dispatch's run, unless the policy process exited.
703
+
704
+ Print the error from a failed cancel instead of raising it: the policy
705
+ side's own failed report then ends the side.
706
+ """
707
+ if served.exit_code is not None:
708
+ return
709
+ try:
710
+ client.cancel_run(dispatch.run.id)
711
+ except Exception as exc:
712
+ console.print(
713
+ "[yellow]Could not cancel the run:[/yellow] "
714
+ f"{escape(str(exc) or type(exc).__name__)}"
715
+ )
716
+ else:
717
+ console.print(
718
+ "\n" + stopped(f"Canceled run [bold]{escape(dispatch.run.name)}[/bold]")
719
+ )
720
+
721
+
722
+ def _discard(message: str) -> None:
723
+ """Drop a line from the runner loop."""
724
+
725
+
726
+ def _release(console: Console, client: ManifoldApiClient) -> None:
727
+ """Unregister this runner, printing any error instead of raising it.
728
+
729
+ This runs in a `finally`, where raising would hide the original exception.
730
+ """
731
+ try:
732
+ client.release_runner()
733
+ except Exception as exc:
734
+ console.print(
735
+ "[yellow]Could not unregister this runner:[/yellow] "
736
+ f"{escape(str(exc) or type(exc).__name__)}. Manifold stops sending "
737
+ "it runs once it stops polling. An operator can remove it with "
738
+ "`manifold runner revoke`."
739
+ )
740
+
741
+
742
+ def _parse_environment(values: list[str] | None) -> dict[str, str]:
743
+ environment: dict[str, str] = {}
744
+ for value in values or []:
745
+ key, separator, setting = value.partition("=")
746
+ if not separator or not key:
747
+ raise typer.BadParameter("must be KEY=VALUE", param_hint="--env")
748
+ environment[key] = setting
749
+ return environment