Skip to content

Commit 0d1da72

Browse files
authored
Merge pull request #215 from aaditagrawal/codex/sync-20260808-l1-server-settle-a13c
sync: port upstream server settle, tunnel/update safety, and early web polish (pingdotgg#5482pingdotgg#5486)
2 parents 911276d + f0b03e8 commit 0d1da72

29 files changed

Lines changed: 1189 additions & 304 deletions

apps/mobile/src/features/threads/PendingApprovalCard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export interface PendingApprovalCardProps {
1414
}
1515

1616
export function PendingApprovalCard(props: PendingApprovalCardProps) {
17+
// Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
18+
// behind this card, so a translucent surface bleeds messages through it.
1719
return (
18-
<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">
20+
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
1921
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
2022
Approval needed
2123
</Text>

apps/mobile/src/features/threads/PendingUserInputCard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ export interface PendingUserInputCardProps {
2424
}
2525

2626
export function PendingUserInputCard(props: PendingUserInputCardProps) {
27+
// The surface is opaque on purpose: the card floats over the thread feed
28+
// with no blur behind it, so a translucent background renders the questions
29+
// on top of whatever message happens to sit underneath.
2730
return (
28-
<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">
31+
<View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
2932
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
3033
User input needed
3134
</Text>

apps/server/src/cloud/bootService.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
3131
});
3232

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

apps/server/src/cloud/bootService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
6464
`Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
6565
`Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
6666
`ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
67-
"KillMode=control-group",
67+
// Let the launcher mark an explicit stop before it signals the server.
68+
// systemd still SIGKILLs the whole cgroup if graceful shutdown times out.
69+
"KillMode=mixed",
6870
"Restart=always",
6971
"RestartSec=5",
7072
`StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,

apps/server/src/cloud/http.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
22
import { describe, expect, it } from "@effect/vitest";
33
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Layer from "effect/Layer";
46
import * as Option from "effect/Option";
7+
import * as Path from "effect/Path";
58
import * as PlatformError from "effect/PlatformError";
69
import * as Tracer from "effect/Tracer";
710
import {
@@ -15,6 +18,14 @@ import { EnvironmentId } from "@t3tools/contracts";
1518
import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1619
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1720
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
21+
import * as ServerConfigModule from "../config.ts";
22+
import { writeServiceState } from "../serviceLauncher.ts";
23+
import {
24+
SERVICE_LAUNCHER_PROTOCOL,
25+
SERVICE_STATE_FILE,
26+
SERVICE_STOP_MARKER_FILE,
27+
type ServiceUpdateRecord,
28+
} from "./serviceProtocol.ts";
1829
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1930
import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
2031
import * as CliTokenManager from "./CliTokenManager.ts";
@@ -24,6 +35,7 @@ import {
2435
consumeCloudReplayGuards,
2536
isSupportedLinkProviderKind,
2637
linkProofScopes,
38+
pendingServiceUpdateExists,
2739
reconcileDesiredCloudLink,
2840
releaseManagedTunnelOnShutdown,
2941
} from "./http.ts";
@@ -256,6 +268,23 @@ describe("releaseManagedTunnelOnShutdown", () => {
256268
readonly respond?: () => Response;
257269
}
258270

271+
// Writes the launcher's durable state file into this test's baseDir with
272+
// the launcher's own writer; the release reads it to detect an in-flight
273+
// update handoff.
274+
const writeLauncherState = (update: ServiceUpdateRecord) =>
275+
Effect.gen(function* () {
276+
const path = yield* Path.Path;
277+
const config = yield* ServerConfigModule.ServerConfig;
278+
const statePath = path.join(config.baseDir, "runtime", SERVICE_STATE_FILE);
279+
yield* Effect.promise(() =>
280+
writeServiceState(statePath, {
281+
protocol: SERVICE_LAUNCHER_PROTOCOL,
282+
activeVersion: "0.0.30",
283+
update,
284+
}),
285+
);
286+
});
287+
259288
const provideReleaseHarness =
260289
(harness: ReleaseHarness) =>
261290
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -306,6 +335,14 @@ describe("releaseManagedTunnelOnShutdown", () => {
306335
}),
307336
),
308337
),
338+
// The release consults the launcher state file under the configured
339+
// baseDir, so every harness run gets a scoped temp baseDir.
340+
Effect.provide(
341+
ServerConfigModule.layerTest("/", { prefix: "t3-http-release-test-" }).pipe(
342+
Layer.provideMerge(NodeServices.layer),
343+
),
344+
),
345+
Effect.scoped,
309346
);
310347

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

430+
it.effect("keeps the tunnel when shutdown hands off to a pending update", () => {
431+
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
432+
const applyConfigCalls: Array<unknown> = [];
433+
const requests: Array<HttpClientRequest.HttpClientRequest> = [];
434+
435+
return Effect.gen(function* () {
436+
yield* writeLauncherState({
437+
id: "update-1",
438+
fromVersion: "0.0.30",
439+
targetVersion: "0.0.31",
440+
dbPath: "/tmp/state.sqlite",
441+
status: "pending",
442+
});
443+
444+
const released = yield* releaseManagedTunnelOnShutdown();
445+
446+
// The launcher restarts a server immediately, so the tunnel is not
447+
// orphaned; keeping it avoids the hostname route re-propagation that
448+
// dominates update downtime. The stored config must survive so the
449+
// next boot respawns the connector against the same tunnel.
450+
expect(released).toBe(false);
451+
expect(applyConfigCalls).toEqual([]);
452+
expect(requests).toEqual([]);
453+
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
454+
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
455+
});
456+
457+
it.effect("still releases a pending update when the launcher is stopping", () => {
458+
// `t3 service uninstall` or `systemctl stop` during the pending window:
459+
// the launcher writes its stop marker before signalling the child, so no
460+
// replacement server is coming and the tunnel must not be kept.
461+
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
462+
const applyConfigCalls: Array<unknown> = [];
463+
const requests: Array<HttpClientRequest.HttpClientRequest> = [];
464+
465+
return Effect.gen(function* () {
466+
yield* writeLauncherState({
467+
id: "update-1",
468+
fromVersion: "0.0.30",
469+
targetVersion: "0.0.31",
470+
dbPath: "/tmp/state.sqlite",
471+
status: "pending",
472+
});
473+
const fs = yield* FileSystem.FileSystem;
474+
const path = yield* Path.Path;
475+
const config = yield* ServerConfigModule.ServerConfig;
476+
yield* fs.writeFileString(path.join(config.baseDir, "runtime", SERVICE_STOP_MARKER_FILE), "");
477+
478+
expect(yield* pendingServiceUpdateExists).toBe(true);
479+
const released = yield* releaseManagedTunnelOnShutdown();
480+
481+
expect(released).toBe(true);
482+
expect(requests).toHaveLength(1);
483+
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
484+
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
485+
});
486+
487+
it.effect("still releases when the recorded update already settled", () => {
488+
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
489+
const applyConfigCalls: Array<unknown> = [];
490+
const requests: Array<HttpClientRequest.HttpClientRequest> = [];
491+
492+
return Effect.gen(function* () {
493+
yield* writeLauncherState({
494+
id: "update-1",
495+
fromVersion: "0.0.30",
496+
targetVersion: "0.0.31",
497+
status: "committed",
498+
});
499+
500+
const released = yield* releaseManagedTunnelOnShutdown();
501+
502+
expect(released).toBe(true);
503+
expect(requests).toHaveLength(1);
504+
expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
505+
}).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
506+
});
507+
393508
it.effect("keeps a runtime config that a fast restart replaced mid-release", () => {
394509
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
395510
const applyConfigCalls: Array<unknown> = [];

apps/server/src/cloud/http.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import * as DateTime from "effect/DateTime";
4646
import * as Crypto from "effect/Crypto";
4747
import * as Duration from "effect/Duration";
4848
import * as Effect from "effect/Effect";
49+
import * as FileSystem from "effect/FileSystem";
4950
import * as Option from "effect/Option";
51+
import * as Path from "effect/Path";
5052
import * as Schema from "effect/Schema";
5153
import * as HttpEffect from "effect/unstable/http/HttpEffect";
5254
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
@@ -56,8 +58,14 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
5658
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
5759
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
5860
import { requireEnvironmentScope } from "../auth/http.ts";
61+
import * as ServerConfig from "../config.ts";
5962
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
6063
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
64+
import {
65+
SERVICE_STATE_FILE,
66+
SERVICE_STOP_MARKER_FILE,
67+
serviceStateHasPendingUpdate,
68+
} from "./serviceProtocol.ts";
6169
import {
6270
CLOUD_ENDPOINT_RUNTIME_CONFIG,
6371
CLOUD_LINKED_USER_ID,
@@ -627,6 +635,37 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
627635
},
628636
);
629637

638+
// The launcher owns this durable state, so read it directly both when a trial
639+
// decides whether it owns pre-activation cleanup and while a server tears down.
640+
export const pendingServiceUpdateExists = Effect.gen(function* () {
641+
const config = yield* ServerConfig.ServerConfig;
642+
const fs = yield* FileSystem.FileSystem;
643+
const path = yield* Path.Path;
644+
const runtimeDir = path.join(config.baseDir, "runtime");
645+
const stateText = yield* fs
646+
.readFileString(path.join(runtimeDir, SERVICE_STATE_FILE))
647+
.pipe(Effect.option);
648+
return Option.isSome(stateText) && serviceStateHasPendingUpdate(stateText.value);
649+
});
650+
651+
// A pending update alone is not proof a replacement server is coming: an
652+
// explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during
653+
// the pending window also tears this server down. The launcher marks that case
654+
// just before it signals the child, so pending + no marker is the handoff.
655+
const pendingUpdateHandoffExists = Effect.gen(function* () {
656+
if (!(yield* pendingServiceUpdateExists)) {
657+
return false;
658+
}
659+
const config = yield* ServerConfig.ServerConfig;
660+
const fs = yield* FileSystem.FileSystem;
661+
const path = yield* Path.Path;
662+
const runtimeDir = path.join(config.baseDir, "runtime");
663+
const stopping = yield* fs
664+
.exists(path.join(runtimeDir, SERVICE_STOP_MARKER_FILE))
665+
.pipe(Effect.orElseSucceed(() => false));
666+
return !stopping;
667+
});
668+
630669
// Cloudflare bills per provisioned tunnel, so an environment that goes offline
631670
// must not leave its tunnel behind. Releasing deletes only the tunnel — the
632671
// relay keeps the link and its hostname reservation, and the next startup's
@@ -649,6 +688,19 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
649688
if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {
650689
return false;
651690
}
691+
// A shutdown that hands off to a pending remote update is not the
692+
// environment going offline: the launcher immediately brings a server back
693+
// (the new version, or the old one after a rollback). Deleting the tunnel
694+
// here forces that server to provision a replacement UUID, and the public
695+
// hostname's route to the new tunnel takes 1-2 minutes to propagate — the
696+
// dominant cost of an update restart. Keep the tunnel instead: the next
697+
// boot respawns the connector from the stored config and is reachable as
698+
// soon as it connects, and the reconcile confirms the still-live tunnel
699+
// without replacing it.
700+
if (yield* pendingUpdateHandoffExists) {
701+
yield* Effect.logInfo("Keeping the managed tunnel across the update restart");
702+
return false;
703+
}
652704
const token = yield* dependencies.cliTokenManager.getExisting;
653705
if (Option.isNone(token)) {
654706
return false;

apps/server/src/cloud/serviceProtocol.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;
55
export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
66
export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
77
export const SERVICE_STATE_FILE = "service-state.json";
8+
/** Written by the launcher just before an explicit stop kills its child, so
9+
the child can tell "the service is going away" from "the launcher is about
10+
to start my replacement" while a pending update is recorded. */
11+
export const SERVICE_STOP_MARKER_FILE = ".service-stopping";
812

913
export interface PendingServiceUpdate {
1014
readonly id: string;

apps/server/src/orchestration/ActivityPayloadProjection.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => {
4444
expect(data.somethingClientNeverReads).toBeUndefined();
4545
});
4646

47+
it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
48+
const projected = projectActivityPayload(
49+
activity({
50+
itemType: "mcp_tool_call",
51+
data: {
52+
item: {
53+
type: "mcpToolCall",
54+
id: "item-1",
55+
tool: "fetch_pr",
56+
server: "github",
57+
status: "completed",
58+
arguments: { pr: 42 },
59+
durationMs: 1200,
60+
result: {
61+
content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }],
62+
structuredContent: { huge: "y".repeat(5000) },
63+
},
64+
_meta: { internal: true },
65+
},
66+
},
67+
}),
68+
);
69+
const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
70+
const item = data.item as Record<string, unknown>;
71+
expect(item.tool).toBe("fetch_pr");
72+
expect(item.server).toBe("github");
73+
expect(item.arguments).toEqual({ pr: 42 });
74+
expect(item._meta).toBeUndefined();
75+
expect(item.result).toEqual({ content: "PR body line one" });
76+
expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
77+
});
78+
79+
it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => {
80+
const projected = projectActivityPayload(
81+
activity({
82+
itemType: "mcp_tool_call",
83+
data: {
84+
toolName: "mcp__github__fetch_pr",
85+
input: { pr: 42 },
86+
result: {
87+
type: "tool_result",
88+
tool_use_id: "toolu_1",
89+
content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }],
90+
},
91+
},
92+
}),
93+
);
94+
const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
95+
expect(data.toolName).toBe("mcp__github__fetch_pr");
96+
expect(data.input).toEqual({ pr: 42 });
97+
expect(data.result).toEqual({ content: "first line of output" });
98+
expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
99+
});
100+
47101
it("passes task lifecycle payloads (no data field) through untouched", () => {
48102
const source = activity({
49103
taskId: "task-9",

0 commit comments

Comments
 (0)