retrace-sdk 0.16.10 → 0.16.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resume.js CHANGED
@@ -9,20 +9,60 @@
9
9
  */
10
10
  // Registry of resumable functions
11
11
  const resumableFunctions = new Map();
12
+ import { getConfig } from "./config.js";
12
13
  export function registerResumable(name, fn) {
13
14
  resumableFunctions.set(name, fn);
14
15
  }
15
16
  export function getResumable(name) {
16
17
  return resumableFunctions.get(name);
17
18
  }
19
+ /**
20
+ * Best-effort execution-start / heartbeat claim. Returns false ONLY on a definitive 409 (a newer
21
+ * replay superseded this one). ANY other outcome — network/timeout, 401/403, deploy-window 404, 429,
22
+ * 5xx — returns true and PROCEEDS: this fence is best-effort by design; the server's completion CAS
23
+ * is the final authority that rejects a stale result. It does NOT, on its own, prevent stale work on
24
+ * a claim-endpoint failure.
25
+ */
26
+ async function claimExecution(forkId, executionId) {
27
+ try {
28
+ const cfg = getConfig();
29
+ const resp = await fetch(`${cfg.baseUrl}/api/v1/forks/${forkId}/replay/claim`, {
30
+ method: "POST",
31
+ headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json" },
32
+ body: JSON.stringify({ execution_id: executionId }),
33
+ signal: AbortSignal.timeout(5000),
34
+ });
35
+ return resp.status !== 409;
36
+ }
37
+ catch {
38
+ return true; // ambiguous failure → proceed (best-effort)
39
+ }
40
+ }
18
41
  export async function handleResume(command) {
19
42
  const fn = getResumable(command.traceName);
20
43
  if (!fn)
21
44
  return false;
45
+ // Execution-start fence BEFORE creating the recorder, so a superseded replay leaves NO leaked
46
+ // recorder/trace/interceptors/context (the recorder is only started once we're cleared to run).
47
+ if (command.executionId && !(await claimExecution(command.forkId, command.executionId))) {
48
+ console.warn("[retrace] Cascade replay superseded by a newer replay — aborting before execution.");
49
+ return false;
50
+ }
51
+ // Keep-alive heartbeat: refresh our generation while a (possibly long) replay runs so a new dispatch
52
+ // can't reclaim it as stale — which is what would otherwise let a second replay run concurrently.
53
+ // Interval is well under the server lease window; stopped in finally.
54
+ let heartbeat = null;
55
+ if (command.executionId) {
56
+ heartbeat = setInterval(() => { void claimExecution(command.forkId, command.executionId); }, 5 * 60 * 1000);
57
+ if (typeof heartbeat.unref === "function")
58
+ heartbeat.unref();
59
+ }
60
+ let recorder = null;
61
+ let markFailed = null;
22
62
  try {
23
63
  const { TraceRecorder } = await import("./recorder.js");
24
64
  const { TraceStatus } = await import("./trace.js");
25
- const recorder = new TraceRecorder({
65
+ recorder = new TraceRecorder({
26
66
  name: `Fork: ${command.traceName}`,
27
67
  input: command.modifiedInput,
28
68
  metadata: {
@@ -35,6 +75,10 @@ export async function handleResume(command) {
35
75
  forkPointSpanId: command.forkPointSpanId,
36
76
  forkPointIndex: command.forkPointIndex,
37
77
  });
78
+ // Finalizer captured with the right enum type — used to end the trace as FAILED if user code
79
+ // throws, so a thrown replay never leaks a running trace / interceptor routing / trace context /
80
+ // an elevated active-recorder count.
81
+ markFailed = () => recorder.end(undefined, TraceStatus.FAILED);
38
82
  recorder.start(`Fork: ${command.traceName}`, command.modifiedInput);
39
83
  // Determine args for re-execution
40
84
  let args = command.originalArgs || [];
@@ -50,8 +94,16 @@ export async function handleResume(command) {
50
94
  }
51
95
  catch (err) {
52
96
  console.error("[retrace] Cascade replay failed:", err);
97
+ try {
98
+ markFailed?.();
99
+ }
100
+ catch { /* best-effort finalize */ }
53
101
  return false;
54
102
  }
103
+ finally {
104
+ if (heartbeat)
105
+ clearInterval(heartbeat);
106
+ }
55
107
  }
56
108
  export function parseResumeMessage(msg) {
57
109
  if (msg.type !== "resume" || !msg.data)
package/dist/transport.js CHANGED
@@ -87,7 +87,7 @@ export class WSTransport {
87
87
  // (no-op on runtimes without `_socket`, e.g. the global WebSocket). Graceful shutdown still
88
88
  // drains via flush()/beforeExit.
89
89
  socket?._socket?.unref?.();
90
- socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey, resumable: getConfig().allowRemoteReplay }));
90
+ socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey, resumable: getConfig().allowRemoteReplay, replay_generation: 1 }));
91
91
  });
92
92
  socket.addEventListener("message", (ev) => {
93
93
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.16.10",
3
+ "version": "0.16.12",
4
4
  "description": "The execution replay engine for AI agents. Record, replay, fork, and share agent executions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",