Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
80347bc
perf(server): stop shipping full MCP tool results in thread payloads …
t3dotgg Aug 6, 2026
d2bd4b9
fix(web): closed plan sidebar stays closed when returning to a thread…
t3dotgg Aug 6, 2026
228b092
fix: respect time format for sidebar snooze (#4438)
huxcrux Aug 6, 2026
b9d97a2
fix(web): calm one-row server update status and banner
t3dotgg Aug 5, 2026
42d8a2b
fix(server): keep the managed tunnel across remote update restarts
t3dotgg Aug 6, 2026
7d44f1a
test(server): use the launcher state writer in release tests
t3dotgg Aug 6, 2026
4b0eed9
fix(server): release the tunnel when an explicit stop interrupts an u…
t3dotgg Aug 6, 2026
258dc8f
fix(server): clean up interrupted update trials
t3dotgg Aug 6, 2026
69c7f4f
fix(server): order explicit service shutdown
t3dotgg Aug 6, 2026
0080f13
fix(web): make update version tooltips accessible
t3dotgg Aug 6, 2026
4c3e47f
fix(server): separate trial cleanup from handoff retention
t3dotgg Aug 6, 2026
a6a5f4a
fix(server): drop superseded tool updates from snapshots
t3dotgg Aug 6, 2026
6aa3dee
docs(server): pin interleaved-collapse divergence as intentional with…
t3dotgg Aug 6, 2026
2007045
fix(web): clarify auto permission fallback (#5431)
t3-code[bot] Aug 6, 2026
db5f02c
fix(acp): keep unknown approvals actionable (#5430)
t3-code[bot] Aug 6, 2026
a1f222d
fix(mobile): stop thread messages reading through pending cards (#5450)
carlosricojr Aug 6, 2026
6358918
fix(web): align composer inline chips with prompt text (#5495)
StiensWout Aug 6, 2026
9f7f721
fix(web): clear woke state on explicit thread actions (#5486)
StiensWout Aug 6, 2026
bdbd402
fix: format mobile composer route transition css
cursoragent Aug 8, 2026
ff2218f
fix: format index.css for vp check
cursoragent Aug 8, 2026
f0b03e8
fix(server): keep explicit-stop marker across launcher recover
cursoragent Aug 8, 2026
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
4 changes: 3 additions & 1 deletion apps/mobile/src/features/threads/PendingApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export interface PendingApprovalCardProps {
}

export function PendingApprovalCard(props: PendingApprovalCardProps) {
// Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
// behind this card, so a translucent surface bleeds messages through it.
return (
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
Approval needed
</Text>
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ export interface PendingUserInputCardProps {
}

export function PendingUserInputCard(props: PendingUserInputCardProps) {
// The surface is opaque on purpose: the card floats over the thread feed
// with no blur behind it, so a translucent background renders the questions
// on top of whatever message happens to sit underneath.
return (
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80">
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
User input needed
</Text>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
});

expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs");
expect(unit).toContain("KillMode=control-group");
expect(unit).toContain("KillMode=mixed");
expect(unit).not.toContain("versions/1.2.3");
});

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
`Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
`Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
`ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
"KillMode=control-group",
// Let the launcher mark an explicit stop before it signals the server.
// systemd still SIGKILLs the whole cgroup if graceful shutdown times out.
"KillMode=mixed",
"Restart=always",
"RestartSec=5",
`StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
Expand Down
115 changes: 115 additions & 0 deletions apps/server/src/cloud/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Tracer from "effect/Tracer";
import {
Expand All @@ -15,6 +18,14 @@ import { EnvironmentId } from "@t3tools/contracts";
import { RelayClientTracer } from "@t3tools/shared/relayTracing";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfigModule from "../config.ts";
import { writeServiceState } from "../serviceLauncher.ts";
import {
SERVICE_LAUNCHER_PROTOCOL,
SERVICE_STATE_FILE,
SERVICE_STOP_MARKER_FILE,
type ServiceUpdateRecord,
} from "./serviceProtocol.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
Expand All @@ -24,6 +35,7 @@ import {
consumeCloudReplayGuards,
isSupportedLinkProviderKind,
linkProofScopes,
pendingServiceUpdateExists,
reconcileDesiredCloudLink,
releaseManagedTunnelOnShutdown,
} from "./http.ts";
Expand Down Expand Up @@ -256,6 +268,23 @@ describe("releaseManagedTunnelOnShutdown", () => {
readonly respond?: () => Response;
}

// Writes the launcher's durable state file into this test's baseDir with
// the launcher's own writer; the release reads it to detect an in-flight
// update handoff.
const writeLauncherState = (update: ServiceUpdateRecord) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* ServerConfigModule.ServerConfig;
const statePath = path.join(config.baseDir, "runtime", SERVICE_STATE_FILE);
yield* Effect.promise(() =>
writeServiceState(statePath, {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "0.0.30",
update,
}),
);
});

const provideReleaseHarness =
(harness: ReleaseHarness) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Expand Down Expand Up @@ -306,6 +335,14 @@ describe("releaseManagedTunnelOnShutdown", () => {
}),
),
),
// The release consults the launcher state file under the configured
// baseDir, so every harness run gets a scoped temp baseDir.
Effect.provide(
ServerConfigModule.layerTest("/", { prefix: "t3-http-release-test-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
Effect.scoped,
);

// The persisted state of a CLI-managed link whose tunnel is releasable.
Expand Down Expand Up @@ -390,6 +427,84 @@ describe("releaseManagedTunnelOnShutdown", () => {
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
});

it.effect("keeps the tunnel when shutdown hands off to a pending update", () => {
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
const applyConfigCalls: Array<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

return Effect.gen(function* () {
yield* writeLauncherState({
id: "update-1",
fromVersion: "0.0.30",
targetVersion: "0.0.31",
dbPath: "/tmp/state.sqlite",
status: "pending",
});

const released = yield* releaseManagedTunnelOnShutdown();

// The launcher restarts a server immediately, so the tunnel is not
// orphaned; keeping it avoids the hostname route re-propagation that
// dominates update downtime. The stored config must survive so the
// next boot respawns the connector against the same tunnel.
expect(released).toBe(false);
expect(applyConfigCalls).toEqual([]);
expect(requests).toEqual([]);
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
});

it.effect("still releases a pending update when the launcher is stopping", () => {
// `t3 service uninstall` or `systemctl stop` during the pending window:
// the launcher writes its stop marker before signalling the child, so no
// replacement server is coming and the tunnel must not be kept.
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
const applyConfigCalls: Array<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

return Effect.gen(function* () {
yield* writeLauncherState({
id: "update-1",
fromVersion: "0.0.30",
targetVersion: "0.0.31",
dbPath: "/tmp/state.sqlite",
status: "pending",
});
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const config = yield* ServerConfigModule.ServerConfig;
yield* fs.writeFileString(path.join(config.baseDir, "runtime", SERVICE_STOP_MARKER_FILE), "");

expect(yield* pendingServiceUpdateExists).toBe(true);
const released = yield* releaseManagedTunnelOnShutdown();

expect(released).toBe(true);
expect(requests).toHaveLength(1);
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
});

it.effect("still releases when the recorded update already settled", () => {
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
const applyConfigCalls: Array<unknown> = [];
const requests: Array<HttpClientRequest.HttpClientRequest> = [];

return Effect.gen(function* () {
yield* writeLauncherState({
id: "update-1",
fromVersion: "0.0.30",
targetVersion: "0.0.31",
status: "committed",
});

const released = yield* releaseManagedTunnelOnShutdown();

expect(released).toBe(true);
expect(requests).toHaveLength(1);
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
});

it.effect("keeps a runtime config that a fast restart replaced mid-release", () => {
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
const applyConfigCalls: Array<unknown> = [];
Expand Down
52 changes: 52 additions & 0 deletions apps/server/src/cloud/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ import * as DateTime from "effect/DateTime";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
Expand All @@ -56,8 +58,14 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { requireEnvironmentScope } from "../auth/http.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import {
SERVICE_STATE_FILE,
SERVICE_STOP_MARKER_FILE,
serviceStateHasPendingUpdate,
} from "./serviceProtocol.ts";
import {
CLOUD_ENDPOINT_RUNTIME_CONFIG,
CLOUD_LINKED_USER_ID,
Expand Down Expand Up @@ -627,6 +635,37 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
},
);

// The launcher owns this durable state, so read it directly both when a trial
// decides whether it owns pre-activation cleanup and while a server tears down.
export const pendingServiceUpdateExists = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runtimeDir = path.join(config.baseDir, "runtime");
const stateText = yield* fs
.readFileString(path.join(runtimeDir, SERVICE_STATE_FILE))
.pipe(Effect.option);
return Option.isSome(stateText) && serviceStateHasPendingUpdate(stateText.value);
});

// A pending update alone is not proof a replacement server is coming: an
// explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during
// the pending window also tears this server down. The launcher marks that case
// just before it signals the child, so pending + no marker is the handoff.
const pendingUpdateHandoffExists = Effect.gen(function* () {
if (!(yield* pendingServiceUpdateExists)) {
return false;
}
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runtimeDir = path.join(config.baseDir, "runtime");
const stopping = yield* fs
.exists(path.join(runtimeDir, SERVICE_STOP_MARKER_FILE))
.pipe(Effect.orElseSucceed(() => false));
return !stopping;
Comment on lines +663 to +666

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle explicit stops from pre-marker service launchers

When an existing background-service installation is remotely updated to this server version, its stable launcher and systemd unit are not replaced, and the launcher protocol remains version 2; those pre-change launchers never write .service-stopping. If systemctl stop or t3 service uninstall occurs while an update is pending, this check therefore treats the explicit stop as a handoff and skips managed-tunnel deletion even though no replacement server is coming, leaving the provisioned tunnel orphaned. The handoff decision needs backward-compatible launcher detection or a launcher/protocol migration before absence of the marker can mean handoff.

AGENTS.md reference: AGENTS.md:L20-L25

Useful? React with 👍 / 👎.

});

// Cloudflare bills per provisioned tunnel, so an environment that goes offline
// must not leave its tunnel behind. Releasing deletes only the tunnel — the
// relay keeps the link and its hostname reservation, and the next startup's
Expand All @@ -649,6 +688,19 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {
return false;
}
// A shutdown that hands off to a pending remote update is not the
// environment going offline: the launcher immediately brings a server back
// (the new version, or the old one after a rollback). Deleting the tunnel
// here forces that server to provision a replacement UUID, and the public
// hostname's route to the new tunnel takes 1-2 minutes to propagate — the
// dominant cost of an update restart. Keep the tunnel instead: the next
// boot respawns the connector from the stored config and is reachable as
// soon as it connects, and the reconcile confirms the still-live tunnel
// without replacing it.
if (yield* pendingUpdateHandoffExists) {
yield* Effect.logInfo("Keeping the managed tunnel across the update restart");
return false;
}
const token = yield* dependencies.cliTokenManager.getExisting;
if (Option.isNone(token)) {
return false;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/cloud/serviceProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;
export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
export const SERVICE_STATE_FILE = "service-state.json";
/** Written by the launcher just before an explicit stop kills its child, so
the child can tell "the service is going away" from "the launcher is about
to start my replacement" while a pending update is recorded. */
export const SERVICE_STOP_MARKER_FILE = ".service-stopping";

export interface PendingServiceUpdate {
readonly id: string;
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => {
expect(data.somethingClientNeverReads).toBeUndefined();
});

it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
const projected = projectActivityPayload(
activity({
itemType: "mcp_tool_call",
data: {
item: {
type: "mcpToolCall",
id: "item-1",
tool: "fetch_pr",
server: "github",
status: "completed",
arguments: { pr: 42 },
durationMs: 1200,
result: {
content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }],
structuredContent: { huge: "y".repeat(5000) },
},
_meta: { internal: true },
},
},
}),
);
const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
const item = data.item as Record<string, unknown>;
expect(item.tool).toBe("fetch_pr");
expect(item.server).toBe("github");
expect(item.arguments).toEqual({ pr: 42 });
expect(item._meta).toBeUndefined();
expect(item.result).toEqual({ content: "PR body line one" });
expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
});

it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => {
const projected = projectActivityPayload(
activity({
itemType: "mcp_tool_call",
data: {
toolName: "mcp__github__fetch_pr",
input: { pr: 42 },
result: {
type: "tool_result",
tool_use_id: "toolu_1",
content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }],
},
},
}),
);
const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
expect(data.toolName).toBe("mcp__github__fetch_pr");
expect(data.input).toEqual({ pr: 42 });
expect(data.result).toEqual({ content: "first line of output" });
expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
});

it("passes task lifecycle payloads (no data field) through untouched", () => {
const source = activity({
taskId: "task-9",
Expand Down
Loading
Loading