diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 2613cb3..81cc99a 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -181,6 +181,7 @@ public sealed class EntryPoint : IExtensionApplication RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document); RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document); var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState); + FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome); details["ApplyOutcome"] = outcome.ToString(); details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0; details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0; diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs index 34f4372..970fd09 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs @@ -102,7 +102,16 @@ internal sealed record FirstApplyAutomationEvidence public int? CreateRun1 { get; init; } public int? CreateRun2 { get; init; } public int? SkipRun2 { get; init; } + public string? ApplyOutcomeRun1 { get; init; } + public string? ApplyOutcomeRun2 { get; init; } public bool? PermitAccepted { get; init; } + public bool? CommandsCompleted { get; init; } + public bool? InspectorsCompleted { get; init; } + public bool? EvidenceFlushed { get; init; } + public bool? ExitAttemptQueued { get; init; } + public bool? QuitCommandWillStart { get; init; } + public bool? QuitCommandEnded { get; init; } + public string? ExitFailureCode { get; init; } public int? IdleCallbackCount { get; init; } public int? CoordinatorStartCount { get; init; } // Local test evidence only. This is intentionally never copied into Git @@ -116,7 +125,7 @@ internal sealed record FirstApplyAutomationEvidence internal sealed class FirstLimitedApplyAutomationCoordinator { private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued } - private sealed record PxpResult(string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, bool? PermitAccepted); + private sealed record PxpResult(string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome); private static string? selectedTargetSnapshotId; private static readonly List pxpResults = []; internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId; @@ -125,7 +134,13 @@ internal sealed class FirstLimitedApplyAutomationCoordinator internal static void RecordPxpOa(string? planId, IReadOnlyList candidates, string? view, int create, int skip, bool? permitAccepted) { if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) - pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted)); + pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted, null)); + } + internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome) + { + if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || pxpResults.Count == 0) return; + var last = pxpResults[^1]; + pxpResults[^1] = last with { ApplyOutcome = outcome.ToString() }; } private FirstApplyAutomationConfiguration? config; private Document? document; @@ -144,6 +159,10 @@ internal sealed class FirstLimitedApplyAutomationCoordinator private bool exitQueued; private bool exitHandlerSubscribed; private string? exceptionText; + private bool functionalCompleted; + private bool evidenceFlushed; + private bool exitAttemptQueued; + private string? exitFailureCode; public void Start() { @@ -293,6 +312,7 @@ internal sealed class FirstLimitedApplyAutomationCoordinator private void Complete(bool success, string? error, bool safeToExitNow = false) { if (IsTerminal) return; + functionalCompleted = success; Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed"); UnsubscribeHandlers(); TryWriteEvidence(success, error); @@ -330,16 +350,29 @@ internal sealed class FirstLimitedApplyAutomationCoordinator { if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle; exitHandlerSubscribed = false; + exitAttemptQueued = true; + if (!AcadApplication.IsQuiescent) + { + transitions.Add("QuitDispatchDeferredNotQuiescent"); + TryWriteEvidence(functionalCompleted, null); + return; + } // LifecycleProbe opens no fixture document, but the real startup // document is available at this point (observed as Drawing1.dwg). // Use that live document only to queue native QUIT; never retain it // past this callback or use it for fixture/Plant work. - var exitDocument = document ?? AcadApplication.DocumentManager.MdiActiveDocument; - if (exitDocument is null) throw new InvalidOperationException("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE"); - transitions.Add("NativeQuitCommandQueued"); + var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument; + if (exitDocument is null) + { + exitFailureCode = "AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE"; + transitions.Add("QuitDispatchSkippedNoLiveDocument"); + TryWriteEvidence(functionalCompleted, null); + return; + } + transitions.Add("QuitDispatchAttempted"); // Persist the exit boundary before handing control back to AutoCAD; // process termination can otherwise prevent the final evidence flush. - TryWriteEvidence(state == CoordinatorState.Completed, null); + TryWriteEvidence(functionalCompleted, null); exitDocument.SendStringToExecute("_.QUIT _N ", true, false, false); document = null; automationOpenedDocuments.Clear(); @@ -348,7 +381,11 @@ internal sealed class FirstLimitedApplyAutomationCoordinator { // This is terminal cleanup: contain the exception and leave process // cleanup to Launcher rather than re-entering document APIs. - FailFromCallback("ControlledExitIdle", exception, "AUTOMATION_CONTROLLED_EXIT_FAILED"); + // A failed graceful-exit dispatch is not a failure of already + // completed PXP-OA/UNDO work. Launcher owns bounded forced cleanup. + exitFailureCode = "AUTOMATION_CONTROLLED_EXIT_FAILED:" + exception.GetType().Name; + transitions.Add("QuitDispatchFailed"); + TryWriteEvidence(functionalCompleted, null); } } @@ -376,7 +413,13 @@ internal sealed class FirstLimitedApplyAutomationCoordinator else { UnsubscribeHandlers(); - TryWriteEvidence(false, code + ":" + callback + ":" + exception.GetType().Name); + if (functionalCompleted) + { + exitFailureCode = code + ":" + callback + ":" + exception.GetType().Name; + transitions.Add("ExitCallbackFailed"); + TryWriteEvidence(true, null); + } + else TryWriteEvidence(false, code + ":" + callback + ":" + exception.GetType().Name); } } @@ -396,17 +439,18 @@ internal sealed class FirstLimitedApplyAutomationCoordinator lifecycleHandlersSubscribed = false; } - private void TryWriteEvidence(bool success, string? error) + private bool TryWriteEvidence(bool success, string? error) { - try { WriteEvidence(success, error); } - catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); } + try { evidenceFlushed = true; WriteEvidence(success, error); return true; } + catch (System.Exception exception) { evidenceFlushed = false; RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); return false; } } private void WriteEvidence(bool success, string? error) { if (config is null) return; var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1); - var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, PermitAccepted = first?.PermitAccepted, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, ExceptionText = exceptionText, PreviousState = previousState.ToString(), CurrentState = state.ToString(), StateTransitions = transitions }; + var expectedCommands = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) ? 3 : 2; + var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = completed.Count >= expectedCommands, InspectorsCompleted = baseline is not null && counts1 is not null && counts2 is not null && (!string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) || countsUndo is not null), EvidenceFlushed = evidenceFlushed, ExitAttemptQueued = exitAttemptQueued, ExitFailureCode = exitFailureCode, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, ExceptionText = exceptionText, PreviousState = previousState.ToString(), CurrentState = state.ToString(), StateTransitions = transitions }; Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!); File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence)); } diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs index e7d422b..ead611b 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -103,9 +103,9 @@ public sealed class FoundationTests var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); Assert.Contains("RequestControlledExit", source); Assert.Contains("AcadApplication.Idle += OnExitIdle", source); - Assert.Contains("NativeQuitCommandQueued", source); + Assert.Contains("QuitDispatchAttempted", source); Assert.Contains("exitDocument.SendStringToExecute(\"_.QUIT _N \", true, false, false);", source); - Assert.Contains("var exitDocument = document ?? AcadApplication.DocumentManager.MdiActiveDocument;", source); + Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", source); } [Fact] public void Callback_AfterDocumentDestroyed_DoesNotAccessDocument() @@ -119,7 +119,7 @@ public sealed class FoundationTests { var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); Assert.Contains("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE", source); - Assert.Contains("if (exitDocument is null) throw new InvalidOperationException", source); + Assert.Contains("QuitDispatchSkippedNoLiveDocument", source); } [Fact] public void TerminalState_IgnoresFurtherCallbacks() @@ -159,7 +159,7 @@ public sealed class FoundationTests var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..]; Assert.DoesNotContain("document.CloseAndDiscard", controlledExit); - Assert.Contains("var exitDocument = document ?? AcadApplication.DocumentManager.MdiActiveDocument;", controlledExit); + Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", controlledExit); Assert.Contains("document = null;", controlledExit); } @@ -170,6 +170,40 @@ public sealed class FoundationTests Assert.Contains("RequestControlledExit(safeToExitNow);", source); } + [Fact] public void TerminalEvidence_IsFlushedBeforeExitAttempt() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..]; + Assert.True(exit.IndexOf("TryWriteEvidence(functionalCompleted, null);", StringComparison.Ordinal) < exit.IndexOf("SendStringToExecute(\"_.QUIT _N ", StringComparison.Ordinal)); + Assert.Contains("EvidenceFlushed = evidenceFlushed", source); + Assert.Contains("CommandsCompleted", source); + Assert.Contains("InspectorsCompleted", source); + } + + [Fact] public void ExitFailure_DoesNotInvalidateCompletedFunctionalEvidence() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + Assert.Contains("if (functionalCompleted)", source); + Assert.Contains("ExitFailureCode = exitFailureCode", source); + Assert.Contains("TryWriteEvidence(functionalCompleted, null);", source); + } + + [Fact] public void ExitDispatch_DoesNotUseUnavailableDocument() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..]; + Assert.Contains("AcadApplication.IsQuiescent", exit); + Assert.Contains("QuitDispatchSkippedNoLiveDocument", exit); + Assert.DoesNotContain("document ?? AcadApplication.DocumentManager", exit); + } + + [Fact] public void ProductionRuntime_DoesNotEnableForceAfterEvidence() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); + Assert.DoesNotContain("ForceAfterEvidence", source); + Assert.Contains("P_REC_ANNOT_AUTOMATION", source); + } + [Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs"))); [Fact] public void LegacyAliases_AreRegistered() => Assert.True(File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-AAV") && File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")).Contains("PXP-OA")); [Fact] public void RibbonCommand_ReferencesRegisteredCommand() => Assert.Contains("PHP_REC_ANNOT ", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));