diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts
index 43ca40e9c7c..36ef8264328 100644
--- a/apps/server/src/process/externalLauncher.test.ts
+++ b/apps/server/src/process/externalLauncher.test.ts
@@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
@@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+it.effect("memoizes editor discovery and refreshes after the cache window", () => {
+ let statCalls = 0;
+ const fileInfo = { type: "File" } as FileSystem.File.Info;
+ const launcherLayer = ExternalLauncher.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ FileSystem.layerNoop({
+ stat: () =>
+ Effect.sync(() => {
+ statCalls += 1;
+ return fileInfo;
+ }),
+ }),
+ Path.layer,
+ Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
+ ),
+ ),
+ ),
+ );
+
+ return Effect.gen(function* () {
+ const launcher = yield* ExternalLauncher.ExternalLauncher;
+
+ const first = yield* launcher.resolveAvailableEditors();
+ assert.equal(first.includes("vscode"), true);
+ const statCallsAfterFirstScan = statCalls;
+ assert.isAbove(statCallsAfterFirstScan, 0);
+
+ // Past the shared command-resolution cache TTL (30s) but within the
+ // discovery cache window: the memoized set is reused without any scan.
+ yield* TestClock.adjust("31 seconds");
+ const second = yield* launcher.resolveAvailableEditors();
+ assert.deepEqual([...second], [...first]);
+ assert.equal(statCalls, statCallsAfterFirstScan);
+
+ // Past the discovery cache window the next call rescans.
+ yield* TestClock.adjust("30 seconds");
+ yield* launcher.resolveAvailableEditors();
+ assert.isAbove(statCalls, statCallsAfterFirstScan);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ launcherLayer,
+ Layer.succeed(HostProcessPlatform, "win32"),
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: {
+ PATH: "C:\\t3-editor-discovery-cache-test",
+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
+ },
+ }),
+ ),
+ TestClock.layer(),
+ ),
+ ),
+ );
+});
+
it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts
index 9c2f0e417d3..2cac42f0fec 100644
--- a/apps/server/src/process/externalLauncher.ts
+++ b/apps/server/src/process/externalLauncher.ts
@@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
return yield* buildAvailableEditors(platform, env);
});
+// Editor discovery walks PATH for every known editor and runs for every
+// client connect (the server config embeds the available editors). Memoize
+// the discovered set for a bounded window so repeat connects skip even the
+// per-command cache lookups in @t3tools/shared/shell.
+const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds";
+
/**
* ExternalLauncher - Service tag for browser/editor launch operations.
*/
@@ -443,8 +449,13 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);
+ const cachedAvailableEditors = yield* Effect.cachedWithTTL(
+ provideCommandResolutionServices(resolveAvailableEditors()),
+ EDITOR_DISCOVERY_CACHE_TTL,
+ );
+
return ExternalLauncher.of({
- resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
+ resolveAvailableEditors: () => cachedAvailableEditors,
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 6bafb9ec3ba..135f1efecc7 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -294,10 +294,12 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;
// When a resuming client's cursor is more than this many events behind the
// current head, skip the per-event catch-up replay and send a fresh shell
-// snapshot instead. Replaying each intervening event costs a shell refetch;
-// past this gap a single O(active-threads) snapshot is cheaper and bounded.
-// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
-const SHELL_RESUME_MAX_GAP = 1_000;
+// snapshot instead. Past this gap a single O(active-threads) snapshot is
+// cheaper and bounded. The shell replay is coalesced per aggregate (see
+// coalesceShellStream), so its cost stays bounded well past the event store's
+// default page size and a wider gap keeps wake/resume on the cheap replay
+// path instead of forcing a full snapshot.
+const SHELL_RESUME_MAX_GAP = 5_000;
// Same bound for thread resume. The replay reads the *global* event range and
// filters per-thread afterwards, so a stale cursor far behind the head would
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f17e7021c44..85009b755bd 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -4732,12 +4732,21 @@ function ChatViewContent(props: ChatViewProps) {
isSendBusy ||
isConnecting ||
threadDetailLoading ||
- activeEnvironmentUnavailable ||
sendInFlightRef.current
) {
notifyDirectAnnotationAttached();
return;
}
+ if (activeEnvironmentUnavailable) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Not connected — message not sent",
+ description: "Reconnecting to the environment. Try again once it is connected.",
+ }),
+ );
+ return;
+ }
if (activePendingProgress) {
if (directAnnotation) {
notifyDirectAnnotationAttached();
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index f6d34315dac..f96e72bb819 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -1270,7 +1270,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
);
const collapsedComposerPrimaryActionDisabled =
- phase === "running" ||
isSendBusy ||
isSendDisabled ||
isConnecting ||
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
index 504b7e1cc44..f942ba3ab58 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
@@ -134,17 +134,48 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
if (isRunning) {
return (
-
+
+
+
+
);
}
diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts
index a925859049f..625b2cf2a8c 100644
--- a/packages/client-runtime/src/connection/supervisor.test.ts
+++ b/packages/client-runtime/src/connection/supervisor.test.ts
@@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => {
const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt");
expect(firstAttempt).toBeDefined();
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
const attempts = spans.filter((span) => span.name === "relay.connection.attempt");
@@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(1);
- for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) {
+ for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) {
yield* TestClock.adjust(delay);
yield* eventuallyState(
supervisor.state,
@@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
const retrying = yield* awaitState(
supervisor.state,
@@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => {
},
});
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
expect(yield* Ref.get(harness.prepareCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
@@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 2,
@@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(3);
- yield* TestClock.adjust("999 millis");
+ yield* TestClock.adjust("2999 millis");
expect(yield* Ref.get(harness.prepareCount)).toBe(3);
yield* TestClock.adjust("1 milli");
yield* eventuallyState(
@@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "blocked" && state.attempt === 2,
@@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => {
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => {
expect(secondFailure.retryAt).not.toBeNull();
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
expect(yield* Ref.get(harness.sessionCount)).toBe(2);
yield* TestClock.adjust("1 second");
@@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
@@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2,
@@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
- yield* TestClock.adjust("1 second");
+ yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connecting" && state.attempt === 2,
@@ -925,7 +925,7 @@ describe("EnvironmentSupervisor", () => {
}),
);
- it.effect("reconnects when the foreground liveness probe fails", () =>
+ it.effect("reconnects immediately when the foreground liveness probe fails", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
probe: (attempt) =>
@@ -937,15 +937,68 @@ describe("EnvironmentSupervisor", () => {
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active");
- yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
- yield* TestClock.adjust("1 second");
+ // No TestClock advance: a failed wake probe skips the first backoff rung
+ // and reconnects immediately.
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
+ );
+
+ expect(yield* Ref.get(harness.sessionCount)).toBe(2);
+ expect(yield* Ref.get(harness.releaseCount)).toBe(1);
+ }).pipe(Effect.provide(TestClock.layer())),
+ );
+
+ it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ prepare: (attempt) =>
+ attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION),
+ probe: (attempt) =>
+ attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ yield* harness.wake("application-active");
+ // The immediate follow-up attempt fails: only the first attempt after
+ // the wake probe skips the ladder, so this failure backs off normally.
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "backoff" && state.attempt === 1,
+ );
+ yield* TestClock.adjust("2999 millis");
+ expect(yield* Ref.get(harness.prepareCount)).toBe(2);
+ yield* TestClock.adjust("1 milli");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
);
+ expect(yield* Ref.get(harness.prepareCount)).toBe(3);
+ }).pipe(Effect.provide(TestClock.layer())),
+ );
+
+ it.effect("quickly times out a stalled desktop foreground liveness probe", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness({
+ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
+ });
+ const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
+ initiallyDesired: true,
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+ yield* harness.wake("application-active");
+ yield* TestClock.adjust("3 seconds");
+ yield* awaitState(
+ supervisor.state,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
+ );
+
expect(yield* Ref.get(harness.sessionCount)).toBe(2);
- expect(yield* Ref.get(harness.releaseCount)).toBe(1);
}).pipe(Effect.provide(TestClock.layer())),
);
@@ -961,15 +1014,14 @@ describe("EnvironmentSupervisor", () => {
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active-probe");
yield* TestClock.adjust("3 seconds");
+ // The timed-out wake probe reconnects immediately without a backoff
+ // sleep: no further clock advance is needed.
yield* awaitState(
supervisor.state,
- (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout",
- );
- yield* TestClock.adjust("1 second");
- yield* eventuallyState(
- supervisor.state,
- (state) => state.phase === "connected" && state.generation === 2,
+ (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
);
+
+ expect(yield* Ref.get(harness.sessionCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
);
diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts
index 2a9c7519072..5d5bcd098e6 100644
--- a/packages/client-runtime/src/connection/supervisor.ts
+++ b/packages/client-runtime/src/connection/supervisor.ts
@@ -29,9 +29,9 @@ import * as RpcSession from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import * as ConnectionWakeups from "./wakeups.ts";
-const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
+const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const;
const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
-const CONNECTION_PROBE_TIMEOUT = "15 seconds";
+const CONNECTION_PROBE_TIMEOUT = "3 seconds";
const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds";
const BACKOFF_RESET_AFTER_MS = 30_000;
@@ -232,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
const intent = yield* Ref.make(initialIntent);
const signals = yield* Queue.unbounded();
const resetRetryState = yield* Ref.make(false);
+ // Set when a foreground wake probe fails or times out: the user is actively
+ // returning to the app on a dead transport, so the follow-up reconnect skips
+ // the first backoff rung instead of sleeping.
+ const wakeProbeFailed = yield* Ref.make(false);
const state = yield* SubscriptionRef.make(
!initialIntent.desired
? availableState(initialIntent, 0)
@@ -441,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
),
);
if (probeEvent._tag === "ProbeCompleted") {
+ if (Exit.isFailure(probeEvent.exit)) {
+ yield* Ref.set(wakeProbeFailed, true);
+ }
yield* probeEvent.exit;
break;
}
@@ -673,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
const outcome: AttemptOutcome = yield* Effect.scoped(
runAttempt(attempt, nextGeneration, latestFailure, pendingRetry),
);
+ // Consumed on every iteration so a stale marker can never leak into a
+ // later, unrelated failure.
+ const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false);
if (outcome.established) {
generation = nextGeneration;
if (outcome.stable) {
@@ -709,6 +719,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
continue;
}
+ if (failedWakeProbe) {
+ // The wake probe found a dead transport while the user is returning to
+ // the app, so reconnect immediately instead of sleeping the first
+ // backoff rung. Only this first attempt skips the ladder; if it fails
+ // too, normal backoff resumes.
+ resetRetryLadder();
+ continue;
+ }
+
failureCount += 1;
const delayMs = retryDelayMs(failureCount - 1);
pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({
diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts
index e006fc3cd76..b357fef2012 100644
--- a/packages/client-runtime/src/state/shell-sync.test.ts
+++ b/packages/client-runtime/src/state/shell-sync.test.ts
@@ -150,17 +150,17 @@ describe("environment shell synchronization", () => {
}),
);
- it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () =>
+ it.effect("resumes from a warm shell cache without reloading the HTTP snapshot", () =>
Effect.gen(function* () {
const cachedSnapshot: OrchestrationShellSnapshot = {
snapshotSequence: 5,
projects: [],
- threads: [{ id: "stale-thread" } as never],
+ threads: [{ id: "cached-thread" } as never],
updatedAt: "2026-06-06T00:00:00.000Z",
};
- const httpSnapshot: OrchestrationShellSnapshot = {
+ const resetSnapshot: OrchestrationShellSnapshot = {
...cachedSnapshot,
- snapshotSequence: 9,
+ snapshotSequence: 9_999,
threads: [],
updatedAt: "2026-06-07T00:00:00.000Z",
};
@@ -210,7 +210,7 @@ describe("environment shell synchronization", () => {
const snapshotLoader = ShellSnapshotLoader.of({
load: () =>
SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(
- Effect.as(Option.some(httpSnapshot)),
+ Effect.as(Option.some(resetSnapshot)),
),
});
const shellState = yield* makeEnvironmentShellState().pipe(
@@ -225,33 +225,41 @@ describe("environment shell synchronization", () => {
Stream.runHead,
);
- expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9);
+ expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5);
expect(yield* Ref.get(capturedCompletionMarker)).toBe(true);
- expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1);
+ expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0);
const synchronizing = yield* SubscriptionRef.get(shellState);
expect(synchronizing.status).toBe("synchronizing");
- expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot);
+ expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot);
+ // When the resume gap overflows SHELL_RESUME_MAX_GAP the server resets
+ // the stream with a fresh snapshot; the client applies it as usual.
+ yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot });
yield* Queue.offer(events, { kind: "synchronized" });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter((value) => value.status === "live"),
Stream.runHead,
);
+
+ const live = yield* SubscriptionRef.get(shellState);
+ expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot);
+ expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0);
}),
);
- it.effect("refreshes the authoritative shell snapshot when the app becomes active", () =>
+ it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () =>
Effect.gen(function* () {
const events = yield* Queue.unbounded();
const wakeups = yield* Queue.unbounded();
const loaderCalls = yield* Ref.make(0);
- const subscriptionCount = yield* Ref.make(0);
+ const capturedAfterSequences = yield* Ref.make>([]);
const client = {
- [ORCHESTRATION_WS_METHODS.subscribeShell]: () =>
+ [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) =>
Stream.unwrap(
- Ref.update(subscriptionCount, (count) => count + 1).pipe(
- Effect.as(Stream.fromQueue(events)),
- ),
+ Ref.update(capturedAfterSequences, (captured) => [
+ ...captured,
+ input.afterSequence,
+ ]).pipe(Effect.as(Stream.fromQueue(events))),
),
} as unknown as WsRpcProtocolClient;
const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
@@ -296,54 +304,53 @@ describe("environment shell synchronization", () => {
),
);
- yield* SubscriptionRef.changes(shellState).pipe(
- Stream.filter(
- (value) =>
- value.status === "synchronizing" &&
- Option.isSome(value.snapshot) &&
- value.snapshot.value.snapshotSequence === 10,
- ),
- Stream.runHead,
- );
+ // The warm cache resumes at its own cursor without an HTTP load.
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break;
+ yield* Effect.yieldNow;
+ }
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([1]);
yield* Queue.offer(events, { kind: "synchronized" });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter((value) => value.status === "live"),
Stream.runHead,
);
- yield* Queue.offer(wakeups, "application-active");
+ // A newer snapshot arrives on the stream and advances the cursor.
+ yield* Queue.offer(events, {
+ kind: "snapshot",
+ snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 },
+ });
yield* SubscriptionRef.changes(shellState).pipe(
Stream.filter(
- (value) =>
- value.status === "synchronizing" &&
- Option.isSome(value.snapshot) &&
- value.snapshot.value.snapshotSequence === 20,
+ (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40,
),
Stream.runHead,
);
+ yield* Queue.offer(wakeups, "application-active");
for (let attempt = 0; attempt < 100; attempt += 1) {
- if ((yield* Ref.get(subscriptionCount)) >= 2) break;
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break;
yield* Effect.yieldNow;
}
-
- expect(yield* Ref.get(loaderCalls)).toBe(2);
- expect(yield* Ref.get(subscriptionCount)).toBe(2);
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([1, 40]);
+ yield* Queue.offer(events, { kind: "synchronized" });
yield* Queue.offer(wakeups, "application-active-probe");
for (let attempt = 0; attempt < 100; attempt += 1) {
- if ((yield* Ref.get(subscriptionCount)) >= 3) break;
+ if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break;
yield* Effect.yieldNow;
}
- expect(yield* Ref.get(loaderCalls)).toBe(3);
- expect(yield* Ref.get(subscriptionCount)).toBe(3);
+ expect(yield* Ref.get(capturedAfterSequences)).toEqual([1, 40, 40]);
yield* Queue.offer(wakeups, "application-active-reconnect");
for (let attempt = 0; attempt < 10; attempt += 1) {
yield* Effect.yieldNow;
}
- expect(yield* Ref.get(loaderCalls)).toBe(3);
- expect(yield* Ref.get(subscriptionCount)).toBe(3);
+ expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3);
+ // The in-memory cursor made every resubscription warm: the HTTP
+ // snapshot loader is never consulted.
+ expect(yield* Ref.get(loaderCalls)).toBe(0);
}),
);
});
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index a266af5f5f4..234a052e810 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -187,30 +187,51 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
yield* setSynchronizing;
- const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
- Effect.flatMap(
- Option.match({
- onSome: Effect.succeed,
- onNone: () =>
- SubscriptionRef.changes(supervisor.prepared).pipe(
- Stream.filter(Option.isSome),
- Stream.map((value) => value.value),
- Stream.runHead,
- Effect.map(Option.getOrThrow),
- ),
- }),
- ),
- );
- const httpSnapshot = yield* snapshotLoader.load(prepared);
- if (Option.isSome(httpSnapshot)) {
- yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
- return {
- afterSequence: httpSnapshot.value.snapshotSequence,
- ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
- };
+ // Warm resume: a snapshot already in memory (restored from the cache
+ // or left over from a previous subscription) carries a resume cursor,
+ // so subscribe with it directly instead of re-fetching the full shell
+ // snapshot over HTTP. When the cursor is too far behind, the server
+ // resets the stream with a fresh snapshot (see SHELL_RESUME_MAX_GAP
+ // in the server), which applyItem applies like any other item.
+ let current = yield* SubscriptionRef.get(state);
+ if (Option.isNone(current.snapshot)) {
+ const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
+ Effect.flatMap(
+ Option.match({
+ onSome: Effect.succeed,
+ onNone: () =>
+ SubscriptionRef.changes(supervisor.prepared).pipe(
+ Stream.filter(Option.isSome),
+ Stream.map((value) => value.value),
+ Stream.runHead,
+ Effect.map(Option.getOrThrow),
+ ),
+ }),
+ ),
+ );
+ const httpSnapshot = yield* snapshotLoader.load(prepared);
+ if (Option.isSome(httpSnapshot)) {
+ yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
+ current = yield* SubscriptionRef.get(state);
+ }
}
- return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ if (Option.isNone(current.snapshot)) {
+ return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ }
+ if (!supportsCompletionMarker) {
+ // Without a completion marker there is no synchronized signal for a
+ // resumed subscription, so report live immediately, like threads.
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ status: "live" as const,
+ error: Option.none(),
+ }));
+ }
+ return {
+ afterSequence: current.snapshot.value.snapshotSequence,
+ ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
+ };
}),
{
onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts
index 4bd1070bf1f..c58395393d3 100644
--- a/packages/shared/src/observability.test.ts
+++ b/packages/shared/src/observability.test.ts
@@ -21,6 +21,7 @@ import {
makeTraceSink,
type TraceRecord,
type TraceSinkFlushStats,
+ truncateTraceAttributes,
} from "./observability.ts";
describe("errorTag", () => {
@@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) =>
const nodeServicesIt = it.layer(NodeServices.layer);
+describe("truncateTraceAttributes", () => {
+ it("clamps oversized strings at any depth without mutating the input", () => {
+ const stack = "s".repeat(2_000);
+ const attributes = {
+ "db.query.text": "q".repeat(2_000),
+ short: "ok",
+ error: { name: "Error", stack, nested: ["a".repeat(2_000)] },
+ };
+ const truncated = truncateTraceAttributes(attributes);
+
+ assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length);
+ assert.equal(truncated["short"], "ok");
+ const error = truncated["error"] as { stack: string; nested: Array };
+ assert.equal(error.stack.length, 500 + "…[truncated]".length);
+ assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length);
+ // Input is untouched: the live span's attributes are shared.
+ assert.equal(attributes.error.stack, stack);
+ });
+
+ it("returns the same reference when nothing exceeds the limits", () => {
+ const attributes = { short: "ok", nested: { fine: "also ok" } };
+ assert.equal(truncateTraceAttributes(attributes), attributes);
+ });
+});
+
describe("observability", () => {
it("normalizes circular arrays, maps, and sets without recursing forever", () => {
const array: Array = ["alpha"];
diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts
index e0a7595865d..67057c54880 100644
--- a/packages/shared/src/observability.ts
+++ b/packages/shared/src/observability.ts
@@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord["
};
}
+const TRACE_ATTRIBUTE_MAX_LENGTH = 500;
+const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200;
+const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]";
+const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]);
+
+// Clamps strings nested inside already-normalized attribute values (arrays and
+// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the
+// input reference when nothing was clamped.
+function truncateNestedValue(value: unknown): unknown {
+ if (typeof value === "string") {
+ return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH
+ ? value
+ : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`;
+ }
+ if (Array.isArray(value)) {
+ const truncated = value.map(truncateNestedValue);
+ return truncated.some((entry, index) => entry !== value[index]) ? truncated : value;
+ }
+ if (isPlainObject(value)) {
+ let truncated: Record | undefined;
+ for (const [key, entry] of Object.entries(value)) {
+ const next = truncateNestedValue(entry);
+ if (next === entry) continue;
+ truncated ??= { ...value };
+ truncated[key] = next;
+ }
+ return truncated ?? value;
+ }
+ return value;
+}
+
+/**
+ * Clamps oversized attribute values on the serialized trace record so the file
+ * sink stays small, including strings nested inside arrays and objects (e.g.
+ * error stacks). Returns a new record when anything was clamped; never
+ * mutates the input (the live span's attributes are shared with other tracers).
+ */
+export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes {
+ let truncated: Record | undefined;
+ for (const [key, value] of Object.entries(attributes)) {
+ if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) {
+ if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue;
+ truncated ??= { ...attributes };
+ truncated[key] =
+ `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`;
+ continue;
+ }
+ const next = truncateNestedValue(value);
+ if (next === value) continue;
+ truncated ??= { ...attributes };
+ truncated[key] = next;
+ }
+ return truncated ?? attributes;
+}
+
export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
const status = span.status as Extract;
const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
@@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
startTimeUnixNano: String(status.startTime),
endTimeUnixNano: String(status.endTime),
durationMs: Number(status.endTime - status.startTime) / 1_000_000,
- attributes: compactTraceAttributes(Object.fromEntries(span.attributes)),
+ attributes: truncateTraceAttributes(
+ compactTraceAttributes(Object.fromEntries(span.attributes)),
+ ),
events: span.events.map(([name, startTime, attributes]) => ({
name,
timeUnixNano: String(startTime),
- attributes: compactTraceAttributes(attributes),
+ attributes: truncateTraceAttributes(compactTraceAttributes(attributes)),
})),
links: span.links.map((link) => ({
traceId: link.span.traceId,
spanId: link.span.spanId,
- attributes: compactTraceAttributes(link.attributes),
+ attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)),
})),
exit: formatTraceExit(status.exit),
};
diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts
index cf2f2417ff4..efdd05683ab 100644
--- a/packages/shared/src/shell.ts
+++ b/packages/shared/src/shell.ts
@@ -3,6 +3,7 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
+import * as Clock from "effect/Clock";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
@@ -491,6 +492,54 @@ function resolveCommandCandidates(
return Array.from(new Set(candidates));
}
+// Session bootstrap resolves the same commands over and over, each PATH scan
+// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of
+// thousands per connect). Memoize the scan outcome per
+// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the
+// cache while any change to the search environment invalidates immediately.
+// Explicit-path resolution is never cached - callers probe paths they have
+// just written (e.g. managed binary installs). A "not-found" outcome is also
+// cached for the TTL, so a just-installed binary can stay invisible for up to
+// 30s unless resolved by explicit path.
+// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward
+// wall-clock adjustments cannot keep expired entries alive.
+const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n;
+const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512;
+const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0);
+
+interface CommandResolutionCacheEntry {
+ readonly resolvedPath: string | null;
+ readonly expiresAtNanos: bigint;
+}
+
+// The cache lives in the Effect environment (like HostProcessPlatform above)
+// so tests and embedders can provide an isolated instance; the default is a
+// single process-wide map shared by all consumers.
+export const CommandResolutionCache = Context.Reference