fix: allocate pilot session before fixture hashing

This commit is contained in:
codex
2026-08-04 15:52:27 +03:00
parent 73b97d9c2c
commit 4ae5a93a4e
+26 -4
View File
@@ -86,13 +86,18 @@ internal static class PilotCli
private static async Task<int> AutomateFirstApplyAsync(string[] args) private static async Task<int> AutomateFirstApplyAsync(string[] args)
{ {
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!); if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
var journal = await AutomationJournal.AllocateAsync(options.PilotRoot, "NextPilotPreview", CancellationToken.None);
await journal.RecordAsync("ArgumentsParsed", "Completed", null, CancellationToken.None);
if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!); if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!);
await journal.RecordAsync("ConfigurationLoaded", "Completed", null, CancellationToken.None);
if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!); if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!);
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED"); if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED");
if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!); if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING"); if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
Directory.CreateDirectory(options.EvidenceRoot); Directory.CreateDirectory(options.EvidenceRoot);
var before = ContentHashes(options.FixtureMarker); await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json"); var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json"); var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256; var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256;
@@ -103,7 +108,7 @@ internal static class PilotCli
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED"); if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED");
var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID"); if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID");
if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED"); if (!HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
// A diagnostic preview establishes the script/registration boundary only. // A diagnostic preview establishes the script/registration boundary only.
// It must never mint an Apply permit or start an Apply process. // It must never mint an Apply permit or start an Apply process.
if (options.Diagnostic) return Success; if (options.Diagnostic) return Success;
@@ -115,7 +120,7 @@ internal static class PilotCli
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}"); await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false }; var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false };
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence); var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker)); var hashesEqual = HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None));
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
if (File.Exists(permitPath)) File.Delete(permitPath); if (File.Exists(permitPath)) File.Delete(permitPath);
var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null; var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null;
@@ -183,7 +188,24 @@ internal static class PilotCli
if (!File.Exists(V("--annotation-template-dwg"))) { error = "ANNOTATION_TEMPLATE_ASSET_MISSING"; return false; } if (!File.Exists(V("--annotation-template-dwg"))) { error = "ANNOTATION_TEMPLATE_ASSET_MISSING"; return false; }
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")!, diagnostic); return true; options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")!, diagnostic); return true;
} }
private static Dictionary<string,string> ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); } private static async Task<Dictionary<string,string>> ContentHashesAsync(string marker, AutomationJournal journal, CancellationToken cancellationToken)
{
var root = Path.GetDirectoryName(marker)!;
var files = new[] { Path.Combine(root, "Piping.dcf"), Path.Combine(root, "Project.xml") }
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Orthos", "DWGs"), "*.dwg", SearchOption.TopDirectoryOnly))
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Plant 3D Models"), "*.dwg", SearchOption.TopDirectoryOnly))
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
var result = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in files) { if (!File.Exists(path)) throw new InvalidOperationException("HASH_FILE_NOT_FOUND"); await journal.RecordAsync("HashFileStarted", "Started", path, cancellationToken); await using var stream = File.OpenRead(path); result[Path.GetRelativePath(root,path)] = Convert.ToHexString(await SHA256.HashDataAsync(stream,cancellationToken)); await journal.RecordAsync("HashFileCompleted", "Completed", path, cancellationToken); }
return result;
}
private sealed class AutomationJournal
{
private readonly string path; private readonly DateTimeOffset started=DateTimeOffset.UtcNow; private int sequence;
private AutomationJournal(string path) { this.path=path; }
public static async Task<AutomationJournal> AllocateAsync(string root,string mode,CancellationToken ct) { var id=Guid.NewGuid().ToString("N"); var dir=Path.Combine(root,"state","automation-sessions",id); Directory.CreateDirectory(dir); var j=new AutomationJournal(Path.Combine(dir,"session-events.jsonl")); await j.RecordAsync("SessionAllocated","Completed",null,ct); var pointer=Path.Combine(root,"state","automation-current.json"); var temp=pointer+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(new { sessionId=id,launchMode=mode,state="SessionAllocated",terminal=false,currentStage="InitialEvidenceFlushed",createdUtc=DateTimeOffset.UtcNow }),ct); File.Move(temp,pointer,true); await j.RecordAsync("InitialEvidenceFlushed","Completed",null,ct); return j; }
public Task RecordAsync(string stage,string status,string? currentPath,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,elapsedMs=(long)(DateTimeOffset.UtcNow-started).TotalMilliseconds,stage,status,canonicalPath=currentPath,managedThreadId=Environment.CurrentManagedThreadId })+Environment.NewLine,ct);
}
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value); private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver()); private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver());
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); } private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }