Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/letta-moved-process-identity.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 10 additions & 6 deletions server/letta-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<uuid>`, `local-conv-<n>`, `agent-<uuid>`, `agent-local-<uuid>`, and `default`. */
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/;
Expand Down Expand Up @@ -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<string>();

/**
Expand All @@ -164,7 +167,7 @@ const moved = new Set<string>();
* 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);
Expand All @@ -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 };
Expand Down
7 changes: 5 additions & 2 deletions server/letta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<string, string>): LettaProcess => ({ pid, cwd, startedAt: now, env });
const at = (pid: number, cwd: string, env: Record<string, string>, 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
Expand All @@ -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", () => {
Expand Down
16 changes: 16 additions & 0 deletions server/process-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading