Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ host: migrate ## Start task service + session-service host in background tmux s
tmux -L panopticon kill-session -t service 2>/dev/null || true
tmux -L panopticon new-session -d -s service 'uv run python -m panopticon.taskservice 2>&1 | tee /tmp/panopticon-service.log'
tmux -L panopticon kill-session -t runner 2>/dev/null || true
tmux -L panopticon new-session -d -s runner 'uv run python -m panopticon.sessionservice.host 2>&1 | tee /tmp/panopticon-runner.log'
tmux -L panopticon new-session -d -s runner 'uv run panopticon start-runner --local 2>&1 | tee /tmp/panopticon-runner.log'

start: host ## Run panopticon: task service + session-service runner (background) + dashboard supervisor
uv run panopticon console
Expand Down
133 changes: 133 additions & 0 deletions docs/start-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# `panopticon start-runner` — start a session-service runner

`panopticon start-runner` starts a `panopticon.sessionservice.host` runner,
either locally (`--local`) or on a remote machine via SSH.

```
panopticon start-runner --local # start a runner on this machine
panopticon start-runner <host> # SSH to <host> and start a runner there
```

## Overview

The command gives operators a single entry point for all runner lifecycle.
`--local` replaces the direct `python -m panopticon.sessionservice.host` call
(used by `make start`) and ensures the runner registers with no hostname, so
locally-claimed tasks attach without triggering an unnecessary SSH hop.

The remote form (no `--local`) solves a different problem: compute on another
machine. A reverse port forward means the remote machine only needs a normal
outbound SSH connection — no inbound access to the task service required.

## Modes

### Local mode (`--local`)

```
panopticon start-runner --local
```

Starts the session service on the current machine. No SSH is used. The runner
registers with no hostname (`--host ""`), so locally-claimed tasks attach
without SSH. `make start` uses this form.

`--python` defaults to `sys.executable` (the interpreter running the CLI), so
the same virtual environment is reused automatically.

### Remote — Tunnel mode (default)

```
panopticon start-runner myhost
```

What happens:

1. SSH opens a reverse port forward: `localhost:<port>` on `myhost` reaches the
local task service.
2. The remote `panopticon.sessionservice.host` is started with
`--service-url http://localhost:<port>` (the forwarded address) and
`--container-service-url http://host.docker.internal:<port>` (what Docker
containers use to call back).
3. Docker's `--add-host host.docker.internal:host-gateway` (already injected by
`LocalRunner`) routes `host.docker.internal` inside containers to the tunnel.

**Prerequisite**: `GatewayPorts clientspecified` (or `yes`) must be set in the
remote host's `/etc/ssh/sshd_config`, otherwise the tunnel only binds
`127.0.0.1` on the remote and containers cannot reach it.

```sshd_config
GatewayPorts clientspecified
```

After editing, reload: `sudo systemctl reload sshd`.

The constructed SSH command (for reference):

```sh
ssh \
-R localhost:8000:localhost:8000 \
-o ExitOnForwardFailure=yes \
myhost \
python -m panopticon.sessionservice.host \
--service-url http://localhost:8000 \
--container-service-url http://host.docker.internal:8000 \
--runner-id myhost \
--host myhost \
--tasks-root ~/.panopticon/tasks \
--cache-root ~/.panopticon/cache
```

### Remote — Direct mode (`--no-tunnel`)

For deployments where the task service is on a routable LAN address:

```
panopticon start-runner myhost \
--no-tunnel \
--service-url http://10.0.1.5:8000
```

No port forward is opened; the remote runner connects directly.
`--container-service-url` defaults to the same value as `--service-url` in
direct mode.

## Options

| Flag | Default | Description |
|---|---|---|
| `--local` | off | Run on this machine (no SSH) |
| `--service-url URL` | `$PANOPTICON_SERVICE_URL` or `http://localhost:8000` | Task service URL |
| `--remote-port PORT` | same as local port | Port forwarded on the remote host (remote only) |
| `--runner-id ID` | `<host>` or `local` | Runner id to register as |
| `--container-service-url URL` | derived | URL injected into containers |
| `--no-tunnel` | off | Skip the reverse port forward (remote only) |
| `--image IMAGE` | `panopticon-base` | Task container image |
| `--tasks-root PATH` | `~/.panopticon/tasks` | Tasks root directory |
| `--cache-root PATH` | `~/.panopticon/cache` | Cache root directory |
| `--python CMD` | `sys.executable` (local) / `python3` (remote) | Python interpreter; multi-word values are split (e.g. `uv run python`) |

## Keeping the runner alive

For a local runner, `make start` manages the lifecycle automatically via the
`runner` tmux session on the `panopticon` socket.

For a remote runner, the SSH session IS the tunnel. Run it in a persistent
tmux pane alongside `make start`:

```
# in one tmux pane
make start

# in another pane
panopticon start-runner myhost
```

The runner reconnects automatically if `panopticon.sessionservice.host`
restarts, but if the SSH session dies the tunnel is lost.

## Future: `make start-remote`

A `REMOTE_HOST=myhost make start-remote` target could open a `start-runner`
pane alongside the existing `service` / `runner` / `dashboard` panes on the
`-L panopticon` tmux server, integrating remote runners into the standard
`make start` workflow.
101 changes: 101 additions & 0 deletions src/panopticon/terminal/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
`panopticon` (or `panopticon console`) runs the session supervisor (ADR 0009): the dashboard,
plus handing the terminal to a task's tmux on `t` and rejoining on detach. `panopticon dashboard`
runs the dashboard once without the attach loop; `panopticon tasks` lists tasks as plain text.
`panopticon start-runner <host>` SSHes to a remote host, opens a reverse port forward so the
remote runner can reach the local task service, and starts the session service there.
"""

from __future__ import annotations
Expand All @@ -15,6 +17,13 @@
import httpx

from panopticon.client import TaskServiceClient
from panopticon.terminal.launch import (
DEFAULT_CACHE_ROOT,
DEFAULT_IMAGE,
DEFAULT_PYTHON,
DEFAULT_TASKS_ROOT,
start_runner,
)

DEFAULT_SERVICE_URL = "http://localhost:8000"

Expand All @@ -37,8 +46,100 @@ def main(
# the operator picked with `t` by writing it here instead of returning it in-process.
dash.add_argument("--switch-file", help=argparse.SUPPRESS)
sub.add_parser("tasks", help="list tasks as plain text")

sr = sub.add_parser(
"start-runner",
help="start a session-service runner locally (--local) or on a remote host via SSH",
)
sr.add_argument(
"host",
nargs="?",
default=None,
help="remote host to SSH to (omit when using --local)",
)
sr.add_argument(
"--local",
action="store_true",
help="run the session service on this machine instead of SSH-ing to a remote host; "
"registers with no hostname so locally-claimed tasks attach without SSH",
)
sr.add_argument(
"--service-url",
default=os.environ.get("PANOPTICON_SERVICE_URL", DEFAULT_SERVICE_URL),
help="task service URL (default: $PANOPTICON_SERVICE_URL or http://localhost:8000)",
)
sr.add_argument(
"--remote-port",
type=int,
default=None,
metavar="PORT",
help="port forwarded on the remote host (default: same as local service port)",
)
sr.add_argument(
"--runner-id",
default=None,
metavar="ID",
help="runner id to register as (default: <host>, or 'local' with --local)",
)
sr.add_argument(
"--container-service-url",
default=None,
metavar="URL",
help="URL injected into containers to reach the task service (default: derived from tunnel port)",
)
sr.add_argument(
"--no-tunnel",
dest="tunnel",
action="store_false",
help="skip the reverse port forward; use when the task service has a routable address",
)
sr.add_argument(
"--image",
default=DEFAULT_IMAGE,
help=f"task container image (default: {DEFAULT_IMAGE})",
)
sr.add_argument(
"--tasks-root",
default=DEFAULT_TASKS_ROOT,
metavar="PATH",
help=f"tasks root directory (default: {DEFAULT_TASKS_ROOT})",
)
sr.add_argument(
"--cache-root",
default=DEFAULT_CACHE_ROOT,
metavar="PATH",
help=f"cache root directory (default: {DEFAULT_CACHE_ROOT})",
)
sr.add_argument(
"--python",
default=None,
metavar="CMD",
help=f"Python interpreter; multi-word values are split (e.g. 'uv run python') "
f"(default: sys.executable with --local, {DEFAULT_PYTHON} for remote)",
)

args = parser.parse_args(argv)

if args.command == "start-runner":
if args.local and args.host:
sr.error("--local and host are mutually exclusive")
if not args.local and not args.host:
sr.error("host is required unless --local is specified")
start_runner(
args.host,
local=args.local,
local_service_url=args.service_url,
remote_port=args.remote_port,
runner_id=args.runner_id,
container_service_url=args.container_service_url,
tunnel=args.tunnel,
image=args.image,
tasks_root=args.tasks_root,
cache_root=args.cache_root,
python=args.python,
)
return 0

client = client or TaskServiceClient(httpx.Client(base_url=args.service_url))
if args.command == "tasks":
for t in client.list_tasks():
Expand Down
Loading
Loading