@agent-native/core 0.76.4 → 0.76.5

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.
@@ -24,7 +24,7 @@ import { createToolSearchEntry, TOOL_SEARCH_ACTION_NAME, } from "./tool-search.j
24
24
  import { getDefaultMaxIterations, normalizeMaxIterations, readAgentLoopSettings, } from "./loop-settings.js";
25
25
  import { isReasoningEffort, normalizeReasoningEffortForModel, } from "../shared/reasoning-effort.js";
26
26
  import { isAgentActionStopError } from "../action.js";
27
- import { writeLedgerEntry, readLedgerEntry, clearLedgerForThread, getCurrentTurnEventsForThread, insertRun, updateRunHeartbeat, updateRunStatusIfRunning, claimBackgroundRun, recordRunDiagnostic, RUN_DIAG_STAGE, } from "./run-store.js";
27
+ import { writeLedgerEntry, readLedgerEntry, clearLedgerForThread, getCurrentTurnEventsForThread, insertRun, updateRunHeartbeat, updateRunStatusIfRunning, claimBackgroundRun, readBackgroundRunClaim, recordRunDiagnostic, RUN_DIAG_STAGE, } from "./run-store.js";
28
28
  import { classifyToolCallJournal, findCompletedJournalEntry, } from "./tool-call-journal.js";
29
29
  import { preUploadAttachments } from "../file-upload/pre-upload-attachments.js";
30
30
  import { extensionIdFromPathname } from "../extensions/path.js";
@@ -38,6 +38,71 @@ import { maybeCompactThread, buildObservationalContext, hasObservationalMemory,
38
38
  // Register built-in engines on first import
39
39
  registerBuiltinEngines();
40
40
  export { PROVIDER_TO_ENV };
41
+ /**
42
+ * Grace window + poll interval for the foreground circuit-breaker that confirms
43
+ * a background worker actually CLAIMED a 202-dispatched run before recovering
44
+ * inline. The grace is long enough for a cold-start worker to win the claim
45
+ * (~1-2s typical) and short enough to recover quickly within the foreground's
46
+ * ~40s soft-timeout.
47
+ */
48
+ export const BACKGROUND_CLAIM_GRACE_MS = 8_000;
49
+ export const BACKGROUND_CLAIM_POLL_MS = 400;
50
+ /**
51
+ * Decide what the foreground should do after attempting a durable background
52
+ * dispatch. A Netlify async background function returns 202 the instant it
53
+ * ENQUEUES the invocation — that is NOT proof the worker executed. If the
54
+ * generated wrapper fails to import/hand off to the route, the worker never
55
+ * reaches `claimBackgroundRun` and the run is reaped as "worker never claimed".
56
+ *
57
+ * So after a successful dispatch we poll briefly for the worker to CLAIM the run:
58
+ * - claimed within grace → "stream" (subscribe to the worker)
59
+ * - dispatch failed OR no claim → recover inline by atomically claiming the
60
+ * run ourselves: if we win → "inline"; if a (delayed) worker already won
61
+ * it → "subscribe" (never double-run).
62
+ *
63
+ * Pure except for the injected `readClaim`/`claim`/`now`/`sleep` deps, so each
64
+ * branch is unit-testable.
65
+ */
66
+ export async function resolveBackgroundDispatchOutcome(opts) {
67
+ const now = opts.now ?? (() => Date.now());
68
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
69
+ if (opts.dispatched) {
70
+ const deadline = now() + opts.graceMs;
71
+ for (;;) {
72
+ const claim = await opts.readClaim(opts.runId).catch(() => null);
73
+ if (claim &&
74
+ ((claim.dispatchMode && claim.dispatchMode !== "background") ||
75
+ (claim.status && claim.status !== "running"))) {
76
+ return { action: "stream" };
77
+ }
78
+ if (now() >= deadline)
79
+ break;
80
+ await sleep(opts.pollIntervalMs);
81
+ }
82
+ }
83
+ // Dispatch fast-failed OR no worker claimed within grace → recover inline.
84
+ if (!opts.backgroundRowInserted) {
85
+ // No row to reconcile (insert failed / non-duplicate) — run a fresh inline
86
+ // turn; `startRun` inserts the row.
87
+ return { action: "inline", reason: "no-row" };
88
+ }
89
+ let claimedInline = false;
90
+ try {
91
+ claimedInline = await opts.claim(opts.runId);
92
+ }
93
+ catch {
94
+ claimedInline = false;
95
+ }
96
+ if (claimedInline) {
97
+ return {
98
+ action: "inline",
99
+ reason: opts.dispatched ? "worker-never-claimed" : "dispatch-failed",
100
+ };
101
+ }
102
+ // The atomic claim was lost: a (delayed) background worker already owns the
103
+ // run — subscribe to it, never run a second copy.
104
+ return { action: "subscribe" };
105
+ }
41
106
  const SAFE_BROWSER_TAB_ID_RE = /^[A-Za-z0-9_-]{1,96}$/;
42
107
  function normalizeBrowserTabId(value) {
43
108
  if (typeof value !== "string")
@@ -3375,7 +3440,30 @@ export function createProductionAgentHandler(options) {
3375
3440
  catch (err) {
3376
3441
  console.error("[agent-chat] background dispatch failed; falling back to inline:", err instanceof Error ? err.message : err);
3377
3442
  }
3378
- if (dispatched) {
3443
+ // ─── Circuit-breaker: a 202 only ENQUEUES the background invocation ─────
3444
+ // It is NOT proof the worker executed. If the generated background-function
3445
+ // wrapper fails to import `./main.mjs` or hand off to the Nitro
3446
+ // `_process-run` route, the worker never reaches `claimBackgroundRun`: the
3447
+ // row sits at `dispatch_mode='background'` until the reaper errors it
3448
+ // ("worker never claimed the run"). `resolveBackgroundDispatchOutcome`
3449
+ // polls briefly for the claim and decides:
3450
+ // - "stream": a worker claimed the run → subscribe to it.
3451
+ // - "subscribe": a (delayed) worker already owns it → subscribe, NEVER
3452
+ // run a second copy.
3453
+ // - "inline": dispatch failed OR no worker claimed within grace → we
3454
+ // atomically own the run; recover by running it inline so a
3455
+ // dead worker degrades to a working synchronous turn.
3456
+ const backgroundOutcome = await resolveBackgroundDispatchOutcome({
3457
+ dispatched,
3458
+ backgroundRowInserted,
3459
+ runId,
3460
+ graceMs: BACKGROUND_CLAIM_GRACE_MS,
3461
+ pollIntervalMs: BACKGROUND_CLAIM_POLL_MS,
3462
+ readClaim: readBackgroundRunClaim,
3463
+ claim: claimBackgroundRun,
3464
+ });
3465
+ if (backgroundOutcome.action === "stream" ||
3466
+ backgroundOutcome.action === "subscribe") {
3379
3467
  const stream = subscribeToRun(runId, 0);
3380
3468
  if (stream) {
3381
3469
  setResponseHeader(event, "Content-Type", "text/event-stream");
@@ -3384,62 +3472,30 @@ export function createProductionAgentHandler(options) {
3384
3472
  setResponseHeader(event, "X-Run-Id", runId);
3385
3473
  return stream;
3386
3474
  }
3387
- // Subscription failed even though dispatch landed — surface an error
3388
- // rather than silently running inline (the background worker is already
3389
- // processing this runId, so an inline second run would double-execute).
3475
+ // A background worker owns this run but we cannot subscribe — surface an
3476
+ // error rather than risk a double-run by falling through to inline.
3477
+ await updateRunStatusIfRunning(runId, "errored").catch(() => { });
3390
3478
  setResponseStatus(event, 500);
3391
- return { error: "Failed to subscribe to background run" };
3392
- }
3393
- // ─── Dispatch failed → degrade to a normal synchronous (inline) run ────
3394
- // `fireInternalDispatch` throws ONLY when the self-POST failed *fast*
3395
- // (rejected the connection, or returned a non-2xx within the ~250ms settle
3396
- // race). The `_process-run` route verifies the HMAC token and validates
3397
- // the body BEFORE it ever reaches the SQL atomic claim, so a fast throw
3398
- // means NO background worker claimed this run there is nothing to
3399
- // double-execute. Rather than break the chat with "Failed to dispatch
3400
- // background run", we run the turn inline (the same synchronous path the
3401
- // flag-off branch below takes), reusing the already-inserted run row.
3402
- //
3403
- // Safety against a (very improbable) delayed background delivery: claim the
3404
- // run atomically here via `claimBackgroundRun`, which flips the row's
3405
- // dispatch_mode `background background-processing` in one conditional
3406
- // UPDATE. If a delayed dispatch DID land and a worker already won the
3407
- // claim, our claim returns false and we must NOT run inline (that would
3408
- // double-execute) fall back to subscribing to the worker's run instead.
3409
- // The SQL atomic claim is the single source of truth for ownership, so at
3410
- // most one of {inline fallback, background worker} ever executes this run.
3411
- if (backgroundRowInserted) {
3412
- let claimedInline = false;
3413
- try {
3414
- claimedInline = await claimBackgroundRun(runId);
3415
- }
3416
- catch (err) {
3417
- console.error("[agent-chat] inline-fallback claim failed:", err instanceof Error ? err.message : err);
3418
- }
3419
- if (!claimedInline) {
3420
- // A background worker already owns this run (a delayed delivery landed
3421
- // after our fast throw). Stream its events instead of running a second
3422
- // copy. If we somehow can't subscribe, surface an error rather than
3423
- // risk a double-run.
3424
- const stream = subscribeToRun(runId, 0);
3425
- if (stream) {
3426
- setResponseHeader(event, "Content-Type", "text/event-stream");
3427
- setResponseHeader(event, "Cache-Control", "no-cache");
3428
- setResponseHeader(event, "Connection", "keep-alive");
3429
- setResponseHeader(event, "X-Run-Id", runId);
3430
- return stream;
3431
- }
3432
- await updateRunStatusIfRunning(runId, "errored").catch(() => { });
3433
- setResponseStatus(event, 500);
3434
- return { error: "Failed to dispatch background run" };
3435
- }
3436
- // We own the run. `startRun` (below) calls `insertRun` again, but its
3437
- // duplicate-PK collision is swallowed (`insertRun(...).catch(() => {})`),
3438
- // so the existing `background-processing` row is reused — no double row.
3479
+ return {
3480
+ error: backgroundOutcome.action === "stream"
3481
+ ? "Failed to subscribe to background run"
3482
+ : "Failed to dispatch background run",
3483
+ };
3484
+ }
3485
+ // backgroundOutcome.action === "inline": we atomically own the run (or
3486
+ // there was no row to reconcile), so falling through to the inline
3487
+ // `startRun` path below cannot double-execute. `startRun` calls `insertRun`
3488
+ // again, but its duplicate-PK collision is swallowed, so an existing
3489
+ // `background-processing` row is reused no double row.
3490
+ if (backgroundOutcome.reason === "worker-never-claimed") {
3491
+ // The async 202 landed but no worker claimed within grace — the generated
3492
+ // wrapper never reached the route. Record it so the failure is visible on
3493
+ // the run even though the user still gets a working (inline) turn.
3494
+ console.error("[agent-chat] background worker did not claim the 202-dispatched run " +
3495
+ "within grace; recovering inline:", runId);
3496
+ await recordRunDiagnostic(runId, RUN_DIAG_STAGE.foregroundInlineRecovery, "202 dispatched but no worker claimed within the foreground grace window").catch(() => { });
3439
3497
  }
3440
- // Fall through to the inline `startRun` path below (the same one the
3441
- // flag-off branch uses). If the row was never inserted, `startRun` inserts
3442
- // it fresh; if it was, the claim above made us the sole owner.
3498
+ // Fall through to the inline `startRun` path below.
3443
3499
  }
3444
3500
  const trackedProgressOwner = trackInRunsTray === true && ownerEmail ? ownerEmail : null;
3445
3501
  const trackedProgressRunId = trackedProgressOwner