Przeglądaj źródła

fix: harden cmux pane lifecycle

highcoldddd 1 miesiąc temu
rodzic
commit
995ced2783

+ 9 - 0
docs/multiplexer-integration.md

@@ -34,6 +34,15 @@ multiple instances. The plugin now reads `ctx.serverUrl` only when checking,
 spawning, or polling, which avoids snapshotting the temporary startup URL; it
 cannot create a listener that OpenCode did not start.
 
+For cmux, a session status that remains missing for more than the 30-second
+grace period is treated as an idle candidate. Closing still requires the pane
+to have been attached for at least 10 seconds, three stable idle-candidate
+checks, and a final status recheck.
+
+If all bounded close attempts and both cooldown retries are exhausted, the pane
+remains tracked as an orphan without a running timer. A later lifecycle for the
+same directory claims it with a fresh, bounded close-attempt budget.
+
 This zsh helper preserves an explicit `--port` and exports the matching
 `OPENCODE_PORT`. Otherwise, it asks Python to select an available loopback port
 and starts OpenCode with that port explicitly:

+ 3 - 0
src/multiplexer/cmux/close-policy.test.ts

@@ -28,6 +28,9 @@ describe('CmuxClosePolicy', () => {
     expect(first).toMatchObject({ phase: 'cooldown', nextAttemptAt: 30_001 });
     const second = policy.failed(first, 30_001);
     expect(second.nextAttemptAt).toBe(90_001);
+    const resumed = policy.resume(first, 30_001);
+    const third = policy.failed(resumed, 30_002);
+    expect(third.nextAttemptAt).toBe(90_002);
     expect(policy.complete()).toBeUndefined();
   });
 });

+ 17 - 1
src/multiplexer/cmux/close-policy.ts

@@ -6,6 +6,7 @@ export interface CmuxCloseIntent {
   deadline: number;
   phase: 'pending' | 'cooldown';
   nextAttemptAt: number;
+  cooldowns: number;
 }
 
 export class CmuxClosePolicy {
@@ -27,6 +28,7 @@ export class CmuxClosePolicy {
         deadline: now + this.budgetMs,
         phase: 'pending',
         nextAttemptAt: now,
+        cooldowns: 0,
       };
     }
     return current;
@@ -37,16 +39,30 @@ export class CmuxClosePolicy {
   failed(intent: CmuxCloseIntent, now: number): CmuxCloseIntent {
     const attempts = intent.attempts + 1;
     if (attempts >= this.maxAttempts || now >= intent.deadline) {
-      const delay = intent.phase === 'cooldown' ? 60_000 : 30_000;
+      const cooldowns = intent.cooldowns + 1;
+      const delay =
+        cooldowns === 1 ? 30_000 : cooldowns === 2 ? 60_000 : Infinity;
       return {
         ...intent,
         attempts,
         phase: 'cooldown',
         nextAttemptAt: now + delay,
+        cooldowns,
       };
     }
     return { ...intent, attempts, nextAttemptAt: now + 1_000 };
   }
+  resume(intent: CmuxCloseIntent, now: number): CmuxCloseIntent {
+    if (intent.phase !== 'cooldown' || now < intent.nextAttemptAt)
+      return intent;
+    return {
+      ...intent,
+      attempts: 0,
+      deadline: now + this.budgetMs,
+      phase: 'pending',
+      nextAttemptAt: now,
+    };
+  }
   complete(): undefined {
     return undefined;
   }

+ 103 - 7
src/multiplexer/cmux/index.test.ts

@@ -154,6 +154,7 @@ describe('CmuxMultiplexer', () => {
     expect(api.closeSurface).toHaveBeenCalledWith(
       'workspace-root',
       'surface-1',
+      '/tmp/cmux.sock',
     );
     expect(await waiting).toEqual({ success: false, error: 'unavailable' });
   });
@@ -179,27 +180,119 @@ describe('CmuxMultiplexer', () => {
     const instance = mux(api);
     const first = await instance.spawnPane('s1', 'one', 'http://server', '/r');
     await instance.spawnPane('s2', 'two', 'http://server', '/r');
-    expect(api.createSurface).toHaveBeenNthCalledWith(1, {
-      workspaceId: 'workspace-root',
-      targetSurfaceId: 'surface-root',
-      direction: 'right',
-      focus: false,
-    });
+    expect(api.createSurface).toHaveBeenNthCalledWith(
+      1,
+      {
+        workspaceId: 'workspace-root',
+        targetSurfaceId: 'surface-root',
+        direction: 'right',
+        focus: false,
+      },
+      '/tmp/cmux.sock',
+    );
     expect(api.createSurface).toHaveBeenNthCalledWith(
       2,
       expect.objectContaining({
         targetSurfaceId: 'surface-1',
         direction: 'down',
       }),
+      '/tmp/cmux.sock',
     );
     await instance.closePane(first.paneId ?? '');
     expect(api.closeSurface).toHaveBeenCalledWith(
       'workspace-root',
       'surface-1',
+      '/tmp/cmux.sock',
     );
     expect(api.equalizeSplits).toHaveBeenCalledTimes(3);
   });
 
+  test('uses the identified socket for every operation after identify', async () => {
+    const calls: string[][] = [];
+    const runner: CommandRunner = {
+      run: mock(async (argv) => {
+        calls.push(argv);
+        if (argv.includes('--version'))
+          return { exitCode: 0, stdout: 'cmux 0.64.17', stderr: '' };
+        if (argv.includes('identify'))
+          return {
+            exitCode: 0,
+            stderr: '',
+            stdout: JSON.stringify({
+              socket_path: '/tmp/socket-a',
+              caller: {
+                workspace_id: 'w',
+                pane_id: 'p',
+                surface_id: 'root',
+              },
+            }),
+          };
+        return {
+          exitCode: 0,
+          stderr: '',
+          stdout: JSON.stringify({ pane_id: 'p2', surface_id: 's2' }),
+        };
+      }),
+    };
+    const previous = process.env.CMUX_SOCKET_PATH;
+    process.env.CMUX_SOCKET_PATH = '/tmp/socket-a';
+    const instance = mux(new CliCmuxClient(runner, '/bin/cmux'));
+    const pane = await instance.spawnPane('s', 'agent', 'http://server', '/r');
+    process.env.CMUX_SOCKET_PATH = '/tmp/socket-b';
+    await instance.closePane(pane.paneId ?? '');
+    const close = calls.find((call) => call.includes('close-surface'));
+    for (const operation of [
+      'new-split',
+      'respawn-pane',
+      'rpc',
+      'close-surface',
+    ]) {
+      const call = calls.find((candidate) => candidate.includes(operation));
+      expect(call?.slice(1, 3)).toEqual(['--socket', '/tmp/socket-a']);
+    }
+    if (previous === undefined) delete process.env.CMUX_SOCKET_PATH;
+    else process.env.CMUX_SOCKET_PATH = previous;
+  });
+
+  test('keeps separate multiplexer instances on their identified sockets', async () => {
+    const first = client();
+    const second = client();
+    first.identify = mock(async () => ({
+      workspaceId: 'w1',
+      paneId: 'p1',
+      surfaceId: 'root1',
+      socketPath: '/tmp/a',
+    }));
+    second.identify = mock(async () => ({
+      workspaceId: 'w2',
+      paneId: 'p2',
+      surfaceId: 'root2',
+      socketPath: '/tmp/b',
+    }));
+    await mux(first).spawnPane('a', 'agent', 'http://server', '/repo');
+    await mux(second).spawnPane('b', 'agent', 'http://server', '/repo');
+    expect(first.createSurface).toHaveBeenCalledWith(
+      expect.any(Object),
+      '/tmp/a',
+    );
+    expect(second.createSurface).toHaveBeenCalledWith(
+      expect.any(Object),
+      '/tmp/b',
+    );
+    expect(first.respawnSurface).toHaveBeenCalledWith(
+      'w1',
+      expect.any(String),
+      expect.any(String),
+      '/tmp/a',
+    );
+    expect(second.respawnSurface).toHaveBeenCalledWith(
+      'w2',
+      expect.any(String),
+      expect.any(String),
+      '/tmp/b',
+    );
+  });
+
   test('serializes concurrent spawns', async () => {
     const api = client();
     const instance = mux(api);
@@ -210,6 +303,7 @@ describe('CmuxMultiplexer', () => {
     expect(api.createSurface).toHaveBeenNthCalledWith(
       2,
       expect.objectContaining({ direction: 'down' }),
+      '/tmp/cmux.sock',
     );
   });
 
@@ -225,6 +319,7 @@ describe('CmuxMultiplexer', () => {
       'workspace-root',
       'surface-1',
       "'/opt/opencode' attach 'http://host/a b;$()' --session 's'\\''$(touch /tmp/no);' --dir '/repo/a b/'\\''quoted'\\'''",
+      '/tmp/cmux.sock',
     );
   });
 
@@ -241,6 +336,7 @@ describe('CmuxMultiplexer', () => {
       'workspace-root',
       'surface-1',
       "'/Users/king/.opencode/bin/opencode' attach 'http://server' --session 's1' --dir '/repo'",
+      '/tmp/cmux.sock',
     );
   });
 
@@ -270,7 +366,7 @@ describe('CmuxMultiplexer', () => {
     );
     expect(result).toEqual({
       success: false,
-      error: 'hard',
+      error: 'unavailable',
       orphanPaneId: expect.stringContaining('cmux:v1:'),
     });
     expect(await mux(api).closePane(result.orphanPaneId ?? '')).toBe(false);

+ 119 - 70
src/multiplexer/cmux/index.ts

@@ -45,26 +45,34 @@ export interface CmuxClient {
   getVersionError?(): 'unavailable' | 'hard';
   identify(): Promise<CmuxIdentity | null>;
   getIdentifyError?(): 'unavailable' | 'hard';
-  createSurface(input: {
-    workspaceId: string;
-    targetSurfaceId: string;
-    direction: 'right' | 'down';
-    focus: false;
-  }): Promise<{ paneId: string; surfaceId: string } | null>;
+  createSurface(
+    input: {
+      workspaceId: string;
+      targetSurfaceId: string;
+      direction: 'right' | 'down';
+      focus: false;
+    },
+    socketPath?: string,
+  ): Promise<{ paneId: string; surfaceId: string } | null>;
   getCreateError?(): 'not_found' | 'unavailable' | 'invalid_state' | 'hard';
   respawnSurface(
     workspaceId: string,
     surfaceId: string,
     command: string,
+    socketPath?: string,
   ): Promise<boolean>;
   closeSurface(
     workspaceId: string,
     surfaceId: string,
+    socketPath?: string,
   ): Promise<'closed' | 'not_found' | 'failed'>;
-  equalizeSplits(params: {
-    workspace_id: string;
-    orientation: 'vertical';
-  }): Promise<boolean>;
+  equalizeSplits(
+    params: {
+      workspace_id: string;
+      orientation: 'vertical';
+    },
+    socketPath?: string,
+  ): Promise<boolean>;
 }
 
 interface Handle {
@@ -178,12 +186,15 @@ export class CmuxMultiplexer implements Multiplexer {
       const previous = registry.agents.at(-1);
       const targetSurfaceId = previous?.surfaceId ?? registry.root.surfaceId;
       const direction = previous ? 'down' : 'right';
-      const created = await this.client.createSurface({
-        workspaceId: root.workspaceId,
-        targetSurfaceId,
-        direction,
-        focus: false,
-      });
+      const created = await this.client.createSurface(
+        {
+          workspaceId: root.workspaceId,
+          targetSurfaceId,
+          direction,
+          focus: false,
+        },
+        root.socketPath,
+      );
       if (!created) {
         log('[cmux] spawnPane failed', {
           sequence,
@@ -219,6 +230,7 @@ export class CmuxMultiplexer implements Multiplexer {
           root.workspaceId,
           created.surfaceId,
           command,
+          root.socketPath,
         );
         if (!started) {
           log('[cmux] spawnPane failed', {
@@ -244,14 +256,19 @@ export class CmuxMultiplexer implements Multiplexer {
         const cleaned = await this.cleanupPane(
           root.workspaceId,
           created.surfaceId,
+          root.socketPath,
         );
         return cleaned
-          ? { success: false, error: 'hard' }
-          : { success: false, error: 'hard', orphanPaneId: encodedHandle };
+          ? { success: false, error: 'unavailable' }
+          : {
+              success: false,
+              error: 'unavailable',
+              orphanPaneId: encodedHandle,
+            };
       }
 
       registry.agents.push(handle);
-      await this.equalize(root.workspaceId);
+      await this.equalize(root.workspaceId, root.socketPath);
       return { success: true, paneId: encodeHandle(handle) };
     });
   }
@@ -264,6 +281,7 @@ export class CmuxMultiplexer implements Multiplexer {
       const result = await this.client.closeSurface(
         handle.workspaceId,
         handle.surfaceId,
+        handle.socketPath,
       );
       if (result === 'failed') return false;
       const key = registryKey(handle.socketPath, handle.workspaceId);
@@ -275,7 +293,7 @@ export class CmuxMultiplexer implements Multiplexer {
         if (index >= 0) registry.agents.splice(index, 1);
         if (registry.agents.length === 0) registries.delete(key);
       }
-      await this.equalize(handle.workspaceId);
+      await this.equalize(handle.workspaceId, handle.socketPath);
       return true;
     });
   }
@@ -290,9 +308,14 @@ export class CmuxMultiplexer implements Multiplexer {
   private async cleanupPane(
     workspaceId: string,
     surfaceId: string,
+    socketPath: string,
   ): Promise<boolean> {
     try {
-      const result = await this.client.closeSurface(workspaceId, surfaceId);
+      const result = await this.client.closeSurface(
+        workspaceId,
+        surfaceId,
+        socketPath,
+      );
       if (result === 'failed') {
         log('[cmux] failed to close pre-respawn surface', {
           workspaceId,
@@ -348,12 +371,18 @@ export class CmuxMultiplexer implements Multiplexer {
     return false;
   }
 
-  private async equalize(workspaceId: string): Promise<void> {
+  private async equalize(
+    workspaceId: string,
+    socketPath: string,
+  ): Promise<void> {
     try {
-      const success = await this.client.equalizeSplits({
-        workspace_id: workspaceId,
-        orientation: 'vertical',
-      });
+      const success = await this.client.equalizeSplits(
+        {
+          workspace_id: workspaceId,
+          orientation: 'vertical',
+        },
+        socketPath,
+      );
       if (!success)
         log('[cmux] workspace.equalize_splits failed', { workspaceId });
     } catch (error) {
@@ -462,26 +491,31 @@ export class CliCmuxClient implements CmuxClient {
     return this.identifyError;
   }
 
-  async createSurface(input: {
-    workspaceId: string;
-    targetSurfaceId: string;
-    direction: 'right' | 'down';
-    focus: false;
-  }): Promise<{ paneId: string; surfaceId: string } | null> {
+  async createSurface(
+    input: {
+      workspaceId: string;
+      targetSurfaceId: string;
+      direction: 'right' | 'down';
+      focus: false;
+    },
+    socketPath?: string,
+  ): Promise<{ paneId: string; surfaceId: string } | null> {
     this.createError = 'hard';
-    const result = await this.run([
-      '--json',
-      '--id-format',
-      'uuids',
-      'new-split',
-      input.direction,
-      '--workspace',
-      input.workspaceId,
-      '--surface',
-      input.targetSurfaceId,
-      '--focus',
-      'false',
-    ]);
+    const result = await this.run(
+      withSocket(socketPath, [
+        '--json',
+        '--id-format',
+        'uuids',
+        'new-split',
+        input.direction,
+        '--workspace',
+        input.workspaceId,
+        '--surface',
+        input.targetSurfaceId,
+        '--focus',
+        'false',
+      ]),
+    );
     if (!result || result.exitCode !== 0) {
       this.createError = this.lastRunThrew
         ? 'unavailable'
@@ -516,30 +550,36 @@ export class CliCmuxClient implements CmuxClient {
     workspaceId: string,
     surfaceId: string,
     command: string,
+    socketPath?: string,
   ): Promise<boolean> {
-    const result = await this.run([
-      'respawn-pane',
-      '--workspace',
-      workspaceId,
-      '--surface',
-      surfaceId,
-      '--command',
-      command,
-    ]);
+    const result = await this.run(
+      withSocket(socketPath, [
+        'respawn-pane',
+        '--workspace',
+        workspaceId,
+        '--surface',
+        surfaceId,
+        '--command',
+        command,
+      ]),
+    );
     return result?.exitCode === 0;
   }
 
   async closeSurface(
     workspaceId: string,
     surfaceId: string,
+    socketPath?: string,
   ): Promise<'closed' | 'not_found' | 'failed'> {
-    const result = await this.run([
-      'close-surface',
-      '--workspace',
-      workspaceId,
-      '--surface',
-      surfaceId,
-    ]);
+    const result = await this.run(
+      withSocket(socketPath, [
+        'close-surface',
+        '--workspace',
+        workspaceId,
+        '--surface',
+        surfaceId,
+      ]),
+    );
     if (result?.exitCode === 0) return 'closed';
     return result?.stderr.toLowerCase().includes('not_found') ||
       result?.stderr.toLowerCase().includes('not found')
@@ -547,15 +587,20 @@ export class CliCmuxClient implements CmuxClient {
       : 'failed';
   }
 
-  async equalizeSplits(params: {
-    workspace_id: string;
-    orientation: 'vertical';
-  }): Promise<boolean> {
-    const result = await this.run([
-      'rpc',
-      'workspace.equalize_splits',
-      JSON.stringify(params),
-    ]);
+  async equalizeSplits(
+    params: {
+      workspace_id: string;
+      orientation: 'vertical';
+    },
+    socketPath?: string,
+  ): Promise<boolean> {
+    const result = await this.run(
+      withSocket(socketPath, [
+        'rpc',
+        'workspace.equalize_splits',
+        JSON.stringify(params),
+      ]),
+    );
     return result?.exitCode === 0;
   }
 
@@ -649,6 +694,10 @@ function commandOperation(args: string[]): string {
   );
 }
 
+function withSocket(socketPath: string | undefined, args: string[]): string[] {
+  return socketPath ? ['--socket', socketPath, ...args] : args;
+}
+
 function safeSummary(value: string): string {
   const trimmed = value.trim();
   return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;

+ 234 - 18
src/multiplexer/cmux/session-lifecycle.test.ts

@@ -1,6 +1,7 @@
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
 import type { Multiplexer } from '../types';
 import { CmuxSessionLifecycle } from './session-lifecycle';
+import { CmuxClosePolicy } from './close-policy';
 import { CmuxSessionStore } from './session-state';
 
 function deferred<T>() {
@@ -24,36 +25,35 @@ describe('CmuxSessionLifecycle races', () => {
   const store = new CmuxSessionStore();
   beforeEach(() => store.resetForTests());
 
-  test('in-flight activity automatically respawns after idle close succeeds', async () => {
+  test('coordinator completion still requires lifetime and three stable idle polls', async () => {
     const mux = multiplexer();
-    const close = deferred<boolean>();
-    mux.closePane.mockImplementationOnce(() => close.promise);
+    let now = 0;
     const lifecycle = new CmuxSessionLifecycle(
       'owner',
       mux,
       () => 'http://server',
       '/repo',
       undefined,
-      { isServerRunning: async () => true },
+      {
+        now: () => now,
+        isServerRunning: async () => true,
+        fetchStatuses: async () => ({ s: { type: 'idle' } }),
+      },
     );
     await lifecycle.onSessionCreated({
       type: 'session.created',
       properties: { info: { id: 's', parentID: 'p' } },
     });
-    const closing = lifecycle.closeSessionFromCoordinator('s');
-    await lifecycle.onSessionStatus({
-      type: 'session.status',
-      properties: { sessionID: 's', status: { type: 'busy' } },
-    });
-    close.resolve(true);
-    await closing;
-    expect(mux.spawnPane).toHaveBeenCalledTimes(2);
-    expect(store.get('s')).toMatchObject({
-      paneId: 'pane',
-      spawnState: 'attached',
-      lifecycle: 'active',
-      owner: 'owner',
-    });
+    await lifecycle.closeSessionFromCoordinator('s');
+    await lifecycle.pollOnce();
+    expect(mux.closePane).not.toHaveBeenCalled();
+    now = 10_000;
+    await lifecycle.pollOnce();
+    await lifecycle.pollOnce();
+    expect(mux.closePane).not.toHaveBeenCalled();
+    await lifecycle.pollOnce();
+    expect(mux.closePane).toHaveBeenCalledTimes(1);
+    await lifecycle.cleanup();
   });
 
   test('dispose gate closes a late successful spawn and never marks it active', async () => {
@@ -124,4 +124,220 @@ describe('CmuxSessionLifecycle races', () => {
       paneId: 'orphan-pane',
     });
   });
+
+  test('terminal tracked orphan gets a fresh close budget when claimed', async () => {
+    const policy = new CmuxClosePolicy(1, 1);
+    let intent = policy.request('cleanup', 0, 0);
+    intent = policy.failed(intent, 1);
+    intent = policy.failed(policy.resume(intent, 30_001), 30_002);
+    intent = policy.failed(policy.resume(intent, 90_002), 90_003);
+    store.claimCreated({
+      session: 'spent',
+      owner: 'old',
+      parent: 'p',
+      title: 'agent',
+      directory: '/repo',
+      paneId: 'pane',
+      spawnState: 'attached',
+      lifecycle: 'orphaned',
+      lastActivityAt: 0,
+      activityVersion: 0,
+      idleConsecutive: 0,
+      closeIntent: intent,
+    });
+    const mux = multiplexer();
+    mux.closePane.mockResolvedValue(false);
+    new CmuxSessionLifecycle(
+      'new',
+      mux,
+      () => 'http://server',
+      '/repo',
+      undefined,
+      {
+        closeRetryMaxAttempts: 1,
+      },
+    );
+    await Promise.resolve();
+    expect(mux.closePane).toHaveBeenCalledTimes(1);
+    expect(store.get('spent')?.owner).toBe('new');
+    expect(store.get('spent')?.closeIntent?.nextAttemptAt).not.toBe(Infinity);
+  });
+
+  test('busy activity cancels idle close during the first cooldown', async () => {
+    let now = 0;
+    const mux = multiplexer();
+    mux.closePane.mockResolvedValue(false);
+    const lifecycle = new CmuxSessionLifecycle(
+      'owner',
+      mux,
+      () => 'http://server',
+      '/repo',
+      undefined,
+      {
+        now: () => now,
+        delay: () => new Promise(() => {}),
+        closeRetryMaxAttempts: 1,
+        isServerRunning: async () => true,
+        fetchStatuses: async () => ({ active: { type: 'idle' } }),
+      },
+    );
+    await lifecycle.onSessionCreated({
+      type: 'session.created',
+      properties: { info: { id: 'active', parentID: 'p' } },
+    });
+    now = 10_000;
+    await lifecycle.pollOnce();
+    await lifecycle.pollOnce();
+    await lifecycle.pollOnce();
+    expect(store.get('active')).toMatchObject({
+      lifecycle: 'active',
+      closeIntent: { phase: 'cooldown', cooldowns: 1 },
+    });
+    await lifecycle.onSessionStatus({
+      type: 'session.status',
+      properties: { sessionID: 'active', status: { type: 'busy' } },
+    });
+    expect(store.get('active')).toMatchObject({ lifecycle: 'active' });
+    expect(store.get('active')?.closeIntent).toBeUndefined();
+    expect(store.get('active')?.closeTimer).toBeUndefined();
+  });
+
+  for (const result of [true, false]) {
+    test(`old owner close ${result ? 'success' : 'failure'} cannot mutate a claimed orphan`, async () => {
+      store.claimCreated({
+        session: 'race',
+        owner: 'old',
+        parent: 'p',
+        title: 'agent',
+        directory: '/repo',
+        paneId: 'pane',
+        spawnState: 'attached',
+        lifecycle: 'orphaned',
+        lastActivityAt: 0,
+        activityVersion: 0,
+        idleConsecutive: 0,
+      });
+      const oldMux = multiplexer();
+      const close = deferred<boolean>();
+      oldMux.closePane.mockImplementationOnce(() => close.promise);
+      new CmuxSessionLifecycle('old', oldMux, () => 'http://server', '/repo');
+      await Promise.resolve();
+      const newMux = multiplexer();
+      newMux.closePane.mockResolvedValue(false);
+      new CmuxSessionLifecycle(
+        'new',
+        newMux,
+        () => 'http://server',
+        '/repo',
+        undefined,
+        {
+          closeRetryMaxAttempts: 1,
+        },
+      );
+      await Promise.resolve();
+      const currentIntent = store.get('race')?.closeIntent;
+      close.resolve(result);
+      await Promise.resolve();
+      await Promise.resolve();
+      expect(store.get('race')).toMatchObject({ owner: 'new', paneId: 'pane' });
+      expect(store.get('race')?.closeIntent).toBe(currentIntent);
+    });
+  }
+
+  for (const result of [true, false]) {
+    test(`cleanup close ${result ? 'success' : 'failure'} cannot mutate a newly claimed record`, async () => {
+      store.claimCreated({
+        session: 'cleanup-race',
+        owner: 'old',
+        parent: 'p',
+        title: 'agent',
+        directory: '/repo',
+        paneId: 'pane',
+        spawnState: 'attached',
+        lifecycle: 'active',
+        lastActivityAt: 0,
+        activityVersion: 0,
+        idleConsecutive: 0,
+      });
+      const oldMux = multiplexer();
+      const close = deferred<boolean>();
+      oldMux.closePane.mockImplementationOnce(() => close.promise);
+      const old = new CmuxSessionLifecycle(
+        'old',
+        oldMux,
+        () => 'http://server',
+        '/repo',
+        undefined,
+        { delay: async () => {} },
+      );
+      const cleaning = old.cleanup();
+      await Promise.resolve();
+      store.markOrphaned('cleanup-race');
+      const newMux = multiplexer();
+      newMux.closePane.mockResolvedValue(false);
+      new CmuxSessionLifecycle(
+        'new',
+        newMux,
+        () => 'http://server',
+        '/repo',
+        undefined,
+        { closeRetryMaxAttempts: 1 },
+      );
+      await Promise.resolve();
+      const currentIntent = store.get('cleanup-race')?.closeIntent;
+      close.resolve(result);
+      await cleaning;
+      expect(store.get('cleanup-race')).toMatchObject({
+        owner: 'new',
+        paneId: 'pane',
+      });
+      expect(store.get('cleanup-race')?.closeIntent).toBe(currentIntent);
+    });
+  }
+
+  test('late spawn does not overwrite a record claimed by a new owner', async () => {
+    const oldMux = multiplexer();
+    const spawn = deferred<{ success: true; paneId: string }>();
+    oldMux.spawnPane.mockImplementationOnce(() => spawn.promise);
+    oldMux.closePane.mockResolvedValue(false);
+    const old = new CmuxSessionLifecycle(
+      'old',
+      oldMux,
+      () => 'http://server',
+      '/repo',
+      undefined,
+      { delay: async () => {}, isServerRunning: async () => true },
+    );
+    const creating = old.onSessionCreated({
+      type: 'session.created',
+      properties: { info: { id: 'late-race', parentID: 'p' } },
+    });
+    await Promise.resolve();
+    await old.cleanup();
+    store.markOrphaned('late-race');
+    const existing = store.get('late-race');
+    if (existing) existing.paneId = 'new-pane';
+    const newMux = multiplexer();
+    newMux.closePane.mockResolvedValue(false);
+    new CmuxSessionLifecycle(
+      'new',
+      newMux,
+      () => 'http://server',
+      '/repo',
+      undefined,
+      { closeRetryMaxAttempts: 1 },
+    );
+    await Promise.resolve();
+    const currentIntent = store.get('late-race')?.closeIntent;
+    spawn.resolve({ success: true, paneId: 'old-late-pane' });
+    await creating;
+    expect(store.get('late-race')).toMatchObject({
+      owner: 'new',
+      paneId: 'new-pane',
+    });
+    expect(store.get('late-race')?.closeIntent).toBe(currentIntent);
+    expect(
+      store.ownedBy('old').some((record) => record.paneId === 'old-late-pane'),
+    ).toBe(true);
+  });
 });

+ 94 - 17
src/multiplexer/cmux/session-lifecycle.ts

@@ -105,8 +105,15 @@ export class CmuxSessionLifecycle {
     this.serverCheck = options.isServerRunning ?? isServerRunning;
     this.fetchStatuses = options.fetchStatuses ?? (() => this.loadStatuses());
     for (const orphan of this.store.claimOrphans(owner, defaultDirectory)) {
-      orphan.closeIntent = undefined;
-      void this.requestClose(orphan, 'cleanup');
+      if (
+        orphan.closeIntent?.phase === 'cooldown' &&
+        Number.isFinite(orphan.closeIntent.nextAttemptAt)
+      ) {
+        this.scheduleCooldown(orphan);
+      } else {
+        orphan.closeIntent = undefined;
+        void this.requestClose(orphan, 'cleanup');
+      }
     }
   }
 
@@ -184,8 +191,7 @@ export class CmuxSessionLifecycle {
   async closeSessionFromCoordinator(session: string): Promise<void> {
     if (this.disposed) return;
     const record = this.store.get(session);
-    if (record?.paneId && record.owner === this.owner)
-      await this.requestClose(record, 'idle');
+    if (record?.paneId && record.owner === this.owner) this.startPolling();
   }
 
   cleanup(): Promise<void> {
@@ -352,6 +358,12 @@ export class CmuxSessionLifecycle {
       this.store.get(record.session) !== record
     )
       return;
+    if (intent.phase === 'cooldown' && this.now() < intent.nextAttemptAt) {
+      this.scheduleCooldown(record);
+      return;
+    }
+    record.closeIntent = this.policy.resume(intent, this.now());
+    if (record.closeIntent !== intent) return this.attemptClose(record);
     if (
       intent.reason === 'idle' &&
       intent.expectedActivityVersion !== record.activityVersion
@@ -363,6 +375,13 @@ export class CmuxSessionLifecycle {
     try {
       closed = await this.multiplexer.closePane(record.paneId);
     } catch {}
+    if (
+      this.disposed ||
+      this.store.get(record.session) !== record ||
+      record.owner !== this.owner ||
+      record.closeIntent !== intent
+    )
+      return;
     const intentStillCurrent = record.closeIntent === intent;
     const idleStillCurrent =
       intent.reason !== 'idle' ||
@@ -391,8 +410,9 @@ export class CmuxSessionLifecycle {
     if (!intentStillCurrent || !idleStillCurrent) return;
     record.closeIntent = this.policy.failed(intent, this.now());
     if (record.closeIntent.phase === 'cooldown') {
-      this.store.markOrphaned(record.session);
-      record.closeTimer = undefined;
+      if (!Number.isFinite(record.closeIntent.nextAttemptAt))
+        this.store.markOrphaned(record.session);
+      this.scheduleCooldown(record);
       return;
     }
     record.closeTimer?.cancel();
@@ -402,6 +422,18 @@ export class CmuxSessionLifecycle {
     );
   }
 
+  private scheduleCooldown(record: CmuxSessionRecord): void {
+    record.closeTimer?.cancel();
+    record.closeTimer = undefined;
+    const intent = record.closeIntent;
+    if (!intent || !Number.isFinite(intent.nextAttemptAt) || this.disposed)
+      return;
+    record.closeTimer = this.timer(
+      () => this.attemptClose(record),
+      Math.max(0, intent.nextAttemptAt - this.now()),
+    );
+  }
+
   private async poll(): Promise<void> {
     if (this.polling || this.disposed) return;
     this.polling = true;
@@ -411,14 +443,14 @@ export class CmuxSessionLifecycle {
         if (!record.paneId || record.lifecycle !== 'active') continue;
         const status = statuses[record.session];
         if (!status) {
-          record.idleConsecutive = 0;
           record.statusMissingSince ??= this.now();
-          if (this.now() - record.statusMissingSince >= this.missingGraceMs)
-            await this.requestClose(record, 'idle');
-          continue;
+          if (this.now() - record.statusMissingSince < this.missingGraceMs) {
+            record.idleConsecutive = 0;
+            continue;
+          }
         }
-        record.statusMissingSince = undefined;
-        if (status.type !== 'idle') {
+        if (status) record.statusMissingSince = undefined;
+        if (status && status.type !== 'idle') {
           this.activity(record.session);
           continue;
         }
@@ -434,7 +466,10 @@ export class CmuxSessionLifecycle {
         const version = record.activityVersion;
         const final = await this.fetchStatuses();
         if (
-          final[record.session]?.type === 'idle' &&
+          (final[record.session]?.type === 'idle' ||
+            (!final[record.session] &&
+              record.statusMissingSince !== undefined &&
+              this.now() - record.statusMissingSince >= this.missingGraceMs)) &&
           version === record.activityVersion
         )
           await this.requestClose(record, 'idle');
@@ -492,12 +527,21 @@ export class CmuxSessionLifecycle {
         record.activityVersion,
         this.now(),
       );
-      while (record.closeIntent?.phase === 'pending') {
+      while (
+        record.closeIntent?.phase === 'pending' &&
+        this.store.get(record.session) === record &&
+        record.owner === this.owner
+      ) {
         await this.attemptCloseWithoutTimer(record);
         if (record.closeIntent?.phase === 'pending')
           await this.delay(this.closeRetryMs);
       }
-      if (record.closeIntent) this.store.markOrphaned(record.session);
+      if (
+        record.closeIntent &&
+        this.store.get(record.session) === record &&
+        record.owner === this.owner
+      )
+        this.store.markOrphaned(record.session);
     }
   }
 
@@ -506,10 +550,18 @@ export class CmuxSessionLifecycle {
   ): Promise<void> {
     const intent = record.closeIntent;
     if (!intent || !record.paneId) return;
+    const paneId = record.paneId;
     let closed = false;
     try {
-      closed = await this.multiplexer.closePane(record.paneId);
+      closed = await this.multiplexer.closePane(paneId);
     } catch {}
+    if (
+      this.store.get(record.session) !== record ||
+      record.owner !== this.owner ||
+      record.closeIntent !== intent ||
+      record.paneId !== paneId
+    )
+      return;
     if (closed) {
       record.closeIntent = undefined;
       this.store.removeAfterConfirmedClose(record.session);
@@ -531,9 +583,16 @@ export class CmuxSessionLifecycle {
     paneId: string,
   ): Promise<void> {
     const existing = this.store.get(source.session);
+    if (existing && existing.owner !== this.owner) {
+      let closed = false;
+      try {
+        closed = await this.multiplexer.closePane(paneId);
+      } catch {}
+      if (!closed) this.trackStalePane(source, paneId);
+      return;
+    }
     const record = existing ?? source;
     if (!existing) this.store.claimCreated(record);
-    record.owner = this.owner;
     record.paneId = paneId;
     record.spawnState = 'attached';
     record.lifecycle = 'orphaned';
@@ -549,6 +608,24 @@ export class CmuxSessionLifecycle {
     }
   }
 
+  private trackStalePane(source: CmuxSessionRecord, paneId: string): void {
+    const session = `${source.session}\0late\0${paneId}`;
+    this.store.claimCreated({
+      session,
+      owner: this.owner,
+      parent: source.parent,
+      title: source.title,
+      directory: source.directory,
+      paneId,
+      spawnState: 'attached',
+      lifecycle: 'orphaned',
+      attachedAt: this.now(),
+      lastActivityAt: source.lastActivityAt,
+      activityVersion: source.activityVersion,
+      idleConsecutive: 0,
+    });
+  }
+
   private eventSession(event: CmuxSessionEvent): string | undefined {
     return (
       event.properties?.sessionID ??

+ 3 - 6
src/multiplexer/session-manager.test.ts

@@ -1549,12 +1549,7 @@ describe('MultiplexerSessionManager', () => {
         properties: { sessionID: 'busy-url', status: { type: 'busy' } },
       });
 
-      expect(mockMultiplexer.spawnPane).toHaveBeenLastCalledWith(
-        'busy-url',
-        'Subagent',
-        'http://127.0.0.1:63872/',
-        '/test/directory',
-      );
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
     });
 
     test('each poll resolves the latest URL', async () => {
@@ -2108,6 +2103,8 @@ describe('MultiplexerSessionManager', () => {
       await (manager as any).pollSessions();
       now += 30;
       await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
       expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
     });