diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index eead713..e463495 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -133,7 +133,12 @@ internal static class PilotCli Console.Error.WriteLine("AUTOMATION_PREPARE_BLOCKED:" + string.Join(',', prepared.SafetyDecision.Violations)); return new(false, false, false, false, false); } - var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None); if (!execution.Started || execution.ProcessId is null) return new(false, false, false, false, false); + var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None); + if (!execution.Started || execution.ProcessId is null) + { + Console.Error.WriteLine("AUTOMATION_EXECUTION_BLOCKED:" + (execution.BlockCode ?? "PROCESS_START_FAILED")); + return new(false, false, false, false, false); + } var forcedCleanup = false; var gracefulExit = false; try diff --git a/Launcher.Next.Core/LauncherPaths.cs b/Launcher.Next.Core/LauncherPaths.cs index 3b92400..aa88777 100644 --- a/Launcher.Next.Core/LauncherPaths.cs +++ b/Launcher.Next.Core/LauncherPaths.cs @@ -35,7 +35,12 @@ public sealed class PathResolver(IReadOnlyDictionary? environme public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null) { var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var name in variables.Values.Keys) resolved[name] = ResolveVariable(name, variables, resolved, []); + // Explicit null values represent optional paths (for example the Apply + // permit during Preview). They are retained as null rather than being + // treated as an unknown variable; required values are still enforced + // below. + foreach (var name in variables.Values.Keys) + resolved[name] = variables[name] is null ? null : ResolveVariable(name, variables, resolved, []); foreach (var name in resolved.Keys.ToArray()) if (resolved[name] is { } value && value.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) { diff --git a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs index 821a8f6..3dbe05f 100644 --- a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs +++ b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs @@ -27,6 +27,7 @@ public sealed class PathAndLaunchTests [Fact] public void PathResolver_ResolvesEnvironmentVariable() => Assert.Equal("C:\\From Environment", Resolver(new Dictionary { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary { ["Value"] = "${ROOT}" })).Require("Value")); [Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["Value"] = "${Missing}" }))); [Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["Value"] = "" }), ["Value"])); + [Fact] public void PathResolver_PreservesOptionalNullVariable() => Assert.Null(Resolver().Resolve(new(new Dictionary { ["Optional"] = null })).Get("Optional")); [Fact] public void PathResolver_RejectsCycle() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["A"] = "${B}", ["B"] = "${A}" }))); [Fact] public void PathResolver_PreservesUnicode() => Assert.Contains("Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot)); [Fact] public void PathResolver_NormalizesSeparators() => Assert.DoesNotContain('/', Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));