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"}}