Skip to content

Commit 7d3afd5

Browse files
just-cameronletta-codecpacker
authored
fix: repair stale conversation working directories (#3318)
Co-authored-by: Letta Code <noreply@letta.com> Co-authored-by: cpacker <packercharles@gmail.com>
1 parent bf2ded3 commit 7d3afd5

24 files changed

Lines changed: 2280 additions & 55 deletions

scripts/check-test-coverage.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const ciDirs = [
7373
"src/cli",
7474
"src/cron",
7575
"src/experiments",
76+
"src/helpers",
7677
"src/hooks",
7778
"src/lsp",
7879
"src/mods",

scripts/isolated-unit-tests.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@
6969
"path": "src/websocket/listener/auth-lifecycle.test.ts",
7070
"timeoutMs": 30000,
7171
"reason": "Exercises real WebSockets while mutating listener, settings, HOME, and process environment state."
72+
},
73+
{
74+
"path": "src/websocket/listener/control-inputs.test.ts",
75+
"timeoutMs": 15000,
76+
"reason": "Mutates HOME and the remote-settings singleton while exercising cwd recovery persistence."
77+
},
78+
{
79+
"path": "src/websocket/listener/protocol-ergonomics.test.ts",
80+
"timeoutMs": 15000,
81+
"reason": "Mutates HOME and the remote-settings singleton while verifying durable cwd-map pruning."
82+
},
83+
{
84+
"path": "src/websocket/listener/remote-settings.test.ts",
85+
"timeoutMs": 15000,
86+
"reason": "Mutates HOME and the remote-settings singleton to verify startup cwd-map repair."
7287
}
7388
]
7489
}

scripts/run-unit-tests.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const dirs = [
1212
"src/cli",
1313
"src/cron",
1414
"src/experiments",
15+
"src/helpers",
1516
"src/hooks",
1617
"src/lsp",
1718
"src/mods",

src/cli/subcommands/listen.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
MissingListenerApiKeyError,
3333
resolveListenerRegistrationOptions,
3434
} from "@/websocket/listener/auth";
35+
import { flushRemoteSettingsWrites } from "@/websocket/listener/remote-settings";
3536

3637
type ListenerProcessAnchor = {
3738
close: () => void;
@@ -269,13 +270,18 @@ export async function runListenSubcommand(argv: string[]): Promise<number> {
269270
await settingsManager.initialize();
270271
await applyStartupPermissionMode({});
271272
telemetry.setSurface(getListenerTelemetrySurface());
272-
telemetry.init();
273+
telemetry.init({ handleSigint: false });
273274

274275
// Register signal handlers so the listener can clean up child processes
275276
// (subagents, bash commands, PTY sessions) before exiting. Without these,
276277
// SIGTERM from the desktop app only kills the listener process itself,
277278
// orphaning its descendants which accumulate over time.
278-
const handleShutdownSignal = async (): Promise<void> => {
279+
let isShuttingDown = false;
280+
const handleShutdownSignal = async (
281+
signal: "SIGINT" | "SIGHUP" | "SIGTERM",
282+
): Promise<void> => {
283+
if (isShuttingDown) return;
284+
isShuttingDown = true;
279285
try {
280286
const { stopListenerClient, isListenerActive } = await import(
281287
"@/websocket/listen-client"
@@ -292,11 +298,14 @@ export async function runListenSubcommand(argv: string[]): Promise<number> {
292298
} catch {
293299
// Best-effort cleanup — don't block exit
294300
}
301+
await flushRemoteSettingsWrites();
302+
await flushListenerTelemetryEnd(`listener_${signal.toLowerCase()}`);
295303
process.exit(0);
296304
};
297305

298-
process.once("SIGTERM", handleShutdownSignal);
299-
process.once("SIGINT", handleShutdownSignal);
306+
process.once("SIGTERM", () => void handleShutdownSignal("SIGTERM"));
307+
process.once("SIGINT", () => void handleShutdownSignal("SIGINT"));
308+
process.once("SIGHUP", () => void handleShutdownSignal("SIGHUP"));
300309

301310
const exitWithTelemetry = async (
302311
code: number,
@@ -312,6 +321,7 @@ export async function runListenSubcommand(argv: string[]): Promise<number> {
312321
} catch {
313322
// Best effort — don't block exit on channel cleanup failure
314323
}
324+
await flushRemoteSettingsWrites();
315325
await flushListenerTelemetryEnd(exitReason);
316326
process.exit(code);
317327
};
@@ -772,6 +782,7 @@ export async function runListenSubcommand(argv: string[]): Promise<number> {
772782
const msg = error instanceof Error ? error.message : String(error);
773783
sessionLog.log(`FATAL: ${msg}`);
774784
console.error(`Failed to start listener: ${msg}`);
785+
await flushRemoteSettingsWrites();
775786
await flushListenerTelemetryEnd("listener_start_failed");
776787
return 1;
777788
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
getDirectoryUsability,
4+
isConfirmedUnusableDirectory,
5+
} from "./usable-directory";
6+
7+
describe("directory usability", () => {
8+
test("classifies missing paths and regular files as confirmed unusable", () => {
9+
expect(getDirectoryUsability("/missing", () => undefined)).toBe("missing");
10+
expect(
11+
getDirectoryUsability("/file", () => ({ isDirectory: () => false })),
12+
).toBe("not-directory");
13+
});
14+
15+
test("preserves paths when stat fails unexpectedly", () => {
16+
for (const code of ["EACCES", "EIO", "EMFILE"]) {
17+
const statDirectory = () => {
18+
throw Object.assign(new Error("unexpected stat failure"), { code });
19+
};
20+
21+
expect(getDirectoryUsability("/restricted", statDirectory)).toBe(
22+
"unknown",
23+
);
24+
expect(isConfirmedUnusableDirectory("/restricted", statDirectory)).toBe(
25+
false,
26+
);
27+
}
28+
});
29+
30+
test("treats thrown missing-path errors as confirmed unusable", () => {
31+
for (const code of ["ENOENT", "ENOTDIR"]) {
32+
const statDirectory = () => {
33+
throw Object.assign(new Error("missing path"), { code });
34+
};
35+
36+
expect(getDirectoryUsability("/missing", statDirectory)).toBe("missing");
37+
expect(isConfirmedUnusableDirectory("/missing", statDirectory)).toBe(
38+
true,
39+
);
40+
}
41+
});
42+
});

src/helpers/usable-directory.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
1-
import { statSync } from "node:fs";
1+
import { type Stats, statSync } from "node:fs";
22

3-
/** True when the path exists and is a directory. */
4-
export function isUsableDirectory(dirPath: string | null | undefined): boolean {
3+
export type DirectoryUsability =
4+
| "usable"
5+
| "missing"
6+
| "not-directory"
7+
| "unknown";
8+
9+
type DirectoryStat = (
10+
dirPath: string,
11+
) => Pick<Stats, "isDirectory"> | undefined;
12+
13+
function readDirectoryStats(dirPath: string): Stats | undefined {
14+
return statSync(dirPath, { throwIfNoEntry: false });
15+
}
16+
17+
export function getDirectoryUsability(
18+
dirPath: string | null | undefined,
19+
statDirectory: DirectoryStat = readDirectoryStats,
20+
): DirectoryUsability {
521
if (typeof dirPath !== "string" || dirPath.length === 0) {
6-
return false;
22+
return "missing";
723
}
824

925
try {
10-
return statSync(dirPath, { throwIfNoEntry: false })?.isDirectory() ?? false;
11-
} catch {
12-
return false;
26+
const stats = statDirectory(dirPath);
27+
if (!stats) {
28+
return "missing";
29+
}
30+
return stats.isDirectory() ? "usable" : "not-directory";
31+
} catch (error) {
32+
const code = (error as NodeJS.ErrnoException | null)?.code;
33+
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
1334
}
1435
}
36+
37+
export function isConfirmedUnusableDirectory(
38+
dirPath: string | null | undefined,
39+
statDirectory?: DirectoryStat,
40+
): boolean {
41+
const usability = getDirectoryUsability(dirPath, statDirectory);
42+
return usability === "missing" || usability === "not-directory";
43+
}
44+
45+
/** True when the path exists and is a directory. */
46+
export function isUsableDirectory(dirPath: string | null | undefined): boolean {
47+
return getDirectoryUsability(dirPath) === "usable";
48+
}

src/runtime-context.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AsyncLocalStorage } from "node:async_hooks";
2-
import { homedir } from "node:os";
2+
import { homedir, tmpdir } from "node:os";
33
import type { SkillSource } from "./agent/skills";
44
import type { MessageChannelToolDiscoveryScope } from "./channels/message-tool";
55
import type { ChannelTurnSource } from "./channels/types";
@@ -92,11 +92,12 @@ function getProcessWorkingDirectory(): string | null {
9292
}
9393
}
9494

95-
function getFallbackWorkingDirectory(): string {
95+
export function getFallbackWorkingDirectory(): string {
9696
const fallback = [
9797
process.env.USER_CWD,
9898
getProcessWorkingDirectory(),
9999
homedir(),
100+
tmpdir(),
100101
process.platform === "win32" ? undefined : "/",
101102
].find(isUsableDirectory);
102103

src/types/protocol_v2.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -423,16 +423,16 @@ export interface GitContext {
423423
recent_branches: string[];
424424
}
425425

426-
/**
427-
* Bottom-bar and device execution context state.
428-
*/
426+
/** Bottom-bar and device execution context state. */
429427
export interface DeviceStatus {
430428
current_connection_id: string | null;
431429
connection_name: string | null;
432430
is_online: boolean;
433431
is_processing: boolean;
434432
current_permission_mode: DevicePermissionMode;
435433
current_working_directory: string | null;
434+
/** Monotonic signal for cwd changes and rejected stale cwd requests. */
435+
cwd_revision?: number;
436436
git_context: GitContext | null;
437437
letta_code_version: string | null;
438438
current_toolset: ToolsetName | null;

src/websocket/listener/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from "./control-inputs";
5252
import { getOrCreateScopedRuntime } from "./conversation-runtime";
5353
import {
54+
getBootWorkingDirectory,
5455
getConversationWorkingDirectory,
5556
setConversationWorkingDirectory,
5657
} from "./cwd";
@@ -239,7 +240,7 @@ function createLegacyTestRuntime(): ConversationRuntime & {
239240
},
240241
},
241242
bootWorkingDirectory: {
242-
get: () => listener.bootWorkingDirectory,
243+
get: () => getBootWorkingDirectory(listener),
243244
set: (value: string) => {
244245
listener.bootWorkingDirectory = value;
245246
},

src/websocket/listener/commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
emitCanonicalMessageDelta,
4949
emitDeviceStatusUpdate,
5050
} from "./protocol-outbound";
51+
import { flushRemoteSettingsWrites } from "./remote-settings";
5152
import { clearConversationRuntimeState, emitListenerStatus } from "./runtime";
5253
import {
5354
ensureSecretsHydratedForAgent,
@@ -374,7 +375,8 @@ function scheduleRemoteRestart(
374375
}
375376

376377
log(`scheduling remote listener restart for env ${connectionName}`);
377-
setTimeout(() => {
378+
setTimeout(async () => {
379+
await flushRemoteSettingsWrites();
378380
log(
379381
`spawning replacement listener: ${process.execPath} ${entrypoint} remote --env-name ${connectionName}`,
380382
);

0 commit comments

Comments
 (0)