@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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Wall-clock reserved between a background automation's round budget and its
3
+ * own hard abort.
4
+ *
5
+ * It covers wind-down only — emit the terminal event, persist the turn, let
6
+ * `finalized` settle — because the recoverable boundary on this path belongs to
7
+ * the agent-loop wrapper's own per-round timer, not to a second timer in the
8
+ * run manager.
9
+ *
10
+ * This is the constant that makes `automation soft timeout < automation hard
11
+ * abort` true by construction rather than by hoping two independently chosen
12
+ * numbers happen to be ordered — they were not: the shipped build gave the
13
+ * automation path a 13-minute soft timeout under a 10-minute hard abort, so
14
+ * the recoverable boundary was dead code and the only boundary an automation
15
+ * could reach was the terminal one.
16
+ */
17
+ export const BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS = 20_000;
18
+ /**
19
+ * The host's hard kill for a background function (Netlify: 15 minutes).
20
+ *
21
+ * Not configuration — a deployment does not choose it, the platform does. It is
22
+ * here because `backgroundSoftTimeoutCeilingMs` IS the clamp that
23
+ * `resolveRunSoftTimeoutMs` reduces every background soft timeout to, so once
24
+ * that ceiling became configurable nothing was left bounding it: a deployment
25
+ * could set 60 minutes and push its own chunk boundary past the wall the
26
+ * ceiling exists to stay inside, turning every long background turn back into
27
+ * the silent platform kill it was introduced to prevent. Configurable must not
28
+ * mean unclamped.
29
+ */
30
+ export const BACKGROUND_FUNCTION_WALL_MS = 15 * 60_000;
31
+ /**
32
+ * Wall-clock a background chunk must leave itself to abort, persist the partial
33
+ * turn, write the terminal event, and chain a successor before the host kills
34
+ * the invocation. The shipped 13-minute ceiling under a 15-minute wall is
35
+ * exactly this margin.
36
+ */
37
+ export const BACKGROUND_FUNCTION_WALL_HEADROOM_MS = 2 * 60_000;
38
+ /**
39
+ * Slack between the CHAIN bound (`agent.maxBackgroundRunContinuations`) and the
40
+ * per-turn LEDGER bound below.
41
+ *
42
+ * They count different things. The chain bound counts handoffs a chunk decided
43
+ * to make; the ledger counts every run ROW the turn produced, which also
44
+ * includes sweep redispatches and stale-run recoveries no chunk ever decided.
45
+ * Without slack the ledger would refuse a turn before the chain bound it is
46
+ * meant to sit above, so a turn recovered once would die holding unused chain
47
+ * budget.
48
+ */
49
+ /**
50
+ * Shipped run-lifecycle bounds.
51
+ *
52
+ * They live beside the relationships that constrain them so a change to one is
53
+ * checked against the others in the same file. `run-manager.ts` and
54
+ * `production-agent.ts` re-export them under their historical names; this
55
+ * module imports no agent code, so nothing here can become circular.
56
+ */
57
+ /**
58
+ * Hard ceiling for the soft timeout when a run executes inside a Netlify
59
+ * background function (any deployed function whose name ends in `-background`).
60
+ * Background functions return 202 immediately and run detached for up to 15
61
+ * minutes, so the ~60s synchronous function wall that 40s defends against does
62
+ * NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
63
+ * kill to abort, persist the partial turn, write the terminal event, and (for
64
+ * the rare >13-min turn) self-fire another background continuation.
65
+ *
66
+ * This ceiling is used ONLY when a caller explicitly opts in with
67
+ * `backgroundFunction: true`. It does not change the foreground/interactive
68
+ * ceiling and does not fire unless the durable-background path dispatched the
69
+ * run into a background function. Per the design doc Guardrail, the 40s
70
+ * interactive clamp stays correct for every non-background run.
71
+ */
72
+ export const BACKGROUND_SOFT_TIMEOUT_CEILING_MS = 13 * 60_000;
73
+ /**
74
+ * AUTHORITATIVE no-progress backstop for a run, enforced by the run manager
75
+ * itself (timer-driven, independent of any layer below).
76
+ *
77
+ * The finer-grained watchdogs inside the agent loop (model-stream and
78
+ * action-preparation no-progress, both 90s) only guard the model event stream
79
+ * — a stall in any segment OUTSIDE that guarded loop (engine-call
80
+ * establishment, worker setup between continuation chunks, a wedged transport
81
+ * that emits keepalives while the loop never runs) previously hung forever
82
+ * with the client watching keepalives. This backstop covers every segment by
83
+ * construction: if no REAL progress event (see `shouldBumpProgressForEvent`;
84
+ * keepalives and zero-byte prep activity don't count) lands for this long —
85
+ * and no unit of work is in flight (see `inFlightWorkDelta`: tool calls,
86
+ * cross-app calls, and the model stream all legitimately emit nothing for
87
+ * minutes and each carry a bound of their own) — the run manager emits
88
+ * `auto_continue { reason: "no_progress" }` and aborts the chunk, exactly
89
+ * like the soft timeout, so the normal continuation machinery recovers it.
90
+ *
91
+ * Being numerically larger than the in-loop watchdogs is NOT what keeps this
92
+ * from killing a healthy run, and treating it that way is what made it do so:
93
+ * this clock and the loop's `lastModelStreamProgressAt` measure DIFFERENT
94
+ * events. An extended-thinking phase bumps the inner clock on every engine
95
+ * frame while forwarding nothing, so the inner watchdog correctly stayed quiet
96
+ * and this one saw pure silence — runs whose worst gap crossed 150s died while
97
+ * still streaming, some by a single second. Ordering between two clocks only
98
+ * means something when they watch the same events; suspending on in-flight
99
+ * work is what actually makes the two agree.
100
+ *
101
+ * This is now only the CEILING, not the value: `resolveRunNoProgressTimeoutMs`
102
+ * clamps the foreground backstop to a fraction of the chunk's soft timeout
103
+ * (~30s at a 40s chunk), which is BELOW the 90s in-loop watchdogs rather than
104
+ * above them. That ordering is deliberate — the in-loop watchdogs could never
105
+ * fire inside a hosted foreground chunk anyway, since the serverless wall
106
+ * (~57-59s) arrives first. Proven durable-background chunks keep the full
107
+ * `DEFAULT_BACKGROUND_NO_PROGRESS_TIMEOUT_MS` so large outputs can use the
108
+ * background budget. Only armed when a soft-timeout regime is active (hosted
109
+ * runs); local dev stays unbounded.
110
+ */
111
+ export const RUN_NO_PROGRESS_HARD_TIMEOUT_MS = 150_000;
112
+ /**
113
+ * Default in-loop watchdog for silence while an action's arguments stream in.
114
+ * Read through `resolveActionPreparationNoProgressTimeoutMs`, never directly:
115
+ * a host diagnosing a timeout has to be able to see and change this number.
116
+ */
117
+ export const ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS = 90_000;
118
+ /**
119
+ * Default in-loop watchdog for silence between engine stream frames. Read
120
+ * through `resolveModelStreamNoProgressTimeoutMs`, never directly.
121
+ */
122
+ export const MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS = 90_000;
123
+ /**
124
+ * Consecutive chunks allowed to end on the SAME terminal error code having
125
+ * produced nothing before the chain stops.
126
+ *
127
+ * Two, because two independent recovery layers multiply here and neither can
128
+ * see the other: the engine already retried this identical request 3x with
129
+ * backoff before the error was ever emitted, and a recoverable error is also a
130
+ * continuation boundary, so every chunk that fails costs 4 gateway attempts
131
+ * and dispatches a fresh one. A production turn spent 27 background runs and
132
+ * 15 minutes on one message this way. The first repeat is the retry this path
133
+ * exists for; a second identical failure that moved nothing is evidence the
134
+ * retrying itself is what is broken, not the request.
135
+ */
136
+ export const MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS = 2;
137
+ /**
138
+ * Wall-clock ceiling on a single logical turn. The run-count ledger alone is
139
+ * not a time bound: in durable mode each of the ~25 permitted chunks may burn
140
+ * ~780s, so the ledger's real worst case is over five hours (production has an
141
+ * observed 2h34m turn). Nobody is waiting that long, and every minute past
142
+ * this point is spend on a request the user has abandoned.
143
+ */
144
+ export const MAX_TURN_WALL_CLOCK_MS = 90 * 60_000;
145
+ /**
146
+ * Cap on continuation iterations inside a single
147
+ * `runAgentLoopDirectWithSoftTimeout` invocation. The host's hard function
148
+ * timeout usually bounds this naturally — but a defensive cap prevents an
149
+ * instant-error spiral from looping forever inside hosting environments with a
150
+ * generous budget.
151
+ *
152
+ * 6 leaves room for: 1 normal completion + a few resume rounds for design
153
+ * generation (prompt + 3 variants ≈ 4 LLM calls), with a small safety margin.
154
+ */
155
+ export const MAX_RUN_LOOP_CONTINUATIONS = 6;
156
+ /**
157
+ * A delegated turn that is proven to be running inside a durable background
158
+ * function has the same 15-minute host budget as main chat, but this wrapper
159
+ * historically kept the foreground-sized six-continuation cap. A healthy
160
+ * child A2A call can consume several minutes and the receiving model may then
161
+ * need more than six recovery/model-stream boundaries to finish its own tool
162
+ * work. Keep a hard cap, but give the proven background path the same bounded
163
+ * continuation allowance as the durable main-chat runner. The cumulative
164
+ * soft-timeout below still prevents these rounds from exceeding the one real
165
+ * background-function wall-clock budget.
166
+ */
167
+ export const MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS = 20;
168
+ export const TURN_RUN_LEDGER_SLACK = 5;
169
+ /**
170
+ * Hard cap on server-driven background→background continuation chunks for a
171
+ * single logical turn. A `backgroundFunction` run gets a ~13-min soft timeout,
172
+ * so reaching this boundary at all is the rare exception (most turns finish in
173
+ * one chunk). The cap bounds a pathological turn that would otherwise chain
174
+ * background invocations forever, mirroring `MAX_AGENT_TEAM_CONTINUATIONS`.
175
+ */
176
+ export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
177
+ /**
178
+ * Per-TURN follow budgets the browser applies while reading a background turn.
179
+ *
180
+ * They live here, not in `client/agent-chat-adapter.ts`, because they are one
181
+ * half of an ordering relationship whose other half is server configuration —
182
+ * and a relationship checked in only one of its two homes is the failure this
183
+ * module exists to prevent. This file has no runtime imports (the `AppConfig`
184
+ * import is type-only and erased), so the browser bundle pays nothing to read
185
+ * them from here.
186
+ *
187
+ * CLIENT-ABOVE-SERVER: these MUST stay above the server's own ceilings. The
188
+ * client fires on a clock and cannot tell looping from working; the server can,
189
+ * so the server must always terminate a turn first and write a truthful
190
+ * terminal reason. They shipped at 10 min / 6 runs while ONE legal background
191
+ * chunk may run 13 minutes — so the client killed healthy turns the server was
192
+ * still streaming, measured in production as aborts at 11-25 minutes with
193
+ * progress recorded right up to the abort. That was the top non-auth cause of
194
+ * "the chat just stopped".
195
+ *
196
+ * Do NOT tighten these to catch a stuck turn. A turn that is not progressing is
197
+ * already caught twice by mechanisms that read progress rather than a clock:
198
+ * `BACKGROUND_FOLLOW_IDLE_TIMEOUT_MS` and the repeated-terminal-reason
199
+ * detector.
200
+ */
201
+ export const MAX_FOLLOWED_BACKGROUND_RUNS = 30;
202
+ export const MAX_BACKGROUND_FOLLOW_WALL_TIME_MS = 110 * 60_000;
203
+ export class RunLifecycleInvariantError extends Error {
204
+ constructor(violations) {
205
+ super(`Agent run-lifecycle configuration is inconsistent:\n${violations
206
+ .map((v) => ` - ${v.name}: ${v.smaller.key} (${v.smaller.value}) must be ` +
207
+ `${v.relation === "<" ? "less than" : "at most"} ` +
208
+ `${v.larger.key} (${v.larger.value}) — ${v.why}`)
209
+ .join("\n")}`);
210
+ this.name = "RunLifecycleInvariantError";
211
+ }
212
+ }
213
+ /**
214
+ * Throws when the resolved run-lifecycle bounds cannot all do their job.
215
+ *
216
+ * Called from configuration resolution, so it fails at startup naming both
217
+ * constants and the relationship rather than at 3am when a run dies inside the
218
+ * window a mis-ordered pair opened.
219
+ */
220
+ export function assertRunLifecycleInvariants(agent) {
221
+ // Only two of these are configuration. The rest are the shipped constants,
222
+ // read here rather than duplicated as config defaults — a number with two
223
+ // homes needs a test to keep them in step, and that test is the tell that it
224
+ // should have had one home to begin with. A deployment that wants to move a
225
+ // bound it cannot currently reach should get a field added deliberately, with
226
+ // the relationship below extended to cover it.
227
+ const { backgroundNoProgressTimeoutMs, backgroundRunHardTimeoutMs } = agent;
228
+ const backgroundSoftTimeoutCeilingMs = BACKGROUND_SOFT_TIMEOUT_CEILING_MS;
229
+ const modelStreamNoProgressTimeoutMs = MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS;
230
+ const actionPreparationNoProgressTimeoutMs = ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS;
231
+ const maxBackgroundRunContinuations = MAX_BACKGROUND_RUN_CONTINUATIONS;
232
+ const maxConsecutiveNoProgressContinuations = MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS;
233
+ const maxTurnWallClockMs = MAX_TURN_WALL_CLOCK_MS;
234
+ const violations = [];
235
+ const require = (name, smaller, larger, why) => {
236
+ if (smaller.value < larger.value)
237
+ return;
238
+ violations.push({ name, smaller, larger, relation: "<", why });
239
+ };
240
+ const requireAtMost = (name, smaller, larger, why) => {
241
+ if (smaller.value <= larger.value)
242
+ return;
243
+ violations.push({ name, smaller, larger, relation: "<=", why });
244
+ };
245
+ // A disabled backstop (0) has no ordering to satisfy — it never fires.
246
+ if (backgroundNoProgressTimeoutMs > 0) {
247
+ require("in-loop watchdog before the run-manager backstop", {
248
+ key: "agent.modelStreamNoProgressTimeoutMs",
249
+ value: modelStreamNoProgressTimeoutMs,
250
+ }, {
251
+ key: "agent.backgroundNoProgressTimeoutMs",
252
+ value: backgroundNoProgressTimeoutMs,
253
+ }, "the in-loop watchdog emits a boundary the agent loop itself recovers; the run-manager backstop is the coarser one above it");
254
+ require("action-preparation watchdog before the run-manager backstop", {
255
+ key: "agent.actionPreparationNoProgressTimeoutMs",
256
+ value: actionPreparationNoProgressTimeoutMs,
257
+ }, {
258
+ key: "agent.backgroundNoProgressTimeoutMs",
259
+ value: backgroundNoProgressTimeoutMs,
260
+ }, "a stalled argument stream must be caught by the watchdog that knows which tool stalled");
261
+ require("background backstop inside the background chunk budget", {
262
+ key: "agent.backgroundNoProgressTimeoutMs",
263
+ value: backgroundNoProgressTimeoutMs,
264
+ }, {
265
+ key: "agent.backgroundSoftTimeoutCeilingMs",
266
+ value: backgroundSoftTimeoutCeilingMs,
267
+ }, "a backstop at or above the chunk budget can never fire — the chunk boundary always arrives first");
268
+ require("background backstop inside the automation's own budget", {
269
+ key: "agent.backgroundNoProgressTimeoutMs",
270
+ value: backgroundNoProgressTimeoutMs,
271
+ }, {
272
+ key: "agent.backgroundRunHardTimeoutMs - BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS",
273
+ value: backgroundRunHardTimeoutMs -
274
+ BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS,
275
+ }, "an automation whose backstop outlives its own chunk budget dies at the hard abort instead of checkpointing");
276
+ }
277
+ requireAtMost("background chunk budget inside the host's background-function wall", {
278
+ key: "agent.backgroundSoftTimeoutCeilingMs",
279
+ value: backgroundSoftTimeoutCeilingMs,
280
+ }, {
281
+ key: "BACKGROUND_FUNCTION_WALL_MS - BACKGROUND_FUNCTION_WALL_HEADROOM_MS",
282
+ value: BACKGROUND_FUNCTION_WALL_MS - BACKGROUND_FUNCTION_WALL_HEADROOM_MS,
283
+ }, "this ceiling is the clamp every background soft timeout is reduced to, so raising it past the host wall makes the chunk boundary unreachable and the run dies as a silent platform kill instead");
284
+ require("graceful boundary fits before the hard abort", {
285
+ key: "BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS",
286
+ value: BACKGROUND_AUTOMATION_SOFT_TIMEOUT_HEADROOM_MS,
287
+ }, {
288
+ key: "agent.backgroundRunHardTimeoutMs",
289
+ value: backgroundRunHardTimeoutMs,
290
+ }, "without room for the headroom there is no chunk budget left to hand a boundary to");
291
+ requireAtMost("no-progress streak bound inside the chain bound", {
292
+ key: "agent.maxConsecutiveNoProgressContinuations",
293
+ value: maxConsecutiveNoProgressContinuations,
294
+ }, {
295
+ key: "agent.maxBackgroundRunContinuations",
296
+ value: maxBackgroundRunContinuations,
297
+ }, "a streak bound above the chain bound can never trip, so a repeating failure runs to the chain limit instead");
298
+ // ── Client-above-server ────────────────────────────────────────────────
299
+ //
300
+ // Until this branch these were pinned in `agent-chat-adapter.spec.ts` against
301
+ // the server's module CONSTANTS. Making those constants configurable moved
302
+ // the real values out from under that test without moving the test: a
303
+ // deployment could raise any of them past what the shipped client can follow
304
+ // and every check still passed. Asserting against the RESOLVED config is what
305
+ // closes that.
306
+ require("server chunk budget leaves the client room for more than one chunk", {
307
+ key: "agent.backgroundSoftTimeoutCeilingMs * 2",
308
+ value: backgroundSoftTimeoutCeilingMs * 2,
309
+ }, {
310
+ key: "MAX_BACKGROUND_FOLLOW_WALL_TIME_MS",
311
+ value: MAX_BACKGROUND_FOLLOW_WALL_TIME_MS,
312
+ }, "a whole-turn client budget below two full-length chunks kills a healthy turn mid-stream — the exact inversion that shipped");
313
+ require("server turn ceiling below the client's follow budget", {
314
+ // EFFECTIVE, not nominal: the ceiling is checked at chunk boundaries, so a
315
+ // turn passing the check one chunk short of it still gets a whole further
316
+ // chunk. Comparing the configured number alone hid a real inversion in the
317
+ // shipped values — 90min + a 13min chunk against a client that stopped
318
+ // following at 95min.
319
+ key: "agent.maxTurnWallClockMs + agent.backgroundSoftTimeoutCeilingMs",
320
+ value: maxTurnWallClockMs + backgroundSoftTimeoutCeilingMs,
321
+ }, {
322
+ key: "MAX_BACKGROUND_FOLLOW_WALL_TIME_MS",
323
+ value: MAX_BACKGROUND_FOLLOW_WALL_TIME_MS,
324
+ }, "the server must end the turn first, because it is the side that can tell progress from a loop and write a truthful terminal reason");
325
+ require("server chain bound below the client's follow-run budget", {
326
+ // EFFECTIVE, not nominal: the durable ledger allows the chain bound PLUS
327
+ // the recovery slack in run ROWS, and the client counts rows. 20 + 5 = 25
328
+ // against a client that stopped at 24 — the same inversion, hidden the
329
+ // same way.
330
+ key: "agent.maxBackgroundRunContinuations + TURN_RUN_LEDGER_SLACK",
331
+ value: maxBackgroundRunContinuations + TURN_RUN_LEDGER_SLACK,
332
+ }, {
333
+ key: "MAX_FOLLOWED_BACKGROUND_RUNS",
334
+ value: MAX_FOLLOWED_BACKGROUND_RUNS,
335
+ }, "a client that stops following before the server stops chaining leaves the user watching a spinner over a live run");
336
+ requireAtMost("turn ceiling above one chunk budget", {
337
+ key: "agent.backgroundSoftTimeoutCeilingMs",
338
+ value: backgroundSoftTimeoutCeilingMs,
339
+ }, { key: "agent.maxTurnWallClockMs", value: maxTurnWallClockMs }, "a turn ceiling below a single chunk budget kills every turn at its first chunk boundary");
340
+ if (violations.length > 0)
341
+ throw new RunLifecycleInvariantError(violations);
342
+ }
@@ -23,6 +23,8 @@ export declare const appConfigSchema: z.ZodObject<{
23
23
  runSoftTimeoutMs: z.ZodOptional<z.ZodNumber>;
24
24
  completedRunRetentionMs: z.ZodOptional<z.ZodNumber>;
25
25
  erroredRunRetentionMs: z.ZodOptional<z.ZodNumber>;
26
+ backgroundNoProgressTimeoutMs: z.ZodDefault<z.ZodNumber>;
27
+ backgroundRunHardTimeoutMs: z.ZodDefault<z.ZodNumber>;
26
28
  }, z.core.$strip>>;
27
29
  app: z.ZodPrefault<z.ZodObject<{
28
30
  id: z.ZodOptional<z.ZodString>;
@@ -1,4 +1,5 @@
1
1
  import { readEnvConfigLayer } from "./env-layer.js";
2
+ import { assertRunLifecycleInvariants } from "./run-lifecycle-invariants.js";
2
3
  import { appConfigSchema, } from "./schema.js";
3
4
  /**
4
5
  * Resolution order for app configuration, lowest opinion first.
@@ -46,7 +47,14 @@ function resolve(envLayer) {
46
47
  if (value)
47
48
  merged = mergeLayers(merged, value);
48
49
  }
49
- return appConfigSchema.parse(merged);
50
+ const parsed = appConfigSchema.parse(merged);
51
+ // Checked on the MERGED result, not per layer: a deployment may legitimately
52
+ // set one half of a relationship in the environment and the other in a
53
+ // plugin, and a per-layer check would reject that pairing before it exists.
54
+ // Defaults go through here too — the pair that shipped violated was a pair of
55
+ // defaults.
56
+ assertRunLifecycleInvariants(parsed.agent);
57
+ return parsed;
50
58
  }
51
59
  /**
52
60
  * Nitro embeds build-only deployment markers into direct env reads. Netlify's
@@ -15,8 +15,6 @@ export type AgentChatSurfaceKind =
15
15
  | "desktop";
16
16
  export declare const BACKGROUND_FOLLOW_ATTACH_WATCHDOG_MS = 90000;
17
17
  export declare const BACKGROUND_FOLLOW_IDLE_TIMEOUT_MS = 210000;
18
- export declare const MAX_FOLLOWED_BACKGROUND_RUNS = 24;
19
- export declare const MAX_BACKGROUND_FOLLOW_WALL_TIME_MS: number;
20
18
  /**
21
19
  * True when an action was streamed but never returned a result yet — i.e. a
22
20
  * `tool_start` with no matching `tool_done`. The server is still executing it,
@@ -170,29 +170,13 @@ export const BACKGROUND_FOLLOW_IDLE_TIMEOUT_MS = 210_000;
170
170
  // 22 minutes, all error:stale_run, user watching a spinner). These bound the
171
171
  // whole turn instead, and an identical repeated failure counts as no progress.
172
172
  //
173
- // CLIENT-ABOVE-SERVER INVARIANT (asserted in agent-chat-adapter.spec.ts).
174
- // These are a backstop for a server that has gone silent in a way the idle
175
- // timeout missesNOT the primary limit. They must stay ABOVE the server's
176
- // own ceilings so the server, which can actually tell progress from looping,
177
- // always terminates a turn first and writes a truthful terminal reason:
178
- // BACKGROUND_SOFT_TIMEOUT_CEILING_MS (13 min, run-manager.ts) — one chunk
179
- // MAX_TURN_WALL_CLOCK_MS (90 min, production-agent.ts) one turn
180
- // MAX_BACKGROUND_RUN_CONTINUATIONS (20, production-agent.ts)
181
- //
182
- // They were originally set to 10 min / 6 runs, which put the whole-turn client
183
- // budget BELOW the 13-minute ceiling of a single legal chunk. Any turn needing
184
- // a second full-length chunk was killed by the client while the server was
185
- // healthy and had 80 minutes left — measured in prod as turns dying at 11-25
186
- // minutes with `last_progress_at` tracking the abort, i.e. still streaming
187
- // tokens and completing tools when the client gave up. That is the top
188
- // non-auth cause of "the chat just stopped" reports.
189
- //
190
- // Killing a turn that is NOT progressing is already covered twice over, by
191
- // mechanisms that read progress rather than a clock: the 210s idle timeout
192
- // above, and MAX_REPEATED_BACKGROUND_TERMINAL_REASONS below. Do not re-tighten
193
- // these two to catch a stuck turn — fix the progress signal instead.
194
- export const MAX_FOLLOWED_BACKGROUND_RUNS = 24;
195
- export const MAX_BACKGROUND_FOLLOW_WALL_TIME_MS = 95 * 60_000;
173
+ // Defined in `app-config/run-lifecycle-invariants.ts`, not here, because the
174
+ // CLIENT-ABOVE-SERVER relationship they belong to is checked there against the
175
+ // RESOLVED server configuration — the server bounds are runtime-configurable
176
+ // now, so a spec pinning them against module constants stopped enforcing
177
+ // anything. Re-exported under the same names so importers are unchanged; that
178
+ // module has no runtime imports, so the bundle pays nothing for it.
179
+ import { MAX_BACKGROUND_FOLLOW_WALL_TIME_MS, MAX_FOLLOWED_BACKGROUND_RUNS, } from "../app-config/run-lifecycle-invariants.js";
196
180
  const MAX_REPEATED_BACKGROUND_TERMINAL_REASONS = 3;
197
181
  // A re-observed terminal run whose outcome would be an ERROR (never a
198
182
  // genuine "done" success) gets a short extra grace window before the follow
@@ -5,7 +5,23 @@ import { type AutomationExecutionIdentity } from "../automations/service.js";
5
5
  import { type Resource } from "../resources/store.js";
6
6
  import { type RequestContext } from "../server/request-context.js";
7
7
  import type { JobFrontmatter } from "./frontmatter.js";
8
+ /**
9
+ * Default hard abort for one in-process automation run. Read through
10
+ * `resolveBackgroundRunHardTimeoutMs()` at the use site — this is the host's
11
+ * real function budget for scheduled work, and it differs by deployment.
12
+ */
8
13
  export declare const BACKGROUND_RUN_HARD_TIMEOUT_MS: number;
14
+ /**
15
+ * Terminal failure of a background automation, carrying the machine-readable
16
+ * code the failure taxonomy already computes.
17
+ *
18
+ * The code used to be produced and then dropped, so "how often are runs cut
19
+ * off?" was a `LIKE '%no_progress%'` over an English sentence.
20
+ */
21
+ export declare class BackgroundAutomationRunError extends Error {
22
+ readonly errorCode: string;
23
+ constructor(message: string, errorCode: string);
24
+ }
9
25
  export interface BackgroundAutomationContext {
10
26
  name: string;
11
27
  meta: JobFrontmatter;
@@ -35,6 +51,15 @@ export interface BackgroundAutomationRunOptions {
35
51
  actionAutomation?: ActionAutomationContext;
36
52
  /** Reuse a history row created by a durable run-now enqueue. */
37
53
  historyId?: string;
54
+ /**
55
+ * Per-run overrides for the run-manager no-progress backstop. `startRun` has
56
+ * always accepted these; the automation path had no way to reach them, and
57
+ * the one indirect route (zeroing `agent.runSoftTimeoutMs`) is global and
58
+ * would strip foreground chat of its chunk boundary. Additive: unset means
59
+ * the configured/default behaviour, unchanged.
60
+ */
61
+ noProgressTimeoutMs?: number;
62
+ backgroundNoProgressTimeoutMs?: number;
38
63
  }
39
64
  export interface BackgroundAutomationRunResult {
40
65
  responseText: string;