From 0a639da559e0e4424af94ec3a3b2c71fc3c60f86 Mon Sep 17 00:00:00 2001 From: devswha Date: Fri, 6 Nov 2026 10:13:53 +0900 Subject: [PATCH 1/2] fix(chat): remember a letta seen outside its start folder by its stable start The moved-process memory was keyed on processStartLowerBound, whose wall-clock reading shifts after the clock is stepped back, so a letta that left its start folder could bind again. Key it on processStartedAt, the read that names the process. Refs #2314. --- changelog.d/letta-moved-process-identity.md | 3 +++ server/letta-store.ts | 16 ++++++++++------ server/letta.test.ts | 7 +++++-- 3 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 changelog.d/letta-moved-process-identity.md diff --git a/changelog.d/letta-moved-process-identity.md b/changelog.d/letta-moved-process-identity.md new file mode 100644 index 0000000000..4ea48ce582 --- /dev/null +++ b/changelog.d/letta-moved-process-identity.md @@ -0,0 +1,3 @@ +### Fixed +- A Letta Code pane whose letta was seen outside the folder it started in keeps showing Terminal + after the PC's clock is corrected, instead of Chat showing a conversation it may have left. diff --git a/server/letta-store.ts b/server/letta-store.ts index 82572b0bdb..ebdfab989e 100644 --- a/server/letta-store.ts +++ b/server/letta-store.ts @@ -22,7 +22,7 @@ import { basename, isAbsolute, join, normalize } from "node:path"; import type { ConversationMetadata, HerdrPane } from "../shared/protocol.ts"; import { darwinProcess } from "./claude-store.ts"; import { paneProcessInfo, type ProcessInfo } from "./herdr/client.ts"; -import { processStartLowerBound } from "./process-start.ts"; +import { processStartedAt, processStartLowerBound } from "./process-start.ts"; /** Letta's ids: `conv-`, `local-conv-`, `agent-`, `agent-local-`, and `default`. */ const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; @@ -150,10 +150,13 @@ export function isLettaProcess(argv: readonly string[]): boolean { return ["node", "bun"].includes(name(argv[0])) && ["letta", "letta.js"].includes(name(argv[1])); } -/** `startedAt`: no later than the process started (processStartLowerBound) */ -export interface LettaProcess { pid: number; cwd: string; startedAt: number; env: Env } +/** + * `startedAt`: no later than the process started (processStartLowerBound), which may read differently each time; + * `birth`: its start as processStartedAt reads it, the same on every read, so with the pid it names the process + */ +export interface LettaProcess { pid: number; cwd: string; startedAt: number; birth: number; env: Env } -/** Letta processes, by pid and start, seen in a folder other than the one they started in. */ +/** Letta processes, by pid and birth, seen in a folder other than the one they started in. */ const moved = new Set(); /** @@ -164,7 +167,7 @@ const moved = new Set(); * once seen elsewhere, it is never bound again. */ export function lettaInStartFolder(owner: LettaProcess): boolean { - const key = `${owner.pid}:${owner.startedAt}`; + const key = `${owner.pid}:${owner.birth}`; if (moved.has(key)) return false; const start = owner.env["PWD"]; const here = canonical(owner.cwd); @@ -184,8 +187,9 @@ export async function paneLettaProcess(paneId: string, known?: ProcessInfo | nul const { pid, cwd } = found[0]!; if (typeof cwd !== "string" || !isAbsolute(cwd)) return null; const startedAt = processStartLowerBound(pid); + const birth = processStartedAt(pid); const env = processEnvironment(pid); - return startedAt === null || env === null ? null : { pid, cwd, startedAt, env }; + return startedAt === null || birth === null || env === null ? null : { pid, cwd, startedAt, birth, env }; } export type LettaTranscript = { path: string; session: string; agent: string; conversation: string }; diff --git a/server/letta.test.ts b/server/letta.test.ts index edcba71199..e590983cb3 100644 --- a/server/letta.test.ts +++ b/server/letta.test.ts @@ -108,7 +108,7 @@ describe("Letta conversation binding", () => { writeFileSync(join(cwd, ".letta", "settings.local.json"), settings); utimesSync(join(cwd, ".letta", "settings.local.json"), at / 1000, at / 1000); } - return { pid: 4242, cwd, startedAt: started, env: { HOME: root, ...env } }; + return { pid: 4242, cwd, startedAt: started, birth: started, env: { HOME: root, ...env } }; }; const now = Date.now(); @@ -179,7 +179,7 @@ describe("Letta conversation binding", () => { const other = join(root, "project2"); mkdirSync(start); mkdirSync(other); symlinkSync(start, join(root, "link1")); - const at = (pid: number, cwd: string, env: Record): LettaProcess => ({ pid, cwd, startedAt: now, env }); + const at = (pid: number, cwd: string, env: Record, startedAt = now): LettaProcess => ({ pid, cwd, startedAt, birth: now, env }); expect(lettaInStartFolder(at(101, start, { PWD: start }))).toBe(true); expect(lettaInStartFolder(at(102, start, { PWD: join(root, "link1") }))).toBe(true); // no start folder to compare: its shell set no PWD @@ -188,6 +188,9 @@ describe("Letta conversation binding", () => { // SetWorkingDirectory moved it into project2; a later move back does not make project1's record its own again expect(lettaInStartFolder(at(105, other, { PWD: start }))).toBe(false); expect(lettaInStartFolder(at(105, start, { PWD: start }))).toBe(false); + // the lower bound read differently on its return (a stepped wall clock moves it on Linux): still the same process + expect(lettaInStartFolder(at(106, other, { PWD: start }))).toBe(false); + expect(lettaInStartFolder(at(106, start, { PWD: start }, now - 1_000))).toBe(false); }); test("a closed peer of the same agent that reopened the pane's old conversation binds nothing", () => { From 5d9ff3dfadc81654aee487f3c4acbc81d62efb1d Mon Sep 17 00:00:00 2001 From: devswha Date: Fri, 6 Nov 2026 10:35:21 +0900 Subject: [PATCH 2/2] fix(chat): key a moved letta on its start ticks, which no clock change moves --- server/letta-store.ts | 14 +++++++------- server/letta.test.ts | 4 ++-- server/process-start.ts | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/server/letta-store.ts b/server/letta-store.ts index ebdfab989e..7f495472b7 100644 --- a/server/letta-store.ts +++ b/server/letta-store.ts @@ -22,7 +22,7 @@ import { basename, isAbsolute, join, normalize } from "node:path"; import type { ConversationMetadata, HerdrPane } from "../shared/protocol.ts"; import { darwinProcess } from "./claude-store.ts"; import { paneProcessInfo, type ProcessInfo } from "./herdr/client.ts"; -import { processStartedAt, processStartLowerBound } from "./process-start.ts"; +import { processStartIdentity, processStartLowerBound } from "./process-start.ts"; /** Letta's ids: `conv-`, `local-conv-`, `agent-`, `agent-local-`, and `default`. */ const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; @@ -152,11 +152,11 @@ export function isLettaProcess(argv: readonly string[]): boolean { /** * `startedAt`: no later than the process started (processStartLowerBound), which may read differently each time; - * `birth`: its start as processStartedAt reads it, the same on every read, so with the pid it names the process + * `identity`: processStartIdentity, the same on every read, so with the pid it names the process */ -export interface LettaProcess { pid: number; cwd: string; startedAt: number; birth: number; env: Env } +export interface LettaProcess { pid: number; cwd: string; startedAt: number; identity: number; env: Env } -/** Letta processes, by pid and birth, seen in a folder other than the one they started in. */ +/** Letta processes, by pid and start identity, seen in a folder other than the one they started in. */ const moved = new Set(); /** @@ -167,7 +167,7 @@ const moved = new Set(); * once seen elsewhere, it is never bound again. */ export function lettaInStartFolder(owner: LettaProcess): boolean { - const key = `${owner.pid}:${owner.birth}`; + const key = `${owner.pid}:${owner.identity}`; if (moved.has(key)) return false; const start = owner.env["PWD"]; const here = canonical(owner.cwd); @@ -187,9 +187,9 @@ export async function paneLettaProcess(paneId: string, known?: ProcessInfo | nul const { pid, cwd } = found[0]!; if (typeof cwd !== "string" || !isAbsolute(cwd)) return null; const startedAt = processStartLowerBound(pid); - const birth = processStartedAt(pid); + const identity = processStartIdentity(pid); const env = processEnvironment(pid); - return startedAt === null || birth === null || env === null ? null : { pid, cwd, startedAt, birth, env }; + return startedAt === null || identity === null || env === null ? null : { pid, cwd, startedAt, identity, env }; } export type LettaTranscript = { path: string; session: string; agent: string; conversation: string }; diff --git a/server/letta.test.ts b/server/letta.test.ts index e590983cb3..89c4d77056 100644 --- a/server/letta.test.ts +++ b/server/letta.test.ts @@ -108,7 +108,7 @@ describe("Letta conversation binding", () => { writeFileSync(join(cwd, ".letta", "settings.local.json"), settings); utimesSync(join(cwd, ".letta", "settings.local.json"), at / 1000, at / 1000); } - return { pid: 4242, cwd, startedAt: started, birth: started, env: { HOME: root, ...env } }; + return { pid: 4242, cwd, startedAt: started, identity: started, env: { HOME: root, ...env } }; }; const now = Date.now(); @@ -179,7 +179,7 @@ describe("Letta conversation binding", () => { const other = join(root, "project2"); mkdirSync(start); mkdirSync(other); symlinkSync(start, join(root, "link1")); - const at = (pid: number, cwd: string, env: Record, startedAt = now): LettaProcess => ({ pid, cwd, startedAt, birth: now, env }); + const at = (pid: number, cwd: string, env: Record, startedAt = now): LettaProcess => ({ pid, cwd, startedAt, identity: now, env }); expect(lettaInStartFolder(at(101, start, { PWD: start }))).toBe(true); expect(lettaInStartFolder(at(102, start, { PWD: join(root, "link1") }))).toBe(true); // no start folder to compare: its shell set no PWD diff --git a/server/process-start.ts b/server/process-start.ts index 5618c1e6f7..162c0adf01 100644 --- a/server/process-start.ts +++ b/server/process-start.ts @@ -39,6 +39,22 @@ function linuxBirth(pid: number): number | null { } } +/** + * What tells one process from another on every read, however the wall clock moves: Linux's start in + * ticks after boot (`btime`, and so processStartedAt, follows a clock that is set later), macOS's + * kernel start record. null where neither answers. + */ +export function processStartIdentity(pid: number): number | null { + if (process.platform === "darwin") return darwinStart(pid); + if (process.platform !== "linux") return null; + try { + const ticks = Number(readFileSync(`/proc/${pid}/stat`, "utf8").split(") ").pop()!.split(" ")[19]); + return Number.isFinite(ticks) ? ticks : null; + } catch { + return null; + } +} + /** Whether `pid` names a running process: signal 0 checks without sending anything, and a refusal means it runs. */ export function processAlive(pid: number): boolean { try {