@agent-native/core 0.168.13 → 0.169.1

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.
Files changed (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/bigquery-table-info.ts +3 -2
  3. package/corpus/templates/analytics/actions/search-bigquery-schema.ts +130 -4
  4. package/corpus/templates/analytics/agent-native.config.ts +1 -0
  5. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +36 -23
  6. package/corpus/templates/analytics/app/lib/dashboard-visibility.ts +41 -0
  7. package/corpus/templates/analytics/app/pages/DashboardOverview.tsx +34 -9
  8. package/corpus/templates/analytics/changelog/2026-08-21-dashboard-filters-keep-other-users-dashboards-out-of-mine.md +6 -0
  9. package/corpus/templates/analytics/server/lib/real-data-actions.ts +14 -3
  10. package/corpus/templates/analytics/server/plugins/agent-chat.ts +33 -3
  11. package/corpus/templates/brain/server/lib/brain.ts +27 -2
  12. package/corpus/templates/clips/actions/stop-meeting-recording.ts +87 -6
  13. package/corpus/templates/clips/app/components/library/search-bar.tsx +1 -1
  14. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +3 -4
  15. package/corpus/templates/design/app/components/design/EditPanel.tsx +15 -47
  16. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -3
  17. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +0 -7
  18. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +2 -4
  19. package/corpus/templates/design/app/i18n-data.ts +10 -10
  20. package/corpus/templates/design/app/pages/DesignEditor.tsx +22 -22
  21. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +39 -8
  22. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  23. package/corpus/templates/design/app/pages/Index.tsx +1 -1
  24. package/corpus/templates/design/app/pages/design-editor/preview-navigation.ts +25 -0
  25. package/corpus/templates/mail/app/hooks/use-emails.ts +13 -3
  26. package/dist/agent/engine/credential-errors.d.ts +1 -1
  27. package/dist/agent/engine/credential-errors.js +2 -2
  28. package/dist/agent/engine/first-event-timeout.d.ts +8 -0
  29. package/dist/agent/engine/first-event-timeout.js +8 -0
  30. package/dist/agent/production-agent.d.ts +0 -30
  31. package/dist/agent/production-agent.js +17 -38
  32. package/dist/agent/run-loop-with-resume.d.ts +38 -25
  33. package/dist/agent/run-loop-with-resume.js +140 -55
  34. package/dist/agent/run-manager.d.ts +83 -68
  35. package/dist/agent/run-manager.js +280 -94
  36. package/dist/agent/run-store.d.ts +31 -0
  37. package/dist/agent/run-store.js +42 -12
  38. package/dist/app-config/agent.d.ts +2 -0
  39. package/dist/app-config/agent.js +33 -0
  40. package/dist/app-config/run-lifecycle-invariants.d.ts +248 -0
  41. package/dist/app-config/run-lifecycle-invariants.js +342 -0
  42. package/dist/app-config/schema.d.ts +2 -0
  43. package/dist/app-config/store.js +9 -1
  44. package/dist/client/AssistantChat.js +47 -15
  45. package/dist/client/ConnectBuilderCard.d.ts +3 -1
  46. package/dist/client/ConnectBuilderCard.js +6 -3
  47. package/dist/client/agent-chat-adapter.d.ts +0 -2
  48. package/dist/client/agent-chat-adapter.js +7 -23
  49. package/dist/client/chat/message-components.d.ts +7 -0
  50. package/dist/client/chat/message-components.js +1 -1
  51. package/dist/client/chat/run-recovery.js +5 -2
  52. package/dist/client/chat/tool-call-display.js +9 -3
  53. package/dist/client/error-format.js +12 -0
  54. package/dist/client/navigation/index.d.ts +1 -0
  55. package/dist/client/navigation/index.js +1 -0
  56. package/dist/client/route-state.js +6 -0
  57. package/dist/client/workspace-app-navigation.d.ts +10 -0
  58. package/dist/client/workspace-app-navigation.js +38 -0
  59. package/dist/collab/struct-routes.d.ts +1 -1
  60. package/dist/deploy/build.d.ts +13 -0
  61. package/dist/deploy/build.js +51 -0
  62. package/dist/jobs/background-automation-runner.d.ts +25 -0
  63. package/dist/jobs/background-automation-runner.js +104 -21
  64. package/dist/jobs/run-history.d.ts +7 -1
  65. package/dist/jobs/run-history.js +57 -14
  66. package/dist/localization/core-messages/ar-SA.js +1 -1
  67. package/dist/localization/core-messages/de-DE.js +1 -1
  68. package/dist/localization/core-messages/en-US.d.ts +1 -1
  69. package/dist/localization/core-messages/en-US.js +1 -1
  70. package/dist/localization/core-messages/es-ES.js +1 -1
  71. package/dist/localization/core-messages/fr-FR.js +1 -1
  72. package/dist/localization/core-messages/hi-IN.js +1 -1
  73. package/dist/localization/core-messages/ja-JP.js +1 -1
  74. package/dist/localization/core-messages/ko-KR.js +1 -1
  75. package/dist/localization/core-messages/pt-BR.js +1 -1
  76. package/dist/localization/core-messages/zh-CN.js +1 -1
  77. package/dist/localization/core-messages/zh-TW.js +1 -1
  78. package/dist/localization/core-messages.d.ts +1 -1
  79. package/dist/notifications/routes.d.ts +1 -1
  80. package/dist/observability/routes.d.ts +3 -3
  81. package/dist/observability/traces.d.ts +13 -0
  82. package/dist/observability/traces.js +369 -317
  83. package/dist/progress/routes.d.ts +1 -1
  84. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  85. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  86. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  87. package/dist/resources/handlers.d.ts +1 -1
  88. package/dist/scripts/db/check-scoping.js +9 -6
  89. package/dist/secrets/routes.d.ts +6 -6
  90. package/dist/server/agent-chat-plugin.js +2 -4
  91. package/dist/server/builder-browser.d.ts +4 -0
  92. package/dist/server/builder-browser.js +23 -1
  93. package/dist/server/core-routes-plugin.js +13 -1
  94. package/dist/server/transcribe-voice.d.ts +1 -1
  95. package/package.json +3 -3
  96. /package/corpus/templates/dispatch/app/routes/{apps.$appId.tsx → apps.$appId.$.tsx} +0 -0
@@ -1,9 +1,10 @@
1
1
  import { getAppConfig } from "../app-config/index.js";
2
+ import { BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS, BACKGROUND_SOFT_TIMEOUT_CEILING_MS, RUN_NO_PROGRESS_HARD_TIMEOUT_MS, } from "../app-config/run-lifecycle-invariants.js";
2
3
  import { captureError } from "../server/capture-error.js";
3
4
  import { isLlmCredentialError, LLM_MISSING_CREDENTIALS_ERROR_CODE, LLM_MISSING_CREDENTIALS_MESSAGE, } from "./engine/credential-errors.js";
4
5
  import { classifyTerminalErrorCode, describeErrorWithCauses, isProviderConnectionError, } from "./engine/error-detail.js";
5
6
  import { EngineError } from "./engine/types.js";
6
- import { insertRun, insertRunEvent, updateRunStatusIfRunning, markRunAborted, getRunAbortState, getRunStatus, getRunEventsSince, getRunById, getRunByThread, getRunTurnRef, markTurnAborted, cleanupOldRuns, updateRunHeartbeat, bumpRunProgress, setRunInFlightMarker, reapIfStale, reapUnclaimedBackgroundRun, shouldRedispatchUnclaimedBackgroundRun, reconcileTerminalRunFromEvents, ensureTerminalRunEvent, getLastTerminalRunEvent, resolveErroredRunTerminalEvent, setRunError, setRunTerminalReason, persistRunCheckpointEvent, terminalEventForAbortReason, } from "./run-store.js";
7
+ import { insertRun, insertRunEvent, updateRunStatusIfRunning, markRunAborted, getRunAbortState, getRunStatus, getRunEventsSince, getRunById, getRunByThread, getRunTurnRef, markTurnAborted, cleanupOldRuns, updateRunHeartbeat, bumpRunProgress, setRunInFlightMarker, reapIfStale, reapUnclaimedBackgroundRun, shouldRedispatchUnclaimedBackgroundRun, reconcileTerminalRunFromEvents, ensureTerminalRunEvent, getLastTerminalRunEvent, resolveErroredRunTerminalEvent, setRunError, setRunTerminalReason, persistRunCheckpointEvent, recordRunDiagnostic, RUN_DIAG_STAGE, terminalEventForAbortReason, } from "./run-store.js";
7
8
  import { isContinuationTerminalReason } from "./types.js";
8
9
  const activeRuns = new Map();
9
10
  const threadToRun = new Map();
@@ -43,68 +44,7 @@ export const DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS = 40_000;
43
44
  * function (no ~60s wall, 15-min budget) and therefore can safely outlast 40s.
44
45
  */
45
46
  export const HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000;
46
- /**
47
- * Hard ceiling for the soft timeout when a run executes inside a Netlify
48
- * background function (any deployed function whose name ends in `-background`).
49
- * Background functions return 202 immediately and run detached for up to 15
50
- * minutes, so the ~60s synchronous function wall that 40s defends against does
51
- * NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
52
- * kill to abort, persist the partial turn, write the terminal event, and (for
53
- * the rare >13-min turn) self-fire another background continuation.
54
- *
55
- * This ceiling is used ONLY when a caller explicitly opts in with
56
- * `backgroundFunction: true`. It does not change the foreground/interactive
57
- * ceiling and does not fire unless the durable-background path dispatched the
58
- * run into a background function. Per the design doc Guardrail, the 40s
59
- * interactive clamp stays correct for every non-background run.
60
- */
61
- export const BACKGROUND_SOFT_TIMEOUT_CEILING_MS = 13 * 60_000; // 780_000
62
- /**
63
- * Default soft-timeout budget for a background-function run when the caller
64
- * does not pass an explicit `softTimeoutMs`. Same value as the ceiling — we
65
- * want a background turn to use nearly its whole 15-min budget before handing
66
- * off to a chained background continuation.
67
- */
68
- export const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS = BACKGROUND_SOFT_TIMEOUT_CEILING_MS;
69
- /**
70
- * AUTHORITATIVE no-progress backstop for a run, enforced by the run manager
71
- * itself (timer-driven, independent of any layer below).
72
- *
73
- * The finer-grained watchdogs inside the agent loop (model-stream and
74
- * action-preparation no-progress, both 90s) only guard the model event stream
75
- * — a stall in any segment OUTSIDE that guarded loop (engine-call
76
- * establishment, worker setup between continuation chunks, a wedged transport
77
- * that emits keepalives while the loop never runs) previously hung forever
78
- * with the client watching keepalives. This backstop covers every segment by
79
- * construction: if no REAL progress event (see `shouldBumpProgressForEvent`;
80
- * keepalives and zero-byte prep activity don't count) lands for this long —
81
- * and no unit of work is in flight (see `inFlightWorkDelta`: tool calls,
82
- * cross-app calls, and the model stream all legitimately emit nothing for
83
- * minutes and each carry a bound of their own) — the run manager emits
84
- * `auto_continue { reason: "no_progress" }` and aborts the chunk, exactly
85
- * like the soft timeout, so the normal continuation machinery recovers it.
86
- *
87
- * Being numerically larger than the in-loop watchdogs is NOT what keeps this
88
- * from killing a healthy run, and treating it that way is what made it do so:
89
- * this clock and the loop's `lastModelStreamProgressAt` measure DIFFERENT
90
- * events. An extended-thinking phase bumps the inner clock on every engine
91
- * frame while forwarding nothing, so the inner watchdog correctly stayed quiet
92
- * and this one saw pure silence — runs whose worst gap crossed 150s died while
93
- * still streaming, some by a single second. Ordering between two clocks only
94
- * means something when they watch the same events; suspending on in-flight
95
- * work is what actually makes the two agree.
96
- *
97
- * This is now only the CEILING, not the value: `resolveRunNoProgressTimeoutMs`
98
- * clamps the foreground backstop to a fraction of the chunk's soft timeout
99
- * (~30s at a 40s chunk), which is BELOW the 90s in-loop watchdogs rather than
100
- * above them. That ordering is deliberate — the in-loop watchdogs could never
101
- * fire inside a hosted foreground chunk anyway, since the serverless wall
102
- * (~57-59s) arrives first. Proven durable-background chunks keep the full
103
- * `DEFAULT_BACKGROUND_NO_PROGRESS_TIMEOUT_MS` so large outputs can use the
104
- * background budget. Only armed when a soft-timeout regime is active (hosted
105
- * runs); local dev stays unbounded.
106
- */
107
- export const RUN_NO_PROGRESS_HARD_TIMEOUT_MS = 150_000;
47
+ // 780_000
108
48
  /**
109
49
  * Default no-progress window for a run executing inside a proven durable
110
50
  * background function. A background worker that is heartbeating but has no
@@ -157,17 +97,38 @@ export function resolveRunNoProgressTimeoutMs(params) {
157
97
  const explicit = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0
158
98
  ? value
159
99
  : undefined;
100
+ // Per-call override wins, then configuration, then the shipped default —
101
+ // the same ladder `resolveRunSoftTimeoutMs` implements.
102
+ const configured = getAppConfig().agent;
103
+ // Largest window that can still fire inside the chunk it is guarding. A
104
+ // backstop at or above the chunk budget is not a loose backstop, it is an
105
+ // absent one.
106
+ const budgetCeilingMs = Math.floor(softTimeoutMs * FOREGROUND_NO_PROGRESS_SOFT_TIMEOUT_FRACTION);
160
107
  if (backgroundFunction === true) {
108
+ // The background override exists to RAISE this window, so it is honoured
109
+ // as given — a caller asking for a longer one is making an explicit choice
110
+ // and stays bounded by its own hard abort.
161
111
  const override = explicit(params.backgroundOverrideMs) ?? explicit(params.overrideMs);
162
- if (override !== undefined)
163
- return override;
164
- return softTimeoutMs > 0 ? DEFAULT_BACKGROUND_NO_PROGRESS_TIMEOUT_MS : 0;
112
+ if (!(softTimeoutMs > 0))
113
+ return override ?? 0;
114
+ // Honoured as given, because this override exists to RAISE the window —
115
+ // but still bounded by the chunk it guards. A backstop at or above the
116
+ // chunk budget is not a longer backstop, it is an absent one, so a caller
117
+ // asking for one was disabling recovery without meaning to.
118
+ if (override !== undefined) {
119
+ return override === 0 ? 0 : Math.min(override, budgetCeilingMs);
120
+ }
121
+ // Clamped: returned flat, a deployment that lowered the GLOBAL
122
+ // `runSoftTimeoutMs` shrank the chunk without shrinking the backstop, and
123
+ // the backstop silently stopped being reachable. Nothing changes at the
124
+ // shipped values — min(150s, 0.75 x 13min) is still 150s.
125
+ return Math.min(configured.backgroundNoProgressTimeoutMs, budgetCeilingMs);
165
126
  }
166
127
  const override = explicit(params.overrideMs);
167
128
  // Local dev keeps runs unbounded unless a caller explicitly asks otherwise.
168
129
  if (!(softTimeoutMs > 0))
169
130
  return override ?? 0;
170
- const ceiling = Math.min(RUN_NO_PROGRESS_HARD_TIMEOUT_MS, Math.floor(softTimeoutMs * FOREGROUND_NO_PROGRESS_SOFT_TIMEOUT_FRACTION));
131
+ const ceiling = Math.min(RUN_NO_PROGRESS_HARD_TIMEOUT_MS, budgetCeilingMs);
171
132
  if (override === undefined)
172
133
  return ceiling;
173
134
  return override === 0 ? 0 : Math.min(override, ceiling);
@@ -442,7 +403,7 @@ export function resolveRunSoftTimeoutMs(overrideMs, options) {
442
403
  // A background-function run uses the full background budget by default; the
443
404
  // foreground default (40s) is unchanged.
444
405
  if (background) {
445
- return hosted ? DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS : 0;
406
+ return hosted ? BACKGROUND_SOFT_TIMEOUT_CEILING_MS : 0;
446
407
  }
447
408
  return options?.useHostedDefault && hosted
448
409
  ? DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS
@@ -456,6 +417,37 @@ export function resolveErroredRunRetentionMs() {
456
417
  return (getAppConfig().agent.erroredRunRetentionMs ??
457
418
  DEFAULT_ERRORED_RUN_RETENTION_MS);
458
419
  }
420
+ /**
421
+ * Hard abort for one in-process background automation run.
422
+ *
423
+ * This is the host's real function budget for scheduled work, which is exactly
424
+ * the kind of number that differs between deployments — so it is configuration,
425
+ * not a module constant nobody outside this package can see.
426
+ */
427
+ export function resolveBackgroundRunHardTimeoutMs() {
428
+ return getAppConfig().agent.backgroundRunHardTimeoutMs;
429
+ }
430
+ /**
431
+ * Chunk budget for a background automation, derived from the runner's OWN hard
432
+ * abort rather than from the durable-chat background ceiling.
433
+ *
434
+ * The shipped build took the 13-minute chat ceiling for a path whose process is
435
+ * killed at 10 minutes, which made the recoverable soft-timeout boundary dead
436
+ * code and left the terminal no-progress backstop as the only boundary an
437
+ * automation could ever reach. Deriving from the hard abort keeps
438
+ * `soft timeout < hard abort` true by construction; the invariant check asserts
439
+ * the headroom still fits.
440
+ */
441
+ export function resolveBackgroundAutomationSoftTimeoutMs(overrideMs) {
442
+ const budget = Math.max(1_000, resolveBackgroundRunHardTimeoutMs() -
443
+ BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS);
444
+ const resolved = resolveRunSoftTimeoutMs(overrideMs, {
445
+ useHostedDefault: true,
446
+ backgroundFunction: true,
447
+ });
448
+ // `0` means "no soft-timeout regime" (local dev) and is never clamped up.
449
+ return resolved > 0 ? Math.min(resolved, budget) : 0;
450
+ }
459
451
  function isTerminalRunEvent(event) {
460
452
  return (event.type === "done" ||
461
453
  event.type === "error" ||
@@ -519,6 +511,49 @@ function terminalReasonForRun(finalStatus, terminalEvent, abortReason, completio
519
511
  return "done";
520
512
  }
521
513
  const MAX_RUN_ERROR_DETAIL_LENGTH = 500;
514
+ /**
515
+ * One counter per chunk boundary, dimensioned by reason and by whether the
516
+ * turn continued past it.
517
+ *
518
+ * Boundaries are normal; boundaries that TERMINATE a run are not, and before
519
+ * this the two were indistinguishable from outside — which is how a 37%
520
+ * automation failure rate stayed invisible. The ratio between `recovered:true`
521
+ * and `recovered:false` is the number that belongs on a dashboard.
522
+ *
523
+ * Same swallow-everything mechanism as `emitRunTerminalTrackingEvent`: a
524
+ * missing or broken tracking provider can never affect the run.
525
+ */
526
+ function emitRunBoundaryTrackingEvent(args) {
527
+ const properties = {
528
+ source: "agent_run_manager",
529
+ run_id: args.runId,
530
+ thread_id: args.threadId,
531
+ reason: args.reason,
532
+ recovered: args.recovered,
533
+ boundary_index: args.boundaryIndex,
534
+ dispatch_mode: args.dispatchMode,
535
+ model: args.model,
536
+ engine: args.engineName,
537
+ };
538
+ for (const key of Object.keys(properties)) {
539
+ if (properties[key] === undefined)
540
+ delete properties[key];
541
+ }
542
+ try {
543
+ void Promise.all([
544
+ import("../tracking/registry.js"),
545
+ import("../observability/tracking-identity.js"),
546
+ ])
547
+ .then(([{ track }, { trackingIdentityProperties }]) => {
548
+ track("agent_run_boundary", { ...properties, ...trackingIdentityProperties() }, { userId: args.userId });
549
+ })
550
+ .catch(() => { });
551
+ // coercion-ok: a boundary counter must never affect the run it counts.
552
+ }
553
+ catch {
554
+ // Tracking must never affect the agent run or its persisted status.
555
+ }
556
+ }
522
557
  /**
523
558
  * Emit one analytics event per terminal run — the seam that makes cutoffs
524
559
  * (`run_budget_exhausted`, `loop_limit`, aborts, `truncated` continuation
@@ -547,7 +582,7 @@ function emitRunTerminalTrackingEvent(args) {
547
582
  ? `${args.errorDetail.slice(0, MAX_RUN_ERROR_DETAIL_LENGTH)}…`
548
583
  : args.errorDetail
549
584
  : undefined,
550
- dispatch_mode: args.dispatchMode ?? "foreground",
585
+ dispatch_mode: args.dispatchMode,
551
586
  abort_reason: args.abortReason,
552
587
  duration_ms: args.durationMs,
553
588
  model: args.model,
@@ -618,6 +653,66 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
618
653
  abortRun(existingRunId);
619
654
  }
620
655
  const abort = new AbortController();
656
+ // Chunk-scoped controller, only for a runFn that recovers boundaries in this
657
+ // invocation. `abort` stays the turn: a Stop, the cross-isolate abort check,
658
+ // and a caller's hard timeout all still end the run through it, and it always
659
+ // ends whichever chunk is executing under it.
660
+ const recoverChunkBoundaries = options?.recoverChunkBoundaries === true;
661
+ let chunkAbort = recoverChunkBoundaries
662
+ ? new AbortController()
663
+ : null;
664
+ let chunkBoundaryReason = null;
665
+ let recoveredChunkBoundaries = 0;
666
+ /** A boundary that has been reached but not yet proven recovered. */
667
+ let pendingBoundary = null;
668
+ /**
669
+ * Resolve the outstanding boundary once its fate is known: `true` when the
670
+ * caller actually opened another round, `false` when the run ended first.
671
+ */
672
+ const settleBoundary = (recovered) => {
673
+ const boundary = pendingBoundary;
674
+ if (!boundary)
675
+ return;
676
+ pendingBoundary = null;
677
+ recordRunBoundaryDiagnostic(boundary.reason, boundary.diagnostic, recovered ? "recovered" : "terminal");
678
+ emitRunBoundaryTrackingEvent({
679
+ runId,
680
+ threadId,
681
+ reason: boundary.reason,
682
+ recovered,
683
+ boundaryIndex: boundary.index,
684
+ dispatchMode: options?.dispatchMode,
685
+ model: options?.model,
686
+ engineName: options?.engineName,
687
+ userId: options?.userId,
688
+ });
689
+ };
690
+ if (chunkAbort) {
691
+ abort.signal.addEventListener("abort", () => {
692
+ chunkAbort?.abort(abort.signal.reason);
693
+ });
694
+ }
695
+ const runControl = {
696
+ get turnSignal() {
697
+ return abort.signal;
698
+ },
699
+ get chunkSignal() {
700
+ return chunkAbort?.signal ?? abort.signal;
701
+ },
702
+ chunkBoundaryReason: () => chunkBoundaryReason,
703
+ beginChunk: () => {
704
+ if (abort.signal.aborted || !chunkAbort)
705
+ return abort.signal;
706
+ settleBoundary(true);
707
+ chunkBoundaryReason = null;
708
+ chunkAbort = new AbortController();
709
+ // The boundary is behind us; the silence clock restarts with the chunk,
710
+ // or the backstop fires again on the elapsed time of the chunk it just
711
+ // ended and every recovery round dies instantly.
712
+ lastRealProgressAt = Date.now();
713
+ return chunkAbort.signal;
714
+ },
715
+ };
621
716
  let softTimedOut = false;
622
717
  let resolveFinalized = () => { };
623
718
  let rejectFinalized = () => { };
@@ -952,29 +1047,114 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
952
1047
  checkpointAbortInFlight = false;
953
1048
  }
954
1049
  };
955
- const checkNoProgressBackstop = () => {
956
- if (noProgressTimeoutMs <= 0)
957
- return;
1050
+ /**
1051
+ * Localise the stall. `RUN_DIAG_STAGE`/`recordRunDiagnostic` existed for
1052
+ * exactly this and were wired only into the `_process-run` HTTP path, which
1053
+ * is why a 37%-failure-rate backstop could not name the segment it killed.
1054
+ */
1055
+ const recordRunBoundaryDiagnostic = (reason, diagnostic, disposition) => {
1056
+ void recordRunDiagnostic(runId, RUN_DIAG_STAGE.runBoundaryReached, JSON.stringify({
1057
+ reason,
1058
+ disposition,
1059
+ silentForMs: diagnostic.silentForMs,
1060
+ lastEventType: diagnostic.lastEventType,
1061
+ inFlightWorkCount,
1062
+ eventCount: run.events.length,
1063
+ elapsedMs: Date.now() - run.startedAt,
1064
+ })).catch(() => { });
1065
+ };
1066
+ /**
1067
+ * Reach a server-owned chunk boundary.
1068
+ *
1069
+ * Two outcomes, and the difference is the whole point: a runFn that recovers
1070
+ * boundaries in this invocation gets its CHUNK aborted and keeps the turn;
1071
+ * every other caller gets the turn ended so a fresh invocation can continue
1072
+ * it. The recoverable case deliberately does NOT emit `auto_continue` or
1073
+ * write a checkpoint terminal event — both describe a turn that stopped here,
1074
+ * and this one has not: the checkpoint row is written at a reserved seq that
1075
+ * outranks the real `done` this run is still going to emit, so persisting it
1076
+ * would relabel a recovered run as truncated.
1077
+ */
1078
+ const reachRunBoundary = (reason, diagnostic = {}) => {
958
1079
  if (run.status !== "running" || abort.signal.aborted)
959
1080
  return;
960
- if (inFlightWorkCount > 0)
961
- return;
962
- if (Date.now() - lastRealProgressAt < noProgressTimeoutMs)
1081
+ if (chunkAbort) {
1082
+ if (chunkAbort.signal.aborted)
1083
+ return;
1084
+ recoveredChunkBoundaries += 1;
1085
+ console.warn(`[run-manager] chunk boundary (${reason}) — recovering in-invocation`, runId, diagnostic);
1086
+ // NOT counted as recovered yet. `recovered` is the whole point of this
1087
+ // counter — it answers "is the recovery working?" — so it has to mean a
1088
+ // round actually started, not that one was invited to. The caller can
1089
+ // still exhaust its budget or fail to build continuation context, and
1090
+ // counting the invitation would over-report recovery, which is the
1091
+ // direction that hides the failure.
1092
+ pendingBoundary = { reason, diagnostic, index: recoveredChunkBoundaries };
1093
+ chunkBoundaryReason = reason;
1094
+ chunkAbort.abort(reason);
963
1095
  return;
964
- console.error(`[run-manager] no real progress for ${noProgressTimeoutMs}ms with no tool ` +
965
- `or model stream in flight — ` +
966
- `checkpointing run for continuation`, runId);
1096
+ }
967
1097
  // Mirror the soft-timeout semantics exactly: the chunk completes (not
968
1098
  // aborts) at an auto_continue boundary, so the continuation machinery —
969
1099
  // server-chained for background workers, client-driven for foreground —
970
1100
  // recovers the turn.
971
1101
  softTimedOut = true;
972
- const event = {
973
- type: "auto_continue",
974
- reason: "no_progress",
975
- };
1102
+ recordRunBoundaryDiagnostic(reason, diagnostic, "terminal");
1103
+ emitRunBoundaryTrackingEvent({
1104
+ runId,
1105
+ threadId,
1106
+ reason,
1107
+ recovered: false,
1108
+ boundaryIndex: recoveredChunkBoundaries + 1,
1109
+ dispatchMode: options?.dispatchMode,
1110
+ model: options?.model,
1111
+ engineName: options?.engineName,
1112
+ userId: options?.userId,
1113
+ });
1114
+ const event = { type: "auto_continue", reason };
976
1115
  send(event);
977
- void checkpointRunBoundary(event, "no_progress");
1116
+ void checkpointRunBoundary(event, reason);
1117
+ };
1118
+ const checkNoProgressBackstop = () => {
1119
+ if (noProgressTimeoutMs <= 0)
1120
+ return;
1121
+ if (run.status !== "running" || abort.signal.aborted)
1122
+ return;
1123
+ if (inFlightWorkCount > 0)
1124
+ return;
1125
+ const silentForMs = Date.now() - lastRealProgressAt;
1126
+ if (silentForMs < noProgressTimeoutMs)
1127
+ return;
1128
+ const lastEventType = run.events.at(-1)?.event.type;
1129
+ if (!chunkAbort) {
1130
+ // This backstop ends the TURN here; whether a successor invocation picks
1131
+ // it up is decided later and elsewhere, so from this vantage it is a run
1132
+ // that died on silence. It reached production for two releases as one
1133
+ // console line nobody read. A boundary recovered in THIS invocation stays
1134
+ // a log line — that distinction is the point.
1135
+ console.error(`[run-manager] no real progress for ${noProgressTimeoutMs}ms with no tool ` +
1136
+ `or model stream in flight — ` +
1137
+ `checkpointing run for continuation`, runId);
1138
+ captureError(new Error(`Agent run checkpointed after ${silentForMs}ms of silence (no_progress)`), {
1139
+ route: "/_agent-native/agent-chat",
1140
+ aiTraceId: runId,
1141
+ tags: {
1142
+ source: "agent-run-manager",
1143
+ phase: "no-progress-backstop",
1144
+ terminalReason: "no_progress",
1145
+ lastEventType,
1146
+ },
1147
+ extra: {
1148
+ runId,
1149
+ threadId,
1150
+ silentForMs,
1151
+ noProgressTimeoutMs,
1152
+ lastEventType,
1153
+ eventCount: run.events.length,
1154
+ },
1155
+ });
1156
+ }
1157
+ reachRunBoundary("no_progress", { silentForMs, lastEventType });
978
1158
  };
979
1159
  // Periodic SQL abort check interval (for cross-isolate abort on Workers).
980
1160
  // Also self-aborts when our row is no longer status='running' — catches the
@@ -1096,17 +1276,17 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1096
1276
  overrideMs: options?.noProgressTimeoutMs,
1097
1277
  backgroundOverrideMs: options?.backgroundNoProgressTimeoutMs,
1098
1278
  });
1099
- const softTimeoutTimer = softTimeoutMs > 0
1279
+ // Not armed for a runFn that recovers boundaries in this invocation. That
1280
+ // runFn already races the SAME wall with its own per-round timer, budgeted
1281
+ // against cumulative elapsed time — so this timer fires at the moment the
1282
+ // wrapper has nothing left to continue with, producing a boundary that is
1283
+ // recoverable in name only and burning the tail of the budget on nothing.
1284
+ // One wall, one clock; the caller's hard abort still backstops it.
1285
+ const softTimeoutTimer = softTimeoutMs > 0 && !recoverChunkBoundaries
1100
1286
  ? setTimeout(() => {
1101
- if (run.status !== "running" || abort.signal.aborted)
1102
- return;
1103
- softTimedOut = true;
1104
- const event = {
1105
- type: "auto_continue",
1106
- reason: "run_timeout",
1107
- };
1108
- send(event);
1109
- void checkpointRunBoundary(event, "run_timeout");
1287
+ reachRunBoundary("run_timeout", {
1288
+ lastEventType: run.events.at(-1)?.event.type,
1289
+ });
1110
1290
  }, softTimeoutMs)
1111
1291
  : null;
1112
1292
  let pendingTerminalEvent = null;
@@ -1213,8 +1393,13 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1213
1393
  emitRunEvent(runEvent);
1214
1394
  };
1215
1395
  // Run in background — intentionally detached from any HTTP connection
1216
- const runPromise = runFn(send, abort.signal)
1396
+ const runPromise = runFn(send, runControl.chunkSignal, runControl)
1217
1397
  .then(() => {
1398
+ // Settled inside the existing handlers rather than a `.finally()`: that
1399
+ // would add a microtask tick to a chain whose ordering callers depend on.
1400
+ // The runFn is done and never opened another round, so an outstanding
1401
+ // boundary ended the run rather than being recovered from.
1402
+ settleBoundary(false);
1218
1403
  if (abort.signal.aborted) {
1219
1404
  run.status = softTimedOut ? "completed" : "aborted";
1220
1405
  return;
@@ -1222,6 +1407,7 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1222
1407
  run.status = "completed";
1223
1408
  })
1224
1409
  .catch((err) => {
1410
+ settleBoundary(false);
1225
1411
  // Don't surface abort errors — the run was intentionally stopped
1226
1412
  if (abort.signal.aborted) {
1227
1413
  run.status = softTimedOut ? "completed" : "aborted";
@@ -148,6 +148,29 @@ export declare const UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS: number;
148
148
  export declare const UNCLAIMED_BACKGROUND_RUN_FAST_SWEEP_MS = 20000;
149
149
  /** Test seam — the probe cache is module state, so suites must clear it. */
150
150
  export declare function __resetNoRunningRunsProbeForTests(): void;
151
+ /**
152
+ * Ceiling on run ROWS for one logical turn — the number the continuation-chain
153
+ * guard and stale-run recovery must agree on.
154
+ *
155
+ * This was a literal `25` here plus `MAX_BACKGROUND_RUN_CONTINUATIONS + 5` in
156
+ * production-agent.ts, kept in step by a comment asking the next editor to
157
+ * remember, because importing back from this file would have been circular.
158
+ * It no longer needs to be: the base value is configuration, and `app-config`
159
+ * imports no agent code, so both sites can read the same resolver.
160
+ */
161
+ export declare function resolveTurnRunLedgerBudget(): number;
162
+ /**
163
+ * True when a turn holding `turnRunCount` run rows must not be given another.
164
+ *
165
+ * A predicate rather than a number the callers compare themselves, because both
166
+ * call sites had `turnRunCount > budget` and both were off by one: the current
167
+ * run's row is already inserted when they check, and the successor's row is
168
+ * inserted after — so at equality they permitted a row past the documented
169
+ * ceiling. Two sites, one comparison, no way for them to disagree about the
170
+ * boundary again. That is the third time in this area that one number had two
171
+ * spellings.
172
+ */
173
+ export declare function turnRunLedgerExhausted(turnRunCount: number): boolean;
151
174
  /**
152
175
  * Maximum time the stale reapers (`reapIfStale`, `reapAllStaleRuns`,
153
176
  * `cleanupOldRuns`'s heartbeat-stale pass) will suspend reaping a "running"
@@ -449,6 +472,14 @@ export declare const RUN_DIAG_STAGE: {
449
472
  * the per-turn budget is exhausted). See `attemptStaleRunRecovery`.
450
473
  */
451
474
  readonly staleRunRecoveryAttempted: "stale_run_recovery_attempted";
475
+ /**
476
+ * The run manager reached a server-owned chunk boundary (`no_progress` or
477
+ * `run_timeout`). Detail carries the reason, whether it was recovered in the
478
+ * same invocation or terminated the turn, how long the run had been silent,
479
+ * and the last event type seen — the segment that went quiet, which is what
480
+ * no boundary previously recorded anywhere.
481
+ */
482
+ readonly runBoundaryReached: "run_boundary_reached";
452
483
  };
453
484
  export type RunDiagStage = (typeof RUN_DIAG_STAGE)[keyof typeof RUN_DIAG_STAGE];
454
485
  /**
@@ -1,3 +1,9 @@
1
+ /**
2
+ * SQL persistence for agent runs and events.
3
+ * Enables cross-isolate access on Cloudflare Workers and
4
+ * reliable reconnection after page refreshes.
5
+ */
6
+ import { MAX_BACKGROUND_RUN_CONTINUATIONS, TURN_RUN_LEDGER_SLACK, } from "../app-config/run-lifecycle-invariants.js";
1
7
  import { getDbExec, intType, isPostgres } from "../db/client.js";
2
8
  import { ensureColumnExists, ensureTableExists } from "../db/ddl-guard.js";
3
9
  import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
@@ -190,15 +196,32 @@ async function hasRunningRuns() {
190
196
  return false;
191
197
  }
192
198
  /**
193
- * FIX 3 (durable-background incident) per-turn run-count ceiling for
194
- * stale-run recovery mirrors `chainServerDrivenContinuation`'s own ledger
195
- * guard in production-agent.ts (`MAX_BACKGROUND_RUN_CONTINUATIONS + 5` = 25).
196
- * Duplicated as a literal rather than imported: production-agent.ts already
197
- * imports run-manager.ts, which imports this file, so a runtime import back
198
- * from here would be circular. Keep this numerically in sync if that
199
- * constant ever changes.
199
+ * Ceiling on run ROWS for one logical turn the number the continuation-chain
200
+ * guard and stale-run recovery must agree on.
201
+ *
202
+ * This was a literal `25` here plus `MAX_BACKGROUND_RUN_CONTINUATIONS + 5` in
203
+ * production-agent.ts, kept in step by a comment asking the next editor to
204
+ * remember, because importing back from this file would have been circular.
205
+ * It no longer needs to be: the base value is configuration, and `app-config`
206
+ * imports no agent code, so both sites can read the same resolver.
207
+ */
208
+ export function resolveTurnRunLedgerBudget() {
209
+ return MAX_BACKGROUND_RUN_CONTINUATIONS + TURN_RUN_LEDGER_SLACK;
210
+ }
211
+ /**
212
+ * True when a turn holding `turnRunCount` run rows must not be given another.
213
+ *
214
+ * A predicate rather than a number the callers compare themselves, because both
215
+ * call sites had `turnRunCount > budget` and both were off by one: the current
216
+ * run's row is already inserted when they check, and the successor's row is
217
+ * inserted after — so at equality they permitted a row past the documented
218
+ * ceiling. Two sites, one comparison, no way for them to disagree about the
219
+ * boundary again. That is the third time in this area that one number had two
220
+ * spellings.
200
221
  */
201
- const STALE_RUN_RECOVERY_MAX_TURN_RUNS = 25;
222
+ export function turnRunLedgerExhausted(turnRunCount) {
223
+ return turnRunCount >= resolveTurnRunLedgerBudget();
224
+ }
202
225
  /**
203
226
  * Circuit breaker for a DETERMINISTIC dead-on-arrival loop: some request
204
227
  * shapes make the worker hang almost immediately every single time (e.g. an
@@ -206,7 +229,7 @@ const STALE_RUN_RECOVERY_MAX_TURN_RUNS = 25;
206
229
  * hitting a transient blip. Because `attemptStaleRunRecovery` replays the
207
230
  * SAME captured `dispatch_payload` on every successor (never a fresh
208
231
  * request), such a turn was retrying an unwinnable request up to
209
- * `STALE_RUN_RECOVERY_MAX_TURN_RUNS` (25) times — ~25 * 53s ≈ 22 minutes,
232
+ * `resolveTurnRunLedgerBudget()` (25) times — ~25 * 53s ≈ 22 minutes,
210
233
  * each cycle re-billing the full input context — before finally giving up.
211
234
  * Confirmed live in prod (assets: one turn cycled 24x, each attempt an
212
235
  * identical ~32K-token request that made a token of real progress around
@@ -1271,6 +1294,14 @@ export const RUN_DIAG_STAGE = {
1271
1294
  * the per-turn budget is exhausted). See `attemptStaleRunRecovery`.
1272
1295
  */
1273
1296
  staleRunRecoveryAttempted: "stale_run_recovery_attempted",
1297
+ /**
1298
+ * The run manager reached a server-owned chunk boundary (`no_progress` or
1299
+ * `run_timeout`). Detail carries the reason, whether it was recovered in the
1300
+ * same invocation or terminated the turn, how long the run had been silent,
1301
+ * and the last event type seen — the segment that went quiet, which is what
1302
+ * no boundary previously recorded anywhere.
1303
+ */
1304
+ runBoundaryReached: "run_boundary_reached",
1274
1305
  };
1275
1306
  /**
1276
1307
  * Record the last reached pipeline stage (+ optional short detail) for a run.
@@ -1491,7 +1522,7 @@ function staleRecoveryDispatchPayload(payload) {
1491
1522
  * caller's own atomic "did I win the reap" gate, this guarantees AT MOST
1492
1523
  * ONE recovery successor per reaped run even under concurrent reapers.
1493
1524
  * - the per-turn run ledger (`countRunsForTurn`'s underlying query) has
1494
- * room (`STALE_RUN_RECOVERY_MAX_TURN_RUNS`) — mirrors
1525
+ * room (`resolveTurnRunLedgerBudget`) — mirrors
1495
1526
  * `chainServerDrivenContinuation`'s own budget guard so a pathological
1496
1527
  * turn can't loop forever through reaper-driven recovery either.
1497
1528
  */
@@ -1535,8 +1566,7 @@ async function attemptStaleRunRecovery(db, runId) {
1535
1566
  args: [threadId, turnId],
1536
1567
  });
1537
1568
  const turnRunCount = Number(countRows?.[0]?.run_count);
1538
- if (Number.isFinite(turnRunCount) &&
1539
- turnRunCount > STALE_RUN_RECOVERY_MAX_TURN_RUNS) {
1569
+ if (Number.isFinite(turnRunCount) && turnRunLedgerExhausted(turnRunCount)) {
1540
1570
  return { outcome: "budget_exhausted" };
1541
1571
  }
1542
1572
  // See `STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT`: a run whose last
@@ -15,4 +15,6 @@ export declare const agentConfig: z.ZodObject<{
15
15
  runSoftTimeoutMs: z.ZodOptional<z.ZodNumber>;
16
16
  completedRunRetentionMs: z.ZodOptional<z.ZodNumber>;
17
17
  erroredRunRetentionMs: z.ZodOptional<z.ZodNumber>;
18
+ backgroundNoProgressTimeoutMs: z.ZodDefault<z.ZodNumber>;
19
+ backgroundRunHardTimeoutMs: z.ZodDefault<z.ZodNumber>;
18
20
  }, z.core.$strip>;
@@ -66,4 +66,37 @@ export const agentConfig = z.object({
66
66
  env: ["AGENT_ERRORED_RUN_RETENTION_MS"],
67
67
  doc: "How long an errored agent run row is kept, in milliseconds.",
68
68
  }),
69
+ // ── Run-lifecycle bounds ────────────────────────────────────────────────
70
+ //
71
+ // These are the numbers that can TERMINATE a run, or that encode an
72
+ // assumption about the host it runs on. They carry today's shipped values as
73
+ // declared defaults, so a deployment that configures nothing sees no
74
+ // behaviour change; `agent/run-lifecycle.ts` is the only place that reads
75
+ // them, one resolver per field, and `assertRunLifecycleInvariants` checks the
76
+ // ordering between them every time configuration resolves.
77
+ //
78
+ // Derived values (the foreground backstop fraction, tool-timeout headroom)
79
+ // stay internal on purpose: they are relationships, not host facts, and
80
+ // making them settable is how the ordering below stops being checkable.
81
+ //
82
+ // Each default here is the value that shipped as a module constant, and the
83
+ // constant still exists under its historical name where its reasoning is
84
+ // written down. `agent-run-lifecycle-config.spec.ts` pins the two together so
85
+ // editing one alone is a failing test, not a silent divergence.
86
+ backgroundNoProgressTimeoutMs: z
87
+ .number()
88
+ .nonnegative()
89
+ .default(150_000)
90
+ .meta({
91
+ env: ["AGENT_BACKGROUND_NO_PROGRESS_TIMEOUT_MS"],
92
+ doc: "No-progress backstop for a background-function run, in milliseconds. 0 disables it.",
93
+ }),
94
+ backgroundRunHardTimeoutMs: z
95
+ .number()
96
+ .positive()
97
+ .default(10 * 60_000)
98
+ .meta({
99
+ env: ["AGENT_BACKGROUND_RUN_HARD_TIMEOUT_MS"],
100
+ doc: "Hard abort for one in-process background automation run, in milliseconds. This is the host's real function budget for scheduled work.",
101
+ }),
69
102
  });