skalpel 4.0.14 → 4.0.15

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.
@@ -32,6 +32,7 @@ const ITEM_VERSION = 1;
32
32
  const MAX_TRANSCRIPT_BYTES = 90 * 1024 * 1024;
33
33
  const MAX_DELIVERED_KEYS = 2048;
34
34
  const MAX_TERMINAL_RETRIES = 3;
35
+ const MAX_VANISHED_RESUBMITS = 1;
35
36
  const LOCK_STALE_MS = 2 * 60 * 1000;
36
37
  const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
37
38
  const DEFAULT_WORK_BUDGET_MS = 45_000;
@@ -125,8 +126,16 @@ function baseKey(sessionID, transcript) {
125
126
  .digest("hex");
126
127
  }
127
128
 
129
+ // The idempotency key is a STABLE CONTENT ADDRESS: item.key is the sha256 of (session id + full
130
+ // transcript), so the SAME content produces the SAME key across every retry and generation. A retry
131
+ // of an already-accepted transcript therefore dedups server-side and can never mint a duplicate
132
+ // job/session or re-judge at Gemini cost. The key rotates ONLY when the server EXPLICITLY discards
133
+ // the payload and asks for a fresh operation (requires_replay -> replay_epoch bump); ordinary retries
134
+ // (network, error, 404-after-accept) must keep the key fixed. Generation still drives retry backoff,
135
+ // but no longer leaks into the key.
128
136
  function idempotencyKey(item) {
129
- return `session-end:${item.key}:${item.generation || 0}`;
137
+ const epoch = item.replay_epoch || 0;
138
+ return epoch > 0 ? `session-end:${item.key}:replay${epoch}` : `session-end:${item.key}`;
130
139
  }
131
140
 
132
141
  function readLedger(home) {
@@ -227,6 +236,10 @@ function scheduleTerminalRetry(home, item, status, now, doctorFn) {
227
236
  item.server_state = status.state || "error";
228
237
  item.last_error = String(status.error || "graph job failed").slice(0, 240);
229
238
  item.generation = nextGeneration;
239
+ // Only a server that EXPLICITLY discarded the payload needs a fresh operation key. Every other
240
+ // failure re-sends the identical content and must reuse the stable content-addressed key so the
241
+ // server can dedup it — otherwise a retry duplicates the session and re-judges at full cost.
242
+ if (status.requires_replay === true) item.replay_epoch = (item.replay_epoch || 0) + 1;
230
243
  item.next_poll_at = null;
231
244
  item.updated_at = new Date(now()).toISOString();
232
245
  if (nextGeneration <= MAX_TERMINAL_RETRIES) {
@@ -254,6 +267,45 @@ function scheduleTerminalRetry(home, item, status, now, doctorFn) {
254
267
  );
255
268
  }
256
269
 
270
+ // A job that was ACCEPTED (the server issued a job_id) whose /ingest/status now 404s. Per the server
271
+ // contract a mid-poll 404 means the job most likely COMPLETED and was evicted from the ephemeral job
272
+ // store — the acceptance already gave us at-least-once delivery upstream. Because the idempotency key
273
+ // is now a stable content address, a bounded re-submit is guaranteed non-duplicating (the server
274
+ // dedups it on the same key), so we try ONE safe re-submit and then treat the content as delivered
275
+ // rather than re-judging forever at full Gemini cost or risking a duplicate. This path is reached
276
+ // ONLY for content that already reached the server; genuinely-never-accepted content has no job_id
277
+ // and never lands here, so at-least-once is preserved for it separately.
278
+ function handleVanishedJob(home, item, auth, now, doctorFn) {
279
+ const attempt = (item.not_found_count || 0) + 1;
280
+ item.not_found_count = attempt;
281
+ item.last_job_id = item.job_id || item.last_job_id || null;
282
+ item.user_id = item.user_id || auth.uid;
283
+ item.updated_at = new Date(now()).toISOString();
284
+ if (attempt > MAX_VANISHED_RESUBMITS) {
285
+ item.state = "delivered_assumed";
286
+ saveItem(home, item);
287
+ doctorFn(
288
+ home,
289
+ `job ${item.last_job_id} for key ${item.key} 404'd ${attempt}x after accept; ` +
290
+ "acceptance = at-least-once delivered, so recording as delivered and NOT re-sending",
291
+ );
292
+ markDelivered(home, item, now);
293
+ return "done";
294
+ }
295
+ item.job_id = null;
296
+ item.state = "resubmit_queued";
297
+ item.next_poll_at = null;
298
+ item.next_attempt_at = now() + retryDelayMs(attempt);
299
+ saveItem(home, item);
300
+ doctorFn(
301
+ home,
302
+ `job ${item.last_job_id} for key ${item.key} 404'd after accept ` +
303
+ `(attempt ${attempt}/${MAX_VANISHED_RESUBMITS}); one safe re-submit queued with ` +
304
+ `stable idempotency-key ${idempotencyKey(item)}`,
305
+ );
306
+ return "retry";
307
+ }
308
+
257
309
  async function pollAcceptedJob(home, item, auth, options, deadline) {
258
310
  const { api, fetchFn, doctorFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
259
311
  const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
@@ -278,8 +330,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
278
330
  return "pending";
279
331
  }
280
332
  if (response.status === 404) {
281
- scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now, doctorFn);
282
- return "retry";
333
+ return handleVanishedJob(home, item, auth, now, doctorFn);
283
334
  }
284
335
  if (!response.ok) {
285
336
  item.state = `status_http_${response.status}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.14",
3
+ "version": "4.0.15",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {