@agent-native/core 0.70.2 → 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 +19 -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/action.ts +21 -10
- 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/app/components/dashboard/SqlChart.tsx +33 -6
- 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/app/components/recorder/recorder-engine.ts +8 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
- 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/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +1 -0
- package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
- 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 +17 -20
- 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/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
- package/corpus/templates/clips/shared/upload-limits.ts +5 -2
- package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
- package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
- package/dist/action.js +21 -10
- package/dist/action.js.map +1 -1
- 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
|
@@ -64,9 +64,42 @@ export const DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS = 40_000;
|
|
|
64
64
|
* env value just guarantees the cutoff is a hard error instead of a graceful
|
|
65
65
|
* hand-off. Any resolved value above this is clamped down when hosted. Local
|
|
66
66
|
* dev (non-hosted) is left alone so long-running local turns aren't chunked.
|
|
67
|
+
*
|
|
68
|
+
* IMPORTANT: this clamp is for the INTERACTIVE / foreground path and must NOT
|
|
69
|
+
* be raised. The foreground POST still rides a synchronous serverless function
|
|
70
|
+
* (~60-65s wall), so 40s remains correct there. The only sanctioned exception
|
|
71
|
+
* is the opt-in `backgroundFunction` mode (see
|
|
72
|
+
* `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`), which runs inside a Netlify background
|
|
73
|
+
* function (no ~60s wall, 15-min budget) and therefore can safely outlast 40s.
|
|
67
74
|
*/
|
|
68
75
|
export const HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000;
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Hard ceiling for the soft timeout when a run executes inside a Netlify
|
|
79
|
+
* background function (any deployed function whose name ends in `-background`).
|
|
80
|
+
* Background functions return 202 immediately and run detached for up to 15
|
|
81
|
+
* minutes, so the ~60s synchronous function wall that 40s defends against does
|
|
82
|
+
* NOT apply. 13 minutes leaves ~2 min of headroom under Netlify's 15-min hard
|
|
83
|
+
* kill to abort, persist the partial turn, write the terminal event, and (for
|
|
84
|
+
* the rare >13-min turn) self-fire another background continuation.
|
|
85
|
+
*
|
|
86
|
+
* This ceiling is used ONLY when a caller explicitly opts in with
|
|
87
|
+
* `backgroundFunction: true`. It does not change the foreground/interactive
|
|
88
|
+
* ceiling and does not fire unless the durable-background path dispatched the
|
|
89
|
+
* run into a background function. Per the design doc Guardrail, the 40s
|
|
90
|
+
* interactive clamp stays correct for every non-background run.
|
|
91
|
+
*/
|
|
92
|
+
export const BACKGROUND_SOFT_TIMEOUT_CEILING_MS = 13 * 60_000; // 780_000
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Default soft-timeout budget for a background-function run when the caller
|
|
96
|
+
* does not pass an explicit `softTimeoutMs`. Same value as the ceiling — we
|
|
97
|
+
* want a background turn to use nearly its whole 15-min budget before handing
|
|
98
|
+
* off to a chained background continuation.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS =
|
|
101
|
+
BACKGROUND_SOFT_TIMEOUT_CEILING_MS;
|
|
102
|
+
|
|
70
103
|
/** Default SQL retention for completed run event logs (24 hours). */
|
|
71
104
|
export const DEFAULT_COMPLETED_RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
72
105
|
|
|
@@ -137,10 +170,28 @@ export interface StartRunOptions {
|
|
|
137
170
|
* share one `turnId` so the durable assistant message can be folded across
|
|
138
171
|
* them instead of dropped per-run. Defaults to the runId (turn == run). */
|
|
139
172
|
turnId?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Opt into the durable-background-function soft-timeout regime for THIS run
|
|
175
|
+
* only. When true, `resolveRunSoftTimeoutMs` lifts the hosted ceiling from
|
|
176
|
+
* 40s to ~13min (`BACKGROUND_SOFT_TIMEOUT_CEILING_MS`) because the run is
|
|
177
|
+
* executing inside a Netlify background function (no ~60s wall). Off by
|
|
178
|
+
* default — the foreground/interactive path never sets this, so its 40s
|
|
179
|
+
* clamp is unchanged. See the design doc + the durable-background dispatch
|
|
180
|
+
* decision in production-agent.ts.
|
|
181
|
+
*/
|
|
182
|
+
backgroundFunction?: boolean;
|
|
140
183
|
}
|
|
141
184
|
|
|
142
185
|
export interface ResolveRunSoftTimeoutOptions {
|
|
143
186
|
useHostedDefault?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the soft timeout for a run executing inside a Netlify background
|
|
189
|
+
* function. Lifts the hosted clamp to `BACKGROUND_SOFT_TIMEOUT_CEILING_MS`
|
|
190
|
+
* (~13min) for this invocation only and, when no override/env is supplied,
|
|
191
|
+
* defaults to `DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS`. Does NOT change the
|
|
192
|
+
* foreground ceiling. Off by default.
|
|
193
|
+
*/
|
|
194
|
+
backgroundFunction?: boolean;
|
|
144
195
|
}
|
|
145
196
|
|
|
146
197
|
function isHostedRuntime(): boolean {
|
|
@@ -172,15 +223,21 @@ export function resolveRunSoftTimeoutMs(
|
|
|
172
223
|
options?: ResolveRunSoftTimeoutOptions,
|
|
173
224
|
): number {
|
|
174
225
|
const hosted = isHostedRuntime();
|
|
226
|
+
const background = options?.backgroundFunction === true;
|
|
227
|
+
// The interactive/foreground ceiling is 40s — the synchronous serverless
|
|
228
|
+
// function wall. A background-function run (opt-in only) has no ~60s wall, so
|
|
229
|
+
// it is allowed to outlast that and is clamped to the larger 13-min ceiling
|
|
230
|
+
// instead. The 40s clamp for non-background hosted runs is unchanged.
|
|
231
|
+
const ceiling = background
|
|
232
|
+
? BACKGROUND_SOFT_TIMEOUT_CEILING_MS
|
|
233
|
+
: HOSTED_SOFT_TIMEOUT_CEILING_MS;
|
|
175
234
|
// A configured/env soft timeout that exceeds the upstream walls can never
|
|
176
235
|
// actually fire (the gateway/function kills the run first), so clamp it down
|
|
177
236
|
// on hosted runtimes. This is what makes auto_continue reach the client
|
|
178
237
|
// instead of the run dying as builder_gateway_timeout / stale_run. `0` means
|
|
179
238
|
// "disabled" and is never clamped up.
|
|
180
239
|
const clampHosted = (ms: number): number =>
|
|
181
|
-
hosted && ms >
|
|
182
|
-
? HOSTED_SOFT_TIMEOUT_CEILING_MS
|
|
183
|
-
: ms;
|
|
240
|
+
hosted && ms > ceiling ? ceiling : ms;
|
|
184
241
|
|
|
185
242
|
if (typeof overrideMs === "number" && Number.isFinite(overrideMs)) {
|
|
186
243
|
return clampHosted(Math.max(0, overrideMs));
|
|
@@ -190,6 +247,11 @@ export function resolveRunSoftTimeoutMs(
|
|
|
190
247
|
const raw = Number(envValue);
|
|
191
248
|
if (Number.isFinite(raw) && raw >= 0) return clampHosted(raw);
|
|
192
249
|
}
|
|
250
|
+
// A background-function run uses the full background budget by default; the
|
|
251
|
+
// foreground default (40s) is unchanged.
|
|
252
|
+
if (background) {
|
|
253
|
+
return hosted ? DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS : 0;
|
|
254
|
+
}
|
|
193
255
|
return options?.useHostedDefault && hosted
|
|
194
256
|
? DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS
|
|
195
257
|
: 0;
|
|
@@ -343,6 +405,7 @@ export function startRun(
|
|
|
343
405
|
}, 1500);
|
|
344
406
|
const softTimeoutMs = resolveRunSoftTimeoutMs(options?.softTimeoutMs, {
|
|
345
407
|
useHostedDefault: options?.useHostedSoftTimeoutDefault === true,
|
|
408
|
+
backgroundFunction: options?.backgroundFunction === true,
|
|
346
409
|
});
|
|
347
410
|
const softTimeoutTimer =
|
|
348
411
|
softTimeoutMs > 0
|
|
@@ -18,6 +18,21 @@ let _initPromise: Promise<void> | undefined;
|
|
|
18
18
|
*/
|
|
19
19
|
export const RUN_STALE_MS = 15_000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Stale window for runs dispatched into a Netlify background function
|
|
23
|
+
* (`dispatch_mode = 'background'`). The design doc flags the 15s reaper vs a
|
|
24
|
+
* background cold-start as the #1 false-failure risk: the foreground POST
|
|
25
|
+
* inserts the `running` row, then `fireInternalDispatch` returns 202 and the
|
|
26
|
+
* background function may take >15s to cold-start and emit its first heartbeat.
|
|
27
|
+
* With the normal 15s window the reaper would falsely kill that freshly-
|
|
28
|
+
* inserted-but-not-yet-heartbeaten row. 90s tolerates a slow background
|
|
29
|
+
* cold-start while still reaping a genuinely dead background worker promptly.
|
|
30
|
+
*
|
|
31
|
+
* Only applied to rows explicitly marked background-dispatched; ordinary
|
|
32
|
+
* foreground runs keep the tight 15s window unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export const BACKGROUND_RUN_STALE_MS = 90_000;
|
|
35
|
+
|
|
21
36
|
export const STALE_RUN_ERROR_EVENT = {
|
|
22
37
|
type: "error",
|
|
23
38
|
error:
|
|
@@ -44,7 +59,8 @@ async function ensureRunTables(): Promise<void> {
|
|
|
44
59
|
last_progress_at ${intType()},
|
|
45
60
|
turn_id TEXT,
|
|
46
61
|
error_code TEXT,
|
|
47
|
-
error_detail TEXT
|
|
62
|
+
error_detail TEXT,
|
|
63
|
+
dispatch_mode TEXT
|
|
48
64
|
)
|
|
49
65
|
`);
|
|
50
66
|
// Backfill heartbeat_at on older deployments.
|
|
@@ -97,7 +113,16 @@ async function ensureRunTables(): Promise<void> {
|
|
|
97
113
|
// error_code / error_detail = terminal failure classification captured
|
|
98
114
|
// at completion so errored/cut-off runs are queryable for
|
|
99
115
|
// pattern analysis (see listErroredRuns).
|
|
100
|
-
|
|
116
|
+
// dispatch_mode marks how a run was started: NULL/"foreground" for the
|
|
117
|
+
// normal synchronous path, "background" for a run dispatched into a
|
|
118
|
+
// Netlify background function. The reaper/claim widen the stale window
|
|
119
|
+
// for background rows so a slow cold-start isn't falsely reaped.
|
|
120
|
+
for (const col of [
|
|
121
|
+
"turn_id",
|
|
122
|
+
"error_code",
|
|
123
|
+
"error_detail",
|
|
124
|
+
"dispatch_mode",
|
|
125
|
+
] as const) {
|
|
101
126
|
try {
|
|
102
127
|
if (isPostgres()) {
|
|
103
128
|
await client.execute(
|
|
@@ -234,16 +259,68 @@ export async function insertRun(
|
|
|
234
259
|
id: string,
|
|
235
260
|
threadId: string,
|
|
236
261
|
turnId?: string,
|
|
262
|
+
options?: { dispatchMode?: "foreground" | "background" },
|
|
237
263
|
): Promise<void> {
|
|
238
264
|
await ensureRunTables();
|
|
239
265
|
const client = getDbExec();
|
|
240
266
|
const now = Date.now();
|
|
241
267
|
await client.execute({
|
|
242
|
-
sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id) VALUES (?, ?, 'running', ?, ?, ?, ?)`,
|
|
243
|
-
args: [
|
|
268
|
+
sql: `INSERT INTO agent_runs (id, thread_id, status, started_at, heartbeat_at, last_progress_at, turn_id, dispatch_mode) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)`,
|
|
269
|
+
args: [
|
|
270
|
+
id,
|
|
271
|
+
threadId,
|
|
272
|
+
now,
|
|
273
|
+
now,
|
|
274
|
+
now,
|
|
275
|
+
turnId ?? id,
|
|
276
|
+
options?.dispatchMode ?? null,
|
|
277
|
+
],
|
|
244
278
|
});
|
|
245
279
|
}
|
|
246
280
|
|
|
281
|
+
/**
|
|
282
|
+
* SQL fragment that resolves the per-row stale cutoff. Background-dispatched
|
|
283
|
+
* runs (`dispatch_mode` starting with `background`) tolerate a much longer gap
|
|
284
|
+
* without a heartbeat (slow Netlify background-function cold-start) before being
|
|
285
|
+
* reaped; every other run keeps the tight 15s window. The bound `now` is
|
|
286
|
+
* subtracted by the resolved window so the comparison is
|
|
287
|
+
* `COALESCE(heartbeat_at, started_at) < (now - window)`.
|
|
288
|
+
*
|
|
289
|
+
* `dispatch_mode` is one of NULL/"foreground" (normal sync path), "background"
|
|
290
|
+
* (foreground inserted the row for a background dispatch), or
|
|
291
|
+
* "background-processing" (the background worker claimed it). Both background
|
|
292
|
+
* states get the wider window via a LIKE-prefix match.
|
|
293
|
+
*/
|
|
294
|
+
function backgroundAwareStaleCutoffSql(): string {
|
|
295
|
+
return `(? - CASE WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Atomically claim a background-dispatched run for processing. The foreground
|
|
300
|
+
* POST inserts the run row with `dispatch_mode = 'background'`; the FIRST
|
|
301
|
+
* delivery of the background dispatch flips it to `background-processing` and
|
|
302
|
+
* wins the claim. A duplicate Netlify delivery (background functions can be
|
|
303
|
+
* retried) sees `background-processing` and loses, so it no-ops — mirroring
|
|
304
|
+
* `claimAgentTeamRun` returning null. Returns true when this caller won.
|
|
305
|
+
*
|
|
306
|
+
* Idempotent and conditional: the WHERE clause only matches the unclaimed
|
|
307
|
+
* `background` state AND a still-running row, so a reaped/terminal row can't be
|
|
308
|
+
* re-claimed.
|
|
309
|
+
*/
|
|
310
|
+
export async function claimBackgroundRun(runId: string): Promise<boolean> {
|
|
311
|
+
await ensureRunTables();
|
|
312
|
+
const client = getDbExec();
|
|
313
|
+
const { rowsAffected } = await client.execute({
|
|
314
|
+
sql: `UPDATE agent_runs
|
|
315
|
+
SET dispatch_mode = 'background-processing'
|
|
316
|
+
WHERE id = ?
|
|
317
|
+
AND status = 'running'
|
|
318
|
+
AND dispatch_mode = 'background'`,
|
|
319
|
+
args: [runId],
|
|
320
|
+
});
|
|
321
|
+
return (rowsAffected ?? 0) > 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
247
324
|
/**
|
|
248
325
|
* Atomically acquire a run lease for a thread. Succeeds (returns true) only
|
|
249
326
|
* when no other run for the same thread is currently status='running' with a
|
|
@@ -256,18 +333,37 @@ export async function insertRun(
|
|
|
256
333
|
*/
|
|
257
334
|
export async function tryClaimRunSlot(
|
|
258
335
|
threadId: string,
|
|
259
|
-
maxStaleMs
|
|
336
|
+
maxStaleMs?: number,
|
|
260
337
|
): Promise<{ claimed: boolean; activeRunId: string | null }> {
|
|
261
338
|
await ensureRunTables();
|
|
262
339
|
const client = getDbExec();
|
|
263
|
-
const
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
// Default: per-row background-aware window so a live background run (which can
|
|
342
|
+
// legitimately go >15s between heartbeats during a cold-start) isn't seen as
|
|
343
|
+
// "free" and double-claimed by a racing foreground POST. An explicit
|
|
344
|
+
// `maxStaleMs` override keeps a flat window for callers that want one.
|
|
345
|
+
if (typeof maxStaleMs === "number") {
|
|
346
|
+
const heartbeatCutoff = now - maxStaleMs;
|
|
347
|
+
const { rows } = await client.execute({
|
|
348
|
+
sql: `SELECT id FROM agent_runs
|
|
349
|
+
WHERE thread_id = ?
|
|
350
|
+
AND status = 'running'
|
|
351
|
+
AND COALESCE(heartbeat_at, started_at) >= ?
|
|
352
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
353
|
+
args: [threadId, heartbeatCutoff],
|
|
354
|
+
});
|
|
355
|
+
if (rows.length > 0) {
|
|
356
|
+
return { claimed: false, activeRunId: (rows[0] as { id: string }).id };
|
|
357
|
+
}
|
|
358
|
+
return { claimed: true, activeRunId: null };
|
|
359
|
+
}
|
|
264
360
|
const { rows } = await client.execute({
|
|
265
361
|
sql: `SELECT id FROM agent_runs
|
|
266
362
|
WHERE thread_id = ?
|
|
267
363
|
AND status = 'running'
|
|
268
|
-
AND COALESCE(heartbeat_at, started_at) >=
|
|
364
|
+
AND COALESCE(heartbeat_at, started_at) >= ${backgroundAwareStaleCutoffSql()}
|
|
269
365
|
ORDER BY started_at DESC LIMIT 1`,
|
|
270
|
-
args: [threadId,
|
|
366
|
+
args: [threadId, now],
|
|
271
367
|
});
|
|
272
368
|
if (rows.length > 0) {
|
|
273
369
|
const row = rows[0] as { id: string };
|
|
@@ -335,12 +431,20 @@ export async function bumpRunProgress(runId: string): Promise<void> {
|
|
|
335
431
|
*/
|
|
336
432
|
export async function reapIfStale(
|
|
337
433
|
runId: string,
|
|
338
|
-
maxStaleMs
|
|
434
|
+
maxStaleMs?: number,
|
|
339
435
|
): Promise<boolean> {
|
|
340
436
|
await ensureRunTables();
|
|
341
437
|
const client = getDbExec();
|
|
342
438
|
const completedAt = Date.now();
|
|
343
|
-
|
|
439
|
+
// Background-dispatched runs get the wider stale window so a slow cold-start
|
|
440
|
+
// isn't reaped; foreground runs keep the tight 15s window. An explicit
|
|
441
|
+
// override forces a flat window for callers that want one.
|
|
442
|
+
const staleClause =
|
|
443
|
+
typeof maxStaleMs === "number"
|
|
444
|
+
? `COALESCE(heartbeat_at, started_at) < ?`
|
|
445
|
+
: `COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`;
|
|
446
|
+
const staleArgs =
|
|
447
|
+
typeof maxStaleMs === "number" ? [completedAt - maxStaleMs] : [completedAt];
|
|
344
448
|
const { rowsAffected } = await client.execute({
|
|
345
449
|
sql: `UPDATE agent_runs
|
|
346
450
|
SET status = 'errored',
|
|
@@ -349,13 +453,13 @@ export async function reapIfStale(
|
|
|
349
453
|
error_detail = ?
|
|
350
454
|
WHERE id = ?
|
|
351
455
|
AND status = 'running'
|
|
352
|
-
AND
|
|
456
|
+
AND ${staleClause}`,
|
|
353
457
|
args: [
|
|
354
458
|
completedAt,
|
|
355
459
|
STALE_RUN_ERROR_EVENT.errorCode,
|
|
356
460
|
STALE_RUN_ERROR_EVENT.details,
|
|
357
461
|
runId,
|
|
358
|
-
|
|
462
|
+
...staleArgs,
|
|
359
463
|
],
|
|
360
464
|
});
|
|
361
465
|
const reaped = (rowsAffected ?? 0) > 0;
|
|
@@ -675,12 +779,13 @@ export async function getCurrentTurnEventsForThread(
|
|
|
675
779
|
export async function reapAllStaleRuns(): Promise<number> {
|
|
676
780
|
await ensureRunTables();
|
|
677
781
|
const client = getDbExec();
|
|
678
|
-
const
|
|
782
|
+
const now = Date.now();
|
|
783
|
+
// Background-dispatched runs use the wider window; everything else 15s.
|
|
679
784
|
const stale = await client.execute({
|
|
680
785
|
sql: `SELECT id FROM agent_runs
|
|
681
786
|
WHERE status = 'running'
|
|
682
|
-
AND COALESCE(heartbeat_at, started_at) <
|
|
683
|
-
args: [
|
|
787
|
+
AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
|
|
788
|
+
args: [now],
|
|
684
789
|
});
|
|
685
790
|
const completedAt = Date.now();
|
|
686
791
|
const { rowsAffected } = await client.execute({
|
|
@@ -690,12 +795,12 @@ export async function reapAllStaleRuns(): Promise<number> {
|
|
|
690
795
|
error_code = ?,
|
|
691
796
|
error_detail = ?
|
|
692
797
|
WHERE status = 'running'
|
|
693
|
-
AND COALESCE(heartbeat_at, started_at) <
|
|
798
|
+
AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
|
|
694
799
|
args: [
|
|
695
800
|
completedAt,
|
|
696
801
|
STALE_RUN_ERROR_EVENT.errorCode,
|
|
697
802
|
STALE_RUN_ERROR_EVENT.details,
|
|
698
|
-
|
|
803
|
+
completedAt,
|
|
699
804
|
],
|
|
700
805
|
});
|
|
701
806
|
for (const row of stale.rows) {
|
|
@@ -730,15 +835,15 @@ export async function cleanupOldRuns(
|
|
|
730
835
|
// SELECT covers BOTH UPDATE conditions so the terminal-event-append loop
|
|
731
836
|
// below catches every row we're about to flip — a 24h-old row with a
|
|
732
837
|
// somehow-fresh heartbeat would slip past a heartbeat-only SELECT.
|
|
733
|
-
const
|
|
838
|
+
const now = Date.now();
|
|
734
839
|
const stale = await client.execute({
|
|
735
840
|
sql: `SELECT id FROM agent_runs
|
|
736
841
|
WHERE status = 'running'
|
|
737
842
|
AND (
|
|
738
|
-
COALESCE(heartbeat_at, started_at) <
|
|
843
|
+
COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}
|
|
739
844
|
OR started_at < ?
|
|
740
845
|
)`,
|
|
741
|
-
args: [
|
|
846
|
+
args: [now, cutoff],
|
|
742
847
|
});
|
|
743
848
|
const completedAt = Date.now();
|
|
744
849
|
await client.execute({
|
|
@@ -755,7 +860,8 @@ export async function cleanupOldRuns(
|
|
|
755
860
|
cutoff,
|
|
756
861
|
],
|
|
757
862
|
});
|
|
758
|
-
// Also expire runs whose heartbeat is stale — producer has died.
|
|
863
|
+
// Also expire runs whose heartbeat is stale — producer has died. Uses the
|
|
864
|
+
// background-aware window so a slow background cold-start isn't reaped early.
|
|
759
865
|
await client.execute({
|
|
760
866
|
sql: `UPDATE agent_runs
|
|
761
867
|
SET status = 'errored',
|
|
@@ -763,12 +869,12 @@ export async function cleanupOldRuns(
|
|
|
763
869
|
error_code = ?,
|
|
764
870
|
error_detail = ?
|
|
765
871
|
WHERE status = 'running'
|
|
766
|
-
AND COALESCE(heartbeat_at, started_at) <
|
|
872
|
+
AND COALESCE(heartbeat_at, started_at) < ${backgroundAwareStaleCutoffSql()}`,
|
|
767
873
|
args: [
|
|
768
874
|
completedAt,
|
|
769
875
|
STALE_RUN_ERROR_EVENT.errorCode,
|
|
770
876
|
STALE_RUN_ERROR_EVENT.details,
|
|
771
|
-
|
|
877
|
+
completedAt,
|
|
772
878
|
],
|
|
773
879
|
});
|
|
774
880
|
for (const row of stale.rows) {
|
|
@@ -129,6 +129,26 @@ export interface AgentChatRequest {
|
|
|
129
129
|
attachments?: AgentChatAttachment[];
|
|
130
130
|
/** Internal retry/continuation requests should not create visible user turns. */
|
|
131
131
|
internalContinuation?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Internal marker set ONLY by the durable-background self-dispatch (see
|
|
134
|
+
* `AGENT_CHAT_BACKGROUND_RUN_FIELD`). Present when the agent-chat handler is
|
|
135
|
+
* re-entered as the background worker: it carries the pre-claimed `runId` and
|
|
136
|
+
* logical `turnId` so the worker runs the loop inline with the background
|
|
137
|
+
* soft-timeout instead of re-claiming the slot or re-dispatching. Untrusted
|
|
138
|
+
* on its own — the `_process-run` route HMAC-verifies the dispatch before
|
|
139
|
+
* invoking the handler. Absent on every normal client request.
|
|
140
|
+
*/
|
|
141
|
+
__backgroundRun?: {
|
|
142
|
+
runId: string;
|
|
143
|
+
turnId?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Number of server-driven background→background continuations already
|
|
146
|
+
* chained into this logical turn (0 on the first chunk). The worker
|
|
147
|
+
* increments this when it re-fires `_process-run` at a soft-timeout
|
|
148
|
+
* boundary and refuses to chain past `MAX_BACKGROUND_RUN_CONTINUATIONS`.
|
|
149
|
+
*/
|
|
150
|
+
continuationCount?: number;
|
|
151
|
+
};
|
|
132
152
|
/**
|
|
133
153
|
* Stable identity for the logical assistant turn this request belongs to.
|
|
134
154
|
* The client sends the SAME turnId for the initial POST and every
|
|
@@ -1072,6 +1072,18 @@ function AgentPanelInner({
|
|
|
1072
1072
|
</button>
|
|
1073
1073
|
</DropdownMenuTrigger>
|
|
1074
1074
|
<DropdownMenuContent align="end" sideOffset={6} className="w-48">
|
|
1075
|
+
{onCollapse && (
|
|
1076
|
+
<>
|
|
1077
|
+
<DropdownMenuItem onSelect={onCollapse}>
|
|
1078
|
+
<IconLayoutSidebarRightCollapse
|
|
1079
|
+
size={14}
|
|
1080
|
+
className="shrink-0"
|
|
1081
|
+
/>
|
|
1082
|
+
Collapse sidebar
|
|
1083
|
+
</DropdownMenuItem>
|
|
1084
|
+
<DropdownMenuSeparator />
|
|
1085
|
+
</>
|
|
1086
|
+
)}
|
|
1075
1087
|
{mode === "chat" && toggleHistory && (
|
|
1076
1088
|
<DropdownMenuItem onSelect={toggleHistory}>
|
|
1077
1089
|
<IconHistory size={14} className="shrink-0" />
|
|
@@ -1133,15 +1145,6 @@ function AgentPanelInner({
|
|
|
1133
1145
|
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
1134
1146
|
</DropdownMenuItem>
|
|
1135
1147
|
)}
|
|
1136
|
-
{onCollapse && (
|
|
1137
|
-
<DropdownMenuItem onSelect={onCollapse}>
|
|
1138
|
-
<IconLayoutSidebarRightCollapse
|
|
1139
|
-
size={14}
|
|
1140
|
-
className="shrink-0"
|
|
1141
|
-
/>
|
|
1142
|
-
Collapse sidebar
|
|
1143
|
-
</DropdownMenuItem>
|
|
1144
|
-
)}
|
|
1145
1148
|
{((mode === "chat" && activeTabId) ||
|
|
1146
1149
|
(mode === "cli" && canUseCodeTools && activeCliTab)) && (
|
|
1147
1150
|
<>
|
|
@@ -119,6 +119,10 @@ interface FolderNode {
|
|
|
119
119
|
/** Full slash path of the folder, used as a stable key. */
|
|
120
120
|
path: string;
|
|
121
121
|
children: TreeNode[];
|
|
122
|
+
/** Present when the folder was authored as an explicit directory entry (a
|
|
123
|
+
* `path` ending in `/`), carrying its note/metadata + flat `entries` index. */
|
|
124
|
+
entry?: FileTreeEntry;
|
|
125
|
+
index?: number;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
type TreeNode = FolderNode | FileLeaf;
|
|
@@ -136,6 +140,10 @@ interface FolderBuild {
|
|
|
136
140
|
files: FileLeaf[];
|
|
137
141
|
/** Insertion order of child names (folders + files) for stable rendering. */
|
|
138
142
|
order: string[];
|
|
143
|
+
/** Set when an explicit directory entry (trailing slash) targets this folder,
|
|
144
|
+
* carrying its note/metadata + flat `entries` index. */
|
|
145
|
+
entry?: FileTreeEntry;
|
|
146
|
+
index?: number;
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
function makeFolder(name: string, path: string): FolderBuild {
|
|
@@ -154,8 +162,15 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
|
|
|
154
162
|
entries.forEach((entry, index) => {
|
|
155
163
|
const segments = entry.path.split("/").filter(Boolean);
|
|
156
164
|
if (segments.length === 0) return;
|
|
157
|
-
|
|
158
|
-
|
|
165
|
+
|
|
166
|
+
// A trailing slash marks a DIRECTORY entry: every segment is a folder and
|
|
167
|
+
// the entry's note/metadata attaches to the deepest folder (the last segment
|
|
168
|
+
// does NOT become a file leaf). Because folders are keyed by name, this also
|
|
169
|
+
// merges with any folder the sibling file paths already implied — so
|
|
170
|
+
// `packages/shared/` and `packages/shared/src/…` collapse onto one `shared`
|
|
171
|
+
// folder instead of a duplicate folder + file pair.
|
|
172
|
+
const isDir = /\/\s*$/.test(entry.path);
|
|
173
|
+
const folderSegments = isDir ? segments : segments.slice(0, -1);
|
|
159
174
|
|
|
160
175
|
let cursor = root;
|
|
161
176
|
let prefix = "";
|
|
@@ -170,6 +185,17 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
|
|
|
170
185
|
cursor = next;
|
|
171
186
|
}
|
|
172
187
|
|
|
188
|
+
if (isDir) {
|
|
189
|
+
// Attach metadata to the deepest folder (first declaration wins, so an
|
|
190
|
+
// explicit note isn't clobbered by a later bare prefix re-declaration).
|
|
191
|
+
if (!cursor.entry) {
|
|
192
|
+
cursor.entry = entry;
|
|
193
|
+
cursor.index = index;
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fileName = segments[segments.length - 1] as string;
|
|
173
199
|
cursor.files.push({
|
|
174
200
|
kind: "file",
|
|
175
201
|
name: fileName,
|
|
@@ -191,6 +217,8 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
|
|
|
191
217
|
name: child.name,
|
|
192
218
|
path: child.path,
|
|
193
219
|
children: materialize(child),
|
|
220
|
+
entry: child.entry,
|
|
221
|
+
index: child.index,
|
|
194
222
|
});
|
|
195
223
|
} else {
|
|
196
224
|
const file = folder.files[Number(key.slice(2))];
|
|
@@ -212,7 +240,16 @@ function compactFolderNode(folder: FolderNode): FolderNode {
|
|
|
212
240
|
let path = folder.path;
|
|
213
241
|
let children = folder.children;
|
|
214
242
|
|
|
215
|
-
|
|
243
|
+
// Collapse single-child folder chains (a/b/c) into one row — but never across
|
|
244
|
+
// a folder that carries its own directory note, so an explicitly authored
|
|
245
|
+
// directory (e.g. `packages/shared/` with a note) keeps its own row instead of
|
|
246
|
+
// being folded into its parent or child and losing the note.
|
|
247
|
+
while (
|
|
248
|
+
!folder.entry &&
|
|
249
|
+
children.length === 1 &&
|
|
250
|
+
children[0]?.kind === "folder" &&
|
|
251
|
+
!children[0].entry
|
|
252
|
+
) {
|
|
216
253
|
const child = children[0];
|
|
217
254
|
names.push(child.name);
|
|
218
255
|
path = child.path;
|
|
@@ -223,6 +260,8 @@ function compactFolderNode(folder: FolderNode): FolderNode {
|
|
|
223
260
|
kind: "folder",
|
|
224
261
|
name: names.join("/"),
|
|
225
262
|
path,
|
|
263
|
+
entry: folder.entry,
|
|
264
|
+
index: folder.index,
|
|
226
265
|
children: compactTree(children),
|
|
227
266
|
};
|
|
228
267
|
}
|
|
@@ -353,30 +392,49 @@ export function FileTreeRead({
|
|
|
353
392
|
const indent = depth * INDENT_STEP;
|
|
354
393
|
if (node.kind === "folder") {
|
|
355
394
|
const collapsed = collapsedFolders[node.path] ?? false;
|
|
395
|
+
// An explicit directory entry (trailing-slash path) can be a leaf folder
|
|
396
|
+
// with no children — e.g. `apps/mail/`. It still renders as a folder, just
|
|
397
|
+
// without a toggle chevron. Its authored note shows inline like a file's.
|
|
398
|
+
const expandable = node.children.length > 0;
|
|
399
|
+
const note = node.entry?.note?.trim();
|
|
400
|
+
const open = expandable && !collapsed;
|
|
356
401
|
return (
|
|
357
402
|
<div key={`d:${node.path}`}>
|
|
358
403
|
<button
|
|
359
404
|
type="button"
|
|
360
405
|
data-plan-interactive
|
|
361
|
-
|
|
362
|
-
|
|
406
|
+
disabled={!expandable}
|
|
407
|
+
aria-expanded={expandable ? !collapsed : undefined}
|
|
408
|
+
onClick={expandable ? () => toggleFolder(node.path) : undefined}
|
|
363
409
|
style={{ paddingLeft: indent + 8 }}
|
|
364
|
-
className=
|
|
410
|
+
className={cn(
|
|
411
|
+
"flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-[13px] transition-colors",
|
|
412
|
+
expandable ? "hover:bg-accent/40" : "cursor-default",
|
|
413
|
+
)}
|
|
365
414
|
>
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
<IconFolder className="size-4 shrink-0 text-plan-muted" />
|
|
415
|
+
{expandable ? (
|
|
416
|
+
<IconChevronRight
|
|
417
|
+
className={cn(
|
|
418
|
+
"size-3.5 shrink-0 text-plan-muted transition-transform",
|
|
419
|
+
!collapsed && "rotate-90",
|
|
420
|
+
)}
|
|
421
|
+
/>
|
|
374
422
|
) : (
|
|
423
|
+
<span className="size-3.5 shrink-0" aria-hidden />
|
|
424
|
+
)}
|
|
425
|
+
{open ? (
|
|
375
426
|
<IconFolderOpen className="size-4 shrink-0 text-plan-muted" />
|
|
427
|
+
) : (
|
|
428
|
+
<IconFolder className="size-4 shrink-0 text-plan-muted" />
|
|
376
429
|
)}
|
|
377
430
|
<span className="min-w-0 truncate font-medium text-plan-text">
|
|
378
431
|
{node.name}
|
|
379
432
|
</span>
|
|
433
|
+
{note && (
|
|
434
|
+
<span className="ml-1 min-w-0 flex-1 truncate text-xs text-plan-muted">
|
|
435
|
+
{note}
|
|
436
|
+
</span>
|
|
437
|
+
)}
|
|
380
438
|
</button>
|
|
381
439
|
</div>
|
|
382
440
|
);
|