Recursive AI

Record a failed run and replay it

All oneecma recipes

Save the record log of a JavaScript run that failed in production and replay it anywhere with OneEcma: the same clock readings, random numbers and host-call results come back from the log, so the replay fails the same way and gives the same report, with each frame’s locals added.

The script below fails only sometimes: half of the time Math.random() picks a discount code that doesn’t exist. The program records runs until one fails, saves that run’s log, and replays it from the file in a second process.

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
const string script = """
    function pickDiscount() {
      const codes = { SPRING: 0.1, VIP: 0.2 };
      const roll = Math.random();
      return roll < 0.5 ? codes.SPRING : codes.VIP_PLUS;
    }
    const rate = exchangeRate("EUR");
    const price = (100 * (1 - pickDiscount()) * rate).toFixed(2);
    if (price === "NaN") throw new RangeError(`price is NaN at ${new Date().toISOString()}`);
    price;
    """;
 
if (args is ["replay", string path])
{
    // Later, anywhere: no host functions, no clock, no randomness. Everything comes from the log.
    RecordLog saved = RecordLog.FromArray(File.ReadAllBytes(path));
    ReplayResult replay = ScriptEngine.Replay(saved);
    Console.WriteLine($"replayed {replay.Entries} entry call(s), divergence: {replay.Divergence ?? "none"}");
    Console.WriteLine(DiagnosticRenderer.Render(replay.Report!, new RenderOptions { ShowLocals = true }));
    return;
}
 
// Record runs until one fails, then keep its log.
for (int attempt = 1; ; attempt++)
{
    var engine = new ScriptEngine();
    engine.DefineGlobalFunction("exchangeRate", 1, (_, a) => JsValue.FromNumber(0.92));   // remembered by the log
    try
    {
        JsValue price = engine.Evaluate(script, "price.js");
        Console.WriteLine($"run {attempt}: {engine.ToDisplayString(price)}");
    }
    catch (JsException e)
    {
        byte[] log = engine.RecordLog.ToArray();
        File.WriteAllBytes("failure.oerl", log);
        Console.WriteLine($"run {attempt} failed; saved {log.Length} bytes, {engine.RecordLog.EventCount} events, replayable: {engine.RecordLog.IsReplayable}");
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
        break;
    }
}
Terminal
dotnet run
Text
run 1 failed; saved 462 bytes, 6 events, replayable: True
error[RangeError]: price is NaN at 2026-10-27T19:17:21.167Z
 --> price.js:8:32
  |
8 | if (price === "NaN") throw new RangeError(`price is NaN at ${new Date().toISOString()}`);
  |                                ^^^^^^^^^^
  |
  = stack: <top-level> (price.js:8)

How many runs pass before one fails depends on the random numbers. Then, in another process, with no exchangeRate function defined:

Terminal
dotnet run -- replay failure.oerl
Text
replayed 1 entry call(s), divergence: none
error[RangeError]: price is NaN at 2026-10-27T19:17:21.167Z
 --> price.js:8:32
  |
8 | if (price === "NaN") throw new RangeError(`price is NaN at ${new Date().toISOString()}`);
  |                                ^^^^^^^^^^
  |
  = stack: <top-level> (price.js:8)
  = locals in <top-level>: rate = 0.92, price = "NaN"

The replay throws at the same place with the same timestamp in the message, because new Date() read the recorded clock. Replaying the same file twice gives byte-identical output.

What the log holds

Recording is on by default: engine.RecordLog is the log of everything the run took from outside the engine, in order. That is every script and module evaluated, clock readings, Math.random() results, time zone and locale answers, host function results and exceptions, the settling of host tasks, job-queue drains and weak-reference collections. It costs a few appends per host interaction and nothing per instruction. A run with no host interaction logs its source plus about 20 bytes, Math.random() costs 9 bytes a call, and the log stops growing at 8 MiB by default (new RecordLog(maxBytes) through ScriptEngineOptions.Log).

ScriptEngine.Replay(log) builds a fresh engine whose host answers from the log alone. Host functions don’t run again, so their side effects don’t happen twice. Every event is checked against what the script asks for, and ReplayResult.Divergence says what differed if the code, the host functions or the engine changed since the recording.

Good to know

  • This is the same mechanism that explains errors: when a run fails, the engine replays it with tracing on to find where values came from. That is why host setup belongs in ScriptEngineOptions.Configure, which a replay calls again; pass the same Configure in ReplayOptions when you replay a log yourself.
  • Functions made with engine.DefineGlobalFunction are rebuilt from the log, as exchangeRate is here.
  • RecordLog.IsReplayable is false, with the reason in NotReplayableReason, after things a log can’t reproduce, such as using a second realm or a log that outgrew its limit.
  • ReplayOptions.StopAfterEntry stops after a given entry call, and ReplayResult.Engine is the replayed engine, for inspecting state at that point.
  • The byte format (version 1) and every event are in the record log.