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..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 { 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}$/; @@ -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; + * `identity`: processStartIdentity, the same on every read, so with the pid it names the process + */ +export interface LettaProcess { pid: number; cwd: string; startedAt: number; identity: 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 start identity, 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.identity}`; 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 identity = processStartIdentity(pid); const env = processEnvironment(pid); - return startedAt === null || env === null ? null : { pid, cwd, startedAt, 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 edcba71199..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, 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): LettaProcess => ({ pid, cwd, startedAt: 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 @@ -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", () => { 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 {