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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All @@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading