From 4bb812d91c28add5384013d39f42b600c5408015 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sat, 7 Nov 2026 23:27:25 +0800 Subject: [PATCH] Apply, clamp or refuse launch controls on every route An Auto launch that routed to plan-map or single-agent silently dropped the operator's allowed model pool, persona pool, decision critic, delivery spec and integrate opt-in (only the supervisor builder read them), and plan-map dropped the acceptance floor while the composer demanded one. The planner was shown the whole team pool, dispatch never bounded the model it ran, and a failing check could escalate past the pool. Every route-dependent control now gets a disposition - Applied, Clamped, NotApplicable or Refused, with the reason - from one resolver the launch and its route preview share, so a preview can no longer promise a launch it will not get. A refusal stops the launch before any session or run exists, with task_launch_control_refused; the quality tier's floor mandate is one of those dispositions, so the preview predicts it too. The preview also runs the launch's own pool checks, so a pool member gone since the composer loaded is the same not-found. - The model pool bounds the plan-map planner's catalog and rides every agent.run. Dispatch runs a bounded task on the pooled row its pin names - by row, then model on credential, then model, then the credential's best row, then the pool default - names any move by cause on the run, fails with agent_model_pool_unresolvable (not retried) when nothing in the pool runs, and never escalates outside the pool. The engine leaves an unusable pinned row to that choice instead of failing the node at staging. Quick makes the same choice at launch, so the frozen config shows the row that runs. - An escalation stamps the row it picked beside the model, and every later revise round and a cold-launch record carry that row, so the output review avoids the model that did the work, live and after a re-attach. - A persona the pool excludes refuses a plan-map or quick launch, and is named, not shown as an id. - The acceptance floor follows the route and workspace's own verdict. The single agent grades an argv in its own workspace, repository or not, so a repository-free floor there is applied; the supervisor grades against the branch it publishes, so without a repository its floor is refused, and a Delivery tier there is refused with the same remedies the floor names (bind a repository, or launch at Quick effort); with no floor carried it says to bind a repository or launch at Prototype. Plan-map refuses the floor, and a malformed one is refused by name. On Auto the floor keeps the route on a lane that grades it in this workspace, and the confirm card suggests the tier the route took. - Grounding is resolved before routing, so a chat follow-up is classified as one, and an ambiguous classification asks to confirm. --- .../Agents/AgentDefinitionResolver.cs | 15 +- .../Services/Agents/AgentRunExecutor.cs | 111 ++++-- .../AgentModelPoolUnresolvableException.cs | 16 + .../Services/Agents/HarnessModelReconciler.cs | 33 +- .../ModelCredentials/AllowedModelPool.cs | 101 +++++ .../ModelCredentials/IModelPoolSelector.cs | 11 + .../ModelCredentials/ModelPoolSelector.cs | 11 + .../Services/Tasks/Effort/EffortRouter.cs | 71 +++- .../TaskLaunchControlRefusedException.cs | 20 + .../Tasks/Launch/ILaunchControlResolver.cs | 13 + .../Tasks/Launch/ILaunchGroundingResolver.cs | 14 + .../Tasks/Launch/LaunchControlResolver.cs | 214 ++++++++++ .../Tasks/Launch/LaunchGroundingResolver.cs | 40 ++ .../Projection/Builders/AgentNodeMapping.cs | 13 +- .../Builders/PlanMap/PlanMapBuilderBase.cs | 7 +- .../SingleAgentDefinitionBuilder.cs | 10 +- .../Supervisor/SupervisorDefinitionBuilder.cs | 4 +- .../Projection/IWorkflowDefinitionBuilder.cs | 13 +- .../RoutePreview/TaskRoutePreviewService.cs | 43 +- .../RoutePreview/TaskRouteSnapshotService.cs | 12 +- .../Services/Tasks/TaskLaunchService.cs | 121 +++--- .../Workflows/Nodes/Builtin/AgentCodeNode.cs | 30 +- .../Workflows/Nodes/Builtin/PlanAuthorNode.cs | 9 + .../Planning/Planners/LlmWorkflowPlanner.cs | 8 +- .../CodeSpace.Messages/Agents/AgentTask.cs | 10 + .../CodeSpace.Messages/Agents/PooledModel.cs | 26 ++ .../Commands/Tasks/LaunchTaskCommand.cs | 3 + .../Commands/Tasks/TaskLaunchInput.cs | 24 +- .../Workflows/Planning/WorkflowPlanRequest.cs | 3 + .../Failures/FailureCodes.cs | 6 + .../Tasks/Effort/EffortPolicy.cs | 9 + .../Tasks/Effort/EffortRouteRequest.cs | 15 + .../Tasks/LaunchControlDisposition.cs | 37 ++ .../Tasks/LaunchControlResolution.cs | 6 + .../Tasks/TaskBuildContext.cs | 6 +- .../Tasks/TaskLaunchRequest.cs | 8 +- .../Tasks/TaskRoutePreviewResult.cs | 4 + ...itionResolverCredentialedModelFlowTests.cs | 43 ++ .../Agents/HarnessModelReconcilerFlowTests.cs | 157 ++++++++ .../AgentRunExecutorTests.ModelPool.cs | 209 ++++++++++ .../Workflows/AgentRunExecutorTests.cs | 189 ++++++++- .../Workflows/AgentRunReviseLoopFlowTests.cs | 108 ++++- .../PlanMapSynthPlannerRequest.cs | 3 +- .../Workflows/PlannerModelPoolFlowTests.cs | 87 ++++ .../Workflows/TaskLaunchContractFlowTests.cs | 55 ++- .../Workflows/TaskLaunchFlowTests.cs | 373 ++++++++++++++++-- .../Workflows/TaskRoutePreviewFlowTests.cs | 97 +++++ .../Agents/AllowedModelPoolTests.cs | 140 +++++++ .../Agents/DeploymentAutonomyCeilingTests.cs | 4 +- .../AcceptanceArgvInventoryTests.cs | 1 + .../Architecture/FailureTaxonomyTests.cs | 2 + .../Tasks/LaunchControlResolverTests.cs | 303 ++++++++++++++ .../TaskLaunchServiceQualityTierTests.cs | 54 +-- .../Tasks/TaskRoutePreviewServiceTests.cs | 74 +++- .../Workflows/AgentCodeNodeTests.cs | 49 +++ .../AgentRunExecutorEscalationTests.cs | 13 +- .../Workflows/EffortRouterDegradeTests.cs | 4 +- .../Workflows/EffortRouterGenericityTests.cs | 7 +- .../Workflows/EffortRouterTests.cs | 4 +- .../Workflows/LlmEffortClassifierTests.cs | 137 ++++++- .../Workflows/PlanAuthorNodeTests.cs | 12 + .../PlanMapSynthDefinitionBuilderTests.cs | 18 + .../SingleAgentDefinitionBuilderTests.cs | 17 + 63 files changed, 2957 insertions(+), 300 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Exceptions/AgentModelPoolUnresolvableException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/AllowedModelPool.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/Exceptions/TaskLaunchControlRefusedException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchControlResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchGroundingResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchControlResolver.cs create mode 100644 backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchGroundingResolver.cs create mode 100644 backend/src/CodeSpace.Messages/Agents/PooledModel.cs create mode 100644 backend/src/CodeSpace.Messages/Tasks/LaunchControlDisposition.cs create mode 100644 backend/src/CodeSpace.Messages/Tasks/LaunchControlResolution.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ModelPool.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/PlannerModelPoolFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Agents/AllowedModelPoolTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Tasks/LaunchControlResolverTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentDefinitionResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentDefinitionResolver.cs index 086ecb161..6d48aec7d 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentDefinitionResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentDefinitionResolver.cs @@ -87,6 +87,13 @@ public async Task ResolveAsync(AgentTask task, Guid teamId, Cancellat /// silently falling back — the operator's explicit choice is honoured or surfaced, never substituted. A disabled /// model is "not part of the usable pool", so a pinned-then-disabled model fails rather than runs. No reference → /// the task is returned untouched (byte-identical). + /// + /// The one exception is a task bound to an operator's allowed model pool (): + /// the operator chose the POOL as the fallback, and the dispatch owns that choice (HarnessModelReconciler + /// walks pinned row → model on the pinned credential → model on any pooled credential → the credential's best + /// pooled row → the pool default, names the move on the run's timeline, or fails with + /// agent_model_pool_unresolvable). So an unusable pinned row is left on the task for it — failing here would + /// end the run before any pooled row was tried, with no note and no typed cause. /// private async Task ApplyCredentialedModelAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken) { @@ -95,8 +102,12 @@ private async Task ApplyCredentialedModelAsync(AgentTask task, Guid t var row = await _db.ModelCredentialModel.AsNoTracking() .Where(m => m.Id == rowId && m.Enabled && m.Credential.TeamId == teamId && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active) .Select(m => new { m.ModelId, m.ModelCredentialId }) - .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) - ?? throw new AgentDefinitionResolutionException($"Selected model {rowId} is not an active, enabled credentialed model for this team."); + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (row is null && task.AllowedModelIds is { Count: > 0 }) return task; + + if (row is null) + throw new AgentDefinitionResolutionException($"Selected model {rowId} is not an active, enabled credentialed model for this team."); return task with { Model = row.ModelId, ModelCredentialId = row.ModelCredentialId }; } diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index 5d6af2f96..d1e13cffc 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -354,6 +354,8 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo var reconciliation = await _harnessReconciler.ReconcileAsync(task, run.TeamId, cancellationToken).ConfigureAwait(false); var harness = _harnesses.Resolve(reconciliation.HarnessKind); + task = await HoldToModelPoolAsync(owner, task, reconciliation, cancellationToken).ConfigureAwait(false); + if (reconciliation.Repaired) { _logger.LogWarning("AgentRun {RunId}: {Note}", agentRunId, reconciliation.Note); @@ -422,9 +424,14 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // credential resolve, so the pick is bounded to the very credential ROW whose key is now in this // sandbox's environment — which is what keeps that key, the egress base URL and the reconciled harness // valid for the escalated model without re-resolving any of them. - var escalation = task.Escalation is { } requested - ? await ResolveEscalationAsync(requested.Reason, run.TeamId, modelCredentialId, modelProvider, requested.From ?? effectiveModel, cancellationToken).ConfigureAwait(false) + // Every escalation this run resolves — this dispatch's, a revise round's, the next attempt's proposal — climbs + // only inside the run's own credential AND its operator's allowed model pool. + var escalationScope = new EscalationScope(run.TeamId, modelCredentialId, modelProvider, task.AllowedModelIds); + + var escalationPick = task.Escalation is { } requested + ? await ResolveEscalationAsync(requested.Reason, escalationScope, requested.From ?? effectiveModel, cancellationToken).ConfigureAwait(false) : null; + var escalation = escalationPick?.Escalation; // A no-op escalation is announced ONCE per run: the fact that this team has nothing stronger does not // change between rounds, and repeating it every round would bury the round's real reason under noise. @@ -432,14 +439,14 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo if (escalation is not null) { - effectiveTask = ApplyEscalation(effectiveTask, escalation); + effectiveTask = ApplyEscalation(effectiveTask, escalationPick); noStrongerModelNoted = escalation.To is null; await AppendEscalationEventAsync(owner, escalation, cancellationToken).ConfigureAwait(false); // Surface the escalated model on the run the same way the resolved model is surfaced above — the // identity strip must show what this attempt is ACTUALLY running, not the model it was authored with. if (escalation.To is { Length: > 0 } escalated) - await PersistResolvedModelAsync(owner, task with { Model = escalated }, cancellationToken).ConfigureAwait(false); + await PersistResolvedModelAsync(owner, task with { Model = escalated, ModelCredentialModelId = effectiveTask.ModelCredentialModelId }, cancellationToken).ConfigureAwait(false); } // The escalation event's sibling, for the OTHER thing a dispatcher can decide a respawn owes: this @@ -450,8 +457,10 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo await AppendMitigationEventAsync(owner, cancellationToken).ConfigureAwait(false); // The model in force for the NEXT harness invocation, carried across revise rounds: an escalation won in - // round 1 must not evaporate in round 2 just because round 2's own result asked for nothing further. + // round 1 must not evaporate in round 2 just because round 2's own result asked for nothing further. Its ROW + // travels with it, so every later round is reviewed — and persisted — as the row that actually runs. var dispatchedModel = effectiveTask.Model; + var dispatchedRow = effectiveTask.ModelCredentialModelId; // P3 (3.2c): resolve a REFERENCED (offloaded) restored transcript to bytes NOW — the producer kept only the // ref in task_jsonb to bound its size; the harness needs the bytes to lay down the resume file. Bounded: the @@ -577,7 +586,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // Recorded only once the attempt has passed every check this executor makes before launching it — local // acceptance, spend — so the trace never says a conversation was set aside for an attempt that did not run. if (ranCold) - await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false); + await RecordRunColdAsync(owner, task with { Model = dispatchedModel, ModelCredentialModelId = dispatchedRow }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false); // The same rule for what the harness left out of the launch. Said again only when a revise round's build // leaves out something else: the agent changes the workspace those notices describe. @@ -626,7 +635,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo await AppendReviseEventAsync(owner, reason, round, reviseBudget, cancellationToken).ConfigureAwait(false); - var reviseTask = BuildReviseTask(effectiveTask, result, reason, conversation: ConversationBehind(agentRunId, result)) with { Model = dispatchedModel }; + var reviseTask = BuildReviseTask(effectiveTask, result, reason, conversation: ConversationBehind(agentRunId, result)) with { Model = dispatchedModel, ModelCredentialModelId = dispatchedRow }; // D3: the round that just failed IS the evidence. When it says the model was the limit — an // over-claim, or a check that failed on real work, never a grader / environment / gateway fault — @@ -634,18 +643,20 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // expecting a different answer. A pool with nothing stronger records the fact and changes nothing. if (EscalationReasonFor(result) is { } escalationReason) { - escalation = await ResolveEscalationAsync(escalationReason, run.TeamId, modelCredentialId, modelProvider, dispatchedModel, cancellationToken).ConfigureAwait(false); - reviseTask = ApplyEscalation(reviseTask, escalation); + var revisePick = await ResolveEscalationAsync(escalationReason, escalationScope, dispatchedModel, cancellationToken).ConfigureAwait(false); + escalation = revisePick.Escalation; + reviseTask = ApplyEscalation(reviseTask, revisePick); if (escalation.To is { Length: > 0 }) { dispatchedModel = reviseTask.Model; + dispatchedRow = reviseTask.ModelCredentialModelId; await AppendEscalationEventAsync(owner, escalation, cancellationToken).ConfigureAwait(false); // Keep the PERSISTED envelope truthful per round, not just at launch: the identity strip // reads it live, and it is the fallback floor a later attempt's escalation measures from // when the harness's own stream never names a model. - await PersistResolvedModelAsync(owner, task with { Model = dispatchedModel }, cancellationToken).ConfigureAwait(false); + await PersistResolvedModelAsync(owner, task with { Model = dispatchedModel, ModelCredentialModelId = dispatchedRow }, cancellationToken).ConfigureAwait(false); } else if (!noStrongerModelNoted) { @@ -728,7 +739,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // respawning at all (a null To means a respawn would re-burn the identical model, and stays // deterministic). Costs one bounded read, and only on a run that already failed its own check. if (EscalationReasonFor(result) is { } nextReason) - result = result with { ProposedEscalation = await ResolveEscalationAsync(nextReason, run.TeamId, modelCredentialId, modelProvider, dispatchedModel, cancellationToken).ConfigureAwait(false) }; + result = result with { ProposedEscalation = (await ResolveEscalationAsync(nextReason, escalationScope, dispatchedModel, cancellationToken).ConfigureAwait(false)).Escalation }; // P0-B2: stamp what the fabric ACTUALLY did — observed off the live endpoint while it is still open // (the await-using disposes it at scope end). The re-attach path deliberately leaves this null: the @@ -808,7 +819,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo } catch (Exception ex) { - _logger.LogError(ex, "Agent run {RunId} failed during execution", agentRunId); + LogExecutionFailure(ex, agentRunId); // A failure raised while an agent was being observed says nothing about that agent: it may still be working // in the clone, and deleting the clone would pull the directory out from under it. Asked BEFORE the terminal @@ -2680,9 +2691,12 @@ private static bool EscalationWorkPresent(AgentRunResult result) => || !string.IsNullOrEmpty(result.ProducedBranch) || result.RepositoryResults.Any(r => r.ChangedFiles.Count > 0 || !string.IsNullOrEmpty(r.ProducedBranch)); - /// Apply a resolved escalation to the task it governs: the picked model REPLACES whatever the task carried (a pin is a floor for untested work, not a ceiling once the run's own check has disproved it). A null pick — nothing in the pool beat the floor — leaves the task byte-identical; the fact lives on the result and the timeline, never in a perturbed dispatch. - internal static AgentTask ApplyEscalation(AgentTask task, AgentModelEscalation? escalation) => - escalation?.To is { Length: > 0 } model ? task with { Model = model } : task; + /// Apply a resolved escalation to the task it governs: the picked model REPLACES whatever the task carried (a pin is a floor for untested work, not a ceiling once the run's own check has disproved it), and its ROW replaces the row the task carried, so the output review's producer identity and a re-attach name the row that did the work, not the one the run started on. A null pick — nothing in the pool beat the floor — leaves the task byte-identical; the fact lives on the result and the timeline, never in a perturbed dispatch. + internal static AgentTask ApplyEscalation(AgentTask task, EscalationPick? pick) => + pick?.Escalation.To is { Length: > 0 } model ? task with { Model = model, ModelCredentialModelId = pick.RowId } : task; + + /// A resolved escalation: the record the timeline and the result carry, and the credentialed-model ROW its pick is (null when nothing stronger exists). + internal sealed record EscalationPick(AgentModelEscalation Escalation, Guid? RowId); /// The escalation announcement's pinned prefix — the operator-visible marker on the run's timeline (and a stable hook for tests). internal const string ModelEscalationPrefix = "Model escalation"; @@ -2714,27 +2728,49 @@ internal static string DescribeEscalation(AgentModelEscalation escalation) => /// false keeps never-probed rows preferred; the full set stands when EVERY candidate is known-dead, since a /// maybe-dead stronger model still beats no escalation). /// - /// Always returns a record — a null pick is the OUTCOME "nothing stronger exists", not an absence. + /// Always returns a pick — a null To (and row) is the OUTCOME "nothing stronger exists", not an absence. The + /// picked row's id rides beside the model so the task names the row that runs. /// - private async Task ResolveEscalationAsync(string reason, Guid teamId, Guid? credentialId, string? provider, string? priorModel, CancellationToken cancellationToken) + private async Task ResolveEscalationAsync(string reason, EscalationScope scope, string? priorModel, CancellationToken cancellationToken) { - var rows = await _db.ModelCredentialModel.AsNoTracking() - .Where(m => m.Enabled && m.Credential.TeamId == teamId && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active) - .Select(m => new { m.ModelId, m.IsDefault, m.CapabilityTier, m.ProbedCapabilityTier, m.Available, m.ModelCredentialId, m.Credential.Provider }) + var query = _db.ModelCredentialModel.AsNoTracking() + .Where(m => m.Enabled && m.Credential.TeamId == scope.TeamId && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active); + + // A bounded run climbs only inside its operator's allowed pool — the same bound the supervisor's escalated spawn + // honours. A stronger model outside it is not a stronger model this run may run. + if (scope.AllowedModelIds is { Count: > 0 } allowed) query = query.Where(m => allowed.Contains(m.Id)); + + var rows = await query + .Select(m => new { m.Id, m.ModelId, m.IsDefault, m.CapabilityTier, m.ProbedCapabilityTier, m.Available, m.ModelCredentialId, m.Credential.Provider }) .ToListAsync(cancellationToken).ConfigureAwait(false); // Provider matching in memory + case-insensitively: the pool is a team's handful of rows, and a provider tag // stored with different casing under two credentials must not silently shrink the candidate set. - var bounded = credentialId is { } row ? rows.Where(m => m.ModelCredentialId == row).ToList() - : provider is { Length: > 0 } ? rows.Where(m => string.Equals(m.Provider, provider, StringComparison.OrdinalIgnoreCase)).ToList() + var bounded = scope.CredentialId is { } row ? rows.Where(m => m.ModelCredentialId == row).ToList() + : scope.Provider is { Length: > 0 } provider ? rows.Where(m => string.Equals(m.Provider, provider, StringComparison.OrdinalIgnoreCase)).ToList() : rows; var reachable = bounded.Where(m => m.Available != false).ToList(); var candidates = reachable.Count > 0 ? reachable : bounded; - var picked = SupervisorRetryEscalation.PickStrongerModel(candidates, m => m.IsDefault, m => m.ProbedCapabilityTier, m => m.CapabilityTier, m => m.ModelId, priorModel)?.ModelId; + var picked = SupervisorRetryEscalation.PickStrongerModel(candidates, m => m.IsDefault, m => m.ProbedCapabilityTier, m => m.CapabilityTier, m => m.ModelId, priorModel); + + return new EscalationPick(new AgentModelEscalation { From = priorModel, To = picked?.ModelId, Reason = reason }, picked?.Id); + } + + /// What an escalation may climb within: the run's team, the credential row whose key its sandbox holds (else its provider), and the operator's allowed model pool (null / empty = unbounded). + private sealed record EscalationScope(Guid TeamId, Guid? CredentialId, string? Provider, IReadOnlyList? AllowedModelIds); + + /// An allowed model pool that resolves nothing is the operator's configuration, not an executor fault: named at Warning with its reason and no stack trace. Anything else that ends a run here is a fault, logged with its stack. + private void LogExecutionFailure(Exception ex, Guid agentRunId) + { + if (ex is AgentModelPoolUnresolvableException) + { + _logger.LogWarning("Agent run {RunId} did not run: {Reason}", agentRunId, ex.Message); + return; + } - return new AgentModelEscalation { From = priorModel, To = picked, Reason = reason }; + _logger.LogError(ex, "Agent run {RunId} failed during execution", agentRunId); } /// The note a format-fault respawn announces on the timeline. Written as a fact about THIS attempt (never a promise about a next one, and never a count) — it is emitted from the dispatch of every task that already carries the degrade, so it exists exactly when a respawn does, and the supervisor lane can legitimately buy more than one per run. Wording pinned by unit test. @@ -4387,6 +4423,33 @@ internal static SecretRedactor WithModelBrokerRunToken(SecretRedactor redactor, /// The credential parts embedded IN a base URL — userinfo and secret-named query values (). private static IEnumerable UrlEmbeddedSecrets(string? baseUrl) => CredentialNeedles.FromUrl(baseUrl); + /// + /// Hold a bounded run to its allowed model pool (): run the reconciler's pooled + /// ROW — its model on its own credential, the row id carried so a re-attach re-chooses the same row — and persist it, + /// so every reader sees the model the agent actually runs; a model or credential that had to move is named on the + /// run's timeline. A pool that resolves nothing any more fails the run with a typed, deterministic refusal rather than + /// let the agent run outside it. An unbounded task passes through untouched (byte-identical). The task is still the + /// ORIGINAL (no injected secret env), re-sealed by . + /// + private async Task HoldToModelPoolAsync(AgentRunOwnerToken owner, AgentTask task, HarnessReconciliation reconciliation, CancellationToken cancellationToken) + { + if (task.AllowedModelIds is not { Count: > 0 }) return task; + + if (reconciliation.PooledModel is not { } pooled) + throw new AgentModelPoolUnresolvableException(reconciliation.PoolNote ?? ModelCredentials.AllowedModelPool.NothingRunsNote); + + if (reconciliation.PoolNote is { } note) + { + _logger.LogWarning("AgentRun {RunId}: {Note}", owner.RunId, note); + await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = note }, cancellationToken).ConfigureAwait(false); + } + + var held = task with { Model = pooled.ModelId, ModelCredentialId = pooled.ModelCredentialId, ModelCredentialModelId = pooled.Id }; + await PersistResolvedModelAsync(owner, held, cancellationToken).ConfigureAwait(false); + + return held; + } + /// Re-persist the run's stored task with its RESOLVED model filled, so the live projection shows what an "auto" run actually dispatches from the moment it starts (mirrors the harness-reconciliation write). The task is the ORIGINAL (no injected secret env) with only set — but it is the OPENED task, so it is re-sealed exactly as the store first persisted it: written back raw, the workflow secrets it embeds would land in task_jsonb again. The row is Running, so it keeps that shape whatever the fleet gate says (): unsealed, a re-attach would rebuild this launch's redactor without those secrets and fail the handle's fingerprint. private Task PersistResolvedModelAsync(AgentRunOwnerToken owner, AgentTask taskWithModel, CancellationToken cancellationToken) => PersistRuntimeIdentityAsync(owner, null, AgentTaskSecrets.Reseal(taskWithModel, _sealer), cancellationToken); diff --git a/backend/src/CodeSpace.Core/Services/Agents/Exceptions/AgentModelPoolUnresolvableException.cs b/backend/src/CodeSpace.Core/Services/Agents/Exceptions/AgentModelPoolUnresolvableException.cs new file mode 100644 index 000000000..a39a68b28 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Exceptions/AgentModelPoolUnresolvableException.cs @@ -0,0 +1,16 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Agents.Exceptions; + +/// +/// An agent run bound to the operator's allowed model pool found nothing in it that can run — every pooled model is +/// disabled, or its credential is no longer active — so the run is refused rather than put on a model outside the pool. +/// Its code is the run's exit reason, and a respawn meets the same pool, so the node treats it as deterministic. +/// +public sealed class AgentModelPoolUnresolvableException : Exception, IFailure +{ + public AgentModelPoolUnresolvableException(string message) : base(message) { } + + public FailureKind Kind => FailureKind.Unprocessable; + public string Code => FailureCodes.AgentModelPoolUnresolvable; +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/HarnessModelReconciler.cs b/backend/src/CodeSpace.Core/Services/Agents/HarnessModelReconciler.cs index 6b45b51e4..e45504beb 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/HarnessModelReconciler.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/HarnessModelReconciler.cs @@ -36,6 +36,13 @@ namespace CodeSpace.Core.Services.Agents; /// row, so a pin repair makes the whole (harness, model, credential) triple runnable. Blanking the model here would /// drop a VALID operator choice in the common consistent case, so this layer never does; it only swaps the harness for /// one that can drive the model's provider. +/// +/// The ONE exception is the run's allowed model pool (, the model analogue of +/// the harness allow-list): a bounded task runs on a POOLED ROW — the one chooses for its +/// pinned row, model and credential (a planner-authored model outside the pool lands on the pool's default row) — +/// reported as for the executor to run and persist, with a note whenever +/// the model or its credential moved, and the harness is reconciled against THAT row's provider. An unbounded task is +/// reconciled exactly as before. /// public interface IHarnessModelReconciler { @@ -43,8 +50,8 @@ public interface IHarnessModelReconciler Task ReconcileAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken); } -/// The harness KIND to run, whether it was REPAIRED away from the authored one, and a human-facing note for the timeline when it was. -public sealed record HarnessReconciliation(string HarnessKind, bool Repaired, string? Note); +/// The harness KIND to run, whether it was REPAIRED away from the authored one, and a human-facing note for the timeline when it was — plus, for a task bounded to an allowed model pool, the pooled row it runs on (null when unbounded, or when the pool resolves nothing any more) and a note when the authored model had to move or cannot be honoured. +public sealed record HarnessReconciliation(string HarnessKind, bool Repaired, string? Note, PooledModelRow? PooledModel = null, string? PoolNote = null); public sealed class HarnessModelReconciler : IHarnessModelReconciler, IScopedDependency { @@ -61,11 +68,16 @@ public HarnessModelReconciler(IAgentHarnessRegistry harnesses, IModelPoolSelecto public async Task ReconcileAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken) { - var provider = await ResolveModelProviderAsync(task, teamId, cancellationToken).ConfigureAwait(false); + var choice = await ChoosePooledModelAsync(task, teamId, cancellationToken).ConfigureAwait(false); + var pooled = choice?.Row; + var poolNote = choice?.Note; + + // A bounded task runs on its pooled row, so the harness follows THAT row's provider, not the authored model's. + var provider = pooled?.Provider ?? await ResolveModelProviderAsync(task, teamId, cancellationToken).ConfigureAwait(false); // No provider to reconcile against (no pin AND no pooled model name) → return the authored kind verbatim (the // caller's registry resolves it; a genuinely-unregistered kind surfaces there, unchanged). - if (provider is null) return new HarnessReconciliation(task.Harness, false, null); + if (provider is null) return new HarnessReconciliation(task.Harness, false, null, pooled, poolNote); // The repair chooses from the registry CLAMPED to the run's harness allow-list (null/empty = the whole registry, // which is every non-supervisor path and every pre-field task envelope). Without this clamp the run-time repair @@ -75,7 +87,18 @@ public async Task ReconcileAsync(AgentTask task, Guid tea // admitted one, so the floor stays inside the list too. var pool = AgentHarnessPool.Clamp(_harnesses.All, task.AllowedHarnessKinds); - return Reconcile(task.Harness, provider, pool, AgentHarnessDefaults.DefaultHarness); + return Reconcile(task.Harness, provider, pool, AgentHarnessDefaults.DefaultHarness) with { PooledModel = pooled, PoolNote = poolNote }; + } + + /// The allowed-pool choice for a bounded task — its pinned row, model and credential, as reads them over the pool's described rows. Null for an unbounded task (every pre-field envelope). + private async Task ChoosePooledModelAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken) + { + if (task.AllowedModelIds is not { Count: > 0 } pool) return null; + + var pin = new ModelPin(task.ModelCredentialModelId, task.Model, task.ModelCredentialId); + var rows = await _modelSelector.ListPoolRowsAsync(teamId, AllowedModelPool.RowsToRead(pool, pin), cancellationToken).ConfigureAwait(false); + + return AllowedModelPool.Choose(pool, rows, pin); } /// diff --git a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/AllowedModelPool.cs b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/AllowedModelPool.cs new file mode 100644 index 000000000..1301f5c4e --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/AllowedModelPool.cs @@ -0,0 +1,101 @@ +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.ModelCredentials; + +/// +/// Which pooled row an authored model pin runs on inside an operator's allowed model pool — the ONE choice the launch +/// (a single agent's pinned model, clamped before its config is frozen) and the dispatch (every bounded agent run, via +/// HarnessModelReconciler) both make, so a launch disposition and the run it predicts cannot disagree. Pure: it +/// decides over the rows described; no I/O. +/// +/// A model NAME is unique only per credential, so the choice walks from the most specific thing authored to the +/// least: the pinned ROW, when it is pooled and usable; the pooled row carrying the pinned model on the pinned +/// credential; a pooled row carrying the model name; the pinned credential's best pooled row (a persona may pin only a +/// credential); the pool's own default row. Ranking follows the agent-plane default (), +/// row id breaking ties. Whenever the chosen row differs from what was authored — another model, or the same model on +/// another credential — the choice carries a note naming what moved and why: never in the pool, or in the pool but no +/// longer usable. +/// +public static class AllowedModelPool +{ + internal const string NothingRunsNote = "None of this run's allowed models is enabled under an active credential, so nothing inside the pool can run."; + + /// The rows to describe for one choice: the pool, plus a pinned row the pool does not list — so a note can name the model and credential it moved away from. + public static IReadOnlyList RowsToRead(IReadOnlyList pool, ModelPin pin) => + pin.RowId is { } row && !pool.Contains(row) ? [.. pool, row] : pool; + + public static PooledModelChoice Choose(IReadOnlyList pool, IReadOnlyList rows, ModelPin pin) + { + var usable = rows.Where(r => r.Usable && pool.Contains(r.Id)).ToList(); + var authored = Authored(rows, pin); + + var row = usable.FirstOrDefault(r => r.Id == pin.RowId) + ?? OnCredential(usable, authored.CredentialId, authored.Model) + ?? Best(usable.Where(r => Named(r, authored.Model))) + ?? OnCredential(usable, authored.CredentialId, model: null) + ?? Best(usable); + + if (row is null) return new PooledModelChoice(null, NothingRunsNote); + + return new PooledModelChoice(row, Moved(authored, row) ? Describe(pool, rows, authored, row) : null); + } + + /// What the pin authored, in model + credential terms: a pinned row the rows describe speaks for itself; otherwise the pin's own name and credential. + private static AuthoredPin Authored(IReadOnlyList rows, ModelPin pin) + { + var pinned = rows.FirstOrDefault(r => r.Id == pin.RowId); + var credentialId = pinned?.ModelCredentialId ?? pin.CredentialId; + + return new AuthoredPin(pin.RowId, pinned?.ModelId ?? NullIfBlank(pin.Model), credentialId, rows.FirstOrDefault(r => r.ModelCredentialId == credentialId)?.CredentialName); + } + + /// The best pooled row on — carrying when one is named. Null when no credential was authored. + private static PooledModelRow? OnCredential(IReadOnlyList usable, Guid? credentialId, string? model) => + credentialId is { } credential ? Best(usable.Where(r => r.ModelCredentialId == credential && (model is null || Named(r, model)))) : null; + + private static PooledModelRow? Best(IEnumerable rows) => + AgentPlaneModelRanking.Rank(rows, r => r.IsDefault, r => r.ProbedCapabilityTier, r => r.CapabilityTier).ThenBy(r => r.Id).FirstOrDefault(); + + /// Model ids match case-insensitively — parity with every other pool lookup. + private static bool Named(PooledModelRow row, string? model) => model is not null && string.Equals(row.ModelId, model, StringComparison.OrdinalIgnoreCase); + + private static bool Moved(AuthoredPin authored, PooledModelRow row) => + (authored.RowId is { } id && id != row.Id) || (authored.Model is { } model && !Named(row, model)) || (authored.CredentialId is { } credential && credential != row.ModelCredentialId); + + /// The note for a moved pin, worded by cause: the same model on another credential, a model or credential the pool does not hold, or one it holds that can no longer run. + private static string Describe(IReadOnlyList pool, IReadOnlyList rows, AuthoredPin authored, PooledModelRow row) + { + var instead = $"running '{row.ModelId}' on '{row.CredentialName}' instead."; + + if (authored.Model is { } model && Named(row, model)) + return $"Model '{model}' has no usable row on {(authored.CredentialName is { } name ? $"credential '{name}'" : "the credential it was pinned to")} in this run's allowed model pool; {instead}"; + + return $"{Subject(authored)} {Cause(pool, rows, authored)}; {instead}"; + } + + private static string Subject(AuthoredPin authored) => authored switch + { + { Model: { } model, CredentialName: { } name } => $"Model '{model}' on '{name}'", + { Model: { } model } => $"Model '{model}'", + { CredentialId: not null, CredentialName: { } credential } => $"Credential '{credential}'", + { CredentialId: not null } => "The pinned credential", + _ => "The pinned model", + }; + + private static string Cause(IReadOnlyList pool, IReadOnlyList rows, AuthoredPin authored) + { + if (authored.Model is null && authored.CredentialId is not null) return "carries no usable model in this run's allowed model pool"; + + return rows.Any(r => pool.Contains(r.Id) && !r.Usable && Matches(r, authored)) + ? "is in this run's allowed model pool but disabled, or its credential is no longer active" + : "is not in this run's allowed model pool"; + } + + /// Whether a row is the authored thing — the pinned row itself, or the authored model on the authored credential (any credential when none was pinned). + private static bool Matches(PooledModelRow row, AuthoredPin authored) => + row.Id == authored.RowId || (Named(row, authored.Model) && (authored.CredentialId is null || row.ModelCredentialId == authored.CredentialId)); + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record AuthoredPin(Guid? RowId, string? Model, Guid? CredentialId, string? CredentialName); +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/IModelPoolSelector.cs b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/IModelPoolSelector.cs index f70c2edd5..684757e7c 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/IModelPoolSelector.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/IModelPoolSelector.cs @@ -81,6 +81,17 @@ public interface IModelPoolSelector return pool.Count == 0 ? null : await ResolveDispatchAsync(teamId, pool[0].ModelId, allowedRowIds, cancellationToken).ConfigureAwait(false); } + /// + /// The credentialed-model rows names, TEAM-SCOPED and whatever their state: each row with + /// whether it can run at all (enabled, under an active, non-deleted credential), the credential it runs on and that + /// credential's name, its provider, and the agent-plane ranking inputs. The allowed-model-pool choice + /// (AllowedModelPool) reads it to run an authored pin on its OWN pooled row — never a same-named row of another + /// credential — and to name what moved, and why, when it cannot. The default implementation (a fake that models no + /// rows) lists nothing, so a bounded choice over it fails closed rather than escape its pool. + /// + Task> ListPoolRowsAsync(Guid teamId, IReadOnlyList rowIds, CancellationToken cancellationToken) => + Task.FromResult>(Array.Empty()); + /// /// List the team's credentialed pool models (model id + provider) the brain MAY dispatch — bounded to /// (null/empty = ALL the team's enabled rows under active credentials). For diff --git a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelPoolSelector.cs b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelPoolSelector.cs index 3e47027f7..19fa79902 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelPoolSelector.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/ModelCredentials/ModelPoolSelector.cs @@ -159,6 +159,17 @@ public ModelPoolSelector(CodeSpaceDbContext db, IPayloadEncryptor encryptor) return pick is null ? null : new ModelDispatchRef { ModelId = pick.ModelId, ModelCredentialId = pick.ModelCredentialId, Provider = pick.Provider }; } + public async Task> ListPoolRowsAsync(Guid teamId, IReadOnlyList rowIds, CancellationToken cancellationToken) => + await _db.ModelCredentialModel.AsNoTracking() + .Where(m => rowIds.Contains(m.Id) && m.Credential.TeamId == teamId) + .Select(m => new PooledModelRow + { + Id = m.Id, ModelId = m.ModelId, ModelCredentialId = m.ModelCredentialId, CredentialName = m.Credential.DisplayName, Provider = m.Credential.Provider, + Usable = m.Enabled && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active, + IsDefault = m.IsDefault, CapabilityTier = m.CapabilityTier, ProbedCapabilityTier = m.ProbedCapabilityTier, + }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + public async Task> ListPoolAsync(Guid teamId, IReadOnlyList? allowedRowIds, CancellationToken cancellationToken) { var query = _db.ModelCredentialModel.AsNoTracking() diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Effort/EffortRouter.cs b/backend/src/CodeSpace.Core/Services/Tasks/Effort/EffortRouter.cs index b9862643a..817994913 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Effort/EffortRouter.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Effort/EffortRouter.cs @@ -2,6 +2,7 @@ using CodeSpace.Core.Services.Agents; using CodeSpace.Core.Services.Tasks.Bounds; using CodeSpace.Core.Services.Tasks.Capabilities; +using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Core.Services.Tasks.Recipes; using CodeSpace.Messages.Agents; using CodeSpace.Messages.Tasks; @@ -15,8 +16,9 @@ namespace CodeSpace.Core.Services.Tasks.Effort; /// type: every branch point is a registry lookup, so a new classification / recipe / bounds / capability /// strategy needs zero edit here (the fake-probe + fake-recipe contract test proves it). The pipeline: resolve /// the decision (operator short-circuit vs the default classifier) → policy-decide the effort mode → resolve the -/// recipe (fail-open) → resolve the projection → DEGRADE if the recipe's required capability is unavailable → -/// resolve the bounds preset + merge any caps override → assemble the RoutePlan + a derived confirm card. +/// recipe (fail-open) → keep an auto route with an operator acceptance floor on a projection that grades it → resolve +/// the projection → DEGRADE if the recipe's required capability is unavailable → resolve the bounds preset + merge any +/// caps override → assemble the RoutePlan + a derived confirm card. /// public sealed class EffortRouter : IEffortRouter, IScopedDependency { @@ -24,13 +26,15 @@ public sealed class EffortRouter : IEffortRouter, IScopedDependency private readonly ITaskRecipeRegistry _recipes; private readonly IBoundsPresetRegistry _bounds; private readonly ICapabilityProbeRegistry _capabilities; + private readonly ITaskProjectionRegistry _projections; - public EffortRouter(IEffortClassifierRegistry classifiers, ITaskRecipeRegistry recipes, IBoundsPresetRegistry bounds, ICapabilityProbeRegistry capabilities) + public EffortRouter(IEffortClassifierRegistry classifiers, ITaskRecipeRegistry recipes, IBoundsPresetRegistry bounds, ICapabilityProbeRegistry capabilities, ITaskProjectionRegistry projections) { _classifiers = classifiers; _recipes = recipes; _bounds = bounds; _capabilities = capabilities; + _projections = projections; } public async Task RouteAsync(EffortRouteRequest request, CancellationToken ct) @@ -41,24 +45,61 @@ public async Task RouteAsync(EffortRouteRequest request, Cancellation var recipe = ResolveRecipe(request, decision); + (effortMode, recipe, var floorReason) = KeepOperatorFloorGradable(request, wasAutoClassified, decision.Signals, effortMode, recipe); + var projectionKind = request.RequestedProjection ?? recipe.DefaultProjectionKind; var (effectiveRecipe, effectiveProjection, degradedReason) = DegradeIfCapabilityUnavailable(request, recipe, projectionKind); var (preset, caps) = ResolveCaps(request, effortMode, effectiveRecipe); - // A risky / irreversible task ALWAYS surfaces the confirm card regardless of the model's self-confidence — the - // classifier emits the risk signal, but the ROUTER (not the model's confidence) decides the human gate, so an - // over-confident model can't suppress the operator's escalation affordance on destructive work. This restores the - // pre-LLM always-confirm floor for risk while keeping the confident-routing win for ordinary tasks (model emits - // data, policy decides — the same tighten-only convention as the autonomy ceiling). - var needsConfirmCard = wasAutoClassified && (decision.Confidence < EffortPolicy.ConfirmConfidenceFloor || decision.Signals.RiskySideEffects); + var needsConfirmCard = NeedsConfirmCard(decision, wasAutoClassified); + + var confirm = needsConfirmCard ? BuildConfirmCard(decision, effortMode) : null; + + return BuildPlan(decision, wasAutoClassified, effortMode, effectiveRecipe, effectiveProjection, preset, caps, needsConfirmCard, confirm, JoinReasons(floorReason, degradedReason)); + } + + /// + /// Whether an auto route must be confirmed by the operator before it runs: a confidence below the floor, a risky / + /// irreversible task, or an AMBIGUOUS one. The classifier emits the signals, but the ROUTER (not the model's + /// confidence) decides the human gate, so an over-confident model can't suppress the operator's escalation affordance + /// on destructive work, or route an under-specified goal as though it were understood — the confirm card says + /// exactly that. Model emits data, policy decides — the same tighten-only convention as the autonomy ceiling. An + /// explicit operator tier is already a decision and never confirms. + /// + private static bool NeedsConfirmCard(EffortDecision decision, bool wasAutoClassified) => + wasAutoClassified && (decision.Confidence < EffortPolicy.ConfirmConfidenceFloor || decision.Signals.RiskySideEffects || decision.Signals.Ambiguous); + + /// + /// An operator acceptance floor is a routing signal. When the AUTO path's classified shape lands on a projection that + /// cannot grade an operator command in THIS workspace — plan-map anywhere, the supervisor without a repository — + /// re-decide the tier with every such tier set aside — the policy's next matching row — and say so on the route. An + /// explicit tier, a pinned recipe or a pinned projection is the operator's own choice and stays where it is: the launch + /// refuses that combination with its reason instead of moving it. + /// + private (string EffortMode, ITaskRecipe Recipe, string? Reason) KeepOperatorFloorGradable(EffortRouteRequest request, bool wasAutoClassified, EffortSignals signals, string effortMode, ITaskRecipe recipe) + { + if (!request.HasOperatorFloor || !wasAutoClassified || request.RequestedRecipe is not null || request.RequestedProjection is not null) return (effortMode, recipe, null); + if (GradesOperatorFloor(recipe.DefaultProjectionKind, request.HasRepository)) return (effortMode, recipe, null); - var confirm = needsConfirmCard ? BuildConfirmCard(decision) : null; + var admitted = EffortPolicy.Decide(signals, mode => GradesOperatorFloor(_recipes.RecipeForEffort(mode).DefaultProjectionKind, request.HasRepository)); + var rerouted = _recipes.RecipeForEffort(admitted); - return BuildPlan(decision, wasAutoClassified, effortMode, effectiveRecipe, effectiveProjection, preset, caps, needsConfirmCard, confirm, degradedReason); + // No tier the policy admits grades the floor either: leave the route as classified — the launch refuses it by name. + if (!GradesOperatorFloor(rerouted.DefaultProjectionKind, request.HasRepository)) return (effortMode, recipe, null); + + return (admitted, rerouted, $"the operator's acceptance check needs a route that grades it in this workspace, and '{recipe.DefaultProjectionKind}' does not; moved from {effortMode} to {admitted} ('{rerouted.DefaultProjectionKind}')"); } + /// Whether 's builder grades an operator command in a workspace that does (or does not) bind a repository — the same route + workspace verdict the route preview shows and the launch's floor and tier dispositions read. + private bool GradesOperatorFloor(string projectionKind, bool hasRepository) => + _projections.TryResolve(projectionKind, out var builder) && builder.OperatorAcceptance.GradesCommand(hasRepository); + + /// Every reason the route moved off what was asked for, in the order the moves happened — each is named, none is dropped. + private static string? JoinReasons(string? first, string? second) => + first is null ? second : second is null ? first : $"{first}; {second}"; + /// /// When the resolved recipe DECLARES a required capability (ITaskRecipe.RequiresCapability) that the /// probe registry reports unavailable, DEGRADE to the recipe's fallback (DegradesToRecipe, else the @@ -205,10 +246,10 @@ private static string TightenCeiling(string baseCeiling, string? overrideCeiling return tightened.ToString(); } - /// The confirm card — one option per AVAILABLE bounds preset (= available effort tier), DERIVED from the registry (no hardcoded button list). The operator's answer re-enters RouteAsync as RequestedEffort. - private ConfirmCard BuildConfirmCard(EffortDecision decision) => new() + /// The confirm card — one option per AVAILABLE bounds preset (= available effort tier), DERIVED from the registry (no hardcoded button list). The operator's answer re-enters RouteAsync as RequestedEffort, so the card pre-selects the tier this route actually took: after an operator-floor reroute that is the rerouted tier, never the one the classifier suggested and the floor set aside — confirming that one would send the run to a lane that cannot grade the check. + private ConfirmCard BuildConfirmCard(EffortDecision decision, string effortMode) => new() { - SuggestedMode = decision.SuggestedEffort, + SuggestedMode = effortMode, Rationale = decision.Rationale, Options = _bounds.All.Select(ToOption).ToList(), }; @@ -244,7 +285,7 @@ private static string BuildHint(RouteCaps caps) => NeedsPlanReview = recipe.RequiresPlanReview, WasAutoClassified = wasAutoClassified, ClassifierConfidence = decision.Confidence, - DegradedReason = degradedReason, // set (non-null) when a capability degrade fired, null otherwise — never silent + DegradedReason = degradedReason, // set (non-null) when the route moved — an operator floor it could not grade, a capability degrade — null otherwise; never silent Decision = decision, Confirm = confirm, }; diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/Exceptions/TaskLaunchControlRefusedException.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/Exceptions/TaskLaunchControlRefusedException.cs new file mode 100644 index 000000000..8d49e867b --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/Exceptions/TaskLaunchControlRefusedException.cs @@ -0,0 +1,20 @@ +using CodeSpace.Messages.Failures; +using CodeSpace.Messages.Tasks; + +namespace CodeSpace.Core.Services.Tasks.Launch.Exceptions; + +/// +/// The resolved route cannot honour a launch control the operator set, so the launch stops before any session or run +/// exists rather than run without it. Carries each refused control with its reason — the same dispositions the route +/// preview reports for this input — so every caller can say what to change. +/// +public sealed class TaskLaunchControlRefusedException : Exception, IFailure +{ + private readonly IReadOnlyList _refused; + + public TaskLaunchControlRefusedException(IReadOnlyList refused) : base(string.Join(" ", refused.Select(d => $"{d.Control}: {d.Reason}"))) { _refused = refused; } + + public FailureKind Kind => FailureKind.Unprocessable; + public string Code => FailureCodes.TaskLaunchControlRefused; + public IReadOnlyDictionary Details => new Dictionary { ["controls"] = _refused }; +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchControlResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchControlResolver.cs new file mode 100644 index 000000000..9a3b0b23e --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchControlResolver.cs @@ -0,0 +1,13 @@ +using CodeSpace.Messages.Tasks; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// +/// What a resolved route does with each operator control whose effect depends on the route. ONE method for both callers: +/// the launch refuses on a Refused disposition and applies the model clamp, and the route preview reports the same +/// dispositions and the same acceptance verdict — so the preview can never state what the launch would not do. +/// +public interface ILaunchControlResolver +{ + Task ResolveAsync(TaskLaunchRequest request, TaskLaunchSeed seed, RoutePlan route, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchGroundingResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchGroundingResolver.cs new file mode 100644 index 000000000..624d82835 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/ILaunchGroundingResolver.cs @@ -0,0 +1,14 @@ +using CodeSpace.Messages.Tasks; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// +/// The grounding a launch is primed with — shared by the launch and its route preview, because the route is classified +/// on it too: a follow-up turn has to reach the classifier AS a follow-up, which it cannot when the prior-turn digest is +/// only composed after routing. +/// +public interface ILaunchGroundingResolver +{ + /// On a CONTINUE, the session's prior-turn digest composed over any seed grounding; on a fresh launch, only the seed's own grounding (null for chat). + Task ResolveAsync(TaskLaunchRequest request, TaskLaunchSeed seed, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchControlResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchControlResolver.cs new file mode 100644 index 000000000..aef921898 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchControlResolver.cs @@ -0,0 +1,214 @@ +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Core.Services.Tasks.Projection; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Agents.Benchmark; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// +/// Default . Each control's disposition follows what the route's lane does with it: +/// the supervisor lane bakes every one; a plan-map lane plans and dispatches inside the model pool, confirms and reviews +/// its plan, and gates the launch's persona; the single-agent lane holds its agent to the model pool, grades the floor +/// and gates the persona. A lane that does not consume a control names it NotApplicable with the reason, never drops it. +/// The operator floor and the tier's mandate both derive from the acceptance verdict the route preview shows: the route +/// builder's OperatorAcceptance advertisement, read against whether this launch binds a repository. +/// +public sealed class LaunchControlResolver : ILaunchControlResolver, IScopedDependency +{ + /// Why a route whose builder does not grade an operator command refuses one — the plan-map verdict the route preview shows word for word. + public const string FloorNotGradedReason = "The resolved route does not consume an operator-command acceptance floor. Its plan items use separate acceptance contracts."; + + /// Why a floor no builder would bake is refused — a blank executable, or an argument that is missing or carries a NUL. + public const string MalformedFloorReason = "The acceptance argv is malformed — its executable is blank, or an argument is missing (null) or contains a NUL character — so nothing can run it."; + + /// Why a route that grades its floor only against a repository refuses one on a launch that binds none — the verdict the route preview shows word for word. + public const string FloorNeedsRepositoryReason = "This route grades an acceptance check against the branch it publishes, so it needs a repository: " + RepositoryRemedies; + + /// The remedies that let a route grade a floor it cannot grade without a repository — one wording for the floor's refusal and the tier's. + private const string RepositoryRemedies = "bind a repository, or launch at Quick effort, whose agent runs the check in its own workspace."; + + private readonly ITaskProjectionRegistry _projections; + private readonly IModelPoolSelector _models; + private readonly CodeSpaceDbContext _db; + + public LaunchControlResolver(ITaskProjectionRegistry projections, IModelPoolSelector models, CodeSpaceDbContext db) + { + _projections = projections; + _models = models; + _db = db; + } + + public async Task ResolveAsync(TaskLaunchRequest request, TaskLaunchSeed seed, RoutePlan route, CancellationToken cancellationToken) + { + var compatibility = DescribeAcceptance(route, (request.RepositoryId ?? seed.RepositoryId) is not null); + var (modelPool, clamp) = await DisposeModelPoolAsync(request, route, cancellationToken).ConfigureAwait(false); + var agentPool = await DisposeAgentPoolAsync(request, route, cancellationToken).ConfigureAwait(false); + + var dispositions = new[] + { + modelPool, + agentPool, + DisposeOperatorFloor(request, compatibility), + DisposeTier(request, compatibility), + SupervisorOnly(LaunchControls.DecisionReviewMode, request.DecisionReviewMode != ReviewMode.None, route, "Only the supervisor lane reviews decisions."), + SupervisorOnly(LaunchControls.DeliverySpec, request.DeliverySpec is not null, route, "Only the supervisor lane carries a delivery spec."), + SupervisorOnly(LaunchControls.IntegrateBranches, request.Overrides.IntegrateBranches is not null, route, "Only the supervisor lane reads this flag: a plan-map fan-out always integrates a repo-bound run, and a single agent has nothing to integrate."), + PlanningOnly(LaunchControls.RequirePlanConfirmation, request.RequirePlanConfirmation == true, route, "This route authors no plan to confirm."), + PlanningOnly(LaunchControls.PlannerReviewMode, request.PlannerReviewMode != ReviewMode.None, route, "This route authors no plan to review."), + }; + + return new LaunchControlResolution(dispositions.OfType().ToList(), compatibility, clamp); + } + + /// + /// Whether an acceptance argv is one a builder will actually bake: a non-blank executable and no argument carrying a + /// NUL — the rule every builder applies before it grades a floor. A malformed floor counts as ABSENT for the tier's + /// mandate and the auto reroute (it can grade nothing) and is refused by name. The shared argv normaliser being + /// extracted in parallel (AcceptanceArgv.NormalizeOrNull) replaces this. + /// + internal static bool IsWellFormedFloor(IReadOnlyList? argv) => + AcceptanceArgv.NormalizeOrNull(argv) is not null; + + /// + /// The route + workspace's operator-command acceptance verdict — the one the route preview shows and the floor's and + /// the tier's dispositions derive from: unknown for a builder that advertises no adapter, incompatible for one that + /// grades no operator command (plan-map), and incompatible for an argv adapter that grades only against a repository + /// (the supervisor) on a launch that binds none. The single agent grades in its own workspace, repository or not. + /// + private TaskAcceptanceCompatibility DescribeAcceptance(RoutePlan route, bool hasRepository) + { + var unknown = new TaskAcceptanceCompatibility { ProjectionKind = route.ProjectionKind, Detail = "The resolved route has not advertised an operator-command acceptance adapter; compatibility is unknown." }; + if (!_projections.TryResolve(route.ProjectionKind, out var builder) || builder.OperatorAcceptance.AcceptsCommand is null) return unknown; + var adapter = builder.OperatorAcceptance; + if (adapter.AcceptsCommand == false) return unknown with { State = TaskAcceptanceCompatibilityState.Incompatible, Detail = FloorNotGradedReason }; + if (adapter.GradingKind != BenchmarkGradingKind.TestsPass) return unknown with { Detail = "The resolved acceptance adapter does not advertise the proposed argv input format." }; + var compatible = adapter.GradesCommand(hasRepository); + return new TaskAcceptanceCompatibility + { + ProjectionKind = route.ProjectionKind, GradingKind = adapter.GradingKind.ToString(), RequiresRepository = adapter.RequiresRepository, + State = compatible ? TaskAcceptanceCompatibilityState.Compatible : TaskAcceptanceCompatibilityState.Incompatible, + Detail = compatible ? "The resolved route and workspace support this acceptance input. Runtime prerequisites and the check result still require independent execution." : FloorNeedsRepositoryReason, + }; + } + + /// + /// The allowed model pool. The supervisor gates every spawn inside it. Plan-map and single-agent hold every agent to it + /// at dispatch; an operator-PINNED model is run through the same choice the dispatch + /// makes, so a pin that has to move is reported Clamped here — and on the single-agent lane, whose one agent IS the + /// pin, returned for the launch to bake, so the frozen agent config shows the row that runs. Refused when nothing in + /// the pool can run. + /// + private async Task<(LaunchControlDisposition? Disposition, PooledModelRow? Clamp)> DisposeModelPoolAsync(TaskLaunchRequest request, RoutePlan route, CancellationToken cancellationToken) + { + if (request.AllowedModelIds is not { Count: > 0 } pool) return (null, null); + if (IsSupervisor(route)) return (Applied(LaunchControls.AllowedModelIds), null); + if (!IsPlanMap(route) && !IsSingleAgent(route)) return (NotApplicable(LaunchControls.AllowedModelIds, $"The '{route.ProjectionKind}' route does not hold its agents to a model pool."), null); + + var pin = new ModelPin(request.Overrides.ModelCredentialModelId, request.Overrides.Model, request.Overrides.ModelCredentialId); + if (pin is { RowId: null, CredentialId: null } && string.IsNullOrWhiteSpace(pin.Model)) return (Applied(LaunchControls.AllowedModelIds, HeldAtDispatchReason(route)), null); + + var choice = AllowedModelPool.Choose(pool, await _models.ListPoolRowsAsync(request.TeamId, AllowedModelPool.RowsToRead(pool, pin), cancellationToken).ConfigureAwait(false), pin); + + if (choice.Row is null) return (Refused(LaunchControls.AllowedModelIds, choice.Note ?? AllowedModelPool.NothingRunsNote), null); + if (choice.Note is null) return (Applied(LaunchControls.AllowedModelIds, HeldAtDispatchReason(route)), null); + + return (Clamped(LaunchControls.AllowedModelIds, choice.Note), IsSingleAgent(route) ? choice.Row : null); + } + + private static string HeldAtDispatchReason(RoutePlan route) => IsSingleAgent(route) + ? "The agent's model is held to the pool at dispatch; a model outside it runs the pool's default instead." + : "The planner is offered only these models, and every branch is held to them at dispatch; a model outside them runs the pool's default instead."; + + /// + /// The allowed persona pool. The supervisor gates every spawn inside it. Neither plan-map nor single-agent authors a + /// persona, so the launch's own persona is the only one any agent there runs as, and the pool is its gate: pooled ⇒ + /// applied, excluded ⇒ refused (the operator excluded it), none named ⇒ not applicable. + /// + private async Task DisposeAgentPoolAsync(TaskLaunchRequest request, RoutePlan route, CancellationToken cancellationToken) + { + if (request.AllowedAgentDefinitionIds is not { Count: > 0 } pool) return null; + if (IsSupervisor(route)) return Applied(LaunchControls.AllowedAgentDefinitionIds); + if (!IsPlanMap(route) && !IsSingleAgent(route)) return NotApplicable(LaunchControls.AllowedAgentDefinitionIds, $"The '{route.ProjectionKind}' route does not dispatch personas from a pool."); + + if (request.Overrides.AgentDefinitionId is not { } persona) + return NotApplicable(LaunchControls.AllowedAgentDefinitionIds, IsPlanMap(route) ? "Plan-map branches carry no persona: the launch names none, and the planner never assigns one." : "The agent carries no persona: the launch names none."); + + if (pool.Contains(persona)) return Applied(LaunchControls.AllowedAgentDefinitionIds); + + return Refused(LaunchControls.AllowedAgentDefinitionIds, $"{await PersonaSubjectAsync(request.TeamId, persona, cancellationToken).ConfigureAwait(false)} is not in the allowed agent pool, and every agent on this route runs as it."); + } + + /// The launch's persona as the operator knows it — by name, the way a model note names a model, never by id. A persona this team cannot see stays unnamed. + private async Task PersonaSubjectAsync(Guid teamId, Guid persona, CancellationToken cancellationToken) + { + var name = await _db.AgentDefinition.AsNoTracking() + .Where(a => a.Id == persona && a.TeamId == teamId && a.Scope == DefinitionScope.Working && a.DeletedDate == null) + .Select(a => a.Name) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + return name is null ? "The launch's persona" : $"The launch's persona '{name}'"; + } + + /// The operator's executable acceptance floor, derived from the route + workspace's acceptance verdict: applied where it is compatible, refused — with the verdict's own words — where it is not, and refused by name when no builder would bake the argv at all. + private static LaunchControlDisposition? DisposeOperatorFloor(TaskLaunchRequest request, TaskAcceptanceCompatibility compatibility) + { + if (request.AcceptanceChecks is not { Count: > 0 } argv) return null; + if (!IsWellFormedFloor(argv)) return Refused(LaunchControls.AcceptanceChecks, MalformedFloorReason); + + return compatibility.State == TaskAcceptanceCompatibilityState.Compatible ? Applied(LaunchControls.AcceptanceChecks) : Refused(LaunchControls.AcceptanceChecks, compatibility.Detail); + } + + /// + /// The quality tier's floor mandate (P3.2), read off the same route + workspace verdict as the floor: Delivery / + /// Unattended where this launch's workspace lets the route grade an operator floor — the supervisor's terminal stop + /// with a repository, the single agent's own oracle anywhere — must carry a well-formed one, or a caller could claim + /// Delivery-grade verification while skipping the one lever that gates the result; there is no sensible + /// server-authored argv to default to. Where the route grades a floor only against a repository this launch does not + /// bind, authoring a check cannot satisfy the mandate, so the refusal never says "author a check". With a floor + /// carried, the remedies are the ones that let it be graded — bind a repository, or launch at Quick effort — in the + /// same words the floor's own refusal uses (Prototype would drop the mandate but leave that floor refused); with none, + /// the remedy is to bind a repository or step down to Prototype. A plan-map route grades none (its items carry their own + /// contracts), so nothing is asked of it; the tier's output-review floor applies on every route. Refused with the same + /// code as every refusal, so the preview predicts it too. + /// + private static LaunchControlDisposition? DisposeTier(TaskLaunchRequest request, TaskAcceptanceCompatibility compatibility) + { + if (request.Tier is not { } tier || tier is not (QualityTier.Delivery or QualityTier.Unattended)) return null; + if (NeedsRepository(compatibility)) return Refused(LaunchControls.Tier, request.AcceptanceChecks is { Count: > 0 } ? $"{tier} quality needs an acceptance check this route can grade, and it grades one only against a repository: {RepositoryRemedies}" : $"{tier} quality needs an acceptance check this route can grade, and it grades one only against a repository: bind a repository, or launch at Prototype quality for a self-reported result."); + if (compatibility.State != TaskAcceptanceCompatibilityState.Compatible) return Applied(LaunchControls.Tier, "This route grades no operator floor, so the tier asks it for none; the tier's output-review floor still applies."); + if (IsWellFormedFloor(request.AcceptanceChecks)) return Applied(LaunchControls.Tier); + + return Refused(LaunchControls.Tier, $"{tier} quality requires an executable acceptance check (acceptanceChecks) — author one, or launch at Prototype quality for a self-reported result."); + } + + /// The route grades an operator floor, but only against a repository this launch does not bind. + private static bool NeedsRepository(TaskAcceptanceCompatibility compatibility) => compatibility is { State: TaskAcceptanceCompatibilityState.Incompatible, RequiresRepository: true }; + + /// A control only the supervisor lane consumes — applied there, not applicable everywhere else. + private static LaunchControlDisposition? SupervisorOnly(string control, bool requested, RoutePlan route, string reason) => + !requested ? null : IsSupervisor(route) ? Applied(control) : NotApplicable(control, reason); + + /// A control over an authored plan — applied on the lanes that author one (supervisor, plan-map), not applicable on the rest. + private static LaunchControlDisposition? PlanningOnly(string control, bool requested, RoutePlan route, string reason) => + !requested ? null : IsSupervisor(route) || IsPlanMap(route) ? Applied(control) : NotApplicable(control, reason); + + private static bool IsSupervisor(RoutePlan route) => route.ProjectionKind == TaskProjectionKinds.Supervisor; + + private static bool IsPlanMap(RoutePlan route) => route.ProjectionKind is TaskProjectionKinds.PlanMapSynth or TaskProjectionKinds.PlanMapDynamic; + + private static bool IsSingleAgent(RoutePlan route) => route.ProjectionKind == TaskProjectionKinds.SingleAgent; + + private static LaunchControlDisposition Applied(string control, string? reason = null) => new() { Control = control, Outcome = LaunchControlOutcome.Applied, Reason = reason }; + + private static LaunchControlDisposition Clamped(string control, string reason) => new() { Control = control, Outcome = LaunchControlOutcome.Clamped, Reason = reason }; + + private static LaunchControlDisposition NotApplicable(string control, string reason) => new() { Control = control, Outcome = LaunchControlOutcome.NotApplicable, Reason = reason }; + + private static LaunchControlDisposition Refused(string control, string reason) => new() { Control = control, Outcome = LaunchControlOutcome.Refused, Reason = reason }; +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchGroundingResolver.cs b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchGroundingResolver.cs new file mode 100644 index 000000000..2a52de616 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Tasks/Launch/LaunchGroundingResolver.cs @@ -0,0 +1,40 @@ +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Services.Sessions; +using CodeSpace.Messages.Tasks; + +namespace CodeSpace.Core.Services.Tasks.Launch; + +/// Default — the thread's rolling summary brought up to date, then its prior-turn digest composed over the seed's own grounding. +public sealed class LaunchGroundingResolver : ILaunchGroundingResolver, IScopedDependency +{ + private readonly ISessionContextBuilder _sessionContext; + private readonly ISessionSummarizer _sessionSummarizer; + + public LaunchGroundingResolver(ISessionContextBuilder sessionContext, ISessionSummarizer sessionSummarizer) + { + _sessionContext = sessionContext; + _sessionSummarizer = sessionSummarizer; + } + + public async Task ResolveAsync(TaskLaunchRequest request, TaskLaunchSeed seed, CancellationToken cancellationToken) + { + if (request.ContinueSessionId is not { } sessionId) return seed.GroundingContext; + + // Fold any turns that scrolled out of the recent window into the thread's rolling summary BEFORE building the + // digest, so a long thread's early context is preserved. Best-effort + fail-open (no model / error leaves it). + await _sessionSummarizer.EnsureSummaryUpToDateAsync(sessionId, request.TeamId, cancellationToken).ConfigureAwait(false); + + var priorTurns = await _sessionContext.BuildAsync(sessionId, request.TeamId, cancellationToken).ConfigureAwait(false); + + return ComposeGrounding(priorTurns, seed.GroundingContext); + } + + /// Join the prior-turn digest and the seed's own grounding (either may be absent) into one block, digest first. + private static string? ComposeGrounding(string? priorTurns, string? seedGrounding) + { + if (string.IsNullOrWhiteSpace(priorTurns)) return seedGrounding; + if (string.IsNullOrWhiteSpace(seedGrounding)) return priorTurns; + + return $"{priorTurns}\n\n{seedGrounding}"; + } +} 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 e3fabfd00..23fccf190 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/AgentNodeMapping.cs @@ -110,12 +110,17 @@ public static JsonElement BuildAgentConfig(string goal, ResolvedAgentProfile? pr } /// Add the route-owned monitored cost ceiling without widening the already broad profile-mapping signature. Null leaves the serialized config byte-identical. - public static JsonElement WithCostCap(JsonElement config, decimal? maxCostUsd) - { - if (maxCostUsd is null) return config; + public static JsonElement WithCostCap(JsonElement config, decimal? maxCostUsd) => + maxCostUsd is null ? config : WithKey(config, "maxCostUsd", JsonSerializer.SerializeToElement(maxCostUsd.Value)); + + /// Hold the agent's model to the operator's allowed pool (credentialed-model row ids, as string uuids — the shape the supervisor node bakes): the node carries it onto AgentTask.AllowedModelIds, where dispatch runs the model on a pooled row. Null / empty leaves the config byte-identical (the whole team pool). + public static JsonElement WithAllowedModels(JsonElement config, IReadOnlyList? allowedModelIds) => + allowedModelIds is not { Count: > 0 } pool ? config : WithKey(config, "allowedModelIds", JsonSerializer.SerializeToElement(pool.Select(id => id.ToString()).ToList())); + private static JsonElement WithKey(JsonElement config, string key, JsonElement value) + { var mapped = config.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); - mapped["maxCostUsd"] = JsonSerializer.SerializeToElement(maxCostUsd.Value); + mapped[key] = value; return JsonSerializer.SerializeToElement(mapped); } 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 66cd12b27..c087e8d5a 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 @@ -88,8 +88,10 @@ private IReadOnlyList BuildNodes(TaskBuildContext context) new() { Id = "ms", TypeKey = "flow.map_start", Label = "Subtask", ParentId = "map", Config = Empty(), Inputs = Empty() }, + // The operator's allowed model pool rides every branch, so a planner-authored {{item.model}} outside it runs + // the pool's default row at dispatch instead of escaping the pool. new() { Id = "agent", TypeKey = "agent.run", Label = "Work the subtask", ParentId = "map", Retry = AgentNodeMapping.DefaultRetry, - Config = AgentNodeMapping.BuildAgentConfig(BranchGoal, context.AgentProfile, BranchMode, grounding: context.GroundingContext, acceptance: "{{item.acceptance}}", fallbackModel: BranchModel), Inputs = AgentNodeMapping.BuildAgentInputs(context) }, + Config = AgentNodeMapping.WithAllowedModels(AgentNodeMapping.BuildAgentConfig(BranchGoal, context.AgentProfile, BranchMode, grounding: context.GroundingContext, acceptance: "{{item.acceptance}}", fallbackModel: BranchModel), context.AllowedModelIds), Inputs = AgentNodeMapping.BuildAgentInputs(context) }, }); // P4 (the plan-map integrated candidate): a repo-bound fan-out integrates its produced work into ONE @@ -136,7 +138,7 @@ private static IReadOnlyList BuildEdges(TaskBuildContext context return edges; } - /// The plan.author Config — always a FLAT plan (the parallel map cannot honor ordering), plus the launch's pinned planner model row + the operator's planner critic (reviewMode / reviewerModelId, omitted when off — byte-identical). + /// The plan.author Config — always a FLAT plan (the parallel map cannot honor ordering), plus the launch's pinned planner model row, the operator's allowed model pool (the catalog the planner allocates subtasks from; omitted when unbounded) + the operator's planner critic (reviewMode / reviewerModelId, omitted when off — byte-identical). private static JsonElement PlannerConfig(TaskBuildContext context) { var config = new Dictionary @@ -145,6 +147,7 @@ private static JsonElement PlannerConfig(TaskBuildContext context) }; AddIfPresent(config, "plannerModelId", context.PlannerModelRowId?.ToString()); + AddIfPresent(config, "allowedModelIds", context.AllowedModelIds is { Count: > 0 } pool ? pool.Select(id => id.ToString()).ToList() : null); AddIfPresent(config, "reviewMode", context.PlannerReviewMode != ReviewMode.None ? (int)context.PlannerReviewMode : null); AddIfPresent(config, "reviewerModelId", context.PlannerReviewMode != ReviewMode.None ? context.ReviewerModelId?.ToString() : null); // D① grounded plan review — a real read-only agent verifies the plan against the bound repository's tree. diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/SingleAgent/SingleAgentDefinitionBuilder.cs b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/SingleAgent/SingleAgentDefinitionBuilder.cs index f047fec74..287835473 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/SingleAgent/SingleAgentDefinitionBuilder.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/SingleAgent/SingleAgentDefinitionBuilder.cs @@ -24,7 +24,9 @@ namespace CodeSpace.Core.Services.Tasks.Projection.Builders.SingleAgent; public sealed class SingleAgentDefinitionBuilder : IWorkflowDefinitionBuilder, ISingletonDependency { public string ProjectionKind => TaskProjectionKinds.SingleAgent; - public TaskProjectionAcceptanceContract OperatorAcceptance => new(true, Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass); + + /// The agent's own oracle grades the operator's argv in the workspace the agent ran in — a repository-free scratch workspace included (the executor's local acceptance verifier), so the floor needs no repository here. + public TaskProjectionAcceptanceContract OperatorAcceptance => new(true, Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass, RequiresRepository: false); /// The repo-relative file a shape-derived contract grades. One conventional path, named in the goal, so an answer / report / findings run has a file the oracle can actually read. public const string DeliverableFileName = "DELIVERABLE.md"; @@ -92,10 +94,12 @@ private static IReadOnlyList RubricCriteria(TaskBuildContext context) // staked requirement rows never credit the operator with a shape-derived contract the server composed. // B2: the SHAPE decides the agent's mode (a question is not a coding run) and, absent an operator floor, // which oracle grades it — a code-shaped launch with no floor stays byte-identical (no mode, no oracle). - Config = AgentNodeMapping.WithCostCap(AgentNodeMapping.BuildAgentConfig(context.Seed.Goal, context.AgentProfile, mode: DeliverableShapes.AgentModeFor(context.Route.DeliverableShape), + // The operator's allowed model pool rides the node too, so the one agent is held to it at dispatch — the + // launch already clamped a pinned model outside it, and this covers the model a persona supplies. + Config = AgentNodeMapping.WithAllowedModels(AgentNodeMapping.WithCostCap(AgentNodeMapping.BuildAgentConfig(context.Seed.Goal, context.AgentProfile, mode: DeliverableShapes.AgentModeFor(context.Route.DeliverableShape), grounding: context.GroundingContext, acceptance: QuickAcceptance(context), criteria: context.AcceptanceCriteria, acceptanceAuthority: OperatorArgv(context) is null ? null : nameof(Messages.Contracts.ContractAuthority.Operator), - deliverablePath: DeliverablePath(context)), context.Route.Caps.MaxCostUsd), Inputs = AgentNodeMapping.BuildAgentInputs(context) }, + deliverablePath: DeliverablePath(context)), context.Route.Caps.MaxCostUsd), context.AllowedModelIds), Inputs = AgentNodeMapping.BuildAgentInputs(context) }, new() { Id = "done", TypeKey = "builtin.terminal", Label = "Done", Config = Empty(), Inputs = TerminalInputs(IsMultiRepo(context)) }, diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/Supervisor/SupervisorDefinitionBuilder.cs b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/Supervisor/SupervisorDefinitionBuilder.cs index 12f516641..a3ccb689f 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/Supervisor/SupervisorDefinitionBuilder.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/Builders/Supervisor/SupervisorDefinitionBuilder.cs @@ -54,7 +54,9 @@ public sealed class SupervisorDefinitionBuilder : IWorkflowDefinitionBuilder, IS public static int SynthesisPromptBudgetChars => SupervisorSynthesisBudget.Normalize(int.TryParse(Environment.GetEnvironmentVariable(SynthesisPromptBudgetCharsEnvVar), out var value) ? value : null); public string ProjectionKind => TaskProjectionKinds.Supervisor; - public TaskProjectionAcceptanceContract OperatorAcceptance => new(true, Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass); + + /// The terminal stop grades the operator's argv against the branch the run publishes; a branchless stop leaves the floor ungraded, so a repository-free workspace cannot carry one here. + public TaskProjectionAcceptanceContract OperatorAcceptance => new(true, Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass, RequiresRepository: true); public WorkflowDefinition Build(TaskBuildContext context) => new() { diff --git a/backend/src/CodeSpace.Core/Services/Tasks/Projection/IWorkflowDefinitionBuilder.cs b/backend/src/CodeSpace.Core/Services/Tasks/Projection/IWorkflowDefinitionBuilder.cs index 09824d199..fb989eb43 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/Projection/IWorkflowDefinitionBuilder.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/Projection/IWorkflowDefinitionBuilder.cs @@ -28,4 +28,15 @@ public interface IWorkflowDefinitionBuilder WorkflowDefinition Build(TaskBuildContext context); } -public sealed record TaskProjectionAcceptanceContract(bool? AcceptsCommand, BenchmarkGradingKind? GradingKind); +/// +/// The operator-command adapter a builder emits. says whether it can grade an argv +/// floor only against a repository: the supervisor grades its terminal stop against the branch it publishes, so a +/// repository-free run leaves the floor ungraded, while a single agent's own oracle runs in whatever workspace the agent +/// ran in, a repository-free scratch one included. Defaults to requiring one, so an adapter that never says so is never +/// trusted to grade a floor it may not be able to run. +/// +public sealed record TaskProjectionAcceptanceContract(bool? AcceptsCommand, BenchmarkGradingKind? GradingKind, bool RequiresRepository = true) +{ + /// Whether this adapter grades an operator argv floor in a workspace that does, or does not, bind a repository — the one test the floor's disposition, the tier's mandate and an auto route's floor rule all apply. + public bool GradesCommand(bool hasRepository) => AcceptsCommand == true && GradingKind == BenchmarkGradingKind.TestsPass && (hasRepository || !RequiresRepository); +} diff --git a/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRoutePreviewService.cs b/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRoutePreviewService.cs index d84e2b3a3..83b452ad5 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRoutePreviewService.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRoutePreviewService.cs @@ -1,30 +1,31 @@ using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Services.Completion; using CodeSpace.Core.Services.Tasks.Launch; -using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Core.Services.Agents; using CodeSpace.Messages.Agents; -using CodeSpace.Messages.Agents.Benchmark; using CodeSpace.Messages.Tasks; namespace CodeSpace.Core.Services.Tasks.RoutePreview; -/// Resolve the seed and validate repository scope, then persist the routing decision without opening a session or staging a run. +/// Resolve the seed and validate repository, model-pool and persona-pool scope — the launch's own checks — then persist the routing decision without opening a session or staging a run. public sealed class TaskRoutePreviewService : ITaskRoutePreviewService, IScopedDependency { private readonly ITaskLaunchSeedProviderRegistry _seedProviders; private readonly ILaunchRepositoryScopeGuard _repositoryScope; private readonly ITaskRouteSnapshotService _snapshots; - private readonly ITaskProjectionRegistry _projections; private readonly IModeProfileRegistry _modeProfiles; + private readonly ILaunchControlResolver _controls; + private readonly CodeSpaceDbContext _db; - public TaskRoutePreviewService(ITaskLaunchSeedProviderRegistry seedProviders, ILaunchRepositoryScopeGuard repositoryScope, ITaskRouteSnapshotService snapshots, ITaskProjectionRegistry projections, IModeProfileRegistry modeProfiles) + public TaskRoutePreviewService(ITaskLaunchSeedProviderRegistry seedProviders, ILaunchRepositoryScopeGuard repositoryScope, ITaskRouteSnapshotService snapshots, IModeProfileRegistry modeProfiles, ILaunchControlResolver controls, CodeSpaceDbContext db) { _seedProviders = seedProviders; _repositoryScope = repositoryScope; _snapshots = snapshots; - _projections = projections; _modeProfiles = modeProfiles; + _controls = controls; + _db = db; } public async Task PreviewAsync(TaskLaunchRequest request, CancellationToken cancellationToken) @@ -33,12 +34,23 @@ public async Task PreviewAsync(TaskLaunchRequest request await _repositoryScope.EnsureInTeamAsync(seed, request, cancellationToken).ConfigureAwait(false); + // The launch's own pool checks, in the launch's order: a pooled row disabled (or a persona deleted) after the + // composer loaded is the same not-found here as at launch — never an Applied preview over a launch that 404s. + await TaskLaunchService.EnsureModelsInTeamAsync(_db, request, cancellationToken).ConfigureAwait(false); + + await TaskLaunchService.EnsureAgentDefinitionsInTeamAsync(_db, request, cancellationToken).ConfigureAwait(false); + var preview = await _snapshots.CreateAsync(request, seed, cancellationToken).ConfigureAwait(false); + // The SAME resolution the launch refuses and clamps on — its acceptance verdict included — so a preview cannot + // report a disposition, or a compatibility, the launch would not reach. + var controls = await _controls.ResolveAsync(request, seed, preview.Route, cancellationToken).ConfigureAwait(false); + return preview with { - AcceptanceCompatibility = DescribeAcceptance(preview.Route, request.RepositoryId ?? seed.RepositoryId), + AcceptanceCompatibility = controls.AcceptanceCompatibility, Posture = DescribePosture(request, seed, preview.Route), + ControlDispositions = controls.Dispositions, }; } @@ -73,21 +85,4 @@ private TaskRoutePosture DescribePosture(TaskLaunchRequest request, TaskLaunchSe Completion = CompletionPolicy.Describe(completionMode), }; } - - private TaskAcceptanceCompatibility DescribeAcceptance(RoutePlan route, Guid? repositoryId) - { - var unknown = new TaskAcceptanceCompatibility { ProjectionKind = route.ProjectionKind, Detail = "The resolved route has not advertised an operator-command acceptance adapter; compatibility is unknown." }; - if (!_projections.TryResolve(route.ProjectionKind, out var builder) || builder.OperatorAcceptance.AcceptsCommand is null) return unknown; - var adapter = builder.OperatorAcceptance; - if (adapter.AcceptsCommand == false) return unknown with { State = TaskAcceptanceCompatibilityState.Incompatible, Detail = "The resolved route does not consume an operator-command acceptance floor. Its plan items use separate acceptance contracts." }; - if (adapter.GradingKind != BenchmarkGradingKind.TestsPass) return unknown with { Detail = "The resolved acceptance adapter does not advertise the proposed argv input format." }; - var requiresRepository = !AgentAcceptanceContract.GradesFromDeliverables(new SupervisorAcceptanceSpec { Kind = adapter.GradingKind, Command = [] }); - var compatible = !requiresRepository || repositoryId is not null; - return new TaskAcceptanceCompatibility - { - ProjectionKind = route.ProjectionKind, GradingKind = adapter.GradingKind.ToString(), RequiresRepository = requiresRepository, - State = compatible ? TaskAcceptanceCompatibilityState.Compatible : TaskAcceptanceCompatibilityState.Incompatible, - Detail = compatible ? "The resolved route and workspace support this acceptance input. Runtime prerequisites and the check result still require independent execution." : "This acceptance adapter requires a repository workspace for an argv check. Keep the proposal for review or use content criteria; no repository-free argv adapter is available on this route.", - }; - } } diff --git a/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRouteSnapshotService.cs b/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRouteSnapshotService.cs index 22a83a930..899f37644 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRouteSnapshotService.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/RoutePreview/TaskRouteSnapshotService.cs @@ -5,6 +5,7 @@ using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents; using CodeSpace.Core.Services.Tasks.Effort; +using CodeSpace.Core.Services.Tasks.Launch; using CodeSpace.Core.Services.Tasks.RoutePreview.Exceptions; using CodeSpace.Core.Services.Workflows; using CodeSpace.Messages.Commands.Tasks; @@ -20,14 +21,16 @@ public sealed class TaskRouteSnapshotService : ITaskRouteSnapshotService, IScope { public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); private readonly IEffortRouter _router; + private readonly ILaunchGroundingResolver _grounding; private readonly TaskRoutePolicyFingerprint _policy; private readonly CodeSpaceDbContext _db; private readonly IPostCommitActions _postCommit; private PendingCommitRecovery? _pendingCommitRecovery; - public TaskRouteSnapshotService(IEffortRouter router, TaskRoutePolicyFingerprint policy, CodeSpaceDbContext db, IPostCommitActions postCommit) + public TaskRouteSnapshotService(IEffortRouter router, ILaunchGroundingResolver grounding, TaskRoutePolicyFingerprint policy, CodeSpaceDbContext db, IPostCommitActions postCommit) { _router = router; + _grounding = grounding; _policy = policy; _db = db; _postCommit = postCommit; @@ -36,8 +39,13 @@ public TaskRouteSnapshotService(IEffortRouter router, TaskRoutePolicyFingerprint public async Task CreateAsync(TaskLaunchRequest request, TaskLaunchSeed seed, CancellationToken cancellationToken) { await ResolvePendingCommitAsync().ConfigureAwait(false); + + // The SAME grounding the launch resolves before it routes, so a follow-up turn is classified as one here too. It + // only feeds the router: the seed digest below still binds the provider's seed, as the launch's read validates it. + var grounding = await _grounding.ResolveAsync(request, seed, cancellationToken).ConfigureAwait(false); + var policy = _policy.Capture(); - var route = await _router.RouteAsync(TaskLaunchService.BuildRouteRequest(seed, request), cancellationToken).ConfigureAwait(false); + var route = await _router.RouteAsync(TaskLaunchService.BuildRouteRequest(seed with { GroundingContext = grounding }, request), cancellationToken).ConfigureAwait(false); if (policy != _policy.Capture()) throw new TaskRouteSnapshotMismatchException(); var now = await ReadDatabaseClockAsync(cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs index 1fa63862d..c029876d2 100644 --- a/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs +++ b/backend/src/CodeSpace.Core/Services/Tasks/TaskLaunchService.cs @@ -7,6 +7,7 @@ using CodeSpace.Core.Services.Workflows.Llm; using CodeSpace.Core.Services.Tasks.Effort; using CodeSpace.Core.Services.Tasks.Launch; +using CodeSpace.Core.Services.Tasks.Launch.Exceptions; using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Core.Services.Tasks.Contracts; using CodeSpace.Core.Services.Completion; @@ -25,7 +26,8 @@ namespace CodeSpace.Core.Services.Tasks; /// /// Default — a flat named-method pipeline (Rule 4/5): resolve the seed provider by -/// the open surface kind → seed → validate the repo TEAM-SCOPED (fail-closed) → route → build the agent profile → +/// the open surface kind → seed → validate the repo TEAM-SCOPED (fail-closed) → ground → route → resolve every +/// route-dependent control (applied / clamped / not applicable, or the launch is refused) → build the agent profile → /// project + start the snapshot run → return the handle + route. Holds no per-surface logic: the ONLY surface /// dispatch is _seedProviders.Resolve(surfaceKind), and the core NEVER reads the surface payload (only the /// resolved provider does), so a new surface plugs in by registering a provider with zero edit here (the generic @@ -39,8 +41,8 @@ public sealed class TaskLaunchService : ITaskLaunchService, IScopedDependency private readonly ITaskRouteSnapshotService _routeSnapshots; private readonly ITaskRunSnapshotFactory _factory; private readonly IWorkSessionService _sessions; - private readonly ISessionContextBuilder _sessionContext; - private readonly ISessionSummarizer _sessionSummarizer; + private readonly ILaunchGroundingResolver _grounding; + private readonly ILaunchControlResolver _controls; private readonly ISessionBranchResolver _sessionBranches; private readonly ILaunchBasePinResolver _basePins; private readonly IModelPoolSelector _modelSelector; @@ -48,7 +50,7 @@ public sealed class TaskLaunchService : ITaskLaunchService, IScopedDependency private readonly CodeSpaceDbContext _db; private readonly ILogger _logger; - public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, ILaunchRepositoryScopeGuard repositoryScope, IEffortRouter router, ITaskRouteSnapshotService routeSnapshots, ITaskRunSnapshotFactory factory, IWorkSessionService sessions, ISessionContextBuilder sessionContext, ISessionSummarizer sessionSummarizer, ISessionBranchResolver sessionBranches, ILaunchBasePinResolver basePins, IModelPoolSelector modelSelector, ILLMClientRegistry llm, CodeSpaceDbContext db, ILogger logger) + public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, ILaunchRepositoryScopeGuard repositoryScope, IEffortRouter router, ITaskRouteSnapshotService routeSnapshots, ITaskRunSnapshotFactory factory, IWorkSessionService sessions, ILaunchGroundingResolver grounding, ILaunchControlResolver controls, ISessionBranchResolver sessionBranches, ILaunchBasePinResolver basePins, IModelPoolSelector modelSelector, ILLMClientRegistry llm, CodeSpaceDbContext db, ILogger logger) { _seedProviders = seedProviders; _repositoryScope = repositoryScope; @@ -56,8 +58,8 @@ public TaskLaunchService(ITaskLaunchSeedProviderRegistry seedProviders, ILaunchR _routeSnapshots = routeSnapshots; _factory = factory; _sessions = sessions; - _sessionContext = sessionContext; - _sessionSummarizer = sessionSummarizer; + _grounding = grounding; + _controls = controls; _sessionBranches = sessionBranches; _basePins = basePins; _modelSelector = modelSelector; @@ -72,25 +74,33 @@ public async Task LaunchAsync(TaskLaunchRequest request, Cance await _repositoryScope.EnsureInTeamAsync(seed, request, cancellationToken).ConfigureAwait(false); - await EnsureModelsInTeamAsync(request, cancellationToken).ConfigureAwait(false); + await EnsureModelsInTeamAsync(_db, request, cancellationToken).ConfigureAwait(false); - await EnsureAgentDefinitionsInTeamAsync(request, cancellationToken).ConfigureAwait(false); + await EnsureAgentDefinitionsInTeamAsync(_db, request, cancellationToken).ConfigureAwait(false); var preview = request.RouteSnapshotId is not null ? await _routeSnapshots.ReadAsync(request, seed, cancellationToken).ConfigureAwait(false) : null; if (preview?.PreviousResult is { } previous) return previous; - var route = preview?.Route ?? await _router.RouteAsync(BuildRouteRequest(seed, request), cancellationToken).ConfigureAwait(false); - EnsureRouteConfirmed(route); + await EnsureSessionCanContinueAsync(request, cancellationToken).ConfigureAwait(false); - EnsureAcceptanceMandate(request, route); + // On a CONTINUE, prime the run with the thread's prior-turn digest — the projection folds this grounding into + // the agent's prompt so the follow-up builds on earlier work. Resolved BEFORE routing: the classifier reads the + // same grounding, and a follow-up it cannot see is classified as a fresh task. A fresh launch carries only the + // seed's own grounding (so its route request is unchanged). + var grounding = await _grounding.ResolveAsync(request, seed, cancellationToken).ConfigureAwait(false); - await EnsureSessionCanContinueAsync(request, cancellationToken).ConfigureAwait(false); + var route = preview?.Route ?? await _router.RouteAsync(BuildRouteRequest(seed with { GroundingContext = grounding }, request), cancellationToken).ConfigureAwait(false); - var profile = BuildAgentProfile(request, seed, route); + EnsureRouteConfirmed(route); - // On a CONTINUE, prime the run with the thread's prior-turn digest — the projection folds this grounding into - // the agent's prompt so the follow-up builds on earlier work. A fresh launch carries only the seed's own grounding. - var grounding = await ResolveGroundingAsync(request, seed, cancellationToken).ConfigureAwait(false); + // Every route-dependent control the operator set — the quality tier's floor mandate included — is applied, clamped, + // named as not applicable, or refused: the same resolution the route preview reports. A refusal stops the launch + // here, before any session or run exists. + var controls = await _controls.ResolveAsync(request, seed, route, cancellationToken).ConfigureAwait(false); + + EnsureControlsHonoured(controls); + + var profile = ClampAgentModel(BuildAgentProfile(request, seed, route), controls.ModelClamp); // …and clone EACH repo (primary + related) at the prior turn's produced branch for it, so the follow-up builds // on earlier CODE (not just the narrative). Empty on a fresh launch / no repo / no prior branch ⇒ default branches. @@ -115,10 +125,23 @@ public async Task LaunchAsync(TaskLaunchRequest request, Cance // Potential model/Git preparation above runs without holding the snapshot row lock. The callback below // performs only transactional session/run staging and is entered once across competing workers. return request.RouteSnapshotId is not null - ? await _routeSnapshots.ConsumeAsync(new TaskRouteSnapshotConsumption(request, seed, () => StageAsync(request, context, cancellationToken)), cancellationToken).ConfigureAwait(false) - : await StageAsync(request, context, cancellationToken).ConfigureAwait(false); + ? await _routeSnapshots.ConsumeAsync(new TaskRouteSnapshotConsumption(request, seed, () => StageAsync(request, context, controls.Dispositions, cancellationToken)), cancellationToken).ConfigureAwait(false) + : await StageAsync(request, context, controls.Dispositions, cancellationToken).ConfigureAwait(false); } + /// A control the resolved route refuses stops the launch before any session or run exists, named with its reason — never silently dropped. + private static void EnsureControlsHonoured(LaunchControlResolution controls) + { + var refused = controls.Dispositions.Where(d => d.Outcome == LaunchControlOutcome.Refused).ToList(); + + if (refused.Count > 0) + throw new TaskLaunchControlRefusedException(refused); + } + + /// The single agent's model when its pinned one had to move inside the allowed pool: the pooled ROW the dispatch will choose too — its model, its own credential, its row id — so the frozen agent config shows the row that actually runs. No clamp ⇒ the profile verbatim. + private static ResolvedAgentProfile ClampAgentModel(ResolvedAgentProfile profile, PooledModelRow? clamp) => + clamp is null ? profile : profile with { Model = clamp.ModelId, ModelCredentialId = clamp.ModelCredentialId, ModelCredentialModelId = clamp.Id }; + /// /// A low-confidence or risky auto route is a question, not authorization to execute. The router owns the generic /// decision and its registry-derived choices; this single launch chokepoint enforces it for HTTP, jobs and future @@ -138,7 +161,7 @@ private async Task EnsureSessionCanContinueAsync(TaskLaunchRequest request, Canc if (status != WorkSessionStatus.Open) throw new InvalidOperationException($"Session {id} is {status} and cannot take a new turn."); } - private async Task StageAsync(TaskLaunchRequest request, TaskBuildContext context, CancellationToken cancellationToken) + private async Task StageAsync(TaskLaunchRequest request, TaskBuildContext context, IReadOnlyList dispositions, CancellationToken cancellationToken) { var seed = context.Seed; var route = context.Route; @@ -171,6 +194,7 @@ private async Task StageAsync(TaskLaunchRequest request, TaskB Route = route, SurfaceKind = seed.SurfaceKind, LinkedEntity = seed.LinkedEntity, + ControlDispositions = dispositions, }; } @@ -245,29 +269,6 @@ private static string SelectionCapability(TaskLaunchRequest request, TaskLaunchS private sealed record StructuredBrainSelection(Guid RowId, bool PinIneligible, bool Pinned, ModelSelectionReceipt Receipt); - /// The grounding the run is primed with: on a CONTINUE, the session's prior-turn digest composed over any seed grounding; on a fresh launch, only the seed's own grounding (null for chat). The projection folds this into the agent prompt. - private async Task ResolveGroundingAsync(TaskLaunchRequest request, TaskLaunchSeed seed, CancellationToken cancellationToken) - { - if (request.ContinueSessionId is not { } sessionId) return seed.GroundingContext; - - // Fold any turns that scrolled out of the recent window into the thread's rolling summary BEFORE building the - // digest, so a long thread's early context is preserved. Best-effort + fail-open (no model / error leaves it). - await _sessionSummarizer.EnsureSummaryUpToDateAsync(sessionId, request.TeamId, cancellationToken).ConfigureAwait(false); - - var priorTurns = await _sessionContext.BuildAsync(sessionId, request.TeamId, cancellationToken).ConfigureAwait(false); - - return ComposeGrounding(priorTurns, seed.GroundingContext); - } - - /// Join the prior-turn digest and the seed's own grounding (either may be absent) into one block, digest first. - private static string? ComposeGrounding(string? priorTurns, string? seedGrounding) - { - if (string.IsNullOrWhiteSpace(priorTurns)) return seedGrounding; - if (string.IsNullOrWhiteSpace(seedGrounding)) return priorTurns; - - return $"{priorTurns}\n\n{seedGrounding}"; - } - /// On a CONTINUE, the prior turn's produced branch for EACH repo the run touches (primary + related) — the projection clones each repo's workspace at its own ref. Empty on a fresh launch, an analysis-only run (no repos), or when no prior turn produced a branch for any (⇒ default branches — the safe fallback). A repo absent from the map clones at its default. private async Task> ResolveBaseRefsAsync(TaskLaunchRequest request, TaskLaunchSeed seed, ResolvedAgentProfile profile, CancellationToken cancellationToken) { @@ -291,15 +292,16 @@ private async Task> ResolveBaseRefsAs /// A foreign / disabled / dead-credential row is an indistinguishable not-found (so a foreign row never leaks its /// existence), and ANY bad row fails the whole launch fail-closed BEFORE the session opens (no orphan). An empty /// pool ⇒ skip (all the team's models — byte-identical to no override). The dispatch-time pool guard still fails - /// closed too; this just turns a silent "agent can't run" into a clear launch-time rejection. + /// closed too; this just turns a silent "agent can't run" into a clear launch-time rejection. Internal + static so the + /// route PREVIEW runs the very same check and answers a pool the launch would reject the same way. /// - private async Task EnsureModelsInTeamAsync(TaskLaunchRequest request, CancellationToken cancellationToken) + internal static async Task EnsureModelsInTeamAsync(CodeSpaceDbContext db, TaskLaunchRequest request, CancellationToken cancellationToken) { if (request.AllowedModelIds is not { Count: > 0 } pool) return; var ids = pool.ToHashSet(); - var inTeam = await _db.ModelCredentialModel.AsNoTracking() + var inTeam = await db.ModelCredentialModel.AsNoTracking() .Where(m => ids.Contains(m.Id) && m.Enabled && m.Credential.TeamId == request.TeamId && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active) .Select(m => m.Id) @@ -317,14 +319,15 @@ private async Task EnsureModelsInTeamAsync(TaskLaunchRequest request, Cancellati /// dispatch-time pool gate still fails closed too; this turns a silent "can't dispatch" into a clear launch rejection. /// (Persona has no enabled/active flag — team + non-deleted + WORKING is the whole predicate, matching /// AgentDefinitionResolver: a Library STORE snapshot is not runnable, so it must never enter the dispatch pool.) + /// Internal + static so the route preview runs the very same check. /// - private async Task EnsureAgentDefinitionsInTeamAsync(TaskLaunchRequest request, CancellationToken cancellationToken) + internal static async Task EnsureAgentDefinitionsInTeamAsync(CodeSpaceDbContext db, TaskLaunchRequest request, CancellationToken cancellationToken) { if (request.AllowedAgentDefinitionIds is not { Count: > 0 } pool) return; var ids = pool.ToHashSet(); - var inTeam = await _db.AgentDefinition.AsNoTracking() + var inTeam = await db.AgentDefinition.AsNoTracking() .Where(a => ids.Contains(a.Id) && a.TeamId == request.TeamId && a.Scope == DefinitionScope.Working && a.DeletedDate == null) .Select(a => a.Id) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -333,7 +336,7 @@ private async Task EnsureAgentDefinitionsInTeamAsync(TaskLaunchRequest request, throw new KeyNotFoundException($"Agent {string.Join(", ", ids.Except(inTeam))} not found or not accessible."); } - /// Maps the seed + the operator's effort/recipe/autonomy + safety-budget caps onto the router input. The router TIGHTENS the effort preset's caps with CapsOverride (null ⇒ preset-only, byte-identical). Internal (not private) so the read-only route PREVIEW routes through the SAME mapping — a preview that built its own request would be free to drift from the launch it claims to predict. + /// Maps the seed + the operator's effort/recipe/autonomy + safety-budget caps + acceptance floor onto the router input. The router TIGHTENS the effort preset's caps with CapsOverride (null ⇒ preset-only, byte-identical) and keeps an auto route with an operator floor on a projection that grades it. Internal (not private) so the read-only route PREVIEW routes through the SAME mapping — a preview that built its own request would be free to drift from the launch it claims to predict. internal static EffortRouteRequest BuildRouteRequest(TaskLaunchSeed seed, TaskLaunchRequest request) => new() { Seed = seed, @@ -341,6 +344,8 @@ private async Task EnsureAgentDefinitionsInTeamAsync(TaskLaunchRequest request, RequestedRecipe = request.RequestedRecipe, CapsOverride = request.CapsOverride, DeliverableShape = request.DeliverableShape, + HasOperatorFloor = LaunchControlResolver.IsWellFormedFloor(request.AcceptanceChecks), + HasRepository = (seed.RepositoryId ?? request.RepositoryId) is not null, }; /// Pure mapping: the request overrides + (seed repo ?? request repo) + each related repo + the CLAMPED autonomy → the agent envelope the projection stamps. Every field optional, folding to agent.run's own defaults. Related repos require a primary (fail-loud, mirroring the agent.run node — a workspace has nowhere to anchor without one). Internal (not private) so the clamp + related-repo choke point is unit-pinned directly (InternalsVisibleTo), not only through integration coverage. @@ -409,26 +414,6 @@ private static string ClampAutonomy(TaskLaunchRequest request, RoutePlan route) return AgentAutonomyPolicy.Clamp(requested, ceiling).ToString(); } - /// - /// P3.2: Delivery/Unattended quality on a SUPERVISOR-projected launch (the only projection AcceptanceChecks - /// already has any effect on — S4b) MUST carry an executable acceptance floor, so a caller cannot claim - /// Delivery-grade verification while skipping the one lever that actually gates the terminal stop. Prototype (or - /// an unset tier) is unchanged — self-report stands, byte-identical. Inert on a non-supervisor projection — this - /// doesn't invent new acceptance-floor plumbing for single-agent/plan-map launches (AcceptanceChecks has no - /// effect there today either); such a launch still gets its output-review floor () - /// even though this specific mandate is inert for it. There is no sensible SERVER-SYNTHESIZED check to default to - /// (the argv is domain-specific) — an omission fails LOUD here rather than silently shipping ungated, mirroring - /// ILaunchRepositoryScopeGuard's fail-closed launch-time rejection shape (before the session opens). - /// - internal static void EnsureAcceptanceMandate(TaskLaunchRequest request, RoutePlan route) - { - if (request.Tier is not (QualityTier.Delivery or QualityTier.Unattended)) return; - if (route.ProjectionKind != TaskProjectionKinds.Supervisor) return; - if (request.AcceptanceChecks is { Count: > 0 }) return; - - throw new ArgumentException($"{request.Tier} quality requires an executable acceptance check (acceptanceChecks) — author one, or launch at Prototype quality for a self-reported result."); - } - /// /// P3.2: the tier-mandated output-review FLOOR — a MINIMUM, not just a fallback: an operator's explicit choice /// only ever RAISES the effective mode above the tier's floor, never lowers it below (Delivery's floor is Gate, 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 d4c4143cf..61ba9f3f9 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs @@ -55,6 +55,7 @@ public sealed class AgentCodeNode : INodeRuntime "model": { "type": "string", "description": "Model id within the harness's catalog. Leave empty to use the persona's model, or the harness default.", "x-spotlight": 2 }, "modelCredentialId": { "type": "string", "format": "uuid", "x-selector": "modelCredential", "description": "Model credential the agent authenticates with. Leave empty to use the persona's default, or the team/operator default." }, "modelCredentialModelId": { "type": "string", "format": "uuid", "x-selector": "credentialedModel", "description": "Pick a specific model from a credential's maintained list — sets BOTH the model and its backing credential from one choice. Takes precedence over 'model' / 'modelCredentialId' above. Leave empty to use those loose fields." }, + "allowedModelIds": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "The operator's allowed model pool, as credentialed-model ids. Dispatch holds the agent to it: a model outside it, or one that can no longer run, moves to a pooled row and the move is named on the run's timeline; a pool with nothing runnable fails the run. Escalation never climbs outside it. Leave empty for the team's whole pool." }, "approvalConversationId": { "type": "string", "format": "uuid", "x-selector": "conversation", "description": "Conversation the run posts its tool-approval cards into. Leave empty for no approval surface." }, "tools": { "type": "array", "items": { "type": "string" }, "description": "Tool allow-list the agent is restricted to (e.g. Read, Grep, Bash). Empty = the harness default. Added to (not replacing) the persona's tools; enforced by harnesses that support an allow-list (Claude Code), carried otherwise (Codex restricts via sandbox)." }, "runnerKind": { "type": "string", "description": "Sandbox runner (e.g. \"local\"). Empty → the deployment default, set by the Agents:DefaultRunnerKind configuration key (Agents__DefaultRunnerKind in the environment); \"local\" when that is unset." }, @@ -148,6 +149,8 @@ public Task RunAsync(NodeRunContext context, CancellationToken cance if (!TryReadModelCredentialModelId(context, out var modelCredentialModelId)) return Fail("Config 'modelCredentialModelId' must be a credentialed-model id (uuid)."); + if (!TryReadAllowedModelIds(context.Config, out var allowedModelIds)) return Fail("Config 'allowedModelIds' must be an array of credentialed-model ids (uuid)."); + if (string.IsNullOrWhiteSpace(harness)) return Fail("Config 'harness' is required."); // A persona supplies the prompt floor (its system prompt), so 'goal' is only required without one. @@ -182,6 +185,7 @@ public Task RunAsync(NodeRunContext context, CancellationToken cance AgentDefinitionId = agentDefinitionId, ModelCredentialId = modelCredentialId, ModelCredentialModelId = modelCredentialModelId, + AllowedModelIds = allowedModelIds, Tools = ReadStringArray(context.Config, "tools"), RepositoryId = repositoryId, Workspace = workspace, @@ -376,6 +380,11 @@ private static NodeResult MapResult(JsonElement payload, decimal? maxCostUsd, bo // keeps its own code and stays retryable, because another worker may admit it. var argumentTooLong = exitReason == FailureCodes.SandboxArgumentTooLong; + // The same shape once more, from the pool's side: the run is bound to the operator's allowed model pool and + // nothing in that pool can run (every pooled model disabled, or its credential inactive). A respawn meets + // the SAME pool, so it is refused identically — retrying only buries the one fact the operator needs. + var modelPoolUnresolvable = exitReason == FailureCodes.AgentModelPoolUnresolvable; + // D3: the third carve-out, and the only one that is not about the FAILURE's nature but about whether // anything can be done differently. A non-infra acceptance failure is a verdict the SAME model will // reproduce — which is why it is deterministic — but the finished attempt may have left a resolved @@ -402,7 +411,7 @@ private static NodeResult MapResult(JsonElement payload, decimal? maxCostUsd, bo // failed grade, and stands down for any classified cause, so an overflowing attempt carries no proposal. var contextWindowExceeded = cause == Supervisor.AgentRetryCauses.ContextWindowExceeded; - var deterministic = ((status is nameof(AgentRunStatus.NeedsReview) or nameof(AgentRunStatus.Cancelled) || acceptanceFailed || resourceExhausted || argumentTooLong || contextWindowExceeded) + var deterministic = ((status is nameof(AgentRunStatus.NeedsReview) or nameof(AgentRunStatus.Cancelled) || acceptanceFailed || resourceExhausted || argumentTooLong || modelPoolUnresolvable || contextWindowExceeded) && !acceptanceInfraFault && !stalled && !escalationAvailable) @@ -701,6 +710,25 @@ private static bool TryReadModelCredentialModelId(NodeRunContext context, out Gu return true; } + /// Read the optional allowedModelIds config — the operator's allowed model pool a task-launch projection bakes, as string uuids. Absent / empty → null (unbounded, byte-identical). Any entry that is not a uuid → false (a clean node failure): a pool is a bound, and one that half-parsed would silently bound the run to a different set of models — or, emptied, to none at all. + private static bool TryReadAllowedModelIds(IReadOnlyDictionary config, out IReadOnlyList? allowedModelIds) + { + allowedModelIds = null; + + if (!config.TryGetValue("allowedModelIds", out var raw) || raw.ValueKind == JsonValueKind.Null) return true; + if (raw.ValueKind != JsonValueKind.Array) return false; + + var ids = new List(); + foreach (var entry in raw.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.String || !Guid.TryParse(entry.GetString(), out var id)) return false; + ids.Add(id); + } + + allowedModelIds = ids.Count > 0 ? ids : null; + return true; + } + /// Read the optional baseRef input — the branch/ref to clone the primary repo at (session branch continuity). Absent / blank / non-string → null (the repo default). private static string? ReadBaseRef(NodeRunContext context) => context.Inputs.TryGetValue("baseRef", out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString()) 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 1d3389f35..322598494 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/PlanAuthorNode.cs @@ -240,6 +240,7 @@ internal static WorkflowPlanRequest BuildPlanRequest(IReadOnlyDictionaryA string-uuid array config read (the allowed model pool a projection bakes) — defensive per the node convention: absent / non-array ⇒ null, a non-uuid entry skipped, nothing left ⇒ null. It bounds only the catalog the planner allocates from; each branch's own agent.run is where the pool is enforced. + private static IReadOnlyList? ReadGuids(IReadOnlyDictionary config, string key) + { + var ids = ReadStringArray(config, key).Select(raw => Guid.TryParse(raw, out var id) ? id : (Guid?)null).OfType().ToList(); + + return ids.Count > 0 ? ids : null; + } + /// Defensive bool config read — absent / non-bool ⇒ false, mirroring the node convention. private static bool ReadBool(IReadOnlyDictionary config, string key) => config.TryGetValue(key, out var value) && value.ValueKind == JsonValueKind.True; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/LlmWorkflowPlanner.cs b/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/LlmWorkflowPlanner.cs index edd5b3ed0..70335c4d5 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/LlmWorkflowPlanner.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Planning/Planners/LlmWorkflowPlanner.cs @@ -57,10 +57,10 @@ public async Task PlanAsync(WorkflowPlanRequest request, Cancel var (structured, pick) = pickedBrain; - // P2 — render the capability catalog (harnesses + drivable providers, the team's whole credentialed pool) so the - // planner allocates a provider-compatible harness + model PER subtask informed, not blind. The run-time - // reconciler is the backstop. - var pool = await _modelSelector.ListPoolAsync(request.TeamId, allowedRowIds: null, cancellationToken).ConfigureAwait(false); + // P2 — render the capability catalog (harnesses + drivable providers, the team's credentialed pool bounded to the + // operator's allowed models when the launch set any) so the planner allocates a provider-compatible harness + model + // PER subtask informed, not blind. The run-time reconciler is the backstop — for the pool too. + var pool = await _modelSelector.ListPoolAsync(request.TeamId, request.AllowedModelIds, cancellationToken).ConfigureAwait(false); var catalog = CapabilityCatalog.Render(_harnesses.All, pool); // D2 (cross-run learning): the distilled lessons ride the plan prompt — under a deterministic, toggle-free diff --git a/backend/src/CodeSpace.Messages/Agents/AgentTask.cs b/backend/src/CodeSpace.Messages/Agents/AgentTask.cs index 265b47e1b..9031106f2 100644 --- a/backend/src/CodeSpace.Messages/Agents/AgentTask.cs +++ b/backend/src/CodeSpace.Messages/Agents/AgentTask.cs @@ -67,6 +67,16 @@ public sealed record AgentTask [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? AllowedHarnessKinds { get; init; } + /// + /// The credentialed-model ROW ids this run's model must come from — the model analogue of . + /// At dispatch HarnessModelReconciler runs the named model on its pooled row, and a model outside the pool (or + /// none at all) on the pool's default row, naming the move on the run; the run then uses that row's credential. + /// Null / empty (the default, and every task envelope persisted before this field) = UNBOUNDED, byte-identical. + /// The task-launch projections stamp it from the operator's allowed model pool; the supervisor gates its own spawns. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? AllowedModelIds { get; init; } + /// Model id within the chosen harness's catalog, or null/blank to let the harness pick its own default (the Model=empty rule). public string? Model { get; init; } diff --git a/backend/src/CodeSpace.Messages/Agents/PooledModel.cs b/backend/src/CodeSpace.Messages/Agents/PooledModel.cs new file mode 100644 index 000000000..7e1e76747 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Agents/PooledModel.cs @@ -0,0 +1,26 @@ +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Messages.Agents; + +/// What an agent was authored to run on — a credentialed-model ROW, a model name, a credential, or any mix (a persona may supply only a credential). All null means nothing was authored. +public sealed record ModelPin(Guid? RowId, string? Model, Guid? CredentialId); + +/// One credentialed-model row as the allowed-model-pool choice reads it (Rule 18.1, a pure data noun): its identity, the credential it runs on and that credential's name (for a note a person reads), its provider, whether it can run at all, and the agent-plane ranking inputs. +public sealed record PooledModelRow +{ + public required Guid Id { get; init; } + public required string ModelId { get; init; } + public required Guid ModelCredentialId { get; init; } + public required string CredentialName { get; init; } + public required string Provider { get; init; } + + /// Enabled, under an active, non-deleted credential — a row that is not usable can still be NAMED in a note, never run. + public required bool Usable { get; init; } + + public bool IsDefault { get; init; } + public ModelCapabilityTier? CapabilityTier { get; init; } + public ModelCapabilityTier? ProbedCapabilityTier { get; init; } +} + +/// The pooled row an authored pin runs on inside an allowed model pool, and — whenever that is not exactly what was authored — why, in a sentence a person reads. A null means nothing in the pool can run. +public sealed record PooledModelChoice(PooledModelRow? Row, string? Note); diff --git a/backend/src/CodeSpace.Messages/Commands/Tasks/LaunchTaskCommand.cs b/backend/src/CodeSpace.Messages/Commands/Tasks/LaunchTaskCommand.cs index 1d92c7155..8d9c09da8 100644 --- a/backend/src/CodeSpace.Messages/Commands/Tasks/LaunchTaskCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Tasks/LaunchTaskCommand.cs @@ -40,4 +40,7 @@ public sealed record LaunchTaskResult /// The external entity the task was launched from, when the seed carried one. public LinkedEntityRef? LinkedEntity { get; init; } + + /// What the route did with each route-dependent operator control the launch carried — applied, clamped or not applicable, with the reason. A refused control never reaches a result: it stops the launch. Empty when the launch carried none. + public IReadOnlyList ControlDispositions { get; init; } = []; } diff --git a/backend/src/CodeSpace.Messages/Commands/Tasks/TaskLaunchInput.cs b/backend/src/CodeSpace.Messages/Commands/Tasks/TaskLaunchInput.cs index a18553a9b..979ceec40 100644 --- a/backend/src/CodeSpace.Messages/Commands/Tasks/TaskLaunchInput.cs +++ b/backend/src/CodeSpace.Messages/Commands/Tasks/TaskLaunchInput.cs @@ -82,9 +82,10 @@ public abstract record TaskLaunchInput /// credentialed-model ROW ids (ModelCredentialModel ids, NOT model names). EVERY id is validated TEAM-SCOPED /// by the service (fail-closed, exactly like the repos): a foreign / disabled / deleted-credential row rejects the /// whole launch. Baked into the projected agent.supervisor node's allowedModelIds, where every - /// dispatched agent's model must resolve to a row in the pool (out of pool ⇒ fails closed at dispatch). Null / - /// empty ⇒ the pool is ALL the team's credentialed models (byte-identical to no pool). Inert on a non-supervisor - /// projection (single-agent / map ignore it). + /// dispatched agent's model must resolve to a row in the pool (out of pool ⇒ fails closed at dispatch). A plan-map + /// run offers its planner only the pool and holds every branch to it; a single-agent run holds its one agent to it + /// (a pinned model outside it is clamped to the pool's default). Null / empty ⇒ the pool is ALL the team's + /// credentialed models (byte-identical to no pool). Its disposition on the route is reported on the launch result. /// public IReadOnlyList? AllowedModelIds { get; init; } @@ -94,8 +95,9 @@ public abstract record TaskLaunchInput /// validated TEAM-SCOPED by the service (fail-closed, exactly like the repos / model pool): a foreign / deleted /// persona rejects the whole launch. Baked into the projected agent.supervisor node's /// allowedAgentDefinitionIds, where every dispatched agent's effective persona (model-authored slug OR the - /// profile default) must be in the pool (out of pool ⇒ fails closed at dispatch). Null / empty ⇒ the pool is ALL - /// the team's personas (byte-identical to no pool). Inert on a non-supervisor projection. + /// profile default) must be in the pool (out of pool ⇒ fails closed at dispatch). On a plan-map / single-agent run + /// the launch's own persona is the only one any agent runs as, so a persona outside the pool refuses the launch. + /// Null / empty ⇒ the pool is ALL the team's personas (byte-identical to no pool). /// public IReadOnlyList? AllowedAgentDefinitionIds { get; init; } @@ -112,11 +114,11 @@ public abstract record TaskLaunchInput public bool? IntegrateBranches { get; init; } /// - /// Deep/supervisor only: the operator's free-text ACCEPTANCE CRITERIA (e.g. "tests pass", "PR opened") — the - /// definition of done the supervisor TARGETS. RENDERED into the supervisor decider prompt (a yardstick the model - /// aims at); it is NOT executed and is DISTINCT from the executable acceptanceChecks argv floor. Null / empty - /// ⇒ omitted ⇒ byte-identical. Inert on a non-supervisor projection. (The structured list is also the forward seam - /// for a future supervisor critic-gate, which would judge against the same criteria.) + /// The operator's free-text ACCEPTANCE CRITERIA (e.g. "tests pass", "PR opened") — the definition of done every + /// route TARGETS: rendered into the supervisor decider prompt, the plan-map planner prompt, and the single agent's + /// goal (a yardstick the model aims at); it is NOT executed and is DISTINCT from the executable + /// acceptanceChecks argv floor. Null / empty ⇒ omitted ⇒ byte-identical. (The structured list is also the + /// forward seam for a future supervisor critic-gate, which would judge against the same criteria.) /// public IReadOnlyList? AcceptanceCriteria { get; init; } @@ -132,7 +134,7 @@ public abstract record TaskLaunchInput /// How an INDEPENDENT critic reviews the AUTHORED PLAN — tier-generic (S4e): the plan-map tiers bake it into plan.author/plan.confirm's reviewMode; Deep bakes it into the supervisor's plan-scoped planReviewMode (plans only — never a critic call on every spawn/merge/stop). None (default, byte-identical) / Gate / Improve. Runs on when set. Inert on quick (no plan). public ReviewMode PlannerReviewMode { get; init; } = ReviewMode.None; - /// The operator's EXECUTABLE acceptance floor (S4b, Deep only) — an argv (e.g. ["sh","check.sh"]) run against the run's reviewable head at the terminal stop; a non-zero exit fails the stop and withholds the branch. DISTINCT from the free-text (prompt-rendered, never executed). Null / empty ⇒ omitted ⇒ byte-identical. Inert on a non-supervisor projection. + /// The operator's EXECUTABLE acceptance floor (S4b) — an argv (e.g. ["sh","check.sh"]) graded on every route that advertises an operator-command adapter: Deep runs it against the run's reviewable head at the terminal stop (a non-zero exit fails the stop and withholds the branch), Quick grades its single agent with it. A plan-map route grades none, so a floor sent there refuses the launch — and on Auto it keeps the route off plan-map. DISTINCT from the free-text (prompt-rendered, never executed). Null / empty ⇒ omitted ⇒ byte-identical. public IReadOnlyList? AcceptanceChecks { get; init; } /// diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs index 128f07921..94ec5cac6 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/Planning/WorkflowPlanRequest.cs @@ -67,6 +67,9 @@ public sealed record WorkflowPlanRequest /// public Guid? BrainModelId { get; init; } + /// The operator's allowed model pool (credentialed-model ROW ids) the plan's subtasks are allocated from — the capability catalog the planner reads is bounded to it, so a per-subtask model is picked from the pool. It bounds the catalog only; the planner's own brain is . Null / empty ⇒ the whole team pool (byte-identical). + public IReadOnlyList? AllowedModelIds { get; init; } + /// Whether an INDEPENDENT reviewer model gates / improves the plan (the CriticPlannerDecorator). Default ⇒ no review (byte-identical). public ReviewMode Review { get; init; } = ReviewMode.None; diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs index a181ae6cb..f62cd4ddc 100644 --- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs +++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs @@ -89,6 +89,12 @@ public static class FailureCodes public const string ExternalTriggerBodyTooLarge = "external_trigger_body_too_large"; public const string TaskRouteConfirmationRequired = "task_route_confirmation_required"; public const string TaskRouteSnapshotMismatch = "task_route_snapshot_mismatch"; + + /// The resolved route cannot honour a launch control the operator set (an acceptance check a plan-map route cannot grade, a persona the allowed pool excludes, …), so the launch stopped before any session or run. The details name each control and why. Remedy: drop the control or choose a route that consumes it — a retry of the same input is refused again. + public const string TaskLaunchControlRefused = "task_launch_control_refused"; + + /// An agent run bound to an operator's allowed model pool found nothing in that pool it can run on — every pooled model is disabled, or its credential is no longer active — so it was refused rather than run on a model outside the pool. Deterministic: a respawn meets the same pool. Remedy: re-enable a pooled model or its credential, or relaunch with a pool that has one. + public const string AgentModelPoolUnresolvable = "agent_model_pool_unresolvable"; public const string WorkspaceUnresolvable = "workspace_unresolvable"; /// An agent's tool call could not be shown to a human as it would run — the pull request it names could not be read, its head or base is not the one the call names, or its arguments are too long to show whole — so it was answered instead of put to a reviewer. Remedy: read the pull request again, or shorten the arguments, then re-issue the call. diff --git a/backend/src/CodeSpace.Messages/Tasks/Effort/EffortPolicy.cs b/backend/src/CodeSpace.Messages/Tasks/Effort/EffortPolicy.cs index 02510a83e..1148f36c8 100644 --- a/backend/src/CodeSpace.Messages/Tasks/Effort/EffortPolicy.cs +++ b/backend/src/CodeSpace.Messages/Tasks/Effort/EffortPolicy.cs @@ -29,6 +29,15 @@ public static string Decide(EffortSignals signals, string? requestedEffort) return Rows.First(row => row.Matches(signals)).Mode; } + /// + /// The AUTO tier for among the tiers accepts: the table's first + /// row that both matches and is admitted, so a set-aside tier falls to the next matching row down. When no admitted + /// row matches, the unconstrained pick stands and the caller decides what a tier it cannot use means. An explicit + /// operator tier never reaches here — it is a decision, never re-decided. + /// + public static string Decide(EffortSignals signals, Func admits) => + Rows.FirstOrDefault(row => row.Matches(signals) && admits(row.Mode))?.Mode ?? Decide(signals, requestedEffort: null); + /// An operator tier is explicit when it is non-blank and not the "auto" sentinel — those are honoured verbatim, the rest classify. private static bool IsExplicitOperatorTier(string? requestedEffort) => !string.IsNullOrWhiteSpace(requestedEffort) && !string.Equals(requestedEffort.Trim(), TaskEffortModes.Auto, StringComparison.OrdinalIgnoreCase); diff --git a/backend/src/CodeSpace.Messages/Tasks/Effort/EffortRouteRequest.cs b/backend/src/CodeSpace.Messages/Tasks/Effort/EffortRouteRequest.cs index 1b722b744..a721e9ae6 100644 --- a/backend/src/CodeSpace.Messages/Tasks/Effort/EffortRouteRequest.cs +++ b/backend/src/CodeSpace.Messages/Tasks/Effort/EffortRouteRequest.cs @@ -38,4 +38,19 @@ public sealed record EffortRouteRequest /// own reading on the auto path, and the code default on the explicit one — byte-identical. /// public string? DeliverableShape { get; init; } + + /// + /// The launch carries an operator acceptance floor (an executable acceptanceChecks argv) — a routing signal: + /// the AUTO path must land on a projection that grades it, so a classified tier whose projection cannot is set aside + /// for the next tier the policy admits. An explicit tier or a pinned recipe / projection is the operator's own + /// choice and is never moved; the launch refuses that combination with its reason instead. False ⇒ unconstrained. + /// + public bool HasOperatorFloor { get; init; } + + /// + /// The launch's workspace binds a repository. With it decides which lanes can grade the + /// floor HERE: the supervisor grades one only against the branch it publishes, while the single agent grades it in + /// its own workspace, repository or not — so a repository-free floor keeps the auto path on the single agent. + /// + public bool HasRepository { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Tasks/LaunchControlDisposition.cs b/backend/src/CodeSpace.Messages/Tasks/LaunchControlDisposition.cs new file mode 100644 index 000000000..0f839d2ba --- /dev/null +++ b/backend/src/CodeSpace.Messages/Tasks/LaunchControlDisposition.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace CodeSpace.Messages.Tasks; + +/// What the resolved route did with one operator launch control. Every control a launch carries lands in exactly one of these — never silently dropped. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LaunchControlOutcome { Applied, Clamped, NotApplicable, Refused } + +/// +/// One operator launch control's disposition on the resolved route (Rule 18.1, a pure data noun). The launch and its +/// route preview compute it through the same service, so the preview states what the launch will do; a +/// control stops the launch before any session or run exists. +/// +public sealed record LaunchControlDisposition +{ + /// The control's wire name — a value. + public required string Control { get; init; } + + public required LaunchControlOutcome Outcome { get; init; } + + /// Why the route clamped, set aside or refused the control; for an applied one, how it applies when that is not obvious. Null when there is nothing to add. + public string? Reason { get; init; } +} + +/// The launch controls whose effect depends on the route, by their wire names on the launch input. A control every route consumes the same way has no disposition to report. +public static class LaunchControls +{ + public const string AllowedModelIds = "allowedModelIds"; + public const string AllowedAgentDefinitionIds = "allowedAgentDefinitionIds"; + public const string AcceptanceChecks = "acceptanceChecks"; + public const string DecisionReviewMode = "decisionReviewMode"; + public const string DeliverySpec = "deliverySpec"; + public const string RequirePlanConfirmation = "requirePlanConfirmation"; + public const string PlannerReviewMode = "plannerReviewMode"; + public const string IntegrateBranches = "integrateBranches"; + public const string Tier = "tier"; +} diff --git a/backend/src/CodeSpace.Messages/Tasks/LaunchControlResolution.cs b/backend/src/CodeSpace.Messages/Tasks/LaunchControlResolution.cs new file mode 100644 index 000000000..7f9d9bd7c --- /dev/null +++ b/backend/src/CodeSpace.Messages/Tasks/LaunchControlResolution.cs @@ -0,0 +1,6 @@ +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Messages.Tasks; + +/// The dispositions of one launch's route-dependent controls, the route + workspace's operator-command acceptance verdict they were derived from, and the pooled row the single agent runs on when its pinned model had to move inside the allowed pool (null when nothing was clamped). +public sealed record LaunchControlResolution(IReadOnlyList Dispositions, TaskAcceptanceCompatibility AcceptanceCompatibility, PooledModelRow? ModelClamp); diff --git a/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs b/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs index 66b7245ad..00b46d259 100644 --- a/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs +++ b/backend/src/CodeSpace.Messages/Tasks/TaskBuildContext.cs @@ -56,13 +56,13 @@ public sealed record TaskBuildContext /// True when the brain row is the operator's own HONORED pin — baked so the decider knows it must resolve verbatim (never fail over). public bool SupervisorBrainModelPinned { get; init; } - /// The operator's allowed model pool (credentialed-model ROW ids) for the agents a Deep run dispatches, validated TEAM-SCOPED at launch — the SupervisorDefinitionBuilder bakes it into the node's allowedModelIds, where a dispatched model out of the pool fails closed. Null / empty ⇒ the pool is all the team's models (the builder omits the key — byte-identical). Inert on a non-supervisor projection (its builder never reads this). + /// The operator's allowed model pool (credentialed-model ROW ids), validated TEAM-SCOPED at launch — the SupervisorDefinitionBuilder bakes it into the node's allowedModelIds, where a dispatched model out of the pool fails closed; the plan-map builders bake it into the planner (its catalog) and every branch's agent.run, and the single-agent builder into its one agent.run, where dispatch holds the model to the pool. Null / empty ⇒ the pool is all the team's models (every builder omits the key — byte-identical). public IReadOnlyList? AllowedModelIds { get; init; } /// The operator's allowed AGENT (persona) pool (AgentDefinition ROW ids), validated TEAM-SCOPED at launch — the SupervisorDefinitionBuilder bakes it into the node's allowedAgentDefinitionIds, where a dispatched persona out of the pool fails closed. Null / empty ⇒ all the team's personas (builder omits the key — byte-identical). Inert on a non-supervisor projection. public IReadOnlyList? AllowedAgentDefinitionIds { get; init; } - /// The operator's free-text ACCEPTANCE CRITERIA — the SupervisorDefinitionBuilder bakes them into the node's acceptanceCriteria, rendered into the decider prompt (NOT executed). Null / empty ⇒ the builder omits the key (byte-identical). Inert on a non-supervisor projection. + /// The operator's free-text ACCEPTANCE CRITERIA (NOT executed) — the SupervisorDefinitionBuilder bakes them into the node's acceptanceCriteria (rendered into the decider prompt), the plan-map builders into the planner's criteria input, and the single-agent builder onto its agent's goal. Null / empty ⇒ every builder omits them (byte-identical). public IReadOnlyList? AcceptanceCriteria { get; init; } /// The plan-confirmation gate — the SupervisorDefinitionBuilder bakes it into the node's requirePlanConfirmation (S3); the plan-map builders insert a plan.confirm node after the planner and rebind the map to its APPROVED outputs (S4d). False (the default) ⇒ byte-identical ungated graphs. Inert on the quick tier. @@ -80,7 +80,7 @@ public sealed record TaskBuildContext /// How an INDEPENDENT critic reviews the AUTHORED PLAN — tier-generic (S4e): the plan-map builders bake it into plan.author/plan.confirm's reviewMode; the supervisor builder bakes it into the plan-scoped planReviewMode. (the default) ⇒ omitted (byte-identical). Inert on quick. public ReviewMode PlannerReviewMode { get; init; } = ReviewMode.None; - /// The operator's EXECUTABLE acceptance floor (S4b) — an argv (e.g. ["sh","check.sh"]) the SupervisorDefinitionBuilder bakes into the node's acceptanceChecks, enforced at the terminal stop (a non-zero exit fails the stop + withholds the reviewable head). Null / empty ⇒ omitted (byte-identical). Inert on a non-supervisor projection. + /// The operator's EXECUTABLE acceptance floor (S4b) — an argv (e.g. ["sh","check.sh"]) the SupervisorDefinitionBuilder bakes into the node's acceptanceChecks, enforced at the terminal stop (a non-zero exit fails the stop + withholds the reviewable head), and the single-agent builder into its agent's own oracle. Null / empty ⇒ omitted (byte-identical). The plan-map builders grade none — the launch refuses a floor before it reaches them. public IReadOnlyList? AcceptanceChecks { get; init; } /// How an INDEPENDENT critic reviews each supervisor decision — the SupervisorDefinitionBuilder bakes it into the node's decisionReviewMode. (the default) ⇒ the builder omits the key (byte-identical). Inert on a non-supervisor projection. diff --git a/backend/src/CodeSpace.Messages/Tasks/TaskLaunchRequest.cs b/backend/src/CodeSpace.Messages/Tasks/TaskLaunchRequest.cs index 0016a70ac..9d57a86c8 100644 --- a/backend/src/CodeSpace.Messages/Tasks/TaskLaunchRequest.cs +++ b/backend/src/CodeSpace.Messages/Tasks/TaskLaunchRequest.cs @@ -62,13 +62,13 @@ public sealed record TaskLaunchRequest /// The operator's safety-budget caps projected onto the router's CapsOverride seam (the numeric caps only — autonomy/approval merge tighten-only). Null ⇒ the effort preset's caps stand. The router TIGHTENS the preset with a set cap; a cost cap force-stops the run via the supervisor's bounds. public RouteCaps? CapsOverride { get; init; } - /// The operator's allowed model pool (credentialed-model ROW ids) for the agents a Deep run dispatches — validated TEAM-SCOPED (fail-closed) + baked into the supervisor node's allowedModelIds. Null / empty ⇒ all the team's models (byte-identical). Inert on a non-supervisor projection. + /// The operator's allowed model pool (credentialed-model ROW ids) the run's agents are held to — validated TEAM-SCOPED (fail-closed). The supervisor gates every spawn inside it; plan-map offers its planner only the pool and holds every branch to it; single-agent holds its one agent to it (a pinned model outside it is clamped to the pool's default). Null / empty ⇒ all the team's models (byte-identical). Its disposition on the route is reported on the launch result. public IReadOnlyList? AllowedModelIds { get; init; } - /// The operator's allowed AGENT (persona) pool (AgentDefinition ROW ids) for the agents a Deep run dispatches — validated TEAM-SCOPED (fail-closed) + baked into the supervisor node's allowedAgentDefinitionIds. Null / empty ⇒ all the team's personas (byte-identical). Inert on a non-supervisor projection. + /// The operator's allowed AGENT (persona) pool (AgentDefinition ROW ids) — validated TEAM-SCOPED (fail-closed). The supervisor gates every spawn inside it; on plan-map / single-agent the launch's own persona is the only one any agent runs as, so a persona outside the pool refuses the launch. Null / empty ⇒ all the team's personas (byte-identical). Its disposition on the route is reported on the launch result. public IReadOnlyList? AllowedAgentDefinitionIds { get; init; } - /// The operator's free-text ACCEPTANCE CRITERIA — rendered into the supervisor decider prompt as the definition of done (NOT executed; distinct from the acceptanceChecks argv floor). Null / empty ⇒ omitted (byte-identical). Inert on a non-supervisor projection. + /// The operator's free-text ACCEPTANCE CRITERIA — the definition of done every route steers by (NOT executed; distinct from the acceptanceChecks argv floor): rendered into the supervisor decider prompt, the plan-map planner prompt, and the single agent's goal. Null / empty ⇒ omitted (byte-identical). public IReadOnlyList? AcceptanceCriteria { get; init; } /// The plan-confirmation gate — every authored plan version parks the run for the operator's confirmation before any agent is created (supervisor in-loop on Deep; a plan.confirm node on the plan-map tiers). Null / false ⇒ omitted (byte-identical). Inert on the quick tier. @@ -77,7 +77,7 @@ public sealed record TaskLaunchRequest /// How an INDEPENDENT critic reviews the AUTHORED PLAN — tier-generic (S4e): plan.author/plan.confirm reviewMode on the plan-map tiers, the supervisor's plan-scoped planReviewMode on Deep. (default) ⇒ omitted (byte-identical). Inert on quick. public ReviewMode PlannerReviewMode { get; init; } = ReviewMode.None; - /// The operator's EXECUTABLE acceptance floor (S4b, Deep only) — an argv baked into the supervisor node's acceptanceChecks, enforced at the terminal stop. Null / empty ⇒ omitted (byte-identical). Inert on a non-supervisor projection. + /// The operator's EXECUTABLE acceptance floor (S4b) — an argv graded on every route whose builder advertises an operator-command adapter: the supervisor's terminal stop, the single agent's own oracle. A plan-map route grades none, so a floor sent to it refuses the launch, and on the auto path a floor keeps the route off plan-map. Null / empty ⇒ omitted (byte-identical). public IReadOnlyList? AcceptanceChecks { get; init; } /// DC-2a: the operator's OWN pre-declared delivery preference — baked into the supervisor node's deliverySpec, PER FIELD authoritative over the model's plan-time proposal. Null ⇒ omitted (byte-identical). Inert on a non-supervisor projection. diff --git a/backend/src/CodeSpace.Messages/Tasks/TaskRoutePreviewResult.cs b/backend/src/CodeSpace.Messages/Tasks/TaskRoutePreviewResult.cs index 9993e28e6..9fa6d653d 100644 --- a/backend/src/CodeSpace.Messages/Tasks/TaskRoutePreviewResult.cs +++ b/backend/src/CodeSpace.Messages/Tasks/TaskRoutePreviewResult.cs @@ -31,6 +31,10 @@ public sealed record TaskRoutePreviewResult /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TaskRoutePosture? Posture { get; init; } + + /// What the launch will do with each route-dependent operator control this input carries — computed by the same service the launch calls, so a Refused entry here is a launch that will be refused. Null only on the raw result a snapshot store constructs before TaskRoutePreviewService describes it. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? ControlDispositions { get; init; } } /// diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentDefinitionResolverCredentialedModelFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentDefinitionResolverCredentialedModelFlowTests.cs index 4cfded219..464745912 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentDefinitionResolverCredentialedModelFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentDefinitionResolverCredentialedModelFlowTests.cs @@ -132,6 +132,49 @@ public async Task A_picked_model_whose_credential_was_revoked_fails_closed() await Should.ThrowAsync(() => ResolveAsync(task, teamId)); } + [Theory] + [InlineData("revoked")] + [InlineData("disabled")] + [InlineData("foreign-team")] + [InlineData("missing")] + public async Task A_bounded_task_whose_picked_model_cannot_run_is_left_for_the_dispatchs_pool_choice(string unusable) + { + // The operator named the allowed pool as the fallback, and the dispatch owns that choice: it moves to another + // pooled row and names the move, or fails with agent_model_pool_unresolvable. Failing here would end the run + // before any pooled row was tried. The pin stays on the task, unexpanded, for the dispatch to read — whatever + // made it unusable. (The team barrier for a foreign row is then the dispatch's: ModelPoolSelector.) + var teamId = await SeedTeamAsync(); + var credId = await SeedCredentialAsync(teamId, "Anthropic"); + var rowId = await AddModelAsync(credId, "claude-opus-4-8"); + var otherRow = await AddModelAsync(await SeedCredentialAsync(teamId, "Anthropic"), "claude-sonnet-4-6"); + + var pin = unusable switch + { + "revoked" => await RevokedPinAsync(credId, rowId), + "disabled" => await DisabledPinAsync(rowId), + "foreign-team" => await AddModelAsync(await SeedCredentialAsync(await SeedTeamAsync(), "Anthropic"), "claude-opus-4-8"), + _ => Guid.NewGuid(), + }; + + var task = new AgentTask { Goal = "g", Harness = "claude-code", ModelCredentialModelId = pin, AllowedModelIds = [pin, otherRow] }; + + var resolved = await ResolveAsync(task, teamId); + + resolved.ShouldBe(task, "nothing is substituted here — the pool choice is the dispatch's"); + } + + private async Task RevokedPinAsync(Guid credId, Guid rowId) + { + await RevokeCredentialAsync(credId); + return rowId; + } + + private async Task DisabledPinAsync(Guid rowId) + { + await DisableModelAsync(rowId); + return rowId; + } + [Fact] public async Task A_missing_model_row_fails_closed() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/HarnessModelReconcilerFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/HarnessModelReconcilerFlowTests.cs index ed7ef5005..054ea01b9 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Agents/HarnessModelReconcilerFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Agents/HarnessModelReconcilerFlowTests.cs @@ -8,6 +8,7 @@ using CodeSpace.IntegrationTests.Workflows.Infrastructure; using CodeSpace.Messages.Agents; using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; using Shouldly; namespace CodeSpace.IntegrationTests.Agents; @@ -197,6 +198,162 @@ public async Task A_foreign_or_missing_credential_is_left_for_the_resolver_to_re result.HarnessKind.ShouldBe("codex-cli", "a missing credential is not a harness mismatch — leave it for the resolver's precise error"); } + // ── The run's allowed model pool: a bounded task runs on a POOLED ROW, and the harness follows that row ── + + [Fact] + public async Task A_bounded_task_naming_a_model_outside_its_pool_runs_the_pools_default_row_and_says_so() + { + // The plan-map shape: the planner authored a model the operator's pool excludes. It runs the pool's default row + // on that row's own credential, and the harness is reconciled to THAT row's provider, not the authored model's. + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-claude", "Anthropic"); + await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "outside-gpt", "OpenAI"); + var task = new AgentTask { Goal = "g", Harness = "codex-cli", Model = "outside-gpt", AllowedModelIds = [pooled.RowId] }; + + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ReconcileAsync(task, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull("a bounded task always runs on a pooled row"); + result.PooledModel.ModelId.ShouldBe("pooled-claude"); + result.PooledModel.ModelCredentialId.ShouldBe(pooled.CredentialId, "names repeat across credentials — the row decides the key the agent runs on"); + result.PoolNote.ShouldNotBeNull().ShouldContain("outside-gpt", customMessage: "the note names the model that did not fit…"); + result.PoolNote.ShouldContain("pooled-claude", customMessage: "…and the one that runs instead"); + result.HarnessKind.ShouldBe("claude-code", "the harness follows the pooled row's provider (Anthropic), not the excluded OpenAI model's"); + } + + [Fact] + public async Task A_bounded_task_naming_a_pooled_model_runs_that_row_with_no_note() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-gpt", "OpenAI"); + var task = new AgentTask { Goal = "g", Harness = "codex-cli", Model = "POOLED-GPT", AllowedModelIds = [pooled.RowId] }; + + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ReconcileAsync(task, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull().ModelCredentialId.ShouldBe(pooled.CredentialId, "the pooled row's credential, so a loose name cannot resolve to a key outside the pool"); + result.PoolNote.ShouldBeNull("a pooled model (matched case-insensitively, like every pool lookup) did not move"); + result.HarnessKind.ShouldBe("codex-cli"); + } + + [Fact] + public async Task A_bounded_task_naming_no_model_runs_the_pools_default_row_with_no_note() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-claude", "Anthropic"); + var task = new AgentTask { Goal = "g", Harness = "codex-cli", AllowedModelIds = [pooled.RowId] }; + + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ReconcileAsync(task, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull().ModelId.ShouldBe("pooled-claude", "no name must not escape to the unbounded team default"); + result.PoolNote.ShouldBeNull("nothing authored moved — the pool's default is simply the model that runs"); + } + + [Fact] + public async Task A_bounded_task_whose_pool_resolves_nothing_any_more_is_named_and_an_unbounded_one_is_untouched() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-claude", "Anthropic"); + await DisableModelRowAsync(pooled.RowId); + + using var scope = _fixture.BeginScope(); + var reconciler = scope.Resolve(); + var bounded = await reconciler.ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", Model = "pooled-claude", AllowedModelIds = [pooled.RowId] }, teamId, CancellationToken.None); + var unbounded = await reconciler.ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", Model = "pooled-claude" }, teamId, CancellationToken.None); + + bounded.PooledModel.ShouldBeNull(); + bounded.PoolNote.ShouldNotBeNull("the executor fails the run on this note rather than let the agent run outside its pool"); + unbounded.PooledModel.ShouldBeNull(); + unbounded.PoolNote.ShouldBeNull("an unbounded task is reconciled exactly as before"); + } + + [Fact] + public async Task A_pinned_row_runs_as_pinned_even_when_its_same_named_twin_would_win_a_name_lookup() + { + // A model NAME is unique only per credential. The dispatch used to resolve the name inside the pool, so the twin + // the name lookup ranks first silently replaced the operator's pinned row — another key billed, no note. + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var first = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "sonnet", "Anthropic"); + var second = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "sonnet", "Anthropic"); + IReadOnlyList pool = [first.RowId, second.RowId]; + + using var scope = _fixture.BeginScope(); + var nameWinner = (await scope.Resolve().ResolveDispatchAsync(teamId, "sonnet", pool, CancellationToken.None))!.ModelCredentialId; + var pinned = nameWinner == first.CredentialId ? second : first; // pin the row a name lookup would NOT pick + + var result = await scope.Resolve().ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", Model = "sonnet", ModelCredentialId = pinned.CredentialId, ModelCredentialModelId = pinned.RowId, AllowedModelIds = pool }, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull().Id.ShouldBe(pinned.RowId, "the pinned row runs — on its own credential"); + result.PooledModel.ModelCredentialId.ShouldBe(pinned.CredentialId); + result.PoolNote.ShouldBeNull("nothing moved"); + } + + [Fact] + public async Task A_persona_that_supplies_only_a_credential_runs_that_credentials_pooled_row() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var personaKey = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "persona-model", "Anthropic"); + var other = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "other-model", "Anthropic"); + + using var scope = _fixture.BeginScope(); + var reconciler = scope.Resolve(); + + var honoured = await reconciler.ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", ModelCredentialId = personaKey.CredentialId, AllowedModelIds = [personaKey.RowId, other.RowId] }, teamId, CancellationToken.None); + var moved = await reconciler.ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", ModelCredentialId = personaKey.CredentialId, AllowedModelIds = [other.RowId] }, teamId, CancellationToken.None); + + honoured.PooledModel.ShouldNotBeNull().Id.ShouldBe(personaKey.RowId, "the pool holds the persona's credential, so the persona's credential runs"); + honoured.PoolNote.ShouldBeNull(); + moved.PooledModel.ShouldNotBeNull().Id.ShouldBe(other.RowId); + moved.PoolNote.ShouldNotBeNull("the credential moved, so the run's timeline says so").ShouldContain("credential"); + } + + [Fact] + public async Task The_same_model_on_another_pooled_credential_is_named_as_a_credential_move() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var gateway = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "sonnet", "Anthropic"); + var directCredential = await SeedCredentialAsync(teamId, "Anthropic"); + + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ReconcileAsync(new AgentTask { Goal = "g", Harness = "codex-cli", Model = "sonnet", ModelCredentialId = directCredential, AllowedModelIds = [gateway.RowId] }, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull().ModelCredentialId.ShouldBe(gateway.CredentialId); + result.PoolNote.ShouldNotBeNull().ShouldStartWith("Model 'sonnet' has no usable row on the credential it was pinned to", customMessage: "the same model on another key is a credential move — named, never silent"); + } + + [Fact] + public async Task A_pinned_row_of_another_team_is_never_used_nor_named_in_the_note() + { + // The resolver lets an unusable pin through when the task carries a pool, so this chooser is the only team + // barrier left: a foreign row listed in the pool or pinned must read as absent, and say nothing about itself. + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var (otherTeamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var own = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "own-model", "Anthropic"); + var foreign = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, otherTeamId, "foreign-only-model", "Anthropic"); + var task = new AgentTask { Goal = "g", Harness = "claude-code", ModelCredentialId = foreign.CredentialId, ModelCredentialModelId = foreign.RowId, AllowedModelIds = [foreign.RowId, own.RowId] }; + + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ReconcileAsync(task, teamId, CancellationToken.None); + + result.PooledModel.ShouldNotBeNull("the team's own pooled row still runs"); + result.PooledModel.Id.ShouldBe(own.RowId, "the other team's row is never the one that runs"); + result.PooledModel.ModelCredentialId.ShouldBe(own.CredentialId); + + var note = result.PoolNote ?? ""; + note.ShouldNotContain("foreign-only-model", customMessage: "a row the team cannot see is not named"); + note.ShouldNotContain(foreign.RowId.ToString()); + note.ShouldNotContain(foreign.CredentialId.ToString()); + } + + private async Task DisableModelRowAsync(Guid rowId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + (await db.ModelCredentialModel.SingleAsync(m => m.Id == rowId)).Enabled = false; + await db.SaveChangesAsync(); + } + private async Task SeedCredentialAsync(Guid teamId, string provider) { var id = Guid.NewGuid(); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ModelPool.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ModelPool.cs new file mode 100644 index 000000000..8bdeef487 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ModelPool.cs @@ -0,0 +1,209 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Core.Services.Review; +using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using CodeSpace.Messages.Review; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// The allowed model pool and escalation on the production path: a run the ENGINE stages from an agent.run node +/// (persona/model resolution included) and the REAL executor then dispatches, against real Postgres rows. The pool's +/// fall-through order must hold here, not only when the reconciler is called directly. +/// +public partial class AgentRunExecutorTests +{ + [Fact] + public async Task An_engine_staged_run_pinned_to_a_row_whose_credential_died_runs_another_pooled_row_and_says_so() + { + // Pool [A on cred1, B on cred2], the launch pinned A, cred1 was revoked before dispatch. The engine used to fail + // the node at staging ("Selected model … is not an active, enabled credentialed model") before any pooled row was + // tried — no note, no retry, and on plan-map every branch the same. The pool is the operator's fallback. + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var deadCredential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-dead-key"); + var pinned = await SeedModelRowAsync(deadCredential, "model-a"); + var other = await SeedModelRowAsync(await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-live-key"), "model-b"); + await RevokeModelCredentialAsync(deadCredential); + + using var manual = ResolveJobClient().ManualExecution(); + var (workflowRunId, agentRunId) = await StagePoolBoundAgentThroughEngineAsync(teamId, pinned, [pinned, other]); + + await ExecuteAsync(agentRunId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", "if [ \"$SCRIPTED_MODEL_KEY\" = 'sk-live-key' ]; then echo LIVE_KEY; else echo OTHER_KEY; fi")); + await RunEngineAsync(workflowRunId); + + using var scope = _fixture.BeginScope(); + var svc = scope.Resolve(); + var run = await svc.GetAsync(agentRunId, CancellationToken.None); + run.Status.ShouldBe(AgentRunStatus.Succeeded); + JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!.ModelCredentialModelId.ShouldBe(other, "the run is persisted as the pooled row that ran"); + + var events = (await svc.GetEventsAsync(agentRunId, teamId, 0, CancellationToken.None)).ToList(); + events.Select(e => e.Text).ShouldContain("LIVE_KEY", "the REAL child process ran on the other pooled row's key"); + events.ShouldContain(e => e.Kind == AgentEventKind.Warning && e.Text.Contains("model-a") && e.Text.Contains("no longer active") && e.Text.Contains("model-b"), + customMessage: "the move is named on the run's timeline by its cause"); + + (await scope.Resolve().WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == workflowRunId)).Status.ShouldBe(WorkflowRunStatus.Success); + } + + [Fact] + public async Task An_engine_staged_run_whose_pool_has_nothing_runnable_fails_typed_once_and_is_not_respawned() + { + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var credential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-dead-key"); + var pinned = await SeedModelRowAsync(credential, "model-a"); + await RevokeModelCredentialAsync(credential); + var logger = new RecordingExecutorLogger(); + + using var manual = ResolveJobClient().ManualExecution(); + var (workflowRunId, agentRunId) = await StagePoolBoundAgentThroughEngineAsync(teamId, pinned, [pinned]); + + await ExecuteAsync(agentRunId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", "echo should-not-run"), logger: logger); + await RunEngineAsync(workflowRunId); + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var run = await scope.Resolve().GetAsync(agentRunId, CancellationToken.None); + run.Status.ShouldBe(AgentRunStatus.Failed); + JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!.ExitReason.ShouldBe(FailureCodes.AgentModelPoolUnresolvable); + + (await db.AgentRun.AsNoTracking().CountAsync(r => r.WorkflowRunId == workflowRunId)).ShouldBe(1, "the same pool would refuse a respawn the same way — the node's retry budget is not spent on it"); + (await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == workflowRunId)).Status.ShouldBe(WorkflowRunStatus.Failure); + + logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Exception == null && e.Message.Contains("allowed models"), "an unrunnable pool is the operator's configuration: a Warning naming it, no stack trace"); + logger.Entries.ShouldNotContain(e => e.Level >= LogLevel.Error, "it is not an executor fault"); + } + + [Fact] + public async Task An_escalated_run_is_persisted_and_reviewed_as_the_row_that_did_the_work() + { + // The task started on base-model's row and escalated to strong-model. The stored task used to keep the base row, + // and the output review's producer identity reads the row first — so the reviewer avoided base-model and could + // pick strong-model: the model that did the work reviewing itself. + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var credential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-key"); + var baseRow = await SeedModelRowAsync(credential, "base-model", ModelCapabilityTier.Basic); + var strongRow = await SeedModelRowAsync(credential, "strong-model", ModelCapabilityTier.Strong); + var critic = new ProducerRecordingCritic(); + + var runId = await CreateTaskRunAsync(teamId, new AgentTask + { + Goal = "scripted", Harness = "scripted-projector", Model = "base-model", ModelCredentialId = credential, ModelCredentialModelId = baseRow, OutputReviewMode = ReviewMode.Gate, + Escalation = new AgentModelEscalation { Reason = "the prior attempt claimed success but its check failed", From = "base-model" }, + }); + + await ExecuteAsync(runId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", "echo answered"), critic: critic); + + using var scope = _fixture.BeginScope(); + var run = await scope.Resolve().GetAsync(runId, CancellationToken.None); + JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!.ModelEscalation!.To.ShouldBe("strong-model", "the run escalated — otherwise this test proves nothing"); + + var persisted = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!; + persisted.Model.ShouldBe("strong-model"); + persisted.ModelCredentialModelId.ShouldBe(strongRow, "a re-attach reads the stored task — it must name the row that ran"); + + critic.ProducerModel.ShouldNotBeNull("the run was gated — if the critic never ran, this test proves nothing"); + critic.ProducerModel!.ModelCredentialModelId.ShouldBe(strongRow); + (await scope.Resolve().SelectReviewerRowIdAsync(teamId, ["scripted-provider"], critic.ProducerModel, CancellationToken.None)) + .ShouldBe(baseRow, "the reviewer avoids the model that did the work — the escalated one"); + } + + /// manual → agent.run (a pinned row inside an allowed pool, three attempts) → terminal, run by the REAL engine until it stages the agent. Returns the workflow run and the agent run it staged. + private async Task<(Guid WorkflowRunId, Guid AgentRunId)> StagePoolBoundAgentThroughEngineAsync(Guid teamId, Guid pinnedRow, IReadOnlyList pool) + { + var poolJson = string.Join(",", pool.Select(id => $"\"{id}\"")); + var agent = WorkflowsTestSeed.Json($$"""{"goal":"scripted","harness":"scripted-projector","runnerKind":"local","timeoutSeconds":900,"modelCredentialModelId":"{{pinnedRow}}","allowedModelIds":[{{poolJson}}]}"""); + var definition = new WorkflowDefinition + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "agent", TypeKey = "agent.run", Config = agent, Inputs = WorkflowsTestSeed.EmptyJson(), Retry = new RetryPolicy { MaxAttempts = 3, BackoffSeconds = 0 } }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List { new() { From = "start", To = "agent" }, new() { From = "agent", To = "end" } }, + }; + + Guid workflowId; + using (var scope = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + workflowId = await scope.Resolve().Send(new CreateWorkflowCommand { Name = "pool-" + Guid.NewGuid().ToString("N")[..6], Description = null, Definition = definition, Activations = new List(), Enabled = true }); + + var workflowRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, teamId); + await RunEngineAsync(workflowRunId); + + using var verify = _fixture.BeginScope(); + var staged = await verify.Resolve().AgentRun.AsNoTracking().Where(r => r.WorkflowRunId == workflowRunId).Select(r => r.Id).ToListAsync(); + staged.ShouldHaveSingleItem("the engine staged the agent — an unusable pin inside a pool is the dispatch's to resolve, not a staging failure"); + + return (workflowRunId, staged[0]); + } + + private async Task RunEngineAsync(Guid workflowRunId) + { + using var scope = _fixture.BeginScope(); + await scope.Resolve().ExecuteRunAsync(workflowRunId, CancellationToken.None); + } + + private InMemoryBackgroundJobClient ResolveJobClient() + { + using var scope = _fixture.BeginScope(); + return scope.Resolve(); + } + + private async Task RevokeModelCredentialAsync(Guid credentialId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var credential = await db.ModelCredential.SingleAsync(c => c.Id == credentialId); + + // Mirrors ModelCredentialService.RevokeAsync: status, the key cleared, the soft delete. + credential.Status = CredentialStatus.Revoked; + credential.EncryptedApiKey = null; + credential.DeletedDate = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + /// Records the producer identity the output review hands the critic — the input the reviewer-model avoidance reads — and approves. + private sealed class ProducerRecordingCritic : IStructuredCritic + { + public ReviewModelIdentity? ProducerModel { get; private set; } + + public Task ReviewAsync(CriticRequest request, Guid teamId, Guid? reviewerModelId, CancellationToken cancellationToken) + { + ProducerModel = request.ProducerModel; + return Task.FromResult(new CriticVerdict { Mode = request.Mode, Approved = true, Rationale = "recorded" }); + } + } + + private sealed class RecordingExecutorLogger : ILogger + { + public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Entries.Add((logLevel, exception, formatter(state, exception))); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs index 8b4a7ae5f..be249586d 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.cs @@ -22,6 +22,7 @@ using CodeSpace.Messages.Constants; using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -221,6 +222,49 @@ public async Task A_continuation_whose_transcript_overflows_the_launch_frame_run (await service.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.LaunchRanColdNote, "the timeline says the attempt ran cold, and why"); } + [Fact] + public async Task A_cold_launch_that_escalated_at_dispatch_persists_the_escalated_row() + { + // Tier: high — the real executor, launch frame check and Claude spec. The launch stores the escalated row, then + // the cold record re-persists the envelope with continuity cleared: it must carry that row, not the authored one. + if (OperatingSystem.IsWindows()) return; + + var transcript = new string('x', NativeLaunchProtocol.MaximumFrameBytes + 1); + var teamId = await SeedTeamAsync(); + var credential = await SeedModelCredentialAsync(teamId, "anthropic", "sk-key"); + var baseRow = await SeedModelRowAsync(credential, "base-model", ModelCapabilityTier.Basic); + var strongRow = await SeedModelRowAsync(credential, "strong-model", ModelCapabilityTier.Strong); + Guid runId; + + using (var scope = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + { + var artifactId = await scope.Resolve().PutAsync(teamId, System.Text.Encoding.UTF8.GetBytes(transcript), "text/plain", CancellationToken.None); + var created = await scope.Resolve().CreateAsync( + new AgentTask + { + Goal = "resume the prior work", Harness = "scripted", Model = "base-model", ModelCredentialId = credential, ModelCredentialModelId = baseRow, + ResumeFromSessionId = "session-too-large-to-restore", RestoredTranscriptArtifactId = artifactId, + Escalation = new AgentModelEscalation { Reason = "the prior attempt claimed success but its check failed", From = "base-model" }, + }, + teamId, null, null, iterationKey: "", cancellationToken: CancellationToken.None); + runId = created.Id; + } + + await ExecuteAsync(runId, new ClaudeSpecProjectingHarness("cat >/dev/null; printf 'resumed\\n'")); + + using var verify = _fixture.BeginScope(); + var service = verify.Resolve(); + var run = await service.GetAsync(runId, CancellationToken.None); + + run.Status.ShouldBe(AgentRunStatus.Succeeded, $"fixture check: the run must complete — it failed with: {run.Error}"); + (await service.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ShouldContain(e => e.Text == AgentRunExecutor.LaunchRanColdNote, "fixture check: the launch ran cold"); + + var persisted = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options).ShouldNotBeNull(); + persisted.ResumeFromSessionId.ShouldBeNull("fixture check: the cold record was the last write"); + persisted.Model.ShouldBe("strong-model"); + persisted.ModelCredentialModelId.ShouldBe(strongRow, "the cold record must not put the authored row back over the escalated one"); + } + [Fact] public async Task A_locally_graded_continuation_that_overflows_the_frame_runs_cold_and_is_graded_on_its_contract() { @@ -685,6 +729,99 @@ public async Task An_auto_runs_resolved_credential_default_model_is_persisted_on persisted!.Model.ShouldBe("cred-default-model", "the resolved credential-default model is written to the run's task at dispatch, so the live projection shows it before completion"); } + [Fact] + public async Task A_bounded_run_naming_a_model_outside_its_pool_runs_the_pooled_row_persists_it_and_names_the_move() + { + // The plan-map branch shape: the planner authored a model the operator's pool excludes, pinned to that model's + // own credential. At launch the executor must run the POOLED row — its model on its own key — persist it so a + // re-attach and every reader agree, and say on the run's timeline why the model moved. + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var pooledCredential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-pooled-key"); + var pooledRow = await SeedModelRowAsync(pooledCredential, "pooled-model"); + var outsideCredential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-outside-key"); + await SeedModelRowAsync(outsideCredential, "outside-model"); + + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted-projector", Model = "outside-model", ModelCredentialId = outsideCredential, AllowedModelIds = [pooledRow] }); + + await ExecuteAsync(runId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", "if [ \"$SCRIPTED_MODEL_KEY\" = 'sk-pooled-key' ]; then echo POOLED_KEY; else echo OTHER_KEY; fi")); + + using var scope = _fixture.BeginScope(); + var svc = scope.Resolve(); + var run = await svc.GetAsync(runId, CancellationToken.None); + + run.Status.ShouldBe(AgentRunStatus.Succeeded); + var persisted = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!; + persisted.Model.ShouldBe("pooled-model", "the persisted task is the one that ran — a re-attach redacts and folds against it"); + persisted.ModelCredentialId.ShouldBe(pooledCredential); + persisted.ModelCredentialModelId.ShouldBe(pooledRow, "the row itself is persisted, so a re-attach re-chooses the same row"); + + var events = (await svc.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ToList(); + events.Select(e => e.Text).ShouldContain("POOLED_KEY", "the REAL child process ran on the pooled row's credential, not the excluded model's"); + events.ShouldContain(e => e.Kind == AgentEventKind.Warning && e.Text.Contains("outside-model") && e.Text.Contains("pooled-model"), + customMessage: "the move is named on the run's timeline — never a silent swap"); + } + + [Fact] + public async Task A_bounded_run_whose_pool_resolves_nothing_fails_rather_than_run_outside_it() + { + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var credential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-only-key"); + var pooledRow = await SeedModelRowAsync(credential, "pooled-model"); + await DisableModelRowAsync(pooledRow); // the operator disabled every pooled model after the launch was staged + + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted-projector", Model = "pooled-model", ModelCredentialId = credential, AllowedModelIds = [pooledRow] }); + + await ExecuteAsync(runId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", "echo should-not-run")); + + using var scope = _fixture.BeginScope(); + var svc = scope.Resolve(); + var run = await svc.GetAsync(runId, CancellationToken.None); + + run.Status.ShouldBe(AgentRunStatus.Failed, "an agent bound to a pool that resolves nothing must not run on a model outside it"); + run.Error.ShouldNotBeNull().ShouldContain("allowed models"); + JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!.ExitReason.ShouldBe(FailureCodes.AgentModelPoolUnresolvable, + "a typed, deterministic refusal — the node must not spend its retries meeting the same pool again"); + (await svc.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).Select(e => e.Text).ShouldNotContain("should-not-run"); + } + + [Fact] + public async Task A_bounded_run_pinned_to_one_of_two_same_named_pooled_rows_runs_on_that_rows_own_key() + { + // A model NAME is unique only per credential: the pool holds "sonnet" twice, on two keys. The dispatch used to + // resolve the name, so whichever twin the lookup ranked first ran — billed to the other key, with no note. + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var firstCredential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-first-key"); + var firstRow = await SeedModelRowAsync(firstCredential, "sonnet"); + var secondCredential = await SeedModelCredentialAsync(teamId, "scripted-provider", "sk-second-key"); + var secondRow = await SeedModelRowAsync(secondCredential, "sonnet"); + IReadOnlyList pool = [firstRow, secondRow]; + + Guid nameWinner; + using (var probe = _fixture.BeginScope()) + nameWinner = (await probe.Resolve().ResolveDispatchAsync(teamId, "sonnet", pool, CancellationToken.None))!.ModelCredentialId; + var (pinnedCredential, pinnedRow, pinnedKey) = nameWinner == firstCredential ? (secondCredential, secondRow, "sk-second-key") : (firstCredential, firstRow, "sk-first-key"); + + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted-projector", Model = "sonnet", ModelCredentialId = pinnedCredential, ModelCredentialModelId = pinnedRow, AllowedModelIds = pool }); + + await ExecuteAsync(runId, new ProjectingScriptedHarness("scripted-provider", "SCRIPTED_MODEL_KEY", $"if [ \"$SCRIPTED_MODEL_KEY\" = '{pinnedKey}' ]; then echo PINNED_KEY; else echo OTHER_KEY; fi")); + + using var scope = _fixture.BeginScope(); + var svc = scope.Resolve(); + var run = await svc.GetAsync(runId, CancellationToken.None); + + run.Status.ShouldBe(AgentRunStatus.Succeeded); + var events = (await svc.GetEventsAsync(runId, teamId, 0, CancellationToken.None)).ToList(); + events.Select(e => e.Text).ShouldContain("PINNED_KEY", "the REAL child ran on the pinned row's own key, not its same-named twin's"); + events.ShouldNotContain(e => e.Kind == AgentEventKind.Warning && e.Text.Contains("allowed model pool"), "nothing moved, so nothing is announced"); + JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!.ModelCredentialModelId.ShouldBe(pinnedRow); + } + [Fact] public async Task A_pinned_credential_from_another_team_lands_the_run_failed_clean() { @@ -1555,6 +1692,26 @@ private async Task SeedDefaultCredentialModelAsync(Guid modelCredentialId, strin await db.SaveChangesAsync(); } + /// Seed one enabled credentialed-model row under and return its id — an allowed-pool entry. + private async Task SeedModelRowAsync(Guid modelCredentialId, string modelId, ModelCapabilityTier? tier = null) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + var rowId = Guid.NewGuid(); + db.ModelCredentialModel.Add(new ModelCredentialModel { Id = rowId, ModelCredentialId = modelCredentialId, ModelId = modelId, Enabled = true, CapabilityTier = tier }); + await db.SaveChangesAsync(); + return rowId; + } + + private async Task DisableModelRowAsync(Guid rowId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + (await db.ModelCredentialModel.SingleAsync(m => m.Id == rowId)).Enabled = false; + await db.SaveChangesAsync(); + } + private async Task SeedModelCredentialAsync(Guid teamId, string provider, string plaintextKey) { using var scope = _fixture.BeginScope(); @@ -1880,12 +2037,12 @@ public ValueTask DisposeAsync() } } - private async Task ExecuteAsync(Guid runId, IAgentHarness harness, IAgentRunLogCaptureBridge? logCapture = null, IAgentRunCompletionNotifier? notifier = null, ISandboxRunnerRegistry? runners = null, CodeSpace.Core.Services.Agents.Credentials.IModelCredentialBroker? credentialBroker = null, CodeSpace.Core.Services.Review.IStructuredCritic? critic = null, CancellationToken cancellationToken = default, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, bool productionCapturePlanes = false) + private async Task ExecuteAsync(Guid runId, IAgentHarness harness, IAgentRunLogCaptureBridge? logCapture = null, IAgentRunCompletionNotifier? notifier = null, ISandboxRunnerRegistry? runners = null, CodeSpace.Core.Services.Agents.Credentials.IModelCredentialBroker? credentialBroker = null, CodeSpace.Core.Services.Review.IStructuredCritic? critic = null, CancellationToken cancellationToken = default, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, bool productionCapturePlanes = false, Microsoft.Extensions.Logging.ILogger? logger = null) { using var relay = credentialBroker is null ? null : RelayHelper.UseBuilt(); using var scope = _fixture.BeginScope(); - await NewExecutor(scope, harness, logCapture, notifier, runners, credentialBroker, critic, lifetime, productionCapturePlanes).ExecuteAsync(runId, cancellationToken); + await NewExecutor(scope, harness, logCapture, notifier, runners, credentialBroker, critic, lifetime, productionCapturePlanes, logger: logger).ExecuteAsync(runId, cancellationToken); } /// Drive the RE-ATTACH entry point with the same executor wiring uses — the terminal a run reaches when it finishes on a worker that never saw its launch. The broker is the NEXT worker's, never the launching one's: a re-attach that shared a broker with the launch would never exercise the re-bind at all. @@ -1938,7 +2095,7 @@ public void Dispose() } } - private AgentRunExecutor NewExecutor(Autofac.ILifetimeScope scope, IAgentHarness harness, IAgentRunLogCaptureBridge? logCapture = null, IAgentRunCompletionNotifier? notifier = null, ISandboxRunnerRegistry? runners = null, CodeSpace.Core.Services.Agents.Credentials.IModelCredentialBroker? credentialBroker = null, CodeSpace.Core.Services.Review.IStructuredCritic? critic = null, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, bool productionCapturePlanes = false, IAgentRunService? runs = null, CodeSpace.Core.Services.RunData.IRunDataCompletenessWriter? completeness = null, TimeProvider? clock = null) + private AgentRunExecutor NewExecutor(Autofac.ILifetimeScope scope, IAgentHarness harness, IAgentRunLogCaptureBridge? logCapture = null, IAgentRunCompletionNotifier? notifier = null, ISandboxRunnerRegistry? runners = null, CodeSpace.Core.Services.Agents.Credentials.IModelCredentialBroker? credentialBroker = null, CodeSpace.Core.Services.Review.IStructuredCritic? critic = null, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, bool productionCapturePlanes = false, IAgentRunService? runs = null, CodeSpace.Core.Services.RunData.IRunDataCompletenessWriter? completeness = null, TimeProvider? clock = null, Microsoft.Extensions.Logging.ILogger? logger = null) { var executor = new AgentRunExecutor( runs ?? scope.Resolve(), @@ -1956,7 +2113,7 @@ private AgentRunExecutor NewExecutor(Autofac.ILifetimeScope scope, IAgentHarness scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve>(), - NullLogger.Instance, + logger ?? NullLogger.Instance, logCapture, // The native record plane is DI-registered in production (NativeRecordPlane : IScopedDependency), so a // test that leaves it null is not running the shape production runs. @@ -2227,6 +2384,30 @@ public IReadOnlyList ParseEvents(string rawLine) => : new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = "non-zero-exit", Error = $"exit {exitCode}" }); } + /// The real Claude spec and credential projection around a shell script: the launch frame is the production one, and a pinned credential resolves. + private sealed class ClaudeSpecProjectingHarness(string script) : IAgentHarness, IModelCredentialProjector + { + private readonly ClaudeCodeHarness _real = new(); + + public string Kind => "scripted"; + public string Version => "test"; + public IReadOnlyList Models { get; } = new[] { "base-model", "strong-model" }; + + public SandboxSpec BuildInvocation(AgentTask task) => _real.BuildInvocation(task) with { Command = "/bin/sh", Args = new[] { "-c", script } }; + + public IReadOnlyList ParseEvents(string rawLine) => + string.IsNullOrWhiteSpace(rawLine) ? Array.Empty() : new[] { new AgentEvent { Kind = AgentEventKind.AssistantMessage, Text = rawLine.Trim() } }; + + public IAgentEventFolder CreateFolder() => new TestEventFolder((fold, exitCode) => + exitCode == 0 + ? new AgentRunResult { Status = AgentRunStatus.Succeeded, ExitReason = "completed", Summary = fold.LastText } + : new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = "non-zero-exit", Error = $"exit {exitCode}" }); + + public IReadOnlyList SupportedProviders => _real.SupportedProviders; + + public IReadOnlyDictionary ProjectToEnv(ResolvedModelCredential credential) => _real.ProjectToEnv(credential); + } + /// /// A scripted harness (kind "scripted") that delegates BOTH halves under test — the stream parse and the result /// fold — to the REAL Claude adapter, and owns only the invocation: the test needs a process it can make die a diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs index f1c53e922..1910ccb4a 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunReviseLoopFlowTests.cs @@ -458,7 +458,7 @@ public async Task A_round_that_over_claims_escalates_the_next_round_to_a_stronge // The run's OWN credential holds the ladder it may climb… var credentialId = await SeedModelCredentialAsync(teamId); await SeedTieredModelAsync(credentialId, "test-model", ModelCapabilityTier.Basic); - await SeedTieredModelAsync(credentialId, "test-model-pro", ModelCapabilityTier.Strong); + var proRowId = await SeedTieredModelAsync(credentialId, "test-model-pro", ModelCapabilityTier.Strong); // …and a SECOND credential of the SAME provider holds a stronger model still. A team credentials one // provider twice all the time (a direct vendor key beside a gateway with its own base URL and family), and @@ -491,11 +491,52 @@ public async Task A_round_that_over_claims_escalates_the_next_round_to_a_stronge result.ModelEscalation.Reason.ShouldContain("claimed success"); result.ProposedEscalation.ShouldBeNull("the run ended PASSING — it owes the next attempt nothing"); + var persisted = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options).ShouldNotBeNull(); + persisted.Model.ShouldBe("test-model-pro"); + persisted.ModelCredentialModelId.ShouldBe(proRowId, "the stored envelope names the ROW the revise round runs, not only its model — a re-attach and the identity strip read it"); + var events = await LoadEventsAsync(runId); events.ShouldContain(e => e.StartsWith(AgentRunExecutor.ModelEscalationPrefix, StringComparison.Ordinal) && e.Contains("test-model → test-model-pro"), "the operator sees the run reached for a stronger model and why"); } + [Fact] + public async Task A_later_round_that_does_not_escalate_is_reviewed_as_the_row_an_earlier_round_escalated_to() + { + // The first attempt fails the check; revise round 1 escalates, passes the check, and is flagged by the critic; + // revise round 2 asks for no escalation. Both revise rounds run on the escalated model, so both reviews must name + // ITS row as the producer — a later round that fell back to the authored row would let the reviewer pick the + // model that did the work. + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + var (teamId, userId) = await SeedTeamAsync(); + var credentialId = await SeedModelCredentialAsync(teamId); + await SeedTieredModelAsync(credentialId, "test-model", ModelCapabilityTier.Basic); + var proRowId = await SeedTieredModelAsync(credentialId, "test-model-pro", ModelCapabilityTier.Strong); + var reviewerRowId = await SeedCriticModelAsync(teamId); + ResetCriticScript(); + + using var remote = new BareRemote(); + await remote.SeedBaseAsync(CheckScript); + var repoId = await SeedBoundRepositoryAsync(teamId, remote.Url); + + var runId = await CreateRunAsync(teamId, userId, TaskWith(repoId) with { ModelCredentialId = credentialId, OutputReviewMode = ReviewMode.Improve, ReviewerModelId = reviewerRowId, MaxReviseRounds = 2 }); + + var harness = new ReviseAwareHarness(first: DraftScript, revised: HalfFixScript, later: RevisedScript); + var critic = new ProducerRecordingCritic(_fixture); + await ExecuteAsync(runId, harness, critic: critic); + + var (run, result) = await LoadAsync(runId); + + run.Status.ShouldBe(AgentRunStatus.Succeeded, "revise round 2's clean work passes both gates"); + result.ReviseRounds.ShouldBe(2); + harness.InvokedModels.AsEnumerable().ShouldBe(new[] { "test-model", "test-model-pro", "test-model-pro" }, "fixture check: only round 1 escalated, and the model held"); + + critic.Producers.Count.ShouldBe(2, "fixture check: the half-fixed round and the clean round were each reviewed"); + critic.Producers.ShouldAllBe(p => p != null && p.ModelCredentialModelId == proRowId, "every review names the escalated row as the producer"); + } + [Fact] public async Task Escalation_jumps_to_the_strongest_model_above_the_floor_and_then_says_it_is_out_of_room() { @@ -541,6 +582,39 @@ public async Task Escalation_jumps_to_the_strongest_model_above_the_floor_and_th result.ProposedEscalation.To.ShouldBeNull("nothing is credentialed above Frontier — a respawn would re-burn the same model, so the node keeps the failure terminal"); } + [Fact] + public async Task A_run_bound_to_an_allowed_model_pool_never_escalates_outside_it() + { + // The operator's pool holds only the Basic model; a Frontier one sits on the run's OWN credential, outside it. + // A Quick launch at Delivery now always carries a check, so a failing check is the ordinary path — and it used + // to climb straight past the pool ("test-model → test-model-ultra"), in the revise round and for the next attempt. + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + var (teamId, userId) = await SeedTeamAsync(); + var credentialId = await SeedModelCredentialAsync(teamId); + var pooled = await SeedTieredModelAsync(credentialId, "test-model", ModelCapabilityTier.Basic); + await SeedTieredModelAsync(credentialId, "test-model-ultra", ModelCapabilityTier.Frontier); + + using var remote = new BareRemote(); + await remote.SeedBaseAsync("#!/bin/sh\nexit 1\n"); // an unfixable check — every round asks to escalate + var repoId = await SeedBoundRepositoryAsync(teamId, remote.Url); + + var runId = await CreateRunAsync(teamId, userId, TaskWith(repoId) with { ModelCredentialId = credentialId, MaxReviseRounds = 1, AllowedModelIds = [pooled] }); + + var harness = new ReviseAwareHarness(first: DraftScript, revised: RevisedScript); + await ExecuteAsync(runId, harness); + + var (_, result) = await LoadAsync(runId); + + harness.InvokedModels.AsEnumerable().ShouldBe(new[] { "test-model", "test-model" }, + customMessage: "the revise round asked for a stronger model — and the pool holds none, so it re-ran the pooled one instead of test-model-ultra"); + result.ModelEscalation.ShouldNotBeNull("the run still reached for a stronger model and said it could not"); + result.ModelEscalation!.To.ShouldBeNull("nothing stronger is INSIDE the pool"); + result.ProposedEscalation.ShouldNotBeNull("the run ended failing, so it answers for the next attempt too"); + result.ProposedEscalation!.To.ShouldBeNull("the next attempt may not climb past the pool either — the node keeps the failure terminal instead of respawning onto test-model-ultra"); + } + [Fact] public async Task A_one_model_team_keeps_its_only_model_and_says_no_stronger_one_exists_ONCE() { @@ -691,15 +765,17 @@ private async Task SeedModelCredentialAsync(Guid teamId) return credentialId; } - /// One credentialed pool model with a DECLARED capability tier — the ordering the escalation pick reads (WorkflowsTestSeed.SeedCredentialedModelAsync leaves the tier Unknown, which no escalation can rank). - private async Task SeedTieredModelAsync(Guid credentialId, string modelId, ModelCapabilityTier tier) + /// One credentialed pool model with a DECLARED capability tier — the ordering the escalation pick reads (WorkflowsTestSeed.SeedCredentialedModelAsync leaves the tier Unknown, which no escalation can rank). Returns the row id — an allowed-pool entry. + private async Task SeedTieredModelAsync(Guid credentialId, string modelId, ModelCapabilityTier tier) { using var scope = _fixture.BeginScope(); var db = scope.Resolve(); - db.ModelCredentialModel.Add(new ModelCredentialModel { Id = Guid.NewGuid(), ModelCredentialId = credentialId, ModelId = modelId, Source = ModelSource.Manual, Enabled = true, CapabilityTier = tier }); + var rowId = Guid.NewGuid(); + db.ModelCredentialModel.Add(new ModelCredentialModel { Id = rowId, ModelCredentialId = credentialId, ModelId = modelId, Source = ModelSource.Manual, Enabled = true, CapabilityTier = tier }); await db.SaveChangesAsync(); + return rowId; } /// The reviewer model row under the content-keyed critic fake's provider tag — pinned onto the task so the reviewer resolution is deterministic even beside other seeded rows. @@ -753,7 +829,7 @@ private async Task SeedBoundRepositoryAsync(Guid teamId, string cloneUrlHt // ─── Execution ─────────────────────────────────────────────────────────── - private async Task ExecuteAsync(Guid runId, IAgentHarness harness, ISandboxRunner? runner = null, TimeProvider? clock = null) + private async Task ExecuteAsync(Guid runId, IAgentHarness harness, ISandboxRunner? runner = null, TimeProvider? clock = null, CodeSpace.Core.Services.Review.IStructuredCritic? critic = null) { using var scope = _fixture.BeginScope(); var executor = new AgentRunExecutor( @@ -767,7 +843,7 @@ private async Task ExecuteAsync(Guid runId, IAgentHarness harness, ISandboxRunne scope.Resolve(), scope.Resolve(), scope.Resolve(), - scope.Resolve(), + critic ?? scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), @@ -1027,13 +1103,29 @@ public Task AttachAsync(SandboxHandle handle, Func TerminateAsync(SandboxHandle handle, CancellationToken cancellationToken) => _inner.TerminateAsync(handle, cancellationToken); } + /// The real critic, recording the producer identity each review was handed. + private sealed class ProducerRecordingCritic(PostgresFixture fixture) : CodeSpace.Core.Services.Review.IStructuredCritic + { + public List Producers { get; } = new(); + + public async Task ReviewAsync(CodeSpace.Core.Services.Review.CriticRequest request, Guid teamId, Guid? reviewerModelId, CancellationToken cancellationToken) + { + Producers.Add(request.ProducerModel); + + using var scope = fixture.BeginScope(); + return await scope.Resolve().ReviewAsync(request, teamId, reviewerModelId, cancellationToken); + } + } + private sealed class ReviseAwareHarness : IAgentHarness { private readonly string _first; private readonly string _revised; private readonly string? _requiredDiagnosis; + private readonly string? _later; - public ReviseAwareHarness(string first, string revised, string? requiredDiagnosis = null) { _first = first; _revised = revised; _requiredDiagnosis = requiredDiagnosis; } + /// When set, the script from the THIRD invocation on (the second revise round), instead of . + public ReviseAwareHarness(string first, string revised, string? requiredDiagnosis = null, string? later = null) { _first = first; _revised = revised; _requiredDiagnosis = requiredDiagnosis; _later = later; } public string Kind => "scripted"; public string Version => "test"; @@ -1055,7 +1147,7 @@ public SandboxSpec BuildInvocation(AgentTask task) return new SandboxSpec { Command = "/bin/sh", - Args = new[] { "-c", task.Goal.StartsWith(AgentRunExecutor.ReviseInstructionPrefix, StringComparison.Ordinal) && (_requiredDiagnosis == null || task.Goal.Contains(_requiredDiagnosis, StringComparison.Ordinal)) ? _revised : _first }, + Args = new[] { "-c", task.Goal.StartsWith(AgentRunExecutor.ReviseInstructionPrefix, StringComparison.Ordinal) && (_requiredDiagnosis == null || task.Goal.Contains(_requiredDiagnosis, StringComparison.Ordinal)) ? (_later is not null && InvokedModels.Count >= 3 ? _later : _revised) : _first }, WorkingDirectory = task.WorkspaceDirectory, TimeoutSeconds = task.TimeoutSeconds, }; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/PlanMapSynthPlannerRequest.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/PlanMapSynthPlannerRequest.cs index b01825b7e..e6fd5d751 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/PlanMapSynthPlannerRequest.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/PlanMapSynthPlannerRequest.cs @@ -53,7 +53,8 @@ public static async Task BuildAsync(ILifetimeSco var planRequest = PlanAuthorNode.BuildPlanRequest(config, teamId, new PlanAuthorNode.PlanPromptParts(goal, [], grounding, "")); - var pool = await scope.Resolve().ListPoolAsync(teamId, allowedRowIds: null, cancellationToken).ConfigureAwait(false); + // The production planner lists the pool bounded to the request's allowed models — null for this unbounded launch, so the key is unchanged. + var pool = await scope.Resolve().ListPoolAsync(teamId, planRequest.AllowedModelIds, cancellationToken).ConfigureAwait(false); var catalog = CapabilityCatalog.Render(scope.Resolve().All, pool); return new StructuredLLMCompletionRequest diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/PlannerModelPoolFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/PlannerModelPoolFlowTests.cs new file mode 100644 index 000000000..c7a535c92 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/PlannerModelPoolFlowTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Core.Services.Learning; +using CodeSpace.Core.Services.Workflows.Llm; +using CodeSpace.Core.Services.Workflows.Planning.Planners; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Dtos.Workflows.Planning; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// 🟢 Integration (real Postgres + real pool selector + real harness registry + the real planner; a capturing fake at +/// the structured-LLM seam): the capability catalog a plan-map planner allocates subtask models from is bounded to the +/// operator's allowed model pool. It used to list the WHOLE team pool, so a planner could only author a model the +/// dispatch then had to clamp; now it is shown the pool it must choose from, and an unbounded request is unchanged. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class PlannerModelPoolFlowTests +{ + private readonly PostgresFixture _fixture; + + public PlannerModelPoolFlowTests(PostgresFixture fixture) { _fixture = fixture; } + + [Fact] + public async Task The_planners_catalog_lists_only_the_allowed_pool_and_the_whole_pool_when_unbounded() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-sonnet"); + await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "outside-opus"); + + var bounded = await PlannerPromptAsync(new WorkflowPlanRequest { TaskText = "split the migration", TeamId = teamId, AllowedModelIds = [pooled.RowId] }); + var unbounded = await PlannerPromptAsync(new WorkflowPlanRequest { TaskText = "split the migration", TeamId = teamId }); + + bounded.ShouldContain("pooled-sonnet", customMessage: "the pooled model is on the planner's menu"); + bounded.ShouldNotContain("outside-opus", customMessage: "a model outside the operator's pool must not be offered to the planner at all"); + unbounded.ShouldContain("pooled-sonnet"); + unbounded.ShouldContain("outside-opus", customMessage: "no pool ⇒ the whole team pool, the catalog the planner has always seen"); + } + + private async Task PlannerPromptAsync(WorkflowPlanRequest request) + { + using var scope = _fixture.BeginScope(); + var client = new CapturingClient(); + var db = scope.Resolve(); + var memory = new PlannerLessonMemory(db, new LessonReader(db, NoLessons.Instance), scope.Resolve>()); + var planner = new LlmWorkflowPlanner(new SingleClient(client), scope.Resolve(), scope.Resolve(), memory); + + await planner.PlanAsync(request, CancellationToken.None); + return client.LastUserPrompt.ShouldNotBeNull("the planner never reached the model"); + } + + /// No lesson is seeded, so relevance is never asked; a call here is a planner reaching for lessons it cannot have. + private sealed class NoLessons : ILessonRelevanceEvaluator + { + public static readonly NoLessons Instance = new(); + public Task EvaluateAsync(LessonRelevanceRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class SingleClient : ILLMClientRegistry + { + public SingleClient(IStructuredLLMClient structured) => All = new ILLMClient[] { (ILLMClient)structured }; + public IReadOnlyList All { get; } + public ILLMClient Resolve(string provider) => All.First(); + } + + private sealed class CapturingClient : ILLMClient, IStructuredLLMClient + { + public string Provider => "Anthropic"; + public string? LastUserPrompt { get; private set; } + public Task CompleteAsync(LLMCompletionRequest request, CancellationToken ct) => throw new NotSupportedException(); + public Task CompleteStructuredAsync(StructuredLLMCompletionRequest request, CancellationToken ct) + { + LastUserPrompt = request.UserPrompt; + return Task.FromResult(new StructuredLLMCompletion + { + Json = JsonSerializer.SerializeToElement(new { goal = "split it", subtasks = new[] { new { id = "s1", title = "T", instruction = "do it" } } }), + Model = request.Model, + }); + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchContractFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchContractFlowTests.cs index 5391b43e0..42c54576d 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchContractFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchContractFlowTests.cs @@ -4,6 +4,8 @@ using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Tasks; using CodeSpace.Core.Services.Tasks.Contracts; +using CodeSpace.Core.Services.Tasks.Launch; +using CodeSpace.Core.Services.Tasks.Launch.Exceptions; using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Core.Services.Completion; using CodeSpace.Core.Services.Workflows; @@ -42,19 +44,21 @@ public TaskLaunchContractFlowTests(PostgresFixture fixture) public void Dispose() => _manualExecution.Dispose(); [Theory] - [InlineData(TaskEffortModes.Quick, TaskProjectionKinds.SingleAgent)] - [InlineData(TaskEffortModes.Standard, TaskProjectionKinds.PlanMapSynth)] - [InlineData(TaskEffortModes.Deep, TaskProjectionKinds.Supervisor)] - public async Task Every_launch_lane_records_original_controls_in_the_frozen_run_detail(string effort, string projectionKind) + [InlineData(TaskEffortModes.Quick, TaskProjectionKinds.SingleAgent, true, false)] // the single agent grades its floor in its own workspace + [InlineData(TaskEffortModes.Standard, TaskProjectionKinds.PlanMapSynth, false, false)] // plan-map grades no operator floor: sending one is refused (pinned below) + [InlineData(TaskEffortModes.Deep, TaskProjectionKinds.Supervisor, true, true)] // the supervisor grades it against the branch it publishes + public async Task Every_launch_lane_records_original_controls_in_the_frozen_run_detail(string effort, string projectionKind, bool gradesOperatorFloor, bool floorNeedsRepository) { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + Guid? repoId = floorNeedsRepository ? await SeedRepositoryAsync(teamId) : null; + using var scope = _fixture.BeginScope(); var request = new TaskLaunchRequest { - TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, RepositoryId = repoId, TaskText = "Preserve the original delivery obligations", RequestedEffort = effort, Autonomy = "Unleashed", CapsOverride = new() { MaxCostUsd = 3.25m, AutonomyCeiling = "Standard" }, - AcceptanceCriteria = ["Use original inputs", "Explain limitations"], AcceptanceChecks = ["sh", "verify.sh"], + AcceptanceCriteria = ["Use original inputs", "Explain limitations"], AcceptanceChecks = gradesOperatorFloor ? ["sh", "verify.sh"] : null, DeliverySpec = new() { OpenPullRequest = false, TargetBranch = "review" }, AllowedModelIds = [], AllowedAgentDefinitionIds = [], RequirePlanConfirmation = false, Overrides = new() { Harness = "codex-cli", AllowedTools = ["Read", "Grep"], PushBranch = false, EnableMcp = false }, @@ -87,7 +91,7 @@ public async Task Every_launch_lane_records_original_controls_in_the_frozen_run_ contract.SupervisorModelSelection.ShouldNotBeNull("the supervisor decision is frozen before execution and survives the real PostgreSQL snapshot path"); run.DefinitionSnapshotJson.ShouldContain(contract.SupervisorModelSelection!.ModelCredentialModelId.ToString(), Case.Insensitive); contract.SupervisorModelSelection.Mode.ShouldBe(RunModeKeys.Supervisor); - contract.SupervisorModelSelection.CapabilityKey.ShouldBe(CapabilityKeys.InlineAnswer); + contract.SupervisorModelSelection.CapabilityKey.ShouldBe(repoId is null ? CapabilityKeys.InlineAnswer : CapabilityKeys.GitBranch, "repository-scoped work produces a reviewable branch"); contract.PlannerModelSelection.ShouldBeNull(); } else if (projectionKind == TaskProjectionKinds.PlanMapSynth) @@ -107,6 +111,26 @@ public async Task Every_launch_lane_records_original_controls_in_the_frozen_run_ (await scope.Resolve().AgentRun.CountAsync(r => r.WorkflowRunId == launched.RunId)).ShouldBe(0, "this verifies persistence, not model execution or control enforcement"); } + [Fact] + public async Task A_standard_launch_carrying_an_operator_floor_is_refused_and_records_nothing() + { + // The plan-map lane grades no operator floor, so the floor is refused by name before a session or run exists — + // it used to be recorded in the contract and silently never graded. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var scope = _fixture.BeginScope(); + var request = new TaskLaunchRequest + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Preserve the original delivery obligations", RequestedEffort = TaskEffortModes.Standard, + AcceptanceChecks = ["sh", "verify.sh"], + }; + + var ex = await Should.ThrowAsync(() => scope.Resolve().LaunchAsync(request, CancellationToken.None)); + + ex.Message.ShouldContain(LaunchControlResolver.FloorNotGradedReason); + (await scope.Resolve().WorkflowRun.CountAsync(r => r.TeamId == teamId)).ShouldBe(0, "no run, no contract — nothing records a floor nothing would grade"); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -225,6 +249,23 @@ private async Task StartAsync(Guid teamId, Guid userId, WorkflowDefinition return await scope.Resolve().StartFromSnapshotAsync(definition, teamId, userId, "{}", null, null, null, CancellationToken.None); } + /// A provider instance + an active repository in the team — enough for the launch's team-scope guard, and a workspace an argv floor can be graded against. + private async Task SeedRepositoryAsync(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; + } + private async Task ExecuteAsync(Guid runId) { using var scope = _fixture.BeginScope(); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchFlowTests.cs index cb8e4c51e..40a069534 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskLaunchFlowTests.cs @@ -5,7 +5,11 @@ using CodeSpace.Core.Services.Agents; using CodeSpace.Core.Services.Supervisor; using CodeSpace.Core.Services.Tasks; +using CodeSpace.Core.Services.Tasks.Effort; +using CodeSpace.Core.Services.Tasks.Effort.Classifiers.Heuristic; +using CodeSpace.Core.Services.Tasks.Effort.Classifiers.Llm; using CodeSpace.Core.Services.Tasks.Launch; +using CodeSpace.Core.Services.Tasks.Launch.Exceptions; using CodeSpace.Core.Services.Tasks.RoutePreview; using CodeSpace.IntegrationTests.Infrastructure; using CodeSpace.IntegrationTests.Infrastructure.Jobs; @@ -15,6 +19,7 @@ using CodeSpace.Messages.Commands.Tasks; using CodeSpace.Messages.Constants; using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; using CodeSpace.Messages.Tasks; using CodeSpace.Messages.Tasks.Effort; using MediatR; @@ -933,22 +938,35 @@ public async Task A_deep_launch_bakes_the_operator_output_review_into_the_superv // ── P3.2: the QualityTier mandate — Delivery/Unattended on a Deep (supervisor) launch requires an acceptance floor ── - [Fact] - public async Task A_deep_launch_at_delivery_quality_without_an_acceptance_check_is_rejected_before_any_run_is_created() + [Theory] + [InlineData(TaskEffortModes.Deep, true, "acceptanceChecks")] + [InlineData(TaskEffortModes.Quick, false, "acceptanceChecks")] // Quick grades its single agent's argv exactly as Deep grades its stop, repository or not — it used to launch unmandated + [InlineData(TaskEffortModes.Deep, false, "bind a repository")] // Deep grades only against a repository: authoring a check could not satisfy it, so that is never the remedy + public async Task A_launch_at_delivery_quality_without_an_acceptance_check_is_refused_as_its_preview_predicts(string effort, bool withRepository, string remedy) { + // The tier's floor mandate is a control disposition now: the same refusal code as every other, and the preview + // names it before launch — it used to be a bare 400 no preview could predict. var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); var request = new TaskLaunchRequest { TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, - TaskText = "Ship the whole feature", RequestedEffort = TaskEffortModes.Deep, + TaskText = "Ship the whole feature", RequestedEffort = effort, RepositoryId = withRepository ? await SeedRepositoryAsync(teamId) : null, Tier = QualityTier.Delivery, }; - var ex = await Should.ThrowAsync(() => LaunchAsync(request)); + TaskRoutePreviewResult preview; + using (var scope = _fixture.BeginScope()) preview = await scope.Resolve().PreviewAsync(request, CancellationToken.None); - ex.Message.ShouldContain("acceptanceChecks", Case.Insensitive, "the operator needs an actionable name for the missing lever"); - (await CountRunsForTeamAsync(teamId)).ShouldBe(0, "the mandate rejects BEFORE any run/session is created — no orphan"); + var predicted = preview.ControlDispositions!.Single(d => d.Control == LaunchControls.Tier); + predicted.Outcome.ShouldBe(LaunchControlOutcome.Refused); + + var ex = await Should.ThrowAsync(() => LaunchAsync(request)); + + ex.Code.ShouldBe(FailureCodes.TaskLaunchControlRefused); + ((IReadOnlyList)ex.Details["controls"]!).ShouldHaveSingleItem().ShouldBe(predicted, "the launch refuses with exactly what its preview predicted"); + ex.Message.ShouldContain(remedy, Case.Insensitive, "the operator needs the one lever that clears this refusal"); + (await CountRunsForTeamAsync(teamId)).ShouldBe(0, "the mandate refuses BEFORE any run/session is created — no orphan"); } [Fact] @@ -961,11 +979,12 @@ public async Task A_deep_launch_at_delivery_quality_with_an_acceptance_check_suc try { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var repoId = await SeedRepositoryAsync(teamId); // an argv floor is graded against a repository workspace — repo-less it is refused var result = await LaunchAsync(new TaskLaunchRequest { TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, - TaskText = "Ship the whole feature", RequestedEffort = TaskEffortModes.Deep, + TaskText = "Ship the whole feature", RequestedEffort = TaskEffortModes.Deep, RepositoryId = repoId, Tier = QualityTier.Delivery, AcceptanceChecks = new[] { "sh", "check.sh" }, }); @@ -983,31 +1002,6 @@ public async Task A_deep_launch_at_delivery_quality_with_an_acceptance_check_suc } } - [Fact] - public async Task A_quick_launch_at_delivery_quality_is_never_rejected_for_a_missing_acceptance_check() - { - // AcceptanceChecks is inert on a non-supervisor projection today — the mandate doesn't invent new - // acceptance-floor plumbing for single-agent launches, so it stays inert there too. - if (OperatingSystem.IsWindows()) return; - - using var cli = new SubtaskAwareFakeCli(); - - var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); - - var jobClient = ResolveJobClient(); - jobClient.Clear(); - jobClient.AutoExecute = true; - - var result = await LaunchAsync(new TaskLaunchRequest - { - TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, - TaskText = "Touch nothing important", RequestedEffort = TaskEffortModes.Quick, - Tier = QualityTier.Delivery, - }); - - result.RunId.ShouldNotBe(Guid.Empty, "a Quick/single-agent Delivery launch is never rejected for the missing acceptance floor — the mandate is inert there"); - } - [Fact] public async Task A_foreign_allowed_model_row_is_rejected_fail_closed_and_never_leaked() { @@ -1508,6 +1502,314 @@ public async Task A_repo_bound_answer_launch_is_graded_but_publishes_no_branch() } } + // ── 3.3: Auto's controls are applied, clamped, named not-applicable, or refused on every lane — never dropped ── + // + // The auto path below is answered by a CONFIDENT classification (the structured-LLM classifier's slot) — the one case + // the composer's confirm card never sees, where a route to a non-supervisor lane used to drop these controls silently. + + [Fact] + public async Task An_auto_route_to_quick_clamps_a_pinned_model_outside_the_allowed_pool_and_says_so() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-model"); + var outside = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "outside-model"); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); // the frozen snapshot is the evidence; dispatch is pinned in AgentRunExecutorTests + + var request = AutoRequest(teamId, userId, "Fix the typo in the README") with + { + AllowedModelIds = [pooled.RowId], + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local", ModelCredentialModelId = outside.RowId }, + }; + + var result = await LaunchOnConfidentAutoAsync(request, new ConfidentClassifier(QuickSignals, TaskRecipeKinds.SingleAgent)); + + result.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent); + var models = result.ControlDispositions.Single(d => d.Control == LaunchControls.AllowedModelIds); + models.Outcome.ShouldBe(LaunchControlOutcome.Clamped, "the operator pinned a model their own pool excludes — the pool wins, and the result says so"); + models.Reason.ShouldBe("Model 'outside-model' on 'test cred' is not in this run's allowed model pool; running 'pooled-model' on 'test cred' instead.", "named by model and credential, never by a row id"); + + var config = FrozenAgentConfig((await LoadRunAsync(result.RunId)).DefinitionSnapshotJson!); + config.GetProperty("model").GetString().ShouldBe("pooled-model", "the frozen agent runs the pool's default row, not the pin outside it"); + config.GetProperty("modelCredentialId").GetString().ShouldBe(pooled.CredentialId.ToString(), "…on that row's own credential — names repeat across credentials, so the row decides"); + config.GetProperty("modelCredentialModelId").GetString().ShouldBe(pooled.RowId.ToString(), "the excluded row pin is replaced by the pooled row itself, the one the dispatch re-chooses"); + config.GetProperty("allowedModelIds").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { pooled.RowId.ToString() }, "the pool rides the node too, holding a persona-supplied model to it at dispatch"); + } + + [Fact] + public async Task An_auto_route_to_quick_refuses_a_persona_the_allowed_pool_excludes_before_any_run() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var admitted = await SeedPersonaAsync(teamId); + var excluded = await SeedPersonaAsync(teamId, "Release Captain"); + + var request = AutoRequest(teamId, userId, "Fix the typo in the README") with + { + AllowedAgentDefinitionIds = [admitted], + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local", AgentDefinitionId = excluded }, + }; + + var ex = await Should.ThrowAsync(() => LaunchOnConfidentAutoAsync(request, new ConfidentClassifier(QuickSignals, TaskRecipeKinds.SingleAgent))); + + ex.Code.ShouldBe(FailureCodes.TaskLaunchControlRefused); + var refused = (IReadOnlyList)ex.Details["controls"]!; + refused.ShouldHaveSingleItem().Control.ShouldBe(LaunchControls.AllowedAgentDefinitionIds); + ex.Message.ShouldContain("'Release Captain'", customMessage: "the refusal names the persona the operator excluded, the way the model notes name a model"); + ex.Message.ShouldNotContain(excluded.ToString(), customMessage: "never by its id"); + (await CountRunsForTeamAsync(teamId)).ShouldBe(0, "a refused control stops the launch before any session or run exists"); + } + + [Fact] + public async Task A_persona_of_another_team_outside_the_pool_is_refused_without_its_name_or_id() + { + // The launch guards reject a foreign persona long before the resolver runs, so this reaches the resolver's own + // lookup directly: the refusal must still hold, and must not be a way to read another team's persona name. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var (otherTeamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var admitted = await SeedPersonaAsync(teamId); + var foreign = await SeedPersonaAsync(otherTeamId, "Other Team Secret Persona"); + + var request = AutoRequest(teamId, userId, "Fix the typo in the README") with + { + AllowedAgentDefinitionIds = [admitted], + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local", AgentDefinitionId = foreign }, + }; + + using var scope = _fixture.BeginScope(); + var resolution = await scope.Resolve().ResolveAsync(request, new TaskLaunchSeed { Goal = "g", SurfaceKind = "chat", TeamId = teamId }, new RoutePlan { ProjectionKind = TaskProjectionKinds.SingleAgent }, CancellationToken.None); + + var disposition = resolution.Dispositions.Single(d => d.Control == LaunchControls.AllowedAgentDefinitionIds); + disposition.Outcome.ShouldBe(LaunchControlOutcome.Refused); + disposition.Reason.ShouldNotBeNull().ShouldNotContain("Other Team Secret Persona", customMessage: "a persona this team cannot see stays unnamed"); + disposition.Reason.ShouldNotContain(foreign.ToString()); + } + + [Fact] + public async Task An_explicit_standard_launch_with_an_operator_floor_is_refused_with_the_reason_its_preview_gives() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var request = new TaskLaunchRequest + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Refactor the parser across modules", RequestedEffort = TaskEffortModes.Standard, + AcceptanceChecks = ["sh", "verify.sh"], + }; + + TaskRoutePreviewResult preview; + using (var scope = _fixture.BeginScope()) preview = await scope.Resolve().PreviewAsync(request, CancellationToken.None); + + preview.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Incompatible); + var previewed = preview.ControlDispositions!.Single(d => d.Control == LaunchControls.AcceptanceChecks); + previewed.Outcome.ShouldBe(LaunchControlOutcome.Refused); + + var ex = await Should.ThrowAsync(() => LaunchAsync(request with { RouteSnapshotId = preview.RouteSnapshotId })); + + var refused = ((IReadOnlyList)ex.Details["controls"]!).ShouldHaveSingleItem(); + refused.Reason.ShouldBe(preview.AcceptanceCompatibility.Detail, "the launch refuses with the very reason its preview gave — preview and launch can no longer disagree"); + refused.ShouldBe(previewed); + (await CountRunsForTeamAsync(teamId)).ShouldBe(0); + } + + [Fact] + public async Task A_repo_less_argv_floor_on_the_supervisor_lane_is_refused_with_the_verdict_its_preview_gives() + { + // The supervisor grades the floor at its terminal stop against the branch the run publishes; a repository-free + // Deep run stops branchless and records the floor as ungraded. One verdict drives the preview and the refusal. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var request = new TaskLaunchRequest + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Fix the failing login test", RequestedEffort = TaskEffortModes.Deep, + AcceptanceChecks = ["sh", "verify.sh"], + }; + + TaskRoutePreviewResult preview; + using (var scope = _fixture.BeginScope()) preview = await scope.Resolve().PreviewAsync(request, CancellationToken.None); + + preview.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Incompatible); + preview.ControlDispositions!.Single(d => d.Control == LaunchControls.AcceptanceChecks).Reason.ShouldBe(preview.AcceptanceCompatibility.Detail); + + var ex = await Should.ThrowAsync(() => LaunchAsync(request)); + + ((IReadOnlyList)ex.Details["controls"]!).ShouldHaveSingleItem().Reason.ShouldBe(preview.AcceptanceCompatibility.Detail); + (await CountRunsForTeamAsync(teamId)).ShouldBe(0); + } + + [Fact] + public async Task A_repo_less_argv_floor_on_the_single_agent_lane_launches_and_its_agent_carries_the_check() + { + // The executor grades a repository-free agent's argv in the scratch workspace it ran in, so a chat-surface Quick + // launch with a floor — at Delivery — is gradable: applied by the launch, and baked into the agent it stages. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); + + var request = new TaskLaunchRequest + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Write the release notes", RequestedEffort = TaskEffortModes.Quick, Tier = QualityTier.Delivery, + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local" }, + AcceptanceChecks = ["test", "-s", "DELIVERABLE.md"], + }; + + TaskRoutePreviewResult preview; + using (var scope = _fixture.BeginScope()) preview = await scope.Resolve().PreviewAsync(request, CancellationToken.None); + var result = await LaunchAsync(request); + + preview.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Compatible, "the preview predicts the launch it gets"); + result.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent); + result.ControlDispositions.Single(d => d.Control == LaunchControls.AcceptanceChecks).Outcome.ShouldBe(LaunchControlOutcome.Applied); + result.ControlDispositions.Single(d => d.Control == LaunchControls.Tier).Outcome.ShouldBe(LaunchControlOutcome.Applied, "Delivery's floor is one this lane grades"); + + using var verify = _fixture.BeginScope(); + var run = await verify.Resolve().WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == result.RunId); + var acceptance = FrozenAgentConfig(run.DefinitionSnapshotJson!).GetProperty("acceptance"); + acceptance.GetProperty("command").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "test", "-s", "DELIVERABLE.md" }, "the agent the run stages carries the operator's own check"); + acceptance.GetProperty("kind").GetString().ShouldBe("TestsPass"); + } + + [Fact] + public async Task An_auto_route_with_a_repo_less_floor_lands_on_the_single_agent_that_grades_it_never_on_deep() + { + // A confidently Deep-classified task with no repository and a floor: Deep's supervisor could not grade it (no + // branch to grade), so the floor moves the auto route to the single agent, which grades it in its own workspace. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); + + var request = AutoRequest(teamId, userId, "Rewrite the onboarding guide end to end") with { AcceptanceChecks = ["test", "-s", "DELIVERABLE.md"], Tier = QualityTier.Delivery }; + var classifier = new ConfidentClassifier(new EffortSignals { NeedsCodeChange = true, EstimatedCostTier = "high" }, TaskRecipeKinds.Supervisor); + + var preview = await PreviewOnConfidentAutoAsync(request, classifier); + var result = await LaunchOnConfidentAutoAsync(request, classifier); + + preview.Route.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "the preview predicts the floor-grading lane"); + preview.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Compatible); + result.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "a repo-less floor never lands an auto route on Deep, where it would go ungraded"); + result.Route.DegradedReason.ShouldNotBeNull().ShouldContain(TaskProjectionKinds.Supervisor, customMessage: "the move is named on the route, never silent"); + result.ControlDispositions.Single(d => d.Control == LaunchControls.AcceptanceChecks).Outcome.ShouldBe(LaunchControlOutcome.Applied); + } + + [Fact] + public async Task An_auto_route_with_an_operator_floor_never_lands_on_plan_map_and_grades_the_floor_instead() + { + // The UI dead-end this closes: Auto + Delivery required a check, but an Auto route to plan-map was Incompatible + // with one. A confidently Standard-classified task with a floor now routes to a lane that grades it. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var repoId = await SeedRepositoryAsync(teamId); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); + + var request = AutoRequest(teamId, userId, "Refactor the parser across modules and keep the tests green") with { RepositoryId = repoId, AcceptanceChecks = ["sh", "verify.sh"], Tier = QualityTier.Delivery }; + var classifier = new ConfidentClassifier(StandardSignals, TaskRecipeKinds.MapFanout); + + var preview = await PreviewOnConfidentAutoAsync(request, classifier); + var result = await LaunchOnConfidentAutoAsync(request, classifier); + + preview.Route.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "the preview predicts the floor-grading lane"); + preview.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Compatible, "Auto + Delivery + a check is launchable again — no dead-end"); + result.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "an operator floor keeps an auto route off a lane that cannot grade it"); + result.Route.EffortMode.ShouldBe(TaskEffortModes.Quick, "the Standard row is set aside for the policy's next matching row"); + result.Route.DegradedReason.ShouldNotBeNull().ShouldContain(TaskProjectionKinds.PlanMapSynth, customMessage: "the move is named on the route, never silent"); + result.ControlDispositions.Single(d => d.Control == LaunchControls.AcceptanceChecks).Outcome.ShouldBe(LaunchControlOutcome.Applied); + + var acceptance = FrozenAgentConfig((await LoadRunAsync(result.RunId)).DefinitionSnapshotJson!).GetProperty("acceptance"); + acceptance.GetProperty("kind").GetString().ShouldBe("TestsPass"); + acceptance.GetProperty("command").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "sh", "verify.sh" }, "the operator's floor grades the agent that runs"); + } + + [Fact] + public async Task A_continue_is_routed_on_the_threads_grounding_by_both_the_preview_and_the_launch() + { + // Grounding used to be resolved AFTER routing, so the classifier read "a fresh task" for every chat follow-up. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); + + var first = await LaunchAsync(new TaskLaunchRequest + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, + TaskText = "Map every caller of the legacy billing client", RequestedEffort = TaskEffortModes.Quick, + }); + + var followUp = AutoRequest(teamId, userId, "and now migrate them") with { ContinueSessionId = first.SessionId }; + var previewClassifier = new ConfidentClassifier(QuickSignals, TaskRecipeKinds.SingleAgent); + var launchClassifier = new ConfidentClassifier(QuickSignals, TaskRecipeKinds.SingleAgent); + + await PreviewOnConfidentAutoAsync(followUp, previewClassifier); + await LaunchOnConfidentAutoAsync(followUp, launchClassifier); + + foreach (var (surface, classifier) in new[] { ("preview", previewClassifier), ("launch", launchClassifier) }) + { + var seen = classifier.LastRequest.ShouldNotBeNull($"the {surface} never classified the follow-up"); + seen.Seed.GroundingContext.ShouldNotBeNull($"the {surface} classified the follow-up with no thread grounding — it read as a fresh task") + .ShouldContain("Map every caller of the legacy billing client", customMessage: $"the {surface}'s classifier must see the prior turn it is continuing"); + LlmEffortClassifier.BuildUserPrompt(seen).ShouldContain("This is a follow-up turn continuing earlier work: yes"); + } + } + + /// Signals the policy routes to the quick tier (a localized code change). + private static readonly EffortSignals QuickSignals = new() { NeedsCodeChange = true }; + + /// Signals the policy routes to the standard tier (a code change across files needing tests). + private static readonly EffortSignals StandardSignals = new() { NeedsCodeChange = true, CrossFile = true, NeedsTestsOrCi = true }; + + private static TaskLaunchRequest AutoRequest(Guid teamId, Guid userId, string taskText) => new() + { + TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, TaskText = taskText, + RequestedEffort = TaskEffortModes.Auto, Autonomy = "Confined", + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local" }, + }; + + /// The production launch graph with only the AUTO classification swapped for — the router, snapshot store, preview and launch services are rebuilt in the scope so they pick it up; everything else is real. + private ILifetimeScope ConfidentAutoScope(IEffortClassifier classifier) => _fixture.BeginScope(b => + { + b.RegisterInstance(new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), classifier })).As(); + b.RegisterType().As().InstancePerLifetimeScope(); + b.RegisterType().As().InstancePerLifetimeScope(); + b.RegisterType().As().InstancePerLifetimeScope(); + b.RegisterType().As().InstancePerLifetimeScope(); + }); + + private async Task LaunchOnConfidentAutoAsync(TaskLaunchRequest request, IEffortClassifier classifier) + { + using var scope = ConfidentAutoScope(classifier); + return await scope.Resolve().LaunchAsync(request, CancellationToken.None); + } + + private async Task PreviewOnConfidentAutoAsync(TaskLaunchRequest request, IEffortClassifier classifier) + { + using var scope = ConfidentAutoScope(classifier); + return await scope.Resolve().PreviewAsync(request, CancellationToken.None); + } + + private static JsonElement FrozenAgentConfig(string definitionSnapshotJson) => + JsonDocument.Parse(definitionSnapshotJson).RootElement.GetProperty("nodes").EnumerateArray().Single(n => n.GetProperty("id").GetString() == "agent").GetProperty("config").Clone(); + + /// A confident auto classification in the structured-LLM classifier's slot (its kind, so the registry's Auto resolves it): fixed signals at 0.9, the policy's tier, a caller-named recipe — and the route request it was asked about, recorded. + private sealed class ConfidentClassifier(EffortSignals signals, string suggestedRecipe) : IEffortClassifier + { + public EffortRouteRequest? LastRequest { get; private set; } + + public string Kind => LlmEffortClassifier.ClassifierKind; + + public Task ClassifyAsync(EffortRouteRequest request, CancellationToken ct) + { + LastRequest = request; + return Task.FromResult(new EffortDecision { Signals = signals, SuggestedEffort = EffortPolicy.Decide(signals, requestedEffort: null), SuggestedRecipe = suggestedRecipe, Confidence = 0.9, Rationale = "Scripted confident classification.", ClassifierKind = Kind }); + } + } + private async Task LaunchAsync(TaskLaunchRequest request) { using var scope = _fixture.BeginScope(); @@ -1606,11 +1908,12 @@ public async Task A_deep_launch_stages_a_session_channel_and_bakes_it_into_the_s try { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var repoId = await SeedRepositoryAsync(teamId); // an argv floor is graded against a repository workspace — repo-less it is refused var result = await LaunchAsync(new TaskLaunchRequest { TeamId = teamId, ActorUserId = userId, SurfaceKind = TaskLaunchSurfaceKinds.Chat, - TaskText = "Ship the whole feature end to end", RequestedEffort = TaskEffortModes.Deep, + TaskText = "Ship the whole feature end to end", RequestedEffort = TaskEffortModes.Deep, RepositoryId = repoId, AcceptanceChecks = new[] { "sh", " ", "check.sh" }, }); @@ -1873,7 +2176,7 @@ private static IReadOnlyList ReadSupervisorAllowedAgentDefinitionIds(str } /// Seeds one team-owned AgentDefinition (persona) and returns its id — a valid AllowedAgentDefinitionIds entry the launch must accept. - private async Task SeedPersonaAsync(Guid teamId) + private async Task SeedPersonaAsync(Guid teamId, string name = "Pool persona") { using var scope = _fixture.BeginScope(); var db = scope.Resolve(); @@ -1881,7 +2184,7 @@ private async Task SeedPersonaAsync(Guid teamId) var now = DateTimeOffset.UtcNow; var agent = new AgentDefinition { - Id = Guid.NewGuid(), TeamId = teamId, Slug = "persona-" + Guid.NewGuid().ToString("N")[..8], Name = "Pool persona", + Id = Guid.NewGuid(), TeamId = teamId, Slug = "persona-" + Guid.NewGuid().ToString("N")[..8], Name = name, SystemPrompt = "You are a pool persona.", Origin = AgentDefinitionOrigin.Authored, CreatedDate = now, CreatedBy = SystemUsers.SeederId, LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId, }; diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskRoutePreviewFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskRoutePreviewFlowTests.cs index 21920f8e2..4f1db500b 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskRoutePreviewFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/TaskRoutePreviewFlowTests.cs @@ -9,6 +9,7 @@ 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; @@ -144,8 +145,104 @@ public async Task A_launch_persists_the_route_it_was_projected_from_on_the_run() persisted.Decision!.ClassifierKind.ShouldBe(result.Route.Decision!.ClassifierKind, "who decided the tier is the whole point of recording it"); } + [Fact] + public async Task The_preview_reports_the_same_control_dispositions_the_launch_then_applies() + { + // One resolver, two callers: a Quick launch that pins a model outside its own pool (Clamped), sends a delivery + // spec and a plan critic Quick has no lane for (NotApplicable) — previewed, then launched on that very preview. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var pooled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-model"); + var outside = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "outside-model"); + + var jobClient = ResolveJobClient(); + jobClient.Clear(); + using var manual = jobClient.ManualExecution(); + + var request = Request(teamId, userId, "Fix the typo in the README", TaskEffortModes.Quick) with + { + Autonomy = "Confined", + AllowedModelIds = [pooled.RowId], + DeliverySpec = new DeliverySpec { OpenPullRequest = true }, + PlannerReviewMode = ReviewMode.Gate, + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local", ModelCredentialModelId = outside.RowId }, + }; + + TaskRoutePreviewResult preview; + using (var scope = _fixture.BeginScope()) preview = await scope.Resolve().PreviewAsync(request, CancellationToken.None); + + var launched = await LaunchAsync(request with { RouteSnapshotId = preview.RouteSnapshotId }); + + preview.ControlDispositions.ShouldNotBeNull(); + preview.ControlDispositions.Select(d => (d.Control, d.Outcome)).ShouldBe(new[] + { + (LaunchControls.AllowedModelIds, LaunchControlOutcome.Clamped), + (LaunchControls.DeliverySpec, LaunchControlOutcome.NotApplicable), + (LaunchControls.PlannerReviewMode, LaunchControlOutcome.NotApplicable), + }); + JsonSerializer.Serialize(launched.ControlDispositions, Json).ShouldBe(JsonSerializer.Serialize(preview.ControlDispositions, Json), + customMessage: "the launch applied a different disposition than its own preview promised — they must share one resolver"); + } + + [Theory] + [InlineData(true)] // a pooled model row the operator disabled after the composer loaded + [InlineData(false)] // a pooled persona deleted after the composer loaded + public async Task A_pool_member_gone_since_the_composer_loaded_is_the_same_not_found_on_the_preview_as_on_the_launch(bool modelPool) + { + // The launch has always 404'd a pool it cannot honour in full; the preview used to skip that check and answer + // Applied — a promise the launch then broke. Both now run the launch's own team-scope checks. + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var usable = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "pooled-model"); + var disabled = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, teamId, "disabled-model"); + await DisableModelRowAsync(disabled.RowId); + + var request = Request(teamId, userId, "Fix the typo in the README", TaskEffortModes.Quick) with + { + AllowedModelIds = modelPool ? [usable.RowId, disabled.RowId] : [], + AllowedAgentDefinitionIds = modelPool ? [] : [await SeedPersonaAsync(teamId), await SeedPersonaAsync(teamId, deleted: true)], + Overrides = new TaskExecutionOverrides { Harness = "codex-cli", RunnerKind = "local" }, + }; + + var previewed = await Should.ThrowAsync(() => PreviewAsync(request)); + var launched = await Should.ThrowAsync(() => LaunchAsync(request)); + + previewed.Message.ShouldBe(launched.Message, "the preview answers exactly what the launch answers"); + previewed.Message.ShouldContain("not found or not accessible"); + + using var verify = _fixture.BeginScope(); + (await verify.Resolve().TaskRouteSnapshot.AsNoTracking().CountAsync(r => r.TeamId == teamId)).ShouldBe(0, "a preview the launch would reject persists no route to launch from"); + } + // ─── Helpers ───────────────────────────────────────────────────────────── + private async Task DisableModelRowAsync(Guid rowId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var row = await db.ModelCredentialModel.SingleAsync(m => m.Id == rowId); + + // Reflection is the only writer of Enabled = false, and it touches only the rows it reflected. + row.Source = ModelSource.Reflected; + row.Enabled = false; + await db.SaveChangesAsync(); + } + + private async Task SeedPersonaAsync(Guid teamId, bool deleted = false) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + var now = DateTimeOffset.UtcNow; + var agent = new AgentDefinition + { + Id = Guid.NewGuid(), TeamId = teamId, Slug = "persona-" + Guid.NewGuid().ToString("N")[..8], Name = "Pool persona", + SystemPrompt = "You are a pool persona.", Origin = AgentDefinitionOrigin.Authored, DeletedDate = deleted ? now : null, + CreatedDate = now, CreatedBy = SystemUsers.SeederId, LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId, + }; + db.AgentDefinition.Add(agent); + await db.SaveChangesAsync(); + return agent.Id; + } + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static TaskLaunchRequest Request(Guid teamId, Guid userId, string goal, string? effort = null) => new() diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AllowedModelPoolTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AllowedModelPoolTests.cs new file mode 100644 index 000000000..7bb8731f0 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Agents/AllowedModelPoolTests.cs @@ -0,0 +1,140 @@ +using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Enums; +using Shouldly; + +namespace CodeSpace.UnitTests.Agents; + +/// +/// Pins — the one choice the launch and the dispatch both make of which pooled +/// ROW an authored pin runs on. A model name is unique only per credential, so the pin is honoured from its most +/// specific form down, and a note — worded by cause, naming model and credential — appears exactly when something moved. +/// +[Trait("Category", "Unit")] +public class AllowedModelPoolTests +{ + private static readonly Guid CredA = Guid.NewGuid(); + private static readonly Guid CredB = Guid.NewGuid(); + + private static PooledModelRow Row(Guid id, string model, Guid credential, string credentialName, bool usable = true, ModelCapabilityTier? tier = null) => + new() { Id = id, ModelId = model, ModelCredentialId = credential, CredentialName = credentialName, Provider = "Anthropic", Usable = usable, CapabilityTier = tier }; + + /// Two pooled rows carrying the SAME model name on two credentials — the smaller id first, which is the row a name lookup's tie-break used to pick. + private static (PooledModelRow Smaller, PooledModelRow Larger) SameNamePair() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }.OrderBy(id => id).ToArray(); + return (Row(ids[0], "sonnet", CredB, "Gateway key"), Row(ids[1], "sonnet", CredA, "Direct key")); + } + + [Fact] + public void A_pinned_row_is_honoured_over_a_same_named_row_on_another_credential() + { + var (smaller, larger) = SameNamePair(); + + var choice = AllowedModelPool.Choose([smaller.Id, larger.Id], [smaller, larger], new ModelPin(larger.Id, "sonnet", CredA)); + + choice.Row.ShouldBe(larger, "the operator pinned THIS row — a name lookup's row-id tie-break must never swap it for its twin on another key"); + choice.Note.ShouldBeNull("nothing moved"); + } + + [Fact] + public void A_pinned_model_on_a_pinned_credential_runs_that_pair() + { + var (smaller, larger) = SameNamePair(); + + AllowedModelPool.Choose([smaller.Id, larger.Id], [smaller, larger], new ModelPin(null, "sonnet", CredA)).Row.ShouldBe(larger); + } + + [Fact] + public void The_same_model_on_another_credential_says_the_credential_moved() + { + var onlyOnB = Row(Guid.NewGuid(), "sonnet", CredB, "Gateway key"); + var direct = Row(Guid.NewGuid(), "haiku", CredA, "Direct key"); + + var choice = AllowedModelPool.Choose([onlyOnB.Id, direct.Id], [onlyOnB, direct], new ModelPin(null, "sonnet", CredA)); + + choice.Row.ShouldBe(onlyOnB); + choice.Note.ShouldBe("Model 'sonnet' has no usable row on credential 'Direct key' in this run's allowed model pool; running 'sonnet' on 'Gateway key' instead."); + } + + [Fact] + public void A_model_outside_the_pool_runs_the_pools_default_and_names_both_ends() + { + var pooled = Row(Guid.NewGuid(), "haiku", CredA, "Direct key"); + var outside = Row(Guid.NewGuid(), "opus", CredA, "Direct key"); + + var choice = AllowedModelPool.Choose([pooled.Id], [pooled, outside], new ModelPin(outside.Id, "opus", CredA)); + + choice.Row.ShouldBe(pooled); + choice.Note.ShouldBe("Model 'opus' on 'Direct key' is not in this run's allowed model pool; running 'haiku' on 'Direct key' instead.", "the reason names the model and credential, never a row id"); + } + + [Fact] + public void A_pooled_row_that_can_no_longer_run_is_named_by_that_cause_not_as_outside_the_pool() + { + var disabled = Row(Guid.NewGuid(), "opus", CredA, "Direct key", usable: false); + var fallback = Row(Guid.NewGuid(), "haiku", CredB, "Gateway key"); + + var choice = AllowedModelPool.Choose([disabled.Id, fallback.Id], [disabled, fallback], new ModelPin(disabled.Id, "opus", CredA)); + + choice.Row.ShouldBe(fallback); + choice.Note.ShouldBe("Model 'opus' on 'Direct key' is in this run's allowed model pool but disabled, or its credential is no longer active; running 'haiku' on 'Gateway key' instead."); + } + + [Fact] + public void A_credential_only_pin_runs_that_credentials_best_pooled_row() + { + // A persona can supply just a credential: the pool is honoured AND the credential is, when the pool holds it. + var basic = Row(Guid.NewGuid(), "haiku", CredA, "Direct key", tier: ModelCapabilityTier.Basic); + var strong = Row(Guid.NewGuid(), "sonnet", CredA, "Direct key", tier: ModelCapabilityTier.Strong); + var elsewhere = Row(Guid.NewGuid(), "gpt", CredB, "Gateway key", tier: ModelCapabilityTier.Strong); + + var choice = AllowedModelPool.Choose([basic.Id, strong.Id, elsewhere.Id], [basic, strong, elsewhere], new ModelPin(null, null, CredA)); + + choice.Row.ShouldBe(strong, "the agent-plane ranking inside the pinned credential's pooled rows"); + choice.Note.ShouldBeNull(); + } + + [Fact] + public void A_credential_only_pin_the_pool_does_not_hold_says_so() + { + var elsewhere = Row(Guid.NewGuid(), "gpt", CredB, "Gateway key"); + + var choice = AllowedModelPool.Choose([elsewhere.Id], [elsewhere], new ModelPin(null, null, CredA)); + + choice.Row.ShouldBe(elsewhere); + choice.Note.ShouldBe("The pinned credential carries no usable model in this run's allowed model pool; running 'gpt' on 'Gateway key' instead."); + } + + [Fact] + public void Nothing_authored_runs_the_pools_default_with_no_note() + { + var pooled = Row(Guid.NewGuid(), "haiku", CredA, "Direct key"); + + var choice = AllowedModelPool.Choose([pooled.Id], [pooled], new ModelPin(null, null, null)); + + choice.Row.ShouldBe(pooled); + choice.Note.ShouldBeNull("the pool's default is simply the model that runs — nothing authored moved"); + } + + [Fact] + public void A_pool_with_nothing_usable_chooses_no_row_and_says_why() + { + var disabled = Row(Guid.NewGuid(), "haiku", CredA, "Direct key", usable: false); + + var choice = AllowedModelPool.Choose([disabled.Id], [disabled], new ModelPin(null, "haiku", null)); + + choice.Row.ShouldBeNull(); + choice.Note.ShouldBe(AllowedModelPool.NothingRunsNote); + } + + [Fact] + public void A_pinned_row_outside_the_pool_is_read_so_its_note_can_name_it() + { + var pinned = Guid.NewGuid(); + var pool = new[] { Guid.NewGuid() }; + + AllowedModelPool.RowsToRead(pool, new ModelPin(pinned, null, null)).ShouldBe([pool[0], pinned]); + AllowedModelPool.RowsToRead(pool, new ModelPin(pool[0], null, null)).ShouldBe(pool); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/DeploymentAutonomyCeilingTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/DeploymentAutonomyCeilingTests.cs index fa8d6e6a0..c83d39838 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/DeploymentAutonomyCeilingTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/DeploymentAutonomyCeilingTests.cs @@ -15,6 +15,7 @@ using CodeSpace.Core.Services.Tasks.Recipes.MapFanout; using CodeSpace.Core.Services.Tasks.Recipes.SingleAgent; using CodeSpace.Core.Services.Tasks.Recipes.Supervisor; +using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Core.Services.Workflows.Nodes; using CodeSpace.Core.Services.Workflows.Nodes.Builtin; using CodeSpace.Core.Services.Workflows.Runtime; @@ -268,7 +269,8 @@ public void A_sandbox_spec_that_says_nothing_about_the_network_is_severed() new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new MapFanoutRecipe(), new SupervisorRecipe() }), new BoundsPresetRegistry(withPresets ? new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() } : Array.Empty()), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + new TaskProjectionRegistry(Array.Empty())); private static EffortRouteRequest RouteRequest(string effort) => new() { diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/AcceptanceArgvInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/AcceptanceArgvInventoryTests.cs index fd3f7d81a..876179f7d 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/AcceptanceArgvInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/AcceptanceArgvInventoryTests.cs @@ -49,6 +49,7 @@ public class AcceptanceArgvInventoryTests ["CodeSpace.Core/Services/Agents/InLoopAcceptanceHook.cs"] = 1, ["CodeSpace.Core/Services/Agents/Workspace/LocalAcceptanceVerifier.cs"] = 1, ["CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs"] = 4, + ["CodeSpace.Core/Services/Tasks/Launch/LaunchControlResolver.cs"] = 1, ["CodeSpace.Core/Services/Tasks/Projection/Builders/SingleAgent/SingleAgentDefinitionBuilder.cs"] = 1, ["CodeSpace.Core/Services/Tasks/Projection/Builders/Supervisor/SupervisorDefinitionBuilder.cs"] = 1, ["CodeSpace.Core/Services/Tasks/SpecPreview/TaskSpecAcceptanceAssessment.cs"] = 1, diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs index 20ac1bd7f..16caa1791 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs @@ -86,6 +86,8 @@ public void The_wire_codes_are_pinned() FailureCodes.ProviderEndpointSecretRequired.ShouldBe("provider_endpoint_secret_required"); FailureCodes.WorkflowDefinitionInvalid.ShouldBe("workflow_definition_invalid"); FailureCodes.TaskRouteConfirmationRequired.ShouldBe("task_route_confirmation_required"); + FailureCodes.TaskLaunchControlRefused.ShouldBe("task_launch_control_refused"); + FailureCodes.AgentModelPoolUnresolvable.ShouldBe("agent_model_pool_unresolvable"); FailureCodes.WorkspaceUnresolvable.ShouldBe("workspace_unresolvable"); FailureCodes.ToolCallNotPreviewable.ShouldBe("tool_call_not_previewable"); FailureCodes.PullRequestMoved.ShouldBe("pull_request_moved"); diff --git a/backend/tests/CodeSpace.UnitTests/Tasks/LaunchControlResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Tasks/LaunchControlResolverTests.cs new file mode 100644 index 000000000..a41d2b2b5 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Tasks/LaunchControlResolverTests.cs @@ -0,0 +1,303 @@ +using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Core.Services.Tasks.Launch; +using CodeSpace.Core.Services.Tasks.Projection; +using CodeSpace.Core.Services.Tasks.Projection.Builders.PlanMapDynamic; +using CodeSpace.Core.Services.Tasks.Projection.Builders.PlanMapSynth; +using CodeSpace.Core.Services.Tasks.Projection.Builders.SingleAgent; +using CodeSpace.Core.Services.Tasks.Projection.Builders.Supervisor; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Tasks; +using CodeSpace.UnitTests.Infrastructure; +using Shouldly; + +namespace CodeSpace.UnitTests.Tasks; + +/// +/// Pins — the one place a route-dependent launch control learns what the resolved +/// route does with it — over the REAL projection builders (their OperatorAcceptance advertisements decide the +/// floor and the tier's mandate) and a scripted model pool (the only I/O). Every lane × control lands in exactly one +/// disposition, with a reason wherever it is not a plain apply; nothing the operator set is left out of the list. +/// +[Trait("Category", "Unit")] +public class LaunchControlResolverTests +{ + private static readonly Guid Persona = Guid.NewGuid(); + private static readonly Guid CredA = Guid.NewGuid(); + private static readonly PooledModelRow Pooled = Row(Guid.NewGuid(), "pooled-model", CredA, "Direct key"); + private static readonly PooledModelRow Outside = Row(Guid.NewGuid(), "outside-model", CredA, "Direct key"); + private static readonly TaskLaunchSeed Seed = new() { Goal = "g", SurfaceKind = "chat", TeamId = Guid.NewGuid() }; + + private static PooledModelRow Row(Guid id, string model, Guid credential, string credentialName, bool usable = true) => + new() { Id = id, ModelId = model, ModelCredentialId = credential, CredentialName = credentialName, Provider = "Anthropic", Usable = usable }; + + private static LaunchControlResolver Resolver(ScriptedPool pool) => new(new TaskProjectionRegistry(new IWorkflowDefinitionBuilder[] { new SingleAgentDefinitionBuilder(), new PlanMapSynthDefinitionBuilder(), new PlanMapDynamicDefinitionBuilder(), new SupervisorDefinitionBuilder(), new UnadvertisedBuilder() }), pool, EmptyTestDb.New()); + + private static RoutePlan Route(string projectionKind) => new() { ProjectionKind = projectionKind }; + + /// A repo-bound Delivery launch carrying EVERY route-dependent control — a pooled model row, a pooled persona, a floor, both critics, a delivery spec, the integrate opt-in and the plan gate. + private static TaskLaunchRequest EveryControl() => new() + { + TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat", RepositoryId = Guid.NewGuid(), Tier = QualityTier.Delivery, + AllowedModelIds = [Pooled.Id], AllowedAgentDefinitionIds = [Persona], AcceptanceChecks = ["sh", "check.sh"], + DecisionReviewMode = ReviewMode.Gate, DeliverySpec = new DeliverySpec { OpenPullRequest = true }, RequirePlanConfirmation = true, PlannerReviewMode = ReviewMode.Improve, + Overrides = new TaskExecutionOverrides { ModelCredentialModelId = Pooled.Id, AgentDefinitionId = Persona, IntegrateBranches = true }, + }; + + private static Task ResolveAsync(ScriptedPool pool, TaskLaunchRequest request, string lane) => Resolver(pool).ResolveAsync(request, Seed, Route(lane), CancellationToken.None); + + private static LaunchControlDisposition Of(LaunchControlResolution resolution, string control) => resolution.Dispositions.Single(d => d.Control == control); + + [Theory] + // lane, models, personas, floor, tier, decision critic, delivery, integrate, plan gate, plan critic + [InlineData(TaskProjectionKinds.Supervisor, "Applied", "Applied", "Applied", "Applied", "Applied", "Applied", "Applied", "Applied", "Applied")] + [InlineData(TaskProjectionKinds.SingleAgent, "Applied", "Applied", "Applied", "Applied", "NotApplicable", "NotApplicable", "NotApplicable", "NotApplicable", "NotApplicable")] + [InlineData(TaskProjectionKinds.PlanMapSynth, "Applied", "Applied", "Refused", "Applied", "NotApplicable", "NotApplicable", "NotApplicable", "Applied", "Applied")] + [InlineData(TaskProjectionKinds.PlanMapDynamic, "Applied", "Applied", "Refused", "Applied", "NotApplicable", "NotApplicable", "NotApplicable", "Applied", "Applied")] + public async Task Every_control_the_launch_carries_gets_exactly_one_disposition_per_lane(string lane, string models, string personas, string floor, string tier, string decisionCritic, string delivery, string integrate, string planGate, string planCritic) + { + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl(), lane); + + resolution.Dispositions.Select(d => d.Control).ShouldBe(new[] { LaunchControls.AllowedModelIds, LaunchControls.AllowedAgentDefinitionIds, LaunchControls.AcceptanceChecks, LaunchControls.Tier, LaunchControls.DecisionReviewMode, LaunchControls.DeliverySpec, LaunchControls.IntegrateBranches, LaunchControls.RequirePlanConfirmation, LaunchControls.PlannerReviewMode }, + customMessage: "a control the launch carried is missing from the list — that is the silent drop this resolver exists to end"); + + var expected = new[] { models, personas, floor, tier, decisionCritic, delivery, integrate, planGate, planCritic }; + resolution.Dispositions.Select(d => d.Outcome.ToString()).ShouldBe(expected); + + resolution.Dispositions.Where(d => d.Outcome is LaunchControlOutcome.NotApplicable or LaunchControlOutcome.Refused or LaunchControlOutcome.Clamped).ShouldAllBe(d => !string.IsNullOrWhiteSpace(d.Reason), "every disposition short of a plain apply says why"); + } + + [Fact] + public async Task A_launch_carrying_no_route_dependent_control_reports_none_and_never_consults_the_pool() + { + var pool = new ScriptedPool(); + + var resolution = await ResolveAsync(pool, new TaskLaunchRequest { TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat" }, TaskProjectionKinds.SingleAgent); + + resolution.Dispositions.ShouldBeEmpty(); + resolution.ModelClamp.ShouldBeNull(); + pool.Calls.ShouldBe(0); + } + + [Theory] + [InlineData(TaskProjectionKinds.SingleAgent, true)] // the one agent IS the pin, so the launch bakes the pooled row + [InlineData(TaskProjectionKinds.PlanMapSynth, false)] // branches are held at dispatch; the synthesis keeps its model + public async Task A_pinned_model_row_outside_the_pool_is_clamped_to_the_pools_default_naming_model_and_credential(string lane, bool bakedAtLaunch) + { + var request = EveryControl() with { Overrides = new TaskExecutionOverrides { ModelCredentialModelId = Outside.Id } }; + + var resolution = await ResolveAsync(new ScriptedPool(Pooled, Outside), request, lane); + + var models = Of(resolution, LaunchControls.AllowedModelIds); + models.Outcome.ShouldBe(LaunchControlOutcome.Clamped); + models.Reason.ShouldBe("Model 'outside-model' on 'Direct key' is not in this run's allowed model pool; running 'pooled-model' on 'Direct key' instead.", "the reason names the model and credential — never a row id"); + (resolution.ModelClamp == Pooled).ShouldBe(bakedAtLaunch); + (resolution.ModelClamp is null).ShouldBe(!bakedAtLaunch); + } + + [Fact] + public async Task A_pinned_row_is_honoured_over_a_same_named_pooled_row_on_another_credential() + { + // The dispatch used to resolve a NAME, so the smaller-id twin on another key silently replaced the pin while this + // disposition said Applied. The launch now makes the dispatch's own row-exact choice. + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }.OrderBy(id => id).ToArray(); + var twin = Row(ids[0], "sonnet", Guid.NewGuid(), "Gateway key"); + var pinned = Row(ids[1], "sonnet", CredA, "Direct key"); + var request = EveryControl() with { AllowedModelIds = [twin.Id, pinned.Id], Overrides = new TaskExecutionOverrides { ModelCredentialModelId = pinned.Id } }; + + var resolution = await ResolveAsync(new ScriptedPool(twin, pinned), request, TaskProjectionKinds.SingleAgent); + + Of(resolution, LaunchControls.AllowedModelIds).Outcome.ShouldBe(LaunchControlOutcome.Applied); + resolution.ModelClamp.ShouldBeNull("an honoured pin is baked as authored"); + } + + [Fact] + public async Task A_pool_that_resolves_nothing_refuses_a_pinned_launch() + { + var disabled = Pooled with { Usable = false }; + var request = EveryControl() with { Overrides = new TaskExecutionOverrides { ModelCredentialModelId = Outside.Id } }; + + var resolution = await ResolveAsync(new ScriptedPool(disabled, Outside), request, TaskProjectionKinds.SingleAgent); + + Of(resolution, LaunchControls.AllowedModelIds).Outcome.ShouldBe(LaunchControlOutcome.Refused); + resolution.ModelClamp.ShouldBeNull(); + } + + [Theory] + [InlineData(TaskProjectionKinds.SingleAgent)] + [InlineData(TaskProjectionKinds.PlanMapSynth)] + public async Task A_persona_the_pool_excludes_refuses_and_no_persona_is_not_applicable(string lane) + { + var outsider = Guid.NewGuid(); + var excluded = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { Overrides = new TaskExecutionOverrides { AgentDefinitionId = outsider } }, lane); + var none = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { Overrides = new TaskExecutionOverrides() }, lane); + + Of(excluded, LaunchControls.AllowedAgentDefinitionIds).Outcome.ShouldBe(LaunchControlOutcome.Refused, "every agent on this lane runs as the launch's persona — the operator excluded it"); + Of(excluded, LaunchControls.AllowedAgentDefinitionIds).Reason.ShouldNotContain(outsider.ToString(), customMessage: "a persona is named the way a model is — never by its id"); + Of(none, LaunchControls.AllowedAgentDefinitionIds).Outcome.ShouldBe(LaunchControlOutcome.NotApplicable, "no agent here carries a persona for the pool to admit or refuse"); + } + + [Fact] + public async Task A_plan_map_floor_is_refused_with_the_verdict_the_preview_shows() + { + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl(), TaskProjectionKinds.PlanMapSynth); + + Of(resolution, LaunchControls.AcceptanceChecks).Reason.ShouldBe(LaunchControlResolver.FloorNotGradedReason); + resolution.AcceptanceCompatibility.State.ShouldBe(TaskAcceptanceCompatibilityState.Incompatible); + resolution.AcceptanceCompatibility.Detail.ShouldBe(LaunchControlResolver.FloorNotGradedReason, "the preview's verdict and the refusal are one fact"); + } + + [Fact] + public async Task A_repo_less_argv_floor_on_the_supervisor_lane_is_refused_with_the_verdict_the_preview_shows() + { + // The supervisor grades its floor at the terminal stop against the branch the run publishes; a repository-free + // run has no branch, so the floor would be recorded as ungraded. One verdict drives the preview and the refusal. + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { RepositoryId = null }, TaskProjectionKinds.Supervisor); + + resolution.AcceptanceCompatibility.State.ShouldBe(TaskAcceptanceCompatibilityState.Incompatible); + resolution.AcceptanceCompatibility.RequiresRepository.ShouldBe(true); + var floor = Of(resolution, LaunchControls.AcceptanceChecks); + floor.Outcome.ShouldBe(LaunchControlOutcome.Refused); + floor.Reason.ShouldBe(resolution.AcceptanceCompatibility.Detail); + floor.Reason.ShouldBe(LaunchControlResolver.FloorNeedsRepositoryReason); + } + + [Fact] + public async Task A_repo_less_argv_floor_on_the_single_agent_lane_is_applied_because_the_agent_grades_it_in_its_own_workspace() + { + // The executor grades a repository-free agent's argv in the scratch workspace it ran in (the local acceptance + // verifier), so a repo-less Quick launch with a floor — and at Delivery — is gradable, not a dead end. + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { RepositoryId = null }, TaskProjectionKinds.SingleAgent); + + resolution.AcceptanceCompatibility.State.ShouldBe(TaskAcceptanceCompatibilityState.Compatible); + resolution.AcceptanceCompatibility.RequiresRepository.ShouldBe(false); + Of(resolution, LaunchControls.AcceptanceChecks).Outcome.ShouldBe(LaunchControlOutcome.Applied); + Of(resolution, LaunchControls.Tier).Outcome.ShouldBe(LaunchControlOutcome.Applied, "the floor the Delivery tier asks for is one this lane grades"); + } + + [Fact] + public async Task A_repo_less_supervisor_launch_at_delivery_with_a_floor_gets_two_refusals_that_name_the_same_remedies() + { + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { RepositoryId = null }, TaskProjectionKinds.Supervisor); + + var refused = resolution.Dispositions.Where(d => d.Outcome == LaunchControlOutcome.Refused).ToList(); + refused.Select(d => d.Control).ShouldBe(new[] { LaunchControls.AcceptanceChecks, LaunchControls.Tier }); + refused.ShouldAllBe(d => d.Reason!.Contains("bind a repository") && d.Reason.Contains("launch at Quick effort"), "both refusals list the same working remedies in the same words"); + refused.ShouldAllBe(d => !d.Reason!.Contains("acceptanceChecks") && !d.Reason.Contains("Prototype"), "authoring a check cannot help, and Prototype would leave the floor refused"); + } + + [Theory] + [InlineData("bind a repository")] + [InlineData("launch at Quick effort")] + public async Task Each_remedy_the_refusals_name_for_a_repo_less_floor_launch_clears_every_refusal(string remedy) + { + var request = EveryControl() with { RepositoryId = null }; + var lane = TaskProjectionKinds.Supervisor; + + var before = await ResolveAsync(new ScriptedPool(Pooled), request, lane); + before.Dispositions.Where(d => d.Outcome == LaunchControlOutcome.Refused).ShouldAllBe(d => d.Reason!.Contains(remedy), "fixture check: the remedy under test is one the refusals name"); + + // Binding a repository changes the request; launching at Quick effort changes the route the effort resolves to. + var after = remedy == "bind a repository" + ? await ResolveAsync(new ScriptedPool(Pooled), request with { RepositoryId = Guid.NewGuid() }, lane) + : await ResolveAsync(new ScriptedPool(Pooled), request, TaskProjectionKinds.SingleAgent); + + after.Dispositions.ShouldNotContain(d => d.Outcome == LaunchControlOutcome.Refused, "the remedy the refusal named is one that actually works"); + } + + [Fact] + public async Task A_repo_less_supervisor_launch_at_delivery_without_a_floor_is_told_to_bind_a_repository_or_step_down_to_prototype_and_prototype_clears_it() + { + var request = EveryControl() with { RepositoryId = null, AcceptanceChecks = null }; + + var resolution = await ResolveAsync(new ScriptedPool(Pooled), request, TaskProjectionKinds.Supervisor); + + var tier = Of(resolution, LaunchControls.Tier); + tier.Outcome.ShouldBe(LaunchControlOutcome.Refused); + tier.Reason.ShouldNotBeNull().ShouldContain("bind a repository", Case.Insensitive); + tier.Reason.ShouldContain("Prototype"); + tier.Reason.ShouldNotContain("acceptanceChecks", customMessage: "authoring a check cannot satisfy a route that grades one only against a repository"); + resolution.Dispositions.Count(d => d.Outcome == LaunchControlOutcome.Refused).ShouldBe(1); + + var prototype = await ResolveAsync(new ScriptedPool(Pooled), request with { Tier = QualityTier.Prototype }, TaskProjectionKinds.Supervisor); + + prototype.Dispositions.ShouldNotContain(d => d.Outcome == LaunchControlOutcome.Refused, "the remedy the refusal named is one that actually works"); + } + + [Theory] + [InlineData("")] // a blank executable + [InlineData(" ")] + [InlineData("sh\0x")] // a NUL no process argv can carry + public async Task A_malformed_floor_is_refused_by_name_and_does_not_satisfy_the_tiers_mandate(string executable) + { + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl() with { AcceptanceChecks = [executable, "check.sh"] }, TaskProjectionKinds.SingleAgent); + + Of(resolution, LaunchControls.AcceptanceChecks).Reason.ShouldBe(LaunchControlResolver.MalformedFloorReason, "the builders drop this argv, so the caller learns why it grades nothing"); + Of(resolution, LaunchControls.Tier).Outcome.ShouldBe(LaunchControlOutcome.Refused, "a floor no builder bakes cannot be the Delivery floor"); + LaunchControlResolver.IsWellFormedFloor([executable, "check.sh"]).ShouldBeFalse(); + } + + [Theory] + // Quick grades its single agent with the operator's argv exactly as Deep grades its terminal stop, so claiming + // Delivery there without one is the same unverified claim — it used to launch, because the mandate asked "is this + // the supervisor?" rather than "does this route grade a floor?". It used to be a 400 the preview never predicted, too. + [InlineData(QualityTier.Delivery, TaskProjectionKinds.SingleAgent, false, "Refused")] + [InlineData(QualityTier.Unattended, TaskProjectionKinds.SingleAgent, false, "Refused")] + [InlineData(QualityTier.Delivery, TaskProjectionKinds.Supervisor, false, "Refused")] + [InlineData(QualityTier.Unattended, TaskProjectionKinds.Supervisor, false, "Refused")] + [InlineData(QualityTier.Delivery, TaskProjectionKinds.SingleAgent, true, "Applied")] + [InlineData(QualityTier.Unattended, TaskProjectionKinds.Supervisor, true, "Applied")] + [InlineData(QualityTier.Delivery, TaskProjectionKinds.PlanMapSynth, false, "Applied")] // plan-map grades no operator floor: demanding one would make it unlaunchable + [InlineData(QualityTier.Unattended, TaskProjectionKinds.PlanMapDynamic, false, "Applied")] + [InlineData(QualityTier.Prototype, TaskProjectionKinds.SingleAgent, false, null)] // self-report stands; nothing to report + [InlineData(null, TaskProjectionKinds.Supervisor, false, null)] + public async Task The_tiers_floor_mandate_is_a_disposition_the_preview_can_predict(QualityTier? tier, string lane, bool withFloor, string? expected) + { + var request = new TaskLaunchRequest { TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat", RepositoryId = Guid.NewGuid(), Tier = tier, AcceptanceChecks = withFloor ? ["sh", "check.sh"] : null }; + + var resolution = await ResolveAsync(new ScriptedPool(), request, lane); + + resolution.Dispositions.SingleOrDefault(d => d.Control == LaunchControls.Tier)?.Outcome.ToString().ShouldBe(expected); + if (expected == "Refused") Of(resolution, LaunchControls.Tier).Reason.ShouldContain("acceptanceChecks", Case.Insensitive, "the operator needs an actionable name for the missing lever"); + } + + [Fact] + public async Task A_route_whose_builder_advertises_nothing_refuses_the_floor_and_holds_no_pool() + { + var resolution = await ResolveAsync(new ScriptedPool(Pooled), EveryControl(), UnadvertisedBuilder.Kind); + + resolution.AcceptanceCompatibility.State.ShouldBe(TaskAcceptanceCompatibilityState.Unknown); + Of(resolution, LaunchControls.AcceptanceChecks).Reason.ShouldBe(resolution.AcceptanceCompatibility.Detail); + Of(resolution, LaunchControls.AllowedModelIds).Outcome.ShouldBe(LaunchControlOutcome.NotApplicable, "an unknown lane is never claimed to hold its agents to the pool"); + Of(resolution, LaunchControls.Tier).Outcome.ShouldBe(LaunchControlOutcome.Applied, "a builder that advertises no adapter grades no floor, so the mandate asks nothing of it"); + } + + /// The pool's one lookup the resolver may make — its rows, scripted; anything else is a resolver reaching past its contract. + private sealed class ScriptedPool(params PooledModelRow[] rows) : IModelPoolSelector + { + public int Calls { get; private set; } + + public Task> ListPoolRowsAsync(Guid teamId, IReadOnlyList rowIds, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult>(rows.Where(r => rowIds.Contains(r.Id)).ToList()); + } + + public Task SelectAsync(Guid teamId, string provider, IReadOnlyList? allowedModels, string? pinnedModel, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveByRowIdAsync(Guid teamId, Guid modelCredentialModelId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveDispatchAsync(Guid teamId, string modelName, IReadOnlyList? allowedRowIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ListPoolAsync(Guid teamId, IReadOnlyList? allowedRowIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SelectBrainRowIdAsync(Guid teamId, IReadOnlyCollection eligibleProviders, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolvePinnedBrainRowIdAsync(Guid teamId, Guid modelCredentialModelId, IReadOnlyCollection eligibleProviders, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveTeamDefaultProviderAsync(Guid teamId, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + /// A registered projection that advertises no operator-command adapter — the interface default. + private sealed class UnadvertisedBuilder : IWorkflowDefinitionBuilder + { + public const string Kind = "unadvertised-lane"; + public string ProjectionKind => Kind; + public WorkflowDefinition Build(TaskBuildContext context) => throw new NotSupportedException(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Tasks/TaskLaunchServiceQualityTierTests.cs b/backend/tests/CodeSpace.UnitTests/Tasks/TaskLaunchServiceQualityTierTests.cs index 0a45ed934..c5d5e4eb3 100644 --- a/backend/tests/CodeSpace.UnitTests/Tasks/TaskLaunchServiceQualityTierTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Tasks/TaskLaunchServiceQualityTierTests.cs @@ -6,12 +6,10 @@ namespace CodeSpace.UnitTests.Tasks; /// -/// Pins P3.2's two tier-mandate choke points: (Delivery/ -/// Unattended on a SUPERVISOR-projected launch must carry an executable acceptanceChecks floor, fail-loud -/// otherwise) and 's tier-aware OutputReviewMode floor -/// (Delivery ⇒ at least Gate, Unattended ⇒ at least Improve — a MINIMUM an operator's explicit choice can only -/// raise, never lower). Both are pure, so they're unit-pinned directly here (no DB) — the integration tier proves -/// a Delivery launch without an acceptance check is rejected through the REAL ITaskLaunchService. +/// Pins 's tier-aware OutputReviewMode floor (Delivery ⇒ at +/// least Gate, Unattended ⇒ at least Improve — a MINIMUM an operator's explicit choice can only raise, never lower). The +/// tier's OTHER mandate — an executable acceptance floor on every route that grades one — is a launch-control disposition +/// now, pinned lane by lane in LaunchControlResolverTests (the preview predicts it; the launch refuses on it). /// [Trait("Category", "Unit")] public class TaskLaunchServiceQualityTierTests @@ -20,56 +18,14 @@ public class TaskLaunchServiceQualityTierTests private static RoutePlan Route(string projectionKind) => new() { ProjectionKind = projectionKind, Caps = new RouteCaps() }; - private static TaskLaunchRequest Request(QualityTier? tier, IReadOnlyList? acceptanceChecks = null) => new() + private static TaskLaunchRequest Request(QualityTier? tier) => new() { TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat", Tier = tier, - AcceptanceChecks = acceptanceChecks, }; - // ── EnsureAcceptanceMandate — Delivery/Unattended on a supervisor launch must carry an acceptance floor ── - - [Theory] - [InlineData(QualityTier.Delivery)] - [InlineData(QualityTier.Unattended)] - public void A_supervisor_launch_at_delivery_or_unattended_quality_without_an_acceptance_check_is_rejected(QualityTier tier) - { - var ex = Should.Throw(() => - TaskLaunchService.EnsureAcceptanceMandate(Request(tier), Route(TaskProjectionKinds.Supervisor))); - - ex.Message.ShouldContain("acceptanceChecks", Case.Insensitive, "the operator needs an actionable name for the missing lever"); - ex.Message.ShouldContain(tier.ToString()); - } - - [Theory] - [InlineData(QualityTier.Delivery)] - [InlineData(QualityTier.Unattended)] - public void A_supervisor_launch_with_an_authored_acceptance_check_is_not_rejected(QualityTier tier) - { - Should.NotThrow(() => - TaskLaunchService.EnsureAcceptanceMandate(Request(tier, new[] { "sh", "check.sh" }), Route(TaskProjectionKinds.Supervisor))); - } - - [Fact] - public void A_supervisor_launch_at_prototype_quality_or_no_tier_is_never_rejected() - { - Should.NotThrow(() => TaskLaunchService.EnsureAcceptanceMandate(Request(QualityTier.Prototype), Route(TaskProjectionKinds.Supervisor))); - Should.NotThrow(() => TaskLaunchService.EnsureAcceptanceMandate(Request(tier: null), Route(TaskProjectionKinds.Supervisor))); - } - - [Theory] - [InlineData(QualityTier.Delivery)] - [InlineData(QualityTier.Unattended)] - public void A_non_supervisor_launch_at_delivery_or_unattended_quality_is_never_rejected(QualityTier tier) - { - // AcceptanceChecks is inert on a non-supervisor projection today — this PR doesn't invent new acceptance-floor - // plumbing for single-agent/plan-map launches, so the mandate is inert there too, matching that existing shape. - Should.NotThrow(() => TaskLaunchService.EnsureAcceptanceMandate(Request(tier), Route(TaskProjectionKinds.SingleAgent))); - Should.NotThrow(() => TaskLaunchService.EnsureAcceptanceMandate(Request(tier), Route(TaskProjectionKinds.PlanMapDynamic))); - } - // ── BuildAgentProfile's tier-aware OutputReviewMode floor ── [Theory] diff --git a/backend/tests/CodeSpace.UnitTests/Tasks/TaskRoutePreviewServiceTests.cs b/backend/tests/CodeSpace.UnitTests/Tasks/TaskRoutePreviewServiceTests.cs index 1dbbf1663..a2e62e634 100644 --- a/backend/tests/CodeSpace.UnitTests/Tasks/TaskRoutePreviewServiceTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Tasks/TaskRoutePreviewServiceTests.cs @@ -25,6 +25,7 @@ using CodeSpace.Messages.Tasks; using CodeSpace.Messages.Commands.Tasks; using CodeSpace.Messages.Tasks.Effort; +using CodeSpace.UnitTests.Infrastructure; using Shouldly; namespace CodeSpace.UnitTests.Tasks; @@ -44,17 +45,23 @@ public class TaskRoutePreviewServiceTests { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private static TaskProjectionRegistry Projections() => new([new SingleAgentDefinitionBuilder(), new SupervisorDefinitionBuilder(), new PlanMapSynthDefinitionBuilder()]); + private static IEffortRouter Router() => new EffortRouter( new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new MapFanoutRecipe(), new SupervisorRecipe() }), new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + Projections()); + + /// The launch's own control resolver over the given builders (the preview's acceptance verdict is ITS verdict). No preview here sets a model pool, so the pool is never consulted — a lookup would throw. + private static LaunchControlResolver Controls(TaskProjectionRegistry? projections = null) => new(projections ?? Projections(), new UnconsultedModelPool(), EmptyTestDb.New()); - private static TaskRoutePreviewService Preview(IEffortRouter router) => new( + private static TaskRoutePreviewService Preview(IEffortRouter router, LaunchControlResolver? controls = null) => new( new TaskLaunchSeedProviderRegistry(new ITaskLaunchSeedProvider[] { new ChatSeedProvider() }), new AllRepositoriesInTeam(), - new RoutingOnlySnapshotStore(router), new TaskProjectionRegistry([new SingleAgentDefinitionBuilder(), new SupervisorDefinitionBuilder(), new PlanMapSynthDefinitionBuilder()]), - new ModeProfileRegistry()); + new RoutingOnlySnapshotStore(router), + new ModeProfileRegistry(), controls ?? Controls(), EmptyTestDb.New()); private static TaskLaunchRequest Request(string goal, string? effort = null, string? recipe = null, RouteCaps? caps = null, string? shape = null, string? autonomy = null, string? completionMode = null) => new() { @@ -134,9 +141,9 @@ public async Task An_auto_tier_previews_the_confirm_card_the_launch_would_otherw [Theory] [InlineData(TaskEffortModes.Quick, true, TaskAcceptanceCompatibilityState.Compatible)] - [InlineData(TaskEffortModes.Quick, false, TaskAcceptanceCompatibilityState.Incompatible)] + [InlineData(TaskEffortModes.Quick, false, TaskAcceptanceCompatibilityState.Compatible)] // the single agent grades the argv in its own workspace, repository or not [InlineData(TaskEffortModes.Deep, true, TaskAcceptanceCompatibilityState.Compatible)] - [InlineData(TaskEffortModes.Deep, false, TaskAcceptanceCompatibilityState.Incompatible)] + [InlineData(TaskEffortModes.Deep, false, TaskAcceptanceCompatibilityState.Incompatible)] // the supervisor grades it against the branch it publishes [InlineData(TaskEffortModes.Standard, true, TaskAcceptanceCompatibilityState.Incompatible)] public async Task Adapter_compatibility_comes_from_the_actual_builder_and_workspace(string effort, bool withRepository, TaskAcceptanceCompatibilityState expected) { @@ -147,7 +154,7 @@ public async Task Adapter_compatibility_comes_from_the_actual_builder_and_worksp if (effort != TaskEffortModes.Standard) { preview.AcceptanceCompatibility.GradingKind.ShouldBe("TestsPass"); - preview.AcceptanceCompatibility.RequiresRepository.ShouldBe(true); + preview.AcceptanceCompatibility.RequiresRepository.ShouldBe(effort == TaskEffortModes.Deep, "only the supervisor lane needs a repository to grade an argv floor"); } preview.Route.WasAutoClassified.ShouldBeFalse("asking about an explicit route's adapter must not classify the goal"); } @@ -156,13 +163,49 @@ public async Task Adapter_compatibility_comes_from_the_actual_builder_and_worksp public async Task Unadvertised_projection_compatibility_stays_unknown_without_changing_the_route_identity() { var request = Request("Inspect this task", TaskEffortModes.Quick) with { RepositoryId = Guid.NewGuid() }; - var service = new TaskRoutePreviewService(new TaskLaunchSeedProviderRegistry([new ChatSeedProvider()]), new AllRepositoriesInTeam(), new RoutingOnlySnapshotStore(Router()), new TaskProjectionRegistry([]), new ModeProfileRegistry()); - var unknown = await service.PreviewAsync(request, CancellationToken.None); + var unknown = await Preview(Router(), Controls(new TaskProjectionRegistry([]))).PreviewAsync(request, CancellationToken.None); var known = await Preview(Router()).PreviewAsync(request, CancellationToken.None); unknown.AcceptanceCompatibility!.State.ShouldBe(TaskAcceptanceCompatibilityState.Unknown); JsonSerializer.Serialize(unknown.Route, Json).ShouldBe(JsonSerializer.Serialize(known.Route, Json)); } + // ─── Control dispositions: the preview states what the launch will do with each route-dependent control ───────── + + [Fact] + public async Task Preview_reports_the_same_control_dispositions_the_launch_resolves_for_the_same_input() + { + // An explicit Standard launch carrying an operator floor and a decision critic: plan-map grades no floor (the + // launch refuses it — with the preview's own acceptance verdict as the reason) and reviews no decisions. + var request = Request("Validate the report", TaskEffortModes.Standard) with { AcceptanceChecks = ["sh", "check.sh"], DecisionReviewMode = Messages.Enums.ReviewMode.Gate, RepositoryId = Guid.NewGuid() }; + + var preview = await Preview(Router()).PreviewAsync(request, CancellationToken.None); + var seed = await new ChatSeedProvider().SeedAsync(request, CancellationToken.None); + var launch = await Controls().ResolveAsync(request, seed, preview.Route, CancellationToken.None); + + preview.ControlDispositions.ShouldNotBeNull(); + JsonSerializer.Serialize(preview.ControlDispositions, Json).ShouldBe(JsonSerializer.Serialize(launch.Dispositions, Json), + customMessage: "the preview must call the launch's own resolver — a divergence here is a preview promising a launch it will not get"); + + var floor = preview.ControlDispositions!.Single(d => d.Control == LaunchControls.AcceptanceChecks); + floor.Outcome.ShouldBe(LaunchControlOutcome.Refused); + floor.Reason.ShouldBe(preview.AcceptanceCompatibility!.Detail, "the refusal names the same reason the preview's acceptance verdict gives"); + + preview.ControlDispositions!.Single(d => d.Control == LaunchControls.DecisionReviewMode).Outcome.ShouldBe(LaunchControlOutcome.NotApplicable); + } + + [Fact] + public async Task A_disposition_serializes_its_outcome_by_name_and_a_control_free_input_reports_none() + { + var preview = await Preview(Router()).PreviewAsync(Request("Fix a small typo", TaskEffortModes.Quick) with { DeliverySpec = new DeliverySpec { OpenPullRequest = true } }, CancellationToken.None); + + var wire = JsonSerializer.SerializeToElement(preview, Json).GetProperty("controlDispositions").EnumerateArray().Single(); + wire.GetProperty("control").GetString().ShouldBe("deliverySpec"); + wire.GetProperty("outcome").GetString().ShouldBe("NotApplicable", "the wire carries the outcome's name, not an ordinal a client would have to decode"); + wire.GetProperty("reason").GetString().ShouldNotBeNullOrWhiteSpace(); + + (await Preview(Router()).PreviewAsync(Request("Fix a small typo", TaskEffortModes.Quick), CancellationToken.None)).ControlDispositions.ShouldBeEmpty(); + } + // ─── Posture (arc3 item 3.2): "preview is not the run" ───────────────────────────────────────────────────── [Theory] @@ -260,6 +303,19 @@ private sealed class RoutingOnlySnapshotStore(IEffortRouter router) : ITaskRoute public Task ConsumeAsync(TaskRouteSnapshotConsumption consumption, CancellationToken cancellationToken) => throw new NotSupportedException(); } + /// A model pool no preview here may consult: none of them sets allowedModelIds, so any lookup is a resolver reaching for the pool when it has nothing to bound. + private sealed class UnconsultedModelPool : Core.Services.Agents.ModelCredentials.IModelPoolSelector + { + public Task> ListPoolRowsAsync(Guid teamId, IReadOnlyList rowIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SelectAsync(Guid teamId, string provider, IReadOnlyList? allowedModels, string? pinnedModel, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveByRowIdAsync(Guid teamId, Guid modelCredentialModelId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveDispatchAsync(Guid teamId, string modelName, IReadOnlyList? allowedRowIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ListPoolAsync(Guid teamId, IReadOnlyList? allowedRowIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SelectBrainRowIdAsync(Guid teamId, IReadOnlyCollection eligibleProviders, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolvePinnedBrainRowIdAsync(Guid teamId, Guid modelCredentialModelId, IReadOnlyCollection eligibleProviders, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResolveTeamDefaultProviderAsync(Guid teamId, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + /// A guard that accepts every repo — tenancy itself is proven against real Postgres in the integration tier; these tests pin the routing, not the query. private sealed class AllRepositoriesInTeam : ILaunchRepositoryScopeGuard { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs index cd7aac0cd..cb20b492e 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs @@ -452,6 +452,54 @@ public async Task A_malformed_credentialed_model_id_fails_the_node() result.Error.ShouldContain("modelCredentialModelId"); } + [Fact] + public void The_manifest_declares_the_allowed_model_pool_the_node_reads() + { + // The node reads allowedModelIds (as it reads maxCostUsd); an undeclared key is invisible to the editor and to + // anything that reads the manifest to learn what an agent.run can be told. + var pool = new AgentCodeNode().Manifest.ConfigSchema.GetProperty("properties").GetProperty("allowedModelIds"); + + pool.GetProperty("type").GetString().ShouldBe("array"); + pool.GetProperty("items").GetProperty("format").GetString().ShouldBe("uuid", "credentialed-model row ids — the shape the node's strict reader accepts"); + } + + [Fact] + public async Task The_allowed_model_pool_a_projection_baked_is_carried_onto_the_task() + { + var pool = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var config = new Dictionary(RequiredConfig()) { ["allowedModelIds"] = JsonSerializer.SerializeToElement(pool.Select(id => id.ToString())) }; + + var result = await new AgentCodeNode().RunAsync(BuildContext(config, resume: null), CancellationToken.None); + + result.Status.ShouldBe(NodeStatus.Suspended); + JsonSerializer.Deserialize(result.SuspendUntil!.Payload, AgentJson.Options)!.AllowedModelIds.ShouldBe(pool, "dispatch holds the model to the pool the task carries"); + } + + [Fact] + public async Task An_absent_allowed_model_pool_leaves_the_task_unbounded_and_its_json_byte_identical() + { + var result = await new AgentCodeNode().RunAsync(BuildContext(RequiredConfig(), resume: null), CancellationToken.None); + + JsonSerializer.Deserialize(result.SuspendUntil!.Payload, AgentJson.Options)!.AllowedModelIds.ShouldBeNull(); + result.SuspendUntil.Payload.GetRawText().ShouldNotContain("allowedModelIds"); + } + + [Theory] + [InlineData("[\"not-a-uuid\"]")] + [InlineData("\"a-single-string\"")] + [InlineData("[42]")] + public async Task A_malformed_allowed_model_pool_fails_the_node_rather_than_run_on_a_different_bound(string raw) + { + // A pool is a bound: silently skipping an entry would run the agent on a different set of models than the + // operator allowed — and an all-skipped pool on none of them — so it fails like any malformed id. + var config = new Dictionary(RequiredConfig()) { ["allowedModelIds"] = JsonDocument.Parse(raw).RootElement.Clone() }; + + var result = await new AgentCodeNode().RunAsync(BuildContext(config, resume: null), CancellationToken.None); + + result.Status.ShouldBe(NodeStatus.Failure); + result.Error.ShouldContain("allowedModelIds"); + } + [Fact] public async Task An_unset_credentialed_model_is_omitted_from_the_staged_task_json() { @@ -578,6 +626,7 @@ public async Task Resumed_failure_fails_the_node_with_the_error() [InlineData("Failed", "harness-reported-failure", true)] // exit-0-but-harness-Error — a fresh respawn may survive [InlineData("Failed", "resource-exhausted", false)] // a cgroup ceiling killed the subtree — a respawn runs at the SAME ceiling and dies identically [InlineData("Failed", FailureCodes.SandboxArgumentTooLong, false)] // the spec itself is unexecutable — every host's kernel refuses the same bytes, so N respawns are N identical refusals + [InlineData("Failed", FailureCodes.AgentModelPoolUnresolvable, false)] // nothing in the run's allowed model pool can run — a respawn meets the same pool [InlineData("Failed", FailureCodes.NativeLaunchUnavailable, true)] // the SLOT could not admit one (foreign host, binding conflict, missing bootstrap) — another worker may well take it public async Task A_resumed_failure_carries_the_retry_verdict_for_the_engine(string status, string? exitReason, bool expectedRetryable) { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorEscalationTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorEscalationTests.cs index 0648ac9b7..50181fb52 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorEscalationTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorEscalationTests.cs @@ -50,15 +50,18 @@ public void A_passing_round_never_asks_for_a_stronger_model() => // ─── applying the pick ──────────────────────────────────────────────────── [Fact] - public void An_escalated_pick_overrides_the_tasks_pinned_model() + public void An_escalated_pick_overrides_the_tasks_pinned_model_and_its_row() { // The supervisor lane's precedent (#1061): an operator's ordinary model choice is a FLOOR for untested work, - // not a ceiling once the run's own check has disproved it. - var task = TaskWith("claude-haiku-4-5"); + // not a ceiling once the run's own check has disproved it. The row moves with the model: a task that kept the + // row it started on would name, to the output review's producer identity, a model that did not do the work. + var escalatedRow = Guid.NewGuid(); + var task = TaskWith("claude-haiku-4-5") with { ModelCredentialModelId = Guid.NewGuid() }; - var escalated = AgentRunExecutor.ApplyEscalation(task, new AgentModelEscalation { From = "claude-haiku-4-5", To = "claude-sonnet-4-5", Reason = "r" }); + var escalated = AgentRunExecutor.ApplyEscalation(task, new AgentRunExecutor.EscalationPick(new AgentModelEscalation { From = "claude-haiku-4-5", To = "claude-sonnet-4-5", Reason = "r" }, escalatedRow)); escalated.Model.ShouldBe("claude-sonnet-4-5"); + escalated.ModelCredentialModelId.ShouldBe(escalatedRow); } [Fact] @@ -66,7 +69,7 @@ public void A_no_stronger_model_outcome_leaves_the_model_exactly_as_it_was() { var task = TaskWith("claude-haiku-4-5"); - var same = AgentRunExecutor.ApplyEscalation(task, new AgentModelEscalation { From = "claude-haiku-4-5", To = null, Reason = "r" }); + var same = AgentRunExecutor.ApplyEscalation(task, new AgentRunExecutor.EscalationPick(new AgentModelEscalation { From = "claude-haiku-4-5", To = null, Reason = "r" }, RowId: null)); same.Model.ShouldBe("claude-haiku-4-5", "a one-model team keeps its only model — the FACT is recorded elsewhere, the dispatch is untouched"); same.ShouldBe(task, "byte-identical task — a no-op escalation must not perturb the dispatch at all"); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterDegradeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterDegradeTests.cs index 6d29ead4f..83b4d9bab 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterDegradeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterDegradeTests.cs @@ -9,6 +9,7 @@ using CodeSpace.Core.Services.Tasks.Recipes.MapFanout; using CodeSpace.Core.Services.Tasks.Recipes.SingleAgent; using CodeSpace.Core.Services.Tasks.Recipes.Supervisor; +using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Messages.Tasks; using CodeSpace.Messages.Tasks.Effort; using Shouldly; @@ -43,7 +44,8 @@ public async Task The_degrade_is_data_driven_a_fake_recipe_and_fake_capability_d new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new FakeGatedRecipe() }), new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset() }), - new CapabilityProbeRegistry(new ICapabilityProbe[] { new FakeProbe(FakeGatedRecipe.FakeCapability, available: false) })); + new CapabilityProbeRegistry(new ICapabilityProbe[] { new FakeProbe(FakeGatedRecipe.FakeCapability, available: false) }), + new TaskProjectionRegistry(Array.Empty())); var plan = await router.RouteAsync(Request(FakeGatedRecipe.FakeTier), CancellationToken.None); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterGenericityTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterGenericityTests.cs index cc6640ae5..f4a256c89 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterGenericityTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterGenericityTests.cs @@ -6,6 +6,7 @@ using CodeSpace.Core.Services.Tasks.Effort.Classifiers.Heuristic; using CodeSpace.Core.Services.Tasks.Recipes; using CodeSpace.Core.Services.Tasks.Recipes.SingleAgent; +using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Messages.Tasks; using CodeSpace.Messages.Tasks.Effort; using Shouldly; @@ -60,7 +61,8 @@ public async Task Routing_a_request_pinned_to_the_fake_recipe_picks_up_its_proje new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), new FakeClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new FakeRecipe() }), new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new FakeBounds() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + new TaskProjectionRegistry(Array.Empty())); // RequestedEffort = the fake bounds kind so the effort-mode ≡ preset-kind convention resolves the fake caps; // RequestedRecipe = the fake recipe so its DefaultProjectionKind drives the projection. @@ -94,7 +96,8 @@ public async Task Auto_path_routes_via_the_registry_Default_classifier_not_a_nam new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), new FakeClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe() }), new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + new TaskProjectionRegistry(Array.Empty())); var plan = await router.RouteAsync(Request(), CancellationToken.None); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterTests.cs index 51842474e..8c2dc435f 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/EffortRouterTests.cs @@ -9,6 +9,7 @@ using CodeSpace.Core.Services.Tasks.Recipes.MapFanout; using CodeSpace.Core.Services.Tasks.Recipes.SingleAgent; using CodeSpace.Core.Services.Tasks.Recipes.Supervisor; +using CodeSpace.Core.Services.Tasks.Projection; using CodeSpace.Messages.Tasks; using CodeSpace.Messages.Tasks.Effort; using Shouldly; @@ -31,7 +32,8 @@ public class EffortRouterTests new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier() }), new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new MapFanoutRecipe(), new SupervisorRecipe() }), new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + new TaskProjectionRegistry(Array.Empty())); private static EffortRouteRequest Request(string goal, string? requestedEffort = null, string? requestedRecipe = null, string? requestedProjection = null, RouteCaps? capsOverride = null, string? deliverableShape = null) => new() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LlmEffortClassifierTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LlmEffortClassifierTests.cs index 310042878..7f7ab2f28 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LlmEffortClassifierTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LlmEffortClassifierTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CodeSpace.Core.Services.Agents.ModelCredentials; +using CodeSpace.Core.Services.Tasks; using CodeSpace.Core.Services.Tasks.Bounds; using CodeSpace.Core.Services.Tasks.Bounds.Presets.Deep; using CodeSpace.Core.Services.Tasks.Bounds.Presets.Quick; @@ -8,6 +9,11 @@ using CodeSpace.Core.Services.Tasks.Effort; using CodeSpace.Core.Services.Tasks.Effort.Classifiers.Heuristic; using CodeSpace.Core.Services.Tasks.Effort.Classifiers.Llm; +using CodeSpace.Core.Services.Tasks.Projection; +using CodeSpace.Core.Services.Tasks.Projection.Builders.PlanMapDynamic; +using CodeSpace.Core.Services.Tasks.Projection.Builders.PlanMapSynth; +using CodeSpace.Core.Services.Tasks.Projection.Builders.SingleAgent; +using CodeSpace.Core.Services.Tasks.Projection.Builders.Supervisor; using CodeSpace.Core.Services.Tasks.Recipes; using CodeSpace.Core.Services.Tasks.Recipes.MapFanout; using CodeSpace.Core.Services.Tasks.Recipes.SingleAgent; @@ -171,7 +177,8 @@ public async Task The_router_auto_path_uses_the_llm_and_skips_the_confirm_card_w new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), llm }), recipes, new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + ProductionProjections()); var plan = await router.RouteAsync(Request("Add validation to the signup endpoint across the service with tests"), CancellationToken.None); @@ -194,7 +201,8 @@ public async Task The_router_auto_path_STILL_confirms_when_the_llm_is_unsure() new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), llm }), recipes, new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + ProductionProjections()); var plan = await router.RouteAsync(Request("do the thing"), CancellationToken.None); @@ -215,7 +223,8 @@ public async Task The_router_STILL_confirms_a_RISKY_task_even_when_the_model_is_ new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), llm }), recipes, new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), - new CapabilityProbeRegistry(Array.Empty())); + new CapabilityProbeRegistry(Array.Empty()), + ProductionProjections()); var plan = await router.RouteAsync(Request("Drop the production users table and deploy"), CancellationToken.None); @@ -225,6 +234,128 @@ public async Task The_router_STILL_confirms_a_RISKY_task_even_when_the_model_is_ plan.EffortMode.ShouldBe(TaskEffortModes.Deep, "the risky task still routes to deep"); } + [Fact] + public async Task The_router_STILL_confirms_an_AMBIGUOUS_task_even_when_the_model_is_confident() + { + // The ambiguity veto: a confident model that ALSO reports the goal under-specified must not have it routed as + // though it were understood. The signal was produced by every classifier and read by nothing, while the confirm + // exception told the operator it was stopping "this ambiguous or potentially risky task". + var plan = await LlmRouter(Reply(needsCodeChange: true, ambiguous: true, confidence: 0.9)).RouteAsync(Request("Make the dashboard better"), CancellationToken.None); + + plan.ClassifierConfidence.ShouldBe(0.9, "the model was confident"); + plan.NeedsConfirmCard.ShouldBeTrue("an ambiguous goal confirms regardless of model confidence — the gate the model cannot talk its way past"); + plan.Confirm.ShouldNotBeNull(); + } + + [Fact] + public async Task An_operator_floor_moves_a_confident_standard_route_off_the_plan_map_lane_and_says_so() + { + // Standard's default lane (plan-map-synth) grades no operator command, so an auto route that must honour an + // acceptance floor takes the policy's next matching row — the single-agent tier, whose one agent the floor grades. + // The same reply without a floor still routes plan-map: the confident-routing win is untouched. + var reply = Reply(needsCodeChange: true, crossFile: true, confidence: 0.9); + + var unfloored = await LlmRouter(reply).RouteAsync(Request(), CancellationToken.None); + var floored = await LlmRouter(reply).RouteAsync(Request() with { HasOperatorFloor = true }, CancellationToken.None); + + unfloored.ProjectionKind.ShouldBe(TaskProjectionKinds.PlanMapSynth); + floored.EffortMode.ShouldBe(TaskEffortModes.Quick, "the Standard row is set aside, and the next matching row down is the cheap catch-all"); + floored.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "an operator floor never lands an auto route on a lane that cannot grade it"); + floored.NeedsConfirmCard.ShouldBeFalse("the move is the router's own on a confident route — there is nothing new for the operator to confirm"); + floored.DegradedReason.ShouldNotBeNull("the route moved, so it says why — never silent"); + floored.DegradedReason!.ShouldContain(TaskProjectionKinds.PlanMapSynth); + } + + [Theory] + [InlineData(TaskEffortModes.Standard, null)] // the operator chose the tier + [InlineData(null, TaskRecipeKinds.MapFanout)] // the operator pinned the recipe on the auto path + public async Task An_operator_floor_never_moves_a_lane_the_operator_chose(string? effort, string? recipe) + { + // The launch refuses the floor on a chosen plan-map lane by name; rerouting it would override the operator. + var request = Request() with { HasOperatorFloor = true, RequestedEffort = effort, RequestedRecipe = recipe }; + + var plan = await LlmRouter(Reply(needsCodeChange: true, crossFile: true, confidence: 0.9)).RouteAsync(request, CancellationToken.None); + + plan.ProjectionKind.ShouldBe(TaskProjectionKinds.PlanMapSynth); + plan.DegradedReason.ShouldBeNull(); + } + + [Fact] + public async Task A_rerouted_auto_route_that_still_needs_confirming_suggests_the_tier_it_took() + { + // An unsure model with Standard-shaped signals and an operator floor: the route moves to Quick, and the confirm + // card must pre-select Quick. Suggesting the Standard tier the floor set aside would send a confirmed launch to + // plan-map — where the check is refused, or stripped by a composer that sends no check on Standard. + var plan = await LlmRouter(Reply(needsCodeChange: true, crossFile: true, confidence: 0.4)).RouteAsync(Request() with { HasOperatorFloor = true }, CancellationToken.None); + + plan.NeedsConfirmCard.ShouldBeTrue(); + plan.EffortMode.ShouldBe(TaskEffortModes.Quick); + plan.Confirm!.SuggestedMode.ShouldBe(TaskEffortModes.Quick, "the card pre-selects the tier the route actually took"); + } + + [Theory] + [InlineData(" ")] + [InlineData("sh\0x")] + public void A_malformed_floor_is_no_routing_signal(string executable) + { + // No builder bakes this argv, so it grades nothing and must not move the route — the launch refuses it by name. + var request = new TaskLaunchRequest { TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat", TaskText = "x", AcceptanceChecks = [executable, "check.sh"] }; + var seed = new TaskLaunchSeed { Goal = "x", SurfaceKind = "chat", TeamId = request.TeamId }; + + TaskLaunchService.BuildRouteRequest(seed, request).HasOperatorFloor.ShouldBeFalse(); + TaskLaunchService.BuildRouteRequest(seed, request with { AcceptanceChecks = ["sh", "check.sh"] }).HasOperatorFloor.ShouldBeTrue(); + } + + [Fact] + public async Task An_operator_floor_keeps_deep_where_its_supervisor_grades_it_against_a_repository() + { + // Deep's supervisor lane grades the floor at its terminal stop, against the branch it publishes — nothing to move. + var plan = await LlmRouter(Reply(estimatedCostTier: "high", confidence: 0.9)).RouteAsync(Request() with { HasOperatorFloor = true, HasRepository = true }, CancellationToken.None); + + plan.ProjectionKind.ShouldBe(TaskProjectionKinds.Supervisor); + plan.DegradedReason.ShouldBeNull(); + } + + [Fact] + public async Task A_repo_less_operator_floor_moves_a_deep_route_to_the_single_agent_that_grades_it() + { + // With no repository the supervisor's stop has no branch to grade, so the floor would go ungraded there; the single + // agent grades it in its own workspace. Every tier the floor cannot be graded on is set aside — Deep AND Standard. + var plan = await LlmRouter(Reply(estimatedCostTier: "high", needsCodeChange: true, crossFile: true, confidence: 0.9)).RouteAsync(Request() with { HasOperatorFloor = true, HasRepository = false }, CancellationToken.None); + + plan.EffortMode.ShouldBe(TaskEffortModes.Quick); + plan.ProjectionKind.ShouldBe(TaskProjectionKinds.SingleAgent, "a repo-less floor never lands an auto route on Deep, where it would go ungraded"); + plan.DegradedReason.ShouldNotBeNull().ShouldContain(TaskProjectionKinds.Supervisor, customMessage: "the move is named on the route, never silent"); + } + + [Fact] + public void The_route_request_says_whether_the_launch_binds_a_repository() + { + var request = new TaskLaunchRequest { TeamId = Guid.NewGuid(), ActorUserId = Guid.NewGuid(), SurfaceKind = "chat", TaskText = "x" }; + var seed = new TaskLaunchSeed { Goal = "x", SurfaceKind = "chat", TeamId = request.TeamId }; + + TaskLaunchService.BuildRouteRequest(seed, request).HasRepository.ShouldBeFalse(); + TaskLaunchService.BuildRouteRequest(seed, request with { RepositoryId = Guid.NewGuid() }).HasRepository.ShouldBeTrue("a repository the request names"); + TaskLaunchService.BuildRouteRequest(seed with { RepositoryId = Guid.NewGuid() }, request).HasRepository.ShouldBeTrue("a repository the surface's seed carries"); + } + + /// The production router over a canned LLM reply, the real recipes + bounds presets, and the REAL projection builders — whose OperatorAcceptance advertisements are what the floor rule reads. + private static EffortRouter LlmRouter(JsonElement reply) + { + var recipes = new TaskRecipeRegistry(new ITaskRecipe[] { new SingleAgentRecipe(), new MapFanoutRecipe(), new SupervisorRecipe() }); + var llm = new LlmEffortClassifier(new FakeClients(new CannedClient(reply)), new FakeSelector(Pick()), recipes, new HeuristicEffortClassifier()); + + return new EffortRouter( + new EffortClassifierRegistry(new IEffortClassifier[] { new HeuristicEffortClassifier(), llm }), + recipes, + new BoundsPresetRegistry(new IBoundsPreset[] { new QuickBoundsPreset(), new StandardBoundsPreset(), new DeepBoundsPreset() }), + new CapabilityProbeRegistry(Array.Empty()), + ProductionProjections()); + } + + private static TaskProjectionRegistry ProductionProjections() => + new(new IWorkflowDefinitionBuilder[] { new SingleAgentDefinitionBuilder(), new PlanMapSynthDefinitionBuilder(), new PlanMapDynamicDefinitionBuilder(), new SupervisorDefinitionBuilder() }); + // ── Schema commit-contract pin ── [Fact] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs index 2ebb51478..0584efabb 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/PlanAuthorNodeTests.cs @@ -102,6 +102,18 @@ public void Model_pins_and_review_mode_map_into_the_planner_request() request.ReviewerModelId.ShouldBe(reviewerRow); } + [Fact] + public void The_allowed_model_pool_maps_into_the_planner_request_and_an_absent_one_is_unbounded() + { + // The pool bounds the capability catalog the planner allocates subtasks from; each branch's agent.run is where + // it is enforced, so the node reads it defensively like every other planner knob. + var pooled = Guid.NewGuid(); + + PlanAuthorNode.BuildPlanRequest(Config($$"""{"allowedModelIds":["{{pooled}}","not-a-uuid"]}"""), Guid.NewGuid(), new PlanAuthorNode.PlanPromptParts("goal", [], "", "")).AllowedModelIds.ShouldBe(new[] { pooled }); + PlanAuthorNode.BuildPlanRequest(Config("""{"allowedModelIds":["not-a-uuid"]}"""), Guid.NewGuid(), new PlanAuthorNode.PlanPromptParts("goal", [], "", "")).AllowedModelIds.ShouldBeNull(); + PlanAuthorNode.BuildPlanRequest(Config("""{}"""), Guid.NewGuid(), new PlanAuthorNode.PlanPromptParts("goal", [], "", "")).AllowedModelIds.ShouldBeNull("no pool ⇒ the whole team pool — the catalog the planner has always seen"); + } + [Fact] public void The_launch_base_pin_maps_into_the_planner_request_and_a_blank_pin_is_omitted() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs index a14db7841..416a4c529 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/PlanMapSynthDefinitionBuilderTests.cs @@ -68,6 +68,24 @@ public void The_projection_declares_the_whole_task_operator_floor_it_does_not_co nodes.ShouldAllBe(n => !n.Config.GetRawText().Contains("unique-task-oracle")); } + [Fact] + public void The_allowed_model_pool_bounds_the_planners_catalog_and_rides_every_branch() + { + // The planner allocates each subtask a model from the catalog it is shown, and the branch body is where a + // planner-authored {{item.model}} outside the pool is held to it at dispatch — both must carry the pool. + var pool = new[] { Guid.NewGuid(), Guid.NewGuid() }; + + var bounded = Builder.Build(Context() with { AllowedModelIds = pool }); + var unbounded = Builder.Build(Context()); + + var plannerConfig = bounded.Nodes.Single(n => n.Id == "planner").Config.Deserialize>()!; + PlanAuthorNode.BuildPlanRequest(plannerConfig, Guid.NewGuid(), new PlanAuthorNode.PlanPromptParts("goal", [], "", "")).AllowedModelIds.ShouldBe(pool, "the planner request the plan.author node builds from this very config carries the pool"); + bounded.Nodes.Single(n => n.Id == "agent").Config.GetProperty("allowedModelIds").EnumerateArray().Select(e => Guid.Parse(e.GetString()!)).ShouldBe(pool); + RealValidator().Validate(bounded).IsValid.ShouldBeTrue(); + + unbounded.Nodes.ShouldAllBe(n => !n.Config.GetRawText().Contains("allowedModelIds"), "no pool ⇒ no node carries the key — byte-identical"); + } + [Fact] public void Emits_the_planner_map_agent_synth_graph() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/SingleAgentDefinitionBuilderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/SingleAgentDefinitionBuilderTests.cs index 6d21cda6a..af8b2c05f 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/SingleAgentDefinitionBuilderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/SingleAgentDefinitionBuilderTests.cs @@ -293,6 +293,23 @@ public void The_agent_node_carries_the_default_transient_retry() private static JsonElement AgentInputsOf(WorkflowDefinition def) => def.Nodes.Single(n => n.Id == "agent").Inputs; private static JsonElement TerminalInputsOf(WorkflowDefinition def) => def.Nodes.Single(n => n.Id == "done").Inputs; + // ── The operator's allowed model pool rides the one agent, where dispatch holds its model to the pool ── + + [Fact] + public void The_allowed_model_pool_rides_the_agent_node_and_an_unbounded_launch_is_byte_identical() + { + var pool = new[] { Guid.NewGuid(), Guid.NewGuid() }; + + var bounded = Builder.Build(Context(Seed(), profile: null) with { AllowedModelIds = pool }); + var unbounded = AgentConfigOf(Builder.Build(Context(Seed(), profile: null))); + + AgentConfigOf(bounded).GetProperty("allowedModelIds").EnumerateArray().Select(e => Guid.Parse(e.GetString()!)).ShouldBe(pool, "the agent.run carries the pool onto its AgentTask, where dispatch holds the model to it"); + RealValidator().Validate(bounded).IsValid.ShouldBeTrue(); + + unbounded.TryGetProperty("allowedModelIds", out _).ShouldBeFalse("no pool ⇒ the key is omitted"); + AgentConfigOf(Builder.Build(Context(Seed(), profile: null) with { AllowedModelIds = [] })).GetRawText().ShouldBe(unbounded.GetRawText(), "an empty pool is the whole team pool — byte-identical"); + } + // ── S5: the quick tier's operator checks floor becomes the single agent's contract ── [Fact]