@agent-native/core 0.168.13 → 0.169.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/engine/first-event-timeout.d.ts +8 -0
- package/dist/agent/engine/first-event-timeout.js +8 -0
- package/dist/agent/production-agent.d.ts +0 -30
- package/dist/agent/production-agent.js +17 -38
- package/dist/agent/run-loop-with-resume.d.ts +38 -25
- package/dist/agent/run-loop-with-resume.js +140 -55
- package/dist/agent/run-manager.d.ts +83 -68
- package/dist/agent/run-manager.js +280 -94
- package/dist/agent/run-store.d.ts +31 -0
- package/dist/agent/run-store.js +42 -12
- package/dist/app-config/agent.d.ts +2 -0
- package/dist/app-config/agent.js +33 -0
- package/dist/app-config/run-lifecycle-invariants.d.ts +248 -0
- package/dist/app-config/run-lifecycle-invariants.js +342 -0
- package/dist/app-config/schema.d.ts +2 -0
- package/dist/app-config/store.js +9 -1
- package/dist/client/agent-chat-adapter.d.ts +0 -2
- package/dist/client/agent-chat-adapter.js +7 -23
- package/dist/jobs/background-automation-runner.d.ts +25 -0
- package/dist/jobs/background-automation-runner.js +104 -21
- package/dist/jobs/run-history.d.ts +7 -1
- package/dist/jobs/run-history.js +57 -14
- package/dist/observability/traces.d.ts +13 -0
- package/dist/observability/traces.js +369 -317
- package/dist/progress/routes.d.ts +1 -1
- package/dist/server/agent-chat-plugin.js +2 -4
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { collectFinalResponseTextFromAgentEvents } from "../a2a/response-text.js
|
|
|
2
2
|
import { getStoredModelForEngine, normalizeModelForEngine, resolveEngine, } from "../agent/engine/index.js";
|
|
3
3
|
import { actionsToEngineTools, filterInitialEngineTools, getOwnerActiveApiKey, } from "../agent/production-agent.js";
|
|
4
4
|
import { runAgentLoopDirectWithSoftTimeout } from "../agent/run-loop-with-resume.js";
|
|
5
|
-
import {
|
|
5
|
+
import { abortRun, resolveBackgroundAutomationSoftTimeoutMs, resolveBackgroundRunHardTimeoutMs, startRun, } from "../agent/run-manager.js";
|
|
6
6
|
import { claimBackgroundRun, insertRun } from "../agent/run-store.js";
|
|
7
7
|
import { buildAssistantMessage, buildUserMessage, extractThreadMeta, foldAssistantTurn, upsertUserMessage, } from "../agent/thread-data-builder.js";
|
|
8
8
|
import { attachToolSearch } from "../agent/tool-search.js";
|
|
@@ -13,8 +13,27 @@ import { organizationIdFromResourceOwner, organizationResourceOwner, } from "../
|
|
|
13
13
|
import { captureError } from "../server/capture-error.js";
|
|
14
14
|
import { runWithRequestContext, } from "../server/request-context.js";
|
|
15
15
|
import { attachAutomationRunThread, finishAutomationRun, startAutomationRun, } from "./run-history.js";
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Default hard abort for one in-process automation run. Read through
|
|
18
|
+
* `resolveBackgroundRunHardTimeoutMs()` at the use site — this is the host's
|
|
19
|
+
* real function budget for scheduled work, and it differs by deployment.
|
|
20
|
+
*/
|
|
17
21
|
export const BACKGROUND_RUN_HARD_TIMEOUT_MS = 10 * 60_000;
|
|
22
|
+
/**
|
|
23
|
+
* Terminal failure of a background automation, carrying the machine-readable
|
|
24
|
+
* code the failure taxonomy already computes.
|
|
25
|
+
*
|
|
26
|
+
* The code used to be produced and then dropped, so "how often are runs cut
|
|
27
|
+
* off?" was a `LIKE '%no_progress%'` over an English sentence.
|
|
28
|
+
*/
|
|
29
|
+
export class BackgroundAutomationRunError extends Error {
|
|
30
|
+
errorCode;
|
|
31
|
+
constructor(message, errorCode) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "BackgroundAutomationRunError";
|
|
34
|
+
this.errorCode = errorCode;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
18
37
|
/**
|
|
19
38
|
* A persisted background run must not outlive its execution identity.
|
|
20
39
|
* Organization runs fail closed when membership state cannot be read. A
|
|
@@ -112,8 +131,11 @@ export function isBackgroundAutomationRunActive(meta, now = new Date()) {
|
|
|
112
131
|
if (!meta.lastRun)
|
|
113
132
|
return false;
|
|
114
133
|
const startedAt = new Date(meta.lastRun).getTime();
|
|
134
|
+
// Tracks the hard abort: past it no run of this automation is still alive, so
|
|
135
|
+
// a deployment that raises the abort must not have its live runs treated as
|
|
136
|
+
// stuck and re-dispatched underneath themselves.
|
|
115
137
|
return (Number.isFinite(startedAt) &&
|
|
116
|
-
now.getTime() - startedAt <
|
|
138
|
+
now.getTime() - startedAt < resolveBackgroundRunHardTimeoutMs());
|
|
117
139
|
}
|
|
118
140
|
/**
|
|
119
141
|
* A soft-timeout/no-progress checkpoint is a continuation boundary, not a
|
|
@@ -186,6 +208,9 @@ export async function runBackgroundAutomation(options, deps) {
|
|
|
186
208
|
}
|
|
187
209
|
catch (err) {
|
|
188
210
|
const message = err instanceof Error ? err.message : String(err);
|
|
211
|
+
const errorCode = err instanceof BackgroundAutomationRunError
|
|
212
|
+
? err.errorCode
|
|
213
|
+
: "background_automation_failed";
|
|
189
214
|
// Both callers (recurring-jobs scheduler, trigger dispatcher) record this
|
|
190
215
|
// onto the automation's own metadata and console.error it, and neither
|
|
191
216
|
// reports it. A failure visible only in a resource field and stdout is not
|
|
@@ -204,7 +229,7 @@ export async function runBackgroundAutomation(options, deps) {
|
|
|
204
229
|
},
|
|
205
230
|
...(runIdRef.current ? { aiTraceId: runIdRef.current } : {}),
|
|
206
231
|
});
|
|
207
|
-
await recordRunOutcome(historyId, "error", `${message}. No delivery was confirmed
|
|
232
|
+
await recordRunOutcome(historyId, "error", `${message}. No delivery was confirmed.`, errorCode);
|
|
208
233
|
throw err;
|
|
209
234
|
}
|
|
210
235
|
// Outside the try: history is bookkeeping about the run, so a failure to
|
|
@@ -229,8 +254,9 @@ async function recordRunThread(historyId, threadId, runId) {
|
|
|
229
254
|
}
|
|
230
255
|
function backgroundAutomationPersistFailure(input) {
|
|
231
256
|
if (input.hardTimedOut) {
|
|
257
|
+
const minutes = Math.round((input.hardTimeoutMs ?? BACKGROUND_RUN_HARD_TIMEOUT_MS) / 60_000);
|
|
232
258
|
return {
|
|
233
|
-
message: `Background automation timed out after ${
|
|
259
|
+
message: `Background automation timed out after ${minutes} minutes`,
|
|
234
260
|
errorCode: "background_automation_hard_timeout",
|
|
235
261
|
};
|
|
236
262
|
}
|
|
@@ -301,11 +327,11 @@ async function persistBackgroundAutomationTurn(input) {
|
|
|
301
327
|
await updateThreadData(input.threadId, JSON.stringify(repo), input.threadTitle || row.title, meta.preview || row.preview, Array.isArray(messages) ? messages.length : 0);
|
|
302
328
|
});
|
|
303
329
|
}
|
|
304
|
-
async function recordRunOutcome(historyId, status, error) {
|
|
330
|
+
async function recordRunOutcome(historyId, status, error, errorCode) {
|
|
305
331
|
if (!historyId)
|
|
306
332
|
return;
|
|
307
333
|
try {
|
|
308
|
-
await finishAutomationRun(historyId, status, error);
|
|
334
|
+
await finishAutomationRun(historyId, status, error, errorCode);
|
|
309
335
|
}
|
|
310
336
|
catch (err) {
|
|
311
337
|
console.error(`[automations] Could not record run ${historyId} as ${status}:`, err);
|
|
@@ -372,12 +398,15 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
372
398
|
// Hardcoded rather than `isInBackgroundFunctionRuntime()` (what
|
|
373
399
|
// webhook-handler.ts uses): a webhook can arrive on either runtime, but
|
|
374
400
|
// a scheduler tick never serves a synchronous request, so the
|
|
375
|
-
// interactive clamp never applies to it.
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
401
|
+
// interactive clamp never applies to it.
|
|
402
|
+
//
|
|
403
|
+
// Derived from this runner's OWN hard abort, not from the durable-chat
|
|
404
|
+
// background ceiling: that ceiling is 13 minutes and this process is
|
|
405
|
+
// killed at 10, so taking it left the recoverable soft-timeout boundary
|
|
406
|
+
// as dead code and the terminal no-progress backstop as the only
|
|
407
|
+
// boundary an automation could reach.
|
|
408
|
+
const hardTimeoutMs = resolveBackgroundRunHardTimeoutMs();
|
|
409
|
+
const softTimeoutMs = resolveBackgroundAutomationSoftTimeoutMs();
|
|
381
410
|
const usageRef = { current: null };
|
|
382
411
|
let responseText = "";
|
|
383
412
|
let hardAbortTimer = null;
|
|
@@ -400,8 +429,8 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
400
429
|
throw new Error(`Background automation "${automation.name}" (run "${runId}") could not claim its own freshly-inserted run row`);
|
|
401
430
|
}
|
|
402
431
|
await new Promise((resolve, reject) => {
|
|
403
|
-
const activeRun = startRun(runId, thread.id, async (send, signal) => {
|
|
404
|
-
|
|
432
|
+
const activeRun = startRun(runId, thread.id, async (send, signal, control) => {
|
|
433
|
+
const loopOpts = {
|
|
405
434
|
engine,
|
|
406
435
|
model,
|
|
407
436
|
systemPrompt,
|
|
@@ -425,7 +454,45 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
425
454
|
runId,
|
|
426
455
|
maxIterations: automation.meta.maxIterations,
|
|
427
456
|
maxRunInputTokens: automation.meta.maxRunInputTokens,
|
|
428
|
-
}
|
|
457
|
+
};
|
|
458
|
+
// Same adapter A2A uses: bridge this runner's multi-argument shape
|
|
459
|
+
// to the single-argument `runAgentLoop` `instrumentAgentLoop`
|
|
460
|
+
// expects. `control` is what lets a chunk boundary be recovered
|
|
461
|
+
// here instead of ending the turn.
|
|
462
|
+
const execute = (o = loopOpts) => runAgentLoopDirectWithSoftTimeout(o, softTimeoutMs, { backgroundFunction: true }, control);
|
|
463
|
+
let instrumented = false;
|
|
464
|
+
try {
|
|
465
|
+
const { getObservabilityConfig, instrumentAgentLoop } = await import("../observability/traces.js");
|
|
466
|
+
const config = await getObservabilityConfig();
|
|
467
|
+
if (config.enabled) {
|
|
468
|
+
instrumented = true;
|
|
469
|
+
usageRef.current = await instrumentAgentLoop({
|
|
470
|
+
runAgentLoop: (o) => execute(o),
|
|
471
|
+
loopOpts,
|
|
472
|
+
runId,
|
|
473
|
+
threadId: thread.id,
|
|
474
|
+
// A scheduled run is NOT anonymous. Passing the owner is what
|
|
475
|
+
// makes it visible to per-user observability reads.
|
|
476
|
+
userId: ownerEmail,
|
|
477
|
+
config,
|
|
478
|
+
spanName: "background_automation_run",
|
|
479
|
+
metadata: {
|
|
480
|
+
automation: automation.name,
|
|
481
|
+
trigger: "background_automation",
|
|
482
|
+
scope: orgId ? "organization" : "personal",
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
// Match A2A and interactive chat: a setup failure falls through
|
|
490
|
+
// to an uninstrumented run, but a failure from INSIDE the
|
|
491
|
+
// instrumented loop is the real run failure and must rethrow.
|
|
492
|
+
if (instrumented)
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
usageRef.current = await execute();
|
|
429
496
|
}, async (run) => {
|
|
430
497
|
if (hardAbortTimer) {
|
|
431
498
|
clearTimeout(hardAbortTimer);
|
|
@@ -434,6 +501,7 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
434
501
|
const persistFailure = backgroundAutomationPersistFailure({
|
|
435
502
|
run,
|
|
436
503
|
hardTimedOut,
|
|
504
|
+
hardTimeoutMs,
|
|
437
505
|
});
|
|
438
506
|
try {
|
|
439
507
|
await persistBackgroundAutomationTurn({
|
|
@@ -453,11 +521,11 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
453
521
|
if (hardTimedOut)
|
|
454
522
|
return;
|
|
455
523
|
if (persistFailure) {
|
|
456
|
-
reject(new
|
|
524
|
+
reject(new BackgroundAutomationRunError(persistFailure.message, persistFailure.errorCode));
|
|
457
525
|
return;
|
|
458
526
|
}
|
|
459
527
|
if (run.status !== "completed") {
|
|
460
|
-
reject(new
|
|
528
|
+
reject(new BackgroundAutomationRunError(`Background automation ended with status: ${run.status}`, `background_automation_${run.status}`));
|
|
461
529
|
return;
|
|
462
530
|
}
|
|
463
531
|
responseText = collectFinalResponseTextFromAgentEvents((run.events ?? []).map((entry) => entry.event));
|
|
@@ -465,22 +533,37 @@ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
|
|
|
465
533
|
}, {
|
|
466
534
|
softTimeoutMs,
|
|
467
535
|
backgroundFunction: true,
|
|
536
|
+
// This runner owns continuation in-process: there is no HTTP body
|
|
537
|
+
// to re-POST and no `chainServerDrivenContinuation` behind it, so a
|
|
538
|
+
// checkpoint must end the CHUNK and let the loop above recover it.
|
|
539
|
+
recoverChunkBoundaries: true,
|
|
540
|
+
// Matches the `dispatch_mode` this runner already writes onto the
|
|
541
|
+
// run row at insert. Without it the terminal and boundary analytics
|
|
542
|
+
// events reported every scheduled run as foreground.
|
|
543
|
+
dispatchMode: "background",
|
|
544
|
+
noProgressTimeoutMs: options.noProgressTimeoutMs,
|
|
545
|
+
backgroundNoProgressTimeoutMs: options.backgroundNoProgressTimeoutMs,
|
|
468
546
|
model,
|
|
469
547
|
engineName: engine.name,
|
|
548
|
+
userId: ownerEmail,
|
|
470
549
|
});
|
|
471
550
|
hardAbortTimer = setTimeout(() => {
|
|
472
551
|
hardAbortTimer = null;
|
|
473
552
|
if (activeRun.status !== "running")
|
|
474
553
|
return;
|
|
475
554
|
hardTimedOut = true;
|
|
476
|
-
activeRun.abort.abort
|
|
477
|
-
|
|
555
|
+
// `abortRun`, not `activeRun.abort.abort`: the controller alone
|
|
556
|
+
// carries no reason the run manager can see, so finalization fell
|
|
557
|
+
// through to `aborted:user` and a hard timeout was recorded as a
|
|
558
|
+
// person pressing Stop.
|
|
559
|
+
abortRun(runId, "background_automation_hard_timeout");
|
|
560
|
+
const timeoutError = new BackgroundAutomationRunError(`Background automation timed out after ${Math.round(hardTimeoutMs / 60_000)} minutes`, "background_automation_hard_timeout");
|
|
478
561
|
void activeRun.finalized
|
|
479
562
|
.catch(() => { })
|
|
480
563
|
.then(() => {
|
|
481
564
|
reject(timeoutError);
|
|
482
565
|
});
|
|
483
|
-
},
|
|
566
|
+
}, hardTimeoutMs);
|
|
484
567
|
}).finally(() => {
|
|
485
568
|
if (hardAbortTimer) {
|
|
486
569
|
clearTimeout(hardAbortTimer);
|
|
@@ -20,6 +20,12 @@ export interface AutomationRun {
|
|
|
20
20
|
startedAt: number;
|
|
21
21
|
finishedAt: number | null;
|
|
22
22
|
error: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Machine-readable failure code, so "how often are runs cut off?" is a
|
|
25
|
+
* GROUP BY instead of a LIKE over an English sentence. Null on success and
|
|
26
|
+
* on rows written before the column existed.
|
|
27
|
+
*/
|
|
28
|
+
errorCode: string | null;
|
|
23
29
|
}
|
|
24
30
|
export interface StartAutomationRunInput {
|
|
25
31
|
owner: string;
|
|
@@ -56,7 +62,7 @@ export declare function listUnclaimedAutomationRuns(options?: {
|
|
|
56
62
|
olderThanMs?: number;
|
|
57
63
|
limit?: number;
|
|
58
64
|
}): Promise<AutomationRun[]>;
|
|
59
|
-
export declare function finishAutomationRun(id: string, status: Exclude<AutomationRunStatus, "running">, error?: string): Promise<void>;
|
|
65
|
+
export declare function finishAutomationRun(id: string, status: Exclude<AutomationRunStatus, "running">, error?: string, errorCode?: string): Promise<void>;
|
|
60
66
|
/**
|
|
61
67
|
* Attach the agent thread once it exists. The thread is created after the run
|
|
62
68
|
* row so the history survives a crash between the two.
|
package/dist/jobs/run-history.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { resolveBackgroundRunHardTimeoutMs } from "../agent/run-manager.js";
|
|
3
4
|
import { getDbExec, intType, isPostgres } from "../db/client.js";
|
|
4
5
|
import { ensureColumnExists, ensureIndexExists, ensureTableExists, } from "../db/ddl-guard.js";
|
|
5
6
|
import { runMigrations } from "../db/migrations.js";
|
|
@@ -17,20 +18,38 @@ registerEvent({
|
|
|
17
18
|
threadId: z.string().nullable(),
|
|
18
19
|
status: z.enum(["success", "error", "interrupted"]),
|
|
19
20
|
error: z.string().nullable(),
|
|
21
|
+
errorCode: z.string().nullable(),
|
|
22
|
+
/** Wall-clock from `started_at` to now. Null when the row predates the
|
|
23
|
+
* start timestamp being readable, so "not measured" stays distinct from
|
|
24
|
+
* "took no time". */
|
|
25
|
+
durationMs: z.number().nullable(),
|
|
20
26
|
}),
|
|
21
27
|
});
|
|
22
28
|
const TABLE = "automation_runs";
|
|
23
29
|
const MAX_ERROR_LENGTH = 500;
|
|
30
|
+
const MAX_ERROR_CODE_LENGTH = 100;
|
|
24
31
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* Past this, no run of this automation is still alive.
|
|
33
|
+
*
|
|
34
|
+
* DERIVED from the runner's own hard abort rather than pinned, because that
|
|
35
|
+
* abort became configurable: a fixed 15 minutes against a longer configured
|
|
36
|
+
* timeout would report a still-executing run as `interrupted` and expire its
|
|
37
|
+
* claim lease, redispatching it on top of itself. Half again the abort leaves
|
|
38
|
+
* room for wind-down and the terminal write without ever preceding them.
|
|
27
39
|
*/
|
|
28
|
-
|
|
40
|
+
function resolveRunLivenessCeilingMs() {
|
|
41
|
+
return Math.ceil(resolveBackgroundRunHardTimeoutMs() * 1.5);
|
|
42
|
+
}
|
|
29
43
|
const INTERRUPTED_RUN_MESSAGE = "Worker stopped before a terminal result was recorded. The serverless worker may have timed out or been recycled. No delivery was confirmed.";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Derived at read time alongside `INTERRUPTED_RUN_MESSAGE`: a process killed
|
|
46
|
+
* mid-run cannot write its own code any more than it can write its own message.
|
|
47
|
+
*/
|
|
48
|
+
const INTERRUPTED_RUN_ERROR_CODE = "background_automation_interrupted";
|
|
49
|
+
// The background worker's hard timeout is always shorter than this lease, by
|
|
50
|
+
// construction above. A worker that dies after claiming can therefore be
|
|
51
|
+
// redelivered without overlapping a still-live execution.
|
|
52
|
+
const claimLeaseMs = () => resolveRunLivenessCeilingMs();
|
|
34
53
|
/** Rows kept per automation, so a per-minute schedule cannot grow forever. */
|
|
35
54
|
const RUNS_RETAINED_PER_AUTOMATION = 50;
|
|
36
55
|
/** Authoritative release-time schema for durable automation history. */
|
|
@@ -76,6 +95,11 @@ export const AUTOMATION_RUN_MIGRATIONS = [
|
|
|
76
95
|
name: "automation-runs-app-id",
|
|
77
96
|
sql: `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS app_id TEXT`,
|
|
78
97
|
},
|
|
98
|
+
{
|
|
99
|
+
version: 5,
|
|
100
|
+
name: "automation-runs-error-code",
|
|
101
|
+
sql: `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS error_code TEXT`,
|
|
102
|
+
},
|
|
79
103
|
];
|
|
80
104
|
export async function runAutomationRunMigrations(nitroApp) {
|
|
81
105
|
await runMigrations(AUTOMATION_RUN_MIGRATIONS, {
|
|
@@ -102,6 +126,7 @@ export async function ensureTable() {
|
|
|
102
126
|
started_at ${intType()} NOT NULL,
|
|
103
127
|
finished_at ${intType()},
|
|
104
128
|
error TEXT,
|
|
129
|
+
error_code TEXT,
|
|
105
130
|
claimed_at ${intType()},
|
|
106
131
|
dispatch_pending ${intType()} NOT NULL DEFAULT 0
|
|
107
132
|
)
|
|
@@ -111,6 +136,7 @@ export async function ensureTable() {
|
|
|
111
136
|
await ensureTableExists(TABLE, createSql);
|
|
112
137
|
await ensureColumnExists(TABLE, "claimed_at", `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS claimed_at ${intType()}`);
|
|
113
138
|
await ensureColumnExists(TABLE, "dispatch_pending", `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS dispatch_pending ${intType()} NOT NULL DEFAULT 0`);
|
|
139
|
+
await ensureColumnExists(TABLE, "error_code", `ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS error_code TEXT`);
|
|
114
140
|
await ensureIndexExists(`idx_${TABLE}_owner_automation`, indexSql);
|
|
115
141
|
return;
|
|
116
142
|
}
|
|
@@ -121,6 +147,7 @@ export async function ensureTable() {
|
|
|
121
147
|
["claimed_at", `${intType()}`],
|
|
122
148
|
["dispatch_pending", `${intType()} NOT NULL DEFAULT 0`],
|
|
123
149
|
["app_id", "TEXT"],
|
|
150
|
+
["error_code", "TEXT"],
|
|
124
151
|
]) {
|
|
125
152
|
if (columns.has(name))
|
|
126
153
|
continue;
|
|
@@ -146,7 +173,7 @@ export async function ensureTable() {
|
|
|
146
173
|
function toRun(row, now) {
|
|
147
174
|
const stored = String(row.status);
|
|
148
175
|
const startedAt = Number(row.started_at);
|
|
149
|
-
const status = stored === "running" && now - startedAt >
|
|
176
|
+
const status = stored === "running" && now - startedAt > resolveRunLivenessCeilingMs()
|
|
150
177
|
? "interrupted"
|
|
151
178
|
: stored;
|
|
152
179
|
return {
|
|
@@ -167,6 +194,11 @@ function toRun(row, now) {
|
|
|
167
194
|
: row.error == null
|
|
168
195
|
? null
|
|
169
196
|
: String(row.error),
|
|
197
|
+
errorCode: row.error_code == null && status === "interrupted"
|
|
198
|
+
? INTERRUPTED_RUN_ERROR_CODE
|
|
199
|
+
: row.error_code == null
|
|
200
|
+
? null
|
|
201
|
+
: String(row.error_code),
|
|
170
202
|
};
|
|
171
203
|
}
|
|
172
204
|
/**
|
|
@@ -212,7 +244,7 @@ export async function claimAutomationRun(id) {
|
|
|
212
244
|
const now = Date.now();
|
|
213
245
|
const result = await getDbExec().execute({
|
|
214
246
|
sql: `UPDATE ${TABLE} SET claimed_at = ? WHERE id = ? AND dispatch_pending = 1 AND (claimed_at IS NULL OR claimed_at <= ?) AND status = 'running'`,
|
|
215
|
-
args: [now, id, now -
|
|
247
|
+
args: [now, id, now - claimLeaseMs()],
|
|
216
248
|
});
|
|
217
249
|
return Number(result.rowsAffected ?? 0) > 0;
|
|
218
250
|
}
|
|
@@ -234,7 +266,7 @@ export async function listUnclaimedAutomationRuns(options) {
|
|
|
234
266
|
AND started_at <= ?
|
|
235
267
|
ORDER BY started_at ASC LIMIT ${limit}`,
|
|
236
268
|
args: [
|
|
237
|
-
Date.now() -
|
|
269
|
+
Date.now() - claimLeaseMs(),
|
|
238
270
|
...(appId ? [appId] : []),
|
|
239
271
|
Date.now() - olderThanMs,
|
|
240
272
|
],
|
|
@@ -263,19 +295,28 @@ async function pruneAutomationRuns(owner, automation) {
|
|
|
263
295
|
args: [owner, automation, owner, automation],
|
|
264
296
|
});
|
|
265
297
|
}
|
|
266
|
-
export async function finishAutomationRun(id, status, error) {
|
|
298
|
+
export async function finishAutomationRun(id, status, error, errorCode) {
|
|
267
299
|
await ensureTable();
|
|
268
300
|
const existing = await getDbExec().execute({
|
|
269
|
-
sql: `SELECT owner, automation, path, org_id, run_id, thread_id FROM ${TABLE} WHERE id = ? LIMIT 1`,
|
|
301
|
+
sql: `SELECT owner, automation, path, org_id, run_id, thread_id, started_at FROM ${TABLE} WHERE id = ? LIMIT 1`,
|
|
270
302
|
args: [id],
|
|
271
303
|
});
|
|
272
304
|
const row = existing.rows?.[0];
|
|
305
|
+
const finishedAt = Date.now();
|
|
273
306
|
await getDbExec().execute({
|
|
274
|
-
sql: `UPDATE ${TABLE} SET status = ?, finished_at = ?, error = ? WHERE id = ?`,
|
|
275
|
-
args: [
|
|
307
|
+
sql: `UPDATE ${TABLE} SET status = ?, finished_at = ?, error = ?, error_code = ? WHERE id = ?`,
|
|
308
|
+
args: [
|
|
309
|
+
status,
|
|
310
|
+
finishedAt,
|
|
311
|
+
error?.slice(0, MAX_ERROR_LENGTH) ?? null,
|
|
312
|
+
errorCode?.slice(0, MAX_ERROR_CODE_LENGTH) ?? null,
|
|
313
|
+
id,
|
|
314
|
+
],
|
|
276
315
|
});
|
|
277
316
|
if (!row)
|
|
278
317
|
return;
|
|
318
|
+
const rawStartedAt = Number(row.started_at);
|
|
319
|
+
const startedAt = Number.isFinite(rawStartedAt) ? rawStartedAt : null;
|
|
279
320
|
try {
|
|
280
321
|
emitBusEvent("automation.run.finished", {
|
|
281
322
|
automationRunId: id,
|
|
@@ -287,6 +328,8 @@ export async function finishAutomationRun(id, status, error) {
|
|
|
287
328
|
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
288
329
|
status,
|
|
289
330
|
error: error?.slice(0, MAX_ERROR_LENGTH) ?? null,
|
|
331
|
+
errorCode: errorCode?.slice(0, MAX_ERROR_CODE_LENGTH) ?? null,
|
|
332
|
+
durationMs: startedAt === null ? null : Math.max(0, finishedAt - startedAt),
|
|
290
333
|
}, { owner: String(row.owner) });
|
|
291
334
|
}
|
|
292
335
|
catch (eventError) {
|
|
@@ -44,6 +44,19 @@ export declare function instrumentAgentLoop(opts: {
|
|
|
44
44
|
* reads. */
|
|
45
45
|
userId: string | null;
|
|
46
46
|
config: ObservabilityConfig;
|
|
47
|
+
/**
|
|
48
|
+
* Name for this run's root span, in the local trace store and in PostHog LLM
|
|
49
|
+
* analytics. Defaults to `"agent_run"`. Without it every path emits the same
|
|
50
|
+
* name and a scheduled automation is indistinguishable from a chat turn in
|
|
51
|
+
* the one view where telling them apart is the whole question.
|
|
52
|
+
*/
|
|
53
|
+
spanName?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Free-form run context. Persisted onto the local store's parent span AND
|
|
56
|
+
* forwarded to PostHog as trace properties — a channel that reached only the
|
|
57
|
+
* SQL store was a channel that could not answer "which automation was this?"
|
|
58
|
+
* in LLM analytics.
|
|
59
|
+
*/
|
|
47
60
|
metadata?: Record<string, unknown> | null;
|
|
48
61
|
experimentAssignments?: Array<{
|
|
49
62
|
experimentId: string;
|