mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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"}}
|
||||
Reference in New Issue
Block a user