feat(docker): let one adopted container back several cases in different dirs

Once a container is adopted, it could not be adopted a second time. But a
container usually holds more than one project directory, and opening a case for
another one had no path forward except starting a second container — precisely
what adoption exists to avoid.

The original reason was in a comment: two cases sharing an adopted container
would make one case's teardown race the other's launch on the same tmux server.
That reason does not hold. The in-container tmux session name is
dockerTmuxSessionName(sessionId), i.e. codeman-dkr-<id8>, keyed by SESSION and
not by case, and buildDockerKillCommand tears down exactly that name, so killing
A never touches B — hosting multiple sessions is what a tmux server is for.

The other three routes into an adopted container's lifecycle do not pass through
here either, confirmed one by one: the stop and remove builders throw outright;
recreate refuses `owned === false` before it even resolves the container name;
and orphan reaping filters on `label=codeman.managed=1`, which a user-built
container does not carry — a structural exclusion.

That leaves exactly three cases worth refusing, none of them tmux-related, split
into the pure, unit-tested classifyAdoptContainerConflict:
- owned-case   the container belongs to a Codeman-created case, whose lifecycle
               Codeman manages: one recreate or delete there would pull the
               container out from under the adopting case.
               ⚠️ `owned` may be absent and absent means owned (cases predate
               the field), so the test is `!== false`, not truthiness.
- other-owner  already adopted by a different user. Adoption hands out a shell
               inside someone else's container.
- duplicate    same container, same directory. The second case would behave
               identically to the first, so name the existing one rather than
               silently minting a twin. A different in-container directory is
               the case this change exists to support and passes.

(cherry picked from commit 1cb6bde891)
This commit is contained in:
d fei
2026-09-14 23:56:19 +02:00
committed by Codeman maintainer
parent e5684d0bba
commit cbb7f635ff
3 changed files with 199 additions and 5 deletions
+112
View File
@@ -19,6 +19,8 @@ import {
checkDockerConfigDrift,
dockerConfigHash,
dockerAdoptProbeModes,
classifyAdoptContainerConflict,
dockerContainerName,
} from '../src/docker-hosts.js';
import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
import {
@@ -451,3 +453,113 @@ describe('adopted container: a missing container means different things per owne
expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
});
});
describe('adopted container: one container may back several cases', () => {
const base = {
type: 'docker' as const,
hostId: 'h1',
hostWorkspacePath: '/srv/work',
};
const mk = (over: Record<string, unknown>) => ({ ...base, ...over }) as never;
const mine = () => true;
it('allows a second adoption of the same container at a DIFFERENT directory', () => {
// The whole point of the feature: one container, two folders, two cases.
const conflict = classifyAdoptContainerConflict({
container: 'devbox',
containerWorkdir: '/app/api',
existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '/app/web', owned: false })],
canAccess: mine,
});
expect(conflict).toBeNull();
});
it('refuses an exact twin (same container AND same directory) and names the first case', () => {
const conflict = classifyAdoptContainerConflict({
container: 'devbox',
containerWorkdir: '/app/web',
existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '/app/web', owned: false })],
canAccess: mine,
});
expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' });
});
it('falls back to hostWorkspacePath when containerWorkdir is absent on either side', () => {
// containerWorkdir defaults to hostWorkspacePath, so an absent field on the
// stored case must compare equal to an incoming adoption that omits it too —
// otherwise the twin check silently stops firing for the default case.
const conflict = classifyAdoptContainerConflict({
container: 'devbox',
containerWorkdir: '/srv/work',
existing: [mk({ name: 'web', container: 'devbox', owned: false })],
canAccess: mine,
});
expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' });
});
it('still refuses a container backing a case Codeman CREATED', () => {
// Codeman owns that container's lifecycle: a recreate or case-delete there
// would destroy the adopted case's container out from under it.
const conflict = classifyAdoptContainerConflict({
container: 'codeman-case-web',
containerWorkdir: '/app/api',
existing: [mk({ name: 'web', container: 'codeman-case-web', owned: true })],
canAccess: mine,
});
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' });
});
it('treats an ABSENT owned flag as owned, so legacy cases keep the old refusal', () => {
const conflict = classifyAdoptContainerConflict({
container: 'legacy',
containerWorkdir: '/app/api',
existing: [mk({ name: 'old', container: 'legacy' })],
canAccess: mine,
});
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'old' });
});
it('derives the container name from the case name when the field is absent', () => {
const conflict = classifyAdoptContainerConflict({
container: dockerContainerName('web'),
containerWorkdir: '/app/api',
existing: [mk({ name: 'web', owned: true })],
canAccess: mine,
});
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' });
});
it('refuses a container another user already adopted', () => {
const conflict = classifyAdoptContainerConflict({
container: 'devbox',
containerWorkdir: '/app/api',
existing: [mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' })],
canAccess: (owner) => owner === 'alice',
});
expect(conflict).toEqual({ kind: 'other-owner', caseName: 'theirs' });
});
it('an owned case outranks a foreign adoption, so the message names the real blocker', () => {
const conflict = classifyAdoptContainerConflict({
container: 'devbox',
containerWorkdir: '/app/api',
existing: [
mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' }),
mk({ name: 'built', container: 'devbox', owned: true }),
],
canAccess: (owner) => owner === 'alice',
});
expect(conflict).toEqual({ kind: 'owned-case', caseName: 'built' });
});
it('leaves an unrelated container alone', () => {
expect(
classifyAdoptContainerConflict({
container: 'fresh',
containerWorkdir: '/app',
existing: [mk({ name: 'web', container: 'devbox', owned: false })],
canAccess: mine,
})
).toBeNull();
});
});