From 3bfe7d7fdbb7a12fdc97e45de592ca37da4e3204 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Apr 2026 11:45:52 +0200 Subject: [PATCH] v2 codex-04: wire AppInstall signals as observation-level facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Codex review follow-up #4. Before this commit the reducer had no handler for DecisionSignalKind.AppInstallCompleted / AppInstallFailed — the signals were real (ImeLogTrackerAdapter posts one terminal per app) but fell through to HandleUnhandledSignal, so the journal accumulated 'unhandled_signal_kind:AppInstallCompleted:v1' dead-ends and the per-app outcomes disappeared. Subsequent ESP / IME classifiers had no structured evidence of which apps blocked. Scope: observation-only, as the Master-Tracker recommended. Neither handler changes Stage or Outcome — they roll the terminal outcome into a new DecisionState.AppInstallFacts aggregate that: - increments CompletedCount (+ Installed / Skipped / Postponed breakdown from the adapter's newState payload) - increments FailedCount and appends the appId to FailedAppIds, deduplicated and capped at MaxFailedAppIds (50) so pathological sessions cannot bloat DecisionState. Every DecisionSignalKind now has at least one handler; the dead-end path is reachable only through an unknown (kind, schemaVersion) pair — the genuine forward-compat failure mode per plan §2.5. The 'Still unwired' dispatch comment is replaced with a 'Fall-through: unknown (kind, schemaVersion)' label. Tests: - new AppInstallSignalHandlerTests (+10): Installed/Skipped/Postponed breakdowns, unknown newState forward-compat, no stage/outcome change, failed count + appId recording, dedup on repeated appId, MaxFailedAppIds cap keeps first-observed entries, mixed-stream integration covering all buckets in one fold. - HarnessTests.Replay_unknownSignalKind_recordsDeadEnd renamed to Replay_unknownSchemaVersion_recordsDeadEnd, switched from the now-wired AppInstallCompleted to a v99 SessionStarted so the dead-end path stays covered through its real-world trigger. - ReducerFailSafeTests.Reduce_unhandledKind same retarget. Suites green: DecisionCore 161/161 (+10), Agent.V2.Core 643/643, Functions 734/734. Build 0 warnings. Fixtures: - tests/fixtures/signal-kinds/app-install-completed-v1.json - tests/fixtures/signal-kinds/app-install-failed-v1.json Plan: .claude/plans/codex-04-appinstall-signals-wiring.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AppInstallSignalHandlerTests.cs | 261 ++++++++++++++++++ .../HarnessTests.cs | 16 +- .../ReducerFailSafeTests.cs | 12 +- .../Engine/DecisionEngine.Shared.cs | 67 +++++ .../Engine/DecisionEngine.cs | 6 +- .../State/AppInstallFacts.cs | 141 ++++++++++ .../State/DecisionState.cs | 12 +- .../State/DecisionStateBuilder.cs | 9 + .../app-install-completed-v1.json | 6 + .../signal-kinds/app-install-failed-v1.json | 5 + 10 files changed, 521 insertions(+), 14 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/AppInstallFacts.cs create mode 100644 tests/fixtures/signal-kinds/app-install-completed-v1.json create mode 100644 tests/fixtures/signal-kinds/app-install-failed-v1.json diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs new file mode 100644 index 00000000..8eb3d511 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Codex follow-up #4 — coverage for the AppInstall observation handlers. Until this + /// PR both and + /// fell through to + /// HandleUnhandledSignal, producing dead-end transitions and discarding the + /// per-app outcome. These tests pin the new contract: bookkeeping advances, the + /// aggregate updates correctly, and the + /// transitions come out as taken (not dead-ends). + /// + public sealed class AppInstallSignalHandlerTests + { + private const string SessionId = "session-appinstall"; + private const string TenantId = "tenant-appinstall"; + + private static DecisionSignal MakeCompleted(long ordinal, string appId, string newState) + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.AppInstallCompleted, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc).AddSeconds(ordinal), + sourceOrigin: "IME", + evidence: new Evidence(EvidenceKind.Derived, $"app:{appId}", $"App {appId} → {newState}", + derivationInputs: new Dictionary + { + ["appId"] = appId, + ["newState"] = newState, + }), + payload: new Dictionary + { + ["appId"] = appId, + ["newState"] = newState, + }); + } + + private static DecisionSignal MakeFailed(long ordinal, string appId) + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.AppInstallFailed, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc).AddSeconds(ordinal), + sourceOrigin: "IME", + evidence: new Evidence(EvidenceKind.Derived, $"app:{appId}", $"App {appId} → Error", + derivationInputs: new Dictionary + { + ["appId"] = appId, + ["newState"] = "Error", + }), + payload: new Dictionary + { + ["appId"] = appId, + ["newState"] = "Error", + }); + } + + // ---- Completed handler ----------------------------------------------------------- + + [Fact] + public void Completed_Installed_increments_Completed_and_Installed_breakdown_only() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(SessionId, TenantId); + + var step = engine.Reduce(state, MakeCompleted(0, "app-1", "Installed")); + + Assert.True(step.Transition.Taken); + Assert.Null(step.Transition.DeadEndReason); + Assert.Equal(nameof(DecisionSignalKind.AppInstallCompleted), step.Transition.Trigger); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.CompletedCount); + Assert.Equal(1, facts.InstalledCount); + Assert.Equal(0, facts.SkippedCount); + Assert.Equal(0, facts.PostponedCount); + Assert.Equal(0, facts.FailedCount); + } + + [Fact] + public void Completed_Skipped_increments_Completed_and_Skipped_breakdown_only() + { + var engine = new DecisionEngine(); + var step = engine.Reduce( + DecisionState.CreateInitial(SessionId, TenantId), + MakeCompleted(0, "app-1", "Skipped")); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.CompletedCount); + Assert.Equal(0, facts.InstalledCount); + Assert.Equal(1, facts.SkippedCount); + Assert.Equal(0, facts.PostponedCount); + } + + [Fact] + public void Completed_Postponed_increments_Completed_and_Postponed_breakdown_only() + { + var engine = new DecisionEngine(); + var step = engine.Reduce( + DecisionState.CreateInitial(SessionId, TenantId), + MakeCompleted(0, "app-1", "Postponed")); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.CompletedCount); + Assert.Equal(1, facts.PostponedCount); + } + + [Fact] + public void Completed_unknown_newState_still_increments_CompletedCount_but_no_breakdown() + { + // Forward-compat: an adapter adding a future terminal label (e.g. "Replaced") + // should not throw; the bucket count goes up even if no breakdown matches. + var engine = new DecisionEngine(); + var step = engine.Reduce( + DecisionState.CreateInitial(SessionId, TenantId), + MakeCompleted(0, "app-1", "Replaced")); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.CompletedCount); + Assert.Equal(0, facts.InstalledCount); + Assert.Equal(0, facts.SkippedCount); + Assert.Equal(0, facts.PostponedCount); + } + + [Fact] + public void Completed_advances_step_bookkeeping_without_changing_stage_or_outcome() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(SessionId, TenantId) + .ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithStepIndex(4) + .WithLastAppliedSignalOrdinal(3) + .Build(); + + var step = engine.Reduce(state, MakeCompleted(4, "app-1", "Installed")); + + Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); + Assert.Null(step.NewState.Outcome); + Assert.Equal(5, step.NewState.StepIndex); + Assert.Equal(4, step.NewState.LastAppliedSignalOrdinal); + } + + // ---- Failed handler -------------------------------------------------------------- + + [Fact] + public void Failed_increments_FailedCount_and_records_appId() + { + var engine = new DecisionEngine(); + var step = engine.Reduce( + DecisionState.CreateInitial(SessionId, TenantId), + MakeFailed(0, "app-broken-1")); + + Assert.True(step.Transition.Taken); + Assert.Equal(nameof(DecisionSignalKind.AppInstallFailed), step.Transition.Trigger); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.FailedCount); + Assert.Single(facts.FailedAppIds, "app-broken-1"); + Assert.Equal(0, facts.CompletedCount); + } + + [Fact] + public void Failed_without_appId_still_increments_count_but_does_not_pollute_list() + { + // Defensive: a malformed payload without "appId" should not crash or poison the + // list with empty entries. + var engine = new DecisionEngine(); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.AppInstallFailed, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "IME", + evidence: new Evidence(EvidenceKind.Synthetic, "app:unknown", "test"), + payload: new Dictionary()); // no "appId" + + var step = engine.Reduce(DecisionState.CreateInitial(SessionId, TenantId), signal); + + var facts = step.NewState.AppInstallFacts; + Assert.Equal(1, facts.FailedCount); + Assert.Empty(facts.FailedAppIds); + } + + [Fact] + public void Failed_deduplicates_repeated_appId() + { + // The adapter posts terminal once per app, but a defensive dedup guards against + // any replay-injection that might double-feed the same id. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(SessionId, TenantId); + + var step1 = engine.Reduce(state, MakeFailed(0, "app-x")); + var step2 = engine.Reduce(step1.NewState, MakeFailed(1, "app-x")); + + var facts = step2.NewState.AppInstallFacts; + Assert.Equal(2, facts.FailedCount); + Assert.Single(facts.FailedAppIds); // still just one entry + Assert.Equal("app-x", facts.FailedAppIds[0]); + } + + [Fact] + public void Failed_caps_FailedAppIds_at_MaxFailedAppIds_limit() + { + var engine = new DecisionEngine(); + DecisionState state = DecisionState.CreateInitial(SessionId, TenantId); + + // Post MaxFailedAppIds + 10 distinct failures; the tail must be dropped. + var total = AppInstallFacts.MaxFailedAppIds + 10; + for (var i = 0; i < total; i++) + { + state = engine.Reduce(state, MakeFailed(i, $"app-{i}")).NewState; + } + + var facts = state.AppInstallFacts; + Assert.Equal(total, facts.FailedCount); + Assert.Equal(AppInstallFacts.MaxFailedAppIds, facts.FailedAppIds.Count); + // First-observed wins — the cap drops later arrivals, not earlier ones. + Assert.Equal("app-0", facts.FailedAppIds[0]); + Assert.Equal($"app-{AppInstallFacts.MaxFailedAppIds - 1}", + facts.FailedAppIds[AppInstallFacts.MaxFailedAppIds - 1]); + } + + // ---- Integration ----------------------------------------------------------------- + + [Fact] + public void Mixed_stream_of_completed_and_failed_accumulates_facts_correctly() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(SessionId, TenantId); + + state = engine.Reduce(state, MakeCompleted(0, "a1", "Installed")).NewState; + state = engine.Reduce(state, MakeCompleted(1, "a2", "Skipped")).NewState; + state = engine.Reduce(state, MakeFailed(2, "a3")).NewState; + state = engine.Reduce(state, MakeCompleted(3, "a4", "Installed")).NewState; + state = engine.Reduce(state, MakeFailed(4, "a5")).NewState; + + var facts = state.AppInstallFacts; + Assert.Equal(3, facts.CompletedCount); + Assert.Equal(2, facts.InstalledCount); + Assert.Equal(1, facts.SkippedCount); + Assert.Equal(0, facts.PostponedCount); + Assert.Equal(2, facts.FailedCount); + Assert.Equal(new[] { "a3", "a5" }, facts.FailedAppIds); + Assert.Equal(5, state.StepIndex); + Assert.Equal(4, state.LastAppliedSignalOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs index 70d50436..a39d38c7 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs @@ -84,20 +84,22 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Fact] - public void Replay_unknownSignalKind_recordsDeadEnd_stateUnchanged() + public void Replay_unknownSchemaVersion_recordsDeadEnd_stateUnchanged() { - // An unhandled signal advances bookkeeping but leaves stage/outcome alone - // and records a DeadEndReason — plan §2.5 fail-safe shape for non-handlers. - // AppInstallCompleted is scheduled for v11.1; still unhandled in M3.3. + // Codex follow-up #4 wired AppInstallCompleted / AppInstallFailed, so every + // DecisionSignalKind now has at least one handler. The dead-end path remains + // reachable through an unknown schema version — the forward-compat surface the + // reducer guards per plan §2.5. A v2 SessionStarted arriving at a v1-aware + // backend is treated as an unhandled kind dispatch. var harness = new ReplayHarness(new DecisionEngine()); var signal = new DecisionSignal( sessionSignalOrdinal: 0, sessionTraceOrdinal: 0, - kind: DecisionSignalKind.AppInstallCompleted, - kindSchemaVersion: 1, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 99, occurredAtUtc: DateTime.UtcNow, sourceOrigin: "harness", - evidence: new Evidence(EvidenceKind.Raw, "App:test", "test")); + evidence: new Evidence(EvidenceKind.Raw, "session:future", "test")); var result = harness.Replay("s", "t", new List { signal }); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs index 5f82ce21..5302a3ad 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs @@ -55,18 +55,20 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Fact] - public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason() + public void Reduce_unhandledSchemaVersion_producesDeadEnd_withVersionedReason() { - // Pick a kind that no sub-milestone has wired yet. AppInstallCompleted is - // scheduled for v11.1 app-install observability; update this test when wired. + // After Codex follow-up #4 every DecisionSignalKind has at least one handler, + // so the dead-end path is now only reachable through an unknown schema version + // on a known kind. This is the realistic forward-compat failure mode: the + // backend sees a v2 signal it doesn't know how to interpret yet. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("s", "t"); - var signal = MakeSignal(DecisionSignalKind.AppInstallCompleted, schemaVersion: 1); + var signal = MakeSignal(DecisionSignalKind.SessionStarted, schemaVersion: 99); var step = engine.Reduce(state, signal); Assert.False(step.Transition.Taken); - Assert.Equal("unhandled_signal_kind:AppInstallCompleted:v1", step.Transition.DeadEndReason); + Assert.Equal("unhandled_signal_kind:SessionStarted:v99", step.Transition.DeadEndReason); Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage); Assert.Equal(1, step.NewState.StepIndex); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index ece2f3fb..ed47ad4b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -159,6 +159,73 @@ namespace AutopilotMonitor.DecisionCore.Engine return new DecisionStep(newState, transition, new[] { failEffect }); } + /// + /// Handle . Codex follow-up #4 — + /// observation-only: advance bookkeeping, roll the terminal outcome into + /// , record a taken transition. Does NOT + /// affect or ; downstream + /// consumers (Inspector UI, future classifiers) read the aggregate directly. + /// + /// Payload contract (from ImeLogTrackerAdapter): appId, newState + /// ∈ {Installed, Skipped, Postponed}. Unknown newState + /// values still count toward but do not + /// update any breakdown counter. + /// + /// + private DecisionStep HandleAppInstallCompletedV1(DecisionState state, DecisionSignal signal) + { + var newStatePayload = signal.Payload != null && signal.Payload.TryGetValue("newState", out var v) + ? v + : null; + var updatedFacts = state.AppInstallFacts.WithCompleted(newStatePayload); + + var nextStep = state.StepIndex + 1; + var newState = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .WithAppInstallFacts(updatedFacts) + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.AppInstallCompleted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . Codex follow-up #4 — + /// observation-only: advance bookkeeping, roll the failure into + /// (increments counter, appends appId up to + /// ), record a taken transition. + /// + private DecisionStep HandleAppInstallFailedV1(DecisionState state, DecisionSignal signal) + { + var appId = signal.Payload != null && signal.Payload.TryGetValue("appId", out var v) + ? v + : null; + var updatedFacts = state.AppInstallFacts.WithFailed(appId); + + var nextStep = state.StepIndex + 1; + var newState = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .WithAppInstallFacts(updatedFacts) + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.AppInstallFailed)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + /// /// Handle . /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 6c584554..acfebde8 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -98,7 +98,11 @@ namespace AutopilotMonitor.DecisionCore.Engine // ----- Informational pass-through (DecisionEngine.Shared.cs) — single-rail §1.3 ----- (DecisionSignalKind.InformationalEvent, 1) => HandleInformationalEventV1(state, signal), - // ----- Still unwired (AppInstall — v11.1+) ----- + // ----- App-install observations (DecisionEngine.Shared.cs) — Codex follow-up #4 ----- + (DecisionSignalKind.AppInstallCompleted, 1) => HandleAppInstallCompletedV1(state, signal), + (DecisionSignalKind.AppInstallFailed, 1) => HandleAppInstallFailedV1(state, signal), + + // ----- Fall-through: unknown (kind, schemaVersion) pair → dead-end journal entry ----- _ => HandleUnhandledSignal(state, signal), }; } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/AppInstallFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/AppInstallFacts.cs new file mode 100644 index 00000000..d0400c5d --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/AppInstallFacts.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Aggregated facts about app-install terminal outcomes observed during a session. + /// Codex follow-up #4 — and + /// are now first-class + /// observation signals; this value holds their rolled-up counts so the reducer, the + /// journal (via ) and any future classifier have structured + /// evidence about which apps finished and which blocked. + /// + /// Invariants: + /// + /// == + + . + /// is capped at ; overflow is discarded so DecisionState cannot grow unbounded. + /// Instances are immutable; / return new instances. + /// + /// + /// + public sealed class AppInstallFacts + { + /// Cap on so pathological sessions cannot bloat state. + public const int MaxFailedAppIds = 50; + + public static readonly AppInstallFacts Empty = new AppInstallFacts( + completedCount: 0, + installedCount: 0, + skippedCount: 0, + postponedCount: 0, + failedCount: 0, + failedAppIds: Array.Empty()); + + public AppInstallFacts( + int completedCount, + int installedCount, + int skippedCount, + int postponedCount, + int failedCount, + IReadOnlyList failedAppIds) + { + if (completedCount < 0) throw new ArgumentOutOfRangeException(nameof(completedCount)); + if (installedCount < 0) throw new ArgumentOutOfRangeException(nameof(installedCount)); + if (skippedCount < 0) throw new ArgumentOutOfRangeException(nameof(skippedCount)); + if (postponedCount < 0) throw new ArgumentOutOfRangeException(nameof(postponedCount)); + if (failedCount < 0) throw new ArgumentOutOfRangeException(nameof(failedCount)); + + CompletedCount = completedCount; + InstalledCount = installedCount; + SkippedCount = skippedCount; + PostponedCount = postponedCount; + FailedCount = failedCount; + FailedAppIds = failedAppIds ?? Array.Empty(); + } + + /// Installed + Skipped + Postponed (everything the adapter maps to AppInstallCompleted). + public int CompletedCount { get; } + + /// Apps with terminal state == Installed. + public int InstalledCount { get; } + + /// Apps with terminal state == Skipped (policy-skipped, not failed). + public int SkippedCount { get; } + + /// Apps with terminal state == Postponed. + public int PostponedCount { get; } + + /// Apps with terminal state == Error (i.e. AppInstallFailed). + public int FailedCount { get; } + + /// + /// Identifiers of failed apps, first in order of + /// observation. Duplicate appIds are ignored (terminal is posted once per app, + /// but the guard is defensive). + /// + public IReadOnlyList FailedAppIds { get; } + + /// + /// Produce a new facts value with the completed sub-category incremented. + /// is the adapter's payload value — one of + /// "Installed", "Skipped", "Postponed". Unknown values still + /// increment but don't contribute to any breakdown. + /// + public AppInstallFacts WithCompleted(string? newStatePayload) + { + var installed = InstalledCount; + var skipped = SkippedCount; + var postponed = PostponedCount; + + if (string.Equals(newStatePayload, "Installed", StringComparison.OrdinalIgnoreCase)) installed++; + else if (string.Equals(newStatePayload, "Skipped", StringComparison.OrdinalIgnoreCase)) skipped++; + else if (string.Equals(newStatePayload, "Postponed", StringComparison.OrdinalIgnoreCase)) postponed++; + + return new AppInstallFacts( + completedCount: CompletedCount + 1, + installedCount: installed, + skippedCount: skipped, + postponedCount: postponed, + failedCount: FailedCount, + failedAppIds: FailedAppIds); + } + + /// + /// Produce a new facts value with incremented and + /// appended to (dedup + cap). + /// + public AppInstallFacts WithFailed(string? appId) + { + var newIds = FailedAppIds; + if (!string.IsNullOrEmpty(appId)) + { + var alreadyTracked = false; + for (var i = 0; i < FailedAppIds.Count; i++) + { + if (string.Equals(FailedAppIds[i], appId, StringComparison.Ordinal)) + { + alreadyTracked = true; + break; + } + } + + if (!alreadyTracked && FailedAppIds.Count < MaxFailedAppIds) + { + var copy = new List(FailedAppIds.Count + 1); + copy.AddRange(FailedAppIds); + copy.Add(appId!); + newIds = copy; + } + } + + return new AppInstallFacts( + completedCount: CompletedCount, + installedCount: InstalledCount, + skippedCount: SkippedCount, + postponedCount: PostponedCount, + failedCount: FailedCount + 1, + failedAppIds: newIds); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index 865d0052..d1f3bc12 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -51,6 +51,7 @@ namespace AutopilotMonitor.DecisionCore.State IReadOnlyList deadlines, long lastAppliedSignalOrdinal, int stepIndex, + AppInstallFacts? appInstallFacts = null, string? schemaVersion = null) { if (string.IsNullOrEmpty(sessionId)) @@ -93,6 +94,7 @@ namespace AutopilotMonitor.DecisionCore.State Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines)); LastAppliedSignalOrdinal = lastAppliedSignalOrdinal; StepIndex = stepIndex; + AppInstallFacts = appInstallFacts ?? AppInstallFacts.Empty; SchemaVersion = schemaVersion ?? CurrentSchemaVersion; } @@ -164,6 +166,13 @@ namespace AutopilotMonitor.DecisionCore.State public int StepIndex { get; } + /// + /// Rolled-up terminal outcomes from + /// and signals — Codex + /// follow-up #4. Never null; defaults to . + /// + public AppInstallFacts AppInstallFacts { get; } + public string SchemaVersion { get; } /// @@ -208,6 +217,7 @@ namespace AutopilotMonitor.DecisionCore.State skipDeviceEsp: null, deadlines: Array.Empty(), lastAppliedSignalOrdinal: -1, - stepIndex: 0); + stepIndex: 0, + appInstallFacts: AppInstallFacts.Empty); } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs index dd09d821..20353e28 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -49,6 +49,7 @@ namespace AutopilotMonitor.DecisionCore.State Deadlines = new List(source.Deadlines); LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal; StepIndex = source.StepIndex; + AppInstallFacts = source.AppInstallFacts; SchemaVersion = source.SchemaVersion; } @@ -82,6 +83,7 @@ namespace AutopilotMonitor.DecisionCore.State public List Deadlines { get; set; } public long LastAppliedSignalOrdinal { get; set; } public int StepIndex { get; set; } + public AppInstallFacts AppInstallFacts { get; set; } = AppInstallFacts.Empty; public string SchemaVersion { get; set; } // ---------- fluent helpers for the most common reducer operations ---------- @@ -125,6 +127,12 @@ namespace AutopilotMonitor.DecisionCore.State public DecisionStateBuilder ClearDeadlines() { Deadlines.Clear(); return this; } + public DecisionStateBuilder WithAppInstallFacts(AppInstallFacts facts) + { + AppInstallFacts = facts ?? throw new ArgumentNullException(nameof(facts)); + return this; + } + public DecisionState Build() => new DecisionState( sessionId: SessionId, @@ -157,6 +165,7 @@ namespace AutopilotMonitor.DecisionCore.State deadlines: Deadlines.ToArray(), lastAppliedSignalOrdinal: LastAppliedSignalOrdinal, stepIndex: StepIndex, + appInstallFacts: AppInstallFacts, schemaVersion: SchemaVersion); } } diff --git a/tests/fixtures/signal-kinds/app-install-completed-v1.json b/tests/fixtures/signal-kinds/app-install-completed-v1.json new file mode 100644 index 00000000..dfa3a7e7 --- /dev/null +++ b/tests/fixtures/signal-kinds/app-install-completed-v1.json @@ -0,0 +1,6 @@ +# AppInstallCompleted v1 — Codex follow-up #4. +# Posted by ImeLogTrackerAdapter once per app on terminal Installed/Skipped/Postponed. +# Reducer: HandleAppInstallCompletedV1 → bookkeeping advance + AppInstallFacts update +# (CompletedCount++, and Installed/Skipped/Postponed breakdown when newState matches). +# No Stage/Outcome change. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AppInstallCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T10:00:00.0000000Z","SourceOrigin":"IME","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"App app-guid-001 → Installed","DerivationInputs":{"appId":"app-guid-001","appName":"Company Portal","previousState":"Installing","newState":"Installed"}},"Payload":{"appId":"app-guid-001","newState":"Installed"}} diff --git a/tests/fixtures/signal-kinds/app-install-failed-v1.json b/tests/fixtures/signal-kinds/app-install-failed-v1.json new file mode 100644 index 00000000..e133a30d --- /dev/null +++ b/tests/fixtures/signal-kinds/app-install-failed-v1.json @@ -0,0 +1,5 @@ +# AppInstallFailed v1 — Codex follow-up #4. +# Posted by ImeLogTrackerAdapter once per app on terminal Error state. +# Reducer: HandleAppInstallFailedV1 → bookkeeping advance + AppInstallFacts.FailedCount++ +# + FailedAppIds.Add(appId) (dedup, capped at MaxFailedAppIds). No Stage/Outcome change. +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"AppInstallFailed","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T10:01:00.0000000Z","SourceOrigin":"IME","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"App app-guid-002 → Error","DerivationInputs":{"appId":"app-guid-002","appName":"Broken Tool","previousState":"Installing","newState":"Error"}},"Payload":{"appId":"app-guid-002","newState":"Error"}}