@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.
- package/dist/agent/engine/first-event-timeout.d.ts +8 -0
- package/dist/agent/engine/first-event-timeout.js +8 -0
- package/dist/agent/production-agent.d.ts +0 -30
- package/dist/agent/production-agent.js +17 -38
- package/dist/agent/run-loop-with-resume.d.ts +38 -25
- package/dist/agent/run-loop-with-resume.js +140 -55
- package/dist/agent/run-manager.d.ts +83 -68
- package/dist/agent/run-manager.js +280 -94
- package/dist/agent/run-store.d.ts +31 -0
- package/dist/agent/run-store.js +42 -12
- package/dist/app-config/agent.d.ts +2 -0
- package/dist/app-config/agent.js +33 -0
- package/dist/app-config/run-lifecycle-invariants.d.ts +248 -0
- package/dist/app-config/run-lifecycle-invariants.js +342 -0
- package/dist/app-config/schema.d.ts +2 -0
- package/dist/app-config/store.js +9 -1
- package/dist/client/agent-chat-adapter.d.ts +0 -2
- package/dist/client/agent-chat-adapter.js +7 -23
- package/dist/jobs/background-automation-runner.d.ts +25 -0
- package/dist/jobs/background-automation-runner.js +104 -21
- package/dist/jobs/run-history.d.ts +7 -1
- package/dist/jobs/run-history.js +57 -14
- package/dist/observability/traces.d.ts +13 -0
- package/dist/observability/traces.js +369 -317
- package/dist/progress/routes.d.ts +1 -1
- package/dist/server/agent-chat-plugin.js +2 -4
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
300
|
-
* clients through `/runs/active
|
|
301
|
-
*
|
|
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
|
|
326
|
-
*
|
|
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 (
|
|
163
|
-
return override;
|
|
164
|
-
|
|
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,
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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 (
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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,
|
|
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";
|