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
27 changes: 27 additions & 0 deletions src/CodeyBox.Core/GitNameStatusParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace CodeyBox.Core;

/// <summary>
/// Parses <c>git diff --name-status -M</c> stdout into
/// <see cref="GitChangedPath"/> records. Shared by the host git
/// implementation and sandbox-side verifiers so both interpret status
/// letters and rename columns identically: two-column lines are
/// <c>STATUS\tpath</c>, three-column lines are
/// <c>STATUS\toldPath\tnewPath</c> (rename/copy). Lines in any other shape
/// are skipped, matching the historical host parser.
/// </summary>
public static class GitNameStatusParser
{
public static IReadOnlyList<GitChangedPath> Parse(string stdout)
{
var changes = new List<GitChangedPath>();
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var parts = line.Split('\t', StringSplitOptions.None);
if (parts.Length == 2)
changes.Add(new GitChangedPath(parts[0], parts[1]));
else if (parts.Length == 3)
changes.Add(new GitChangedPath(parts[0], parts[2], parts[1]));
}
return changes;
}
}
15 changes: 15 additions & 0 deletions src/CodeyBox.Core/IGitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,21 @@ Task<string> CreateMergeCommitAsync(
Task<string> ResolveCommitAsync(string repositoryId, string commitish, CancellationToken ct = default)
=> throw new NotSupportedException("This git host does not support host-side commit resolution.");

/// <summary>
/// Typed object-existence probe: returns true when
/// <paramref name="commitish"/> resolves to a commit object present in
/// the host bare repo, and false ONLY when git proves the object (or a
/// commit it peels to) is absent. Operational failures — missing repo
/// path, corrupt object store, git-level errors — throw, so a caller
/// can distinguish "provably absent" from "could not evaluate" and must
/// never treat the latter as absence.
///
/// Default throws <see cref="NotSupportedException"/> so test fakes that
/// cannot run git behave as before.
/// </summary>
Task<bool> CommitExistsAsync(string repositoryId, string commitish, CancellationToken ct = default)
=> throw new NotSupportedException("This git host does not support host-side commit existence probes.");

/// <summary>
/// Returns true when <paramref name="ancestorCommit"/> is reachable from
/// <paramref name="descendantCommit"/> in the host bare repo
Expand Down
7 changes: 6 additions & 1 deletion src/CodeyBox.Core/IUpstreamRemote.cs
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,12 @@ public sealed record UpstreamCompletionRequest
public required ProjectId ProjectId { get; init; }
public required string WorkBranch { get; init; }
public required string BaseBranch { get; init; }
/// <summary>SHA produced by the local merge. Null when resuming past the merge phase.</summary>
/// <summary>
/// SHA produced by the local merge. Null when the merge phase did not run
/// for this pickup — except on a skip-merge resume, where the orchestrator
/// backfills it from the recorded local squash sha so the pushed-head/base
/// ancestry assertion still evaluates.
/// </summary>
public string? MergeSha { get; init; }
public required string Title { get; init; }
/// <summary>Static fallback PR description. Used when LLM generation is disabled or fails.</summary>
Expand Down
18 changes: 18 additions & 0 deletions src/CodeyBox.Core/UpstreamCompletionVerificationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace CodeyBox.Core;

/// <summary>
/// Raised when an upstream remote cannot evaluate a pre-merge invariant it
/// depends on — for example the pushed-head/base ancestry assertion — because
/// the underlying probe failed or returned no verdict. This is deliberately
/// distinct from a proven negative: the orchestrator classifies it as an
/// infrastructure failure and retries, instead of letting an unevaluable
/// check false-pass a merge gate or misrouting the item into conflict
/// handling.
/// </summary>
public sealed class UpstreamCompletionVerificationException : InvalidOperationException
{
public UpstreamCompletionVerificationException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
12 changes: 12 additions & 0 deletions src/CodeyBox.Core/Validation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ public static partial class Validation
[GeneratedRegex(@"^[0-9a-fA-F]{40,64}$", RegexOptions.CultureInvariant)]
private static partial Regex CommitShaRegex();

/// <summary>
/// Lowercase hex git object id: exactly 40 chars for SHA-1 repos, 64 for
/// SHA-256. Used to validate raw plumbing output (e.g. merge-tree's
/// emitted tree id) before it is fed back into git argv.
/// </summary>
[GeneratedRegex(@"^(?:[0-9a-f]{40}|[0-9a-f]{64})$", RegexOptions.CultureInvariant)]
private static partial Regex GitObjectIdRegex();

/// <summary>True when <paramref name="value"/> is a well-formed lowercase git object id.</summary>
public static bool IsGitObjectId(string? value)
=> value is not null && GitObjectIdRegex().IsMatch(value);

/// <summary>
/// Git tag name: ASCII alnum plus a small set of separators. Slashes are
/// excluded (tags don't need namespacing), keeping the set conservative
Expand Down
36 changes: 24 additions & 12 deletions src/CodeyBox.Git/LocalGitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,25 @@ public async Task<string> ResolveCommitAsync(string repositoryId, string commiti
return rc.Stdout.Trim();
}

public async Task<bool> CommitExistsAsync(string repositoryId, string commitish, CancellationToken ct = default)
{
var path = GetRepoPath(repositoryId);
SanitizeBareRepositoryConfig(path);
// `rev-parse --verify --quiet` exits 1 (silently) only when the
// expression does not resolve to a commit — a missing object, an
// unknown name, or a non-commit type. Fatal conditions (repo path
// gone, corrupt object store) exit non-1 and surface as stderr, so
// they throw instead of masquerading as "object absent".
var rc = await RunGitAsync(path, ct, "rev-parse", "--verify", "--quiet", $"{commitish}^{{commit}}");
return rc.ExitCode switch
{
0 => true,
1 => false,
_ => throw new InvalidOperationException(
$"git rev-parse existence probe for '{commitish}' failed: {rc.Stderr}"),
};
}

public async Task<bool> IsAncestorAsync(string repositoryId, string ancestorCommit, string descendantCommit, CancellationToken ct = default)
{
if (!LooksLikeSha(ancestorCommit))
Expand Down Expand Up @@ -1258,20 +1277,11 @@ public async Task<IReadOnlyList<GitChangedPath>> GetChangedPathsAsync(
{
var path = GetRepoPath(repositoryId);
SanitizeBareRepositoryConfig(path);
var rc = await RunGitAsync(path, ct, "diff", "--name-status", "-M", fromTreeish, toTreeish);
var rc = await RunGitAsync(path, ct, MergeScopeFenceDiffContract.NameStatusArguments(fromTreeish, toTreeish));
if (rc.ExitCode != 0)
throw new InvalidOperationException($"git diff --name-status failed: {rc.Stderr}");

var changes = new List<GitChangedPath>();
foreach (var line in rc.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var parts = line.Split('\t', StringSplitOptions.None);
if (parts.Length == 2)
changes.Add(new GitChangedPath(parts[0], parts[1]));
else if (parts.Length == 3)
changes.Add(new GitChangedPath(parts[0], parts[2], parts[1]));
}
return changes;
return GitNameStatusParser.Parse(rc.Stdout);
}

public async Task<string> GetUnifiedDiffAsync(
Expand All @@ -1284,8 +1294,10 @@ public async Task<string> GetUnifiedDiffAsync(
ValidateRepositoryRelativePath(filePath);
var path = GetRepoPath(repositoryId);
SanitizeBareRepositoryConfig(path);
// Attribute-proofing flags live in the shared fence contract so the
// host and sandbox diffs cannot drift apart.
var rc = await RunGitAsync(
path, ct, "diff", "--no-ext-diff", "--no-color", "--unified=0", fromTreeish, toTreeish, "--", filePath);
path, ct, MergeScopeFenceDiffContract.FileContentArguments(fromTreeish, toTreeish, filePath));
if (rc.ExitCode != 0)
throw new InvalidOperationException($"git diff for '{filePath}' failed: {rc.Stderr}");
return rc.Stdout;
Expand Down
47 changes: 47 additions & 0 deletions src/CodeyBox.Git/MergeScopeFenceDiffContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace CodeyBox.Git;

/// <summary>
/// Single source of truth for the <c>git diff</c> argv contract the merge
/// scope fence parses. The host-side <see cref="CodeyBox.Core.IGitHost"/>
/// diff path and
/// the in-sandbox fence must emit byte-identical zero-context diffs or the
/// in-loop verdict can diverge from the final host fence, so both build
/// their argv through these helpers — a flag change (rename handling,
/// context width, textconv, color) cannot drift between the two call sites.
/// </summary>
public static class MergeScopeFenceDiffContract
{
/// <summary>
/// Whole-tree change-set diff behind the fence's added/removed/renamed
/// path check. Rename detection stays on so a rename surfaces as 'R'
/// rather than a delete+add pair the fence would misclassify.
/// </summary>
public static string[] NameStatusArguments(string fromTreeish, string toTreeish)
{
ValidateTreeish(fromTreeish, nameof(fromTreeish));
ValidateTreeish(toTreeish, nameof(toTreeish));
return ["diff", "--name-status", "-M", fromTreeish, toTreeish];
}

/// <summary>
/// Per-file zero-context content diff behind the fence's line-coordinate
/// check. <c>--no-ext-diff</c>/<c>--no-textconv</c>/<c>--text</c>/
/// <c>--no-color</c> keep repository-controlled attributes and diff
/// drivers from suppressing or rewriting the hunks the fence measures;
/// <c>--unified=0</c> makes every changed line visible to the parser.
/// </summary>
public static string[] FileContentArguments(string fromTreeish, string toTreeish, string path)
{
ValidateTreeish(fromTreeish, nameof(fromTreeish));
ValidateTreeish(toTreeish, nameof(toTreeish));
ArgumentException.ThrowIfNullOrWhiteSpace(path);
return ["diff", "--no-ext-diff", "--no-textconv", "--text", "--no-color", "--unified=0",
fromTreeish, toTreeish, "--", path];
}

private static void ValidateTreeish(string treeish, string paramName)
{
if (string.IsNullOrWhiteSpace(treeish) || treeish.StartsWith('-'))
throw new ArgumentException("treeish must be a non-empty revision, not an option", paramName);
}
}
70 changes: 69 additions & 1 deletion src/CodeyBox.Orchestrator/AgenticConflictResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ public AgentCredentialSandboxPoisonedException(
public AgentKind Candidate { get; }
}

/// <summary>
/// Infrastructure failure inside a caller-supplied post-resolution
/// verification hook — sandbox/git plumbing that could not run (a failed
/// <c>git rm --cached</c>/<c>git add</c>/<c>write-tree</c>, a dead exec
/// channel), NOT a verdict on the agent's resolution. The resolver lets
/// this propagate instead of folding it into per-attempt agent feedback:
/// no additional agent turn can repair a plumbing failure, so it must
/// abort resolution and surface to the caller's infrastructure handling.
/// A genuine resolution defect still travels as the hook's string reason.
/// </summary>
internal sealed class PostResolutionVerificationInfrastructureException : Exception
{
public PostResolutionVerificationInfrastructureException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}

/// <summary>
/// Mutable hot-reload holder for <see cref="AgenticConflictResolverOptions"/>.
/// Construction-time DI binds a single instance; operators swap the underlying
Expand Down Expand Up @@ -263,13 +281,29 @@ public AgenticConflictResolver(
/// attempts. Returns success on the first attempt whose post-run
/// verification passes, failure with a concrete reason otherwise.
/// </summary>
/// <param name="postVerificationAsync">
/// Optional caller-supplied check run after <see cref="VerifyResolutionAsync"/>
/// passes. Returns null when the resolution is acceptable, or a failure
/// reason consumed exactly like a verification failure — the attempt is
/// charged and the next attempt/escalation sees the reason as feedback.
/// The merge phase uses it to evaluate the scope fence inside the loop
/// (the fence otherwise only runs after the resolver reports success,
/// which burned the whole attempt budget on a rejection the agent could
/// have repaired). Throw
/// <see cref="PostResolutionVerificationInfrastructureException"/> for
/// infrastructure failures (sandbox/git plumbing): those propagate
/// rather than burning attempts, because no agent turn can repair them.
/// <see cref="IExecutionTransportLoss"/> exceptions likewise propagate.
/// Any other unexpected exception is folded into a verification failure.
/// </param>
public async Task<AgenticConflictResolverResult> ResolveAsync(
ISandbox sandbox,
string workingDirectory,
WorkItemId workItemId,
AgenticConflictResolverContext context,
IReadOnlyList<AgenticConflictResolverCandidate> candidates,
CancellationToken ct = default)
CancellationToken ct = default,
Func<ISandbox, string, CancellationToken, Task<string?>>? postVerificationAsync = null)
{
if (sandbox is null) throw new ArgumentNullException(nameof(sandbox));
if (string.IsNullOrWhiteSpace(workingDirectory)) throw new ArgumentException("workingDirectory must be non-empty", nameof(workingDirectory));
Expand Down Expand Up @@ -758,6 +792,40 @@ await ClearCandidateCredentialFilesAsync(

var verification = await VerifyResolutionAsync(
sandbox, workingDirectory, conflictFiles, options, ct);
if (verification.Success && postVerificationAsync is not null)
{
string? postFailure;
try
{
postFailure = await postVerificationAsync(sandbox, workingDirectory, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (PostResolutionVerificationInfrastructureException)
{
// Plumbing failure, not a resolution defect: abort
// the resolver so the caller's infrastructure path
// handles it instead of feeding it back as if the
// agent could repair it.
throw;
}
catch (Exception postEx) when (postEx is IExecutionTransportLoss)
{
// A dead exec transport is infrastructure too — never
// fold it into agent feedback.
throw;
}
catch (Exception postEx)
{
postFailure = $"post-resolution verification failed: {postEx.Message}";
}

if (postFailure is not null)
verification = new VerificationOutcome(false, postFailure);
}

if (verification.Success)
{
return new AgenticConflictResolverResult(
Expand Down
Loading
Loading