v2 codex-04: wire AppInstall signals as observation-level facts

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-24 11:45:52 +02:00
parent 8da8dc649d
commit 3bfe7d7fdb
10 changed files with 521 additions and 14 deletions
@@ -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
{
/// <summary>
/// Codex follow-up #4 — coverage for the AppInstall observation handlers. Until this
/// PR both <see cref="DecisionSignalKind.AppInstallCompleted"/> and
/// <see cref="DecisionSignalKind.AppInstallFailed"/> fell through to
/// <c>HandleUnhandledSignal</c>, producing dead-end transitions and discarding the
/// per-app outcome. These tests pin the new contract: bookkeeping advances, the
/// <see cref="DecisionState.AppInstallFacts"/> aggregate updates correctly, and the
/// transitions come out as taken (not dead-ends).
/// </summary>
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<string, string>
{
["appId"] = appId,
["newState"] = newState,
}),
payload: new Dictionary<string, string>
{
["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<string, string>
{
["appId"] = appId,
["newState"] = "Error",
}),
payload: new Dictionary<string, string>
{
["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<string, string>()); // 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);
}
}
}
@@ -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<DecisionSignal> { signal });
@@ -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);
}
@@ -159,6 +159,73 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, new[] { failEffect });
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.AppInstallCompleted"/>. Codex follow-up #4 —
/// observation-only: advance bookkeeping, roll the terminal outcome into
/// <see cref="DecisionState.AppInstallFacts"/>, record a taken transition. Does NOT
/// affect <see cref="SessionStage"/> or <see cref="SessionOutcome"/>; downstream
/// consumers (Inspector UI, future classifiers) read the aggregate directly.
/// <para>
/// Payload contract (from <c>ImeLogTrackerAdapter</c>): <c>appId</c>, <c>newState</c>
/// ∈ {<c>Installed</c>, <c>Skipped</c>, <c>Postponed</c>}. Unknown <c>newState</c>
/// values still count toward <see cref="AppInstallFacts.CompletedCount"/> but do not
/// update any breakdown counter.
/// </para>
/// </summary>
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<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.AppInstallFailed"/>. Codex follow-up #4 —
/// observation-only: advance bookkeeping, roll the failure into
/// <see cref="AppInstallFacts"/> (increments counter, appends <c>appId</c> up to
/// <see cref="AppInstallFacts.MaxFailedAppIds"/>), record a taken transition.
/// </summary>
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<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.SessionAborted"/>.
/// <para>
@@ -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),
};
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Aggregated facts about app-install terminal outcomes observed during a session.
/// Codex follow-up #4 — <see cref="Signals.DecisionSignalKind.AppInstallCompleted"/> and
/// <see cref="Signals.DecisionSignalKind.AppInstallFailed"/> are now first-class
/// observation signals; this value holds their rolled-up counts so the reducer, the
/// journal (via <see cref="DecisionState"/>) and any future classifier have structured
/// evidence about which apps finished and which blocked.
/// <para>
/// <b>Invariants</b>:
/// <list type="bullet">
/// <item><see cref="CompletedCount"/> == <see cref="InstalledCount"/> + <see cref="SkippedCount"/> + <see cref="PostponedCount"/>.</item>
/// <item><see cref="FailedAppIds"/> is capped at <see cref="MaxFailedAppIds"/>; overflow is discarded so DecisionState cannot grow unbounded.</item>
/// <item>Instances are immutable; <see cref="WithCompleted"/> / <see cref="WithFailed"/> return new instances.</item>
/// </list>
/// </para>
/// </summary>
public sealed class AppInstallFacts
{
/// <summary>Cap on <see cref="FailedAppIds"/> so pathological sessions cannot bloat state.</summary>
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<string>());
public AppInstallFacts(
int completedCount,
int installedCount,
int skippedCount,
int postponedCount,
int failedCount,
IReadOnlyList<string> 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<string>();
}
/// <summary>Installed + Skipped + Postponed (everything the adapter maps to <c>AppInstallCompleted</c>).</summary>
public int CompletedCount { get; }
/// <summary>Apps with terminal state == <c>Installed</c>.</summary>
public int InstalledCount { get; }
/// <summary>Apps with terminal state == <c>Skipped</c> (policy-skipped, not failed).</summary>
public int SkippedCount { get; }
/// <summary>Apps with terminal state == <c>Postponed</c>.</summary>
public int PostponedCount { get; }
/// <summary>Apps with terminal state == <c>Error</c> (i.e. <c>AppInstallFailed</c>).</summary>
public int FailedCount { get; }
/// <summary>
/// Identifiers of failed apps, first <see cref="MaxFailedAppIds"/> in order of
/// observation. Duplicate <c>appId</c>s are ignored (terminal is posted once per app,
/// but the guard is defensive).
/// </summary>
public IReadOnlyList<string> FailedAppIds { get; }
/// <summary>
/// Produce a new facts value with the completed sub-category incremented.
/// <paramref name="newStatePayload"/> is the adapter's payload value — one of
/// <c>"Installed"</c>, <c>"Skipped"</c>, <c>"Postponed"</c>. Unknown values still
/// increment <see cref="CompletedCount"/> but don't contribute to any breakdown.
/// </summary>
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);
}
/// <summary>
/// Produce a new facts value with <see cref="FailedCount"/> incremented and
/// <paramref name="appId"/> appended to <see cref="FailedAppIds"/> (dedup + cap).
/// </summary>
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<string>(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);
}
}
}
@@ -51,6 +51,7 @@ namespace AutopilotMonitor.DecisionCore.State
IReadOnlyList<ActiveDeadline> 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; }
/// <summary>
/// Rolled-up terminal outcomes from <see cref="Signals.DecisionSignalKind.AppInstallCompleted"/>
/// and <see cref="Signals.DecisionSignalKind.AppInstallFailed"/> signals — Codex
/// follow-up #4. Never null; defaults to <see cref="State.AppInstallFacts.Empty"/>.
/// </summary>
public AppInstallFacts AppInstallFacts { get; }
public string SchemaVersion { get; }
/// <summary>
@@ -208,6 +217,7 @@ namespace AutopilotMonitor.DecisionCore.State
skipDeviceEsp: null,
deadlines: Array.Empty<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
stepIndex: 0,
appInstallFacts: AppInstallFacts.Empty);
}
}
@@ -49,6 +49,7 @@ namespace AutopilotMonitor.DecisionCore.State
Deadlines = new List<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
AppInstallFacts = source.AppInstallFacts;
SchemaVersion = source.SchemaVersion;
}
@@ -82,6 +83,7 @@ namespace AutopilotMonitor.DecisionCore.State
public List<ActiveDeadline> 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);
}
}
@@ -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"}}
@@ -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"}}