From 0310c2ba66e967fdc779e8353f9d3791c4a26afa Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 6 Apr 2026 16:32:54 -0500 Subject: [PATCH 1/5] fix: reflect resume drops orchestrator @worker dispatch after restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app restarts mid-reflect-loop, ResumeOrchestrationIfPendingAsync collects worker results and sends a synthesis prompt to the orchestrator. For OrchestratorReflect groups, the orchestrator may respond with new @worker blocks (it wants to keep iterating). The previous code used SendPromptAsync (fire-and-forget), so that response was never read — the @worker assignments were silently dropped and the loop stalled forever. Fix: for reflect groups, use SendPromptAndWaitAsync to get the orchestrator's response, parse it for @worker assignments, and if found execute one more bounded dispatch+collect+synthesize round. The OrchestratorCollectionTimeout and ForceCompleteProcessingAsync guards match the normal reflect loop path. Observed: orchestrator emitted @worker:Copilot Cli-worker-1 at 21:19:56 after resume synthesis; worker never received the message; loop stalled until screen lock killed the connection at 21:23. Co-Authored-By: Claude Sonnet 4.6 --- PolyPilot.Tests/MultiAgentRegressionTests.cs | 28 ++++++++ .../Services/CopilotService.Organization.cs | 64 ++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/PolyPilot.Tests/MultiAgentRegressionTests.cs b/PolyPilot.Tests/MultiAgentRegressionTests.cs index 3cf6b9d96..c073f45bd 100644 --- a/PolyPilot.Tests/MultiAgentRegressionTests.cs +++ b/PolyPilot.Tests/MultiAgentRegressionTests.cs @@ -1581,6 +1581,34 @@ public void MonitorAndSynthesize_ShouldRedispatchUnstartedWorkers() Assert.Contains("ExecuteWorkerAsync", block); } + [Fact] + public void MonitorAndSynthesize_ReflectResume_WaitsForOrchestratorResponse() + { + // When a reflect-mode group resumes after restart and the orchestrator responds with + // new @worker blocks (wants to keep iterating), MonitorAndSynthesizeAsync must wait for + // the response and dispatch those workers — not fire-and-forget the synthesis. + // + // Observed failure: orchestrator emitted @worker:Copilot Cli-worker-1 after resume + // synthesis, but SendPromptAsync (fire-and-forget) meant the response was never + // processed and the worker was never dispatched. Loop stalled indefinitely. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + + var startIdx = source.IndexOf("private async Task MonitorAndSynthesizeAsync"); + Assert.True(startIdx >= 0, "MonitorAndSynthesizeAsync method not found in source"); + var block = source.Substring(startIdx, Math.Min(source.Length - startIdx, 8000)); + + // For reflect groups: must use SendPromptAndWaitAsync (not just SendPromptAsync) + // to get the orchestrator's response and detect new @worker assignments. + Assert.Contains("pending.IsReflect", block); + Assert.Contains("SendPromptAndWaitAsync", block); + // Must parse the orchestrator's response for @worker assignments + Assert.Contains("ParseTaskAssignments", block); + // Must dispatch workers if assignments found + Assert.Contains("resumeAssignments.Count > 0", block); + // Must apply the same collection timeout as the normal reflect loop + Assert.Contains("OrchestratorCollectionTimeout", block); + } + [Fact] public async Task RetryOrchestration_MissingGroup_DoesNothing() { diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index ca068c557..75b5263f5 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -3230,8 +3230,68 @@ private async Task MonitorAndSynthesizeAsync(PendingOrchestration pending, Cance // response still streaming when workers complete and we try to send synthesis). await WaitForSessionIdleAsync(pending.OrchestratorName, ct); - await SendPromptAsync(pending.OrchestratorName, synthesisPrompt, cancellationToken: ct, originalPrompt: pending.OriginalPrompt); - Debug($"[DISPATCH] Resume synthesis sent to '{pending.OrchestratorName}'"); + if (pending.IsReflect) + { + // For reflect-mode groups the orchestrator's response to the synthesis may contain + // new @worker blocks (it wants to keep iterating rather than emit + // [[GROUP_REFLECT_COMPLETE]]). If we just fire-and-forget with SendPromptAsync the + // @worker dispatch is silently dropped and the loop stalls indefinitely. + // Wait for the response and, if new assignments are found, execute one more + // collect+synthesize round so the loop can complete or continue naturally. + var availableWorkers = GetMultiAgentGroupMembers(pending.GroupId); + var orchestratorResponse = await SendPromptAndWaitAsync( + pending.OrchestratorName, synthesisPrompt, ct, originalPrompt: pending.OriginalPrompt); + Debug($"[DISPATCH] Resume reflect: orchestrator response received from '{pending.OrchestratorName}'"); + + var resumeAssignments = ParseTaskAssignments(orchestratorResponse, availableWorkers); + if (resumeAssignments.Count > 0 && !ct.IsCancellationRequested) + { + Debug($"[DISPATCH] Resume reflect: orchestrator dispatched {resumeAssignments.Count} worker(s) — executing continuation"); + AddOrchestratorSystemMessage(pending.OrchestratorName, + $"🔄 Resume: executing {resumeAssignments.Count} worker(s) dispatched by orchestrator..."); + InvokeOnUI(() => OnOrchestratorPhaseChanged?.Invoke(pending.GroupId, OrchestratorPhase.Dispatching, "Resume iteration")); + + var workerTasks = resumeAssignments + .Select(a => ExecuteWorkerAsync(a.WorkerName, a.Task, pending.OriginalPrompt, ct)) + .ToList(); + + var allDone = Task.WhenAll(workerTasks); + var timeoutTask = Task.Delay(OrchestratorCollectionTimeout, CancellationToken.None); + if (await Task.WhenAny(allDone, timeoutTask) != allDone) + { + Debug($"[DISPATCH] Resume reflect: collection timeout — force-completing stuck workers"); + foreach (var a in resumeAssignments) + { + if (_sessions.TryGetValue(a.WorkerName, out var ws)) + { + if (ws.Info.IsProcessing) + await ForceCompleteProcessingAsync(a.WorkerName, ws, $"resume reflect collection timeout"); + else + ws.ResponseCompletion?.TrySetResult("(worker timed out)"); + } + } + } + + var resumeResults = new List(); + for (var i = 0; i < workerTasks.Count; i++) + { + var workerName = i < resumeAssignments.Count ? resumeAssignments[i].WorkerName : "unknown"; + try { resumeResults.Add(await workerTasks[i]); } + catch (Exception ex) { resumeResults.Add(new WorkerResult(workerName, null, false, $"Error: {ex.Message}", TimeSpan.Zero)); } + } + + InvokeOnUI(() => OnOrchestratorPhaseChanged?.Invoke(pending.GroupId, OrchestratorPhase.Synthesizing, "Resume final")); + var finalSynthesis = BuildSynthesisPrompt(pending.OriginalPrompt, resumeResults); + await WaitForSessionIdleAsync(pending.OrchestratorName, ct); + await SendPromptAsync(pending.OrchestratorName, finalSynthesis, cancellationToken: ct, originalPrompt: pending.OriginalPrompt); + Debug($"[DISPATCH] Resume reflect: final synthesis sent to '{pending.OrchestratorName}'"); + } + } + else + { + await SendPromptAsync(pending.OrchestratorName, synthesisPrompt, cancellationToken: ct, originalPrompt: pending.OriginalPrompt); + Debug($"[DISPATCH] Resume synthesis sent to '{pending.OrchestratorName}'"); + } } catch (Exception ex) { From 440e4bc86e578246164f8444bf7c33a6bd9d786f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 6 Apr 2026 17:23:27 -0500 Subject: [PATCH 2/5] fix: filter orchestrator from availableWorkers in reflect resume dispatch Both existing dispatch paths (SendViaOrchestratorAsync, SendViaOrchestratorReflectAsync) filter the orchestrator from the worker list. The new reflect-resume path in MonitorAndSynthesizeAsync was missing this filter, allowing self-dispatch if the orchestrator's response includes its own name in an @worker block. Co-Authored-By: Claude Sonnet 4.6 --- PolyPilot/Services/CopilotService.Organization.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 75b5263f5..591bdf54b 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -3238,7 +3238,8 @@ private async Task MonitorAndSynthesizeAsync(PendingOrchestration pending, Cance // @worker dispatch is silently dropped and the loop stalls indefinitely. // Wait for the response and, if new assignments are found, execute one more // collect+synthesize round so the loop can complete or continue naturally. - var availableWorkers = GetMultiAgentGroupMembers(pending.GroupId); + var availableWorkers = GetMultiAgentGroupMembers(pending.GroupId) + .Where(m => m != pending.OrchestratorName).ToList(); var orchestratorResponse = await SendPromptAndWaitAsync( pending.OrchestratorName, synthesisPrompt, ct, originalPrompt: pending.OriginalPrompt); Debug($"[DISPATCH] Resume reflect: orchestrator response received from '{pending.OrchestratorName}'"); From bb52e3c1431dc7960e9aa6d1f82e5f2f0e2f6872 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 6 Apr 2026 17:24:49 -0500 Subject: [PATCH 3/5] fix: session.idle with stale background-task payload no longer defers completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Race condition in IDLE-DEFER: backgroundTasksChanged fires with shells=0 (PolyPilot confirms all shells done) then a session.idle arrives with shells=2 (stale CLI snapshot generated before completions landed). PolyPilot was starting a fresh 60-min zombie clock on the stale payload, keeping IsProcessing=true indefinitely. Fix: capture pre-idle fingerprint/ticks before calling RefreshDeferredBackgroundTaskTracking. If they were null/0 (backgroundTasksChanged already confirmed empty), treat the idle payload as stale and skip the defer. PolyPilot's real-time tracking is ground truth. Observed in the PROMPT session: agents all completed (agents=0), backgroundTasksChanged showed shells=0, but session.idle fired with shells=2 — session stuck in IsProcessing. Co-Authored-By: Claude Sonnet 4.6 --- PolyPilot.Tests/BackgroundTasksIdleTests.cs | 32 +++++++++++++++++++++ PolyPilot/Services/CopilotService.Events.cs | 19 +++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/PolyPilot.Tests/BackgroundTasksIdleTests.cs b/PolyPilot.Tests/BackgroundTasksIdleTests.cs index a475a2e8e..de2d2e516 100644 --- a/PolyPilot.Tests/BackgroundTasksIdleTests.cs +++ b/PolyPilot.Tests/BackgroundTasksIdleTests.cs @@ -287,6 +287,38 @@ public void ProactiveIdleDefer_CompareExchange_SetsWhenZero() Assert.Equal(now, field); } + [Fact] + public void SessionIdle_StalePayload_NotDeferredWhenBgTasksAlreadyConfirmedEmpty() + { + // Regression: session.idle arrives with shells=2 but backgroundTasksChanged already + // confirmed shells=0 (race — CLI snapshotted before completions landed). PolyPilot + // must NOT defer in this case. The fix captures pre-idle fingerprint/ticks; if they + // were null/0, the payload is stale. + // + // Verified by checking that SessionIdleEvent handler reads preIdleFingerprint and + // preIdleTicks BEFORE calling RefreshDeferredBackgroundTaskTracking, and only defers + // when !idlePayloadIsStale. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), + "PolyPilot", "Services", "CopilotService.Events.cs")); + + var idleHandlerStart = source.IndexOf("case SessionIdleEvent idle:"); + Assert.True(idleHandlerStart >= 0, "SessionIdleEvent handler not found"); + var idleHandlerEnd = source.IndexOf("case SessionBackgroundTasks", idleHandlerStart + 1); + if (idleHandlerEnd < 0) idleHandlerEnd = source.Length; + var handler = source.Substring(idleHandlerStart, idleHandlerEnd - idleHandlerStart); + + // The handler must capture state before RefreshDeferredBackgroundTaskTracking + Assert.Contains("preIdleFingerprint", handler); + Assert.Contains("preIdleTicks", handler); + // Staleness detection guard must reference both captured values and the new snapshot + Assert.Contains("idlePayloadIsStale", handler); + Assert.Contains("preIdleFingerprint == null", handler); + Assert.Contains("preIdleTicks == 0", handler); + Assert.Contains("tracking.Snapshot.HasAny", handler); + // hasActiveTasks must be guarded by !idlePayloadIsStale + Assert.Contains("!idlePayloadIsStale", handler); + } + private static string GetRepoRoot() { var dir = AppContext.BaseDirectory; diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 0115be0c5..332f8ff18 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -851,9 +851,26 @@ void Invoke(Action action) // KEY FIX: age background tasks by stable fingerprint (agent/shell IDs), not just // by "current turn." Without this, the same orphaned shell IDs get their timer // reset on every new prompt and sessions like PROMPT can appear busy forever. + + // Capture PolyPilot's own background-task state BEFORE the idle payload can + // overwrite it. If backgroundTasksChanged already confirmed shells=0 + // (fingerprint=null, ticks=0) but the session.idle payload still reports + // shells>0, the payload is stale: shell completions arrived and were processed + // before this idle event, but the CLI snapshotted its state slightly earlier + // (race). PolyPilot's own tracking is the ground truth — don't defer. + var preIdleFingerprint = state.DeferredBackgroundTaskFingerprint; + var preIdleTicks = Interlocked.Read(ref state.DeferredBackgroundTasksFirstSeenAtTicks); + var tracking = RefreshDeferredBackgroundTaskTracking(state, idle.Data?.BackgroundTasks); var deferTicks = tracking.FirstSeenTicks; - var hasActiveTasks = HasActiveBackgroundTasks(idle, deferTicks); + + bool idlePayloadIsStale = preIdleFingerprint == null && preIdleTicks == 0 && tracking.Snapshot.HasAny; + if (idlePayloadIsStale) + Debug($"[IDLE-DIAG-STALE] '{sessionName}' session.idle backgroundTasks " + + $"({tracking.Snapshot.AgentCount} agents, {tracking.Snapshot.ShellCount} shells) are stale — " + + $"backgroundTasksChanged already confirmed empty, completing normally"); + + var hasActiveTasks = !idlePayloadIsStale && HasActiveBackgroundTasks(idle, deferTicks); // Log zombie expiry here where Debug() is available (HasActiveBackgroundTasks is static) var zombieAgentCount = tracking.Snapshot.AgentCount; From 081399262728f34d75713a8934b12f2ac000341e Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 6 Apr 2026 18:21:23 -0500 Subject: [PATCH 4/5] fix: use string.Empty sentinel to distinguish confirmed-empty from never-seen in IDLE-DEFER null = never seen a backgroundTasksChanged event this turn (initial/reset state) string.Empty = backgroundTasksChanged explicitly confirmed zero tasks non-empty = backgroundTasksChanged reported active tasks with this fingerprint The previous null check caused a false positive: when session.idle arrived with genuine new tasks before backgroundTasksChanged had fired at all, preIdleFingerprint==null && ticks==0 matched the same condition as confirmed- empty, incorrectly treating the idle payload as stale and skipping the defer. --- PolyPilot.Tests/BackgroundTasksIdleTests.cs | 37 +++++++++++++++++---- PolyPilot/Services/CopilotService.Events.cs | 20 +++++++++-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/PolyPilot.Tests/BackgroundTasksIdleTests.cs b/PolyPilot.Tests/BackgroundTasksIdleTests.cs index de2d2e516..6e2c90330 100644 --- a/PolyPilot.Tests/BackgroundTasksIdleTests.cs +++ b/PolyPilot.Tests/BackgroundTasksIdleTests.cs @@ -292,12 +292,13 @@ public void SessionIdle_StalePayload_NotDeferredWhenBgTasksAlreadyConfirmedEmpty { // Regression: session.idle arrives with shells=2 but backgroundTasksChanged already // confirmed shells=0 (race — CLI snapshotted before completions landed). PolyPilot - // must NOT defer in this case. The fix captures pre-idle fingerprint/ticks; if they - // were null/0, the payload is stale. + // must NOT defer in this case. // - // Verified by checking that SessionIdleEvent handler reads preIdleFingerprint and - // preIdleTicks BEFORE calling RefreshDeferredBackgroundTaskTracking, and only defers - // when !idlePayloadIsStale. + // The fix uses a sentinel: DeferredBackgroundTaskFingerprint == string.Empty means + // "backgroundTasksChanged explicitly confirmed zero tasks this turn." null means + // "no backgroundTasksChanged event has fired yet" (initial/reset state). Only the + // empty-string sentinel triggers stale detection, preventing false positives when + // session.idle arrives with genuine new tasks before backgroundTasksChanged fires. var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); @@ -310,15 +311,37 @@ public void SessionIdle_StalePayload_NotDeferredWhenBgTasksAlreadyConfirmedEmpty // The handler must capture state before RefreshDeferredBackgroundTaskTracking Assert.Contains("preIdleFingerprint", handler); Assert.Contains("preIdleTicks", handler); - // Staleness detection guard must reference both captured values and the new snapshot + // Staleness check uses string.Empty sentinel (not null) to distinguish confirmed-empty + // from never-seen — guards against false positives on first idle with genuine tasks Assert.Contains("idlePayloadIsStale", handler); - Assert.Contains("preIdleFingerprint == null", handler); + Assert.Contains("preIdleFingerprint == string.Empty", handler); Assert.Contains("preIdleTicks == 0", handler); Assert.Contains("tracking.Snapshot.HasAny", handler); // hasActiveTasks must be guarded by !idlePayloadIsStale Assert.Contains("!idlePayloadIsStale", handler); } + [Fact] + public void RefreshDeferredBackgroundTaskTracking_SetsEmptyStringSentinel_WhenTasksConfirmedGone() + { + // When backgroundTasksChanged fires with no tasks, RefreshDeferredBackgroundTaskTracking + // must set DeferredBackgroundTaskFingerprint = string.Empty (not null). This sentinel + // is what distinguishes "confirmed empty" from "never seen" (null). + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), + "PolyPilot", "Services", "CopilotService.Events.cs")); + + var methodStart = source.IndexOf("private static (BackgroundTaskSnapshot Snapshot, long FirstSeenTicks) RefreshDeferredBackgroundTaskTracking("); + Assert.True(methodStart >= 0, "RefreshDeferredBackgroundTaskTracking not found"); + var methodEnd = source.IndexOf("\n private ", methodStart + 1); + if (methodEnd < 0) methodEnd = source.Length; + var method = source.Substring(methodStart, methodEnd - methodStart); + + // Must set string.Empty sentinel (not null) when clearing after confirmed-empty event + Assert.Contains("string.Empty", method); + // Must NOT set null when clearing in this path (null is reserved for initial/reset state) + Assert.DoesNotContain("= null", method); + } + private static string GetRepoRoot() { var dir = AppContext.BaseDirectory; diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 332f8ff18..866cff137 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -200,7 +200,12 @@ private static (BackgroundTaskSnapshot Snapshot, long FirstSeenTicks) RefreshDef if (!snapshot.HasAny) { - state.DeferredBackgroundTaskFingerprint = null; + // Use string.Empty (not null) to distinguish "confirmed empty by backgroundTasksChanged" + // from "never seen a backgroundTasksChanged event" (null = initial/reset state). + // This sentinel lets the stale-idle detection in SessionIdleEvent tell apart: + // - null → no backgroundTasksChanged has fired this turn → idle payload may be genuine + // - "" → backgroundTasksChanged confirmed zero tasks → idle payload is stale + state.DeferredBackgroundTaskFingerprint = string.Empty; Interlocked.Exchange(ref state.DeferredBackgroundTasksFirstSeenAtTicks, 0L); Interlocked.Exchange(ref state.SubagentDeferStartedAtTicks, 0L); return (snapshot, 0L); @@ -854,17 +859,26 @@ void Invoke(Action action) // Capture PolyPilot's own background-task state BEFORE the idle payload can // overwrite it. If backgroundTasksChanged already confirmed shells=0 - // (fingerprint=null, ticks=0) but the session.idle payload still reports + // (fingerprint="" sentinel, ticks=0) but the session.idle payload still reports // shells>0, the payload is stale: shell completions arrived and were processed // before this idle event, but the CLI snapshotted its state slightly earlier // (race). PolyPilot's own tracking is the ground truth — don't defer. + // + // Sentinel values for DeferredBackgroundTaskFingerprint: + // null → no backgroundTasksChanged has fired this turn (initial/reset state) + // string.Empty → backgroundTasksChanged explicitly confirmed zero tasks + // non-empty → backgroundTasksChanged reported active tasks with this fingerprint + // + // Only treat the idle as stale when fingerprint == "" (confirmed empty), NOT when + // fingerprint == null (never seen). The null case means tasks may genuinely be + // starting up and backgroundTasksChanged simply hasn't fired yet. var preIdleFingerprint = state.DeferredBackgroundTaskFingerprint; var preIdleTicks = Interlocked.Read(ref state.DeferredBackgroundTasksFirstSeenAtTicks); var tracking = RefreshDeferredBackgroundTaskTracking(state, idle.Data?.BackgroundTasks); var deferTicks = tracking.FirstSeenTicks; - bool idlePayloadIsStale = preIdleFingerprint == null && preIdleTicks == 0 && tracking.Snapshot.HasAny; + bool idlePayloadIsStale = preIdleFingerprint == string.Empty && preIdleTicks == 0 && tracking.Snapshot.HasAny; if (idlePayloadIsStale) Debug($"[IDLE-DIAG-STALE] '{sessionName}' session.idle backgroundTasks " + $"({tracking.Snapshot.AgentCount} agents, {tracking.Snapshot.ShellCount} shells) are stale — " + From 863111375803d2d9684a2d1590abd0ba5c932bea Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 6 Apr 2026 19:11:16 -0500 Subject: [PATCH 5/5] fix: harden test assertions for refactored handler and longer method - BackgroundTasksIdleTests: update handler search to match variable binding pattern; replace CompareExchange assertion with RefreshDeferredBackgroundTaskTracking (logic moved there); anchor DoesNotContain to DeferredBackgroundTaskFingerprint field name - MultiAgentRegressionTests: increase block size to 14000 chars to reach the reflect-resume code at the end of the method Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/BackgroundTasksIdleTests.cs | 10 +++++----- PolyPilot.Tests/MultiAgentRegressionTests.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/PolyPilot.Tests/BackgroundTasksIdleTests.cs b/PolyPilot.Tests/BackgroundTasksIdleTests.cs index 6e2c90330..7ca363879 100644 --- a/PolyPilot.Tests/BackgroundTasksIdleTests.cs +++ b/PolyPilot.Tests/BackgroundTasksIdleTests.cs @@ -228,7 +228,7 @@ public void ProactiveIdleDefer_SubagentDeferStartedAtTicks_StampedOnBackgroundTa var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); - var handlerStart = source.IndexOf("case SessionBackgroundTasksChangedEvent:"); + var handlerStart = source.IndexOf("case SessionBackgroundTasksChangedEvent"); Assert.True(handlerStart >= 0, "SessionBackgroundTasksChangedEvent handler not found"); // Find the next case or closing brace to bound the handler @@ -236,9 +236,9 @@ public void ProactiveIdleDefer_SubagentDeferStartedAtTicks_StampedOnBackgroundTa if (handlerEnd < 0) handlerEnd = source.Length; var handler = source.Substring(handlerStart, handlerEnd - handlerStart); - // Must stamp SubagentDeferStartedAtTicks via CompareExchange + // Must stamp SubagentDeferStartedAtTicks via RefreshDeferredBackgroundTaskTracking + Assert.Contains("RefreshDeferredBackgroundTaskTracking", handler); Assert.Contains("SubagentDeferStartedAtTicks", handler); - Assert.Contains("CompareExchange", handler); } [Fact] @@ -249,7 +249,7 @@ public void ProactiveIdleDefer_Handler_DoesNotSetIsProcessingFalse() var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); - var handlerStart = source.IndexOf("case SessionBackgroundTasksChangedEvent:"); + var handlerStart = source.IndexOf("case SessionBackgroundTasksChangedEvent"); Assert.True(handlerStart >= 0, "SessionBackgroundTasksChangedEvent handler not found"); var handlerEnd = source.IndexOf("case System", handlerStart + 1); @@ -339,7 +339,7 @@ public void RefreshDeferredBackgroundTaskTracking_SetsEmptyStringSentinel_WhenTa // Must set string.Empty sentinel (not null) when clearing after confirmed-empty event Assert.Contains("string.Empty", method); // Must NOT set null when clearing in this path (null is reserved for initial/reset state) - Assert.DoesNotContain("= null", method); + Assert.DoesNotContain("DeferredBackgroundTaskFingerprint = null", method); } private static string GetRepoRoot() diff --git a/PolyPilot.Tests/MultiAgentRegressionTests.cs b/PolyPilot.Tests/MultiAgentRegressionTests.cs index c073f45bd..30460286f 100644 --- a/PolyPilot.Tests/MultiAgentRegressionTests.cs +++ b/PolyPilot.Tests/MultiAgentRegressionTests.cs @@ -1595,7 +1595,7 @@ public void MonitorAndSynthesize_ReflectResume_WaitsForOrchestratorResponse() var startIdx = source.IndexOf("private async Task MonitorAndSynthesizeAsync"); Assert.True(startIdx >= 0, "MonitorAndSynthesizeAsync method not found in source"); - var block = source.Substring(startIdx, Math.Min(source.Length - startIdx, 8000)); + var block = source.Substring(startIdx, Math.Min(source.Length - startIdx, 14000)); // For reflect groups: must use SendPromptAndWaitAsync (not just SendPromptAsync) // to get the orchestrator's response and detect new @worker assignments.