# Multiple servers share one \-\-base-dir and each resumes the others' live threads · Issue \#14115 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/issues/14115)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: completed

Priority: 0

## Description

[Discussion and activity](https://git.cafe/pingdotgg/t3code/issues/14115)

---

## What happened

Continuing an existing Claude thread produced several agents working at once. The
agent reported that multiple copies of the session were running, and the thread
looked like it spanned both old and new threads. First noticed from the iOS app; the
thread below was started from a Tailscale-connected MacBook and ran on a Mac mini.

That machine had four T3 Code servers running against one `--base-dir`. Three of them
independently spawned their own agent process for the same thread.

## Diagnosis

Nothing enforces that one `--base-dir` is served by one server. When several are,
each spawns its own provider session for the same thread, and the user sees one of
them.

Verified by parentage, not inferred. All three of these are live `claude` processes
for one thread on one day, each with a different server as its parent:

```
pid    ppid   started    parent
16705  28646  09:56:40   desktop app (Nightly), port 3776
63219  53721  10:58:03   CLI 0.0.43-nightly.20260928.2375, port 3774
81957  35731  11:17:03   CLI 0.0.43-nightly.20260922.2123, port 3775
```

### How several servers come to share a base dir

Two routes observed, both unguarded.

#### Route 1: the manual update command starts a server

`apps/web/src/versionSkew.ts:117` hands
users whose server cannot update itself:

```ts
/** The command to hand users whose server cannot update itself. */
export function manualServerUpdateCommand(targetVersion: string): string {
  return `npx t3@${targetVersion}`;
}
```

That has no subcommand, and at `apps/server/src/bin.ts:57` the root command runs a
server:

```ts
Command.make("t3", { ...sharedServerCommandFlags }).pipe(
  Command.withDescription("Run the T3 Code server."),
  Command.withHandler((flags) => runServerCommand(flags)),
  Command.withSubcommands([...]),
);
```

So the documented way to update a server starts an additional one instead. Following
it once per nightly built a four-server ladder.

#### Route 2: two launches race and both win

On a second machine (Linux), two servers of the **same** build run against one base
dir, started three seconds apart, both orphaned to PPID 1, with no service unit
installed and no manual update involved. Nothing was updated by hand there, so a fix
aimed only at Route 1 would not reach this machine.

#### Why both routes succeed silently
 In `apps/server/src/cli/config.ts`
(`resolveServerConfig`), web mode resolves an unspecified port with
`findAvailablePort(ServerConfig.DEFAULT_PORT)`, and `packages/shared/src/Net.ts:195`
falls through when that port is busy:

```ts
findAvailablePort: (preferred) =>
  Effect.gen(function* () {
    if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) {
      return preferred;
    }
    return yield* reserveLoopbackPort();
  }),
```

A busy default port is the normal signal that a server already serves this base
directory. It is treated only as a port collision: the new server takes the next free
port and serves the same database, with no warning. I searched `apps/server` and
`packages` for a lock file, port registry or instance id and found none, so the
one-server-per-base-dir assumption appears unenforced rather than enforced-and-broken.

### One mechanism by which a second server adopts a live thread
 In
`apps/server/src/serverRuntimeStartup.ts:535` (`reconcileProviderSessions`):

```ts
const orphanedThreads = threads.filter(
  (thread) =>
    thread.session !== null &&
    (thread.session.status === "starting" ||
      thread.session.status === "running" ||
      thread.session.activeTurnId !== null ||
      (thread.session.status === "ready" && preparedThreadIds.has(thread.id))) &&
    !liveThreadIds.has(thread.id),
);
```

`liveThreadIds` comes from `providerService.listSessions()`, the calling process's own
sessions. Shared state records no server identity for a thread, so a thread genuinely
live in server A is indistinguishable from an orphan when server B looks at it.

This accounts for adoption at server startup. It does not account for every instance I
observed: on the affected thread, four sessions began within 7 milliseconds
(`16:16:52.391/.396/.396/.398`) at a moment when no server was starting. I could not
identify the code path for that and am not going to guess at one.

### The state is not self-correcting

 `apps/server/src/cli/update.ts:601` only warns:

```
A server started by hand is still running at {origin} (pid {pid}).
Stop it and start it again to pick up {targetVersion}.
```

It never stops it, and `findForegroundServer` locates the stray through
`readPersistedServerRuntimeState`, which holds a single pid, so with three strays it
reports at most one. I cleaned this condition up by hand around Sep 11; every server
now running postdates that cleanup.

### Cost

Duplicate resumes are not free: each re-reads the transcript before doing any work.
On the affected thread, 28 session starts cost 2.59M tokens in first-call context
reads (median 88k, worst 186k) against a thread total of 40M, so roughly 6.5% of that
thread's usage went to resumes, most of them unnecessary.

That is real waste, but it is not the headline, and I want to correct an earlier
impression rather than leave it standing: I first attributed a large subscription
quota drain to this bug, and measurement did not support it. The dominant cost of
these threads is ordinary agent work. The argument for fixing this is correctness,
not spend: duplicate agents run real turns, in the same worktree, under
bypassPermissions, invisibly to the user.

## Steps to reproduce

1. Start a server: `t3 serve --base-dir <DIR>` (no `--port`). It binds the default port.
2. Run the command the UI gives for a manual server update, `npx t3@<version>`, or
   start a second server with the same flags. Expected a refusal or a warning; instead
   it silently binds the next free port and serves the same state.
3. Open a thread and submit a turn that runs long enough to still be active a few
   seconds in.
4. Observe more than one provider process for that one turn, with different servers as
   parents (`ps -Ao pid,ppid,command | grep claude`), and clustered `session.started`
   events for the single thread id in
   `<DIR>/userdata/logs/provider/events.<threadId>.log`.

Step 2 need not be deliberate: on one machine it was the UI's own update instruction,
on another two launches three seconds apart did it with no user action.

## Version

Servers `0.0.43-nightly.20260928.2375`, `.20260922.2123`, `.20260921.2058`, and the
T3 Code (Nightly) desktop app. Source analysis against `main` at `d15210cd`.
`npx t3` separately reports 0.0.42, the stable `latest` tag.

## Environment

Three machines, each with its own base dir. The correlation with server count is clean:

| machine | servers on one base dir | threads with clustered session starts |
|---|---|---|
| Mac mini (darwin arm64, Darwin 27.0.0, Node v26.8.2) | 4 | 3 |
| Linux desktop | 2 | 1, predating the current pair |
| MacBook (desktop app only) | 1 | 0 |

The single-server machine is unaffected. The Linux machine's two servers both started
this morning and no turn has been submitted there since, so it is exposed but not yet
exercised.

Provider Claude Code (`claudeAgent`), model `claude-opus-5-5[1m]`,
`permissionMode: bypassPermissions`, `defaultThreadEnvMode: worktree`. Surfaces: iOS
app, a Tailscale-connected MacBook, and the local desktop app.
`continueThreadsAfterServerUpdate` is `true` on both multi-server machines and unset
on the single-server one, so I cannot separate its contribution from server count here.

## Evidence

```shell
# Thread 606f5fd7, session.started timestamps across one day.
# Clusters are tight; the 3.5s group at the top is the reported incident.
14:56:39.236  14:56:40.940  14:56:42.760      3 in 3.5s
14:57:12.950  14:57:12.951                    2 in 1ms
15:58:03.085
16:13:35.920  16:13:50.745  16:13:50.745      2 in the same millisecond
16:16:52.391  16:16:52.396  16:16:52.396  16:16:52.398    4 in 7ms
16:17:03.136

# Same thread, the three claude processes still alive, by parent server:
16705  28646  Sep 28 09:56:40   desktop app (Nightly), port 3776
63219  53721  Sep 28 10:58:03   CLI 0.0.43-nightly.20260928.2375, port 3774
81957  35731  Sep 28 11:17:03   CLI 0.0.43-nightly.20260922.2123, port 3775

# The four servers, one base dir (update-command ladder):
56473  Sep 21  <runtime>/0.0.43-nightly.20260921.2058/t3 serve --port 3773 --base-dir <DIR>
35731  Sep 23  <runtime>/0.0.43-nightly.20260922.2123/t3 serve --port 3775 --base-dir <DIR>
53721  Sep 28  <runtime>/0.0.43-nightly.20260928.2375/t3 serve --port 3774 --base-dir <DIR>
28646  Sep 24  T3 Code (Nightly).app …                                        (port 3776)

# Linux machine: two servers of ONE build, three seconds apart, no user action:
2464729  1  Sep 28 09:20:23  <runtime>/0.0.43-nightly.20260928.2375/t3 serve --port 3773 --base-dir <DIR>
2465796  1  Sep 28 09:20:26  <runtime>/0.0.43-nightly.20260928.2375/t3 serve --port 3774 --base-dir <DIR>
LISTEN 127.0.0.1:3773  pid=2464729
LISTEN 127.0.0.1:3774  pid=2465796

# Worst single incident, Sep 14, a different shape: 10 session.started
# over 67s at even ~10s spacing, more like a restart loop than simultaneity.
16:16:51.709 … 16:17:58.924

# Provider processes orphaned by superseded servers, still alive days later:
 4388  35731  Sep 24 14:52  claude …   (3d 19h)
17524  56473  Sep 24 20:09  claude …   (3d 13h)
51566  56473  Sep 27 01:21  claude …   (1d 08h)
54163  28646  Sep 24 15:10  claude …   (3d 18h)
```

Counting method: a thread is counted only when two or more `session.started` events
fall within 60 seconds of each other. Threads with several starts spread over hours
are normal returns to idle work and are excluded; counting those inflates the numbers
several-fold.

All four servers also interleaved writes into one `server.trace.ndjson`, rotating it
ten times in one day.

## Related issues

#5447 is the nearest existing report and is **not** a duplicate: there the extra
session is a `claude-haiku-4-5` title-generation helper executing the quoted prompt,
spawned by a single server. Here there is no helper: full `claudeAgent` sessions run on
the thread's configured model, spawned by distinct server processes sharing a base
directory. Searches for multi-instance, shared base-dir, stale-server-after-update and
orphan-reconcile phrasings returned no other matches.

## Fix applied or workaround

Stopped the two superseded CLI servers on the four-server machine, leaving the current
nightly CLI and the desktop app's embedded server. That is still two servers on one
base dir, so the condition is reduced, not removed; fully clearing it means not running
the desktop app and a CLI server against the same base dir at all. No source was
patched and no database writes were made.

This is a delay rather than a workaround. I did the same cleanup around Sep 11 and the
ladder rebuilt itself within days, because the update instruction regenerates it.

Suggested directions, in the order I would expect them to help:

- Make `npx t3@<version>` not start a server. Either give the root command a help or
  update handler, or have `manualServerUpdateCommand` emit `npx t3@<version> update`.
- Refuse to start, or attach to the existing server, when another already serves the
  requested `--base-dir`. A busy default port should not be resolved by silently
  choosing another. A lock file in the base dir would also cover the launch race,
  which no fix to the update command can reach.
- Record an owning server identity (pid plus boot id, or a lease in shared state) on
  running sessions, and exclude threads owned by a live server from the orphan
  reconcile.
- Stop the superseded server and its provider children when applying an update, rather
  than printing a warning about one of them.

## Filed by

claude (Opus 5) via `t3 triage`, Claude Code harness.

