diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs
index 72b73c1e8..55c3fda66 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRetryContinuity.cs
@@ -11,7 +11,7 @@ namespace CodeSpace.Core.Services.Agents;
public static class AgentRetryContinuity
{
/// The honest-redo line: fires ONLY when a resumed conversation exists but the workspace was NOT pinned to a prior pushed branch — never on a genuine cold-start retry (no prior attempt at all), which stays byte-identical.
- public const string HonestNoContinuityHint = "Note: your prior attempt's conversation is restored, but its git changes were NOT preserved in this workspace (your prior attempt pushed no branch of its own) — you must redo any relevant file changes from scratch.";
+ public const string HonestNoContinuityHint = "Note: your prior attempt's conversation is restored, but its git changes were NOT preserved in this workspace (your prior attempt pushed no branch of its own for this repository) — you must redo any relevant file changes from scratch.";
/// Append to a resumed task's goal. One composition, so the two lanes cannot drift on the separator either.
public static string WithHonestNoContinuityHint(string goal) => $"{goal}\n\n{HonestNoContinuityHint}";
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Credentials/Broker/LoopbackModelCredentialBroker.cs b/backend/src/CodeSpace.Core/Services/Agents/Credentials/Broker/LoopbackModelCredentialBroker.cs
index fd21c221c..22a00f800 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Credentials/Broker/LoopbackModelCredentialBroker.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Credentials/Broker/LoopbackModelCredentialBroker.cs
@@ -499,6 +499,16 @@ private async Task AcceptAsync(Lease lease)
// matters more here than it used to — a revoke now closes a listener per FINISHED RUN, where before a
// listener only ever closed at process teardown — and the only correct response to any of them is to stop
// serving an address that no longer exists, and to stop CLAIMING it.
+ //
+ // A close can also land between the request above and this re-registration. The managed listener checks
+ // its state and only then queues the wait, and Close() completes-and-clears that queue in between, so the
+ // wait joins a closed listener's queue and this loop never resumes. That strands nothing: every close path
+ // takes the lease out of _byRun (supersede, revoke, sweep and drop before closing, dispose right after),
+ // and what is left is a cycle no root reaches — the closed listener's queue, the wait, this state machine,
+ // the lease, the listener — because Close() has already unhooked the listener from the endpoint manager's
+ // statics. It is collected, decrypted key and all, exactly as a loop that woke and returned would be
+ // (checked on .NET 10 by stranding a loop this way: its lease's weak reference cleared, while a lease a
+ // table still held stayed alive).
try { context = await lease.Listener.GetContextAsync().ConfigureAwait(false); }
catch (Exception exception) { DropIfStillServing(lease, exception); return; }
diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs
index 4977ef974..1e1bf704c 100644
--- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs
+++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs
@@ -11,6 +11,7 @@
using CodeSpace.Messages.Dtos.Decisions;
using CodeSpace.Messages.Review;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace CodeSpace.Core.Services.Supervisor;
@@ -1567,20 +1568,34 @@ private async Task GradeStopTargetsWithHeartbeatAsync(Guid super
}
}
- /// The heartbeat loop itself: sleeps, logs, repeats — until fires (grading finished). A cancellation mid-sleep is the expected exit, never propagated as a fault. Internal + clock-parameterized so a unit test drives the sleep on a fake instead of racing the wall clock; REQUIRED rather than defaulting to the system clock for the reason gives — a default is how a call site keeps the wall clock without saying so.
- internal async Task RunGradingHeartbeatLoopAsync(Guid supervisorRunId, string nodeId, TimeSpan interval, CancellationToken cancellationToken, TimeProvider timeProvider, string message = "Supervisor stop acceptance grading is still in progress.")
+ ///
+ /// The heartbeat loop itself: sleeps, pulses, repeats — until fires (grading
+ /// finished). It is , so it NEVER completes faulted: a cancellation is the
+ /// expected exit, and a pulse that fails is logged as a warning and retried on the next interval. That is
+ /// load-bearing rather than tidy — every call site awaits this task in the finally around the grade it
+ /// protects, so a fault here would REPLACE the grade with the error of a missed log line.
+ ///
+ /// Internal + clock-parameterized so a unit test drives the sleep on a fake
+ /// instead of racing the wall clock; REQUIRED rather than defaulting to the system clock for the reason
+ /// gives — a default is how a call site keeps the wall clock without saying so.
+ ///
+ internal Task RunGradingHeartbeatLoopAsync(Guid supervisorRunId, string nodeId, TimeSpan interval, CancellationToken cancellationToken, TimeProvider timeProvider, string message = "Supervisor stop acceptance grading is still in progress.") =>
+ HeartbeatLoop.RunAsync(ct => PulseGradingHeartbeatAsync(supervisorRunId, nodeId, message, ct), interval, exception => _logger.LogWarning(exception, "Supervisor run {SupervisorRunId} node {NodeId}: a grading heartbeat pulse failed; grading continues and the next pulse retries", supervisorRunId, nodeId), cancellationToken, timeProvider);
+
+ ///
+ /// One pulse, written through a record logger from a DI scope of its OWN. The pulse runs concurrently with the
+ /// grade, and this service's scope holds the one DbContext the grade is using — its manifest stamps and recorded
+ /// judge calls go through it — so a pulse on this scope's logger that fell due mid-query died on EF's "a second
+ /// operation was started on this context" guard. A scope per pulse rather than one per loop, so an insert that
+ /// failed never waits in a change tracker for the next pulse's save to retry it.
+ ///
+ private async Task PulseGradingHeartbeatAsync(Guid supervisorRunId, string nodeId, string message, CancellationToken cancellationToken)
{
- try
- {
- while (true)
- {
- await Task.Delay(interval, timeProvider, cancellationToken).ConfigureAwait(false);
+ using var scope = _scopeFactory.CreateScope();
- await _recordLogger.LogAsync(supervisorRunId, nodeId, Workflows.Lifecycle.LogLevel.Info,
- message, cancellationToken).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException) { }
+ var records = scope.ServiceProvider.GetRequiredService();
+
+ await records.LogAsync(supervisorRunId, nodeId, Workflows.Lifecycle.LogLevel.Info, message, cancellationToken).ConfigureAwait(false);
}
///
diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.cs
index 5881125dd..95c8dd925 100644
--- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.cs
+++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.cs
@@ -10,6 +10,7 @@
using CodeSpace.Messages.Budget;
using CodeSpace.Messages.Dtos.Agents;
using CodeSpace.Messages.Plans;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace CodeSpace.Core.Services.Supervisor;
@@ -41,6 +42,9 @@ public sealed partial class SupervisorTurnService : ISupervisorTurnService, ISco
private readonly ISupervisorPublishedBranchResolver _publishedBranches;
private readonly Completion.ICompletionAssessmentComposer _completion;
+ /// Opens the scope each grading-heartbeat pulse writes through — never this service's own, whose DbContext the grade the pulse runs beside is using (see ).
+ private readonly IServiceScopeFactory _scopeFactory;
+
/// C1 — the rubric judge a BRANCHLESS LlmJudge stop reads its summary with. OPTIONAL so the many hand-built test doubles keep compiling; DI always supplies it, and a null one fails the gate CLOSED rather than passing it silently.
private readonly Review.IRubricJudge? _rubricJudge;
@@ -49,7 +53,7 @@ public sealed partial class SupervisorTurnService : ISupervisorTurnService, ISco
private readonly ILogger _logger;
- public SupervisorTurnService(ISupervisorDecisionLog ledger, ISupervisorDecider decider, ISupervisorActionExecutor executor, CodeSpaceDbContext db, ISupervisorAcceptanceGrader acceptanceGrader, IDecisionQueueService decisionQueue, IDecisionArbiter arbiter, IDecisionAnswerService decisionAnswer, Plans.IWorkPlanService workPlans, Workflows.Lifecycle.IRunRecordLogger recordLogger, Workflows.Artifacts.IArtifactOffloader offloader, IPublishManifestStore manifests, ISupervisorPublishedBranchResolver publishedBranches, Completion.ICompletionAssessmentComposer completion, Workflows.Budget.IBudgetLedger budget, Learning.ILessonReader lessons, ILogger logger, Review.IRubricJudge? rubricJudge = null, Completion.IModeProfileRegistry? modes = null)
+ public SupervisorTurnService(ISupervisorDecisionLog ledger, ISupervisorDecider decider, ISupervisorActionExecutor executor, CodeSpaceDbContext db, ISupervisorAcceptanceGrader acceptanceGrader, IDecisionQueueService decisionQueue, IDecisionArbiter arbiter, IDecisionAnswerService decisionAnswer, Plans.IWorkPlanService workPlans, Workflows.Lifecycle.IRunRecordLogger recordLogger, Workflows.Artifacts.IArtifactOffloader offloader, IPublishManifestStore manifests, ISupervisorPublishedBranchResolver publishedBranches, Completion.ICompletionAssessmentComposer completion, Workflows.Budget.IBudgetLedger budget, Learning.ILessonReader lessons, IServiceScopeFactory scopeFactory, ILogger logger, Review.IRubricJudge? rubricJudge = null, Completion.IModeProfileRegistry? modes = null)
{
_ledger = ledger;
_decider = decider;
@@ -67,6 +71,7 @@ public SupervisorTurnService(ISupervisorDecisionLog ledger, ISupervisorDecider d
_manifests = manifests;
_publishedBranches = publishedBranches;
_completion = completion;
+ _scopeFactory = scopeFactory;
_rubricJudge = rubricJudge;
_modes = modes;
_logger = logger;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionPolicy.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionPolicy.cs
index 5f6df4193..14324803e 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionPolicy.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionPolicy.cs
@@ -62,8 +62,7 @@ public static class ArtifactRetentionPolicy
[SessionTranscriptCheckpoint.Class] = SessionTranscriptCheckpoint,
};
- /// The rule for , or null when the running policy does not register it — which the reaper reads as "cannot tell" and keeps.
- /// The rule for a class NAME, or null when this build registers none — including a name a rolled-back build wrote that this one has never heard of. Null settles as keep.
+ /// The rule for the class NAME , or null when this build registers none — including a name a rolled-back build wrote that this one has never heard of. The reaper reads null as "cannot tell" and keeps.
public static ArtifactRetentionRule? For(string value) => Enum.TryParse(value, ignoreCase: false, out var parsed) && Rules.TryGetValue(parsed, out var rule) ? rule : null;
///
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/ModelPricingUnderCapFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/ModelPricingUnderCapFlowTests.cs
index b40b4d853..73041834b 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/ModelPricingUnderCapFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/ModelPricingUnderCapFlowTests.cs
@@ -764,7 +764,7 @@ private static SupervisorTurnService BuildService(ILifetimeScope scope, ISupervi
scope.Resolve(), scope.Resolve(),
scope.Resolve(), scope.Resolve(),
scope.Resolve(), scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private sealed class AlwaysSpawnDecider : ISupervisorDecider
{
diff --git a/backend/tests/CodeSpace.IntegrationTests/Learning/LessonArmSupervisorFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Learning/LessonArmSupervisorFlowTests.cs
index 11ae63669..737579c9a 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Learning/LessonArmSupervisorFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Learning/LessonArmSupervisorFlowTests.cs
@@ -219,7 +219,7 @@ private async Task SeedLessonAsync(Guid teamId)
scope.Resolve(),
scope.Resolve(),
lessons ?? scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private static Lesson Lesson(Guid teamId, string howToApply) => new()
{
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Supervisor/RealModelChecksBeforeCriticE2ETests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Supervisor/RealModelChecksBeforeCriticE2ETests.cs
index 9e7e37da0..30fb5462e 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Supervisor/RealModelChecksBeforeCriticE2ETests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Supervisor/RealModelChecksBeforeCriticE2ETests.cs
@@ -105,7 +105,7 @@ private async Task RunPlanTurnAsync(Guid runId, Guid teamId, Guid reviewerRowId,
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
var goalConfig = new SupervisorGoalConfig { Goal = Goal, DecisionReviewMode = ReviewMode.Gate, ReviewerModelId = reviewerRowId };
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorAcceptanceFoldFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorAcceptanceFoldFlowTests.cs
index 452c48d63..37fed8ff4 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorAcceptanceFoldFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorAcceptanceFoldFlowTests.cs
@@ -729,7 +729,7 @@ public async Task The_real_grade_drives_the_terminal_stop_status_over_a_real_rep
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
result = await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, GoalConfig(repoId, acceptanceChecks: null), CancellationToken.None);
}
@@ -773,7 +773,7 @@ public async Task The_real_operator_floor_gates_a_clean_runs_terminal_stop_over_
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
result = await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, GoalConfig(repoId, acceptanceChecks: new[] { "sh", "check.sh" }), CancellationToken.None);
}
@@ -821,7 +821,7 @@ public async Task The_real_operator_floor_gates_a_multi_repo_stop_all_or_nothing
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
result = await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, GoalConfig(repoA, acceptanceChecks: new[] { "sh", "check.sh" }), CancellationToken.None);
}
@@ -878,7 +878,7 @@ public async Task The_real_operator_floor_voids_a_heads_rewrite_of_the_check_scr
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
result = await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, GoalConfig(repoId, acceptanceChecks: new[] { "sh", "check.sh" }), CancellationToken.None);
}
@@ -1217,7 +1217,7 @@ private async Task RehydrateAsync(Guid runId, Guid teamId
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
return await service.RehydrateFromDecisionLogAsync(runId, teamId, NodeId, Goal, goalConfig, CancellationToken.None);
}
@@ -1244,7 +1244,7 @@ private async Task RunStopTurnWithGraderAsync(Guid runId,
scope.Resolve(),
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
- scope.Resolve(), scope.Resolve>());
+ scope.Resolve(), scope.Resolve(), scope.Resolve>());
return await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, goalConfig, CancellationToken.None);
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorArbiterDrainFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorArbiterDrainFlowTests.cs
index 08b6e0371..b087a5c9d 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorArbiterDrainFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorArbiterDrainFlowTests.cs
@@ -219,7 +219,7 @@ public async Task The_frozen_in_flight_replay_bypasses_the_arbiter_drain()
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private async Task RunTurnAsync(Guid runId, Guid teamId, IDecisionArbiter arbiter)
{
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorChecksBeforeCriticFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorChecksBeforeCriticFlowTests.cs
index 41d4489c5..d13b42223 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorChecksBeforeCriticFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorChecksBeforeCriticFlowTests.cs
@@ -96,7 +96,7 @@ private async Task RunPlanTurnAsync(Guid runId, Guid teamId, RecordingCritic cri
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
var goalConfig = new SupervisorGoalConfig { Goal = Goal, DecisionReviewMode = ReviewMode.Gate };
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDeliveryGateFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDeliveryGateFlowTests.cs
index c6cb153c8..b9a10f4e0 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDeliveryGateFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDeliveryGateFlowTests.cs
@@ -572,7 +572,7 @@ private async Task RunTurnAsync(Guid runId, Guid teamId
scope.Resolve(),
scope.Resolve(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
[Fact]
public async Task A_model_minted_gate_card_cannot_drive_the_adjudication_release()
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDependencyOrderingFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDependencyOrderingFlowTests.cs
index bdb8f8b87..3df2a7fa1 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDependencyOrderingFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorDependencyOrderingFlowTests.cs
@@ -149,7 +149,7 @@ private async Task RunSpawnTurnAsync(Guid runId, Guid teamId, SupervisorRational
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, goalConfig: null, CancellationToken.None);
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorGradingHeartbeatIsolationFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorGradingHeartbeatIsolationFlowTests.cs
new file mode 100644
index 000000000..d2dece17e
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorGradingHeartbeatIsolationFlowTests.cs
@@ -0,0 +1,142 @@
+using Autofac;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Services.Supervisor;
+using CodeSpace.Core.Services.Workflows.Lifecycle;
+using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.IntegrationTests.Workflows.Infrastructure;
+using CodeSpace.Messages.Agents.Benchmark;
+using CodeSpace.Messages.Commands.Workflows;
+using CodeSpace.Messages.Constants;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using Shouldly;
+
+namespace CodeSpace.IntegrationTests.Workflows;
+
+///
+/// 🟢 High fidelity — the production container's own scope wiring, a real and real
+/// Postgres. The grading heartbeat () runs
+/// CONCURRENTLY with the grade it protects, and one DI scope holds ONE : the turn
+/// service's own reads and writes (the unit grade's manifest stamps, the judge's recorded model calls) and every
+/// resolved from that scope write through the same instance. A pulse that fell due
+/// while a grade query was in flight on it died on EF's "a second operation was started on this context" guard —
+/// and, awaited in the grade's finally, replaced the grade with that error.
+///
+/// The test holds a real query open on the grade scope's context (blocked on an advisory lock only the test
+/// releases), lets a pulse fall due, and releases only once that pulse has settled — so the verdict is decided by
+/// which context the pulse wrote through, never by how the runner scheduled it. The wall clock decides only WHEN
+/// the first pulse fires; every wait on it is bounded and names what it watched.
+///
+[Collection(PostgresCollection.Name)]
+[Trait("Category", "Integration")]
+public class SupervisorGradingHeartbeatIsolationFlowTests
+{
+ private const string NodeId = "sup";
+ private const string Graded = "graded while a pulse fell due";
+
+ /// The advisory-lock class this test's key lives under — a two-key lock never contends with the single-key pg_advisory_xact_lock the run-record admission trigger takes per run.
+ private const int LockClass = 1_976_1994;
+
+ private static readonly TimeSpan PulseInterval = TimeSpan.FromMilliseconds(100);
+ private static readonly TimeSpan SettleBound = TimeSpan.FromSeconds(30);
+
+ private readonly PostgresFixture _fixture;
+
+ public SupervisorGradingHeartbeatIsolationFlowTests(PostgresFixture fixture) { _fixture = fixture; }
+
+ [Fact]
+ public async Task A_pulse_that_falls_due_mid_query_lands_on_its_own_context_and_the_grade_survives()
+ {
+ var runId = await SeedRunAsync();
+ var lockKey = Random.Shared.Next();
+
+ using var gradeScope = _fixture.BeginScope();
+ var service = gradeScope.Resolve();
+
+ await using var holder = await HoldLockAsync(lockKey);
+
+ // The grade's own DB work, in flight on the scope's context for as long as the holder keeps the lock.
+ var gradeQuery = gradeScope.Resolve().Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock({0}, {1})", LockClass, lockKey);
+
+ // The premise, at the DI level: a record logger from the grade's own scope writes through the grade's own
+ // context, so a write on it collides with the query above. Without this collision the test proves nothing.
+ var collision = await Should.ThrowAsync(() => gradeScope.Resolve().LogAsync(runId, NodeId, LogLevel.Info, "premise probe", CancellationToken.None));
+ collision.Message.ShouldContain("second operation was started on this context", customMessage: "premise: the scope's record logger shares the grade's DbContext — the collision the heartbeat must be kept out of");
+
+ using var heartbeatCts = new CancellationTokenSource();
+ var heartbeat = service.RunGradingHeartbeatLoopAsync(runId, NodeId, PulseInterval, heartbeatCts.Token, TimeProvider.System);
+
+ BenchmarkGrade grade;
+ try
+ {
+ await PulseSettledAsync(runId, heartbeat);
+
+ await holder.DisposeAsync(); // release: the grade's query takes the lock and completes
+ await gradeQuery;
+
+ grade = new BenchmarkGrade { Passed = true, Detail = Graded };
+ }
+ finally
+ {
+ // The production call sites' own finally shape: whatever the loop ends with is what this await surfaces.
+ heartbeatCts.Cancel();
+
+ try { await heartbeat; }
+ catch (OperationCanceledException) { }
+ }
+
+ grade.Detail.ShouldBe(Graded, "a heartbeat pulse can never replace the grade it protects");
+
+ (await PulseCountAsync(runId)).ShouldBeGreaterThan(0, "a pulse landed while the grade's query still held the scope's context — only a pulse on its OWN context can");
+ }
+
+ ///
+ /// Waits until a pulse row for has landed, or the loop itself has ended — a loop that
+ /// died on its first pulse has settled too, and the call-site finally then surfaces what killed it.
+ ///
+ private async Task PulseSettledAsync(Guid runId, Task heartbeat)
+ {
+ var deadline = DateTime.UtcNow + SettleBound;
+
+ while (DateTime.UtcNow < deadline)
+ {
+ if (heartbeat.IsCompleted || await PulseCountAsync(runId) > 0) return;
+
+ await Task.Delay(50);
+ }
+
+ throw new TimeoutException($"no grading-heartbeat pulse landed for run {runId} within {SettleBound.TotalSeconds}s while the grade's query held its scope's context, and the loop is still running — every pulse is failing. Look for the SupervisorTurnService pulse warnings: a pulse that writes through the grade's own context dies on EF's second-operation guard.");
+ }
+
+ private async Task PulseCountAsync(Guid runId)
+ {
+ using var scope = _fixture.BeginScope();
+
+ return await scope.Resolve().WorkflowRunRecord.AsNoTracking().CountAsync(r => r.RunId == runId && r.NodeId == NodeId && r.RecordType == WorkflowRunRecordTypes.Log);
+ }
+
+ /// Takes the lock on an unpooled connection of its own, so disposing it ends the session and releases the lock on every path — including a failed assertion.
+ private async Task HoldLockAsync(int lockKey)
+ {
+ var connection = new NpgsqlConnection(new NpgsqlConnectionStringBuilder(_fixture.ConnectionString) { Pooling = false }.ConnectionString);
+ await connection.OpenAsync();
+
+ await using var take = new NpgsqlCommand("SELECT pg_advisory_lock(@class, @key)", connection);
+ take.Parameters.AddWithValue("class", LockClass);
+ take.Parameters.AddWithValue("key", lockKey);
+ await take.ExecuteNonQueryAsync();
+
+ return connection;
+ }
+
+ private async Task SeedRunAsync()
+ {
+ var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture, inProcessPool: false);
+
+ using var scope = _fixture.BeginScopeAs(userId, teamId);
+ var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand { Name = $"grading-heartbeat-{Guid.NewGuid():N}"[..24], Definition = WorkflowsTestSeed.MinimalDefinition(), Activations = Array.Empty(), Enabled = true });
+
+ return await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, teamId);
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorLedgerDirectTerminalOutputFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorLedgerDirectTerminalOutputFlowTests.cs
index c1f3e475e..8af583463 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorLedgerDirectTerminalOutputFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorLedgerDirectTerminalOutputFlowTests.cs
@@ -191,7 +191,7 @@ private async Task RunTurnAsync(Guid runId, Guid teamId, I
scope.Resolve(),
scope.Resolve(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private sealed class AlwaysStopDecider : ISupervisorDecider
{
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorMergeWithholdFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorMergeWithholdFlowTests.cs
index 72f2c350a..431e07059 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorMergeWithholdFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorMergeWithholdFlowTests.cs
@@ -331,7 +331,7 @@ private static SupervisorAgentResult Unit(Guid agentRunId, string producedBranch
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, GoalConfig(), CancellationToken.None);
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPayloadReaskFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPayloadReaskFlowTests.cs
index 422c2b4fd..da2dc11bf 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPayloadReaskFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPayloadReaskFlowTests.cs
@@ -220,7 +220,7 @@ private async Task CurrentWorkPlanItemsAsync(Guid runId, Guid teamId)
scope.Resolve(),
scope.Resolve(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private static LlmSupervisorDecider NewDecider(ILifetimeScope scope, IStructuredLLMClient client) => new(
new LLMClientRegistry(new ILLMClient[] { (ILLMClient)client }),
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanDeliveryFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanDeliveryFlowTests.cs
index 66fe89c11..2032e72db 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanDeliveryFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanDeliveryFlowTests.cs
@@ -195,7 +195,7 @@ private async Task LatestPlanPayloadAsync(Guid runId, Guid teamId)
scope.Resolve(),
scope.Resolve(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
/// A decider that always authors a plan with one subtask, proposing the given delivery contract (or none).
private sealed class AlwaysPlanDecider : ISupervisorDecider
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanValidatorFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanValidatorFlowTests.cs
index 4ee213228..32d29e14e 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanValidatorFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPlanValidatorFlowTests.cs
@@ -81,7 +81,7 @@ private async Task RunPlanTurnAsync(Guid runId, Guid teamId, params (string Id,
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
await service.RunTurnAsync(runId, teamId, NodeId, Goal, conversationId: null, goalConfig: null, CancellationToken.None);
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPublishGateFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPublishGateFlowTests.cs
index a923269a7..4d4afde9e 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPublishGateFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorPublishGateFlowTests.cs
@@ -300,7 +300,7 @@ private async Task RunStopTurnAsync(Guid runId
scope.Resolve(),
scope.Resolve(),
scope.Resolve(),
- scope.Resolve>());
+ scope.Resolve(), scope.Resolve>());
private sealed record SupervisorDecisionRecordSnapshot(string Kind, string PayloadJson, string? OutcomeJson);
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorUnitAcceptanceFoldFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorUnitAcceptanceFoldFlowTests.cs
index aab88f7b4..17790c08d 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorUnitAcceptanceFoldFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SupervisorUnitAcceptanceFoldFlowTests.cs
@@ -1524,7 +1524,7 @@ private async Task RehydrateAsync(Guid runId, Guid teamId
scope.Resolve(),
scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), scope.Resolve(), new AdmitAllBudgetLedger(),
scope.Resolve(),
- scope.Resolve>(),
+ scope.Resolve(), scope.Resolve>(),
rubricJudge: null,
modes: scope.Resolve());
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorArbiterDrainTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorArbiterDrainTests.cs
index ccc2a6df5..ae3215a07 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorArbiterDrainTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorArbiterDrainTests.cs
@@ -164,7 +164,7 @@ public async Task A_no_spawn_rehydrate_skips_the_queue_read_entirely()
var queue = new FakeDecisionQueue();
var ledger = new FakeSupervisorDecisionLog();
ledger.SeedTerminal(runId, TeamId, SupervisorDecisionKinds.Plan, """{"subtasks":["a"]}""", """{"planned":["a"]}""");
- var service = new SupervisorTurnService(ledger, new StubSupervisorDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), queue, new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new StubSupervisorDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), queue, new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), null!, NullLogger.Instance);
var context = await service.RehydrateFromDecisionLogAsync(runId, TeamId, "sup", "goal", goalConfig: null, CancellationToken.None);
@@ -187,7 +187,7 @@ public async Task The_arbiter_call_is_metered_against_the_runs_own_budget_ledger
});
var ledger = new AdmitAllBudgetLedger();
var runId = Guid.NewGuid();
- var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), arbiter, new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), ledger, new NoLessonsReaderStub(), NullLogger.Instance);
+ var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), arbiter, new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), ledger, new NoLessonsReaderStub(), null!, NullLogger.Instance);
var context = new SupervisorTurnContext { SupervisorRunId = runId, TeamId = TeamId, NodeId = "sup", Goal = "ship it", SupervisorModelId = BrainModelId, MaxCostUsd = 7.5m, PendingChildDecisions = new[] { Pending() } };
@@ -230,7 +230,7 @@ public async Task The_drain_runs_before_the_delivery_decider_and_falls_through_t
private static SupervisorTurnService Drain(FakeDecisionArbiter arbiter, FakeDecisionAnswerService answer) =>
new(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), arbiter, answer, new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(),
- new NoLessonsReaderStub(), NullLogger.Instance);
+ new NoLessonsReaderStub(), null!, NullLogger.Instance);
private static SupervisorTurnContext Context(params PendingDecision[] pending) => new()
{
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsServiceTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsServiceTests.cs
index a180c672c..c0e17254e 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsServiceTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsServiceTests.cs
@@ -132,7 +132,7 @@ public async Task A_spawns_policy_parks_the_spawn_for_a_human_instead_of_creatin
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Plan, """{"subtasks":[{"id":"a","title":"A","instruction":"do"}]}""", "{}");
var executor = new CountingExecutor();
- var service = new SupervisorTurnService(ledger, new AlwaysSpawnDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysSpawnDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "g", null, Config(approvalPolicy: "spawns"), CancellationToken.None);
@@ -151,7 +151,7 @@ public async Task A_none_policy_spawns_without_a_gate()
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Plan, """{"subtasks":[{"id":"a","title":"A","instruction":"do"}]}""", "{}");
var executor = new CountingExecutor();
- var service = new SupervisorTurnService(ledger, new AlwaysSpawnDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysSpawnDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReaderStub(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "g", null, Config(approvalPolicy: "none"), CancellationToken.None);
@@ -163,7 +163,7 @@ public async Task A_none_policy_spawns_without_a_gate()
private SupervisorTurnService Service(FakeSupervisorDecisionLog ledger, ISupervisorDecider decider) =>
new(ledger, decider, new CountingExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(),
- new NoLessonsReaderStub(), NullLogger.Instance);
+ new NoLessonsReaderStub(), null!, NullLogger.Instance);
private static SupervisorGoalConfig Config(int? maxTotalSpawns = null, int? maxNoProgress = null, string? approvalPolicy = null) =>
new() { MaxTotalSpawns = maxTotalSpawns, MaxNoProgressDecisions = maxNoProgress, ApprovalPolicy = approvalPolicy };
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBranchlessStopGradeTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBranchlessStopGradeTests.cs
index ce62d77aa..daddc68c1 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBranchlessStopGradeTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBranchlessStopGradeTests.cs
@@ -232,7 +232,7 @@ private static async Task GradeAsync(CapturingGrader grader, string stop
// scope carries. Every other seam is untouched by ApplyStopAcceptanceGradeAsync.
var service = new SupervisorTurnService(null!, null!, null!, db: Infrastructure.EmptyTestDb.New(), grader, null!, null!, null!, null!,
null!, null!, new NoManifests(), new FakeSupervisorPublishedBranchResolver(), null!, new AdmitAllBudgetLedger(),
- null!, NullLogger.Instance, rubricJudge);
+ null!, null!, NullLogger.Instance, rubricJudge);
var context = new SupervisorTurnContext
{
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorDependencyStagingTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorDependencyStagingTests.cs
index 518f78b7d..456afc9ec 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorDependencyStagingTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorDependencyStagingTests.cs
@@ -446,8 +446,8 @@ public void A_unit_resumed_from_a_host_loss_checkpoint_carries_its_provenance_an
{
// A checkpoint is not a captured transcript. The attempt that wrote it never finished: its machine is gone,
// so the conversation may describe turns the checkpoint never saw and edits the new sandbox does not
- // contain — which is true even when a branch WAS pushed, because the unpublished remainder died with the
- // host. The ordinary honest-redo line only covers "no branch to continue from", a smaller claim. The whole
+ // contain — which is true even when a branch WAS pushed, because the unpublished remainder was lost with that
+ // attempt. The ordinary honest-redo line only covers "no branch to continue from", a smaller claim. The whole
// goal is asserted, so each arm pins exactly WHICH tree sentence follows the preamble.
// MUTATION: drop the CheckpointAt branch from ApplyResumeRecord → the task carries no provenance, is not
// marked a checkpoint (so an unreadable ref would FAIL the attempt instead of degrading), and the goal says
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorTurnServiceTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorTurnServiceTests.cs
index d24bb4bc6..61e0ad26c 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorTurnServiceTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorTurnServiceTests.cs
@@ -133,7 +133,7 @@ public async Task The_no_progress_guard_forces_a_clean_terminal_stop()
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Plan, $$"""{"turn":{{i}}}""", "{}");
// A decider that would NEVER stop on its own — proving the bound, not the decider, terminates.
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: null, goalConfig: null, CancellationToken.None);
@@ -149,7 +149,7 @@ public async Task A_budget_ledger_refusal_forces_the_cost_cap_stop_not_an_infra_
// land the SAME cost-cap terminal the realized-spend bound reaches, one call earlier; never a park-and-
// retry loop (the ledger will refuse forever) and never an exception-shaped run failure.
var ledger = new FakeSupervisorDecisionLog();
- var service = new SupervisorTurnService(ledger, new BudgetRefusedDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new BudgetRefusedDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: null, goalConfig: null, CancellationToken.None);
@@ -189,7 +189,7 @@ public async Task A_merge_executor_runs_under_its_own_recorded_and_budgeted_synt
{
var ledger = new FakeSupervisorDecisionLog();
var executor = new ScopeObservingExecutor();
- var service = new SupervisorTurnService(ledger, new MergeDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new MergeDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: null, goalConfig: null, CancellationToken.None);
@@ -216,7 +216,7 @@ public async Task A_no_progress_forced_stop_under_a_required_delivery_contract_i
for (var i = 0; i < SupervisorLane.DefaultMaxNoProgressDecisions; i++)
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Plan, $$"""{"turn":{{i}}}""", "{}");
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: Guid.NewGuid(), goalConfig: DeliveryGoalConfig(), CancellationToken.None);
@@ -234,7 +234,7 @@ public async Task A_forced_stop_over_an_unsatisfied_publish_parks_on_the_deliver
// The latest publish ran and found NOTHING to open a PR from — unsatisfied, and never satisfied by absence (H1).
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Publish, "{}", """{"pullRequests":[]}""");
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: Guid.NewGuid(), goalConfig: DeliveryGoalConfig(), CancellationToken.None);
@@ -253,7 +253,7 @@ public async Task A_forced_stop_over_an_unsatisfied_publish_with_no_conversation
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Publish, "{}", """{"pullRequests":[]}""");
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var result = await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: null, goalConfig: DeliveryGoalConfig(), CancellationToken.None);
@@ -277,7 +277,7 @@ public async Task An_unanswered_gate_card_on_the_tape_fuses_the_forced_stop_back
ledger.SeedTerminal(_runId, _teamId, SupervisorDecisionKinds.Publish, "{}", """{"pullRequests":[]}""");
var goalConfig = DeliveryGoalConfig();
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var context = await service.RehydrateFromDecisionLogAsync(_runId, _teamId, "sup", "goal", goalConfig, CancellationToken.None);
// The gate's own card, already degraded once: terminal, question pinned to the gate prefix, NO answer.
@@ -296,7 +296,7 @@ public async Task A_depth_capped_run_is_never_gated_into_delivering()
// parent. Gating it would let a run that should never have taken a single decision open PRs, and (with no
// conversation) erase DepthCapExceeded behind DeliveryAdjudicationUnavailable in the scorecard.
var ledger = new FakeSupervisorDecisionLog();
- var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new AlwaysPlanDecider(), new StubSupervisorActionExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var goalConfig = DeliveryGoalConfig();
var context = await service.RehydrateFromDecisionLogAsync(_runId, _teamId, "sup", "goal", goalConfig, CancellationToken.None);
@@ -395,7 +395,7 @@ public void A_governance_denied_side_effecting_decision_force_stops_and_stages_n
// — the same forward-compat exposure a future irreversible/merge-PR policy would open. Asserts the gate
// turns the denied side effect into a force-STOP carrying the GovernanceDenied reason and stages NO agent.
var executor = new CountingExecutor();
- var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var context = new SupervisorTurnContext { Goal = "goal", TurnNumber = 0, ApprovalPolicy = (SupervisorApprovalPolicy)999 };
var spawn = new SupervisorDecision { Kind = kind, PayloadJson = """{"subtaskIds":["a","b"]}""" };
@@ -417,7 +417,7 @@ public void A_resolve_parks_for_approval_under_the_autonomous_policy_but_a_spawn
// The safety floor wired end-to-end through the REAL gate: under None (autonomous) a spawn runs unchanged,
// but a resolve — which dispatches an agent to autonomously RE-MERGE code — escalates to a human approval
// card (it parks), because GateSideEffectingDecision passes irreversible=IsIrreversible(kind) for resolve.
- var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), new CountingExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(new FakeSupervisorDecisionLog(), new StubSupervisorDecider(), new CountingExecutor(), db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
var context = new SupervisorTurnContext { Goal = "goal", SupervisorRunId = _runId, TeamId = _teamId, NodeId = "sup", TurnNumber = 1, ApprovalPolicy = SupervisorApprovalPolicy.None, ConversationId = Guid.NewGuid() };
@@ -774,7 +774,7 @@ public async Task Replaying_a_settled_turn_does_not_double_execute()
{
var ledger = new FakeSupervisorDecisionLog();
var executor = new CountingExecutor();
- var service = new SupervisorTurnService(ledger, new StubSupervisorDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger.Instance);
+ var service = new SupervisorTurnService(ledger, new StubSupervisorDecider(), executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), null!, NullLogger.Instance);
// First pass: turn 0 (plan) executes once + records terminal.
await service.RunTurnAsync(_runId, _teamId, "sup", "goal", conversationId: null, goalConfig: null, CancellationToken.None);
@@ -813,7 +813,7 @@ public async Task Replaying_an_in_flight_turn_re_executes_the_frozen_decision_ev
var decider = new NonDeterministicDecider(SupervisorDecisionKinds.Plan, plannedA, SupervisorDecisionKinds.Stop, """{"reason":"divergent-B"}""");
var executor = new CountingExecutor();
- var service = new SupervisorTurnService(ledger, decider, executor, db: Infrastructure.EmptyTestDb.New(), new FakeAcceptanceGrader(), new FakeDecisionQueue(), new FakeDecisionArbiter(), new FakeDecisionAnswerService(), new FakeWorkPlanStore(), null!, null!, new FakePublishManifestStore(), new FakeSupervisorPublishedBranchResolver(), new NullCompletionComposer(), new AdmitAllBudgetLedger(), new NoLessonsReader(), NullLogger