authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 16:19:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log981cf405612932afa8a0b98b8c98474ef7e1015d
tree77f358d2b39ba5f0104d2f6d8bf9e7cfaf9a0388
parent689dda59e997337103c76ed93af0574dcc554d26

Improve session restart handling and server logging

- add structured lifecycle logs for Codex thread open/resume and provider session restarts - introduce persistent server file logging layer and tighten WebSocket server startup/finalizer ordering - preserve env-based boolean overrides in dev runner and stop forcing WS event logging in dev - enrich provider session listing from persisted bindings and fix Claude rate-limit/task-id event mapping - update orchestration and dev-runner tests for runtime-mode restart and env-flag behavior

12 files changed, 386 insertions(+), 74 deletions(-)

apps/server/scripts/logger-scope-repro.ts created+66
...@@ -0,0 +1,66 @@
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import path from "node:path";
4
5import { Effect, FileSystem, Layer, Logger, ServiceMap } from "effect";
6
7import { makeEventNdjsonLogger } from "../src/provider/Layers/EventNdjsonLogger.ts";
8
9class LogDir extends ServiceMap.Service<LogDir, string>()("t3/scripts/logger-scope-repro/LogDir") {}
10
11const main = Effect.gen(function* () {
12 const logdir = yield* LogDir;
13 const providerLogPath = path.join(logdir, "provider");
14
15 yield* Effect.logInfo(`providerLogPath=${providerLogPath}`);
16
17 const providerLogger = yield* makeEventNdjsonLogger(providerLogPath, {
18 stream: "native",
19 batchWindowMs: 10,
20 });
21
22 yield* Effect.logInfo("before provider write");
23
24 if (providerLogger) {
25 yield* providerLogger.write(
26 {
27 kind: "probe",
28 message: "provider-only event",
29 },
30 "thread-123" as never,
31 );
32 }
33
34 yield* Effect.logInfo("after provider write");
35 yield* Effect.sleep("50 millis");
36
37 if (providerLogger) {
38 yield* providerLogger.close();
39 }
40 yield* Effect.logInfo("after provider close");
41
42 const fs = yield* FileSystem.FileSystem;
43 const logContents = yield* fs
44 .readDirectory(logdir, { recursive: true })
45 .pipe(
46 Effect.flatMap((entries) =>
47 Effect.all(entries.map((entry) => fs.readFileString(path.join(logdir, entry)))),
48 ),
49 );
50 yield* Effect.logInfo(`logContents=${logContents}`);
51});
52
53Effect.gen(function* () {
54 const fs = yield* FileSystem.FileSystem;
55 const logdir = path.join(process.cwd(), "logtest");
56 yield* fs.makeDirectory(logdir);
57
58 const fileLogger = yield* Logger.formatSimple.pipe(
59 Logger.toFile(path.join(logdir, "global.log")),
60 );
61 const dualLogger = Logger.layer([fileLogger, Logger.consolePretty()]);
62
63 const mainLayer = Layer.mergeAll(dualLogger, Layer.succeed(LogDir, logdir));
64
65 yield* main.pipe(Effect.provide(mainLayer));
66}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
apps/server/src/codexAppServerManager.ts+42
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type ProviderSessionStartInput,18 type ProviderSessionStartInput,
19 type ProviderTurnStartResult,19 type ProviderTurnStartResult,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { Effect } from "effect";
2122
22type PendingRequestKey = string;23type PendingRequestKey = string;
2324
...@@ -265,6 +266,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -265,6 +266,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
265 experimentalRawEvents: false,266 experimentalRawEvents: false,
266 };267 };
267 const resumeThreadId = readResumeThreadId(input);268 const resumeThreadId = readResumeThreadId(input);
269 this.emitLifecycleEvent(
270 context,
271 "session/threadOpenRequested",
272 resumeThreadId
273 ? `Attempting to resume thread ${resumeThreadId}.`
274 : "Starting a new Codex thread.",
275 );
276 await Effect.logInfo("codex app-server opening thread", {
277 sessionId,
278 requestedRuntimeMode: input.runtimeMode,
279 requestedModel: normalizedModel ?? null,
280 requestedCwd: resolvedCwd,
281 resumeThreadId: resumeThreadId ?? null,
282 }).pipe(Effect.runPromise);
268283
269 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";284 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
270 let threadOpenResponse: unknown;285 let threadOpenResponse: unknown;
...@@ -277,6 +292,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -277,6 +292,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
277 });292 });
278 } catch (error) {293 } catch (error) {
279 if (!isRecoverableThreadResumeError(error)) {294 if (!isRecoverableThreadResumeError(error)) {
295 this.emitErrorEvent(
296 context,
297 "session/threadResumeFailed",
298 error instanceof Error ? error.message : "Codex thread resume failed.",
299 );
300 await Effect.logWarning("codex app-server thread resume failed", {
301 sessionId,
302 requestedRuntimeMode: input.runtimeMode,
303 resumeThreadId,
304 recoverable: false,
305 cause: error instanceof Error ? error.message : String(error),
306 }).pipe(Effect.runPromise);
280 throw error;307 throw error;
281 }308 }
282309
...@@ -286,6 +313,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -286,6 +313,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
286 "session/threadResumeFallback",313 "session/threadResumeFallback",
287 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,314 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,
288 );315 );
316 await Effect.logWarning("codex app-server thread resume fell back to fresh start", {
317 sessionId,
318 requestedRuntimeMode: input.runtimeMode,
319 resumeThreadId,
320 recoverable: true,
321 cause: error instanceof Error ? error.message : String(error),
322 }).pipe(Effect.runPromise);
289 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);323 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
290 }324 }
291 } else {325 } else {
...@@ -307,6 +341,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -307,6 +341,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
307 threadId,341 threadId,
308 resumeCursor: { threadId },342 resumeCursor: { threadId },
309 });343 });
344 this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`);
345 await Effect.logInfo("codex app-server thread open resolved", {
346 sessionId,
347 threadOpenMethod,
348 requestedResumeThreadId: resumeThreadId ?? null,
349 resolvedThreadId: threadId,
350 requestedRuntimeMode: input.runtimeMode,
351 }).pipe(Effect.runPromise);
310 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);352 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);
311 return { ...context.session };353 return { ...context.session };
312 } catch (error) {354 } catch (error) {
apps/server/src/main.ts+21-26
...@@ -24,6 +24,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite";...@@ -24,6 +24,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite";
24import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";24import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
25import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";25import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
26import { Server } from "./wsServer";26import { Server } from "./wsServer";
27import { ServerLoggerLive } from "./serverLogger";
2728
28export class StartupError extends Data.TaggedError("StartupError")<{29export class StartupError extends Data.TaggedError("StartupError")<{
29 readonly message: string;30 readonly message: string;
...@@ -120,6 +121,9 @@ const CliEnvConfig = Config.all({...@@ -120,6 +121,9 @@ const CliEnvConfig = Config.all({
120 ),121 ),
121});122});
122123
124const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
125 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
126
123const ServerConfigLive = (input: CliInput) =>127const ServerConfigLive = (input: CliInput) =>
124 Layer.effect(128 Layer.effect(
125 ServerConfig,129 ServerConfig,
...@@ -151,25 +155,16 @@ const ServerConfigLive = (input: CliInput) =>...@@ -151,25 +155,16 @@ const ServerConfigLive = (input: CliInput) =>
151 Option.getOrUndefined(input.stateDir) ?? env.stateDir,155 Option.getOrUndefined(input.stateDir) ?? env.stateDir,
152 );156 );
153 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);157 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);
154 const noBrowser = Option.match(input.noBrowser, {158 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
155 // effect/cli boolean flags parse to `false` when absent; in that case
156 // we still want env/mode fallbacks to apply.
157 onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")),
158 onNone: () => env.noBrowser ?? mode === "desktop",
159 });
160 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;159 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
161 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {160 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
162 // effect/cli boolean flags parse to `false` when absent; in that case161 input.autoBootstrapProjectFromCwd,
163 // we still want env/mode fallbacks to apply.162 env.autoBootstrapProjectFromCwd ?? mode === "web",
164 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")),163 );
165 onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web",164 const logWebSocketEvents = resolveBooleanFlag(
166 });165 input.logWebSocketEvents,
167 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {166 env.logWebSocketEvents ?? Boolean(devUrl),
168 // effect/cli boolean flags parse to `false` when absent; in that case167 );
169 // we still want env/dev fallbacks to apply.
170 onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))),
171 onNone: () => env.logWebSocketEvents ?? Boolean(devUrl),
172 });
173 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;168 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
174 const { join } = yield* Path.Path;169 const { join } = yield* Path.Path;
175 const keybindingsConfigPath = join(stateDir, "keybindings.json");170 const keybindingsConfigPath = join(stateDir, "keybindings.json");
...@@ -178,7 +173,7 @@ const ServerConfigLive = (input: CliInput) =>...@@ -178,7 +173,7 @@ const ServerConfigLive = (input: CliInput) =>
178 env.host ??173 env.host ??
179 (mode === "desktop" ? "127.0.0.1" : undefined);174 (mode === "desktop" ? "127.0.0.1" : undefined);
180175
181 return {176 const config: ServerConfigShape = {
182 mode,177 mode,
183 port,178 port,
184 cwd: cliConfig.cwd,179 cwd: cliConfig.cwd,
...@@ -192,6 +187,8 @@ const ServerConfigLive = (input: CliInput) =>...@@ -192,6 +187,8 @@ const ServerConfigLive = (input: CliInput) =>
192 autoBootstrapProjectFromCwd,187 autoBootstrapProjectFromCwd,
193 logWebSocketEvents,188 logWebSocketEvents,
194 } satisfies ServerConfigShape;189 } satisfies ServerConfigShape;
190
191 return config;
195 }),192 }),
196 );193 );
197194
...@@ -201,6 +198,7 @@ const LayerLive = (input: CliInput) =>...@@ -201,6 +198,7 @@ const LayerLive = (input: CliInput) =>
201 Layer.provideMerge(makeServerProviderLayer()),198 Layer.provideMerge(makeServerProviderLayer()),
202 Layer.provideMerge(ProviderHealthLive),199 Layer.provideMerge(ProviderHealthLive),
203 Layer.provideMerge(SqlitePersistence.layerConfig),200 Layer.provideMerge(SqlitePersistence.layerConfig),
201 Layer.provideMerge(ServerLoggerLive),
204 Layer.provideMerge(ServerConfigLive(input)),202 Layer.provideMerge(ServerConfigLive(input)),
205 );203 );
206204
...@@ -235,14 +233,11 @@ const makeServerProgram = (input: CliInput) =>...@@ -235,14 +233,11 @@ const makeServerProgram = (input: CliInput) =>
235 config.host && !isWildcardHost(config.host)233 config.host && !isWildcardHost(config.host)
236 ? `http://${formatHostForUrl(config.host)}:${config.port}`234 ? `http://${formatHostForUrl(config.host)}:${config.port}`
237 : localUrl;235 : localUrl;
236 const { authToken, devUrl, ...safeConfig } = config;
238 yield* Effect.logInfo("T3 Code running", {237 yield* Effect.logInfo("T3 Code running", {
239 url: bindUrl,238 ...safeConfig,
240 localUrl,239 devUrl: devUrl?.toString(),
241 bindHost: config.host ?? "default",240 authEnabled: Boolean(authToken),
242 cwd: config.cwd,
243 mode: config.mode,
244 stateDir: config.stateDir,
245 authEnabled: Boolean(config.authToken),
246 });241 });
247242
248 if (!config.noBrowser) {243 if (!config.noBrowser) {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+30
...@@ -509,6 +509,16 @@ describe("ProviderCommandReactor", () => {...@@ -509,6 +509,16 @@ describe("ProviderCommandReactor", () => {
509 const harness = await createHarness();509 const harness = await createHarness();
510 const now = new Date().toISOString();510 const now = new Date().toISOString();
511511
512 await Effect.runPromise(
513 harness.engine.dispatch({
514 type: "thread.runtime-mode.set",
515 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"),
516 threadId: ThreadId.makeUnsafe("thread-1"),
517 runtimeMode: "full-access",
518 createdAt: now,
519 }),
520 );
521
512 await Effect.runPromise(522 await Effect.runPromise(
513 harness.engine.dispatch({523 harness.engine.dispatch({
514 type: "thread.turn.start",524 type: "thread.turn.start",
...@@ -538,6 +548,11 @@ describe("ProviderCommandReactor", () => {...@@ -538,6 +548,11 @@ describe("ProviderCommandReactor", () => {
538 }),548 }),
539 );549 );
540550
551 await waitFor(async () => {
552 const readModel = await Effect.runPromise(harness.engine.getReadModel());
553 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
554 return thread?.runtimeMode === "approval-required";
555 });
541 await waitFor(() => harness.startSession.mock.calls.length === 2);556 await waitFor(() => harness.startSession.mock.calls.length === 2);
542 await waitFor(() => harness.stopSession.mock.calls.length === 1);557 await waitFor(() => harness.stopSession.mock.calls.length === 1);
543558
...@@ -636,6 +651,16 @@ describe("ProviderCommandReactor", () => {...@@ -636,6 +651,16 @@ describe("ProviderCommandReactor", () => {
636 const harness = await createHarness();651 const harness = await createHarness();
637 const now = new Date().toISOString();652 const now = new Date().toISOString();
638653
654 await Effect.runPromise(
655 harness.engine.dispatch({
656 type: "thread.runtime-mode.set",
657 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"),
658 threadId: ThreadId.makeUnsafe("thread-1"),
659 runtimeMode: "full-access",
660 createdAt: now,
661 }),
662 );
663
639 await Effect.runPromise(664 await Effect.runPromise(
640 harness.engine.dispatch({665 harness.engine.dispatch({
641 type: "thread.turn.start",666 type: "thread.turn.start",
...@@ -669,6 +694,11 @@ describe("ProviderCommandReactor", () => {...@@ -669,6 +694,11 @@ describe("ProviderCommandReactor", () => {
669 }),694 }),
670 );695 );
671696
697 await waitFor(async () => {
698 const readModel = await Effect.runPromise(harness.engine.getReadModel());
699 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
700 return thread?.runtimeMode === "approval-required";
701 });
672 await waitFor(() => harness.startSession.mock.calls.length === 2);702 await waitFor(() => harness.startSession.mock.calls.length === 2);
673 await Effect.runPromise(Effect.sleep("30 millis"));703 await Effect.runPromise(Effect.sleep("30 millis"));
674704
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+21
...@@ -283,10 +283,31 @@ const make = Effect.gen(function* () {...@@ -283,10 +283,31 @@ const make = Effect.gen(function* () {
283 providerChanged || shouldRestartForModelChange283 providerChanged || shouldRestartForModelChange
284 ? undefined284 ? undefined
285 : (activeSession?.resumeCursor ?? undefined);285 : (activeSession?.resumeCursor ?? undefined);
286 yield* Effect.logInfo("provider command reactor restarting provider session", {
287 threadId,
288 existingSessionId,
289 currentProvider,
290 desiredProvider: options?.provider ?? currentProvider,
291 currentRuntimeMode: thread.session?.runtimeMode,
292 desiredRuntimeMode: thread.runtimeMode,
293 runtimeModeChanged,
294 providerChanged,
295 modelChanged,
296 shouldRestartForModelChange,
297 hasResumeCursor: resumeCursor !== undefined,
298 });
286 const restartedSession = yield* startProviderSession({299 const restartedSession = yield* startProviderSession({
287 ...(resumeCursor !== undefined ? { resumeCursor } : {}),300 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
288 ...(options?.provider !== undefined ? { provider: options.provider } : {}),301 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
289 });302 });
303 yield* Effect.logInfo("provider command reactor restarted provider session", {
304 threadId,
305 previousSessionId: existingSessionId,
306 restartedSessionId: restartedSession.sessionId,
307 provider: restartedSession.provider,
308 providerThreadId: restartedSession.threadId,
309 runtimeMode: restartedSession.runtimeMode,
310 });
290 yield* bindSessionToThread(restartedSession);311 yield* bindSessionToThread(restartedSession);
291 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(312 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
292 Effect.catchCause((cause) =>313 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.projectScripts.test.ts+6-4
...@@ -251,10 +251,12 @@ describe("decider project scripts", () => {...@@ -251,10 +251,12 @@ describe("decider project scripts", () => {
251 if (singleResult === null) {251 if (singleResult === null) {
252 throw new Error("Expected a single runtime-mode-set event.");252 throw new Error("Expected a single runtime-mode-set event.");
253 }253 }
254 expect(singleResult.type).toBe("thread.runtime-mode-set");254 expect(singleResult).toMatchObject({
255 expect(singleResult.payload).toMatchObject({255 type: "thread.runtime-mode-set",
256 threadId: ThreadId.makeUnsafe("thread-1"),256 payload: {
257 runtimeMode: "approval-required",257 threadId: ThreadId.makeUnsafe("thread-1"),
258 runtimeMode: "approval-required",
259 },
258 });260 });
259 });261 });
260});262});
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+6-5
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 RuntimeItemId,34 RuntimeItemId,
35 RuntimeRequestId,35 RuntimeRequestId,
36 RuntimeSessionId,36 RuntimeSessionId,
37 RuntimeTaskId,
37 ThreadId,38 ThreadId,
38 TurnId,39 TurnId,
39} from "@t3tools/contracts";40} from "@t3tools/contracts";
...@@ -1130,7 +1131,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1130,7 +1131,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1130 ...base,1131 ...base,
1131 type: "task.started",1132 type: "task.started",
1132 payload: {1133 payload: {
1133 taskId: message.task_id,1134 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1134 description: message.description,1135 description: message.description,
1135 ...(message.task_type ? { taskType: message.task_type } : {}),1136 ...(message.task_type ? { taskType: message.task_type } : {}),
1136 },1137 },
...@@ -1141,7 +1142,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1141,7 +1142,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1141 ...base,1142 ...base,
1142 type: "task.progress",1143 type: "task.progress",
1143 payload: {1144 payload: {
1144 taskId: message.task_id,1145 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1145 description: message.description,1146 description: message.description,
1146 ...(message.usage ? { usage: message.usage } : {}),1147 ...(message.usage ? { usage: message.usage } : {}),
1147 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),1148 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
...@@ -1153,7 +1154,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1153,7 +1154,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1153 ...base,1154 ...base,
1154 type: "task.completed",1155 type: "task.completed",
1155 payload: {1156 payload: {
1156 taskId: message.task_id,1157 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1157 status: message.status,1158 status: message.status,
1158 ...(message.summary ? { summary: message.summary } : {}),1159 ...(message.summary ? { summary: message.summary } : {}),
1159 ...(message.usage ? { usage: message.usage } : {}),1160 ...(message.usage ? { usage: message.usage } : {}),
...@@ -1259,7 +1260,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1259,7 +1260,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1259 return;1260 return;
1260 }1261 }
12611262
1262 if (message.type === "rate_limit") {1263 if (message.type === "rate_limit_event") {
1263 yield* offerRuntimeEvent({1264 yield* offerRuntimeEvent({
1264 ...base,1265 ...base,
1265 type: "account.rate-limits.updated",1266 type: "account.rate-limits.updated",
...@@ -1295,7 +1296,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1295,7 +1296,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1295 case "tool_progress":1296 case "tool_progress":
1296 case "tool_use_summary":1297 case "tool_use_summary":
1297 case "auth_status":1298 case "auth_status":
1298 case "rate_limit":1299 case "rate_limit_event":
1299 yield* handleSdkTelemetryMessage(context, message);1300 yield* handleSdkTelemetryMessage(context, message);
1300 return;1301 return;
1301 default:1302 default:
apps/server/src/provider/Layers/ProviderService.ts+44-3
...@@ -539,9 +539,50 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -539,9 +539,50 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
539 });539 });
540540
541 const listSessions: ProviderServiceShape["listSessions"] = () =>541 const listSessions: ProviderServiceShape["listSessions"] = () =>
542 Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe(542 Effect.gen(function* () {
543 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),543 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());
544 );544 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
545 const persistedBindings = yield* directory
546 .listSessionIds()
547 .pipe(
548 Effect.flatMap((sessionIds) =>
549 Effect.forEach(
550 sessionIds,
551 (sessionId) =>
552 directory.getBinding(sessionId).pipe(
553 Effect.orElseSucceed(() => Option.none<ProviderSessionBinding>()),
554 ),
555 { concurrency: "unbounded" },
556 ),
557 ),
558 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>),
559 );
560 const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>();
561 for (const bindingOption of persistedBindings) {
562 const binding = Option.getOrUndefined(bindingOption);
563 if (binding) {
564 bindingsBySessionId.set(binding.sessionId, binding);
565 }
566 }
567
568 return activeSessions.map((session) => {
569 const binding = bindingsBySessionId.get(session.sessionId);
570 if (!binding) {
571 return session;
572 }
573
574 return {
575 ...session,
576 ...(session.threadId === undefined && binding.providerThreadId
577 ? { threadId: binding.providerThreadId }
578 : {}),
579 ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined
580 ? { resumeCursor: binding.resumeCursor }
581 : {}),
582 ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}),
583 };
584 });
585 });
545586
546 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>587 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>
547 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));588 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));
apps/server/src/serverLogger.ts created+27
...@@ -0,0 +1,27 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import { Effect, Logger, Option } from "effect";
5import * as Layer from "effect/Layer";
6
7import { ServerConfig } from "./config";
8
9export const ServerLoggerLive = Effect.gen(function* () {
10 const config = yield* Effect.serviceOption(ServerConfig);
11 if (Option.isNone(config)) {
12 return Logger.layer([Logger.defaultLogger]);
13 }
14
15 const logDir = path.join(config.value.stateDir, "logs");
16 const logPath = path.join(logDir, "server.log");
17
18 yield* Effect.sync(() => {
19 fs.mkdirSync(logDir, { recursive: true });
20 });
21
22 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath));
23
24 return Logger.layer([Logger.defaultLogger, fileLogger], {
25 mergeWithExisting: false,
26 });
27}).pipe(Layer.unwrap);
apps/server/src/wsServer.ts+32-30
...@@ -613,6 +613,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -613,6 +613,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
613 );613 );
614 }614 }
615615
616 const runPromise = yield* Effect.map(Effect.services<never>(), Effect.runPromiseWith);
617 yield* Effect.addFinalizer(() =>
618 Effect.catch(liveProviderService.stopAll(), (cause) =>
619 Effect.logWarning("failed to stop provider service", { cause }),
620 ),
621 );
622
623 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
624 (event) => void Effect.runPromise(onTerminalEvent(event)),
625 );
626 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
627
628 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
629 Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
630 );
631
632 yield* Effect.addFinalizer(() =>
633 Effect.all([
634 closeAllClients,
635 closeWebSocketServer.pipe(
636 Effect.catch((error) =>
637 Effect.logWarning("failed to close web socket server", { cause: error }),
638 ),
639 ),
640 ]),
641 );
642
616 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {643 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
617 switch (request.body._tag) {644 switch (request.body._tag) {
618 case ORCHESTRATION_WS_METHODS.getSnapshot:645 case ORCHESTRATION_WS_METHODS.getSnapshot:
...@@ -800,6 +827,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -800,6 +827,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
800 id: request.value.id,827 id: request.value.id,
801 result: result.value,828 result: result.value,
802 });829 });
830
803 ws.send(response);831 ws.send(response);
804 });832 });
805833
...@@ -828,7 +856,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -828,7 +856,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
828 });856 });
829857
830 wss.on("connection", (ws) => {858 wss.on("connection", (ws) => {
831 void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws)));859 void runPromise(Ref.update(clients, (clients) => clients.add(ws)));
832860
833 const segments = cwd.split(/[/\\]/).filter(Boolean);861 const segments = cwd.split(/[/\\]/).filter(Boolean);
834 const projectName = segments[segments.length - 1] ?? "project";862 const projectName = segments[segments.length - 1] ?? "project";
...@@ -847,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -847,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
847 ws.send(JSON.stringify(welcome));875 ws.send(JSON.stringify(welcome));
848876
849 ws.on("message", (raw) => {877 ws.on("message", (raw) => {
850 void Effect.runPromise(878 void runPromise(
851 handleMessage(ws, raw).pipe(879 handleMessage(ws, raw).pipe(
852 Effect.catch((error) => Effect.logError("Error handling message", error)),880 Effect.catch((error) => Effect.logError("Error handling message", error)),
853 ),881 ),
...@@ -855,7 +883,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -855,7 +883,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
855 });883 });
856884
857 ws.on("close", () => {885 ws.on("close", () => {
858 void Effect.runPromise(886 void runPromise(
859 Ref.update(clients, (clients) => {887 Ref.update(clients, (clients) => {
860 clients.delete(ws);888 clients.delete(ws);
861 return clients;889 return clients;
...@@ -864,7 +892,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -864,7 +892,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
864 });892 });
865893
866 ws.on("error", () => {894 ws.on("error", () => {
867 void Effect.runPromise(895 void runPromise(
868 Ref.update(clients, (clients) => {896 Ref.update(clients, (clients) => {
869 clients.delete(ws);897 clients.delete(ws);
870 return clients;898 return clients;
...@@ -873,32 +901,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -873,32 +901,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
873 });901 });
874 });902 });
875903
876 yield* Effect.addFinalizer(() =>
877 Effect.catch(liveProviderService.stopAll(), (cause) =>
878 Effect.logWarning("failed to stop provider service", { cause }),
879 ),
880 );
881
882 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
883 (event) => void Effect.runPromise(onTerminalEvent(event)),
884 );
885 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
886
887 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
888 Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
889 );
890
891 yield* Effect.addFinalizer(() =>
892 Effect.all([
893 closeAllClients,
894 closeWebSocketServer.pipe(
895 Effect.catch((error) =>
896 Effect.logWarning("failed to close web socket server", { cause: error }),
897 ),
898 ),
899 ]),
900 );
901
902 return httpServer;904 return httpServer;
903});905});
904906
scripts/dev-runner.test.ts+45
...@@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
97 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/");97 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/");
98 }),98 }),
99 );99 );
100
101 it.effect("does not force websocket logging on in dev mode when unset", () =>
102 Effect.gen(function* () {
103 const env = yield* createDevRunnerEnv({
104 mode: "dev",
105 baseEnv: {
106 T3CODE_LOG_WS_EVENTS: "keep-me-out",
107 },
108 serverOffset: 0,
109 webOffset: 0,
110 stateDir: undefined,
111 authToken: undefined,
112 noBrowser: undefined,
113 autoBootstrapProjectFromCwd: undefined,
114 logWebSocketEvents: undefined,
115 host: undefined,
116 port: undefined,
117 devUrl: undefined,
118 });
119
120 assert.equal(env.T3CODE_MODE, "web");
121 assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined);
122 }),
123 );
124
125 it.effect("forwards explicit websocket logging false without coercing it away", () =>
126 Effect.gen(function* () {
127 const env = yield* createDevRunnerEnv({
128 mode: "dev",
129 baseEnv: {},
130 serverOffset: 0,
131 webOffset: 0,
132 stateDir: undefined,
133 authToken: undefined,
134 noBrowser: undefined,
135 autoBootstrapProjectFromCwd: undefined,
136 logWebSocketEvents: false,
137 host: undefined,
138 port: undefined,
139 devUrl: undefined,
140 });
141
142 assert.equal(env.T3CODE_LOG_WS_EVENTS, "0");
143 }),
144 );
100 });145 });
101146
102 describe("findFirstAvailableOffset", () => {147 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+46-6
...@@ -189,9 +189,6 @@ export function createDevRunnerEnv({...@@ -189,9 +189,6 @@ export function createDevRunnerEnv({
189189
190 if (mode === "dev") {190 if (mode === "dev") {
191 output.T3CODE_MODE = "web";191 output.T3CODE_MODE = "web";
192 if (logWebSocketEvents === undefined) {
193 output.T3CODE_LOG_WS_EVENTS = "1";
194 }
195 delete output.T3CODE_DESKTOP_WS_URL;192 delete output.T3CODE_DESKTOP_WS_URL;
196 }193 }
197194
...@@ -350,6 +347,35 @@ interface DevRunnerCliInput {...@@ -350,6 +347,35 @@ interface DevRunnerCliInput {
350 readonly turboArgs: ReadonlyArray<string>;347 readonly turboArgs: ReadonlyArray<string>;
351}348}
352349
350const readOptionalBooleanEnv = (name: string): boolean | undefined => {
351 const value = process.env[name];
352 if (value === undefined) {
353 return undefined;
354 }
355 if (value === "1" || value.toLowerCase() === "true") {
356 return true;
357 }
358 if (value === "0" || value.toLowerCase() === "false") {
359 return false;
360 }
361 return undefined;
362};
363
364const resolveOptionalBooleanOverride = (
365 explicitValue: boolean | undefined,
366 envValue: boolean | undefined,
367): boolean | undefined => {
368 if (explicitValue === true) {
369 return true;
370 }
371
372 if (explicitValue === false) {
373 return envValue;
374 }
375
376 return envValue;
377};
378
353export function runDevRunnerWithInput(input: DevRunnerCliInput) {379export function runDevRunnerWithInput(input: DevRunnerCliInput) {
354 return Effect.gen(function* () {380 return Effect.gen(function* () {
355 const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe(381 const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe(
...@@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
371 }),397 }),
372 });398 });
373399
400 const envOverrides = {
401 noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"),
402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv(
403 "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD",
404 ),
405 logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"),
406 };
407
374 const { serverOffset, webOffset } = yield* resolveModePortOffsets({408 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
375 mode: input.mode,409 mode: input.mode,
376 startOffset: offset,410 startOffset: offset,
...@@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
385 webOffset,419 webOffset,
386 stateDir: input.stateDir,420 stateDir: input.stateDir,
387 authToken: input.authToken,421 authToken: input.authToken,
388 noBrowser: input.noBrowser,422 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),
389 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,423 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(
390 logWebSocketEvents: input.logWebSocketEvents,424 input.autoBootstrapProjectFromCwd,
425 envOverrides.autoBootstrapProjectFromCwd,
426 ),
427 logWebSocketEvents: resolveOptionalBooleanOverride(
428 input.logWebSocketEvents,
429 envOverrides.logWebSocketEvents,
430 ),
391 host: input.host,431 host: input.host,
392 port: input.port,432 port: input.port,
393 devUrl: input.devUrl,433 devUrl: input.devUrl,