From 925b0fae77399570e32103b0423c0df979390272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 16:33:46 -0400 Subject: [PATCH] Exclude deleted paths from the artifact projector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bleed-throughs let a removed file land in `work_products` and plant a ghost row: - `listChangedRelPaths` pushed every non-`!!` git porcelain entry, including worktree-deleted (`Y = D`) and staged-deleted-with- synced-worktree (`XD` + ` `) rows, so the projector tried to upsert a path that was no longer on disk. - `upsertFilesystemArtifact` never stat'd the absolute path; a deleted file (from either source detector) still reached `createWorkProduct` and left the DocumentCenter "open" link pointing to nothing. Filter the two deletion forms at the git list step and add an `existsSync + isFile` gate in `upsertFilesystemArtifact`. The check also catches edge cases the baseline diff surfaces — a file touched and then `rm`'d, a regular file replaced by a symlink, a path that raced with a `cleanup` shell script — so neither change detector can register a row that would not open. --- src/main/agent-runtime/artifact-projector.ts | 44 ++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts index 22d825d8..ab282007 100644 --- a/src/main/agent-runtime/artifact-projector.ts +++ b/src/main/agent-runtime/artifact-projector.ts @@ -400,6 +400,12 @@ function isGitRepo(workspacePath: string): boolean { * renames the new path is in the entry with the `R` status code and * the old path follows as its own NUL-terminated entry — we advance * past that old-path entry without emitting it. + * + * Deleted paths are excluded at the source. A worktree `D` status (the + * file is gone on disk) or a staged `D` with an unchanged worktree + * both represent a removal, not a produced artifact; registering them + * would plant ghost rows in `work_products` and send the DocumentCenter + * "open" link to an empty path. */ function listChangedRelPaths(workspacePath: string): string[] { const raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { @@ -414,16 +420,31 @@ function listChangedRelPaths(workspacePath: string): string[] { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.length < 3) continue; - const statusCode = entry.slice(0, 2); + const indexStatus = entry[0]; + const worktreeStatus = entry[1]; const rest = entry.slice(3); - if (statusCode === "!!") continue; + if (indexStatus === "!" && worktreeStatus === "!") continue; + const isRename = indexStatus === "R" || worktreeStatus === "R"; + if (representsDeletion(indexStatus, worktreeStatus)) { + if (isRename) i += 1; + continue; + } paths.push(rest); - const isRename = statusCode[0] === "R" || statusCode[1] === "R"; if (isRename) i += 1; } return paths; } +function representsDeletion(indexStatus: string, worktreeStatus: string): boolean { + // Worktree missing (`Y = D`) always means the file is not on disk. + if (worktreeStatus === "D") return true; + // Staged deletion whose worktree matches (`XD` + Y space) means the + // file was removed from the tree and the worktree is in sync; nothing + // to register either. + if (indexStatus === "D" && worktreeStatus === " ") return true; + return false; +} + function upsertFilesystemArtifact( db: AppDatabase, ctx: ArtifactProjectorRunContext, @@ -431,6 +452,23 @@ function upsertFilesystemArtifact( absPath: string, ): ProjectionOutcome { const relativePath = relative(workspacePath, absPath); + + // Defense-in-depth against the change-detectors (git status + the + // baseline diff) reporting a path whose file is no longer a regular + // file on disk: a staged deletion that was undone mid-run, a symlink + // that replaced a file, a path that the agent touched and then + // rm'd. Registering it would plant a ghost row in `work_products` + // whose DocumentCenter "open" link leads nowhere. + let stat: ReturnType; + try { + stat = statSync(absPath); + } catch { + return { kind: "skipped", reason: "path_missing" }; + } + if (!stat.isFile()) { + return { kind: "skipped", reason: "not_a_regular_file" }; + } + const lastSlash = relativePath.lastIndexOf("/"); const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;