From b6b6cf46b11dd35d72345e1549ae6c6e36bca869 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sat, 11 Jul 2026 08:48:42 +0800 Subject: [PATCH 1/2] Resolve the launch's immutable base SHA vector and pin every participant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At launch each cloneable repo's tip commit is resolved ONCE over the same git transport the clones use (git ls-remote with the same URL/credential), frozen into the definition snapshot as the agent node's pinnedSha (primary + per related entry), and honored by the existing PinnedSha substrate at clone time — so the planner, the grounded plan reviewer, and every agent the run dispatches materialize the SAME base even when the remote advances mid-run. Session-soft refs stay unpinned (their branch-or-default disjunction cannot be one commit); URL-less repos stay unpinned; a missing hard ref or unreachable remote fails the launch loud. --- .../Agents/Review/AgentPlanReviewer.cs | 3 + .../Agents/Review/AgentReviewRunner.cs | 5 +- .../Agents/Review/IAgentPlanReviewer.cs | 3 + .../Workspace/AgentWorkspaceAuthoring.cs | 27 +- .../Agents/Workspace/IRemoteTipResolver.cs | 21 ++ .../Agents/Workspace/RemoteTipResolver.cs | 86 ++++++ .../Tasks/Launch/ILaunchBasePinResolver.cs | 20 ++ .../Tasks/Launch/LaunchBasePinResolver.cs | 85 +++++ .../Projection/Builders/AgentNodeMapping.cs | 8 +- .../Builders/PlanMap/PlanMapBuilderBase.cs | 3 + .../Services/Tasks/TaskLaunchService.cs | 11 +- .../Workflows/Nodes/Builtin/AgentCodeNode.cs | 9 +- .../Workflows/Nodes/Builtin/PlanAuthorNode.cs | 5 +- .../Planners/CriticPlannerDecorator.cs | 1 + .../Agents/WorkspaceSpec.cs | 11 +- .../Workflows/Planning/WorkflowPlanRequest.cs | 7 + .../Tasks/TaskBuildContext.cs | 3 + .../Workflows/LaunchBasePinFlowTests.cs | 290 ++++++++++++++++++ .../Agents/AgentPlanReviewerTests.cs | 19 ++ .../Agents/WorkspaceSpecTests.cs | 60 ++++ .../Tasks/LaunchBasePinResolverTests.cs | 91 ++++++ .../Workflows/AgentNodeMappingTests.cs | 37 +++ .../Workflows/CriticPlannerDecoratorTests.cs | 2 + .../Workflows/PlanAuthorNodeTests.cs | 12 +- .../Workflows/RemoteTipResolverTests.cs | 183 +++++++++++ 25 files changed, 980 insertions(+), 22 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchBasePinResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Tasks/LaunchBasePinResolverTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Agents/Review/AgentPlanReviewer.cs b/backend/src/CodeSpace.Core/Services/Agents/Review/AgentPlanReviewer.cs index 8942c25ea..f09e2cbe2 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Review/AgentPlanReviewer.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Review/AgentPlanReviewer.cs @@ -26,6 +26,9 @@ await _runner.RunAsync(new AgentReviewSpec SubjectInstructions = BuildReviewInstructions(request.PlanArtifact, request.Goal), RepositoryId = request.RepositoryId, BaseRef = null, // the plan targets the repo's CURRENT state — clone the default branch + // S1: …and when the launch pinned an immutable base, materialize exactly THAT commit — the reviewer must + // judge the plan against the same tree the executing agents will see, not a tip that moved since launch. + PinnedSha = request.PinnedSha, TeamId = request.TeamId, WorkflowRunId = request.WorkflowRunId, NodeId = request.NodeId, diff --git a/backend/src/CodeSpace.Core/Services/Agents/Review/AgentReviewRunner.cs b/backend/src/CodeSpace.Core/Services/Agents/Review/AgentReviewRunner.cs index 94b31d269..983edea42 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Review/AgentReviewRunner.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Review/AgentReviewRunner.cs @@ -96,7 +96,7 @@ internal static string PickReviewerHarness(string producerHarness, IReadOnlyList Harness = reviewerHarness, ModelCredentialModelId = spec.ReviewerModelId, RepositoryId = spec.RepositoryId, - Workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(spec.RepositoryId, Array.Empty(), primaryRef: spec.BaseRef), + Workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(spec.RepositoryId, Array.Empty(), primaryRef: spec.BaseRef, primaryPinnedSha: spec.PinnedSha), Autonomy = AgentAutonomyLevel.Confined, Permissions = AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined), TimeoutSeconds = ReviewerTimeoutSeconds, @@ -172,6 +172,9 @@ public sealed record AgentReviewSpec /// The ref to clone at — a produced branch for an output review; null (the default branch) for a plan review. public string? BaseRef { get; init; } + /// S1 — the exact base commit to materialize (the launch's immutable base pin). Null ⇒ the tip of / the default branch at review time (legacy). + public string? PinnedSha { get; init; } + public required Guid TeamId { get; init; } /// Observability linkage: the run/node cell the reviewer AgentRun lands on. Null on run-less paths. diff --git a/backend/src/CodeSpace.Core/Services/Agents/Review/IAgentPlanReviewer.cs b/backend/src/CodeSpace.Core/Services/Agents/Review/IAgentPlanReviewer.cs index 9626f4a15..0cbd1d89d 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Review/IAgentPlanReviewer.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Review/IAgentPlanReviewer.cs @@ -37,4 +37,7 @@ public sealed record PlanReviewRequest /// The operator's reviewer model pin; null ⇒ auto. public Guid? ReviewerModelId { get; init; } + + /// S1 — the exact base commit to clone the repository at (the launch's immutable base pin), so the reviewer verifies the plan against the SAME tree the executing agents materialize. Null ⇒ the default branch's tip at review time (legacy). + public string? PinnedSha { get; init; } } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentWorkspaceAuthoring.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentWorkspaceAuthoring.cs index fcc6cb8fc..1313994da 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentWorkspaceAuthoring.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentWorkspaceAuthoring.cs @@ -71,8 +71,12 @@ public static WorkspaceRepositorySpec ToRelatedSpec(Guid repositoryId, string? a /// entry, its ref is emitted so the agent clones it at the prior turn's produced branch. Null map / a repo /// absent from it ⇒ NO ref key for that entry (byte-identical — the repo clones at its default branch). The /// agent.code path passes the map; the supervisor passes null (it has no per-repo continuity — out of scope). + /// (S1 — the launch's immutable base vector) supplies a per-repo base pin: + /// when a repo has an entry, its pinnedSha is emitted so the agent materializes that exact commit. Wins + /// over a spec-carried pin (the launch vector is fresher than a spec authored earlier); null map / a repo absent + /// from it falls back to the spec's own round-trip (byte-identical). /// - public static IReadOnlyList>? SerializeRelatedRepositories(IReadOnlyList? related, IReadOnlyDictionary? baseRefs = null) + public static IReadOnlyList>? SerializeRelatedRepositories(IReadOnlyList? related, IReadOnlyDictionary? baseRefs = null, IReadOnlyDictionary? pinnedShas = null) { if (related is not { Count: > 0 }) return null; @@ -93,8 +97,10 @@ public static WorkspaceRepositorySpec ToRelatedSpec(Guid repositoryId, string? a entry["refSoftFallback"] = true; } - // S1: the pin survives the projection round-trip (omitted when null — byte-identical to before). - if (!string.IsNullOrWhiteSpace(r.PinnedSha)) entry["pinnedSha"] = r.PinnedSha; + // S1: the pin survives the projection round-trip — the launch vector's entry when present, else the + // spec's own carried pin (omitted when neither — byte-identical to before). + var pin = pinnedShas is not null && pinnedShas.TryGetValue(r.RepositoryId, out var vectorPin) && !string.IsNullOrWhiteSpace(vectorPin) ? vectorPin : r.PinnedSha; + if (!string.IsNullOrWhiteSpace(pin)) entry["pinnedSha"] = pin; return entry; }).ToList(); @@ -113,18 +119,19 @@ internal static WorkspaceAccess ParseAccess(string? access) => /// guards a null primary, the analysis-only case.) is the primary's authored ref /// (null = the repo default). /// EXCEPTION: a single-repo run that PINS a primary ref (session branch continuity — start the next turn - /// from the prior turn's produced branch) needs an EXPLICIT one-repo spec so the resolver clones at that ref; - /// without a ref it stays null (byte-identical — the executor derives FromRepository(id) at the default - /// branch). So: related repos ⇒ the multi-repo spec; else a pinned ref ⇒ FromRepository(id, ref); else null. + /// from the prior turn's produced branch) OR a primary base commit (S1 — ) + /// needs an EXPLICIT one-repo spec so the resolver clones at that ref / materializes that commit; without either + /// it stays null (byte-identical — the executor derives FromRepository(id) at the default branch). So: + /// related repos ⇒ the multi-repo spec; else a pinned ref or base commit ⇒ FromRepository(id, ref, …, sha); else null. /// - public static WorkspaceSpec? ResolveAuthoredWorkspace(Guid? primaryRepositoryId, IReadOnlyList relatedRepositories, string? primaryRef = null, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto) + public static WorkspaceSpec? ResolveAuthoredWorkspace(Guid? primaryRepositoryId, IReadOnlyList relatedRepositories, string? primaryRef = null, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto, string? primaryPinnedSha = null) { if (primaryRepositoryId is not { } primaryId) return null; // cwdMode only bites a MULTI-repo workspace; a single-repo run always runs at the repo root (the single-repo - // invariant), so the pinned-ref single-repo branch below ignores it (byte-identical). - if (relatedRepositories.Count > 0) return WorkspaceSpec.FromAuthoredRepos(primaryId, primaryRef, relatedRepositories, primaryRefSoftFallback, cwdMode); + // invariant), so the pinned single-repo branch below ignores it (byte-identical). + if (relatedRepositories.Count > 0) return WorkspaceSpec.FromAuthoredRepos(primaryId, primaryRef, relatedRepositories, primaryRefSoftFallback, cwdMode, primaryPinnedSha); - return string.IsNullOrWhiteSpace(primaryRef) ? null : WorkspaceSpec.FromRepository(primaryId, primaryRef, primaryRefSoftFallback); + return string.IsNullOrWhiteSpace(primaryRef) && string.IsNullOrWhiteSpace(primaryPinnedSha) ? null : WorkspaceSpec.FromRepository(primaryId, primaryRef, primaryRefSoftFallback, primaryPinnedSha); } } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs new file mode 100644 index 000000000..9a67d6096 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs @@ -0,0 +1,21 @@ +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.Workspace; + +/// +/// Resolves the tip COMMIT of a clone request's effective ref over the GIT transport itself (git ls-remote) — +/// the same transport, URL, and credential the eventual clone uses, so the resolved sha can never skew from what the +/// clone would materialize (a provider-API lookup could disagree with the git remote in tests and on mirrors; the +/// transport cannot). S1's launch-time immutable-base vector is built from these. +/// +public interface IRemoteTipResolver +{ + /// + /// The tip commit sha of 's effective ref: when set + /// (falling back to under the request's own SOFT semantics when the ref + /// is gone), else the remote's HEAD. Null ONLY for an empty remote (no commits — nothing exists to pin); a + /// missing HARD ref or an unreachable remote throws LOUD — the clone would fail + /// the same way later, and the pin's contract is early, honest failure over a silently unpinned launch. + /// + Task ResolveTipShaAsync(WorkspaceRequest request, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs new file mode 100644 index 000000000..29f6250c2 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs @@ -0,0 +1,86 @@ +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace.Providers; +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.Workspace; + +/// +/// over git ls-remote, run through the local +/// exactly like 's own git calls (same auth-URL embedding, same +/// token redaction on surfaced errors, same process/timeout handling). Branch first, tag second (preferring the +/// peeled ^{} commit over the annotated tag object — the pin is a COMMIT), HEAD when no ref is named. +/// +public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency +{ + private const int LsRemoteTimeoutSeconds = 60; + + private readonly ISandboxRunnerRegistry _runners; + + public RemoteTipResolver(ISandboxRunnerRegistry runners) { _runners = runners; } + + public async Task ResolveTipShaAsync(WorkspaceRequest request, CancellationToken cancellationToken) + { + var url = LocalGitWorkspaceProvider.BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token); + + if (string.IsNullOrWhiteSpace(request.Ref)) return await ResolveHeadAsync(url, request, cancellationToken).ConfigureAwait(false); + + if (await ResolveRefAsync(url, request.Ref!, request, cancellationToken).ConfigureAwait(false) is { } sha) return sha; + + // The request's own SOFT semantics (a session-inherited prior branch that a merged PR may have pruned): + // fall to the default branch, mirroring the clone's ResolveCheckoutRefAsync. A HARD ref (DefaultRef null) + // that is gone fails LOUD — the clone would fail identically later; the pin just surfaces it at launch. + if (!string.IsNullOrWhiteSpace(request.DefaultRef) && !string.Equals(request.Ref, request.DefaultRef, StringComparison.Ordinal)) + return await ResolveRefAsync(url, request.DefaultRef!, request, cancellationToken).ConfigureAwait(false) + ?? throw MissingRef(request.DefaultRef!, request); + + throw MissingRef(request.Ref!, request); + } + + /// The remote's HEAD commit — null for an EMPTY remote (ls-remote succeeds with no output: nothing exists to pin). + private async Task ResolveHeadAsync(string url, WorkspaceRequest request, CancellationToken cancellationToken) + { + var lines = await LsRemoteAsync(url, new[] { "HEAD" }, request, cancellationToken).ConfigureAwait(false); + + return lines.Where(l => l.Ref == "HEAD").Select(l => l.Sha).FirstOrDefault(); + } + + /// The tip commit of a NAMED ref: its branch, else its tag (peeled ^{{}} commit preferred over the annotated tag object). Null when the remote has no such ref. + private async Task ResolveRefAsync(string url, string @ref, WorkspaceRequest request, CancellationToken cancellationToken) + { + var branch = await LsRemoteAsync(url, new[] { $"refs/heads/{@ref}" }, request, cancellationToken).ConfigureAwait(false); + + if (branch.Count > 0) return branch[0].Sha; + + var tags = await LsRemoteAsync(url, new[] { $"refs/tags/{@ref}", $"refs/tags/{@ref}^{{}}" }, request, cancellationToken).ConfigureAwait(false); + + return tags.Where(t => t.Ref.EndsWith("^{}", StringComparison.Ordinal)).Select(t => t.Sha).FirstOrDefault() + ?? tags.Select(t => t.Sha).FirstOrDefault(); + } + + /// One git ls-remote round-trip parsed to (sha, ref) lines. A non-zero exit throws LOUD with the token redacted — an unreachable remote at launch is the SAME failure the clone would surface later, just earlier and honest. + private async Task> LsRemoteAsync(string url, IReadOnlyList patterns, WorkspaceRequest request, CancellationToken cancellationToken) + { + var args = new List { "ls-remote", url }; + args.AddRange(patterns); + + var result = await _runners.Resolve(LocalProcessRunner.LocalKind) + .RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = LsRemoteTimeoutSeconds }, cancellationToken).ConfigureAwait(false); + + if (result.Status != SandboxStatus.Success) + throw new WorkspaceException($"git ls-remote failed (exit {result.ExitCode}) resolving the launch base for {request.RepositoryUrl}: {LocalGitWorkspaceProvider.Redact(Truncate(result.Stderr), request.Token)} — the launch base pin requires a reachable remote; the clone would fail the same way later"); + + return result.Stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(line => line.Split('\t')) + .Where(parts => parts.Length == 2 && parts[0].Length == 40) + .Select(parts => (parts[0], parts[1])) + .ToList(); + } + + private static WorkspaceException MissingRef(string @ref, WorkspaceRequest request) => + new($"the launch base ref '{@ref}' does not exist on {request.RepositoryUrl} — the launch base pin is resolved from the ref the run would clone, so a missing ref fails the launch loud instead of silently launching unpinned"); + + private static string Truncate(string text) => text.Length <= 500 ? text : text[..500]; +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchBasePinResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchBasePinResolver.cs new file mode 100644 index 000000000..ce6be548c --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchBasePinResolver.cs @@ -0,0 +1,20 @@ +using CodeSpace.Messages.Tasks; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// +/// S1 — resolves the launch's immutable base VECTOR: one tip commit per repo the launch touches, resolved ONCE at +/// launch time so the planner, the grounded plan reviewer, and every agent the run dispatches materialize the SAME +/// base regardless of when they clone. The vector rides TaskBuildContext.PinnedShas into every projection. +/// +public interface ILaunchBasePinResolver +{ + /// + /// The per-repo (repositoryId → commit sha) base vector for a launch, over the primary + every related repo. + /// A repo is UNPINNED (absent from the map) when it has no clone URL (nothing will ever clone it), when it rides + /// a SESSION-soft ref from (a soft ref's "prior branch, or default if pruned" + /// disjunction cannot be expressed as one commit), or when its remote is empty. A HARD ref that is missing or an + /// unreachable remote fails the launch LOUD (the clone would fail identically later). Null when nothing pinned. + /// + Task?> ResolveVectorAsync(Guid teamId, TaskLaunchSeed seed, ResolvedAgentProfile profile, IReadOnlyDictionary sessionBaseRefs, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs new file mode 100644 index 000000000..69e989a60 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs @@ -0,0 +1,85 @@ +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Messages.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// +/// Default : per eligible repo, resolve the clone request through the SAME +/// the run's clone uses (same URL, same credential, same ref defaulting), then +/// read the tip over the SAME git transport () — so the pin can never skew from what +/// the clone would have fetched. Hard refs come from the launch's own authoring (the operator's BaseBranch pin for +/// the primary; each related spec's authored ref); a repo riding a SESSION-soft ref is left unpinned by design. +/// +public sealed class LaunchBasePinResolver : ILaunchBasePinResolver, IScopedDependency +{ + private readonly CodeSpaceDbContext _db; + private readonly IAgentWorkspaceResolver _workspaces; + private readonly IRemoteTipResolver _tips; + + public LaunchBasePinResolver(CodeSpaceDbContext db, IAgentWorkspaceResolver workspaces, IRemoteTipResolver tips) + { + _db = db; + _workspaces = workspaces; + _tips = tips; + } + + public async Task?> ResolveVectorAsync(Guid teamId, TaskLaunchSeed seed, ResolvedAgentProfile profile, IReadOnlyDictionary sessionBaseRefs, CancellationToken cancellationToken) + { + var scope = CollectScope(seed, profile, sessionBaseRefs); + + if (scope.Count == 0) return null; + + var cloneable = await LoadCloneableRepoIdsAsync(teamId, scope.Keys, cancellationToken).ConfigureAwait(false); + + var vector = new Dictionary(); + + foreach (var (repositoryId, hardRef) in scope) + { + if (!cloneable.Contains(repositoryId)) continue; // no clone URL ⇒ nothing will ever clone it ⇒ unpinned + + var request = await _workspaces.ResolveByRepositoryIdAsync(repositoryId, teamId, cancellationToken, hardRef).ConfigureAwait(false); + + if (request is null) continue; + + if (await _tips.ResolveTipShaAsync(request, cancellationToken).ConfigureAwait(false) is { } sha) vector[repositoryId] = sha; + } + + return vector.Count > 0 ? vector : null; + } + + /// The (repositoryId → hard authored ref, null = default branch) pairs to pin: the primary (the operator's BaseBranch pin) + each related repo (its authored ref). A repo riding a SESSION-soft ref is excluded — its branch-or-default disjunction cannot be one commit. Internal static so the eligibility policy is unit-pinned directly. + internal static IReadOnlyDictionary CollectScope(TaskLaunchSeed seed, ResolvedAgentProfile profile, IReadOnlyDictionary sessionBaseRefs) + { + var scope = new Dictionary(); + + if ((profile.RepositoryId ?? seed.RepositoryId) is { } primaryId && !sessionBaseRefs.ContainsKey(primaryId)) + scope[primaryId] = NullIfBlank(seed.BaseBranch); + + foreach (var related in profile.RelatedRepositories ?? Array.Empty()) + { + if (sessionBaseRefs.ContainsKey(related.RepositoryId) || scope.ContainsKey(related.RepositoryId)) continue; + + scope[related.RepositoryId] = NullIfBlank(related.Ref); + } + + return scope; + } + + /// The subset of with a non-blank HTTPS clone URL, team-scoped — the others are unpinnable by construction (the workspace resolver fails loud on them, but a launch that never clones them must not). + private async Task> LoadCloneableRepoIdsAsync(Guid teamId, IEnumerable repositoryIds, CancellationToken cancellationToken) + { + var ids = repositoryIds.ToList(); + + var cloneable = await _db.Repository.AsNoTracking() + .Where(r => ids.Contains(r.Id) && r.TeamId == teamId && r.DeletedDate == null && r.CloneUrlHttps != null && r.CloneUrlHttps != "") + .Select(r => r.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return cloneable.ToHashSet(); + } + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs index b2872a6b0..54f2b884d 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs @@ -132,7 +132,8 @@ public static JsonElement BuildAgentInputs(TaskBuildContext context) AddIfPresent(inputs, "repositoryId", repositoryId?.ToString()); // Session branch continuity: each related repo also clones at its own prior produced branch — the baseRefs map // threads a per-entry ref onto the relatedRepositories shape (omitted per entry when none, byte-identical). - AddIfPresent(inputs, "relatedRepositories", AgentWorkspaceAuthoring.SerializeRelatedRepositories(context.AgentProfile?.RelatedRepositories, baseRefs)); + // S1: each related repo also carries its launch-resolved base pin (omitted per entry when unpinned). + AddIfPresent(inputs, "relatedRepositories", AgentWorkspaceAuthoring.SerializeRelatedRepositories(context.AgentProfile?.RelatedRepositories, baseRefs, context.PinnedShas)); // …and clone the PRIMARY repo at its prior produced branch, else at the operator's launch-pinned BaseBranch // (H3 — the field had a complete write chain down to TaskLaunchSeed and ZERO readers, so an operator pinning @@ -150,6 +151,11 @@ public static JsonElement BuildAgentInputs(TaskBuildContext context) // alongside a SESSION baseRef; the operator's pin (and an author-pinned baseRef) never carries this ⇒ // stays a HARD ref — a missing pinned branch fails LOUD, never a silent default-branch fallback. if (primaryBaseRef is not null) inputs["baseRefFromSession"] = true; + + // S1: the primary's launch-resolved base pin — the exact commit every participant of this run materializes. + // Never set alongside a SESSION baseRef (the launch resolver skips soft-ref repos — a pin cannot express + // the soft ref's branch-or-default disjunction); absent ⇒ omitted ⇒ tip-of-ref (byte-identical). + AddIfPresent(inputs, "pinnedSha", BaseRefFor(context.PinnedShas, primaryId)); } return JsonSerializer.SerializeToElement(inputs); diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/PlanMap/PlanMapBuilderBase.cs b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/PlanMap/PlanMapBuilderBase.cs index 1229a735d..a3900fc31 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/PlanMap/PlanMapBuilderBase.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/PlanMap/PlanMapBuilderBase.cs @@ -110,6 +110,9 @@ private static JsonElement PlannerConfig(TaskBuildContext context) var reviewerAgentOn = context.PlannerReviewMode != ReviewMode.None && context.AgentProfile?.ReviewerAgent == true && context.AgentProfile?.RepositoryId is not null; AddIfPresent(config, "reviewerAgent", reviewerAgentOn ? true : (bool?)null); AddIfPresent(config, "repositoryId", reviewerAgentOn ? context.AgentProfile!.RepositoryId!.Value.ToString() : null); + // S1: the reviewer clones at the launch's immutable base pin — the SAME commit the fan-out agents materialize, + // so the tree the plan is verified against can never drift from the tree the plan executes on. + AddIfPresent(config, "pinnedSha", reviewerAgentOn && context.PinnedShas is { } pins && pins.TryGetValue(context.AgentProfile!.RepositoryId!.Value, out var pin) ? pin : null); return JsonSerializer.SerializeToElement(config); } diff --git a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs index b0f2bce11..2b7cb636c 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs @@ -35,12 +35,13 @@ public sealed class TaskLaunchService : ITaskLaunchService, IScopedDependency private readonly ISessionContextBuilder _sessionContext; private readonly ISessionSummarizer _sessionSummarizer; private readonly ISessionBranchResolver _sessionBranches; + private readonly ILaunchBasePinResolver _basePins; private readonly IModelPoolSelector _modelSelector; private readonly ILLMClientRegistry _llm; private readonly CodeSpaceDbContext _db; private readonly ILogger _logger; - public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, IEffortRouter router, ITaskRunSnapshotFactory factory, IWorkSessionService sessions, ISessionContextBuilder sessionContext, ISessionSummarizer sessionSummarizer, ISessionBranchResolver sessionBranches, IModelPoolSelector modelSelector, ILLMClientRegistry llm, CodeSpaceDbContext db, ILogger logger) + public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, IEffortRouter router, ITaskRunSnapshotFactory factory, IWorkSessionService sessions, ISessionContextBuilder sessionContext, ISessionSummarizer sessionSummarizer, ISessionBranchResolver sessionBranches, ILaunchBasePinResolver basePins, IModelPoolSelector modelSelector, ILLMClientRegistry llm, CodeSpaceDbContext db, ILogger logger) { _seedProviders = seedProviders; _router = router; @@ -49,6 +50,7 @@ public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, IEffortR _sessionContext = sessionContext; _sessionSummarizer = sessionSummarizer; _sessionBranches = sessionBranches; + _basePins = basePins; _modelSelector = modelSelector; _llm = llm; _db = db; @@ -86,6 +88,11 @@ public async Task LaunchAsync(TaskLaunchRequest request, Cance // on earlier CODE (not just the narrative). Empty on a fresh launch / no repo / no prior branch ⇒ default branches. var baseRefs = await ResolveBaseRefsAsync(request, seed, profile, cancellationToken).ConfigureAwait(false); + // S1: the launch's immutable base vector — each repo's tip commit resolved ONCE, over the same git transport + // the clones use, so the planner, the grounded plan reviewer, and every dispatched agent materialize the SAME + // base even when the remote advances mid-run. Session-soft + URL-less repos stay unpinned (resolver contract). + var pinnedShas = await _basePins.ResolveVectorAsync(request.TeamId, seed, profile, baseRefs, cancellationToken).ConfigureAwait(false); + // Deep/Auto: the supervisor's brain model — the operator's pinned "Brain model" chip when set + usable, else // self-resolved so the decider has one instead of stopping turn-1. Inert (null) for every non-supervisor // projection — single-agent / map launches are byte-identical. PinIneligible flags the ONE case worth @@ -102,7 +109,7 @@ public async Task LaunchAsync(TaskLaunchRequest request, Cance ? await _sessions.EnsureConversationAsync(session.SessionId, request.TeamId, request.ActorUserId, cancellationToken).ConfigureAwait(false) : (Guid?)null; - var context = new TaskBuildContext { Seed = seed, Route = route, AgentProfile = profile, GroundingContext = grounding, BaseRefs = baseRefs, SupervisorBrainModelId = brainModelId, SupervisorBrainModelPinIneligible = brainPinIneligible, ConversationId = conversationId, PlannerModelRowId = plannerModelRowId, PlannerReviewMode = request.PlannerReviewMode, AllowedModelIds = request.AllowedModelIds, AllowedAgentDefinitionIds = request.AllowedAgentDefinitionIds, AcceptanceCriteria = request.AcceptanceCriteria, AcceptanceChecks = request.AcceptanceChecks, DeliverySpec = request.DeliverySpec, RequirePlanConfirmation = request.RequirePlanConfirmation == true, DecisionReviewMode = request.DecisionReviewMode, ReviewerModelId = request.ReviewerModelId }; + var context = new TaskBuildContext { Seed = seed, Route = route, AgentProfile = profile, GroundingContext = grounding, BaseRefs = baseRefs, PinnedShas = pinnedShas, SupervisorBrainModelId = brainModelId, SupervisorBrainModelPinIneligible = brainPinIneligible, ConversationId = conversationId, PlannerModelRowId = plannerModelRowId, PlannerReviewMode = request.PlannerReviewMode, AllowedModelIds = request.AllowedModelIds, AllowedAgentDefinitionIds = request.AllowedAgentDefinitionIds, AcceptanceCriteria = request.AcceptanceCriteria, AcceptanceChecks = request.AcceptanceChecks, DeliverySpec = request.DeliverySpec, RequirePlanConfirmation = request.RequirePlanConfirmation == true, DecisionReviewMode = request.DecisionReviewMode, ReviewerModelId = request.ReviewerModelId }; var handle = await _factory.CreateAndRunAsync(context, request.TeamId, request.ActorUserId, session, cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs index 8e4499a7e..18afec8a0 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs @@ -79,6 +79,7 @@ public sealed class AgentCodeNode : INodeRuntime "properties": { "repositoryId": { "type": "string", "format": "uuid", "x-selector": "repository", "description": "The PRIMARY repository the agent works in — cloned into its workspace before it runs. Pick one, or switch to Expression to bind it from the trigger (e.g. {{trigger.repositoryId}}). Leave empty for an analysis-only run with no repo." }, "baseRef": { "type": "string", "description": "The branch/ref to clone the PRIMARY repository at. Leave empty for the repo's default branch. A session follow-up sets this to the prior turn's produced branch so the agent builds on earlier work instead of starting from the default branch." }, + "pinnedSha": { "type": "string", "description": "The EXACT commit to materialize the PRIMARY repository at (S1 — the launch's immutable base). Leave empty for the tip of baseRef / the default branch. When set, the clone is full and hard-checks-out this commit; a missing/unreachable pin fails the run loud." }, "relatedRepositories": { "type": "array", "description": "Multi-repo: ALSO clone these repositories into the workspace (for a coordinated change across e.g. a frontend + backend). The primary is repositoryId; leave empty for a single-repo run.", @@ -161,7 +162,7 @@ public Task RunAsync(NodeRunContext context, CancellationToken cance var cwdMode = WorkspaceCwdModeWire.FromWire(ReadOptionalString(context.Config, "cwdMode")) ?? WorkspaceCwdMode.Auto; - var workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(repositoryId, related, ReadBaseRef(context), ReadBaseRefFromSession(context), cwdMode); + var workspace = AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(repositoryId, related, ReadBaseRef(context), ReadBaseRefFromSession(context), cwdMode, ReadPinnedSha(context)); if (!TryReadAcceptance(context.Config, out var acceptance, out var acceptanceError)) return Fail(acceptanceError!); @@ -387,6 +388,12 @@ private static bool TryReadModelCredentialModelId(NodeRunContext context, out Gu private static bool ReadBaseRefFromSession(NodeRunContext context) => context.Inputs.TryGetValue("baseRefFromSession", out var v) && v.ValueKind == JsonValueKind.True; + /// Read the optional pinnedSha input — the primary repo's launch-resolved base pin (S1): the EXACT commit the workspace materializes. Absent / blank / non-string → null (tip-of-ref, byte-identical). + private static string? ReadPinnedSha(NodeRunContext context) => + context.Inputs.TryGetValue("pinnedSha", out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString()) + ? v.GetString() + : null; + /// Read the optional repositoryId input. Absent / empty → no repo (null, an analysis-only run). Present-but-malformed → false (a clean node failure). private static bool TryReadRepositoryId(NodeRunContext context, out Guid? repositoryId) { diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs index 16c364b72..d3e4d9225 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs @@ -55,7 +55,8 @@ public PlanAuthorNode(IServiceScopeFactory scopeFactory) "reviewerModelId": { "type": "string", "format": "uuid", "x-selector": "credentialedModel", "description": "The credentialed model the plan reviewer runs on (ideally distinct from the planner). Leave empty to auto-pick. Only used when reviewMode is not 0." }, "flatPlan": { "type": "boolean", "default": false, "description": "Constrain the plan to INDEPENDENT subtasks (no dependsOn) — set by parallel fan-out projections (flow.map runs every item concurrently, so ordering could not be honored). Authored dependencies are stripped as a fail-safe (logged)." }, "reviewerAgent": { "type": "boolean", "default": false, "description": "D①: review the plan with a REAL independent agent that clones the repository below and verifies the plan against the actual code (assumptions, feasibility, already-done work), instead of only the in-process model critic. Falls back to the model critic when the agent cannot produce a verdict. Only used when reviewMode is not 0 AND repositoryId is set." }, - "repositoryId": { "type": "string", "format": "uuid", "x-selector": "repository", "description": "The repository the plan targets — what the grounded plan reviewer clones (read-only). Only used when reviewerAgent is on." } + "repositoryId": { "type": "string", "format": "uuid", "x-selector": "repository", "description": "The repository the plan targets — what the grounded plan reviewer clones (read-only). Only used when reviewerAgent is on." }, + "pinnedSha": { "type": "string", "description": "The EXACT commit the grounded plan reviewer clones at (S1 — the launch's immutable base), so the tree the plan is verified against matches the tree the executing agents materialize. Leave empty for the repository's default-branch tip. Only used when reviewerAgent is on." } } } """), @@ -179,6 +180,8 @@ public async Task RunAsync(NodeRunContext context, CancellationToken // D① grounded plan review: a real read-only agent verifies the plan against this repository's actual tree. ReviewerAgent = ReadBool(config, "reviewerAgent"), RepositoryId = ReadGuid(config, "repositoryId"), + // S1: the launch's immutable base pin — the reviewer clones the SAME commit the executing agents materialize. + PinnedSha = string.IsNullOrWhiteSpace(ReadString(config, "pinnedSha")) ? null : ReadString(config, "pinnedSha"), WorkflowRunId = workflowRunId, NodeId = nodeId, }; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/CriticPlannerDecorator.cs b/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/CriticPlannerDecorator.cs index 74f9c4ab3..836769a4a 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/CriticPlannerDecorator.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/CriticPlannerDecorator.cs @@ -50,6 +50,7 @@ public async Task PlanAsync(WorkflowPlanRequest request, Cancel WorkflowRunId = request.WorkflowRunId, NodeId = request.NodeId, ReviewerModelId = request.ReviewerModelId, + PinnedSha = request.PinnedSha, }, cancellationToken).ConfigureAwait(false) : CriticVerdict.ReviewFailed(request.Review, "agent-reviewer: not requested"); diff --git a/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs b/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs index 5ae358967..66501bbb3 100644 --- a/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs +++ b/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs @@ -33,13 +33,14 @@ public sealed record WorkspaceSpec /// /// Build the single-repo workspace an existing AgentTask.RepositoryId implies — one writable primary repo /// at the default alias. The back-compat bridge so every single-repo run resolves through the SAME canonical - /// as a multi-repo one, with byte-identical execution. + /// as a multi-repo one, with byte-identical execution. + /// (S1) pins the primary to an exact base commit (see ). /// - public static WorkspaceSpec FromRepository(Guid repositoryId, string? @ref = null, bool refSoftFallback = false) => new() + public static WorkspaceSpec FromRepository(Guid repositoryId, string? @ref = null, bool refSoftFallback = false, string? pinnedSha = null) => new() { Repositories = new[] { - new WorkspaceRepositorySpec { Alias = DefaultAlias, RepositoryId = repositoryId, Ref = @ref, RefSoftFallback = refSoftFallback, Path = DefaultAlias, Access = WorkspaceAccess.Write, IsPrimary = true }, + new WorkspaceRepositorySpec { Alias = DefaultAlias, RepositoryId = repositoryId, Ref = @ref, RefSoftFallback = refSoftFallback, PinnedSha = pinnedSha, Path = DefaultAlias, Access = WorkspaceAccess.Write, IsPrimary = true }, }, PrimaryAlias = DefaultAlias, CwdMode = WorkspaceCwdMode.Auto, @@ -61,11 +62,11 @@ public sealed record WorkspaceSpec /// first authored occurrence win. Without this, the same repo would clone into two mount folders with conflicting /// access. Symmetric to the alias de-dup: a collision is collapsed, never a double-clone. /// - public static WorkspaceSpec? FromAuthoredRepos(Guid primaryRepositoryId, string? primaryRef, IReadOnlyList relatedRepositories, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto) + public static WorkspaceSpec? FromAuthoredRepos(Guid primaryRepositoryId, string? primaryRef, IReadOnlyList relatedRepositories, bool primaryRefSoftFallback = false, WorkspaceCwdMode cwdMode = WorkspaceCwdMode.Auto, string? primaryPinnedSha = null) { if (relatedRepositories.Count == 0) return null; - var primary = new WorkspaceRepositorySpec { Alias = DefaultAlias, RepositoryId = primaryRepositoryId, Ref = primaryRef, RefSoftFallback = primaryRefSoftFallback, Path = DefaultAlias, Access = WorkspaceAccess.Write, IsPrimary = true }; + var primary = new WorkspaceRepositorySpec { Alias = DefaultAlias, RepositoryId = primaryRepositoryId, Ref = primaryRef, RefSoftFallback = primaryRefSoftFallback, PinnedSha = primaryPinnedSha, Path = DefaultAlias, Access = WorkspaceAccess.Write, IsPrimary = true }; var taken = new HashSet(StringComparer.Ordinal) { DefaultAlias }; var takenRepoIds = new HashSet { primaryRepositoryId }; diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs index d1016a8b3..6052b407e 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs @@ -23,6 +23,13 @@ public sealed record WorkflowPlanRequest /// public Guid? RepositoryId { get; init; } + /// + /// S1 — the exact base commit of this plan is authored against (the launch's immutable + /// base pin). The grounded plan reviewer clones AT this commit, so the tree it verifies the plan against is the + /// same tree the executing agents will materialize. Null ⇒ the default branch's tip at review time (legacy). + /// + public string? PinnedSha { get; init; } + /// /// Optional grounding the planner folds into its prompt (e.g. an honest "top-level repo layout" summary). /// The planning service assembles it from ; the planner is a pure consumer. When diff --git a/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs b/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs index 1dc662a22..1cea54fee 100644 --- a/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs +++ b/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs @@ -34,6 +34,9 @@ public sealed record TaskBuildContext /// Per-repo (repositoryId → branch/ref) clone overrides — session branch continuity: a follow-up turn starts each repo from the prior turn's produced branch for THAT repo (primary + each related). A repo ABSENT from the map clones at its default branch. Null / empty = a fresh launch (byte-identical — every repo default). public IReadOnlyDictionary? BaseRefs { get; init; } + /// S1 — the launch's immutable base: per-repo (repositoryId → commit sha) tip pins resolved ONCE at launch, so the planner, the grounded plan reviewer, and every agent the run dispatches materialize the SAME base commit regardless of when they clone (a remote that advances mid-run can no longer skew participants onto different trees). A repo ABSENT from the map is unpinned (legacy tip-of-ref behaviour): a repo with no clone URL, or one riding a SESSION-soft ref — a soft ref's contract is "the prior branch, or the default if pruned", a disjunction a single pinned commit cannot express. Null / empty = nothing pinned (byte-identical). + public IReadOnlyDictionary? PinnedShas { get; init; } + /// The supervisor's OWN brain-model credentialed-row id (a ModelCredentialModel id), resolved at launch when the Deep lane projects an agent.supervisor node and the operator pinned none — the SupervisorDefinitionBuilder bakes it into the node's supervisorModelId so the decider has a brain instead of stopping turn-1. Resolved ONCE here (replay-stable: every turn + replay reads the same baked id). Null for a non-supervisor projection or an empty pool (the builder then emits no brain — the honest fail-closed floor). public Guid? SupervisorBrainModelId { get; init; } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs new file mode 100644 index 000000000..1d88fd4c3 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs @@ -0,0 +1,290 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Tasks; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Commands.Tasks; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Tasks; +using CodeSpace.Messages.Tasks.Effort; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// S1 — the launch's immutable base vector, proven through the REAL over real +/// Postgres + a REAL local git remote: at launch each cloneable repo's tip is resolved ONCE over the git transport +/// and frozen as the agent node's pinnedSha (primary) / relatedRepositories[].pinnedSha (related), so +/// every participant of the run materializes the SAME base even when the remote advances after launch. URL-less and +/// session-continuing repos stay unpinned (byte-identical legacy); a HARD ref that is gone fails the launch loud. +/// +/// Tier: high-fidelity Integration — real launch pipeline, real git remote (file://); runs are staged, +/// not executed. The provider's materialize-at-pin is proven separately by LocalGitWorkspaceProviderTests. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public class LaunchBasePinFlowTests +{ + private readonly PostgresFixture _fixture; + + public LaunchBasePinFlowTests(PostgresFixture fixture) { _fixture = fixture; } + + [Fact] + public async Task A_fresh_launch_freezes_the_remote_tip_as_the_primary_pin_even_when_the_tip_advances() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + var tipAtLaunch = await remote.SeedAsync("v1"); + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId)); + + await remote.CommitAsync("v2"); // the tip moves on AFTER launch + + (await ReadAgentPinAsync(result.RunId)).ShouldBe(tipAtLaunch, + "the pin is the tip AT LAUNCH, frozen in the definition snapshot — a remote that advances mid-run can no longer skew participants onto different trees"); + } + + [Fact] + public async Task An_operator_pinned_BaseBranch_pins_at_that_branchs_tip() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + await remote.SeedAsync("main-content"); + var releaseTip = await remote.BranchAsync("release/2.x", "release-content"); + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId) with { BaseBranch = "release/2.x" }); + + (await ReadAgentPinAsync(result.RunId)).ShouldBe(releaseTip, "the vector resolves the OPERATOR'S ref, not the default branch"); + } + + [Fact] + public async Task A_missing_operator_BaseBranch_fails_the_launch_loud() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + await remote.SeedAsync("v1"); + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + + var ex = await Should.ThrowAsync(() => LaunchAsync(FreshRequest(teamId, userId, repoId) with { BaseBranch = "release/9.x" })); + + ex.Message.ShouldContain("release/9.x", customMessage: "an operator pin that doesn't exist fails at LAUNCH — the clone would fail identically later, never a silent unpinned launch"); + } + + [Fact] + public async Task A_url_less_repo_stays_unpinned_byte_identical() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + var repoId = await SeedUrlLessRepositoryAsync(teamId); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId)); + + (await ReadAgentPinAsync(result.RunId)).ShouldBeNull("no clone URL ⇒ nothing will ever clone it ⇒ no pin key (byte-identical to before S1)"); + } + + [Fact] + public async Task A_continuing_repo_on_a_session_branch_stays_unpinned() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + await remote.SeedAsync("v1"); + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + var sessionId = await SeedSessionWithCodeTurnAsync(teamId, repoId, "run-1/x"); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId) with { ContinueSessionId = sessionId, TaskText = "Keep going" }); + + (await ReadAgentPinAsync(result.RunId)).ShouldBeNull( + "a session-soft ref's contract is 'the prior branch, or the default if pruned' — a disjunction one commit cannot express, so the continuing repo stays unpinned by design"); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static TaskLaunchRequest FreshRequest(Guid teamId, Guid userId, Guid repoId) => new() + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Fix the login bug", RepositoryId = repoId, RequestedEffort = TaskEffortModes.Quick, Autonomy = "Confined", + }; + + private async Task LaunchAsync(TaskLaunchRequest request) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().LaunchAsync(request, CancellationToken.None); + } + + /// Reads the projected agent.code node's pinnedSha input out of the frozen definition snapshot (null when absent ⇒ unpinned). + private async Task ReadAgentPinAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var run = await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId); + + var agent = JsonDocument.Parse(run.DefinitionSnapshotJson!).RootElement.Clone() + .GetProperty("nodes").EnumerateArray().Single(n => n.GetProperty("id").GetString() == "agent"); + + return agent.GetProperty("inputs").TryGetProperty("pinnedSha", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + } + + private async Task SeedCloneableRepositoryAsync(Guid teamId, string cloneUrl) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + var suffix = Guid.NewGuid().ToString("N")[..8]; + var instanceId = Guid.NewGuid(); + var repoId = Guid.NewGuid(); + + db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.GitHub, DisplayName = "GH", BaseUrl = $"https://gh-{suffix}.local", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.Repository.Add(new Repository { Id = repoId, TeamId = teamId, ProviderInstanceId = instanceId, ExternalId = $"ext-{suffix}", NamespacePath = "acme", Name = "api", FullPath = $"acme/api-{suffix}", WebUrl = "https://gh.local/acme/api", DefaultBranch = "main", CloneUrlHttps = cloneUrl, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + + await db.SaveChangesAsync(); + return repoId; + } + + private async Task SeedUrlLessRepositoryAsync(Guid teamId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + var suffix = Guid.NewGuid().ToString("N")[..8]; + var instanceId = Guid.NewGuid(); + var repoId = Guid.NewGuid(); + + db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.GitHub, DisplayName = "GH", BaseUrl = $"https://gh-{suffix}.local", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.Repository.Add(new Repository { Id = repoId, TeamId = teamId, ProviderInstanceId = instanceId, ExternalId = $"ext-{suffix}", NamespacePath = "acme", Name = "api", FullPath = $"acme/api-{suffix}", WebUrl = "https://gh.local/acme/api", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + + await db.SaveChangesAsync(); + return repoId; + } + + /// A finished single-repo turn that produced a branch — the minimal shape the session branch resolver reads (mirrors WorkSessionBranchFlowTests.SeedTurnAsync). + private async Task SeedSessionWithCodeTurnAsync(Guid teamId, Guid repoId, string branch) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + var sessionId = Guid.CreateVersion7(); + db.WorkSession.Add(new WorkSession { Id = sessionId, TeamId = teamId, Title = "thread", Kind = WorkSessionKind.Task, Status = WorkSessionStatus.Open }); + + var requestId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + db.WorkflowRunRequest.Add(new WorkflowRunRequest + { + Id = requestId, TeamId = teamId, SourceType = WorkflowRunSourceTypes.Snapshot, ActorType = "user", + ActorId = SystemUsers.SeederId, NormalizedPayloadJson = "{}", Status = WorkflowRunRequestStatus.Consumed, + ReceivedAt = now, VerifiedAt = now, NormalizedAt = now, + }); + db.WorkflowRun.Add(new WorkflowRun + { + Id = Guid.NewGuid(), TeamId = teamId, RunRequestId = requestId, SourceType = WorkflowRunSourceTypes.Snapshot, + Status = WorkflowRunStatus.Success, SessionId = sessionId, SessionTurnIndex = 1, + ScopeRepositoryIds = new List { repoId }, + OutputsJson = JsonSerializer.Serialize(new { branch }), + CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId, + }); + + await db.SaveChangesAsync(); + return sessionId; + } + + private IDisposable PauseAutoExecute() + { + SetAutoExecute(clearFirst: true, value: false); + return new Restore(this); + } + + private void SetAutoExecute(bool clearFirst, bool value) + { + using var scope = _fixture.BeginScope(); + var jobClient = scope.Resolve(); + if (clearFirst) jobClient.Clear(); + jobClient.AutoExecute = value; + } + + private sealed class Restore : IDisposable + { + private readonly LaunchBasePinFlowTests _owner; + public Restore(LaunchBasePinFlowTests owner) { _owner = owner; } + public void Dispose() => _owner.SetAutoExecute(clearFirst: false, value: true); + } + + /// A REAL local git repo standing in for the remote (file:// — genuine transport, zero network). GUID-suffixed; best-effort cleanup. + private sealed class GitRemote : IDisposable + { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "cs-launch-pin-" + Guid.NewGuid().ToString("N")); + + public GitRemote() => Directory.CreateDirectory(_dir); + + public string Url => new Uri(_dir).AbsoluteUri; + + public async Task SeedAsync(string content) + { + await RunGitAsync("init", "-b", "main"); + await RunGitAsync("config", "user.email", "test@codespace.dev"); + await RunGitAsync("config", "user.name", "Test"); + await RunGitAsync("config", "commit.gpgsign", "false"); + return await CommitAsync(content); + } + + public async Task CommitAsync(string content) + { + await File.WriteAllTextAsync(Path.Combine(_dir, "file.txt"), content); + await RunGitAsync("add", "."); + await RunGitAsync("commit", "-m", "seed"); + return await StdoutAsync("rev-parse", "HEAD"); + } + + /// Create + commit on a new branch, then return to main. Returns the branch's tip sha. + public async Task BranchAsync(string name, string content) + { + await RunGitAsync("checkout", "-b", name); + var sha = await CommitAsync(content); + await RunGitAsync("checkout", "main"); + return sha; + } + + private async Task RunGitAsync(params string[] args) + { + var result = await new LocalProcessRunner().RunAsync( + new SandboxSpec { Command = "git", Args = args, WorkingDirectory = _dir, TimeoutSeconds = 60 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + } + + private async Task StdoutAsync(params string[] args) + { + var result = await new LocalProcessRunner().RunAsync( + new SandboxSpec { Command = "git", Args = args, WorkingDirectory = _dir, TimeoutSeconds = 60 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + + return result.Stdout.Trim(); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentPlanReviewerTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentPlanReviewerTests.cs index f14e0d4bd..742905827 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentPlanReviewerTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentPlanReviewerTests.cs @@ -62,4 +62,23 @@ public void A_plan_review_task_clones_the_default_branch_read_only() task.Autonomy.ShouldBe(AgentAutonomyLevel.Confined, "the reviewer READS — it never writes"); task.Goal.ShouldContain(AgentReviewRunner.VerdictMarker, customMessage: "the shared final-message contract rides every review goal"); } + + [Fact] + public void A_pinned_plan_review_task_materializes_the_launch_base_commit() + { + // S1: with a launch pin, the reviewer must judge the plan against the SAME tree the executing agents will + // materialize — never the default branch's tip at review time, which may have moved since launch. + var task = AgentReviewRunner.BuildReviewTask(new AgentReviewSpec + { + SubjectInstructions = "verify the plan", + RepositoryId = Guid.NewGuid(), + BaseRef = null, + PinnedSha = "abc123def456", + TeamId = Guid.NewGuid(), + IterationKey = AgentPlanReviewer.IterationKey, + }, "codex-cli"); + + task.Workspace.ShouldNotBeNull("a pin needs an explicit spec — a null Workspace would clone the default TIP and silently drop the pin"); + task.Workspace!.Repositories.Single().PinnedSha.ShouldBe("abc123def456"); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs index 2df6dfe21..d77381eeb 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs @@ -84,6 +84,66 @@ public void ValidatePinnedSha_rejects_non_commit_ids_loud(string raw) Should.Throw(() => Core.Services.Agents.Workspace.RepositoryWorkspaceResolver.ValidatePinnedSha(raw)); } + // ── S1: the launch vector's pins thread onto the workspace-spec factories. ────────── + + [Fact] + public void FromRepository_carries_the_primary_pin() + { + var spec = WorkspaceSpec.FromRepository(Guid.NewGuid(), @ref: "main", pinnedSha: "abc123def456"); + + spec.Repositories.Single().PinnedSha.ShouldBe("abc123def456"); + } + + [Fact] + public void FromAuthoredRepos_carries_the_primary_pin_and_leaves_related_specs_as_authored() + { + var related = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = Guid.NewGuid(), Access = WorkspaceAccess.Read, PinnedSha = "bbb222bbb222" } }; + + var spec = WorkspaceSpec.FromAuthoredRepos(Guid.NewGuid(), primaryRef: null, related, primaryPinnedSha: "aaa111aaa111")!; + + spec.Repositories.Single(r => r.IsPrimary).PinnedSha.ShouldBe("aaa111aaa111"); + spec.Repositories.Single(r => !r.IsPrimary).PinnedSha.ShouldBe("bbb222bbb222", "a related spec's own pin survives the projection untouched"); + } + + [Fact] + public void ResolveAuthoredWorkspace_builds_an_explicit_single_repo_spec_for_a_pin_only_run() + { + // A pin with no authored ref must still produce an explicit spec — a null Workspace would silently drop the + // pin when the executor derives FromRepository(id) at the default branch's TIP. + var spec = Core.Services.Agents.Workspace.AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(Guid.NewGuid(), Array.Empty(), primaryRef: null, primaryPinnedSha: "abc123def456"); + + spec.ShouldNotBeNull(); + spec!.Repositories.Single().PinnedSha.ShouldBe("abc123def456"); + } + + [Fact] + public void ResolveAuthoredWorkspace_stays_null_with_no_ref_and_no_pin_byte_identical() + { + Core.Services.Agents.Workspace.AgentWorkspaceAuthoring.ResolveAuthoredWorkspace(Guid.NewGuid(), Array.Empty()) + .ShouldBeNull("no related repos, no ref, no pin ⇒ null Workspace ⇒ the executor's legacy single-repo derivation"); + } + + [Fact] + public void SerializeRelatedRepositories_prefers_the_launch_vectors_pin_over_the_spec_carried_pin() + { + var repoId = Guid.NewGuid(); + var related = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = repoId, Access = WorkspaceAccess.Write, PinnedSha = "0ld000000000" } }; + + var entry = Core.Services.Agents.Workspace.AgentWorkspaceAuthoring.SerializeRelatedRepositories(related, pinnedShas: new Dictionary { [repoId] = "fresh1234567" })!.Single(); + + entry["pinnedSha"].ShouldBe("fresh1234567", "the launch vector is resolved NOW — a spec-carried pin authored earlier must not shadow it"); + } + + [Fact] + public void SerializeRelatedRepositories_falls_back_to_the_spec_carried_pin_when_the_vector_lacks_the_repo() + { + var related = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = Guid.NewGuid(), Access = WorkspaceAccess.Write, PinnedSha = "abc123def456" } }; + + var entry = Core.Services.Agents.Workspace.AgentWorkspaceAuthoring.SerializeRelatedRepositories(related, pinnedShas: new Dictionary())!.Single(); + + entry["pinnedSha"].ShouldBe("abc123def456", "the spec's own round-tripped pin survives when the vector has no entry for the repo"); + } + [Fact] public void FromAuthoredRepos_returns_null_with_no_related_repos_so_single_repo_stays_byte_identical() { diff --git a/backend/tests/CodeSpace.UnitTests/Tasks/LaunchBasePinResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Tasks/LaunchBasePinResolverTests.cs new file mode 100644 index 000000000..38612e0f5 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Tasks/LaunchBasePinResolverTests.cs @@ -0,0 +1,91 @@ +using CodeSpace.Core.Services.Tasks.Launch; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Tasks; +using Shouldly; + +namespace CodeSpace.UnitTests.Tasks; + +/// +/// — the pure eligibility policy of S1's launch base vector: +/// WHICH repos get pinned and at WHICH hard ref. The DB + transport legs are covered by the launch integration flow. +/// +[Trait("Category", "Unit")] +public sealed class LaunchBasePinResolverTests +{ + private static readonly Guid Primary = Guid.NewGuid(); + private static readonly Guid Api = Guid.NewGuid(); + + private static readonly IReadOnlyDictionary NoSessionRefs = new Dictionary(); + + private static TaskLaunchSeed Seed(string? baseBranch = null) => + new() { Goal = "g", SurfaceKind = "chat", TeamId = Guid.NewGuid(), BaseBranch = baseBranch }; + + [Fact] + public void The_primary_pins_at_the_operators_BaseBranch_and_related_repos_at_their_authored_refs() + { + var profile = new ResolvedAgentProfile + { + RepositoryId = Primary, + RelatedRepositories = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = Api, Access = WorkspaceAccess.Write, Ref = "release/2.x" } }, + }; + + var scope = LaunchBasePinResolver.CollectScope(Seed(baseBranch: "main"), profile, NoSessionRefs); + + scope[Primary].ShouldBe("main", "the primary's hard ref is the operator's launch pin"); + scope[Api].ShouldBe("release/2.x", "a related repo's hard ref is its authored ref"); + } + + [Fact] + public void A_blank_BaseBranch_and_a_refless_related_repo_pin_at_the_default_branch() + { + var profile = new ResolvedAgentProfile + { + RepositoryId = Primary, + RelatedRepositories = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = Api, Access = WorkspaceAccess.Read } }, + }; + + var scope = LaunchBasePinResolver.CollectScope(Seed(baseBranch: " "), profile, NoSessionRefs); + + scope[Primary].ShouldBeNull("null ref = the default branch's tip (still pinned — the vector's whole point is a fresh launch's immutable base)"); + scope[Api].ShouldBeNull(); + } + + [Fact] + public void A_repo_riding_a_session_soft_ref_is_excluded_from_the_vector() + { + // A soft ref's contract is "the prior branch, or the default if pruned" — a disjunction one commit cannot + // express, so the continuing repo stays unpinned (legacy) rather than turning the soft fallback into a + // hard checkout failure after a squash-merge prunes the branch. + var profile = new ResolvedAgentProfile + { + RepositoryId = Primary, + RelatedRepositories = new[] { new WorkspaceRepositorySpec { Alias = "api", RepositoryId = Api, Access = WorkspaceAccess.Write } }, + }; + + var scope = LaunchBasePinResolver.CollectScope(Seed(), profile, new Dictionary { [Primary] = "run-1/primary" }); + + scope.ContainsKey(Primary).ShouldBeFalse("the primary continues on a session branch — unpinned by design"); + scope.ContainsKey(Api).ShouldBeTrue("the related repo has no session ref — it still pins"); + } + + [Fact] + public void A_related_repo_duplicating_the_primary_is_collected_once() + { + var profile = new ResolvedAgentProfile + { + RepositoryId = Primary, + RelatedRepositories = new[] { new WorkspaceRepositorySpec { Alias = "self", RepositoryId = Primary, Access = WorkspaceAccess.Write, Ref = "other" } }, + }; + + var scope = LaunchBasePinResolver.CollectScope(Seed(baseBranch: "main"), profile, NoSessionRefs); + + scope.Count.ShouldBe(1); + scope[Primary].ShouldBe("main", "the primary's own ref wins — the workspace projection drops the duplicate related entry too"); + } + + [Fact] + public void No_repos_yields_an_empty_scope() + { + LaunchBasePinResolver.CollectScope(Seed(), new ResolvedAgentProfile(), NoSessionRefs).ShouldBeEmpty("an analysis-only launch has nothing to pin"); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentNodeMappingTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentNodeMappingTests.cs index f220c07d2..0ff8afae5 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentNodeMappingTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentNodeMappingTests.cs @@ -221,4 +221,41 @@ public void BuildAgentInputs_ignores_the_launch_BaseBranch_when_no_primary_repo_ inputs.TryGetProperty("baseRef", out _).ShouldBeFalse("an analysis-only run has nothing to clone — the pin is meaningless without a repo"); } + + // ── S1: the launch's immutable base vector — PinnedShas threads onto the primary's pinnedSha + each related + // entry's pinnedSha, so every participant of the run materializes the SAME base commit. ── + + [Fact] + public void BuildAgentInputs_threads_per_repo_pins_onto_pinnedSha_and_related_pinnedSha() + { + var inputs = AgentNodeMapping.BuildAgentInputs(Context(baseRefs: null) with + { + PinnedShas = new Dictionary { [Primary] = "aaa111aaa111", [Api] = "bbb222bbb222" }, + }); + + inputs.GetProperty("pinnedSha").GetString().ShouldBe("aaa111aaa111", "the primary's pin comes from the vector keyed by its repo id"); + inputs.GetProperty("relatedRepositories")[0].GetProperty("pinnedSha").GetString().ShouldBe("bbb222bbb222", "each related repo's pin comes from the vector keyed by ITS repo id — no bleed"); + } + + [Fact] + public void BuildAgentInputs_omits_pins_for_repos_absent_from_the_vector() + { + // The primary was pinned; the related repo was UNPINNABLE (no clone URL / session-soft) → only the primary carries a pin. + var inputs = AgentNodeMapping.BuildAgentInputs(Context(baseRefs: null) with + { + PinnedShas = new Dictionary { [Primary] = "aaa111aaa111" }, + }); + + inputs.GetProperty("pinnedSha").GetString().ShouldBe("aaa111aaa111"); + inputs.GetProperty("relatedRepositories")[0].TryGetProperty("pinnedSha", out _).ShouldBeFalse("a repo absent from the vector is unpinned ⇒ it clones at its ref's tip (legacy)"); + } + + [Fact] + public void BuildAgentInputs_with_no_pin_vector_omits_pinnedSha_byte_identical() + { + var inputs = AgentNodeMapping.BuildAgentInputs(Context(baseRefs: null)); + + inputs.TryGetProperty("pinnedSha", out _).ShouldBeFalse("no vector ⇒ no pinnedSha key (tip-of-ref, byte-identical to before S1)"); + inputs.GetProperty("relatedRepositories")[0].TryGetProperty("pinnedSha", out _).ShouldBeFalse("no vector ⇒ no per-repo pin key"); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/CriticPlannerDecoratorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/CriticPlannerDecoratorTests.cs index 161214f6b..38248cdb1 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/CriticPlannerDecoratorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/CriticPlannerDecoratorTests.cs @@ -123,6 +123,7 @@ public async Task The_grounded_opt_in_reviews_via_the_real_agent_and_skips_the_m sent.ReviewerModelId.ShouldBe(request.ReviewerModelId, "the operator's reviewer model pin drives the reviewer agent"); sent.Goal.ShouldBe("do x", "the task text is the reviewer's yardstick"); sent.PlanArtifact.ShouldContain("- t: i", customMessage: "the rendered plan is the artifact under review"); + sent.PinnedSha.ShouldBe(request.PinnedSha, "S1: the launch's immutable base pin reaches the reviewer — it must judge the plan against the SAME tree the executing agents materialize"); result.Risks.ShouldContain(r => r.Contains("no *Tests.csproj exists"), "the agent's EVIDENCE surfaces on the annotated risks"); result.Risks.ShouldContain(r => r.Contains("flagged concerns"), "the agent's Gate-shaped verdict drives the same annotation a model verdict would"); @@ -216,6 +217,7 @@ public void The_effective_critique_prefers_the_improve_critique_and_composes_onl WorkflowRunId = Guid.NewGuid(), NodeId = "plan-1", ReviewerModelId = Guid.NewGuid(), + PinnedSha = "abc123def456", // S1: the launch's immutable base pin rides the plan request into the reviewer }; private sealed class FakePlanner : IWorkflowPlanner diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs index 068cb588d..175f3ef1f 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs @@ -23,7 +23,7 @@ public void Manifest_pins_the_node_contract() node.TypeKey.ShouldBe("plan.author"); node.Manifest.IsSideEffecting.ShouldBeTrue("one structured LLM call per execution — billing, like llm.complete"); - ConfigKeys(node).ShouldBe(new[] { "plannerModelId", "reviewMode", "reviewerModelId", "flatPlan", "reviewerAgent", "repositoryId" }, ignoreOrder: true); + ConfigKeys(node).ShouldBe(new[] { "plannerModelId", "reviewMode", "reviewerModelId", "flatPlan", "reviewerAgent", "repositoryId", "pinnedSha" }, ignoreOrder: true); InputKeys(node).ShouldBe(new[] { "goal", "grounding", "feedback", "criteria" }, ignoreOrder: true); OutputKeys(node).ShouldBe(new[] { "planId", "version", "goal", "items", "executionNeeded", "json" }, ignoreOrder: true); @@ -89,6 +89,16 @@ public void Model_pins_and_review_mode_map_into_the_planner_request() request.ReviewerModelId.ShouldBe(reviewerRow); } + [Fact] + public void The_launch_base_pin_maps_into_the_planner_request_and_a_blank_pin_is_omitted() + { + // S1: the projection's pinnedSha config reaches the plan request, so the grounded reviewer clones the SAME + // commit the executing agents materialize; blank/absent folds to null (byte-identical legacy). + PlanAuthorNode.BuildPlanRequest(Config("""{"pinnedSha":"abc123def456"}"""), Guid.NewGuid(), "goal", "", "").PinnedSha.ShouldBe("abc123def456"); + PlanAuthorNode.BuildPlanRequest(Config("""{"pinnedSha":" "}"""), Guid.NewGuid(), "goal", "", "").PinnedSha.ShouldBeNull(); + PlanAuthorNode.BuildPlanRequest(Config("""{}"""), Guid.NewGuid(), "goal", "", "").PinnedSha.ShouldBeNull(); + } + [Theory] [InlineData("""{"reviewMode":99}""")] // out-of-range int → off, never a throw [InlineData("""{"reviewMode":"Gate"}""")] // wrong JSON type → off (nodes read defensively) diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs new file mode 100644 index 000000000..9d4e0f22b --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs @@ -0,0 +1,183 @@ +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Messages.Agents; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// against a REAL local git repo (the +/// pattern) — the resolver reads tips over the same git transport the clone uses, so the tests prove the pin can +/// never skew from what the clone would fetch. Skips where git isn't installed (cross-host dotnet test stays clean). +/// +[Trait("Category", "Unit")] +public sealed class RemoteTipResolverTests +{ + [Fact] + public async Task Resolves_a_named_branch_to_its_tip_commit() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "main" }, CancellationToken.None); + + sha.ShouldBe(expected); + } + + [Fact] + public async Task Resolves_HEAD_when_no_ref_is_named() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, CancellationToken.None); + + sha.ShouldBe(expected, "no ref ⇒ the remote's HEAD — the same commit a bare `git clone` would materialize"); + } + + [Fact] + public async Task A_missing_HARD_ref_fails_loud_naming_the_ref() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + + var ex = await Should.ThrowAsync(() => NewResolver().ResolveTipShaAsync( + new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "release/9.x" }, CancellationToken.None)); + + ex.Message.ShouldContain("release/9.x", customMessage: "a HARD ref that is gone fails the launch loud — the clone would fail identically later, never a silent unpinned launch"); + } + + [Fact] + public async Task A_missing_SOFT_ref_falls_back_to_the_default_ref_tip() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); + + // DefaultRef set = the request's own SOFT semantics (a pruned session branch degrades to the default) — + // the resolver mirrors the clone's ResolveCheckoutRefAsync so pin and clone can never diverge. + var sha = await NewResolver().ResolveTipShaAsync( + new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "run-1/pruned", DefaultRef = "main" }, CancellationToken.None); + + sha.ShouldBe(expected); + } + + [Fact] + public async Task A_tag_resolves_to_its_commit() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); + await RunGitAsync(origin.Path, "tag", "-a", "v1.0", "-m", "release"); // annotated: the tag OBJECT sha ≠ the commit sha + await WriteAndCommitAsync(origin.Path, "file.txt", "v2"); // the tip moves past the tag + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "v1.0" }, CancellationToken.None); + + sha.ShouldBe(expected, "an annotated tag pin must be the PEELED commit — the tag object itself is not a tree the workspace can materialize"); + } + + [Fact] + public async Task An_empty_remote_returns_null_nothing_exists_to_pin() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await RunGitAsync(origin.Path, "init", "--bare", "-b", "main"); + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, CancellationToken.None); + + sha.ShouldBeNull("an empty remote has no commit to pin — the launch proceeds unpinned rather than failing a brand-new repo"); + } + + [Fact] + public async Task An_unreachable_remote_fails_loud() + { + if (!await GitAvailableAsync()) return; + + var dead = Path.Combine(Path.GetTempPath(), "cs-no-such-remote-" + Guid.NewGuid().ToString("N")); + + await Should.ThrowAsync(() => NewResolver().ResolveTipShaAsync( + new WorkspaceRequest { RepositoryUrl = new Uri(dead).AbsoluteUri, Ref = "main" }, CancellationToken.None)); + } + + // ─── harness (the LocalGitWorkspaceProviderTests pattern) ─────────────────────── + + private static RemoteTipResolver NewResolver() => + new(new SandboxRunnerRegistry(new ISandboxRunner[] { new LocalProcessRunner() })); + + private static string AsFileUrl(string path) => new Uri(path).AbsoluteUri; + + private static async Task GitAvailableAsync() + { + try + { + var result = await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = new[] { "--version" }, TimeoutSeconds = 10 }, CancellationToken.None); + return result.Status == SandboxStatus.Success; + } + catch + { + return false; + } + } + + private static async Task SeedOriginAsync(string dir, string file, string content) + { + await RunGitAsync(dir, "init", "-b", "main"); + await RunGitAsync(dir, "config", "user.email", "test@codespace.dev"); + await RunGitAsync(dir, "config", "user.name", "Test"); + await RunGitAsync(dir, "config", "commit.gpgsign", "false"); + await WriteAndCommitAsync(dir, file, content); + } + + private static async Task WriteAndCommitAsync(string dir, string file, string content) + { + await File.WriteAllTextAsync(Path.Combine(dir, file), content); + await RunGitAsync(dir, "add", "."); + await RunGitAsync(dir, "commit", "-m", "seed"); + } + + private static async Task RunGitAsync(string workdir, params string[] args) + { + var result = await new LocalProcessRunner().RunAsync( + new SandboxSpec { Command = "git", Args = args, WorkingDirectory = workdir, TimeoutSeconds = 60 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + } + + private static async Task GitStdoutAsync(string workdir, params string[] args) + { + var result = await new LocalProcessRunner().RunAsync( + new SandboxSpec { Command = "git", Args = args, WorkingDirectory = workdir, TimeoutSeconds = 60 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + + return result.Stdout.Trim(); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cs-tip-origin-" + Guid.NewGuid().ToString("N")); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} From ebb32059ce094055382cf214119195a7070d7f67 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sat, 11 Jul 2026 09:22:39 +0800 Subject: [PATCH 2/2] Harden the launch base vector against the adversarial-scan findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git ships in the API image (the one sanctioned synchronous use: read-only ls-remote at launch); a missing binary now names the topology bug - WorkspaceException maps to 422 with the operator-actionable message - an IMPLICIT default branch the remote lacks (empty/new repo, stale record) launches unpinned; only authored refs fail loud - ls-remote lines matched by EXACT full ref name (patterns are tail-glob); 15s timeout on the synchronous path; sha256 OIDs accepted; stored-URL userinfo stripped from error messages - pinned clones stay SHALLOW when the pin is the fetched tip, deepening only when the tip advanced (fetch-by-sha, then unshallow) — the common launch pays nothing over the pre-S1 clone - the vector resolves only for projections that consume it (single-agent + plan-map); the supervisor lane is threaded in the follow-up --- backend/Dockerfile.api | 20 +++-- .../Filters/GlobalExceptionFilter.cs | 10 +++ .../Agents/Workspace/IRemoteTipResolver.cs | 11 ++- .../Providers/LocalGitWorkspaceProvider.cs | 38 ++++++-- .../Agents/Workspace/RemoteTipResolver.cs | 63 ++++++++++---- .../Workspace/RepositoryWorkspaceResolver.cs | 6 +- .../Tasks/Launch/LaunchBasePinResolver.cs | 5 +- .../Services/Tasks/TaskLaunchService.cs | 6 +- .../Agents/WorkspaceRequest.cs | 4 +- .../Agents/WorkspaceSpec.cs | 12 +-- .../Workflows/LaunchBasePinFlowTests.cs | 35 ++++++++ .../Agents/WorkspaceSpecTests.cs | 1 + .../LocalGitWorkspaceProviderTests.cs | 17 ++++ .../PlanMapSynthDefinitionBuilderTests.cs | 20 +++++ .../Workflows/RemoteTipResolverTests.cs | 87 +++++++++++++++++-- 15 files changed, 282 insertions(+), 53 deletions(-) diff --git a/backend/Dockerfile.api b/backend/Dockerfile.api index 939d7327c..c1c3ccd52 100644 --- a/backend/Dockerfile.api +++ b/backend/Dockerfile.api @@ -6,11 +6,14 @@ # EXCLUSIVELY in the worker image (backend/Dockerfile.worker — the local sandbox runner). Deploy the two images as # SEPARATE deployments so each scales (replica) independently: the API on HTTP load, the worker on job throughput. # -# Because this image is INTERNET-FACING it is kept minimal — NO git, NO bubblewrap/prlimit, NO harness CLIs (those -# are agent-execution + isolation deps that belong only on the worker). Verified 2026-06: every git / workspace-clone -# path lives under Services/Agents/* (the executor, workspace providers, integrators, acceptance grader), all of -# which run inside Hangfire jobs on the worker — no synchronous controller/handler clones a repo. If a synchronous -# API path ever needs git, reconsider this leanness rather than silently regressing the attack surface. +# Because this image is INTERNET-FACING it is kept minimal — NO bubblewrap/prlimit, NO harness CLIs (those are +# agent-execution + isolation deps that belong only on the worker). Every workspace CLONE path lives under +# Services/Agents/* and runs inside Hangfire jobs on the worker — no synchronous controller/handler clones a repo. +# +# git IS installed here (the one sanctioned exception, S1): TaskLaunchService resolves the launch's immutable base +# vector synchronously via `git ls-remote` (RemoteTipResolver) — a read-only, no-checkout ref listing over the same +# HTTPS transport + credential the API already uses for provider REST calls. No clone, no working tree, no +# bubblewrap. If any OTHER synchronous git use appears, reconsider rather than silently regressing the attack surface. # # docker build -f backend/Dockerfile.api -t codespace-api backend/ # build context = backend/ # @@ -27,6 +30,13 @@ RUN dotnet restore CodeSpace.sln RUN dotnet publish src/CodeSpace.Api/CodeSpace.Api.csproj -c Release -o /app --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime + +# git → launch-time immutable-base resolution ONLY (`git ls-remote`, read-only — see the header note; no clones here) +# ca-certificates → outbound TLS for ls-remote against git remotes (same bundle discipline as the worker image) +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY --from=build /app ./ diff --git a/backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs b/backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs index ccdc6555d..d4a01001f 100644 --- a/backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs +++ b/backend/src/CodeSpace.Api/Filters/GlobalExceptionFilter.cs @@ -1,5 +1,6 @@ using CodeSpace.Core.Authorization; using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Workspace; using CodeSpace.Core.Services.OAuth; using CodeSpace.Core.Services.Providers.Resilience; using CodeSpace.Core.Services.Workflows; @@ -136,6 +137,15 @@ public void OnException(ExceptionContext context) context.Result = BuildProblemResult(StatusCodes.Status404NotFound, "not_found", "The requested resource was not found."); break; + case WorkspaceException workspace: + // 422 — the workspace/launch cannot be provisioned as asked (S1: a launch-pinned base ref that + // doesn't exist, an unreachable remote at base resolution). The message is deliberately operator- + // actionable (it names the ref/remote); masking it as a 500 would defeat the whole point of + // failing at launch instead of inside the run. + _logger.LogWarning("Workspace resolution rejected at {Path}: {Message}", path, workspace.Message); + context.Result = BuildProblemResult(StatusCodes.Status422UnprocessableEntity, "workspace_unresolvable", workspace.Message); + break; + case WorkflowValidationException workflowValidation: // 422 Unprocessable Entity is the right code for "JSON parsed, body shape // accepted, domain rules say no". The workflow editor inspector renders one diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs index 9a67d6096..e9577fe16 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/IRemoteTipResolver.cs @@ -13,9 +13,12 @@ public interface IRemoteTipResolver /// /// The tip commit sha of 's effective ref: when set /// (falling back to under the request's own SOFT semantics when the ref - /// is gone), else the remote's HEAD. Null ONLY for an empty remote (no commits — nothing exists to pin); a - /// missing HARD ref or an unreachable remote throws LOUD — the clone would fail - /// the same way later, and the pin's contract is early, honest failure over a silently unpinned launch. + /// is gone), else the remote's HEAD. An unreachable remote always throws LOUD — + /// the clone would fail the same way later, and the pin's contract is early, honest failure. A ref the remote + /// does not have: throws when (an operator/authored pin — its absence is an + /// authoring error), returns null when not (an IMPLICIT recorded default branch — an empty just-created repo or + /// a stale default-branch record launches UNPINNED, the pre-S1 behaviour, instead of failing an opportunistic + /// pin). Null also for an empty remote's HEAD (nothing exists to pin). /// - Task ResolveTipShaAsync(WorkspaceRequest request, CancellationToken cancellationToken); + Task ResolveTipShaAsync(WorkspaceRequest request, bool refRequired, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs index f9441b038..594c898be 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs @@ -297,9 +297,7 @@ private async Task CloneAsync(WorkspaceRequest request, string directory, Cancel var args = new List { "clone" }; - // S1: a pinned base forces a FULL clone — a shallow tip may not contain the pin, and the pin's whole point - // is materializing an EXACT historical commit. - if (request.Depth > 0 && string.IsNullOrWhiteSpace(request.PinnedSha)) { args.Add("--depth"); args.Add(request.Depth.ToString()); } + if (request.Depth > 0) { args.Add("--depth"); args.Add(request.Depth.ToString()); } if (!string.IsNullOrWhiteSpace(checkoutRef)) { args.Add("--branch"); args.Add(checkoutRef); } args.Add(url); @@ -310,18 +308,40 @@ private async Task CloneAsync(WorkspaceRequest request, string directory, Cancel if (result.Status != SandboxStatus.Success) throw new WorkspaceException($"git clone failed (exit {result.ExitCode}): {Redact(Summarize(result.Stderr), request.Token)}"); - // S1: hard-checkout the pinned base. The clone above kept Ref's branch context (the push path re-branches - // via `checkout -B` anyway, so a detached start is fine); the TREE the agent sees is exactly the pin. A - // missing/unreachable pin fails LOUD — the pin is a freshness guarantee, never a suggestion. if (!string.IsNullOrWhiteSpace(request.PinnedSha)) + await MaterializePinAsync(request, directory, cancellationToken).ConfigureAwait(false); + } + + /// + /// S1: materialize the pinned base EXACTLY, cheapest rung first. (1) The pin is usually the branch tip the + /// shallow clone just fetched — a local object check + detached checkout keeps the clone SHALLOW, so the common + /// launch pays nothing over the pre-S1 clone. (2) A tip that advanced since launch: fetch the pin BY SHA + /// (best-effort — servers without allow-*-sha1-in-want refuse it). (3) Unshallow the cloned branch's history — + /// the pin is an ancestor of the launch-time tip unless the branch was rewritten. Still absent after every rung + /// ⇒ the checkout fails LOUD: the pin is a freshness guarantee, never a suggestion (a force-push that orphaned + /// the pin must surface, never a silent tip fallback). + /// + private async Task MaterializePinAsync(WorkspaceRequest request, string directory, CancellationToken cancellationToken) + { + var pin = request.PinnedSha!; + + if (!await CommitExistsLocallyAsync(directory, pin, cancellationToken).ConfigureAwait(false)) { - var checkout = await RunGitAsync(new[] { "-C", directory, "checkout", "--detach", request.PinnedSha! }, cancellationToken).ConfigureAwait(false); + await RunGitAsync(new[] { "-C", directory, "fetch", "origin", pin }, cancellationToken).ConfigureAwait(false); // best-effort; the checkout below is the arbiter - if (checkout.Status != SandboxStatus.Success) - throw new WorkspaceException($"the pinned base commit '{request.PinnedSha}' could not be checked out (exit {checkout.ExitCode}): {Redact(Summarize(checkout.Stderr), request.Token)} — the pin guarantees every participant sees the SAME immutable base; a stale or unpushed pin must fail the provision, never silently fall back to the tip"); + if (!await CommitExistsLocallyAsync(directory, pin, cancellationToken).ConfigureAwait(false) && request.Depth > 0) + await RunGitAsync(new[] { "-C", directory, "fetch", "--unshallow", "origin" }, cancellationToken).ConfigureAwait(false); } + + var checkout = await RunGitAsync(new[] { "-C", directory, "checkout", "--detach", pin }, cancellationToken).ConfigureAwait(false); + + if (checkout.Status != SandboxStatus.Success) + throw new WorkspaceException($"the pinned base commit '{pin}' could not be checked out (exit {checkout.ExitCode}): {Redact(Summarize(checkout.Stderr), request.Token)} — the pin guarantees every participant sees the SAME immutable base; a stale or unpushed pin must fail the provision, never silently fall back to the tip"); } + private async Task CommitExistsLocallyAsync(string directory, string sha, CancellationToken cancellationToken) => + (await RunGitAsync(new[] { "-C", directory, "rev-parse", "--verify", "--quiet", $"{sha}^{{commit}}" }, cancellationToken).ConfigureAwait(false)).Status == SandboxStatus.Success; + /// /// The ref to actually check out. A SOFT ref (a session-inherited prior branch — /// carries the fallback) is pre-flighted against the remote: if it was pruned (a merged PR auto-deletes it) we clone diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs index 29f6250c2..0946aa3c0 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RemoteTipResolver.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using CodeSpace.Core.DependencyInjection; using CodeSpace.Core.Services.Agents.Sandbox; using CodeSpace.Core.Services.Agents.Sandbox.Runners; @@ -11,16 +12,19 @@ namespace CodeSpace.Core.Services.Agents.Workspace; /// exactly like 's own git calls (same auth-URL embedding, same /// token redaction on surfaced errors, same process/timeout handling). Branch first, tag second (preferring the /// peeled ^{} commit over the annotated tag object — the pin is a COMMIT), HEAD when no ref is named. +/// Returned lines are matched by EXACT full ref name (ls-remote patterns are tail-matched globs — a pattern hit is +/// necessary but not sufficient), so a glob-shaped or shadowing ref can never pin the wrong commit. /// public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency { - private const int LsRemoteTimeoutSeconds = 60; + /// Deliberately short: this runs on the synchronous launch path (inside the request's transaction) — a slow remote must fail the launch fast, not hold the connection for a minute. + private const int LsRemoteTimeoutSeconds = 15; private readonly ISandboxRunnerRegistry _runners; public RemoteTipResolver(ISandboxRunnerRegistry runners) { _runners = runners; } - public async Task ResolveTipShaAsync(WorkspaceRequest request, CancellationToken cancellationToken) + public async Task ResolveTipShaAsync(WorkspaceRequest request, bool refRequired, CancellationToken cancellationToken) { var url = LocalGitWorkspaceProvider.BuildAuthenticatedUrl(request.RepositoryUrl, request.TokenUsername, request.Token); @@ -32,10 +36,20 @@ public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency // fall to the default branch, mirroring the clone's ResolveCheckoutRefAsync. A HARD ref (DefaultRef null) // that is gone fails LOUD — the clone would fail identically later; the pin just surfaces it at launch. if (!string.IsNullOrWhiteSpace(request.DefaultRef) && !string.Equals(request.Ref, request.DefaultRef, StringComparison.Ordinal)) - return await ResolveRefAsync(url, request.DefaultRef!, request, cancellationToken).ConfigureAwait(false) - ?? throw MissingRef(request.DefaultRef!, request); + { + if (await ResolveRefAsync(url, request.DefaultRef!, request, cancellationToken).ConfigureAwait(false) is { } fallback) return fallback; - throw MissingRef(request.Ref!, request); + if (refRequired) throw MissingRef(request.DefaultRef!, request); + + return null; + } + + if (refRequired) throw MissingRef(request.Ref!, request); + + // The caller's ref was IMPLICIT (a recorded default branch, not an operator/authored pin) — a remote that + // doesn't have it (an empty just-created repo, a stale default-branch record) launches UNPINNED, exactly + // the pre-S1 behaviour, instead of turning an opportunistic pin into a launch failure. + return null; } /// The remote's HEAD commit — null for an EMPTY remote (ls-remote succeeds with no output: nothing exists to pin). @@ -46,41 +60,60 @@ public sealed class RemoteTipResolver : IRemoteTipResolver, ISingletonDependency return lines.Where(l => l.Ref == "HEAD").Select(l => l.Sha).FirstOrDefault(); } - /// The tip commit of a NAMED ref: its branch, else its tag (peeled ^{{}} commit preferred over the annotated tag object). Null when the remote has no such ref. + /// The tip commit of a NAMED ref: its branch, else its tag (peeled ^{{}} commit preferred over the annotated tag object). Null when the remote has no such ref. Lines are matched by EXACT full ref name, never by the pattern's tail-glob. private async Task ResolveRefAsync(string url, string @ref, WorkspaceRequest request, CancellationToken cancellationToken) { var branch = await LsRemoteAsync(url, new[] { $"refs/heads/{@ref}" }, request, cancellationToken).ConfigureAwait(false); - if (branch.Count > 0) return branch[0].Sha; + if (branch.FirstOrDefault(l => l.Ref == $"refs/heads/{@ref}") is { Sha.Length: > 0 } hit) return hit.Sha; var tags = await LsRemoteAsync(url, new[] { $"refs/tags/{@ref}", $"refs/tags/{@ref}^{{}}" }, request, cancellationToken).ConfigureAwait(false); - return tags.Where(t => t.Ref.EndsWith("^{}", StringComparison.Ordinal)).Select(t => t.Sha).FirstOrDefault() - ?? tags.Select(t => t.Sha).FirstOrDefault(); + return tags.Where(t => t.Ref == $"refs/tags/{@ref}^{{}}").Select(t => t.Sha).FirstOrDefault() + ?? tags.Where(t => t.Ref == $"refs/tags/{@ref}").Select(t => t.Sha).FirstOrDefault(); } - /// One git ls-remote round-trip parsed to (sha, ref) lines. A non-zero exit throws LOUD with the token redacted — an unreachable remote at launch is the SAME failure the clone would surface later, just earlier and honest. + /// One git ls-remote round-trip parsed to (sha, ref) lines. A non-zero exit throws LOUD with the token redacted and the URL stripped of any userinfo — an unreachable remote at launch is the SAME failure the clone would surface later, just earlier and honest. private async Task> LsRemoteAsync(string url, IReadOnlyList patterns, WorkspaceRequest request, CancellationToken cancellationToken) { var args = new List { "ls-remote", url }; args.AddRange(patterns); - var result = await _runners.Resolve(LocalProcessRunner.LocalKind) - .RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = LsRemoteTimeoutSeconds }, cancellationToken).ConfigureAwait(false); + SandboxResult result; + try + { + result = await _runners.Resolve(LocalProcessRunner.LocalKind) + .RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = LsRemoteTimeoutSeconds }, cancellationToken).ConfigureAwait(false); + } + catch (Win32Exception ex) + { + // The git binary itself is absent — a deployment-topology bug, not a remote fault. Name it directly: + // this resolver is the ONE sanctioned synchronous git use, and the API image must carry git for it. + throw new WorkspaceException($"git is not available on this host — launch-time base resolution runs `git ls-remote` on the API pod, so its image must include git (see Dockerfile.api): {ex.Message}"); + } if (result.Status != SandboxStatus.Success) - throw new WorkspaceException($"git ls-remote failed (exit {result.ExitCode}) resolving the launch base for {request.RepositoryUrl}: {LocalGitWorkspaceProvider.Redact(Truncate(result.Stderr), request.Token)} — the launch base pin requires a reachable remote; the clone would fail the same way later"); + throw new WorkspaceException($"git ls-remote failed (exit {result.ExitCode}) resolving the launch base for {SanitizeUrl(request.RepositoryUrl)}: {LocalGitWorkspaceProvider.Redact(Truncate(result.Stderr), request.Token)} — the launch base pin requires a reachable remote; the clone would fail the same way later"); + // sha1 (40) and sha256 (64) object formats both count — a sha256 remote must not read as "ref missing". return result.Stdout .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(line => line.Split('\t')) - .Where(parts => parts.Length == 2 && parts[0].Length == 40) + .Where(parts => parts.Length == 2 && parts[0].Length is 40 or 64) .Select(parts => (parts[0], parts[1])) .ToList(); } private static WorkspaceException MissingRef(string @ref, WorkspaceRequest request) => - new($"the launch base ref '{@ref}' does not exist on {request.RepositoryUrl} — the launch base pin is resolved from the ref the run would clone, so a missing ref fails the launch loud instead of silently launching unpinned"); + new($"the launch base ref '{@ref}' does not exist on {SanitizeUrl(request.RepositoryUrl)} — the launch base pin is resolved from the ref the run would clone, so a missing ref fails the launch loud instead of silently launching unpinned"); + + /// Strip any userinfo from a URL before it enters an exception message — a stored clone URL may itself carry credentials the token-based knows nothing about. + internal static string SanitizeUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || string.IsNullOrEmpty(parsed.UserInfo)) return url; + + return new UriBuilder(parsed) { UserName = "", Password = "" }.Uri.AbsoluteUri; + } private static string Truncate(string text) => text.Length <= 500 ? text : text[..500]; } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RepositoryWorkspaceResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RepositoryWorkspaceResolver.cs index 0e9e2f658..eb44fd6a1 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/RepositoryWorkspaceResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/RepositoryWorkspaceResolver.cs @@ -94,15 +94,15 @@ public RepositoryWorkspaceResolver(CodeSpaceDbContext db, IProviderAuthResolver }; } - /// Null for blank; a trimmed 4-40 lowercase-hex commit id otherwise — anything else fails LOUD (the pin's contract is an EXACT commit; a malformed pin is a caller bug, and rejecting it here also keeps flag-shaped garbage out of the git argv). + /// Null for blank; a trimmed 4-64 lowercase-hex commit id otherwise (64 = a sha256-object-format repo) — anything else fails LOUD (the pin's contract is an EXACT commit; a malformed pin is a caller bug, and rejecting it here also keeps flag-shaped garbage out of the git argv). internal static string? ValidatePinnedSha(string? pinnedSha) { if (string.IsNullOrWhiteSpace(pinnedSha)) return null; var trimmed = pinnedSha.Trim().ToLowerInvariant(); - if (trimmed.Length is < 4 or > 40 || !trimmed.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f')) - throw new WorkspaceException($"the pinned base commit '{pinnedSha.Trim()}' is not a valid git commit id (4-40 hex chars) — the pin's contract is an EXACT commit, so a malformed pin fails the provision loud"); + if (trimmed.Length is < 4 or > 64 || !trimmed.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f')) + throw new WorkspaceException($"the pinned base commit '{pinnedSha.Trim()}' is not a valid git commit id (4-64 hex chars) — the pin's contract is an EXACT commit, so a malformed pin fails the provision loud"); return trimmed; } diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs index 69e989a60..8e8ecee89 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchBasePinResolver.cs @@ -44,7 +44,10 @@ public LaunchBasePinResolver(CodeSpaceDbContext db, IAgentWorkspaceResolver work if (request is null) continue; - if (await _tips.ResolveTipShaAsync(request, cancellationToken).ConfigureAwait(false) is { } sha) vector[repositoryId] = sha; + // refRequired only for an AUTHORED ref (the operator's BaseBranch / a related spec's Ref) — its absence + // is an authoring error worth failing the launch for. An implicit recorded default branch that the + // remote doesn't have (an empty just-created repo, a stale record) launches unpinned instead. + if (await _tips.ResolveTipShaAsync(request, refRequired: hardRef is not null, cancellationToken).ConfigureAwait(false) is { } sha) vector[repositoryId] = sha; } return vector.Count > 0 ? vector : null; diff --git a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs index 2b7cb636c..067dc059d 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs @@ -91,7 +91,11 @@ public async Task LaunchAsync(TaskLaunchRequest request, Cance // S1: the launch's immutable base vector — each repo's tip commit resolved ONCE, over the same git transport // the clones use, so the planner, the grounded plan reviewer, and every dispatched agent materialize the SAME // base even when the remote advances mid-run. Session-soft + URL-less repos stay unpinned (resolver contract). - var pinnedShas = await _basePins.ResolveVectorAsync(request.TeamId, seed, profile, baseRefs, cancellationToken).ConfigureAwait(false); + // Resolved ONLY for the projections that consume it (single-agent + plan-map) — the supervisor lane neither + // reads pins nor Seed.BaseBranch yet (PR③ threads it), so it must not pay for, or fail on, an unused vector. + var pinnedShas = route.ProjectionKind is TaskProjectionKinds.SingleAgent or TaskProjectionKinds.PlanMapSynth or TaskProjectionKinds.PlanMapDynamic + ? await _basePins.ResolveVectorAsync(request.TeamId, seed, profile, baseRefs, cancellationToken).ConfigureAwait(false) + : null; // Deep/Auto: the supervisor's brain model — the operator's pinned "Brain model" chip when set + usable, else // self-resolved so the decider has one instead of stopping turn-1. Inert (null) for every non-supervisor diff --git a/backend/src/CodeSpace.Messages/Agents/WorkspaceRequest.cs b/backend/src/CodeSpace.Messages/Agents/WorkspaceRequest.cs index 53451a657..1dcd56229 100644 --- a/backend/src/CodeSpace.Messages/Agents/WorkspaceRequest.cs +++ b/backend/src/CodeSpace.Messages/Agents/WorkspaceRequest.cs @@ -29,9 +29,9 @@ public sealed record WorkspaceRequest /// Basic-auth username paired with — provider-specific ("x-access-token", "oauth2"). Ignored when is null; defaults to "x-access-token". public string? TokenUsername { get; init; } - /// Shallow-clone depth. 1 (default) fetches only the tip; 0 → a full clone. Ignored (forced full) when is set — a shallow tip may not contain the pin. + /// Shallow-clone depth. 1 (default) fetches only the tip; 0 → a full clone. With set the clone STARTS at this depth and only deepens (fetch-by-sha, then unshallow) when the pin is not the fetched tip — the common pin-equals-tip launch stays shallow. public int Depth { get; init; } = 1; - /// S1 — the EXACT commit to check out after cloning (see WorkspaceRepositorySpec.PinnedSha): forces a FULL clone, then a hard checkout of this sha; fails LOUD when the pin is missing/unreachable. Null → the tip of (byte-identical legacy behaviour). + /// S1 — the EXACT commit to materialize after cloning (see WorkspaceRepositorySpec.PinnedSha): a hard detached checkout of this sha, deepening the clone only when the tip advanced past the pin; fails LOUD when the pin is missing/unreachable. Null → the tip of (byte-identical legacy behaviour). public string? PinnedSha { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs b/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs index 66501bbb3..0df33309a 100644 --- a/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs +++ b/backend/src/CodeSpace.Messages/Agents/WorkspaceSpec.cs @@ -131,12 +131,12 @@ public sealed record WorkspaceRepositorySpec public string? Ref { get; init; } /// - /// S1 — the EXACT commit this workspace materializes at. When set, the clone is FULL (a shallow tip may not - /// contain the pin) and the working tree is checked out at THIS sha after cloning 's branch - /// context; a missing/unreachable pin fails the provision LOUD (never a silent tip fallback — the pin exists - /// so the planner, the reviewers, and every parallel agent see the SAME immutable base, and a pin that cannot - /// be honoured is a freshness violation, not a suggestion). Null (the default) → the tip of , - /// byte-identical to before this field existed (null-omitted from the serialized spec). + /// S1 — the EXACT commit this workspace materializes at. When set, the working tree is hard-checked-out at THIS + /// sha after cloning 's branch context (the clone deepens past its shallow default only when + /// the tip advanced beyond the pin); a missing/unreachable pin fails the provision LOUD (never a silent tip + /// fallback — the pin exists so the planner, the reviewers, and every parallel agent see the SAME immutable + /// base, and a pin that cannot be honoured is a freshness violation, not a suggestion). Null (the default) → + /// the tip of , byte-identical to before this field existed (null-omitted from the serialized spec). /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PinnedSha { get; init; } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs index 1d88fd4c3..0b51dde2e 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/LaunchBasePinFlowTests.cs @@ -87,6 +87,39 @@ public async Task A_missing_operator_BaseBranch_fails_the_launch_loud() ex.Message.ShouldContain("release/9.x", customMessage: "an operator pin that doesn't exist fails at LAUNCH — the clone would fail identically later, never a silent unpinned launch"); } + [Fact] + public async Task An_empty_remote_launches_unpinned_instead_of_failing() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + await remote.InitBareAsync(); // a just-created repo: the recorded default branch has no commits yet + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId)); + + (await ReadAgentPinAsync(result.RunId)).ShouldBeNull("an IMPLICIT default the remote doesn't have launches UNPINNED (the pre-S1 behaviour) — an opportunistic pin must never fail a brand-new repo's launch"); + } + + [Fact] + public async Task A_deep_launch_skips_the_vector_entirely_until_the_supervisor_lane_consumes_pins() + { + // The supervisor projection reads neither pins nor Seed.BaseBranch yet (the follow-up threads it) — so a + // deep launch must not pay for, or FAIL on, a vector it would drop on the floor. A bogus BaseBranch that + // would fail a single-agent launch loud must leave the deep launch untouched. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var _pauseExec = PauseAutoExecute(); + + using var remote = new GitRemote(); + await remote.SeedAsync("v1"); + var repoId = await SeedCloneableRepositoryAsync(teamId, remote.Url); + + var result = await LaunchAsync(FreshRequest(teamId, userId, repoId) with { RequestedEffort = TaskEffortModes.Deep, BaseBranch = "release/9.x" }); + + result.RunId.ShouldNotBe(Guid.Empty, "the deep lane never resolves the vector, so the bogus authored ref cannot fail its launch"); + } + [Fact] public async Task A_url_less_repo_stays_unpinned_byte_identical() { @@ -245,6 +278,8 @@ public async Task SeedAsync(string content) return await CommitAsync(content); } + public Task InitBareAsync() => RunGitAsync("init", "--bare", "-b", "main"); + public async Task CommitAsync(string content) { await File.WriteAllTextAsync(Path.Combine(_dir, "file.txt"), content); diff --git a/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs index d77381eeb..7c53dd500 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/WorkspaceSpecTests.cs @@ -70,6 +70,7 @@ public void A_null_pin_is_omitted_from_the_serialized_related_shape_byte_identic [Theory] [InlineData("abc123", "abc123")] // short id OK [InlineData(" ABC123DEF ", "abc123def")] // trimmed + lowercased + [InlineData("aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffff0000", "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffff0000")] // 64 hex = a sha256-object-format repo public void ValidatePinnedSha_normalizes_valid_ids(string raw, string expected) { Core.Services.Agents.Workspace.RepositoryWorkspaceResolver.ValidatePinnedSha(raw).ShouldBe(expected); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs index 574836d10..8b58585dd 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs @@ -291,6 +291,23 @@ public async Task A_pinned_workspace_diffs_against_the_pin_not_the_tip() changes.Patch.ShouldNotContain("version-B", customMessage: "provenance: the capture describes the agent's work over the pinned base, not a tip the agent never saw"); } + [Fact] + public async Task A_pin_matching_the_tip_keeps_the_clone_shallow() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var pin = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); // the tip has NOT moved — the common launch + + await using var handle = await NewProvider().PrepareAsync( + WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), PinnedSha = pin }), CancellationToken.None); + + (await GitStdoutAsync(handle.Directory, "rev-parse", "HEAD")).ShouldBe(pin); + (await GitStdoutAsync(handle.Directory, "rev-parse", "--is-shallow-repository")) + .ShouldBe("true", "pin == the fetched tip ⇒ the cheap rung wins — the common launch must not pay a full-history clone for its pin"); + } + [Fact] public async Task A_missing_pinned_sha_fails_the_provision_loud_naming_the_pin() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs index f0ec051e6..0a7d307f5 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs @@ -149,6 +149,26 @@ public void The_reviewer_model_is_omitted_when_the_planner_critic_is_off() planner.Config.TryGetProperty("reviewerModelId", out _).ShouldBeFalse("a reviewer without a review would not be byte-identical"); } + [Fact] + public void The_launch_base_pin_rides_the_planner_config_only_with_a_grounded_reviewer() + { + // S1: the grounded plan reviewer must clone the SAME commit the fan-out agents materialize. + var repoId = Guid.NewGuid(); + var profile = new ResolvedAgentProfile { RepositoryId = repoId, ReviewerAgent = true }; + var pins = new Dictionary { [repoId] = "abc123def456" }; + + var grounded = Context(profile) with { PlannerReviewMode = ReviewMode.Gate, PinnedShas = pins }; + Builder.Build(grounded).Nodes.Single(n => n.Id == "planner").Config.GetProperty("pinnedSha").GetString().ShouldBe("abc123def456"); + + var noReviewer = Context(profile with { ReviewerAgent = false }) with { PlannerReviewMode = ReviewMode.Gate, PinnedShas = pins }; + Builder.Build(noReviewer).Nodes.Single(n => n.Id == "planner").Config.TryGetProperty("pinnedSha", out _) + .ShouldBeFalse("no grounded reviewer ⇒ nothing clones at plan time ⇒ the key is omitted (byte-identical)"); + + var noPin = Context(profile) with { PlannerReviewMode = ReviewMode.Gate }; + Builder.Build(noPin).Nodes.Single(n => n.Id == "planner").Config.TryGetProperty("pinnedSha", out _) + .ShouldBeFalse("no vector ⇒ no pin key — the reviewer clones the default tip (legacy)"); + } + [Fact] public void The_confirm_gate_inserts_the_park_and_rebinds_the_map_to_the_approved_outputs() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs index 9d4e0f22b..7eb9a3af0 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/RemoteTipResolverTests.cs @@ -23,7 +23,7 @@ public async Task Resolves_a_named_branch_to_its_tip_commit() await SeedOriginAsync(origin.Path, "file.txt", "v1"); var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); - var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "main" }, CancellationToken.None); + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "main" }, refRequired: true, CancellationToken.None); sha.ShouldBe(expected); } @@ -37,7 +37,7 @@ public async Task Resolves_HEAD_when_no_ref_is_named() await SeedOriginAsync(origin.Path, "file.txt", "v1"); var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); - var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, CancellationToken.None); + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, refRequired: true, CancellationToken.None); sha.ShouldBe(expected, "no ref ⇒ the remote's HEAD — the same commit a bare `git clone` would materialize"); } @@ -51,7 +51,7 @@ public async Task A_missing_HARD_ref_fails_loud_naming_the_ref() await SeedOriginAsync(origin.Path, "file.txt", "v1"); var ex = await Should.ThrowAsync(() => NewResolver().ResolveTipShaAsync( - new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "release/9.x" }, CancellationToken.None)); + new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "release/9.x" }, refRequired: true, CancellationToken.None)); ex.Message.ShouldContain("release/9.x", customMessage: "a HARD ref that is gone fails the launch loud — the clone would fail identically later, never a silent unpinned launch"); } @@ -68,7 +68,7 @@ public async Task A_missing_SOFT_ref_falls_back_to_the_default_ref_tip() // DefaultRef set = the request's own SOFT semantics (a pruned session branch degrades to the default) — // the resolver mirrors the clone's ResolveCheckoutRefAsync so pin and clone can never diverge. var sha = await NewResolver().ResolveTipShaAsync( - new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "run-1/pruned", DefaultRef = "main" }, CancellationToken.None); + new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "run-1/pruned", DefaultRef = "main" }, refRequired: true, CancellationToken.None); sha.ShouldBe(expected); } @@ -84,7 +84,7 @@ public async Task A_tag_resolves_to_its_commit() await RunGitAsync(origin.Path, "tag", "-a", "v1.0", "-m", "release"); // annotated: the tag OBJECT sha ≠ the commit sha await WriteAndCommitAsync(origin.Path, "file.txt", "v2"); // the tip moves past the tag - var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "v1.0" }, CancellationToken.None); + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "v1.0" }, refRequired: true, CancellationToken.None); sha.ShouldBe(expected, "an annotated tag pin must be the PEELED commit — the tag object itself is not a tree the workspace can materialize"); } @@ -97,7 +97,7 @@ public async Task An_empty_remote_returns_null_nothing_exists_to_pin() using var origin = new TempDir(); await RunGitAsync(origin.Path, "init", "--bare", "-b", "main"); - var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, CancellationToken.None); + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path) }, refRequired: true, CancellationToken.None); sha.ShouldBeNull("an empty remote has no commit to pin — the launch proceeds unpinned rather than failing a brand-new repo"); } @@ -110,7 +110,70 @@ public async Task An_unreachable_remote_fails_loud() var dead = Path.Combine(Path.GetTempPath(), "cs-no-such-remote-" + Guid.NewGuid().ToString("N")); await Should.ThrowAsync(() => NewResolver().ResolveTipShaAsync( - new WorkspaceRequest { RepositoryUrl = new Uri(dead).AbsoluteUri, Ref = "main" }, CancellationToken.None)); + new WorkspaceRequest { RepositoryUrl = new Uri(dead).AbsoluteUri, Ref = "main" }, refRequired: true, CancellationToken.None)); + } + + [Fact] + public async Task A_missing_IMPLICIT_ref_returns_null_instead_of_throwing() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await RunGitAsync(origin.Path, "init", "--bare", "-b", "main"); // an empty just-created repo: its recorded default branch has no commits yet + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "main" }, refRequired: false, CancellationToken.None); + + sha.ShouldBeNull("an IMPLICIT recorded default the remote doesn't have launches UNPINNED (the pre-S1 behaviour) — an opportunistic pin must never fail a brand-new repo's launch"); + } + + [Fact] + public async Task A_branch_is_preferred_over_a_tag_with_the_same_name() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + await RunGitAsync(origin.Path, "tag", "release"); // a tag at v1… + var branchTip = await WriteCommitAndBranchAsync(origin.Path); // …and a BRANCH `release` at a different commit + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "release" }, refRequired: true, CancellationToken.None); + + sha.ShouldBe(branchTip, "refs/heads wins over refs/tags for an ambiguous name — matching git clone --branch's own preference"); + } + + [Fact] + public async Task A_lightweight_tag_resolves_to_its_commit() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + var expected = await GitStdoutAsync(origin.Path, "rev-parse", "HEAD"); + await RunGitAsync(origin.Path, "tag", "v1.0"); // lightweight: no tag object, no peeled ^{} line + await WriteAndCommitAsync(origin.Path, "file.txt", "v2"); + + var sha = await NewResolver().ResolveTipShaAsync(new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "v1.0" }, refRequired: true, CancellationToken.None); + + sha.ShouldBe(expected, "a lightweight tag points at the commit directly — the non-peeled fallback arm must return it"); + } + + [Fact] + public async Task A_missing_soft_ref_whose_default_is_also_missing_fails_loud() + { + if (!await GitAvailableAsync()) return; + + using var origin = new TempDir(); + await SeedOriginAsync(origin.Path, "file.txt", "v1"); + + await Should.ThrowAsync(() => NewResolver().ResolveTipShaAsync( + new WorkspaceRequest { RepositoryUrl = AsFileUrl(origin.Path), Ref = "run-1/pruned", DefaultRef = "no-such-default" }, refRequired: true, CancellationToken.None)); + } + + [Fact] + public void SanitizeUrl_strips_userinfo_and_leaves_clean_urls_alone() + { + RemoteTipResolver.SanitizeUrl("https://user:secret@host/repo.git").ShouldNotContain("secret", customMessage: "a stored clone URL may itself carry credentials the token redaction knows nothing about"); + RemoteTipResolver.SanitizeUrl("https://host/repo.git").ShouldBe("https://host/repo.git"); } // ─── harness (the LocalGitWorkspaceProviderTests pattern) ─────────────────────── @@ -149,6 +212,16 @@ private static async Task WriteAndCommitAsync(string dir, string file, string co await RunGitAsync(dir, "commit", "-m", "seed"); } + /// Commit on a new branch named release (shadowing the tag of the same name), back to main; returns the branch tip. + private static async Task WriteCommitAndBranchAsync(string dir) + { + await RunGitAsync(dir, "checkout", "-b", "release"); + await WriteAndCommitAsync(dir, "file.txt", "branch-content"); + var tip = await GitStdoutAsync(dir, "rev-parse", "HEAD"); + await RunGitAsync(dir, "checkout", "main"); + return tip; + } + private static async Task RunGitAsync(string workdir, params string[] args) { var result = await new LocalProcessRunner().RunAsync(