@agent-native/core 0.70.3 → 0.71.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/docs/content/multi-app-workspace.md +2 -2
- package/corpus/core/docs/design/durable-agent-runs.md +458 -4
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +192 -0
- package/corpus/core/src/agent/production-agent.ts +330 -13
- package/corpus/core/src/agent/run-manager.ts +66 -3
- package/corpus/core/src/agent/run-store.ts +129 -23
- package/corpus/core/src/agent/types.ts +20 -0
- package/corpus/core/src/client/AgentPanel.tsx +12 -9
- package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
- package/corpus/core/src/deploy/build.ts +96 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
- package/corpus/core/src/mcp/build-server.ts +22 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
- package/corpus/templates/analytics/AGENTS.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
- package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
- package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +130 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +195 -34
- package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
- package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
- package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
- package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
- package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
- package/corpus/templates/clips/desktop/src/styles.css +33 -5
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/dist/agent/durable-background.d.ts +60 -0
- package/dist/agent/durable-background.d.ts.map +1 -0
- package/dist/agent/durable-background.js +144 -0
- package/dist/agent/durable-background.js.map +1 -0
- package/dist/agent/production-agent.d.ts +20 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +292 -14
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +48 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +45 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +30 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +124 -24
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +20 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +37 -4
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/deploy/build.d.ts +29 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +85 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +108 -0
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +23 -9
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +68 -13
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/docs/content/multi-app-workspace.md +2 -2
- package/docs/design/durable-agent-runs.md +458 -4
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.71.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 38266fc: Add opt-in durable background agent-chat runs (off by default, host-agnostic). Behind `AGENT_CHAT_DURABLE_BACKGROUND` (active only when hosted AND `A2A_SECRET` is set AND the flag is truthy), a long in-app agent-chat turn is routed through a server-driven background worker via the framework's portable self-dispatch instead of completing synchronously under the ~40s interactive soft-timeout: the foreground POST claims the run slot, inserts the run row, fires an HMAC-signed self-dispatch to a new `/_agent-native/agent-chat/_process-run` route, and returns the existing `subscribeToRun` SSE stream so the client streams the same events via the cross-isolate SQL-poll path with no client change. The background worker idempotently claims the run, runs the full multi-step loop to completion under a host-natural soft-timeout (`backgroundFunction` mode lifts the 40s clamp for that invocation only — the foreground/interactive clamp is unchanged), and chains a server-driven continuation if a chunk hits its budget unfinished. A background-aware stale window (`dispatch_mode`) prevents a cold-starting background run from being falsely reaped. With the flag off, the agent-chat run path is byte-for-byte the current synchronous behavior.
|
|
8
|
+
|
|
9
|
+
As a per-host optimization layered on the portable baseline, the Netlify deploy build emits a second function whose name ends in `-background` (re-exporting the same `main.mjs` handler bundle, with a `config.path` of the process-run route) so the `_process-run` POST runs on Netlify's async 15-minute budget and a long turn completes in one invocation; on that invocation the worker's soft-timeout is raised to ~13 min (`backgroundFunction` mode) instead of 40s. This emit is build-time gated on the same `AGENT_CHAT_DURABLE_BACKGROUND` flag for both the single-template (`deploy/build.ts`) and workspace (`deploy/workspace-deploy.ts`) deploy paths: when the flag is unset at build time the emit functions are never invoked, so the deploy output (functions, routing, config) is byte-identical to today and the default single-function deploy is unchanged.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 38266fc: Move "Collapse sidebar" to the top of the agent panel's options menu (the `⋯` dropdown in the sidebar header), above All chats / Agent runs / Settings, with a separator below it. Makes the most common dismiss action the first item in the list.
|
|
14
|
+
- 38266fc: Fix inline MCP App embeds being hard-killed on `resources/read`. The inline-embed kill switch was enforced inside the shared `resolveMcpAppResource` resolver, which also backs `resources/read` — so when a host read a `ui://` URI it already held (e.g. a cached descriptor) while embeds were disabled, it got a hard `-32603` instead of the shell. The switch is now enforced only at the advertisement/render sites (`tools/list` descriptor meta, `tools/call` result meta, `resources/list`), so disabled embeds are never advertised while `resources/read` still degrades gracefully to the served shell.
|
|
15
|
+
|
|
3
16
|
## 0.70.3
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -117,8 +117,8 @@ The merge happens by file name. If an app provides a local file that also exists
|
|
|
117
117
|
|
|
118
118
|
```an-diagram title="Three layers, merged by file name" summary="Each app resolves plugins, skills, actions, and AGENTS.md from app-local first, then the shared package, then the framework default."
|
|
119
119
|
{
|
|
120
|
-
"html": "<div class=\"layer\"><div class=\"diagram-card accent\"><span class=\"diagram-pill accent\">1 · App local</span><small class=\"diagram-muted\"><code>apps/<name>/</code> — highest priority</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">↓</div><div class=\"diagram-card\"><span class=\"diagram-pill\">2 · Workspace shared</span><small class=\"diagram-muted\"><code>packages/shared/</code> — the mid-layer</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">↓</div><div class=\"diagram-card\"><span class=\"diagram-pill\">3 · Framework default</span><small class=\"diagram-muted\"><code>@agent-native/core</code> — lowest</small></div><div class=\"diagram-arrow diagram-accent\" aria-hidden=\"true\">&
|
|
121
|
-
"css": ".layer{display:flex;flex-direction:column;align-items:
|
|
120
|
+
"html": "<div class=\"layer\"><div class=\"diagram-card accent\"><span class=\"diagram-pill accent\">1 · App local</span><small class=\"diagram-muted\"><code>apps/<name>/</code> — highest priority</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">↓</div><div class=\"diagram-card\"><span class=\"diagram-pill\">2 · Workspace shared</span><small class=\"diagram-muted\"><code>packages/shared/</code> — the mid-layer</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">↓</div><div class=\"diagram-card\"><span class=\"diagram-pill\">3 · Framework default</span><small class=\"diagram-muted\"><code>@agent-native/core</code> — lowest</small></div><div class=\"diagram-arrow diagram-accent\" aria-hidden=\"true\">↓</div><div class=\"diagram-box ok\">first match wins</div></div>",
|
|
121
|
+
"css": ".layer{display:flex;flex-direction:column;align-items:center;gap:6px}.layer .diagram-card{display:flex;flex-direction:column;gap:3px;padding:12px 16px;width:320px}.layer .diagram-arrow{font-size:18px;line-height:1}.layer .diagram-box{margin-top:2px}"
|
|
122
122
|
}
|
|
123
123
|
```
|
|
124
124
|
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
# Design: Durable / Checkpointed Agent Runs
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
|
|
3
|
+
Status: Phase 1 + Phase 2 implemented (flagged, off by default); Phase 0 shipped;
|
|
4
|
+
internal per-step checkpointing (Option A core) still recommended-not-built.
|
|
5
|
+
Owner: core / run-manager + deploy
|
|
5
6
|
Related code: `packages/core/src/agent/run-manager.ts`,
|
|
7
|
+
`packages/core/src/agent/durable-background.ts`,
|
|
8
|
+
`packages/core/src/agent/production-agent.ts`,
|
|
9
|
+
`packages/core/src/server/agent-chat-plugin.ts`,
|
|
10
|
+
`packages/core/src/deploy/build.ts`,
|
|
11
|
+
`packages/core/src/deploy/workspace-deploy.ts`,
|
|
6
12
|
`packages/core/src/agent/engine/builder-engine.ts`
|
|
7
13
|
|
|
14
|
+
> **Final architecture, in one line.** Durable background runs are a
|
|
15
|
+
> **host-agnostic core** — the foreground turn fires an HMAC-signed _self
|
|
16
|
+
> dispatch_ to a sibling worker route, the worker runs the full multi-step loop
|
|
17
|
+
> and persists every event to SQL, and the browser streams those events through
|
|
18
|
+
> the existing cross-isolate SQL-poll reconnect path — with the **Netlify 15-min
|
|
19
|
+
> `-background` function as a per-host optimization** layered on top. The
|
|
20
|
+
> portable baseline works on any host that can re-invoke itself (it just
|
|
21
|
+
> server-chains continuations when the host budget is short); the Netlify layer
|
|
22
|
+
> simply lets one invocation run ~15 min so a long turn finishes in **one**
|
|
23
|
+
> chunk instead of many re-hydrating ones. Nothing about the baseline is
|
|
24
|
+
> Netlify-specific; see [Final layered architecture](#final-layered-architecture).
|
|
25
|
+
|
|
8
26
|
## Problem
|
|
9
27
|
|
|
10
28
|
Hosted agent runs are bounded by a ~40s soft timeout enforced in
|
|
@@ -179,22 +197,34 @@ itself checkpoint).
|
|
|
179
197
|
|
|
180
198
|
### Phased plan
|
|
181
199
|
|
|
200
|
+
> Status: **Phase 0 shipped. Phase 1 + Phase 2 implemented** (the host-agnostic
|
|
201
|
+
> durable-background worker and the Netlify 15-min `-background` optimization),
|
|
202
|
+
> behind `AGENT_CHAT_DURABLE_BACKGROUND`, off by default. Internal per-step
|
|
203
|
+
> checkpointing (the Option A core of Phase 1) is recommended-not-yet-built; the
|
|
204
|
+
> worker today gets its durability from the long single invocation plus
|
|
205
|
+
> server-chained continuations rather than per-step idempotent checkpoints. See
|
|
206
|
+
> [Final layered architecture](#final-layered-architecture).
|
|
207
|
+
|
|
182
208
|
1. **Phase 0 — Stop hitting the ceiling so often (near-term, cheapest).** Land
|
|
183
209
|
the mitigations in the Tie-in below (one-call atomic primitives,
|
|
184
210
|
self-documenting actions, loud termination, proof-of-done verification).
|
|
185
211
|
These don't fix the ceiling but sharply cut how often multi-step loops are
|
|
186
212
|
even attempted, and make the failures that remain _loud and truthful_ instead
|
|
187
213
|
of silent. Capture the agent-facing half as the `reliable-mutations` skill.
|
|
214
|
+
_(Shipped.)_
|
|
188
215
|
2. **Phase 1 — Checkpointed continuation (Option A).** Add a SQL-backed progress
|
|
189
216
|
checkpoint for long operations and make their steps idempotent/resumable so
|
|
190
217
|
each `auto_continue` chunk advances committed progress instead of replanning.
|
|
191
218
|
Drive terminal state ("N of N", or "M of N + remainder") from the checkpoint.
|
|
192
|
-
This is the primary reliability win.
|
|
219
|
+
This is the primary reliability win. _(The durable-background worker +
|
|
220
|
+
host-agnostic self-dispatch/SQL-event/reconnect baseline is implemented;
|
|
221
|
+
per-step idempotent checkpointing is still recommended-not-built.)_
|
|
193
222
|
3. **Phase 2 — Durable background execution (Option B).** For operations that
|
|
194
223
|
can exceed any reasonable number of continuation chunks, enqueue them onto the
|
|
195
224
|
core background infrastructure, have the durable worker run them to completion
|
|
196
225
|
(checkpointing internally per Phase 1), and stream truthful progress back to
|
|
197
|
-
the foreground run and UI.
|
|
226
|
+
the foreground run and UI. _(Implemented: host-agnostic worker baseline +
|
|
227
|
+
Netlify 15-min `-background` per-host optimization, flagged off by default.)_
|
|
198
228
|
|
|
199
229
|
## Tie-in: cheaper near-term mitigations reduce, but do not replace, the fix
|
|
200
230
|
|
|
@@ -215,3 +245,427 @@ failures honest. They are valuable and should ship first (Phase 0), but the
|
|
|
215
245
|
The agent-facing rules for these live in the `reliable-mutations` skill
|
|
216
246
|
(`.agents/skills/reliable-mutations/SKILL.md`). They lower the blast radius;
|
|
217
247
|
checkpointed and durable runs remove the ceiling itself.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
# Final layered architecture
|
|
252
|
+
|
|
253
|
+
Status: implemented (flagged, off by default)
|
|
254
|
+
Owner: core / run-manager + deploy
|
|
255
|
+
|
|
256
|
+
Durable background runs ship as **two layers**. Both are gated behind
|
|
257
|
+
`AGENT_CHAT_DURABLE_BACKGROUND` and default off; when off, the agent-chat run
|
|
258
|
+
path and the deploy output are byte-for-byte the pre-existing synchronous
|
|
259
|
+
behavior.
|
|
260
|
+
|
|
261
|
+
### Layer 1 (portable baseline) — host-agnostic durable execution
|
|
262
|
+
|
|
263
|
+
This is the actual durability mechanism and it is **not tied to any host**:
|
|
264
|
+
|
|
265
|
+
- **Self-dispatch worker.** The foreground POST claims the run slot, inserts the
|
|
266
|
+
run row, and `fireInternalDispatch`es (`server/self-dispatch.ts`) an
|
|
267
|
+
HMAC-signed request to a sibling worker route,
|
|
268
|
+
`AGENT_CHAT_PROCESS_RUN_PATH = /_agent-native/agent-chat/_process-run`
|
|
269
|
+
(`agent/durable-background.ts`). The worker re-enters the same agent-chat
|
|
270
|
+
handler set as the background worker and runs the full multi-step
|
|
271
|
+
`runAgentLoop` (`agent/production-agent.ts`).
|
|
272
|
+
- **SQL event log as the transport.** The worker persists every event to
|
|
273
|
+
`agent_run_events` (`run-store.ts`, idempotent on `(run_id, seq)`). The
|
|
274
|
+
browser streams those events through the existing `subscribeToRun` →
|
|
275
|
+
cross-isolate SQL-poll path (`run-manager.ts subscribeFromSQL`), so the client
|
|
276
|
+
needs no change and reconnect/leave-and-return already works
|
|
277
|
+
(`GET /runs/active`, `GET /runs/:id/events?after=N`).
|
|
278
|
+
- **Idempotent claim + auth.** The worker claims the run with a conditional
|
|
279
|
+
update (`claimBackgroundRun`) so a duplicate delivery no-ops, and verifies the
|
|
280
|
+
HMAC token (`prepareProcessRunRequest`) exactly like the agent-teams / A2A /
|
|
281
|
+
webhook processors.
|
|
282
|
+
- **Continuation by self-chaining, not by the browser.** If a worker chunk hits
|
|
283
|
+
its soft-timeout unfinished, it emits `auto_continue` and **re-fires another
|
|
284
|
+
background dispatch** (mode `continue`) instead of bouncing back to the client.
|
|
285
|
+
On a host with a short invocation budget this just produces more chunks; the
|
|
286
|
+
run still completes, server-driven. This is what makes the baseline portable:
|
|
287
|
+
it degrades to chained self-dispatch on any host that can re-invoke itself.
|
|
288
|
+
|
|
289
|
+
Nothing in Layer 1 assumes Netlify, a 15-minute budget, or a particular preset.
|
|
290
|
+
It is the host-agnostic Option B worker with a SQL fan-in.
|
|
291
|
+
|
|
292
|
+
### Layer 2 (per-host optimization) — Netlify 15-min `-background` function
|
|
293
|
+
|
|
294
|
+
On Netlify, Layer 1's worker invocation can be made to run for up to **15
|
|
295
|
+
minutes** in a single shot, eliminating almost all re-hydration (the costly
|
|
296
|
+
part). This is purely an optimization of _where_ Layer 1's worker runs:
|
|
297
|
+
|
|
298
|
+
- **Deploy emit (build-time gated).** When the flag is set at build time, the
|
|
299
|
+
deploy emits a **second** Netlify function whose name ends in `-background`,
|
|
300
|
+
re-exporting the **same** `main.mjs` handler bundle, with a `config.path` of
|
|
301
|
+
the process-run route. Netlify invokes any function whose deployed name ends in
|
|
302
|
+
`-background` asynchronously (202 immediately, up to 15-min budget). Single
|
|
303
|
+
template: `emitSingleTemplateNetlifyBackgroundFunction` (`deploy/build.ts`).
|
|
304
|
+
Workspace: `emitNetlifyBackgroundFunction` (`deploy/workspace-deploy.ts`),
|
|
305
|
+
per app with a base-path-scoped `config.path`. **When the flag is not set at
|
|
306
|
+
build time, neither emit runs and the single-function output is byte-identical
|
|
307
|
+
to today** (see [Build-time gate](#build-time-gate-byte-identical-when-off)).
|
|
308
|
+
- **Raised soft-timeout on that invocation only.** When the worker is running
|
|
309
|
+
inside the background function it calls
|
|
310
|
+
`startRun(..., { backgroundFunction: true })`, which lifts
|
|
311
|
+
`resolveRunSoftTimeoutMs`'s hosted ceiling from 40s to
|
|
312
|
+
`BACKGROUND_SOFT_TIMEOUT_CEILING_MS` (~13 min, ~2 min under Netlify's 15-min
|
|
313
|
+
hard kill) for that invocation. The interactive/foreground 40s clamp is
|
|
314
|
+
unchanged (see Guardrail).
|
|
315
|
+
|
|
316
|
+
If Layer 2 is absent (or its routing resolves to the synchronous function),
|
|
317
|
+
Layer 1 still works — the run just completes via more, shorter, server-chained
|
|
318
|
+
chunks. **No regression, only a missed optimization.**
|
|
319
|
+
|
|
320
|
+
### Other hosts
|
|
321
|
+
|
|
322
|
+
Any host that can re-invoke itself runs Layer 1 unchanged (chained
|
|
323
|
+
self-dispatch). A host-specific Layer 2 would mirror the Netlify pattern: emit
|
|
324
|
+
or configure a long-lived async worker invocation and pass
|
|
325
|
+
`backgroundFunction: true` to `startRun` on that path. No such layer is built for
|
|
326
|
+
non-Netlify hosts yet; they get the portable baseline.
|
|
327
|
+
|
|
328
|
+
## Build-time gate (byte-identical when off)
|
|
329
|
+
|
|
330
|
+
The safety-critical claim is that with `AGENT_CHAT_DURABLE_BACKGROUND` **unset at
|
|
331
|
+
build time**, the emitted Netlify deploy output is unchanged. How it is
|
|
332
|
+
guaranteed:
|
|
333
|
+
|
|
334
|
+
- The only code that produces the `-background` artifact is the two emit
|
|
335
|
+
functions above, and each is reached **only** through a call site guarded by
|
|
336
|
+
`isDurableBackgroundDeployEnabled()` (single template: `deploy/build.ts`,
|
|
337
|
+
inside the `preset === "netlify"` block; workspace:
|
|
338
|
+
`deploy/workspace-deploy.ts`, inside `copyNetlifyFunctionIntoWorkspace`).
|
|
339
|
+
`isDurableBackgroundDeployEnabled()` returns `false` for an unset or
|
|
340
|
+
non-truthy flag, so when the flag is absent the emit functions are **never
|
|
341
|
+
invoked** — no second function directory, no extra entry file, no extra
|
|
342
|
+
`config.path` route is written. The deploy walks exactly the same code path it
|
|
343
|
+
does today and emits exactly one function per app.
|
|
344
|
+
- The gate reads the flag at **build time** (in the deploy process env), so a
|
|
345
|
+
build produced without the flag can never contain the artifact regardless of
|
|
346
|
+
runtime env.
|
|
347
|
+
- This is covered by tests that run the real deploy path with the flag unset and
|
|
348
|
+
assert that no `*-agent-background` directory exists, and with the flag set and
|
|
349
|
+
assert that exactly one does (`deploy/workspace-deploy.spec.ts`,
|
|
350
|
+
`deploy/build.spec.ts`).
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
# Netlify Layer 2: concrete implementation notes
|
|
355
|
+
|
|
356
|
+
This section details the Netlify-specific Layer 2 optimization (the deploy emit
|
|
357
|
+
and the 15-min budget), reusing the framework's existing background-run
|
|
358
|
+
machinery and Netlify's **background functions** (async, up to **15 minutes**).
|
|
359
|
+
It is deliberately not a from-scratch system: ~90% of the required plumbing
|
|
360
|
+
already ships for Agent Teams, A2A, and integration webhooks — and the
|
|
361
|
+
host-agnostic Layer 1 above is what actually carries the run.
|
|
362
|
+
|
|
363
|
+
## Why a 15-min background invocation changes the math
|
|
364
|
+
|
|
365
|
+
The whole `auto_continue` / 40s soft-timeout dance exists to stay under the
|
|
366
|
+
**serverless function wall** (~60–65s synchronous on Netlify), not under any
|
|
367
|
+
model limit. Evidence:
|
|
368
|
+
|
|
369
|
+
- `builder-engine.ts:62` `MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS = 180_000`
|
|
370
|
+
(3 min) is allowed locally; the run loop has no inherent reason to stop at 40s.
|
|
371
|
+
- `run-manager.ts:58,68` `DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS` /
|
|
372
|
+
`HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000` are pinned just under the function
|
|
373
|
+
wall, and `templates/brain/netlify.toml` sets `[functions."*"] timeout = 75`.
|
|
374
|
+
|
|
375
|
+
Netlify **background functions** (any function whose deployed name ends in
|
|
376
|
+
`-background`) are invoked asynchronously: the HTTP POST returns `202 Accepted`
|
|
377
|
+
immediately and the function runs detached for up to **15 minutes**. Inside that
|
|
378
|
+
function there is no ~60s wall, so:
|
|
379
|
+
|
|
380
|
+
- The agent loop can run for minutes in a single invocation with **few or no
|
|
381
|
+
`auto_continue` continuations**.
|
|
382
|
+
- The per-model-call gateway cap (`MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000`,
|
|
383
|
+
`builder-engine.ts:60`) still applies per call — see
|
|
384
|
+
[Per-model-call gateway cap](#per-model-call-gateway-cap) — but the _run_ is no
|
|
385
|
+
longer chopped into 40s chunks.
|
|
386
|
+
|
|
387
|
+
This is exactly the Layer 1 worker with a concrete long-lived host: Netlify is
|
|
388
|
+
the durable worker, reached through the existing self-dispatch primitive — the
|
|
389
|
+
same primitive that, absent this layer, just chains shorter invocations.
|
|
390
|
+
|
|
391
|
+
## What already exists and is reused verbatim
|
|
392
|
+
|
|
393
|
+
The Agent Teams background processor is the template. The chat path can reuse
|
|
394
|
+
nearly all of it:
|
|
395
|
+
|
|
396
|
+
| Capability | Existing code | Reuse for chat |
|
|
397
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
398
|
+
| Fire a fresh function invocation with its own budget | `server/self-dispatch.ts:122` `fireInternalDispatch()` (HMAC-signed POST, 250ms settle race) | Point it at the `-background` function path instead of the same-function processor route |
|
|
399
|
+
| Processor route pattern (claim → run full loop → persist → finalize) | `agent-teams.ts` `processAgentTeamRun()` + route at `agent-chat-plugin.ts:6001` (`AGENT_TEAM_PROCESS_RUN_PATH = /_agent-native/agent-teams/_process-run`) | Add a sibling `/_agent-native/agent-chat/_process-run` that runs the **chat** loop |
|
|
400
|
+
| HMAC processor auth | `integrations/internal-token.ts:61,72` `signInternalToken` / `verifyInternalToken` (5-min TTL, task-id-bound, timing-safe); gated by `A2A_SECRET` | Same — sign with `runId` |
|
|
401
|
+
| Atomic single-claim (no double-processing) | `agent-teams-run-queue.ts:177` `claimAgentTeamRun()` (`UPDATE … WHERE status='queued' OR (status='running' AND updated_at < stuckCutoff)`) | Same shape; the chat already has an equivalent with `updateRunStatusIfRunning()` (`run-store.ts:392`, `UPDATE … WHERE id=? AND status='running'`) and `tryClaimRunSlot(threadId)` |
|
|
402
|
+
| The actual multi-step agent loop | `runAgentLoop()` (imported into both `production-agent.ts` and `agent-teams.ts:1790`) | Identical — call it from the background processor |
|
|
403
|
+
| Event persistence (SQL, ordered, idempotent) | `run-store.ts`: `insertRunEvent(runId, seq, json)` with `ON CONFLICT (run_id, seq) DO NOTHING` (`:452,466`); `getRunEventsSince(runId, fromSeq)` (`:471`) | Unchanged — background worker writes the same `agent_run_events` rows |
|
|
404
|
+
| Client reconnect / replay by cursor | `agent-chat-plugin.ts:7103` `GET /runs/:id/events?after=N` → `subscribeToRun(runId, after)`; cross-isolate SQL polling path `run-manager.ts:750` `subscribeFromSQL()` (polls `getRunEventsSince` every 500ms) | **This is the key reuse**: the background worker is a different isolate, so the client already falls through to the SQL-polling subscription. Reconnect already works across isolates. |
|
|
405
|
+
| Discover an in-flight run on reload | `agent-chat-plugin.ts:7130` `GET /runs/active?threadId=X` (returns `runId`, `turnId`, `status`, `heartbeatAt`, `lastProgressAt`, `serverNow`) | Unchanged |
|
|
406
|
+
| Heartbeat + stale reaper | `run-manager.ts:340` (1.5s heartbeat), `run-store.ts:19` `RUN_STALE_MS = 15_000`, `reapIfStale` / `reapAllStaleRuns` | Reused; see [Failure handling](#failure-handling-and-loud-terminal-state) for the 15s-vs-15min tension |
|
|
407
|
+
| Stuck-dispatch re-fire / reconcile | `agent-teams.ts:689` refire, `reconcileAgentTeamRunsForOwner` (`:797`) | Optional reuse for chat reconcile |
|
|
408
|
+
|
|
409
|
+
The single genuinely **new** infrastructure piece is **deploy-time function
|
|
410
|
+
splitting**: today the Nitro `netlify` preset emits exactly one function
|
|
411
|
+
(`.netlify/functions-internal/server/` → `server.mjs` re-exporting `main.mjs`,
|
|
412
|
+
patched in `workspace-deploy.ts:670` `patchNetlifyFunctionEntry`; routed via the
|
|
413
|
+
`config.path` array at `:742`). We must additionally emit a second function whose
|
|
414
|
+
name ends in `-background` that re-exports the **same** `main.mjs` handler.
|
|
415
|
+
|
|
416
|
+
## The dispatch flow (designed)
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
┌── Browser ─────────────────────────────────────────────────────────────┐
|
|
420
|
+
│ 1. POST /_agent-native/agent-chat { message, threadId, turnId } │
|
|
421
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
422
|
+
│ (foreground function: the normal interactive handler)
|
|
423
|
+
▼
|
|
424
|
+
┌── agent-chat handler (production-agent.ts) ────────────────────────────┐
|
|
425
|
+
│ 2. tryClaimRunSlot(threadId) → 409 if a run is already active │
|
|
426
|
+
│ 3. runId = generateRunId(); insertRun(runId, threadId, turnId) │
|
|
427
|
+
│ (status='running', heartbeat set) — run-store.ts:233 │
|
|
428
|
+
│ 4. IF durable-background enabled (hosted + flag + A2A_SECRET): │
|
|
429
|
+
│ fireInternalDispatch({ │
|
|
430
|
+
│ path: BACKGROUND_FUNCTION_INVOKE_PATH, // 202, detached │
|
|
431
|
+
│ taskId: runId, body: { threadId, turnId, message, … } }) │
|
|
432
|
+
│ return SSE stream = subscribeToRun(runId, 0) // immediately │
|
|
433
|
+
│ ELSE (local / flag off): │
|
|
434
|
+
│ startRun(runId, …) inline (today's behavior, unchanged) │
|
|
435
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
436
|
+
│ 202 leaves the box (250ms settle race)
|
|
437
|
+
▼
|
|
438
|
+
┌── Netlify background function (…-background, up to 15 min) ───────────┐
|
|
439
|
+
│ 5. Route: POST /_agent-native/agent-chat/_process-run │
|
|
440
|
+
│ 6. verifyInternalToken(runId, bearer) // internal-token.ts:72 │
|
|
441
|
+
│ 7. atomically claim runId (updateRunStatusIfRunning-style guard) │
|
|
442
|
+
│ 8. raise gateway cap for this invocation (see §gateway-cap) │
|
|
443
|
+
│ 9. startRun(runId, …, { softTimeoutMs: 0 OR ~13min }) │
|
|
444
|
+
│ → runAgentLoop(...) runs the FULL multi-step turn │
|
|
445
|
+
│ → emitRunEvent(...) persists every event to agent_run_events (SQL) │
|
|
446
|
+
│ 10. on finish: updateRunStatusIfRunning(runId,'completed'|'errored') │
|
|
447
|
+
│ + terminal event ('done'/'error') persisted │
|
|
448
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
449
|
+
▲ writes SQL events
|
|
450
|
+
│ reads SQL events (cross-isolate)
|
|
451
|
+
┌── Browser (same SSE response from step 4, OR reconnect) ──────────────┐
|
|
452
|
+
│ 11. The SSE stream is subscribeToRun(runId,0). Because the producer is │
|
|
453
|
+
│ a *different* isolate, it serves via subscribeFromSQL() — │
|
|
454
|
+
│ run-manager.ts:750 — polling getRunEventsSince every 500ms. │
|
|
455
|
+
│ 12. On disconnect/reload: GET /runs/active?threadId → runId+lastSeq, │
|
|
456
|
+
│ then GET /runs/:id/events?after=lastSeq resumes the same stream. │
|
|
457
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Endpoints / constants to add
|
|
461
|
+
|
|
462
|
+
- `BACKGROUND_FUNCTION_INVOKE_PATH` — the Netlify async path,
|
|
463
|
+
`/.netlify/functions/<app>-agent-background` (Netlify maps `…-background` to
|
|
464
|
+
async/202). The dispatch URL is built with `resolveSelfDispatchBaseUrl(event)`
|
|
465
|
+
(`self-dispatch.ts:59`) + that path. (Alternatively, give the background
|
|
466
|
+
function a `config.path` like `/_agent-native/_bg/*` so dispatch stays a clean
|
|
467
|
+
framework path and Netlify still routes it to the `-background` function and
|
|
468
|
+
invokes it async.)
|
|
469
|
+
- `AGENT_CHAT_PROCESS_RUN_PATH = "/_agent-native/agent-chat/_process-run"` — the
|
|
470
|
+
handler the background function actually runs (sibling to
|
|
471
|
+
`AGENT_TEAM_PROCESS_RUN_PATH`). It is reached _through_ the background
|
|
472
|
+
function, so it inherits the 15-min budget.
|
|
473
|
+
- Feature flag: `AGENT_CHAT_DURABLE_BACKGROUND` (env or per-app config),
|
|
474
|
+
defaulting **off**, gated additionally on `isHostedRuntime()` and
|
|
475
|
+
`hasConfiguredA2ASecret()`. Local dev keeps the inline path so SSE stays a
|
|
476
|
+
single live stream and no second function is needed.
|
|
477
|
+
|
|
478
|
+
## Where the soft-timeout / auto_continue logic changes
|
|
479
|
+
|
|
480
|
+
Today (`run-manager.ts:344`) every hosted run gets a 40s soft timeout that emits
|
|
481
|
+
`auto_continue` and the client re-POSTs. In the background-function path:
|
|
482
|
+
|
|
483
|
+
- **The background `startRun` gets `softTimeoutMs` ≈ 13 min** (a margin under
|
|
484
|
+
Netlify's 15-min hard kill), not 40s. To allow this, `resolveRunSoftTimeoutMs`
|
|
485
|
+
must learn a **background context**: the `HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000`
|
|
486
|
+
clamp (`run-manager.ts:170`) currently _defeats_ any larger value on hosted.
|
|
487
|
+
Add a `backgroundFunction: true` option that raises the ceiling to ~`780_000`
|
|
488
|
+
(13 min) for that one invocation. **Do not** change the default hosted ceiling
|
|
489
|
+
— the 40s clamp stays correct for the interactive/foreground path; the
|
|
490
|
+
Guardrail above still holds for non-background runs.
|
|
491
|
+
- **`auto_continue` becomes the rare exception, not the rule.** Most turns finish
|
|
492
|
+
inside 13 min with zero continuations, killing the re-hydration thrash
|
|
493
|
+
described in the Problem section. If a turn _does_ exceed 13 min, the existing
|
|
494
|
+
mechanism still works: emit `auto_continue` and re-fire **another background
|
|
495
|
+
dispatch** (mode `continue`) exactly as `agent-teams.ts:1886` does — i.e. the
|
|
496
|
+
continuation chains background invocations instead of bouncing back to the
|
|
497
|
+
browser. The client never has to drive continuation.
|
|
498
|
+
- **Internally, the worker should still checkpoint (Option A).** 15 min is large
|
|
499
|
+
but not infinite; combining background execution with idempotent/checkpointed
|
|
500
|
+
steps means even a continued run advances monotonically. Option B is strongest
|
|
501
|
+
containing Option A.
|
|
502
|
+
|
|
503
|
+
## How the client UX changes (SSE → SSE-over-SQL + reconnect)
|
|
504
|
+
|
|
505
|
+
Minimal client change, because the reconnect machinery already exists:
|
|
506
|
+
|
|
507
|
+
- **The response shape is unchanged.** Step 4 still returns an SSE
|
|
508
|
+
`ReadableStream` from `subscribeToRun(runId, 0)`. The client keeps reading SSE
|
|
509
|
+
exactly as today. The only difference is that the events are produced in
|
|
510
|
+
another isolate and arrive via the 500ms SQL-poll path
|
|
511
|
+
(`subscribeFromSQL`, `run-manager.ts:750`) instead of the in-memory fast path.
|
|
512
|
+
Latency goes from ~instant to ≤500ms per event — acceptable for chat.
|
|
513
|
+
- **Reconnect/leave-and-return becomes a first-class, reliable flow.** Because
|
|
514
|
+
the producer is detached on Netlify, closing the tab no longer kills the run.
|
|
515
|
+
On return, the client calls `GET /runs/active?threadId` (`:7130`) → gets
|
|
516
|
+
`runId` + status, then `GET /runs/:id/events?after=<lastSeq>` (`:7103`) to
|
|
517
|
+
replay from the cursor. This already works; we are just making it the primary
|
|
518
|
+
UX. Recommend the client persist `lastSeq` per thread so reconnect resumes
|
|
519
|
+
precisely.
|
|
520
|
+
- **Optional: drop the long-held foreground SSE entirely and poll.** Instead of
|
|
521
|
+
holding step-4's SSE open against the 75s foreground-function `timeout`
|
|
522
|
+
(`netlify.toml`), the foreground POST can return `{ runId, turnId }` (202-style
|
|
523
|
+
JSON) and the client immediately opens `GET /runs/:id/events?after=0`. This
|
|
524
|
+
avoids tying up a foreground function for the run's lifetime. Either works;
|
|
525
|
+
the JSON-then-poll variant is cleaner on Netlify because the interactive
|
|
526
|
+
function returns in well under 75s.
|
|
527
|
+
|
|
528
|
+
## Per-model-call gateway cap
|
|
529
|
+
|
|
530
|
+
Even in a 15-min function, a _single_ model call is still capped by the Builder
|
|
531
|
+
gateway: `MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000` (`builder-engine.ts:60`),
|
|
532
|
+
resolved by `getBuilderGatewayTimeoutMs()` (`:770`) /
|
|
533
|
+
`resolveMaxBuilderGatewayTimeoutMs()` (`:758`). Locally the cap is already
|
|
534
|
+
`180_000` (`:62`) when the gateway base URL is localhost.
|
|
535
|
+
|
|
536
|
+
Options, in scope-order:
|
|
537
|
+
|
|
538
|
+
1. **In scope / safe now:** keep the 45s per-call cap. A 15-min function makes
|
|
539
|
+
_many_ 45s calls; the cap bounds one call, not the run. This alone delivers
|
|
540
|
+
the goal (long multi-step runs finish) with **zero gateway changes**.
|
|
541
|
+
2. **Flag, out of scope until confirmed:** raise the per-call cap toward 180s in
|
|
542
|
+
the background context. `getBuilderGatewayTimeoutMs()` already honors
|
|
543
|
+
`AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS`, but it is hard-clamped to
|
|
544
|
+
`resolveMaxBuilderGatewayTimeoutMs()` = 45s for non-localhost gateways. Raising
|
|
545
|
+
it requires the **hosted Builder gateway to actually allow >45s upstream** —
|
|
546
|
+
unverified, and not the framework's call. Until confirmed, do not raise it.
|
|
547
|
+
3. **Escape hatch:** a direct Anthropic engine (no Builder gateway) has no 45s
|
|
548
|
+
cap, so a single very long call could use it. Out of scope for this change;
|
|
549
|
+
note it as the lever if a single model call truly needs >45s.
|
|
550
|
+
|
|
551
|
+
**Recommendation:** ship with option 1. The win is removing the _run-level_
|
|
552
|
+
ceiling; the per-call cap is orthogonal and rarely the binding constraint.
|
|
553
|
+
|
|
554
|
+
## Idempotency / dedup
|
|
555
|
+
|
|
556
|
+
Already strong; make the new claim match:
|
|
557
|
+
|
|
558
|
+
- **Run claim.** The foreground inserts the run row (`insertRun`,
|
|
559
|
+
`run-store.ts:233`) _before_ dispatching. The background processor must claim
|
|
560
|
+
it with a conditional update (mirror `updateRunStatusIfRunning`,
|
|
561
|
+
`run-store.ts:392`, or add a `claimRunForProcessing(runId)` that flips a
|
|
562
|
+
`processing` marker only from the unclaimed state). A duplicate Netlify
|
|
563
|
+
delivery (background functions can in theory be retried) then no-ops on the
|
|
564
|
+
second claim, exactly like `claimAgentTeamRun` returning `null`
|
|
565
|
+
(`agent-teams-run-queue.ts:177`).
|
|
566
|
+
- **Event dedup.** `insertRunEvent` is already idempotent on `(run_id, seq)`
|
|
567
|
+
via `ON CONFLICT … DO NOTHING` (`run-store.ts:452,466`). Re-emitting an event
|
|
568
|
+
with the same seq is a safe no-op, so a retried/overlapping producer cannot
|
|
569
|
+
duplicate the stream.
|
|
570
|
+
- **Dispatch token.** `fireInternalDispatch` signs `runId`
|
|
571
|
+
(`self-dispatch.ts:131`); the processor verifies it (`internal-token.ts:72`,
|
|
572
|
+
5-min TTL). A stale or forged dispatch is rejected.
|
|
573
|
+
|
|
574
|
+
## Failure handling and loud terminal state
|
|
575
|
+
|
|
576
|
+
- **15s heartbeat vs 15-min runs — the one real conflict.** The stale reaper
|
|
577
|
+
(`RUN_STALE_MS = 15_000`, `run-store.ts:19`; `reapIfStale`/`reapAllStaleRuns`)
|
|
578
|
+
marks a run `errored` after 15s without a heartbeat. The background worker
|
|
579
|
+
_does_ run `startRun`'s 1.5s heartbeat (`run-manager.ts:340`), so as long as
|
|
580
|
+
the worker is alive the row stays fresh and the reaper leaves it alone — this
|
|
581
|
+
is fine. The risk is a worker that is _slow to start_ (Netlify cold-start of
|
|
582
|
+
the background function) leaving a freshly-inserted `running` row unheartbeaten
|
|
583
|
+
for >15s, which the reaper would falsely kill. Mitigation: the foreground
|
|
584
|
+
insert sets `heartbeat_at = now` (`insertRun`), and the background claim should
|
|
585
|
+
bump the heartbeat immediately on entry; if cold starts can exceed 15s, widen
|
|
586
|
+
`RUN_STALE_MS` for rows known to be background-dispatched (e.g. a
|
|
587
|
+
`dispatch_mode` column or a separate, larger stale window for background runs).
|
|
588
|
+
- **Worker dies mid-run (crash / 15-min kill).** Heartbeat stops, the reaper
|
|
589
|
+
flips the row to `errored` and appends a synthetic terminal event
|
|
590
|
+
(`reapIfStale`), so the client's SQL-poll subscription sees a terminal event
|
|
591
|
+
and stops — no infinite spinner. This is the **loud terminal** contract from
|
|
592
|
+
Phase 0 already wired.
|
|
593
|
+
- **Dispatch never lands (202 lost).** Reuse the Agent-Teams reconcile pattern
|
|
594
|
+
(`reconcileAgentTeamRunsForOwner`, `agent-teams.ts:797`): if a `running` row
|
|
595
|
+
has no heartbeat after a grace window, re-fire the dispatch once, then fail
|
|
596
|
+
loud. Optional for the first slice.
|
|
597
|
+
- **Truthful terminal state.** Combined with the `reliable-mutations` Phase-0
|
|
598
|
+
proof-of-done discipline, a completed background run reports concrete proof;
|
|
599
|
+
a killed one reports `errored` with what was committed — never a false success.
|
|
600
|
+
|
|
601
|
+
## Phased implementation plan (smallest working slice first)
|
|
602
|
+
|
|
603
|
+
> Status: \*\*Slices 0–1 implemented and the Slice-3 background-aware stale window
|
|
604
|
+
>
|
|
605
|
+
> - background→background continuation chaining are implemented\*\*, all behind
|
|
606
|
+
> `AGENT_CHAT_DURABLE_BACKGROUND` (off by default). The host-agnostic baseline
|
|
607
|
+
> (Layer 1) carries the run on any host; the Netlify `-background` emit (Layer 2)
|
|
608
|
+
> is the deploy-time optimization. Slice 2's richer reconnect-first client UX and
|
|
609
|
+
> the internal per-step checkpointing (Option A) remain follow-ups; Slice 4
|
|
610
|
+
> (raising the per-call gateway cap) is intentionally out of scope (see
|
|
611
|
+
> [Per-model-call gateway cap](#per-model-call-gateway-cap)).
|
|
612
|
+
|
|
613
|
+
**Slice 0 — prove async dispatch on Netlify (no chat yet).**
|
|
614
|
+
Emit one extra `-background` function in the deploy build that re-exports the
|
|
615
|
+
existing `main.mjs` handler (extend `patchNetlifyFunctionEntry`,
|
|
616
|
+
`workspace-deploy.ts:670`, and the single-template Netlify output in
|
|
617
|
+
`deploy/build.ts:1935`). Add `AGENT_CHAT_PROCESS_RUN_PATH` returning a stub that
|
|
618
|
+
just writes a run event. Dispatch to it from a temporary test route via
|
|
619
|
+
`fireInternalDispatch`. **Done when:** a POST returns 202 immediately and the
|
|
620
|
+
stub writes an event to `agent_run_events` from the background function, visible
|
|
621
|
+
via `GET /runs/:id/events`. This de-risks the only genuinely new infra.
|
|
622
|
+
|
|
623
|
+
**Slice 1 — route the real chat loop through the background function (flagged).**
|
|
624
|
+
Behind `AGENT_CHAT_DURABLE_BACKGROUND` (off by default; hosted + `A2A_SECRET`
|
|
625
|
+
only): foreground handler inserts the run row, dispatches, returns the SSE
|
|
626
|
+
stream from `subscribeToRun`. The background `_process-run` claims the run and
|
|
627
|
+
calls the same `startRun` + `runAgentLoop` the inline path uses, with
|
|
628
|
+
`softTimeoutMs ≈ 13min` (new `backgroundFunction` option in
|
|
629
|
+
`resolveRunSoftTimeoutMs`). Per-call gateway cap stays at 45s (option 1).
|
|
630
|
+
**Done when:** a long multi-step turn that thrashes today completes in one
|
|
631
|
+
background invocation with zero `auto_continue`, events streaming to the client
|
|
632
|
+
via the SQL-poll path, terminal `done` persisted.
|
|
633
|
+
|
|
634
|
+
**Slice 2 — reconnect/leave-and-return UX.**
|
|
635
|
+
Make the client persist `lastSeq` per thread and, on load, use
|
|
636
|
+
`/runs/active` + `/runs/:id/events?after=lastSeq` as the primary resume path.
|
|
637
|
+
Optionally switch the foreground POST to return `{ runId, turnId }` JSON instead
|
|
638
|
+
of holding SSE, then poll. **Done when:** closing/reopening the tab mid-run
|
|
639
|
+
resumes the live stream with no lost or duplicated events.
|
|
640
|
+
|
|
641
|
+
**Slice 3 — robustness.**
|
|
642
|
+
Background-aware stale window (cold-start tolerance), reconcile/re-fire for lost
|
|
643
|
+
dispatches, and background→background `auto_continue` chaining for the rare
|
|
644
|
+
|
|
645
|
+
> 13-min turn (mirror `agent-teams.ts:1886`). Internal checkpointing (Option A)
|
|
646
|
+
> for monotonic progress across any continuation.
|
|
647
|
+
|
|
648
|
+
**Slice 4 (optional, separate decision) — raise the per-call gateway cap.**
|
|
649
|
+
Only after confirming the hosted Builder gateway accepts >45s upstream; wire
|
|
650
|
+
`AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS` through with a background-context max.
|
|
651
|
+
Out of scope until that confirmation exists.
|
|
652
|
+
|
|
653
|
+
## Open risks / unknowns
|
|
654
|
+
|
|
655
|
+
1. **Netlify background-function invocation contract.** Confirm the exact
|
|
656
|
+
trigger: name-suffix `-background` invoked at `/.netlify/functions/<name>`
|
|
657
|
+
returning 202, and whether a `config.path` route can also mark a function
|
|
658
|
+
background. The deploy currently emits a single function — splitting it is the
|
|
659
|
+
main new work and must not regress the existing `config.path` routing
|
|
660
|
+
(`workspace-deploy.ts:742`).
|
|
661
|
+
2. **Cold-start vs 15s stale reaper** (detailed above) — the highest-likelihood
|
|
662
|
+
false-failure; needs the background-aware stale window or an on-entry
|
|
663
|
+
heartbeat bump.
|
|
664
|
+
3. **Hosted gateway >45s** — unknown whether upstream allows it; gates Slice 4.
|
|
665
|
+
4. **Two functions sharing one bundle** — both re-export the same `main.mjs`, so
|
|
666
|
+
`includedFiles: ["**"]` (`workspace-deploy.ts:747`) must cover the background
|
|
667
|
+
function dir too; verify bundle size and that env (`A2A_SECRET`, DB URL) is
|
|
668
|
+
present in the background function's environment.
|
|
669
|
+
5. **Cost / concurrency** — background functions are billed and concurrency-
|
|
670
|
+
limited differently; long runs hold a slot for minutes. Out of scope to
|
|
671
|
+
solve, but flag for capacity planning.
|