mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(remote): address review feedback on omp/claude respawn continuity
- Remote omp command now renders through buildSpawnCommandFromRegistry (the mode-agnostic engine local/docker spawns use) instead of the buildOmpCommand() the CLI-registry refactor deleted. - Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip host-local ~/.omp resolution entirely for a remote session and fall back to --continue: that resolver only ever reads THIS host's filesystem, which is meaningless (and could wrongly alias an unrelated local conversation) for a conversation that lives on the remote host. - Remote-claude launch now honors an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand's shape), and validates sessionId the same way that sibling does before interpolating it into the remote shell command. - Add the still-missing header-cwd half of the trailing-slash test, and document respawn/reattach continuation + auto-reconnect-vs- clean-exit in docs/remote-sessions.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
88243e9ffa
commit
797f0d387c
+67
-23
@@ -24,14 +24,14 @@ custom port, identity file, `-J` jump host, `-o ProxyCommand`).
|
||||
|
||||
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| Type | Role |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
|
||||
@@ -73,7 +73,7 @@ Rules that keep this safe — **do not bypass them by hand-building an ssh line
|
||||
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
|
||||
the one in `tmux-manager.ts`.
|
||||
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
|
||||
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
|
||||
_before_ escaping — ssh does not expand `~` inside `-i`, and the escaped value
|
||||
never reaches a shell that would.
|
||||
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
|
||||
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
|
||||
@@ -112,7 +112,7 @@ Key points:
|
||||
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||
durable launches live on `-L codeman-remote`.
|
||||
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
|
||||
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID _is_
|
||||
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
|
||||
@@ -128,9 +128,9 @@ Because durable remote sessions require tmux on the remote host,
|
||||
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
|
||||
creating a remote case/session and returns a structured, never-throwing result:
|
||||
|
||||
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"*
|
||||
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
|
||||
- empty stdout / non-zero exit → _"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"_
|
||||
- stderr present → _"could not verify tmux on remote host `<host>`: `<stderr>`"_
|
||||
(a real connection failure, surfaced to the operator)
|
||||
- success → `{ ok: true, tmuxPath }`
|
||||
|
||||
@@ -147,7 +147,7 @@ skipped; command construction is still asserted by unit tests.
|
||||
|
||||
## Ownership: launched vs. discovered-and-attached (COD-105)
|
||||
|
||||
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
|
||||
COD-104 (above) was Phase 1 — Codeman _launches_ a remote session and owns it.
|
||||
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
|
||||
already running on a remote host (created by the remote's own Codeman or another
|
||||
instance) and **attach** to one it didn't launch. Ownership decides what happens
|
||||
@@ -188,7 +188,7 @@ remote command line by ownership:
|
||||
|
||||
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
|
||||
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
|
||||
NOT `new-session -A`**, so it only *joins* an existing session and never creates
|
||||
NOT `new-session -A`**, so it only _joins_ an existing session and never creates
|
||||
one.
|
||||
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
|
||||
|
||||
@@ -196,24 +196,68 @@ remote command line by ownership:
|
||||
|
||||
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
|
||||
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
|
||||
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
|
||||
kill-session` on _this_ host's socket). Killing the local ssh sends SIGHUP to the
|
||||
remote `tmux attach`, which **detaches** — the durable remote session survives.
|
||||
The early return is a structural guarantee that **no code path can ever issue a
|
||||
remote `kill-session` for a session we don't own** — the only `kill-session` run is
|
||||
on the local socket, which never reaches the remote socket.
|
||||
|
||||
## Respawn / reattach continuation
|
||||
|
||||
A dropped connection or a dead pane must reconnect to the **same conversation**,
|
||||
not launch a fresh one — the whole point of a durable remote session.
|
||||
|
||||
- **Claude**: the launch command is idempotent — `claude --session-id <id> ||
|
||||
claude --resume <id>` (see `buildRemoteLaunchCommand`'s claude branch). The
|
||||
first run creates the conversation under the deterministic session id; every
|
||||
later reattach/respawn re-runs the same line, `--session-id` fails
|
||||
("already in use"), and the `||` fallback resumes it.
|
||||
- **OMP**: `omp` has no equivalent idempotent single-line form, so
|
||||
`Session._pinOmpRespawnId()` resolves and pins an explicit `--resume <id>`
|
||||
before a respawn (mirroring the local/docker builders, rendered through the
|
||||
same `buildSpawnCommandFromRegistry` engine — not a hand-rolled command and
|
||||
not `appendResumeFlag()`, which is docker-only and cannot work here: appending
|
||||
a flag after the quoted `-c 'omp'` hands the id to the login shell as `$0`
|
||||
instead of to `omp`). ⚠️ **The resolver only ever reads THIS host's local
|
||||
`~/.omp/agent/sessions/`**, which is meaningless for a remote session — the
|
||||
conversation and its session file live on the remote host, under the remote
|
||||
user's home. For a remote session, `_pinOmpRespawnId()` therefore skips local
|
||||
resolution entirely and falls back to `omp`'s own ambiguous `--continue`
|
||||
(`ompConfig.continueSession`), which the remote pane command already renders.
|
||||
This is a known, accepted degradation versus the local/docker paths' exact
|
||||
`--resume` pin — safe in practice because each remote respawn talks to
|
||||
exactly one remote pane's own omp history, so "most recent" is normally
|
||||
correct, but it can drift the same way `--continue` always could if two
|
||||
remote sessions ever share one remote directory.
|
||||
|
||||
## Auto-reconnect vs. a clean agent exit
|
||||
|
||||
`remoteAutoReconnect` (default ON) watches for a dropped SSH connection and
|
||||
reconnects with bounded backoff. It must **never** revive a session whose agent
|
||||
exited cleanly (Ctrl-C, Ctrl-D, `exit`) — that tears down the durable remote
|
||||
tmux session itself, and a transport-level `isPaneDead()` cannot tell that apart
|
||||
from a plain network drop. `remoteTmuxSessionAlive()` (#355) resolves this by
|
||||
probing the remote host directly: `tmux -L codeman-remote has-session -t
|
||||
codeman-ssh-<id8>` over the same `buildSshConnectionArgs` as launch, classified
|
||||
by **exit status alone** (`classifyRemoteAliveExit`: `0` = alive, ssh's `255` or
|
||||
a timeout = unknown, anything else = gone) — `has-session` prints nothing on
|
||||
success, so reading stdout would misclassify every live session as gone. An
|
||||
unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
| Method | Path | Purpose |
|
||||
| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
|
||||
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||
|
||||
Reference in New Issue
Block a user