mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16: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`.
|
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||||
|
|
||||||
| Type | Role |
|
| 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. |
|
| `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). |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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()`. |
|
| `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:
|
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
|
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
|
||||||
the one in `tmux-manager.ts`.
|
the one in `tmux-manager.ts`.
|
||||||
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
|
- **`~`/`$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.
|
never reaches a shell that would.
|
||||||
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
|
- **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
|
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`
|
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||||
durable launches live on `-L codeman-remote`.
|
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
|
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||||
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
|
`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**
|
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
|
||||||
creating a remote case/session and returns a structured, never-throwing result:
|
creating a remote case/session and returns a structured, never-throwing result:
|
||||||
|
|
||||||
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
|
- empty stdout / non-zero exit → _"remote host `<host>` needs tmux installed for
|
||||||
durable remote sessions"*
|
durable remote sessions"_
|
||||||
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
|
- stderr present → _"could not verify tmux on remote host `<host>`: `<stderr>`"_
|
||||||
(a real connection failure, surfaced to the operator)
|
(a real connection failure, surfaced to the operator)
|
||||||
- success → `{ ok: true, tmuxPath }`
|
- 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)
|
## 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
|
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
|
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
|
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
|
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
|
||||||
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
|
`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.
|
one.
|
||||||
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
|
- **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**:
|
`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
|
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.
|
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
|
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
|
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.
|
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
|
## API
|
||||||
|
|
||||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||||
| `POST` | `/api/remote-hosts` | Create a host |
|
| `POST` | `/api/remote-hosts` | Create a host |
|
||||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||||
| `DELETE` | `/api/remote-hosts/:id` | Delete 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) |
|
| `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`) |
|
| `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:
|
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||||
|
|||||||
@@ -1775,6 +1775,19 @@ export class Session extends EventEmitter {
|
|||||||
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
||||||
// fix intact now that resolution has moved out of the eager options build.
|
// fix intact now that resolution has moved out of the eager options build.
|
||||||
if (!this._muxSession) return;
|
if (!this._muxSession) return;
|
||||||
|
// `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.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. Worse than a no-op: `this.workingDir` for a
|
||||||
|
// remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine
|
||||||
|
// happens to have its own omp history under a directory that mangles to the same name,
|
||||||
|
// this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a
|
||||||
|
// remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote
|
||||||
|
// pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there
|
||||||
|
// because each remote respawn talks to exactly one remote pane's own omp history.
|
||||||
|
if (this._remote) {
|
||||||
|
this._ompConfig = { ...this._ompConfig, continueSession: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||||
if (resolvedId) {
|
if (resolvedId) {
|
||||||
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
||||||
@@ -2568,6 +2581,11 @@ export class Session extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private _maybeCaptureOmpSessionId(): void {
|
private _maybeCaptureOmpSessionId(): void {
|
||||||
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
|
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
|
||||||
|
// Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a
|
||||||
|
// remote session lives on the remote host, not here, so scanning locally risks aliasing
|
||||||
|
// this session onto an unrelated local omp conversation that happens to mangle to the
|
||||||
|
// same directory name. Never resolvable from here — skip.
|
||||||
|
if (this._remote) return;
|
||||||
try {
|
try {
|
||||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||||
if (resolvedId) {
|
if (resolvedId) {
|
||||||
|
|||||||
+22
-7
@@ -778,19 +778,34 @@ export function buildRemoteLaunchCommand(options: {
|
|||||||
modeCommand = override;
|
modeCommand = override;
|
||||||
} else if (mode === 'claude') {
|
} else if (mode === 'claude') {
|
||||||
// Deterministic conversation pinning for SSH-remote claude (mirrors the
|
// Deterministic conversation pinning for SSH-remote claude (mirrors the
|
||||||
// docker-claude shape in claudeDockerPaneCommand): the FIRST run creates
|
// docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct
|
||||||
// the conversation under --session-id <sessionId>; a respawn / reattach
|
// resumeId branch it declares — this used to only mirror the same-id
|
||||||
// re-runs the same idempotent command, --session-id exits non-zero
|
// fallback shape, silently dropping an explicit resumeSessionId that
|
||||||
// ("already in use"), and the `||` fallback RESUMES that same
|
// differs from sessionId, e.g. a resume-from-history launch): the FIRST
|
||||||
|
// run creates the conversation under --session-id <sessionId>; a respawn
|
||||||
|
// / reattach re-runs the same idempotent command, --session-id exits
|
||||||
|
// non-zero ("already in use"), and the `||` fallback RESUMES that same
|
||||||
// conversation. Without a pinned id, every reattach relaunched a bare
|
// conversation. Without a pinned id, every reattach relaunched a bare
|
||||||
// `claude` and started a NEW conversation (found live 2026-08-29: remote
|
// `claude` and started a NEW conversation (found live 2026-08-29: remote
|
||||||
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
|
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
|
||||||
// `commands.claude` override stays authoritative (admin's explicit
|
// `commands.claude` override stays authoritative (admin's explicit
|
||||||
// choice) and skips this entirely.
|
// choice) and skips this entirely.
|
||||||
const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools);
|
const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools);
|
||||||
modeCommand = remoteLoginShellCommand(
|
const cmd = `claude${permFlags}`;
|
||||||
`claude${permFlags} --session-id ${sessionId} || claude${permFlags} --resume ${sessionId}`
|
// Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check:
|
||||||
);
|
// sessionId is server-minted and always safe in practice, but this command is built
|
||||||
|
// as a single shellescaped string and then executed as shell code on the remote
|
||||||
|
// host, so an unsafe value here is validated rather than trusted.
|
||||||
|
if (!RESUME_ID_SAFE.test(sessionId)) {
|
||||||
|
modeCommand = remoteLoginShellCommand(cmd);
|
||||||
|
} else {
|
||||||
|
const rid = resumeSessionId && RESUME_ID_SAFE.test(resumeSessionId) ? resumeSessionId : undefined;
|
||||||
|
modeCommand = remoteLoginShellCommand(
|
||||||
|
rid && rid !== sessionId
|
||||||
|
? `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`
|
||||||
|
: `${cmd} --session-id ${sessionId} || ${cmd} --resume ${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (mode === 'omp') {
|
} else if (mode === 'omp') {
|
||||||
// Remote OMP respawn must RESUME the same conversation instead of
|
// Remote OMP respawn must RESUME the same conversation instead of
|
||||||
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
|
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { join } from 'node:path';
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
import { TmuxManager } from '../src/tmux-manager.js';
|
import { TmuxManager } from '../src/tmux-manager.js';
|
||||||
import type { MuxSession } from '../src/types.js';
|
import type { MuxSession, SessionRemote } from '../src/types.js';
|
||||||
|
|
||||||
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
|
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
|
||||||
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
||||||
@@ -126,4 +126,48 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
|
|||||||
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
|
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
|
||||||
expect(session.claudeSessionId).toBe('real-omp-uuid');
|
expect(session.claudeSessionId).toBe('real-omp-uuid');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a remote session never resolves --resume from this host's local ~/.omp, even when a same-named local session file exists", () => {
|
||||||
|
// Seed a LOCAL session file whose directory mangle happens to match this
|
||||||
|
// remote session's remotePath. If _pinOmpRespawnId() ever fell through to
|
||||||
|
// resolveAndClaimOmpSessionId() for a remote session, it would wrongly
|
||||||
|
// claim/pin this unrelated local conversation's id onto the remote respawn.
|
||||||
|
seedOmpSessionFile('wrong-local-conversation-id');
|
||||||
|
|
||||||
|
const remote: SessionRemote = {
|
||||||
|
hostId: 'remote-box',
|
||||||
|
label: 'remote-box',
|
||||||
|
host: 'remote-box',
|
||||||
|
username: 'someone',
|
||||||
|
remotePath: workingDir,
|
||||||
|
owned: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const muxSession: MuxSession = {
|
||||||
|
sessionId: 'placeholder',
|
||||||
|
muxName: 'codeman-deadbeef',
|
||||||
|
pid: 1,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
workingDir,
|
||||||
|
mode: 'omp',
|
||||||
|
attached: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = new Session({
|
||||||
|
workingDir,
|
||||||
|
mode: 'omp',
|
||||||
|
mux: new TmuxManager(),
|
||||||
|
useMux: true,
|
||||||
|
muxSession,
|
||||||
|
remote,
|
||||||
|
});
|
||||||
|
sessions.push(session);
|
||||||
|
|
||||||
|
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
|
||||||
|
|
||||||
|
const state = session.toState();
|
||||||
|
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
|
||||||
|
expect(state.ompConfig?.continueSession).toBe(true);
|
||||||
|
expect(session.claudeSessionId).toBe(session.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
|
import {
|
||||||
|
findLatestOmpSessionId,
|
||||||
|
mangleOmpWorkingDir,
|
||||||
|
resolveAndClaimOmpSessionId,
|
||||||
|
} from '../src/utils/omp-session-resolver.js';
|
||||||
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
|
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
describe('mangleOmpWorkingDir', () => {
|
describe('mangleOmpWorkingDir', () => {
|
||||||
@@ -78,6 +82,33 @@ describe('findLatestOmpSessionId', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => {
|
||||||
|
// Sibling of the directory-mangle trailing-slash regression above, but for
|
||||||
|
// the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally
|
||||||
|
// verifies each candidate file's own header `cwd` against workingDir (the
|
||||||
|
// mangle is lossy, so the filename-derived id alone isn't enough — see the
|
||||||
|
// function's doc comment). A remote case's workingDir carries a trailing
|
||||||
|
// slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does;
|
||||||
|
// without stripTrailingSlash() on BOTH sides of that comparison, a real
|
||||||
|
// on-disk session would be found by directory but rejected by the cwd
|
||||||
|
// check, silently degrading pinning to the ambiguous `--continue`.
|
||||||
|
const workingDirNoSlash = join(homedir(), 'dotfiles');
|
||||||
|
const workingDirWithSlash = `${workingDirNoSlash}/`;
|
||||||
|
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles');
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a header cwd with no trailing slash against a workingDir that has one', () => {
|
||||||
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`;
|
||||||
|
writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header);
|
||||||
|
|
||||||
|
expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('resolveOmpConfigForCreate', () => {
|
describe('resolveOmpConfigForCreate', () => {
|
||||||
// The exact pipeline "resume this OMP row from the history list" drives:
|
// The exact pipeline "resume this OMP row from the history list" drives:
|
||||||
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
|
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
|
||||||
|
|||||||
@@ -187,6 +187,28 @@ describe('TmuxManager (unit)', () => {
|
|||||||
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||||
expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456');
|
expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resumes an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand)', () => {
|
||||||
|
// The docker-claude builder (claudeDockerPaneCommand) has always handled a
|
||||||
|
// resumeId that differs from sessionId — e.g. a resume-from-history launch —
|
||||||
|
// by leading with `--resume <rid> || --session-id <sessionId>`. The remote
|
||||||
|
// claude branch used to only mirror the SAME-id fallback shape and silently
|
||||||
|
// dropped a distinct resumeSessionId, so a remote resume-from-history claude
|
||||||
|
// launch created a brand-new conversation instead of resuming the named one.
|
||||||
|
const command = buildRemoteLaunchCommand({
|
||||||
|
mode: 'claude',
|
||||||
|
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||||
|
sessionId: 'abc123def456',
|
||||||
|
resumeSessionId: 'old-conversation-uuid',
|
||||||
|
});
|
||||||
|
expect(command).toContain('claude --dangerously-skip-permissions --resume old-conversation-uuid');
|
||||||
|
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||||
|
// The resume attempt must lead — session-id is the fallback here, reversed
|
||||||
|
// from the same-id case.
|
||||||
|
const resumeIdx = command.indexOf('--resume old-conversation-uuid');
|
||||||
|
const sessionIdIdx = command.indexOf('--session-id abc123def456');
|
||||||
|
expect(resumeIdx).toBeLessThan(sessionIdIdx);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remote kill command builder', () => {
|
describe('remote kill command builder', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user