fix: preserve completed automation evidence when quit is unavailable

This commit is contained in:
codex
2026-08-04 07:22:27 +03:00
parent 31171c9ed8
commit 523d063ceb
3 changed files with 95 additions and 16 deletions
@@ -181,6 +181,7 @@ public sealed class EntryPoint : IExtensionApplication
RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document); RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document);
RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document); RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document);
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState); var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState);
FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome);
details["ApplyOutcome"] = outcome.ToString(); details["ApplyOutcome"] = outcome.ToString();
details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0; details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0;
details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0; details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0;
@@ -102,7 +102,16 @@ internal sealed record FirstApplyAutomationEvidence
public int? CreateRun1 { get; init; } public int? CreateRun1 { get; init; }
public int? CreateRun2 { get; init; } public int? CreateRun2 { get; init; }
public int? SkipRun2 { get; init; } public int? SkipRun2 { get; init; }
public string? ApplyOutcomeRun1 { get; init; }
public string? ApplyOutcomeRun2 { get; init; }
public bool? PermitAccepted { 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? IdleCallbackCount { get; init; }
public int? CoordinatorStartCount { get; init; } public int? CoordinatorStartCount { get; init; }
// Local test evidence only. This is intentionally never copied into Git // Local test evidence only. This is intentionally never copied into Git
@@ -116,7 +125,7 @@ internal sealed record FirstApplyAutomationEvidence
internal sealed class FirstLimitedApplyAutomationCoordinator internal sealed class FirstLimitedApplyAutomationCoordinator
{ {
private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued } private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued }
private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted); private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome);
private static string? selectedTargetSnapshotId; private static string? selectedTargetSnapshotId;
private static readonly List<PxpResult> pxpResults = []; private static readonly List<PxpResult> pxpResults = [];
internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId; internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId;
@@ -125,7 +134,13 @@ internal sealed class FirstLimitedApplyAutomationCoordinator
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted) internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted)
{ {
if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) 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 FirstApplyAutomationConfiguration? config;
private Document? document; private Document? document;
@@ -144,6 +159,10 @@ internal sealed class FirstLimitedApplyAutomationCoordinator
private bool exitQueued; private bool exitQueued;
private bool exitHandlerSubscribed; private bool exitHandlerSubscribed;
private string? exceptionText; private string? exceptionText;
private bool functionalCompleted;
private bool evidenceFlushed;
private bool exitAttemptQueued;
private string? exitFailureCode;
public void Start() public void Start()
{ {
@@ -293,6 +312,7 @@ internal sealed class FirstLimitedApplyAutomationCoordinator
private void Complete(bool success, string? error, bool safeToExitNow = false) private void Complete(bool success, string? error, bool safeToExitNow = false)
{ {
if (IsTerminal) return; if (IsTerminal) return;
functionalCompleted = success;
Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed"); Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed");
UnsubscribeHandlers(); UnsubscribeHandlers();
TryWriteEvidence(success, error); TryWriteEvidence(success, error);
@@ -330,16 +350,29 @@ internal sealed class FirstLimitedApplyAutomationCoordinator
{ {
if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle; if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle;
exitHandlerSubscribed = false; exitHandlerSubscribed = false;
exitAttemptQueued = true;
if (!AcadApplication.IsQuiescent)
{
transitions.Add("QuitDispatchDeferredNotQuiescent");
TryWriteEvidence(functionalCompleted, null);
return;
}
// LifecycleProbe opens no fixture document, but the real startup // LifecycleProbe opens no fixture document, but the real startup
// document is available at this point (observed as Drawing1.dwg). // document is available at this point (observed as Drawing1.dwg).
// Use that live document only to queue native QUIT; never retain it // Use that live document only to queue native QUIT; never retain it
// past this callback or use it for fixture/Plant work. // past this callback or use it for fixture/Plant work.
var exitDocument = document ?? AcadApplication.DocumentManager.MdiActiveDocument; var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;
if (exitDocument is null) throw new InvalidOperationException("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE"); if (exitDocument is null)
transitions.Add("NativeQuitCommandQueued"); {
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; // Persist the exit boundary before handing control back to AutoCAD;
// process termination can otherwise prevent the final evidence flush. // process termination can otherwise prevent the final evidence flush.
TryWriteEvidence(state == CoordinatorState.Completed, null); TryWriteEvidence(functionalCompleted, null);
exitDocument.SendStringToExecute("_.QUIT _N ", true, false, false); exitDocument.SendStringToExecute("_.QUIT _N ", true, false, false);
document = null; document = null;
automationOpenedDocuments.Clear(); automationOpenedDocuments.Clear();
@@ -348,7 +381,11 @@ internal sealed class FirstLimitedApplyAutomationCoordinator
{ {
// This is terminal cleanup: contain the exception and leave process // This is terminal cleanup: contain the exception and leave process
// cleanup to Launcher rather than re-entering document APIs. // 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 else
{ {
UnsubscribeHandlers(); 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; lifecycleHandlersSubscribed = false;
} }
private void TryWriteEvidence(bool success, string? error) private bool TryWriteEvidence(bool success, string? error)
{ {
try { WriteEvidence(success, error); } try { evidenceFlushed = true; WriteEvidence(success, error); return true; }
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); } 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) private void WriteEvidence(bool success, string? error)
{ {
if (config is null) return; if (config is null) return;
var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1); 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)!); Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence)); File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence));
} }
@@ -103,9 +103,9 @@ public sealed class FoundationTests
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs")); var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("RequestControlledExit", source); Assert.Contains("RequestControlledExit", source);
Assert.Contains("AcadApplication.Idle += OnExitIdle", 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("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() [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")); var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE", source); 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() [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 source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..]; var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..];
Assert.DoesNotContain("document.CloseAndDiscard", controlledExit); 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); Assert.Contains("document = null;", controlledExit);
} }
@@ -170,6 +170,40 @@ public sealed class FoundationTests
Assert.Contains("RequestControlledExit(safeToExitNow);", source); 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 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 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"))); [Fact] public void RibbonCommand_ReferencesRegisteredCommand() => Assert.Contains("PHP_REC_ANNOT ", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));