Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .t3-turbo/customizations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,91 @@
]
}
]
},
{
"id": "startup-load-shedding",
"status": "implemented",
"summary": "Connection setup no longer competes with background repository work. Measured on 0.0.49 with 28 projects / 125 threads / 72 worktrees, trivial local HTTP GETs took 3-8.5s and the client's 15s CONNECTION_ESTABLISHMENT_TIMEOUT failed repeatedly. Four fork-owned changes: VcsStatusBroadcaster bounds automatic remote refreshes with a REMOTE_STATUS_REFRESH_CONCURRENCY=3 semaphore and holds them for a REMOTE_STATUS_STARTUP_GRACE of 90s after build (local status and user-triggered refresh unaffected, failure backoff unchanged); ThreadSettlementReactor drops its sweep fan-out from 8 to SETTLEMENT_SWEEP_CONCURRENCY=2 and gates the periodic sweep behind a 5-minute boot delay plus a 10-minute floor, while a settings change still sweeps at once; @t3tools/shared/shell caches explicit-path resolutions (hits only) in the shared CommandResolutionCache, memoizes the synchronous Windows spawn resolver, and drops the per-probe shell.isExecutableFile span; and ws.loadServerConfig reads editors through ExternalLauncher.availableEditorsSnapshot, which answers from the 60s cache or returns [] and warms on a detached fiber instead of blocking the snapshot on a PATH walk over every known editor.",
"checks": [
{
"path": "apps/server/src/vcs/VcsStatusBroadcaster.ts",
"markers": [
"REMOTE_STATUS_REFRESH_CONCURRENCY",
"REMOTE_STATUS_STARTUP_GRACE",
"RemoteStatusStartupGrace",
"remoteRefreshPermits",
"remainingStartupGrace"
]
},
{
"path": "apps/server/src/vcs/VcsStatusBroadcaster.test.ts",
"markers": [
"holds automatic remote refresh for the startup grace",
"caps how many automatic remote refreshes run at once"
]
},
{
"path": "apps/server/src/orchestration/ThreadSettlementReactor.ts",
"markers": [
"SETTLEMENT_SWEEP_CONCURRENCY",
"SETTLEMENT_SWEEP_MIN_INTERVAL",
"SETTLEMENT_SWEEP_BOOT_DELAY",
"claimAutomaticSweep",
"SweepTrigger"
]
},
{
"path": "apps/server/src/orchestration/ThreadSettlementReactor.test.ts",
"markers": [
"holds automatic sweeps until the boot delay elapses",
"throttles automatic sweeps to the minimum interval",
"sweeps on a settings change without waiting out the boot delay"
]
},
{
"path": "packages/shared/src/shell.ts",
"markers": [
"COMMAND_RESOLUTION_EXPLICIT_PATH_KEY",
"SPAWN_EXECUTABLE_CACHE_TTL_NANOS",
"spawnExecutableCache",
"scanSpawnExecutableWithNode"
]
},
{
"path": "packages/shared/src/shell.test.ts",
"markers": [
"serves a repeat explicit-path resolve without stating again",
"does not cache an explicit path that is missing",
"keys cached explicit paths per command",
"re-probes an explicit path once the 30s TTL expires"
]
},
{
"path": "apps/server/src/process/externalLauncher.ts",
"markers": [
"availableEditorsSnapshot",
"warmAvailableEditors",
"freshCachedEditors",
"EMPTY_AVAILABLE_EDITORS"
]
},
{
"path": "apps/server/src/process/externalLauncher.test.ts",
"markers": [
"answers a cold editor snapshot immediately without scanning inline",
"serves the discovered editors once the backgrounded warm completes",
"collapses a burst of cold snapshots into a single scan"
]
},
{
"path": "apps/server/src/ws.ts",
"markers": ["availableEditorsSnapshot"]
},
{
"path": "apps/server/src/server.test.ts",
"markers": ["returns server config without waiting on a cold editor scan"]
}
]
}
]
}
57 changes: 57 additions & 0 deletions SEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,63 @@ On a nightly-sync conflict: take upstream's file, then re-add `ProjectionApplyCo
`shouldRefreshThreadShellSummary` gate), `runProjectorBatch`, and the paging `bootstrapProjector`.
Both test files must pass.

## Startup load shedding (`startup-load-shedding`)

Background repository work used to run flat out while a client was still connecting, and on a large
install it starved the event loop the connection setup itself needs. Measured live on 0.0.49, one
backend, 28 projects / 125 threads / 72 live worktrees:

| Symptom | Measurement (90s trace) |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Trivial local HTTP GET | 3-8.5s, so the client's 15s `CONNECTION_ESTABLISHMENT_TIMEOUT` (`packages/client-runtime/src/connection/supervisor.ts`) failed repeatedly |
| `VcsStatusBroadcaster.refreshRemoteStatus` | ~25 concurrent spans, 24-30s each, from `concurrency: "unbounded"` and one poller per worktree |
| `ThreadSettlementReactor.sweep` | 33s, `concurrency: 8`, one `gh pr list` per unsettled thread, re-run every minute |
| `checkClaudeProviderStatus` / `discoverClaudeSkills` | 30s / 25s |
| `shell` command resolution | 572 `runGitCommand` + 173 `shell.resolveSpawnCommand`, ~15k synchronous `shell.isExecutableFile` stats per trace rotation |
| `loadServerConfig` (`apps/server/src/ws.ts`) | `resolveAvailableEditors` on every `subscribeServerConfig` snapshot: 21 editors x PATHEXT x PATH, up to the 5s `CONFIG_DISCOVERY_TIMEOUT` |

The fix sheds load at four points and changes no product behavior:

- **`apps/server/src/vcs/VcsStatusBroadcaster.ts`** - automatic remote refreshes take a permit from a
`Semaphore.make(REMOTE_STATUS_REFRESH_CONCURRENCY)` (3), and `remainingStartupGrace` holds them for
`REMOTE_STATUS_STARTUP_GRACE` (90s) after the broadcaster is built, returning the remaining grace as
the poller's next delay so nothing is dropped. Local status, `getStatus`, and `refreshStatus` are
untouched, as is the exponential failure backoff. `RemoteStatusStartupGrace` is a
`Context.Reference` so tests can zero the grace.
- **`apps/server/src/orchestration/ThreadSettlementReactor.ts`** - `SETTLEMENT_SWEEP_CONCURRENCY` (2)
replaces the fan-out of 8, and `claimAutomaticSweep` gates the periodic sweep behind
`SETTLEMENT_SWEEP_BOOT_DELAY` (5 min) and `SETTLEMENT_SWEEP_MIN_INTERVAL` (10 min). The worker
payload is a `SweepTrigger`; only `"periodic"` is throttled, so a settings change still sweeps
immediately. Settlement semantics (`ThreadSettlementPolicy`) are unchanged.
- **`packages/shared/src/shell.ts`** - the explicit-path branch of `resolveCommandPathForPlatform`
now reads and writes the shared `CommandResolutionCache` under
`COMMAND_RESOLUTION_EXPLICIT_PATH_KEY`, storing hits only so a just-written binary is never masked
by a stale negative; `resolveSpawnExecutableWithNode` memoizes its synchronous scan
(`spawnExecutableCache`, `scanSpawnExecutableWithNode`, hits only, 30s, keyed on
platform + PATH + PATHEXT + command); and `isExecutableFile` is no longer an `Effect.fn`, so one
span per resolution replaces tens of thousands per connect.
- **`apps/server/src/process/externalLauncher.ts` + `apps/server/src/ws.ts`** -
`availableEditorsSnapshot` answers from the existing 60s `editorDiscoveryCache` when it is fresh and
otherwise returns `[]` at once while `warmAvailableEditors` (semaphore-deduped) fills the cache on a
detached fiber. Because the warm is detached it cannot be interrupted by a client timeout, which is
what previously left the cache cold on every connect. Accepted trade-off: on a genuinely cold cache
the first snapshot advertises no editors and they appear on the next one - there is no full-config
re-emit hook on `subscribeServerConfig` (only per-field deltas), and adding one would need a
contract change across web, desktop, and mobile.

Related upstream work: pingdotgg/t3code#7231 and #7233.

Deliberately **not** done: switching the desktop LAN bind from `0.0.0.0` to a dual-stack `::`
(`apps/desktop/src/backend/DesktopServerExposure.ts`). It is unrelated to the measured CPU
starvation, `listen("::")` hard-fails on hosts with IPv6 disabled where `0.0.0.0` always works, and
the WSL backend's wildcard bind carries its own documented forwarding rationale.

On a nightly-sync conflict: take upstream's file, then re-add the named constants above and their
call sites. The two easy regressions are upstream reverting a `concurrency` back to `"unbounded"`/`8`
and `loadServerConfig` going back to `resolveAvailableEditorsForConfig(resolveAvailableEditors())` -
`resolveAvailableEditorsForConfig` is still used for `remoteOpenTargets`, so its presence is not
evidence the seam survived.

## Nightly sync conflicts

Resolve against the new upstream file first, then reapply only the behavior above; never take the
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.0.49",
"version": "0.0.50",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3",
"version": "0.0.49",
"version": "0.0.50",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
105 changes: 103 additions & 2 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";

const NOW = "2026-08-28T12:00:00.000Z";
/** Mirrors `SETTLEMENT_SWEEP_BOOT_DELAY`: automatic sweeps stay quiet this long. */
const SWEEP_BOOT_DELAY = "5 minutes";
const PROJECT_ID = ProjectId.make("settlement-project");
const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");

Expand Down Expand Up @@ -273,6 +275,9 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
activation: Deferred.Deferred<void>,
snapshotReads: Queue.Queue<number>,
) {
// Automatic sweeps are muted for the boot delay. Step past it before starting
// so the periodic tick fired by `start()` is allowed to sweep straight away.
yield* TestClock.adjust(SWEEP_BOOT_DELAY);
yield* reactor.start();
yield* Deferred.succeed(activation, undefined);
yield* Queue.take(snapshotReads);
Expand Down Expand Up @@ -316,6 +321,7 @@ describe("ThreadSettlementReactor", () => {

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* TestClock.adjust(SWEEP_BOOT_DELAY);
yield* reactor.start();
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);

Expand Down Expand Up @@ -350,15 +356,17 @@ describe("ThreadSettlementReactor", () => {
),
);

it.effect("reevaluates inactivity and pull request state once per minute", () =>
it.effect("reevaluates inactivity and pull request state on the next automatic sweep", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const pullRequest = yield* Ref.make<"open" | "merged">("open");
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
// Exactly the inactivity threshold at the first sweep, so it must not
// settle then, and past it by the time the next sweep runs.
makeThread("at-boundary", {
latestUserMessageAt: "2026-08-25T12:00:00.000Z",
latestUserMessageAt: "2026-08-25T12:05:00.000Z",
}),
makeThread("open-pr", {
branch: "saved-feature",
Expand All @@ -375,6 +383,12 @@ describe("ThreadSettlementReactor", () => {
assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);

yield* Ref.set(pullRequest, "merged");
// One periodic tick fires per clock adjustment, so cross the minimum
// interval in two steps: the ticks before it must not sweep.
yield* TestClock.adjust("9 minutes");
yield* reactor.drain;
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 1);

yield* TestClock.adjust("1 minute");
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
Expand Down Expand Up @@ -431,6 +445,7 @@ describe("ThreadSettlementReactor", () => {

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* TestClock.adjust(SWEEP_BOOT_DELAY);
yield* reactor.start();
yield* Deferred.succeed(fixture.activation, undefined);
yield* Queue.take(fixture.snapshotReads);
Expand Down Expand Up @@ -637,4 +652,90 @@ describe("ThreadSettlementReactor", () => {
}),
),
);

it.effect("holds automatic sweeps until the boot delay elapses", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const fixture = yield* makeHarness({
snapshot: makeSnapshot([makeThread("inactive")]),
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* reactor.start();
yield* Deferred.succeed(fixture.activation, undefined);

// Five periodic ticks land inside the boot window and none may sweep.
yield* TestClock.adjust("4 minutes");
yield* reactor.drain;
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);

yield* TestClock.adjust("1 minute");
assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 1);
yield* reactor.drain;
assert.deepStrictEqual(
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
[ThreadId.make("inactive")],
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("throttles automatic sweeps to the minimum interval", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const fixture = yield* makeHarness({
snapshot: makeSnapshot([makeThread("inactive")]),
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 1);

// Nine more periodic ticks, all inside the minimum interval.
yield* TestClock.adjust("9 minutes");
yield* reactor.drain;
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 1);

yield* TestClock.adjust("1 minute");
assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 2);
yield* reactor.drain;
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("sweeps on a settings change without waiting out the boot delay", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const fixture = yield* makeHarness({
snapshot: makeSnapshot([makeThread("inactive")]),
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* reactor.start();
yield* Deferred.succeed(fixture.activation, undefined);
yield* TestClock.adjust("4 minutes");
yield* reactor.drain;
assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);

yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 1);
yield* reactor.drain;
assert.deepStrictEqual(
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
[ThreadId.make("inactive")],
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);
});
Loading
Loading