@agent-native/core 0.168.13 → 0.169.0

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.
@@ -68,68 +68,6 @@ export declare const DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS = 40000;
68
68
  * function (no ~60s wall, 15-min budget) and therefore can safely outlast 40s.
69
69
  */
70
70
  export declare const HOSTED_SOFT_TIMEOUT_CEILING_MS = 40000;
71
- /**
72
- * Hard ceiling for the soft timeout when a run executes inside a Netlify
73
- * background function (any deployed function whose name ends in `-background`).
74
- * Background functions return 202 immediately and run detached for up to 15
75
- * minutes, so the ~60s synchronous function wall that 40s defends against does
76
- * NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
77
- * kill to abort, persist the partial turn, write the terminal event, and (for
78
- * the rare >13-min turn) self-fire another background continuation.
79
- *
80
- * This ceiling is used ONLY when a caller explicitly opts in with
81
- * `backgroundFunction: true`. It does not change the foreground/interactive
82
- * ceiling and does not fire unless the durable-background path dispatched the
83
- * run into a background function. Per the design doc Guardrail, the 40s
84
- * interactive clamp stays correct for every non-background run.
85
- */
86
- export declare const BACKGROUND_SOFT_TIMEOUT_CEILING_MS: number;
87
- /**
88
- * Default soft-timeout budget for a background-function run when the caller
89
- * does not pass an explicit `softTimeoutMs`. Same value as the ceiling — we
90
- * want a background turn to use nearly its whole 15-min budget before handing
91
- * off to a chained background continuation.
92
- */
93
- export declare const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS: number;
94
- /**
95
- * AUTHORITATIVE no-progress backstop for a run, enforced by the run manager
96
- * itself (timer-driven, independent of any layer below).
97
- *
98
- * The finer-grained watchdogs inside the agent loop (model-stream and
99
- * action-preparation no-progress, both 90s) only guard the model event stream
100
- * — a stall in any segment OUTSIDE that guarded loop (engine-call
101
- * establishment, worker setup between continuation chunks, a wedged transport
102
- * that emits keepalives while the loop never runs) previously hung forever
103
- * with the client watching keepalives. This backstop covers every segment by
104
- * construction: if no REAL progress event (see `shouldBumpProgressForEvent`;
105
- * keepalives and zero-byte prep activity don't count) lands for this long —
106
- * and no unit of work is in flight (see `inFlightWorkDelta`: tool calls,
107
- * cross-app calls, and the model stream all legitimately emit nothing for
108
- * minutes and each carry a bound of their own) — the run manager emits
109
- * `auto_continue { reason: "no_progress" }` and aborts the chunk, exactly
110
- * like the soft timeout, so the normal continuation machinery recovers it.
111
- *
112
- * Being numerically larger than the in-loop watchdogs is NOT what keeps this
113
- * from killing a healthy run, and treating it that way is what made it do so:
114
- * this clock and the loop's `lastModelStreamProgressAt` measure DIFFERENT
115
- * events. An extended-thinking phase bumps the inner clock on every engine
116
- * frame while forwarding nothing, so the inner watchdog correctly stayed quiet
117
- * and this one saw pure silence — runs whose worst gap crossed 150s died while
118
- * still streaming, some by a single second. Ordering between two clocks only
119
- * means something when they watch the same events; suspending on in-flight
120
- * work is what actually makes the two agree.
121
- *
122
- * This is now only the CEILING, not the value: `resolveRunNoProgressTimeoutMs`
123
- * clamps the foreground backstop to a fraction of the chunk's soft timeout
124
- * (~30s at a 40s chunk), which is BELOW the 90s in-loop watchdogs rather than
125
- * above them. That ordering is deliberate — the in-loop watchdogs could never
126
- * fire inside a hosted foreground chunk anyway, since the serverless wall
127
- * (~57-59s) arrives first. Proven durable-background chunks keep the full
128
- * `DEFAULT_BACKGROUND_NO_PROGRESS_TIMEOUT_MS` so large outputs can use the
129
- * background budget. Only armed when a soft-timeout regime is active (hosted
130
- * runs); local dev stays unbounded.
131
- */
132
- export declare const RUN_NO_PROGRESS_HARD_TIMEOUT_MS = 150000;
133
71
  /**
134
72
  * Default no-progress window for a run executing inside a proven durable
135
73
  * background function. A background worker that is heartbeating but has no
@@ -296,9 +234,18 @@ export interface StartRunOptions {
296
234
  */
297
235
  backgroundNoProgressTimeoutMs?: number;
298
236
  /**
299
- * Lifecycle metadata persisted to `agent_runs.dispatch_mode` and surfaced to
300
- * clients through `/runs/active`. This does not change run-manager behavior;
301
- * callers use it to describe who owns continuation at hosted chunk boundaries.
237
+ * Lifecycle metadata persisted to `agent_runs.dispatch_mode`, surfaced to
238
+ * clients through `/runs/active`, and carried on the terminal/boundary
239
+ * analytics events. This does not change run-manager behavior; callers use it
240
+ * to describe who owns continuation at hosted chunk boundaries.
241
+ *
242
+ * Unset is reported as ABSENT, never as `"foreground"`. The analytics events
243
+ * used to default it, and the default was wrong every single time it applied:
244
+ * the interactive handler is the one caller that passes this, so the default
245
+ * only ever labelled the callers that are NOT foreground — automations, agent
246
+ * teams, webhooks, harness runs. It made a 6-of-7 no-progress failure rate on
247
+ * the automation path indistinguishable from chat in the one place anybody
248
+ * would have looked.
302
249
  */
303
250
  dispatchMode?: "foreground" | "foreground-self-chain" | "background";
304
251
  /**
@@ -315,6 +262,53 @@ export interface StartRunOptions {
315
262
  /** Continuation/redispatch attempt number for this logical turn, if the
316
263
  * caller is tracking one. */
317
264
  attemptCount?: number;
265
+ /**
266
+ * The `runFn` recovers chunk boundaries INSIDE this invocation — it threads
267
+ * the `RunChunkControl` it is handed into
268
+ * `runAgentLoopDirectWithSoftTimeout`.
269
+ *
270
+ * When true a checkpoint aborts only the current CHUNK; the turn-scoped
271
+ * controller (what a user Stop, a hard timeout, and the cross-isolate abort
272
+ * check use) is left alone so the loop can append its continuation context
273
+ * and keep going. Off by default, and it must stay off for every caller that
274
+ * hands continuation to a FRESH invocation: those need the turn to end here
275
+ * so the next invocation can pick it up.
276
+ *
277
+ * This is the fix for the in-process automation runner, whose checkpoints
278
+ * were aborting the turn for a continuation nobody was going to run.
279
+ */
280
+ recoverChunkBoundaries?: boolean;
281
+ }
282
+ /**
283
+ * Handed to `runFn` so an in-invocation runner can tell a recoverable CHUNK
284
+ * boundary from a turn-ending abort.
285
+ *
286
+ * Without this distinction there is only one signal, and a checkpoint fired
287
+ * from above the agent loop is indistinguishable from a user pressing Stop —
288
+ * which is why `no_progress` was an accepted continuation reason with a
289
+ * 20-round budget that could never be reached.
290
+ */
291
+ export interface RunChunkControl {
292
+ /**
293
+ * Aborted only when the TURN must end: user Stop, cross-isolate abort, the
294
+ * caller's own hard timeout, or a checkpoint on a run that did not opt into
295
+ * `recoverChunkBoundaries`. Never fires for a recoverable chunk boundary.
296
+ */
297
+ readonly turnSignal: AbortSignal;
298
+ /** Signal for the chunk currently executing. Replaced by `beginChunk()`. */
299
+ readonly chunkSignal: AbortSignal;
300
+ /**
301
+ * Reason the CURRENT chunk was checkpointed, or `null` while it is live.
302
+ * Distinct from "the turn was aborted": a caller that cannot tell them apart
303
+ * turns every planned boundary into a terminal failure.
304
+ */
305
+ chunkBoundaryReason(): string | null;
306
+ /**
307
+ * Open a fresh chunk after a recoverable boundary and return its signal.
308
+ * Returns the already-aborted turn signal when the turn is over, so a caller
309
+ * that races a Stop cannot accidentally start another chunk.
310
+ */
311
+ beginChunk(): AbortSignal;
318
312
  }
319
313
  export interface ResolveRunSoftTimeoutOptions {
320
314
  useHostedDefault?: boolean;
@@ -322,8 +316,9 @@ export interface ResolveRunSoftTimeoutOptions {
322
316
  * Resolve the soft timeout for a run executing inside a Netlify background
323
317
  * function. Lifts the hosted clamp to `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`
324
318
  * (~13min) for this invocation only and, when no override/env is supplied,
325
- * defaults to `DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS`. Does NOT change the
326
- * foreground ceiling. Off by default.
319
+ * defaults to that same ceiling a background turn should use nearly its
320
+ * whole budget before handing off to a chained continuation. Does NOT change
321
+ * the foreground ceiling. Off by default.
327
322
  */
328
323
  backgroundFunction?: boolean;
329
324
  }
@@ -339,6 +334,26 @@ export declare function isHostedRuntime(): boolean;
339
334
  export declare function resolveRunSoftTimeoutMs(overrideMs?: number, options?: ResolveRunSoftTimeoutOptions): number;
340
335
  export declare function resolveCompletedRunRetentionMs(): number;
341
336
  export declare function resolveErroredRunRetentionMs(): number;
337
+ /**
338
+ * Hard abort for one in-process background automation run.
339
+ *
340
+ * This is the host's real function budget for scheduled work, which is exactly
341
+ * the kind of number that differs between deployments — so it is configuration,
342
+ * not a module constant nobody outside this package can see.
343
+ */
344
+ export declare function resolveBackgroundRunHardTimeoutMs(): number;
345
+ /**
346
+ * Chunk budget for a background automation, derived from the runner's OWN hard
347
+ * abort rather than from the durable-chat background ceiling.
348
+ *
349
+ * The shipped build took the 13-minute chat ceiling for a path whose process is
350
+ * killed at 10 minutes, which made the recoverable soft-timeout boundary dead
351
+ * code and left the terminal no-progress backstop as the only boundary an
352
+ * automation could ever reach. Deriving from the hard abort keeps
353
+ * `soft timeout < hard abort` true by construction; the invariant check asserts
354
+ * the headroom still fits.
355
+ */
356
+ export declare function resolveBackgroundAutomationSoftTimeoutMs(overrideMs?: number): number;
342
357
  /**
343
358
  * A completed tool with no later assistant text is an unfinished turn, not a
344
359
  * successful terminal response. Keep this predicate beside the run-manager's
@@ -353,7 +368,7 @@ export declare function endsAfterCompletedToolWithoutAssistantFinal(run: ActiveR
353
368
  *
354
369
  * Events are persisted to SQL for cross-isolate access (Cloudflare Workers).
355
370
  */
356
- export declare function startRun(runId: string, threadId: string, runFn: (send: (event: AgentChatEvent) => void, signal: AbortSignal) => Promise<void>, onComplete?: (run: ActiveRun) => void | Promise<void>, options?: StartRunOptions): StartedRun;
371
+ export declare function startRun(runId: string, threadId: string, runFn: (send: (event: AgentChatEvent) => void, signal: AbortSignal, control: RunChunkControl) => Promise<void>, onComplete?: (run: ActiveRun) => void | Promise<void>, options?: StartRunOptions): StartedRun;
357
372
  /**
358
373
  * Subscribe to a run's events starting from `fromSeq`.
359
374
  * Returns a ReadableStream that replays buffered events then live-tails.
@@ -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";