Skip to content

Make the CreateGitRepo git-spawn failure diagnose itself - #467

Open
realtonyyoung wants to merge 7 commits into
mainfrom
tonyyoung/ai-1719-creategitrepo-enoent-diagnostics
Open

Make the CreateGitRepo git-spawn failure diagnose itself#467
realtonyyoung wants to merge 7 commits into
mainfrom
tonyyoung/ai-1719-creategitrepo-enoent-diagnostics

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

Fixes the diagnostic gap behind two filed flake reports (Linear AI-1719 and AI-1735, GitHub #452) — the same failure seen twice:

failed <various AgentOrchestrator tests> (1ms)
  Win32Exception: An error occurred trying to start process 'git' with working
  directory '/tmp/kcap-orch-4412504b'. No such file or directory
    at AgentOrchestratorVendorTests.Git(...)
    at AgentOrchestratorVendorTests.CreateGitRepo()

Both times it failed on the first line of the test, inside CreateGitRepo(), spawning git init in a directory Directory.CreateDirectory had just returned successfully for. Both times it passed on a rerun of the same commit. Nothing under test had run yet.

Why two investigations produced no root cause

The message cannot produce one. On Unix this spawn fails with ENOENT / "No such file or directory" for two unrelated causes, and .NET interpolates the working directory into the text either way:

  1. the working directory does not exist — chdir fails in the forked child
  2. the executable was not found — PATH resolution / execve fails

So the log is genuinely ambiguous, and no amount of re-reading it settles which happened.

Git() now catches the Win32Exception and rethrows naming both facts — whether the working directory exists, whether git resolves on PATH and where, and the PATH itself — preserving the original as InnerException. The next occurrence diagnoses itself.

This does not claim to fix the flake. Root cause remains unknown. Both reports asked for diagnosis before a blind fix, and explicitly not for a retry, which would mask a genuine lifetime bug.

Two things fixed while in here

Temp path. It was Path.Combine(GetTempPath(), "kcap-orch-" + 8 hex chars of a GUID) then CreateDirectory. 32 bits across this helper's 59 call sites is a real birthday risk, and since CreateDirectory is idempotent a collision silently shares a directory — after which either test's cleanup() recursively deletes the other's repo. Probability never explained the observed failures, but the truncation bought nothing, so the class is gone rather than argued about:

var repoPath = Directory.CreateTempSubdirectory("kcap-orch-").FullName;

Atomic and unique by OS guarantee — and already what the rest of this file uses. CreateGitRepo was the outlier.

A latent hang. Git() redirected both stdout and stderr and read neither before WaitForExit. Redirecting a stream and never draining it risks the child blocking forever once the pipe buffer fills, turning a test failure into a hung run — the worst outcome, since a hang produces no report at all. These commands are quiet enough that it hasn't bitten; the shape is the bug. Both streams are now drained before the wait.

The diagnostic is tested, not hoped for

A diagnostic nobody has seen produce output is not a fix. Git_spawn_failure_reports_the_working_directory_and_PATH_resolution spawns into a directory that was never created — so the failure has a known cause — and asserts the message names the working directory, its existence, PATH resolution, and preserves the Win32Exception. Cross-platform: Unix gives ENOENT, Windows "the directory name is invalid", both Win32Exception.

Mutation-verified: reverting the catch to a bare throw; fails that test.

Candidates ruled out (carried forward from the reports, plus one new)

  • UninstallCommand.SweepCapacitorPrefixedDirs — only ever runs against the skills dirs, never the temp root
  • PATH mutation by a parallel test — the only global PATH writes are [NotInParallel] and early-return on non-Windows
  • Tests redirecting HOME/cwd — all scoped below their own CreateTempSubdirectory
  • New: TMPDIR mutation. Worth checking since Path.GetTempPath() honours it and a moved-then-deleted temp root would match the evidence exactly. Ruled out — the only write is psi.Environment["TMPDIR"], which is per-child-process and cannot move the test host's temp root.

Verification

178/178 in AgentOrchestratorVendorTests.

🤖 Generated with Claude Code

The intermittent "An error occurred trying to start process 'git' with working
directory '/tmp/kcap-orch-XXXXXXXX'. No such file or directory" failures in the
AgentOrchestrator test helper have now cost two investigations without producing a
root cause, and the reason is that the message cannot produce one. On Unix this
spawn fails with ENOENT for TWO unrelated causes, and .NET interpolates the
working directory into the text either way:

  1. the working directory does not exist  (chdir fails in the forked child)
  2. the executable was not found          (PATH resolution / execve fails)

So the log alone cannot say which happened. Git() now catches the Win32Exception
and rethrows naming both facts — whether the working directory exists, whether
'git' resolves on PATH and where, and the PATH itself — with the original
exception preserved as the inner. The next occurrence diagnoses itself.

This deliberately does NOT claim to fix the flake. Root cause is still unknown;
both filed reports asked for diagnosis before a blind fix, and explicitly not for
a retry, which would mask a genuine lifetime bug.

Two things fixed while in here:

* The temp path was Path.Combine(GetTempPath(), "kcap-orch-" + 8 hex chars of a
  GUID) followed by CreateDirectory. 32 bits across the 59 call sites of this
  helper is a real birthday risk, and because CreateDirectory is idempotent a
  collision silently SHARES a directory — then either test's cleanup() deletes the
  other's repo recursively. Probability never explained the observed failures, but
  the truncation bought nothing, so the class is now gone rather than argued
  about: Directory.CreateTempSubdirectory("kcap-orch-") is atomic and unique by OS
  guarantee, and is already what the rest of this file uses. CreateGitRepo was the
  outlier.

* Git() redirected both stdout and stderr but read neither before WaitForExit.
  Redirecting a stream and never draining it risks the child blocking forever once
  the pipe buffer fills — which would turn a test failure into a HUNG run, the
  worst outcome since a hang produces no report at all. These commands are quiet
  enough that it has not bitten; the shape is the bug. Both streams are now
  drained before the wait.

A new test guards the diagnostic itself, because a diagnostic nobody has seen
produce output is not a fix. It spawns into a directory that was never created, so
the failure has a KNOWN cause, and asserts the message names the working
directory, its existence, PATH resolution, and preserves the Win32Exception.
Mutation-verified: reverting the catch to a bare `throw;` fails that test.

Verified: 178/178 in AgentOrchestratorVendorTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

AI-1719

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Improve CreateGitRepo git-spawn diagnostics and harden temp repo creation

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Make git spawn failures self-diagnosing (cwd existence, PATH resolution, PATH value).
• Use atomic CreateTempSubdirectory to avoid temp directory collisions and shared cleanup.
• Drain redirected stdout/stderr to prevent potential hangs; add regression test for diagnostics.
Diagram

graph TD
  T["AgentOrchestrator vendor tests"] --> TMP["Create temp git repo"] --> G["Git(cwd,args)"] --> S["Start 'git' process"]
  S -- "Win32Exception" --> D["Diagnose: cwd exists + ResolveOnPath + PATH"] --> F["Throw InvalidOperationException"]
  S -- "Started" --> IO["Drain stdout/stderr + WaitForExit"] --> F
Loading
High-Level Assessment

The chosen approach (enriching the exception at the failure site with cwd/PATH facts, switching to CreateTempSubdirectory, and draining redirected streams) is the most direct way to turn an intermittent CI flake into actionable diagnostics without masking it via retries. Alternatives like adding retries or replacing git with a managed library would either hide the underlying lifecycle issue or broaden scope well beyond the immediate diagnostic gap.

Files changed (1) +109 / -6

Tests (1) +109 / -6
AgentOrchestratorVendorTests.csHarden Git() helper with self-diagnosing spawn errors and safer temp repos +109/-6

Harden Git() helper with self-diagnosing spawn errors and safer temp repos

• Replaces ad-hoc temp repo path generation with Directory.CreateTempSubdirectory for atomic, collision-free directories. Updates the Git() helper to catch Win32Exception on spawn and rethrow an InvalidOperationException that reports whether the working directory exists, whether git resolves on PATH (and where), and the PATH value, preserving the original exception as InnerException. Drains redirected stdout/stderr before waiting for exit to avoid potential deadlocks, and adds a regression test plus a ResolveOnPath helper to ensure the diagnostic remains informative.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs

@qodo-code-review

qodo-code-review Bot commented Aug 5, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Inaccurate PATH resolver ✓ Resolved 🐞 Bug ≡ Correctness
Description
ResolveOnPath() implements a simplified PATH lookup (hardcoded extensions on Windows; File.Exists on
Unix) even though the repo already has CliExecutable.Resolve() with PATHEXT and Unix execute-bit
handling. If these disagree, the new Win32Exception diagnostic message can misreport whether 'git'
is actually resolvable/executable, undermining the purpose of the change.
Code

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[R145-148]

+        foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) {
+            foreach (var candidate in OperatingSystem.IsWindows()
+                         ? new[] { exe + ".exe", exe + ".cmd", exe }
+                         : new[] { exe }) {
Evidence
The new diagnostic path is computed via ResolveOnPath("git") and printed in the exception message.
ResolveOnPath hardcodes Windows candidates (".exe", ".cmd", or bare) and uses only File.Exists,
while the codebase already has a shared resolver explicitly designed to walk PATH with PATHEXT on
Windows and execute-bit checks on Unix; using the shared resolver avoids incorrect diagnostics and
duplication.

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[77-85]
test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[138-157]
src/Capacitor.Cli.Core/CliExecutable.cs[3-12]
src/Capacitor.Cli.Core/CliExecutable.cs[21-49]
src/Capacitor.Cli.Core/CliExecutable.cs[67-83]
src/Capacitor.Cli.Core/CliExecutable.cs[95-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ResolveOnPath()` is a new, diagnostic-only executable resolver, but it is less correct than the repository’s existing shared resolver. This can make the new self-diagnosing exception message misleading (especially on Windows where PATHEXT governs resolution, and on Unix where executability matters).

### Issue Context
The repo already contains `Capacitor.Cli.Core.CliExecutable.Resolve()` which performs PATH walks with PATHEXT handling on Windows and execute-bit checks on Unix. The new `ResolveOnPath()` duplicates this responsibility and is likely to drift.

### Fix
- Replace `ResolveOnPath("git")` usage with `CliExecutable.Resolve("git")` (and keep the same message shape).
- Delete `ResolveOnPath()` entirely (or, if you want to keep a helper, have it delegate to `CliExecutable.Resolve`).
- Ensure the guarding test still passes (it should, since the message text can remain unchanged).

### Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[77-85]
- test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[138-158]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Verbose comments in Git() 📘 Rule violation ⚙ Maintainability
Description
New/modified code adds long explanatory comment blocks that could become outdated and reduce
readability. This conflicts with the guideline to prefer self-explanatory code over verbose
comments.
Code

test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[R66-69]

+            // Self-diagnosing on purpose. On Unix this spawn fails with ENOENT / "No such file or
+            // directory" for TWO unrelated causes, and .NET interpolates the working directory into
+            // the message either way:
+            //
Evidence
PR Compliance ID 4 asks to avoid overly verbose comments in favor of clear code. The added/modified
regions contain multi-paragraph explanatory blocks (e.g., the temp-dir rationale and the detailed
ENOENT ambiguity explanation), which meet the rule’s failure criteria for verbose comments.

CLAUDE.md: Prefer Self-Explanatory Code Over Verbose Comments
test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[27-37]
test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[66-76]
test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[107-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds several long explanatory comment blocks in `AgentOrchestratorVendorTests.cs` (notably around `CreateGitRepo()`, the `Win32Exception` handling in `Git()`, and the new test XML doc). The compliance guidance prefers self-explanatory code and concise comments to reduce maintenance burden.

## Issue Context
These comments explain important background, but they are lengthy and may age poorly; consider shortening to a brief rationale and moving deeper context to PR description or a dedicated doc if needed.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[27-37]
- test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[66-76]
- test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs[107-116]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs Outdated
Comment thread test/Capacitor.Cli.Tests.Unit/AgentOrchestratorVendorTests.cs Outdated
realtonyyoung and others added 6 commits August 4, 2026 23:34
Review flagged that the resolver's accuracy is the whole point, and a naive
File.Exists check gets it wrong in two ways that would send the next investigator
down the wrong path — the exact failure this change exists to prevent:

  * Unix requires the EXECUTE BIT. A readable-but-not-executable file is not on
    PATH as far as execve is concerned, so reporting it as "resolved" would be a
    false positive on the very question being asked.
  * Windows resolves by PATHEXT, not by a hardcoded guess at .exe/.cmd. git ships
    a .exe, but shims are commonly .cmd or .bat and PATHEXT is the authority.

Also strengthened the test. It asserted only that the "resolved on PATH:" FIELD
was present, which would pass while the resolver reported NOT FOUND for
everything — leaving half the ambiguity unsettled by a green test. Every sibling
test in this class spawns git successfully, so on any machine running this suite
git IS resolvable, and a NOT FOUND means the resolver is broken rather than the
environment. Now asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…esolution

Review round 2 (MEDIUM) killed the hand-rolled resolver, and the reason
generalises: a PATH walk cannot be made correct.

  * Process.Start with UseShellExecute=false — which redirecting streams forces —
    will NOT run a .cmd/.bat shim even though PATHEXT lists it, because those need
    a command interpreter. So the PATHEXT support added in the previous commit
    invented a FALSE POSITIVE about what this process could actually start.
  * On Unix, "some execute bit is set" says nothing about EFFECTIVE permission for
    this user, nor whether every PATH directory is traversable.

Each of those is another OS rule to replicate, which is the signal to replace the
mechanism rather than add a rule. A diagnostic that reports a false positive is
worse than no diagnostic, because misdirecting the next investigator is precisely
the failure this change exists to stop.

So the question is now answered authoritatively, by asking the OS: spawn
`git --version` from a directory known to exist and report the outcome. It uses
the same mechanism that just failed, and it splits the ENOENT ambiguity cleanly —
startable means the fault was the working directory, not startable means it was
the executable. Net effect is less code and more truth.

The test asserts the probe reports YES rather than merely that the field exists,
which would have passed while the probe answered NO for everything.
Mutation-verified: pointing the probe at a nonexistent executable fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe read stdout then stderr with blocking ReadToEnd() before WaitForExit —
the exact shape called out as the bug in the main Git() path two commits earlier.
`git --version` output is far too small to fill a pipe buffer, so this could not
bite in practice, but the inconsistency is indefensible when the principle was
just stated: redirect-and-block-read risks the child stalling once a buffer fills.

It matters more here than in the main path, not less. This helper only runs on a
FAILURE path, so a hang would replace a diagnosable error with a run that produces
no report at all — turning the best case (a clear message) into the worst case.

Verified: 178/178 in AgentOrchestratorVendorTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings (both MEDIUM), both correct.

1. The probe reported "NO — exited N" when Process.Start SUCCEEDED but
   `git --version` returned nonzero. That is a misclassification of exactly the
   question the probe exists to answer: once Start returns a process, git WAS
   startable, and what the child then does is a separate fact. "NO" is now
   reserved for genuinely not startable — Start threw, or handed back nothing —
   and a nonzero exit reports YES with the code and stderr alongside it.

2. The probe had no timeout. It runs on a FAILURE path, in a suite CI executes
   serially, so an unbounded wait could wedge the whole run and produce no report
   at all — strictly worse than the error it is explaining. Now bounded at 10s with
   a best-effort kill, reporting startable-but-did-not-exit rather than hanging.

(The reviewer also flagged draining stdout fully before stderr; that was already
fixed in the previous commit — it reviewed the prior diff.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 4 (MEDIUM), correct — and it is the SAME defect as round 3's, in the place I
did not look. I fixed the nonzero-exit branch but left a broad catch(Exception)
around the whole body, which still reported NO for post-start failures: stream
reads, the wait, ExitCode access, disposal. All of those occur after startability
is already proven, so reporting NO conflates exactly the two facts this probe
exists to separate.

Restructured so "NO" means precisely one thing — git could not be STARTED:

  * Process.Start has its own narrow try; only that returns NO.
  * everything after it has a separate try that keeps the YES and appends how it
    went wrong.
  * disposal moved to a finally that swallows, so it can neither change the
    verdict nor escape.

The last point matters beyond tidiness: this helper is called from INSIDE
`catch (Win32Exception)`, so an escaping exception would replace the informative
message with the probe's own — destroying the diagnostic in the one situation it
was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings, both acted on.

The reviewer pointed at a resolver this branch had already deleted, but it carried
a fact worth having: Capacitor.Cli.Core.CliExecutable.Resolve already exists and
handles PATHEXT and the Unix execute bit. Rather than replace the probe with it —
resolution cannot answer "could this process START it", which is why the probe
exists — the message now reports BOTH, using the shared helper for the location:

  'git' startable from a known-good directory: YES (git version 2.x)
  'git' resolves to: /usr/bin/git

Two complementary facts, no duplicated resolution logic. The test asserts the
resolver found something rather than merely that the field is present, for the same
reason it already asserts the probe says YES: a green test must not be compatible
with the diagnostic answering NOT FOUND for everything.

Also trimmed the comment blocks. The reviewer was right that they were long, and
the specific excess was review HISTORY ("round 2 killed the previous approach") —
which belongs in git, where it already is. The load-bearing WHY is kept and is the
point: the ENOENT ambiguity, why executable resolution is not modelled here, and
why NO must mean only "could not start".

Verified: 178/178 in AgentOrchestratorVendorTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant