From 55b681f16e757095108c26ee59e890e9504740d3 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 13 Jul 2026 23:17:26 +0900 Subject: [PATCH 1/3] fix: hold Auto Refresh when Editor starts unfocused (external scene dialog/crash) (#1757) Co-authored-by: Cursor --- .../ExternalSceneChangeResolverTests.cs | 172 +++++++++++++++++- .../ExternalAssetFocusReturnService.cs | 142 ++++++++++++++- .../Compile/ExternalSceneChangeTracker.cs | 112 +++++++++++- 3 files changed, 418 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index 49045dd962..a480fb7d65 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -192,6 +192,53 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload() Assert.That(reloadWasCalled, Is.False); } + [Test] + public void FocusReturnService_WhenHoldSucceeds_EmitsHoldArmedVibeLog() + { + // Verifies successful Disallow arms held and emits the observability vibe event once. + bool autoRefreshHeld = false; + List vibeOperations = new List(); + ExternalAssetFocusReturnService service = new ExternalAssetFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => { }, + () => { }, + () => { }, + logWarning: null, + logVibeInfo: (operation, message, context) => vibeOperations.Add(operation), + logVibeWarning: null); + + service.HoldAutoRefreshIfNeeded(); + service.HoldAutoRefreshIfNeeded(); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(vibeOperations, Is.EqualTo(new[] { "external_scene_hold_armed" })); + } + + [Test] + public void FocusReturnService_WhenDisallowThrows_EmitsHoldFailedVibeLog() + { + // Verifies Disallow failures leave SessionState unheld and emit hold_failed vibe warning. + bool autoRefreshHeld = false; + List vibeOperations = new List(); + ExternalAssetFocusReturnService service = new ExternalAssetFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => throw new InvalidOperationException("kCodeReload"), + () => { }, + () => { }, + logWarning: _ => { }, + logVibeInfo: null, + logVibeWarning: (operation, message, context) => vibeOperations.Add(operation)); + + service.HoldAutoRefreshIfNeeded(); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(vibeOperations, Is.EqualTo(new[] { "external_scene_hold_failed" })); + } + [Test] public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce() { @@ -215,6 +262,125 @@ public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce() Assert.That(allowCallCount, Is.EqualTo(0)); } + [Test] + public void FocusReturnService_WhenHoldIfCurrentlyUnfocusedTwice_HoldsAutoRefreshOnce() + { + // Verifies Initialize-style unfocused Hold is idempotent (disallow once). + bool autoRefreshHeld = false; + int disallowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => disallowCallCount++, + () => { }, + () => { }); + + service.HoldIfCurrentlyUnfocused(); + service.HoldIfCurrentlyUnfocused(); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(disallowCallCount, Is.EqualTo(1)); + } + + [Test] + public void FocusReturnService_WhenDisallowThrows_DoesNotSetHeldFlag() + { + // Verifies kCodeReload Disallow failures leave SessionState unheld for later reconcile. + bool autoRefreshHeld = false; + List warnings = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => throw new InvalidOperationException("kCodeReload"), + () => { }, + () => { }, + warning => warnings.Add(warning)); + + service.HoldAutoRefreshIfNeeded(); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(warnings.Count, Is.EqualTo(1)); + Assert.That(warnings[0], Does.Contain("DisallowAutoRefresh")); + Assert.That(warnings[0], Does.Contain("InvalidOperationException")); + } + + [Test] + public void FocusReturnService_WhenDisallowStopsFailing_ReconcileHoldsAutoRefresh() + { + // Verifies update reconcile arms Hold after transient Disallow failures without delayCall chains. + bool autoRefreshHeld = false; + bool disallowShouldThrow = true; + int disallowCallCount = 0; + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => false, + () => + { + disallowCallCount++; + if (disallowShouldThrow) + { + throw new InvalidOperationException("kCodeReload"); + } + }, + () => { }, + () => { }, + _ => { }); + + service.ReconcileAutoRefreshHoldWithFocus(); + Assert.That(autoRefreshHeld, Is.False); + + disallowShouldThrow = false; + service.ReconcileAutoRefreshHoldWithFocus(); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(disallowCallCount, Is.EqualTo(2)); + } + + [Test] + public void FocusReturnService_WhenFocusedAndHeld_ReconcileReleasesAfterPreflight() + { + // Verifies focused reconcile resolves external changes then releases a surviving Hold. + bool autoRefreshHeld = true; + List events = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => events.Add("disallow"), + () => events.Add("allow"), + () => events.Add("preflight")); + + service.ReconcileAutoRefreshHoldWithFocus(); + + Assert.That(autoRefreshHeld, Is.False); + Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" })); + } + + [Test] + public void FocusReturnService_WhenAllowThrows_KeepsHeldFlag() + { + // Verifies failed Allow leaves SessionState held so reconcile can retry without counter desync. + bool autoRefreshHeld = true; + List warnings = new List(); + ExternalAssetFocusReturnService service = CreateFocusReturnService( + () => autoRefreshHeld, + isHeld => autoRefreshHeld = isHeld, + () => true, + () => { }, + () => throw new InvalidOperationException("kCodeReload"), + () => { }, + warning => warnings.Add(warning)); + + service.HandleFocusChanged(true); + + Assert.That(autoRefreshHeld, Is.True); + Assert.That(warnings.Count, Is.EqualTo(1)); + Assert.That(warnings[0], Does.Contain("AllowAutoRefresh")); + } + [Test] public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAutoRefresh() { @@ -404,7 +570,8 @@ private static ExternalAssetFocusReturnService CreateFocusReturnService( Func isEditorFocused, Action disallowAutoRefresh, Action allowAutoRefresh, - Action resolveFocusReturnChanges) + Action resolveFocusReturnChanges, + Action logWarning = null) { return new ExternalAssetFocusReturnService( getAutoRefreshHeld, @@ -412,7 +579,8 @@ private static ExternalAssetFocusReturnService CreateFocusReturnService( isEditorFocused, disallowAutoRefresh, allowAutoRefresh, - resolveFocusReturnChanges); + resolveFocusReturnChanges, + logWarning); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs index 94d283709d..4561b08636 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalAssetFocusReturnService.cs @@ -5,6 +5,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Coordinates Auto Refresh suspension while Unity is unfocused. + /// Why not rely on focusChanged alone: background launch never fires focus-lost, so + /// DisallowAutoRefresh must also be armed from Initialize and periodic reconcile. /// internal sealed class ExternalAssetFocusReturnService { @@ -14,6 +16,9 @@ internal sealed class ExternalAssetFocusReturnService private readonly Action _disallowAutoRefresh; private readonly Action _allowAutoRefresh; private readonly Action _resolveFocusReturnChanges; + private readonly Action _logWarning; + private readonly Action _logVibeInfo; + private readonly Action _logVibeWarning; internal ExternalAssetFocusReturnService( Func getAutoRefreshHeld, @@ -21,7 +26,10 @@ internal ExternalAssetFocusReturnService( Func isEditorFocused, Action disallowAutoRefresh, Action allowAutoRefresh, - Action resolveFocusReturnChanges) + Action resolveFocusReturnChanges, + Action logWarning = null, + Action logVibeInfo = null, + Action logVibeWarning = null) { Debug.Assert(getAutoRefreshHeld != null, "getAutoRefreshHeld must not be null"); Debug.Assert(setAutoRefreshHeld != null, "setAutoRefreshHeld must not be null"); @@ -37,6 +45,10 @@ internal ExternalAssetFocusReturnService( _allowAutoRefresh = allowAutoRefresh ?? throw new ArgumentNullException(nameof(allowAutoRefresh)); _resolveFocusReturnChanges = resolveFocusReturnChanges ?? throw new ArgumentNullException(nameof(resolveFocusReturnChanges)); + _logWarning = logWarning ?? (message => Debug.LogWarning(message)); + // Why inject: pure C# unit tests stay free of VibeLogger; production wires VibeLogger. + _logVibeInfo = logVibeInfo ?? ((operation, message, context) => { }); + _logVibeWarning = logVibeWarning ?? ((operation, message, context) => { }); } internal bool RestoreAutoRefreshIfHeld() @@ -55,6 +67,52 @@ internal bool RestoreAutoRefreshIfHeld() return true; } + /// + /// Arms DisallowAutoRefresh when the Editor starts unfocused (no focus-lost event yet). + /// + internal void HoldIfCurrentlyUnfocused() + { + if (_isEditorFocused()) + { + return; + } + + HoldAutoRefreshIfNeeded(); + } + + /// + /// Aligns held flag with focus without depending on focusChanged delivery. + /// Idempotent: only calls Disallow/Allow when state must change. + /// Why not delayCall retry chains: kCodeReload failures stay unheld and this reconcile retries later. + /// + internal void ReconcileAutoRefreshHoldWithFocus() + { + if (!_isEditorFocused()) + { + if (HoldAutoRefreshIfNeeded()) + { + // Why only on actual repair: reconcile ticks every 0.5s; spam would drown the gate timeline. + _logVibeInfo( + "external_scene_reconcile_repair", + "Reconcile armed Auto Refresh hold while Editor is unfocused", + new { held = true, isFocused = false }); + } + + return; + } + + if (!_getAutoRefreshHeld()) + { + return; + } + + HandleFocusChanged(true); + _logVibeInfo( + "external_scene_reconcile_repair", + "Reconcile released Auto Refresh hold while Editor is focused", + new { held = _getAutoRefreshHeld(), isFocused = true }); + } + internal void HandleFocusChanged(bool isFocused) { if (!isFocused) @@ -73,15 +131,28 @@ internal void HandleFocusChanged(bool isFocused) } } - private void HoldAutoRefreshIfNeeded() + /// + /// Attempts to arm DisallowAutoRefresh. Returns true only when this call newly armed the hold. + /// + internal bool HoldAutoRefreshIfNeeded() { if (_getAutoRefreshHeld()) { - return; + return false; } - _disallowAutoRefresh(); + if (!TryDisallowAutoRefresh()) + { + return false; + } + + // Why only after success: setting SessionState on failure desyncs the Unity counter (§10). _setAutoRefreshHeld(true); + _logVibeInfo( + "external_scene_hold_armed", + "Auto Refresh hold armed", + new { held = true, isFocused = _isEditorFocused() }); + return true; } private void ReleaseAutoRefreshIfHeld() @@ -91,8 +162,69 @@ private void ReleaseAutoRefreshIfHeld() return; } - _allowAutoRefresh(); + if (!TryAllowAutoRefresh()) + { + return; + } + _setAutoRefreshHeld(false); + _logVibeInfo( + "external_scene_hold_released", + "Auto Refresh hold released", + new { held = false, isFocused = _isEditorFocused() }); + } + + private bool TryDisallowAutoRefresh() + { + // Why try-catch (hatayama-approved, Disallow/Allow boundary only): Unity throws during kCodeReload. + try + { + _disallowAutoRefresh(); + return true; + } + catch (Exception exception) + { + _logWarning( + "Unity CLI Loop could not DisallowAutoRefresh (often during domain reload). " + + "Will retry via focus reconcile. " + exception.GetType().Name + ": " + exception.Message); + _logVibeWarning( + "external_scene_hold_failed", + "DisallowAutoRefresh failed", + new + { + exceptionType = exception.GetType().FullName, + exceptionMessage = exception.Message, + held = _getAutoRefreshHeld(), + isFocused = _isEditorFocused() + }); + return false; + } + } + + private bool TryAllowAutoRefresh() + { + try + { + _allowAutoRefresh(); + return true; + } + catch (Exception exception) + { + _logWarning( + "Unity CLI Loop could not AllowAutoRefresh (often during domain reload). " + + "Will retry via focus reconcile. " + exception.GetType().Name + ": " + exception.Message); + _logVibeWarning( + "external_scene_release_failed", + "AllowAutoRefresh failed", + new + { + exceptionType = exception.GetType().FullName, + exceptionMessage = exception.Message, + held = _getAutoRefreshHeld(), + isFocused = _isEditorFocused() + }); + return false; + } } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index 1e9ddf9346..db7a18d240 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -32,8 +32,20 @@ internal static class ExternalSceneChangeTracker () => EditorApplication.isFocused, AssetDatabase.DisallowAutoRefresh, AssetDatabase.AllowAutoRefresh, - ResolveForFocusReturn); + ResolveForFocusReturn, + logWarning: null, + logVibeInfo: (operation, message, context) => + { + VibeLogger.LogInfo(operation, message, context, includeStackTrace: false); + }, + logVibeWarning: (operation, message, context) => + { + VibeLogger.LogWarning(operation, message, context); + }); + // Why throttle: reconcile must not call Disallow/Allow every frame when already aligned. + private const double AutoRefreshReconcileIntervalSeconds = 0.5d; private static bool _initialized; + private static double _nextAutoRefreshReconcileTime; public static void Initialize() { @@ -65,11 +77,30 @@ public static void Initialize() PrefabStage.prefabSaved += HandlePrefabSaved; EditorApplication.focusChanged -= HandleFocusChanged; EditorApplication.focusChanged += HandleFocusChanged; + EditorApplication.update -= ReconcileAutoRefreshHoldOnUpdate; + EditorApplication.update += ReconcileAutoRefreshHoldOnUpdate; + // Why record before Hold: fingerprints must exist for focus-return resolve after startup Hold. if (!restoredHeldAutoRefresh && !IsAutoRefreshHeld()) { RecordOpenSceneSnapshots(); RecordCurrentPrefabStageSnapshot(); } + + // Why immediate Hold: background launch never fires focusChanged(false), so Auto Refresh + // would stay enabled until the first focus and show a native external-change dialog. + FocusReturnService.HoldIfCurrentlyUnfocused(); + } + + private static void ReconcileAutoRefreshHoldOnUpdate() + { + double now = EditorApplication.timeSinceStartup; + if (now < _nextAutoRefreshReconcileTime) + { + return; + } + + _nextAutoRefreshReconcileTime = now + AutoRefreshReconcileIntervalSeconds; + FocusReturnService.ReconcileAutoRefreshHoldWithFocus(); } public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForCompile( @@ -85,6 +116,11 @@ public static (bool CanProceed, string Message, string[] ScenePaths) ResolveForC private static void HandleFocusChanged(bool isFocused) { + VibeLogger.LogInfo( + "external_scene_focus_changed", + "Editor focus changed", + new { isFocused, held = IsAutoRefreshHeld() }, + includeStackTrace: false); FocusReturnService.HandleFocusChanged(isFocused); } @@ -134,6 +170,20 @@ private static void ResolveForFocusReturn() { // Focus return treats Unity's in-memory editor state as authoritative because source-control // operations can replace files while Unity is unfocused and would otherwise trigger reload dialogs. + (string AssetPath, bool IsDirty)[] openScenesBefore = GetOpenSceneStates(); + object[] fingerprintDiffsBefore = BuildFingerprintDiffContexts(openScenesBefore); + VibeLogger.LogInfo( + "external_scene_resolve_focus_return", + "ResolveForFocusReturn started", + new + { + phase = "start", + scenes = openScenesBefore, + fingerprintDiffs = fingerprintDiffsBefore, + held = IsAutoRefreshHeld() + }, + includeStackTrace: false); + string[] dirtySceneSaveFailures = SaveDirtyOpenScenesBeforeReload(); LogFocusReturnFailures("save dirty Scene files", dirtySceneSaveFailures); @@ -153,10 +203,70 @@ private static void ResolveForFocusReturn() { Debug.LogWarning( "Unity CLI Loop skipped Prefab Stage external-change reload because the current Prefab Stage is still dirty or could not be saved."); + VibeLogger.LogInfo( + "external_scene_resolve_focus_return", + "ResolveForFocusReturn finished early (Prefab Stage still dirty or unsaved)", + new + { + phase = "end", + skippedPrefabReload = true, + dirtySceneSaveFailures, + missingSceneSaveFailures, + dirtyPrefabSaveFailures, + missingPrefabSaveFailures, + held = IsAutoRefreshHeld() + }, + includeStackTrace: false); return; } ResolveCurrentPrefabStageExternalChangeForFocusReturn(); + + (string AssetPath, bool IsDirty)[] openScenesAfter = GetOpenSceneStates(); + VibeLogger.LogInfo( + "external_scene_resolve_focus_return", + "ResolveForFocusReturn finished", + new + { + phase = "end", + scenes = openScenesAfter, + fingerprintDiffs = BuildFingerprintDiffContexts(openScenesAfter), + dirtySceneSaveFailures, + missingSceneSaveFailures, + held = IsAutoRefreshHeld() + }, + includeStackTrace: false); + } + + private static object[] BuildFingerprintDiffContexts((string AssetPath, bool IsDirty)[] scenes) + { + Debug.Assert(scenes != null, "scenes must not be null"); + + List diffs = new List(scenes.Length); + for (int i = 0; i < scenes.Length; i++) + { + string assetPath = scenes[i].AssetPath; + (bool Exists, DateTime LastWriteTimeUtc, long Length) current = + ReadAssetFileFingerprint(assetPath); + bool hasSnapshot = SceneSnapshots.TryGetValue( + assetPath, + out (bool Exists, DateTime LastWriteTimeUtc, long Length) snapshot); + bool changed = !hasSnapshot || + !ExternalAssetFileStateComparer.HasSameFileState(snapshot, current); + diffs.Add(new + { + assetPath, + isDirty = scenes[i].IsDirty, + changed, + hasSnapshot, + snapshotExists = hasSnapshot && snapshot.Exists, + currentExists = current.Exists, + snapshotLength = hasSnapshot ? snapshot.Length : 0L, + currentLength = current.Length + }); + } + + return diffs.ToArray(); } private static void RecordOpenSceneSnapshots() From 9c1947b8f3d06483e467eeea357f47c1d18252b9 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 13 Jul 2026 23:31:05 +0900 Subject: [PATCH 2/3] fix: align compile wait timeout with result TTL (10m / 20m) (#1758) Co-authored-by: Cursor --- ...liLoopEditorSessionStateRepositoryTests.cs | 29 +++++++++++++++---- ...tyCliLoopCompileSessionLifecycleService.cs | 5 +++- .../internal/projectrunner/compile_wait.go | 13 +++++---- .../projectrunner/compile_wait_test.go | 4 +-- .../projectrunner/execution_errors.go | 7 +++-- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs index 83532093f8..9c38262f44 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs @@ -331,17 +331,36 @@ public void GetCompileResult_WhenLegacyCompletedTicksAreOutOfRange_ClearsLegacyS } [Test] - public void ClearExpiredCompileResult_WhenResultIsStale_ClearsSessionValue() + public void ClearExpiredCompileResult_WhenResultIsJustUnderLifetime_KeepsSessionValue() { - // Verifies stale compile results do not survive indefinitely across commands. - DateTime now = new DateTime(2026, 5, 30, 0, 32, 1, DateTimeKind.Utc); + // Verifies compile results stay retrievable until the 20-minute TTL elapses. + DateTime completedAt = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + DateTime nowJustUnderLifetime = completedAt.AddMinutes(20).AddSeconds(-1); + _compileResultSessionRepository.StoreCompileResult( + "compile_test_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: completedAt); + + bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(nowJustUnderLifetime); + + Assert.That(cleared, Is.False); + Assert.That(_compileResultSessionRepository.GetStoredCompileResult().HasResult, Is.True); + } + + [Test] + public void ClearExpiredCompileResult_WhenResultIsJustOverLifetime_ClearsSessionValue() + { + // Verifies compile results expire immediately after the 20-minute TTL. + DateTime completedAt = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + DateTime nowJustOverLifetime = completedAt.AddMinutes(20).AddSeconds(1); _compileResultSessionRepository.StoreCompileResult( "compile_test_request", forceRecompile: false, resultJson: "{\"Success\":true}", - completedAtUtc: new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc)); + completedAtUtc: completedAt); - bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(now); + bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(nowJustOverLifetime); Assert.That(cleared, Is.True); Assert.That(_compileResultSessionRepository.GetStoredCompileResult().HasResult, Is.False); diff --git a/Packages/src/Editor/Domain/UnityCliLoopCompileSessionLifecycleService.cs b/Packages/src/Editor/Domain/UnityCliLoopCompileSessionLifecycleService.cs index e05ebab4b5..360d7d5fc9 100644 --- a/Packages/src/Editor/Domain/UnityCliLoopCompileSessionLifecycleService.cs +++ b/Packages/src/Editor/Domain/UnityCliLoopCompileSessionLifecycleService.cs @@ -8,7 +8,10 @@ namespace io.github.hatayama.UnityCliLoop.Domain /// public sealed class UnityCliLoopCompileSessionLifecycleService { - private static readonly TimeSpan CompileResultLifetime = TimeSpan.FromMinutes(32); + // Why 20m: Go compileWaitTimeout is 10m; TTL must stay longer (wait ≤ TTL) so a + // timed-out client can still retrieve the result by retrying uloop compile for + // about 10 more minutes. Why not keep 32m: shrink session-state leak window. + private static readonly TimeSpan CompileResultLifetime = TimeSpan.FromMinutes(20); private readonly ISessionFlagsRepository _sessionFlagsRepository; private readonly ICompileResultSessionRepository _compileResultSessionRepository; private readonly IPendingCompileSessionRepository _pendingCompileSessionRepository; diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index b33486dbdc..ef00a0ce15 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -18,11 +18,14 @@ import ( ) const ( - compileStatusCommandName = "get-compile-status" - compileRequestIDParam = "RequestId" - compileWaitParam = clicore.DomainReloadWaitParam - compileForceParam = "ForceRecompile" - compileWaitTimeout = clicore.ToolReadinessTimeout + compileStatusCommandName = "get-compile-status" + compileRequestIDParam = "RequestId" + compileWaitParam = clicore.DomainReloadWaitParam + compileForceParam = "ForceRecompile" + // Why separate from ToolReadinessTimeout (180s): launch readiness stays short. + // Why 10m: worst-case blind block beats headroom. Why ≤ C# CompileResultLifetime (20m): + // timed-out clients can still retrieve results by retrying uloop compile ~10m more. + compileWaitTimeout = 10 * time.Minute compileWaitPollInterval = clicore.ToolReadinessPoll compileStatusProbeTimeout = clicore.ToolReadinessProbeTimeout compileResponseTimeout = 2 * time.Second diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index 8840596c2c..7da29d091b 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -567,13 +567,13 @@ func TestCompileWaitTimeoutError(t *testing.T) { if cliErr.ProjectRoot != "/tmp/MyProject" { t.Fatalf("project root mismatch: %#v", cliErr) } - expectedMessage := "Compile status wait timed out after 180000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running." + expectedMessage := "Compile status wait timed out after 600000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running." if cliErr.Message != expectedMessage { t.Fatalf("message mismatch: %#v", cliErr.Message) } expectedActions := []string{ "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", - "If Unity responds, retry `uloop compile`; the previous compile likely finished in the meantime.", + "Unity-side compile continues after this timeout; retry `uloop compile` — the result remains retrievable for about 10 more minutes without `uloop launch -r`.", "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", } if len(cliErr.NextActions) != len(expectedActions) { diff --git a/cli/project-runner/internal/projectrunner/execution_errors.go b/cli/project-runner/internal/projectrunner/execution_errors.go index 7a54c626f8..e8caddfe5f 100644 --- a/cli/project-runner/internal/projectrunner/execution_errors.go +++ b/cli/project-runner/internal/projectrunner/execution_errors.go @@ -19,11 +19,12 @@ func compileWaitTimeoutError(projectRoot string) clierrors.CLIError { SafeToRetry: true, ProjectRoot: projectRoot, Command: clicore.CompileCommandName, - // Agents have terminated whole sessions after misreading this timeout as a - // frozen Editor, so the guidance must walk them through a responsiveness check. + // Why: agents historically treated this timeout as a frozen Editor and ran + // launch -r. The real recovery path is retrying compile while C# still holds + // the result (CompileResultLifetime 20m = wait 10m + ~10m retrievable window). NextActions: []string{ "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", - "If Unity responds, retry `uloop compile`; the previous compile likely finished in the meantime.", + "Unity-side compile continues after this timeout; retry `uloop compile` — the result remains retrievable for about 10 more minutes without `uloop launch -r`.", "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", }, } From 8c470bdd7c0a7ab4068955879339ade9afbf557a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 13 Jul 2026 23:39:53 +0900 Subject: [PATCH 3/3] docs: guide agents through API Update / Script Updating Consent modals (#1759) Co-authored-by: Cursor --- .agents/skills/uloop-compile/SKILL.md | 4 ++++ .claude/skills/uloop-compile/SKILL.md | 4 ++++ Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md | 4 ++++ cli/common/errors/error_editor_unresponsive.go | 6 ++++++ cli/common/errors/error_envelope_test.go | 3 +++ cli/dispatcher/shared-inputs-stamp.json | 2 +- .../internal/projectrunner/compile_wait_test.go | 1 + .../internal/projectrunner/execution_errors.go | 1 + cli/project-runner/shared-inputs-stamp.json | 2 +- 9 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 2a95ecfc63..9aaba36ce4 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -30,3 +30,7 @@ Returns JSON: - `ErrorCount`: number or null - `WarningCount`: number or null - `Message`: string + +## Troubleshooting + +If compile times out or Unity stops responding to uloop while the Editor looks idle, check whether Unity is showing **API Update Required** / **Script Updating Consent**. Ask the user to choose Go Ahead or No — never auto-dismiss that modal. Interactive Editors have no public uloop/Unity API to suppress it. diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 2a95ecfc63..9aaba36ce4 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -30,3 +30,7 @@ Returns JSON: - `ErrorCount`: number or null - `WarningCount`: number or null - `Message`: string + +## Troubleshooting + +If compile times out or Unity stops responding to uloop while the Editor looks idle, check whether Unity is showing **API Update Required** / **Script Updating Consent**. Ask the user to choose Go Ahead or No — never auto-dismiss that modal. Interactive Editors have no public uloop/Unity API to suppress it. diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 2a95ecfc63..9aaba36ce4 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -30,3 +30,7 @@ Returns JSON: - `ErrorCount`: number or null - `WarningCount`: number or null - `Message`: string + +## Troubleshooting + +If compile times out or Unity stops responding to uloop while the Editor looks idle, check whether Unity is showing **API Update Required** / **Script Updating Consent**. Ask the user to choose Go Ahead or No — never auto-dismiss that modal. Interactive Editors have no public uloop/Unity API to suppress it. diff --git a/cli/common/errors/error_editor_unresponsive.go b/cli/common/errors/error_editor_unresponsive.go index e776534159..6b509e6a9b 100644 --- a/cli/common/errors/error_editor_unresponsive.go +++ b/cli/common/errors/error_editor_unresponsive.go @@ -2,6 +2,11 @@ package clierrors import "github.com/hatayama/unity-cli-loop/common/unityipc" +// ApiUpdateConsentModalNextAction is shared recovery guidance when the Editor main +// thread may be blocked by Unity's API Update / Script Updating Consent modal. +// Interactive Editors have no public suppress path. +const ApiUpdateConsentModalNextAction = "The Editor may be showing an API Update / Script Updating Consent modal; check the Unity window and ask the user what to choose — never auto-dismiss it." + func connectionAttemptCause(err *unityipc.ConnectionAttemptError) string { if err == nil { return "" @@ -24,6 +29,7 @@ func unityEditorUnresponsiveError(err *unityipc.EditorUnresponsiveError, context Command: context.Command, NextActions: []string{ "Check Unity for a modal dialog or long editor operation that is blocking the Editor main thread.", + ApiUpdateConsentModalNextAction, "Run `uloop focus-window` if Unity is hidden behind another window.", "Close the modal dialog or wait for the Editor operation to finish, then retry the command.", }, diff --git a/cli/common/errors/error_envelope_test.go b/cli/common/errors/error_envelope_test.go index 7860144d89..438f04798b 100644 --- a/cli/common/errors/error_envelope_test.go +++ b/cli/common/errors/error_envelope_test.go @@ -140,6 +140,9 @@ func TestClassifyEditorUnresponsiveError(t *testing.T) { if !strings.Contains(joinedActions, "modal dialog") { t.Fatalf("next actions should mention modal dialog: %#v", cliErr.NextActions) } + if !strings.Contains(joinedActions, "API Update") || !strings.Contains(joinedActions, "never auto-dismiss") { + t.Fatalf("next actions should mention API Update consent guidance: %#v", cliErr.NextActions) + } if !strings.Contains(joinedActions, "uloop focus-window") { t.Fatalf("next actions should mention focus-window: %#v", cliErr.NextActions) } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 5fb6596a61..3345afda4f 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "8b248a96bc8a1cbc8312c7f77da08d9ef8903680" + "sharedInputsHash": "fbbc3c34d301331c513155ea2bab2a33a4b5de97" } diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index 7da29d091b..c37d9bcc7f 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -574,6 +574,7 @@ func TestCompileWaitTimeoutError(t *testing.T) { expectedActions := []string{ "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", "Unity-side compile continues after this timeout; retry `uloop compile` — the result remains retrievable for about 10 more minutes without `uloop launch -r`.", + clierrors.ApiUpdateConsentModalNextAction, "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", } if len(cliErr.NextActions) != len(expectedActions) { diff --git a/cli/project-runner/internal/projectrunner/execution_errors.go b/cli/project-runner/internal/projectrunner/execution_errors.go index e8caddfe5f..a1bf1261d1 100644 --- a/cli/project-runner/internal/projectrunner/execution_errors.go +++ b/cli/project-runner/internal/projectrunner/execution_errors.go @@ -25,6 +25,7 @@ func compileWaitTimeoutError(projectRoot string) clierrors.CLIError { NextActions: []string{ "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", "Unity-side compile continues after this timeout; retry `uloop compile` — the result remains retrievable for about 10 more minutes without `uloop launch -r`.", + clierrors.ApiUpdateConsentModalNextAction, "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", }, } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index e608f05e02..7e7d299f1b 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "8c336555a69a3cf485147e5669ce8bb4d3fe2982" + "sharedInputsHash": "581697442cd42c4467c8a80ab3bce98f403890c6" }