feat: add deterministic execution heartbeats (AEG-V15-038)

Adds a pure, monotonic execution heartbeat and caller-supplied staleness cutoff without inventing alert thresholds. Evidence: targeted Release tests 5/5 passed; TRX SHA256 2ADBB526FAF6E5D924EB3F53C7E582E736199A59E0DA4E4FD25DCAA82A661BBC. WBS remains IN_PROGRESS pending approved alert contract.
This commit is contained in:
2026-08-09 02:20:14 +09:00
parent d38dc32e7a
commit ec80337389
6 changed files with 124 additions and 1 deletions
@@ -48,6 +48,7 @@ public sealed class ModelOperationExecution
public string IdempotencyKey { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset LastOccurredAt { get; private set; }
public DateTimeOffset? LastHeartbeatAt { get; private set; }
public ModelOperationExecutionState State { get; private set; }
public DateTimeOffset? HoldUntil { get; private set; }
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
@@ -73,8 +74,28 @@ public sealed class ModelOperationExecution
State = next;
LastOccurredAt = occurredAt;
HoldUntil = holdUntil;
if (next == ModelOperationExecutionState.Running) LastHeartbeatAt = occurredAt;
return transition;
}
public void RecordHeartbeat(DateTimeOffset occurredAt)
{
if (State != ModelOperationExecutionState.Running)
throw new InvalidOperationException("Only a running execution may record a heartbeat.");
if (occurredAt < LastHeartbeatAt)
throw new InvalidOperationException("Execution heartbeat time cannot move backwards.");
LastHeartbeatAt = occurredAt;
}
public bool HasExceededHeartbeatCutoff(DateTimeOffset asOf, TimeSpan allowedSilence)
{
ArgumentOutOfRangeException.ThrowIfLessThan(allowedSilence, TimeSpan.Zero);
if (asOf < LastHeartbeatAt) throw new InvalidOperationException("Heartbeat cutoff cannot precede the latest heartbeat.");
return State == ModelOperationExecutionState.Running
&& LastHeartbeatAt.HasValue
&& asOf - LastHeartbeatAt.Value > allowedSilence;
}
private static HashSet<ModelOperationExecutionState> Set(params ModelOperationExecutionState[] states) => new(states);
}