Skip to content

[Bug]: Checkpoint capture fails with exit 128 on every turn when the project path is a symlink to a repository subdirectory #13187

Closed

Description

@m2moiz

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Checked and ruled out: #9245 (symlinked worktree paths compared as strings, different code), #12204 (git dir followed when the back link is missing, different trigger), #5489 (intermittent exit 128 during checkpoint restore, caused by an existing index.lock), and #12602 (open PR that calls realpath in the new metadata path but does not touch the checkpoint code below).

Area

apps/server

Steps to reproduce

In the app

  1. Have a git repository with a subdirectory, and a symlink somewhere else that points at that subdirectory. (My case: I keep several tool-managed folders as symlinks into subdirectories of one repository, and opened one of those symlinks as a project.)
  2. Add the symlink path as a project in T3 Code.
  3. Start a thread and let one turn finish.
  4. The turn ends with VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<project path>) exited with 128. It repeats after every turn, and the thread never gets a checkpoint.

The mechanism, without the app

This uses Node's path.resolve, the same call resolveGitCommonDir makes, so it isolates the bug in a few lines:

R=$(mktemp -d)
git init -q "$R/repo"
git -C "$R/repo" -c user.name=x -c user.email=x@example.invalid commit -q --allow-empty -m init
mkdir -p "$R/repo/sub/dir"
ln -s "$R/repo/sub/dir" "$R/link"
cd "$R/link"

rel=$(git rev-parse --git-common-dir)          # ../../.git, relative to git's PHYSICAL cwd
res=$(node -e 'console.log(require("path").resolve(process.argv[1], process.argv[2]))' "$R/link" "$rel")
echo "$res"                                    # the symlink's grandparent + /.git: does not exist
GIT_INDEX_FILE="$res/t3-checkpoint-index-test" git read-tree HEAD; echo "exit=$?"   # exit=128

git rev-parse --path-format=absolute --git-common-dir   # the correct directory

Observed, one row per way of opening the same repository (paths shortened):

Project path --git-common-dir path.resolve(projectPath, it) read-tree into that dir
symlink to a repo subdirectory ../../.git /tmp/.git (does not exist) exit 128
symlink to the repo root .git <link>/.git (resolves through the link) exit 0
the real subdirectory path ../../.git <repo>/.git exit 0

It takes both: a symlink in the project path, and a project folder that is a subdirectory of the repository, so the relative answer contains ...

Expected behavior

A project opened through a symlink behaves like the directory it points to: checkpoints are captured each turn and "Go back" is available.

Actual behavior

Every turn fails checkpoint capture with exit 128, and the thread has no checkpoints. The agent itself keeps working, because nothing else in the turn depends on the checkpoint.

Cause, in apps/server/src/vcs/GitVcsDriver.ts at main @ 6975efd3dd52, lines 756 to 765:

const resolveGitCommonDir = (cwd: string) =>
  Effect.gen(function* () {
    const result = yield* execute({ /* ... */ cwd, args: ["rev-parse", "--git-common-dir"] });
    const gitCommonDir = result.stdout.trim();
    return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
  });

Git computes the relative path from its physical working directory, but path.resolve applies the .. segments lexically to the project path string. When that string goes through a symlink to a subdirectory, the .. climbs out of the symlink's parent instead of the repository. captureCheckpoint then builds tempIndexPath inside that nonexistent directory, and the first git command given it as GIT_INDEX_FILE fails:

fatal: Unable to create '<wrong dir>/.git/t3-checkpoint-index-<uuid>.lock': No such file or directory

From reading the source (not observed separately): the index-reuse branch fails first, at fileSystem.copyFile(..., tempIndexPath), and is swallowed by Effect.orElseSucceed(() => false). The fallback read-tree is what surfaces as exit 128. Git's actual message above is not shown anywhere, which is the stderr-discarding problem described in #12224 and #4380.

Suggested fix (one line): ask git for the absolute path, the same way this function already does for --git-path index at line 831:

args: ["rev-parse", "--path-format=absolute", "--git-common-dir"],

In the reproduction above, that form returns the correct directory from the symlinked path. realpath(cwd) before path.resolve would also work. --path-format needs git 2.31 or later, which line 831 already assumes.

Impact

Major degradation or frequent failure

For an affected project it fails on every turn, and checkpoints, diffs and "Go back" are all unavailable for the thread. It only affects projects opened through a symlink that points into a repository subdirectory.

Version or commit

T3 Code (Alpha) 0.0.42 desktop; the cause is present on main @ 6975efd3dd52 (2026-09-23)

Environment

macOS 27.0, git 2.55.0, Node v26.8.2, Claude provider

Logs or stack traces

# server.trace.ndjson, one failure per turn (home path redacted)
VcsProcessExitError: VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<home>/<symlink to a repo subdirectory>) exited with 128 - Process exited with a non-zero status.
    at VcsProcessExitError.fromProcessExit (app.asar/apps/server/dist/bin.mjs:31121:10)
    at VcsProcess.runUnbounded (app.asar/apps/server/dist/bin.mjs:93005:47)
    at VcsProcess.run (app.asar/apps/server/dist/bin.mjs:93073:72)
    at GitVcsDriver.checkpoints.captureCheckpoint (app.asar/apps/server/dist/bin.mjs:93682:93)
    at captureCheckpoint (app.asar/apps/server/dist/bin.mjs:93681:28)

Screenshots, recordings, or supporting files

No response

Workaround

Add the project by its real path (realpath <symlink>), or open the repository root instead of the symlinked subdirectory.

Activity

  1. juliusmarminge commented on Sep 23, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (6975efd3d). This is a real server checkpoint bug. Desktop is only the surface that stored the symlink as the project path. Not already fixed. The same path.resolve(cwd, relativeGitCommonDir) is in the v0.0.42 tag (719a76ca1) and on main after that release. No newer nightly changes it.

    What you reported. The project path is a symlink to a subdirectory of a git repo. Every turn ends with VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<symlink>) exited with 128. The thread never gets a checkpoint, so diffs and Go back stay unavailable. The agent itself keeps working.

    That matches the code and a local reproduction of the mechanism (Linux, git 2.43.0, Node path.resolve). Git's relative --git-common-dir is computed from the physical directory. path.resolve applies .. to the symlink string and climbs out of the symlink's parent.

    What the code does

    1. Checkpoint capture builds the temp index path from a lexical join.

    resolveGitCommonDir asks for --git-common-dir and, when the answer is relative, joins it with the project cwd:

    const resolveGitCommonDir = (cwd: string) =>
    Effect.gen(function* () {
    const result = yield* execute({
    operation: "GitVcsDriver.checkpoints.resolveGitCommonDir",
    cwd,
    args: ["rev-parse", "--git-common-dir"],
    });
    const gitCommonDir = result.stdout.trim();
    return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
    });

    captureCheckpoint puts t3-checkpoint-index-<uuid> in that directory and sets GIT_INDEX_FILE:

    const gitCommonDir = yield* resolveGitCommonDir(input.cwd);
    const tempIndexPath = path.join(
    gitCommonDir,
    `t3-checkpoint-index-${NodeCrypto.randomUUID()}`,
    );
    const commitEnv: NodeJS.ProcessEnv = {
    ...process.env,
    GIT_INDEX_FILE: tempIndexPath,
    GIT_AUTHOR_NAME: "T3 Code",
    GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
    GIT_COMMITTER_NAME: "T3 Code",
    GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",

    The cwd is the stored project path, the thread worktree path, or the provider session cwd. resolveCheckpointCwd does not realpath it:

    const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: {
    readonly threadId: ThreadId;
    readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };
    readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;
    readonly preferSessionRuntime: boolean;
    }): Effect.fn.Return<string | undefined, CheckpointStoreError> {
    const fromSession = yield* resolveSessionRuntimeForThread(input.threadId);
    const fromThread = resolveThreadWorkspaceCwd({
    thread: input.thread,
    projects: input.projects,
    });
    const cwd = input.preferSessionRuntime
    ? (Option.match(fromSession, {
    onNone: () => undefined,
    onSome: (runtime) => runtime.cwd,
    }) ?? fromThread)
    : (fromThread ??
    Option.match(fromSession, {
    onNone: () => undefined,
    onSome: (runtime) => runtime.cwd,
    }));
    if (!cwd) {
    return undefined;
    }
    if (!(yield* checkpointStore.isGitRepository(cwd))) {
    return undefined;
    }
    return cwd;

    This helper is only used by captureCheckpoint. Restore and diff never call it. Go back is missing because the ref was never written, not because restore has the same bug.

    2. The index-reuse path fails first and is swallowed. read-tree is what surfaces as exit 128.

    The fast path copies the real index to tempIndexPath, then runs read-tree. The copy goes to a directory that does not exist (ENOENT). That whole branch ends in Effect.orElseSucceed(() => false):

    if (headExists) {
    const reusedIndex = yield* Effect.gen(function* () {
    const indexPath = yield* execute({
    operation,
    cwd: input.cwd,
    args: ["rev-parse", "--path-format=absolute", "--git-path", "index"],
    });
    const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim());
    if (Option.isNone(mtime)) return false;
    // Stay below the source timestamp even if Date rounded up, preserving Git's racy check.
    const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000);
    if (indexTime <= 0) return false;
    yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath);
    // Retain stat data only where the copied index already matches HEAD.
    yield* execute({
    operation,
    cwd: input.cwd,
    args: [...indexConfig, "read-tree", "--reset", "HEAD"],
    env: commitEnv,
    });
    // read-tree can rewrite the index, so restore its racy timestamp afterward.
    yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime);
    let specialFlags = false;
    let recordStart = true;
    let skipped = false;
    let skippedRecord: number[] = [];
    const skippedPaths: string[] = [];
    yield* vcsProcess.run({
    operation,
    command: "git",
    cwd: input.cwd,
    args: [...indexConfig, "ls-files", "--full-name", "--sparse", "-v", "-z"],
    env: commitEnv,
    maxOutputBytes: 4_096,
    outputMode: "truncate",
    // Inspect every tag; retain only skipped file paths for checking sparse rules.
    onStdoutChunk: (chunk) => {
    for (const byte of chunk) {
    if (recordStart) skipped = byte === 83;
    if (skipped && sparseCheckout) {
    if (byte !== 0) skippedRecord.push(byte);
    else {
    if (skippedRecord.at(-1) !== 47) {
    const name = Buffer.from(skippedRecord).subarray(2);
    if (!NodeBuffer.isUtf8(name)) specialFlags = true;
    else skippedPaths.push(name.toString("utf8"));
    }
    skippedRecord = [];
    }
    }
    if (
    recordStart &&
    ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83))
    ) {
    specialFlags = true;
    }
    recordStart = byte === 0;
    }
    },
    });
    if (skippedPaths.length > 0 && !specialFlags) {
    const selected = yield* execute({
    operation,
    cwd: input.cwd,
    args: [...indexConfig, "sparse-checkout", "check-rules", "-z"],
    stdin: skippedPaths.join("\0") + "\0",
    env: commitEnv,
    maxOutputBytes: 1,
    outputMode: "truncate",
    });
    // Any selected skipped file has a manual flag, not a sparse exclusion.
    specialFlags = selected.stdout.length > 0 || selected.stdoutTruncated;
    }
    // Sparse Git clears skip-worktree for present files. Manual flags still need a reset.
    return !specialFlags;
    }).pipe(Effect.orElseSucceed(() => false));

    The fallback then runs read-tree with the same GIT_INDEX_FILE:

    if (!reusedIndex) {
    if (sparseCheckout) {
    const cone = yield* execute({
    operation,
    cwd: input.cwd,
    args: ["config", "--bool", "core.sparseCheckoutCone"],
    allowNonZeroExit: true,
    });
    // Rebuilding a non-cone index loses exclusions; do not publish false deletions.
    if (cone.stdout.trim() !== "true") {
    return yield* new VcsProcessExitError({
    operation,
    command: "git read-tree",
    cwd: input.cwd,
    exitCode: 1,
    detail: "Cannot rebuild a checkpoint index for non-cone sparse checkout.",
    });
    }
    }
    yield* cleanupTempIndex;
    yield* execute({
    operation,
    cwd: input.cwd,
    // A fresh sparse index represents excluded directories without marking them deleted.
    args: sparseCheckout
    ? [...indexConfig, "-c", "index.sparse=true", "read-tree", "--reset", "HEAD"]
    : ["read-tree", "HEAD"],
    env: commitEnv,
    });

    Git's own message is fatal: Unable to create '<wrong dir>/.git/t3-checkpoint-index-<uuid>.lock': No such file or directory. VcsProcessExitError replaces stderr with Process exited with a non-zero status. That opacity is #4380 and #12224, not this bug.

    3. It only breaks when both conditions hold.

    Git prints a relative common dir that contains .. (project folder is below the repo root), and the project cwd is a symlink, so those .. segments leave the symlink's parent instead of the repo.

    Project path --git-common-dir path.resolve(cwd, it) read-tree
    symlink to a repo subdirectory ../../.git parent of the symlink + /.git (missing) exit 128
    symlink to the repo root .git <link>/.git (kernel follows the link) exit 0
    real subdirectory ../../.git <repo>/.git exit 0
    symlink into a linked worktree subdirectory already absolute absolute path kept not this bug

    From the symlink, git rev-parse --git-dir was already absolute, while --git-common-dir was ../../.git. Do not switch this call to --git-dir. For a linked worktree those are different directories, and the temp index is supposed to live in the common dir. Linked worktrees already print an absolute --git-common-dir, so T3-created worktrees do not hit this.

    4. If the lexical path exists, capture does not fail. It writes the temp index into that other directory.

    With the symlink placed so path.resolve landed on a different repo's .git, read-tree exited 0 and created t3-checkpoint-index-* inside the decoy .git. Cleanup deletes that path afterward, so a finished capture does not leave the file, and it does not rewrite the decoy repo's commits. mkdir on the resolved path would turn the reported failure into this wrong-directory write. The path has to be the real common dir.

    5. The metadata cache has the same join, and realpath does not repair it.

    resolveRepositoryPathsUncached does the same path.resolve(cwd, commonDirOutput), then realPath on the result, and on failure keeps the unresolved path:

    const commonDirOutput = commonDirResult.stdout.trim();
    const resolvedGitCommonDir = path.isAbsolute(commonDirOutput)
    ? path.normalize(commonDirOutput)
    : path.resolve(cwd, commonDirOutput);
    const gitCommonDir = yield* fileSystem
    .realPath(resolvedGitCommonDir)
    .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir));

    realpath of a directory that does not exist fails. For this layout the cached gitCommonDir stays the wrong path, and later --git-dir fetches and ref reads use it. #12602 realpaths that metadata path but does not change this order, and it does not touch checkpoint capture. The checkpoint one-line fix does not cover this call.

    Reproduction

    Same shape as the report, on Linux with git 2.43.0:

    rel=../../.git
    resolve=/tmp/.git
    exists=no
    fatal: Unable to create '/tmp/.git/t3-checkpoint-index-test.lock': No such file or directory
    read-tree exit=128
    
    --path-format=absolute --git-common-dir = <repo>/.git
    read-tree exit=0
    
    realpath(cwd) then path.resolve = <repo>/.git
    

    fs.copyFile of the real index onto the bad temp path failed with ENOENT before read-tree ran. Symlink-to-root and the real subdirectory both exited 0. A linked worktree printed an absolute common dir. The decoy case wrote the temp index into the other repo's .git and left that repo's history unchanged.

    Not a duplicate

    No open issue covers --git-common-dir joined lexically with a symlink cwd.

    Fix direction

    1. In GitVcsDriver.ts resolveGitCommonDir, ask for the absolute path, the same way line 831 already does for the index:
    args: ["rev-parse", "--path-format=absolute", "--git-common-dir"],

    --path-format needs git 2.31 or later. Checkpoint capture already assumes that for --git-path index. In the repro this returned <repo>/.git and read-tree exited 0. realpath(cwd) before path.resolve also matched. Prefer --path-format=absolute: the relative answer is from Git's physical cwd, not from the string the project was added with.

    1. Use that same absolute common dir in resolveRepositoryPathsUncached before path.resolve. Keep the existing realPath afterward so Windows 8.3 short names still collapse. Do not mkdir the lexically resolved directory.

    2. Test with a real git repo: symlink to a subdirectory, captureCheckpoint must write the temp index under the real .git and publish the ref. Second case: place the symlink so today's path.resolve lands on a different repo's .git, and assert no t3-checkpoint-index-* is created there.

    Workaround

    Add the project by its real path (realpath of the symlink), or open the repository root. A symlink to the repo root is fine, because the relative answer is .git and has no ... Safe to keep using until a fix ships.

    Accepting as a server checkpoint bug.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions