@desplega.ai/agent-swarm 1.135.2 → 1.136.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/{actions-3az2vwwn.js → actions-agcbc8nd.js} +6 -6
- package/dist/{app-vygnfvrh.js → app-g2s78xya.js} +3 -3
- package/dist/{assistant-2jr2frrm.js → assistant-yd3jkb9d.js} +8 -8
- package/dist/{boot-reembed-y7r9cxt0.js → boot-reembed-6spqw4q0.js} +3 -3
- package/dist/{boot-reembed-ncbg16rb.js → boot-reembed-b22s9w5x.js} +4 -4
- package/dist/{boot-scrub-logs-t82rwa0y.js → boot-scrub-logs-6h3cqkc0.js} +2 -2
- package/dist/{claude-adapter-vvx2aw3v.js → claude-adapter-ng5y8zd7.js} +4 -4
- package/dist/{cli-mfa2eaef.js → cli-35jknh9e.js} +5 -5
- package/dist/{cli-cv2vjg2y.js → cli-3y553gws.js} +1 -1
- package/dist/{cli-nhvym0d2.js → cli-4gzv54ss.js} +15 -6
- package/dist/{cli-4dakdn30.js → cli-4r4jkqep.js} +6 -6
- package/dist/{cli-83razmqg.js → cli-54ht104h.js} +3 -3
- package/dist/{cli-6290n4yz.js → cli-5bnz1t86.js} +1 -1
- package/dist/{cli-6bz2hzvw.js → cli-68vayryq.js} +2 -2
- package/dist/{cli-1z01xdqh.js → cli-6gwgzdgw.js} +2 -2
- package/dist/{cli-1dfatkzm.js → cli-84vrhesc.js} +1 -1
- package/dist/{cli-jfrh963q.js → cli-8v57p983.js} +10 -10
- package/dist/{cli-3f0tnh34.js → cli-ads0hfw2.js} +81 -23
- package/dist/{cli-fmha74ga.js → cli-bnq8h7ea.js} +3 -3
- package/dist/{cli-j3xnd5b4.js → cli-d5ygfv18.js} +7 -6
- package/dist/{cli-8f4h27gm.js → cli-edwrx42s.js} +1 -1
- package/dist/{cli-r5vwg1ma.js → cli-fpjmcbhz.js} +4 -4
- package/dist/{cli-w0ygcv43.js → cli-gpdtxzar.js} +6 -4
- package/dist/{cli-4rdjdddh.js → cli-hz5hp6wn.js} +1 -1
- package/dist/{cli-6mmwmy45.js → cli-j7qp8gtf.js} +1 -1
- package/dist/{cli-5a5nrq3g.js → cli-jjhnma34.js} +5 -5
- package/dist/{cli-ey09krp2.js → cli-m3z53bed.js} +1 -1
- package/dist/{cli-cf6vq6qa.js → cli-mgef08pc.js} +1 -1
- package/dist/{cli-0jhyxtdj.js → cli-qckgdqmr.js} +2 -2
- package/dist/{cli-a1zrc2g6.js → cli-qhfkxrpg.js} +108 -33
- package/dist/{cli-yw2z5kj9.js → cli-spf767vc.js} +1 -1
- package/dist/{cli-1857msnd.js → cli-tee8w7tq.js} +1 -1
- package/dist/{cli-qxn3g3yx.js → cli-tmn19cdz.js} +61 -6
- package/dist/{cli-7ccy0h4r.js → cli-y16cesaq.js} +2 -2
- package/dist/{cli-cm4z7w7w.js → cli-z6e8eqrm.js} +2 -2
- package/dist/cli.js +16 -16
- package/dist/{codex-adapter-6p290yq3.js → codex-adapter-kw11ps3j.js} +2 -2
- package/dist/{codex-session-runner-7b3dhpmx.js → codex-session-runner-sffb69v7.js} +2 -2
- package/dist/{commands-wxg1g4nq.js → commands-0xxp4tz8.js} +2 -2
- package/dist/{db-8fqeheve.js → db-0npp39zd.js} +6 -2
- package/dist/{handlers-gwtqp17c.js → handlers-5p7a1jx6.js} +15 -9
- package/dist/{hook-gd9fwfp0.js → hook-hpefr0a8.js} +2 -2
- package/dist/{hook-mrqygs7p.js → hook-jjpxn4se.js} +3 -3
- package/dist/{http-x4mc1mqg.js → http-wfp33653.js} +322 -61
- package/dist/{index-yd654kac.js → index-17h657rg.js} +12 -8
- package/dist/{index-pzgasj16.js → index-m2p7ctyh.js} +10 -10
- package/dist/{index-2mdqrd0n.js → index-racb4wfq.js} +9 -9
- package/dist/{index-4djjkp9z.js → index-zh66wm65.js} +8 -8
- package/dist/{keepalive-3tycspcy.js → keepalive-wxjd633a.js} +4 -4
- package/dist/{lead-js1h3z7d.js → lead-359ec2z0.js} +23 -23
- package/dist/{maintenance-y91ry8q4.js → maintenance-rr03qjtb.js} +4 -4
- package/dist/{mistral-conversations-hdmnkf6h.js → mistral-conversations-qdrnmrb8.js} +1 -1
- package/dist/{oauth-refresh-sweep-s33gtxrw.js → oauth-refresh-sweep-jwj22yb8.js} +3 -3
- package/dist/{onboard-vndj64mb.js → onboard-asnhchcv.js} +2 -2
- package/dist/{openai-completions-cwy2kkzg.js → openai-completions-yzftqrft.js} +33 -4
- package/dist/{otel-impl-d2p84k6e.js → otel-impl-54cra6mw.js} +18 -4
- package/dist/{pi-mono-adapter-1phspz2y.js → pi-mono-adapter-29g7axgj.js} +669 -335
- package/dist/{pricing-refresh-c2bwsyk5.js → pricing-refresh-mkyy7c2y.js} +4 -4
- package/dist/{rbac-roles-2pedz5e9.js → rbac-roles-ak56tr75.js} +2 -2
- package/dist/{rbac-roles-reef5y2d.js → rbac-roles-kb3afm95.js} +3 -3
- package/dist/{render-v2-zd5r5wgf.js → render-v2-434asg49.js} +3 -3
- package/dist/{seed-pricing-2kyk1vhg.js → seed-pricing-nbmd7ah8.js} +3 -3
- package/dist/{setup-5ys9pwyr.js → setup-wh4qmjs4.js} +2 -2
- package/dist/{worker-nb1f7k04.js → worker-j1d27tjv.js} +23 -23
- package/openapi.json +167 -2
- package/package.json +6 -6
- package/src/be/db-retention.ts +275 -0
- package/src/be/db.ts +82 -7
- package/src/be/swarm-config-guard.ts +30 -0
- package/src/heartbeat/heartbeat.ts +21 -0
- package/src/http/index.ts +33 -33
- package/src/http/route-def.ts +93 -4
- package/src/http/stats.ts +37 -1
- package/src/http/tasks.ts +93 -0
- package/src/http/utils.ts +94 -0
- package/src/otel-impl.ts +21 -3
- package/src/otel.ts +15 -4
- package/src/slack/blocks.ts +1 -0
- package/src/slack/handlers.ts +7 -0
- package/src/tasks/sibling-awareness.ts +1 -0
- package/src/tasks/sibling-block.ts +17 -4
- package/src/tests/artifact-sdk.test.ts +20 -15
- package/src/tests/db-retention.test.ts +344 -0
- package/src/tests/docs-index-footprint.test.js +4 -5
- package/src/tests/draft-task-dispatch-race.test.ts +189 -0
- package/src/tests/events-http.test.ts +70 -27
- package/src/tests/otel-abort-span.test.ts +156 -0
- package/src/tests/otel-span-kind.test.ts +59 -0
- package/src/tests/promote-draft-task-route.test.ts +175 -0
- package/src/tests/route-def-find-route.test.ts +90 -4
- package/src/tests/sibling-block.test.ts +29 -1
- package/src/tests/slack-assistant-comention-production.test.ts +7 -1
- package/src/tests/steering-enabled.test.ts +6 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +27 -0
- package/src/types.ts +2 -1
- package/templates/skills/swarm-scripts/SKILL.md +4 -0
- package/templates/skills/swarm-scripts/content.md +4 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
3
|
+
import { getDbClient } from "./db";
|
|
4
|
+
import { MAX_DB_RETENTION_DAYS } from "./swarm-config-guard";
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const RETENTION_INTERVAL_MS = 60 * 60 * 1000;
|
|
8
|
+
const BATCH_SIZE = 5_000;
|
|
9
|
+
const PER_TABLE_BATCH_CAP = 40;
|
|
10
|
+
const WALL_CLOCK_CAP_MS = 60_000;
|
|
11
|
+
const YIELD_MS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* This is deliberately a closed list. Neither table nor column names may come
|
|
15
|
+
* from operator configuration: a retention policy must never target a table
|
|
16
|
+
* that code review has not explicitly approved as safe to delete from.
|
|
17
|
+
*/
|
|
18
|
+
export const DB_RETENTION_TABLES = [
|
|
19
|
+
{
|
|
20
|
+
table: "session_logs",
|
|
21
|
+
timeColumn: "createdAt",
|
|
22
|
+
envKey: "SESSION_LOG_RETENTION_DAYS",
|
|
23
|
+
metricsKey: "sessionLogs",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
table: "agent_log",
|
|
27
|
+
timeColumn: "createdAt",
|
|
28
|
+
envKey: "AGENT_LOG_RETENTION_DAYS",
|
|
29
|
+
metricsKey: "agentLog",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
table: "events",
|
|
33
|
+
timeColumn: "createdAt",
|
|
34
|
+
envKey: "EVENTS_RETENTION_DAYS",
|
|
35
|
+
metricsKey: "events",
|
|
36
|
+
},
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
type RetentionTable = (typeof DB_RETENTION_TABLES)[number];
|
|
40
|
+
type RetentionMetricsKey = RetentionTable["metricsKey"];
|
|
41
|
+
|
|
42
|
+
export type DbRetentionTableStats = {
|
|
43
|
+
at: string;
|
|
44
|
+
/** Deleted rows, or rows that would be deleted when dryRun is true. */
|
|
45
|
+
rowsDeleted: number;
|
|
46
|
+
batches: number;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
dryRun: boolean;
|
|
49
|
+
cumulativeRowsDeleted: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type DbRetentionStats = Partial<Record<RetentionMetricsKey, DbRetentionTableStats>>;
|
|
53
|
+
|
|
54
|
+
/** Test-only controls; production callers always use the bounded defaults above. */
|
|
55
|
+
export type DbRetentionTickOptions = {
|
|
56
|
+
now?: Date;
|
|
57
|
+
batchSize?: number;
|
|
58
|
+
perTableBatchCap?: number;
|
|
59
|
+
wallClockCapMs?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let retentionTimer: ReturnType<typeof setInterval> | null = null;
|
|
63
|
+
let retentionTickPromise: Promise<void> | null = null;
|
|
64
|
+
let retentionAbortController: AbortController | null = null;
|
|
65
|
+
let retentionStats: DbRetentionStats = {};
|
|
66
|
+
let cumulativeRowsDeleted: Partial<Record<RetentionMetricsKey, number>> = {};
|
|
67
|
+
|
|
68
|
+
// Timers must not inherit an open database transaction from their caller.
|
|
69
|
+
const scheduleContextFree = AsyncLocalStorage.snapshot();
|
|
70
|
+
|
|
71
|
+
function readPositiveIntEnv(key: string, env: NodeJS.ProcessEnv = process.env): number | null {
|
|
72
|
+
const raw = env[key]?.trim();
|
|
73
|
+
if (!raw || !/^\d+$/.test(raw)) return null;
|
|
74
|
+
const value = Number(raw);
|
|
75
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= MAX_DB_RETENTION_DAYS ? value : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function yieldTick(): Promise<void> {
|
|
79
|
+
return new Promise((resolve) => setTimeout(resolve, YIELD_MS));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function retentionDays(table: RetentionTable): number | null {
|
|
83
|
+
return readPositiveIntEnv(table.envKey);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dryRunEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
87
|
+
const raw = env.DB_RETENTION_DRY_RUN;
|
|
88
|
+
if (raw === undefined || raw.trim() === "") return false;
|
|
89
|
+
return isEnvFlagEnabled("DB_RETENTION_DRY_RUN", true, env);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function sweepTable(
|
|
93
|
+
table: RetentionTable,
|
|
94
|
+
cutoff: string,
|
|
95
|
+
dryRun: boolean,
|
|
96
|
+
deadline: number,
|
|
97
|
+
batchSize: number,
|
|
98
|
+
perTableBatchCap: number,
|
|
99
|
+
signal: AbortSignal,
|
|
100
|
+
): Promise<{ rowsDeleted: number; batches: number; complete: boolean }> {
|
|
101
|
+
const client = getDbClient();
|
|
102
|
+
let rowsDeleted = 0;
|
|
103
|
+
let batches = 0;
|
|
104
|
+
let pagesFetched = 0;
|
|
105
|
+
let cursor: string | null = null;
|
|
106
|
+
let complete = true;
|
|
107
|
+
let exhausted = false;
|
|
108
|
+
// Bounded on pages fetched, not on delete batches: a sparse or no-match
|
|
109
|
+
// table would otherwise walk every primary-key page until the wall-clock
|
|
110
|
+
// deadline, since batches only increments when a page has expired rows.
|
|
111
|
+
const pageLimit = dryRun ? Number.POSITIVE_INFINITY : perTableBatchCap;
|
|
112
|
+
while (!signal.aborted && pagesFetched < pageLimit && Date.now() < deadline) {
|
|
113
|
+
// Page by the primary key, not by the retention predicate. A query that
|
|
114
|
+
// filters on createdAt while ordering by id can scan the whole PK index
|
|
115
|
+
// before finding LIMIT matches when eligible rows are sparse. This query
|
|
116
|
+
// visits at most batchSize rows per statement; cutoff filtering happens in
|
|
117
|
+
// memory and deletes are restricted to the IDs in that bounded page.
|
|
118
|
+
const page: Array<{ id: string; createdAt: string }> = await client.query<{
|
|
119
|
+
id: string;
|
|
120
|
+
createdAt: string;
|
|
121
|
+
}>(
|
|
122
|
+
`SELECT id, ${table.timeColumn} AS createdAt
|
|
123
|
+
FROM ${table.table}
|
|
124
|
+
${cursor === null ? "" : "WHERE id > ?"}
|
|
125
|
+
ORDER BY id
|
|
126
|
+
LIMIT ?`,
|
|
127
|
+
cursor === null ? [batchSize] : [cursor, batchSize],
|
|
128
|
+
);
|
|
129
|
+
pagesFetched += 1;
|
|
130
|
+
if (Date.now() >= deadline || signal.aborted) {
|
|
131
|
+
complete = false;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
if (page.length === 0) {
|
|
135
|
+
exhausted = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
cursor = page[page.length - 1]?.id ?? null;
|
|
140
|
+
const expiredIds = page.filter((row) => row.createdAt < cutoff).map((row) => row.id);
|
|
141
|
+
if (expiredIds.length > 0) {
|
|
142
|
+
if (dryRun) {
|
|
143
|
+
rowsDeleted += expiredIds.length;
|
|
144
|
+
} else {
|
|
145
|
+
// IDs came directly from the bounded page and are placeholders, so
|
|
146
|
+
// this statement is bounded by the page size and remains retryable.
|
|
147
|
+
const placeholders = expiredIds.map(() => "?").join(", ");
|
|
148
|
+
const result = await client.run(
|
|
149
|
+
`DELETE FROM ${table.table} WHERE id IN (${placeholders})`,
|
|
150
|
+
expiredIds,
|
|
151
|
+
);
|
|
152
|
+
rowsDeleted += result.changes;
|
|
153
|
+
}
|
|
154
|
+
batches += 1;
|
|
155
|
+
}
|
|
156
|
+
if (page.length < batchSize) {
|
|
157
|
+
exhausted = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
await yieldTick();
|
|
161
|
+
}
|
|
162
|
+
// A dry-run count is only useful if the full table was scanned. Never
|
|
163
|
+
// publish a partial count when the wall-clock budget or shutdown interrupted
|
|
164
|
+
// the keyset scan.
|
|
165
|
+
return { rowsDeleted, batches, complete: !dryRun || (complete && exhausted) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Run one bounded sweep over every enabled table. Failures are isolated per table. */
|
|
169
|
+
export function runDbRetentionTick(options: DbRetentionTickOptions = {}): Promise<void> {
|
|
170
|
+
if (retentionTickPromise) return retentionTickPromise;
|
|
171
|
+
|
|
172
|
+
const abortController = new AbortController();
|
|
173
|
+
retentionAbortController = abortController;
|
|
174
|
+
const promise = Promise.resolve().then(async () => {
|
|
175
|
+
try {
|
|
176
|
+
const tickStartedAt = Date.now();
|
|
177
|
+
const cutoffBase = options.now ?? new Date(tickStartedAt);
|
|
178
|
+
const dryRun = dryRunEnabled();
|
|
179
|
+
const batchSize = options.batchSize ?? BATCH_SIZE;
|
|
180
|
+
const perTableBatchCap = options.perTableBatchCap ?? PER_TABLE_BATCH_CAP;
|
|
181
|
+
const deadline = tickStartedAt + (options.wallClockCapMs ?? WALL_CLOCK_CAP_MS);
|
|
182
|
+
let deletedAny = false;
|
|
183
|
+
|
|
184
|
+
for (const table of DB_RETENTION_TABLES) {
|
|
185
|
+
if (abortController.signal.aborted || Date.now() >= deadline) break;
|
|
186
|
+
const days = retentionDays(table);
|
|
187
|
+
if (days === null) continue;
|
|
188
|
+
|
|
189
|
+
const startedAt = Date.now();
|
|
190
|
+
try {
|
|
191
|
+
const cutoff = new Date(cutoffBase.getTime() - days * DAY_MS).toISOString();
|
|
192
|
+
const result = await sweepTable(
|
|
193
|
+
table,
|
|
194
|
+
cutoff,
|
|
195
|
+
dryRun,
|
|
196
|
+
deadline,
|
|
197
|
+
batchSize,
|
|
198
|
+
perTableBatchCap,
|
|
199
|
+
abortController.signal,
|
|
200
|
+
);
|
|
201
|
+
if (dryRun && !result.complete) {
|
|
202
|
+
throw new Error("dry-run count stopped before completion");
|
|
203
|
+
}
|
|
204
|
+
deletedAny ||= !dryRun && result.rowsDeleted > 0;
|
|
205
|
+
const cumulative =
|
|
206
|
+
(cumulativeRowsDeleted[table.metricsKey] ?? 0) + (dryRun ? 0 : result.rowsDeleted);
|
|
207
|
+
cumulativeRowsDeleted[table.metricsKey] = cumulative;
|
|
208
|
+
retentionStats[table.metricsKey] = {
|
|
209
|
+
at: new Date().toISOString(),
|
|
210
|
+
rowsDeleted: result.rowsDeleted,
|
|
211
|
+
batches: result.batches,
|
|
212
|
+
durationMs: Date.now() - startedAt,
|
|
213
|
+
dryRun,
|
|
214
|
+
cumulativeRowsDeleted: cumulative,
|
|
215
|
+
};
|
|
216
|
+
if (result.rowsDeleted > 0) {
|
|
217
|
+
console.log(
|
|
218
|
+
`[db-retention] ${table.table}: ${dryRun ? "would delete" : "deleted"} ${result.rowsDeleted} row(s) in ${result.batches} batch(es) after ${Date.now() - startedAt}ms`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`[db-retention] ${table.table} sweep failed:`, (err as Error).message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (deletedAny && !abortController.signal.aborted) {
|
|
227
|
+
try {
|
|
228
|
+
// Harmless when auto_vacuum is not INCREMENTAL; never run a blocking VACUUM here.
|
|
229
|
+
await getDbClient().run("PRAGMA incremental_vacuum(2000)");
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error("[db-retention] incremental vacuum failed:", (err as Error).message);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
if (retentionAbortController === abortController) retentionAbortController = null;
|
|
236
|
+
retentionTickPromise = null;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
retentionTickPromise = promise;
|
|
240
|
+
return promise;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getDbRetentionStats(): DbRetentionStats {
|
|
244
|
+
return { ...retentionStats };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Start the hourly sweep. The first tick runs immediately. */
|
|
248
|
+
export async function startDbRetention(intervalMs = RETENTION_INTERVAL_MS): Promise<void> {
|
|
249
|
+
if (retentionTimer) return;
|
|
250
|
+
const configured = DB_RETENTION_TABLES.map(
|
|
251
|
+
(table) => `${table.table}=${retentionDays(table) ?? "disabled"}`,
|
|
252
|
+
).join(", ");
|
|
253
|
+
console.log(`[db-retention] starting (${configured}, dryRun=${dryRunEnabled()})`);
|
|
254
|
+
retentionTimer = scheduleContextFree(() =>
|
|
255
|
+
setInterval(() => void runDbRetentionTick(), intervalMs),
|
|
256
|
+
);
|
|
257
|
+
if (typeof retentionTimer.unref === "function") retentionTimer.unref();
|
|
258
|
+
await runDbRetentionTick();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function stopDbRetention(): Promise<void> {
|
|
262
|
+
if (retentionTimer) {
|
|
263
|
+
clearInterval(retentionTimer);
|
|
264
|
+
retentionTimer = null;
|
|
265
|
+
}
|
|
266
|
+
retentionAbortController?.abort();
|
|
267
|
+
await retentionTickPromise;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Test hook to prevent module state leaking between Bun test files. */
|
|
271
|
+
export async function resetDbRetentionForTests(): Promise<void> {
|
|
272
|
+
await stopDbRetention();
|
|
273
|
+
retentionStats = {};
|
|
274
|
+
cumulativeRowsDeleted = {};
|
|
275
|
+
}
|
package/src/be/db.ts
CHANGED
|
@@ -4782,13 +4782,19 @@ export async function createTaskExtended(
|
|
|
4782
4782
|
let requestedByUserIdInherited = false;
|
|
4783
4783
|
const id = crypto.randomUUID();
|
|
4784
4784
|
const now = new Date().toISOString();
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4785
|
+
// `status: "draft"` wins over offeredTo/agentId — a draft task keeps its
|
|
4786
|
+
// intended owner/offer for when it's promoted (#1240), but must not be
|
|
4787
|
+
// dispatch-eligible while attachments are still uploading.
|
|
4788
|
+
const status: AgentTaskStatus =
|
|
4789
|
+
options?.status === "draft"
|
|
4790
|
+
? "draft"
|
|
4791
|
+
: options?.offeredTo
|
|
4792
|
+
? "offered"
|
|
4793
|
+
: options?.agentId
|
|
4794
|
+
? "pending"
|
|
4795
|
+
: options?.status === "backlog"
|
|
4796
|
+
? "backlog"
|
|
4797
|
+
: "unassigned";
|
|
4792
4798
|
|
|
4793
4799
|
// Inherit Slack/AgentMail metadata from parent task (unless explicitly overridden)
|
|
4794
4800
|
if (options?.parentTaskId) {
|
|
@@ -5355,6 +5361,75 @@ export async function moveTaskFromBacklog(taskId: string): Promise<AgentTask | n
|
|
|
5355
5361
|
return row ? rowToAgentTask(row) : null;
|
|
5356
5362
|
}
|
|
5357
5363
|
|
|
5364
|
+
/**
|
|
5365
|
+
* Promote a task out of `draft` status into whichever status it should have
|
|
5366
|
+
* had at creation time — `offered` if it was offered to an agent, `pending`
|
|
5367
|
+
* if it has an owning agent (the common UI-composer case, which defaults to
|
|
5368
|
+
* Lead), otherwise `unassigned`. `draft` exists solely to keep a task
|
|
5369
|
+
* dispatch-ineligible while its UI-uploaded attachments are still in flight
|
|
5370
|
+
* (#1240); this is the promote half of that window. Called once the upload
|
|
5371
|
+
* batch settles — success, partial failure, or total failure all promote, a
|
|
5372
|
+
* draft is never left stranded by upload errors — or by
|
|
5373
|
+
* `promoteAbandonedDraftTasks` for drafts nobody ever promoted.
|
|
5374
|
+
*/
|
|
5375
|
+
export async function promoteDraftTask(taskId: string): Promise<AgentTask | null> {
|
|
5376
|
+
const now = new Date().toISOString();
|
|
5377
|
+
const row = await getDbClient().get<AgentTaskRow>(
|
|
5378
|
+
`UPDATE agent_tasks
|
|
5379
|
+
SET status = CASE
|
|
5380
|
+
WHEN offeredTo IS NOT NULL THEN 'offered'
|
|
5381
|
+
WHEN agentId IS NOT NULL THEN 'pending'
|
|
5382
|
+
ELSE 'unassigned'
|
|
5383
|
+
END,
|
|
5384
|
+
lastUpdatedAt = ?
|
|
5385
|
+
WHERE id = ? AND status = 'draft'
|
|
5386
|
+
RETURNING *`,
|
|
5387
|
+
[now, taskId],
|
|
5388
|
+
);
|
|
5389
|
+
|
|
5390
|
+
if (row) {
|
|
5391
|
+
try {
|
|
5392
|
+
await createLogEntry({
|
|
5393
|
+
eventType: "task_status_change",
|
|
5394
|
+
taskId,
|
|
5395
|
+
agentId: row.agentId ?? undefined,
|
|
5396
|
+
oldValue: "draft",
|
|
5397
|
+
newValue: row.status,
|
|
5398
|
+
});
|
|
5399
|
+
} catch {}
|
|
5400
|
+
}
|
|
5401
|
+
|
|
5402
|
+
return row ? rowToAgentTask(row) : null;
|
|
5403
|
+
}
|
|
5404
|
+
|
|
5405
|
+
/**
|
|
5406
|
+
* Heartbeat sweep: promote drafts abandoned mid-upload (tab closed, network
|
|
5407
|
+
* dropped) instead of leaving them permanently dispatch-ineligible — the
|
|
5408
|
+
* composer's UI-driven promote (`promoteDraftTask`) never fires if the user
|
|
5409
|
+
* never comes back. Same target-status logic, applied in bulk by elapsed
|
|
5410
|
+
* time. Default 5 minutes is well above the tens-of-seconds worst-case
|
|
5411
|
+
* upload-batch duration seen against slow storage (#1226) while still
|
|
5412
|
+
* surfacing an abandoned session to its owner promptly.
|
|
5413
|
+
*/
|
|
5414
|
+
export async function promoteAbandonedDraftTasks(timeoutMinutes: number = 5): Promise<number> {
|
|
5415
|
+
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
|
|
5416
|
+
const now = new Date().toISOString();
|
|
5417
|
+
|
|
5418
|
+
const result = await getDbClient().run(
|
|
5419
|
+
`UPDATE agent_tasks
|
|
5420
|
+
SET status = CASE
|
|
5421
|
+
WHEN offeredTo IS NOT NULL THEN 'offered'
|
|
5422
|
+
WHEN agentId IS NOT NULL THEN 'pending'
|
|
5423
|
+
ELSE 'unassigned'
|
|
5424
|
+
END,
|
|
5425
|
+
lastUpdatedAt = ?
|
|
5426
|
+
WHERE status = 'draft' AND lastUpdatedAt < ?`,
|
|
5427
|
+
[now, cutoffTime],
|
|
5428
|
+
);
|
|
5429
|
+
|
|
5430
|
+
return result.changes;
|
|
5431
|
+
}
|
|
5432
|
+
|
|
5358
5433
|
/**
|
|
5359
5434
|
* Release tasks that have been in 'reviewing' status for too long.
|
|
5360
5435
|
* Returns them to 'offered' status for retry.
|
|
@@ -39,6 +39,9 @@ type ConfigValidator = (value: unknown) => string | null;
|
|
|
39
39
|
|
|
40
40
|
const BOOLEAN_LITERALS = ["true", "false", "1", "0"];
|
|
41
41
|
|
|
42
|
+
/** A conservative window that remains representable by JavaScript Date arithmetic. */
|
|
43
|
+
export const MAX_DB_RETENTION_DAYS = 1_000_000;
|
|
44
|
+
|
|
42
45
|
/** Build `{ KEY: validator }` entries accepting only boolean literals. */
|
|
43
46
|
function booleanValidators(keys: string[]): Record<string, ConfigValidator> {
|
|
44
47
|
const message = (key: string) =>
|
|
@@ -81,6 +84,27 @@ function integerValidators(keys: string[], min: number): Record<string, ConfigVa
|
|
|
81
84
|
);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/** Build `{ KEY: validator }` entries accepting integers inside a closed range. */
|
|
88
|
+
function boundedIntegerValidators(
|
|
89
|
+
keys: string[],
|
|
90
|
+
min: number,
|
|
91
|
+
max: number,
|
|
92
|
+
): Record<string, ConfigValidator> {
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
keys.map((key) => [
|
|
95
|
+
key,
|
|
96
|
+
(value: unknown) => {
|
|
97
|
+
const str = String(value).trim();
|
|
98
|
+
const parsed = Number(str);
|
|
99
|
+
if (!/^\d+$/.test(str) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
|
100
|
+
return `Invalid ${key} (must be an integer between ${min} and ${max})`;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
},
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
function validateFloatRange(
|
|
85
109
|
key: string,
|
|
86
110
|
value: unknown,
|
|
@@ -163,6 +187,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
163
187
|
"ANONYMIZED_TELEMETRY",
|
|
164
188
|
"SWARM_HIDE_CLOUD_PROMO",
|
|
165
189
|
"DB_QUERY_BOUNDED_ENABLED",
|
|
190
|
+
"DB_RETENTION_DRY_RUN",
|
|
166
191
|
]),
|
|
167
192
|
...enumValidator("SLACK_THREAD_STEERING", ["lead", "all"]),
|
|
168
193
|
...enumValidator("SLACK_THREAD_STEERING_MODE", ["steer", "queue"]),
|
|
@@ -191,6 +216,11 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
191
216
|
],
|
|
192
217
|
1,
|
|
193
218
|
),
|
|
219
|
+
...boundedIntegerValidators(
|
|
220
|
+
["SESSION_LOG_RETENTION_DAYS", "AGENT_LOG_RETENTION_DAYS", "EVENTS_RETENTION_DAYS"],
|
|
221
|
+
1,
|
|
222
|
+
MAX_DB_RETENTION_DAYS,
|
|
223
|
+
),
|
|
194
224
|
// 0 is meaningful here: "auto-assign nothing this sweep".
|
|
195
225
|
...integerValidators(["HEARTBEAT_MAX_AUTO_ASSIGN"], 0),
|
|
196
226
|
MEMORY_MIN_SIMILARITY: (value) =>
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
isAgentEligibleForTask,
|
|
30
30
|
isPoolAffinityEnforcementEnabled,
|
|
31
31
|
MAX_EMPTY_POLLS,
|
|
32
|
+
promoteAbandonedDraftTasks,
|
|
32
33
|
releaseStaleMentionProcessing,
|
|
33
34
|
releaseStaleOfferedTasksForOfflineAgents,
|
|
34
35
|
releaseStaleProcessingInbox,
|
|
@@ -116,6 +117,16 @@ export const STEERING_STALL_GRACE_MIN = Number(process.env.HEARTBEAT_STEERING_GR
|
|
|
116
117
|
/** Stale resource cleanup threshold (minutes) */
|
|
117
118
|
const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEANUP_MIN) || 30;
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Abandoned-draft promotion threshold (minutes, #1240). Deliberately much
|
|
122
|
+
* shorter than STALE_CLEANUP_THRESHOLD_MINUTES: a draft only exists while a
|
|
123
|
+
* UI-composer attachment batch uploads, which even against slow storage
|
|
124
|
+
* (#1226) took tens of seconds — 5 minutes is generous headroom above that
|
|
125
|
+
* worst case while still surfacing an abandoned session (tab closed
|
|
126
|
+
* mid-upload) to its owner within one coffee break, not half an hour.
|
|
127
|
+
*/
|
|
128
|
+
const DRAFT_TASK_TIMEOUT_MINUTES = Number(process.env.HEARTBEAT_DRAFT_TASK_TIMEOUT_MIN) || 5;
|
|
129
|
+
|
|
119
130
|
/** Max pool tasks to auto-assign per sweep */
|
|
120
131
|
function maxAutoAssignPerSweep(): number {
|
|
121
132
|
const raw = process.env.HEARTBEAT_MAX_AUTO_ASSIGN?.trim();
|
|
@@ -221,6 +232,7 @@ export interface HeartbeatFindings {
|
|
|
221
232
|
workflowRuns: number;
|
|
222
233
|
staleRuntimes: number;
|
|
223
234
|
staleOfferedTasks: number;
|
|
235
|
+
abandonedDraftTasks: number;
|
|
224
236
|
};
|
|
225
237
|
}
|
|
226
238
|
|
|
@@ -296,6 +308,7 @@ export async function codeLevelTriage(): Promise<HeartbeatFindings> {
|
|
|
296
308
|
workflowRuns: 0,
|
|
297
309
|
staleRuntimes: 0,
|
|
298
310
|
staleOfferedTasks: 0,
|
|
311
|
+
abandonedDraftTasks: 0,
|
|
299
312
|
},
|
|
300
313
|
};
|
|
301
314
|
|
|
@@ -1026,6 +1039,9 @@ async function cleanupStaleResources(findings: HeartbeatFindings): Promise<void>
|
|
|
1026
1039
|
findings.staleCleanup.inboxProcessing = await releaseStaleProcessingInbox(
|
|
1027
1040
|
STALE_CLEANUP_THRESHOLD_MINUTES,
|
|
1028
1041
|
);
|
|
1042
|
+
findings.staleCleanup.abandonedDraftTasks = await promoteAbandonedDraftTasks(
|
|
1043
|
+
DRAFT_TASK_TIMEOUT_MINUTES,
|
|
1044
|
+
);
|
|
1029
1045
|
// DES-523 Phase 3: escalate pinned crash-recovery resumes that were never
|
|
1030
1046
|
// reclaimed within the grace window to a Lead re-delegation decision.
|
|
1031
1047
|
await escalateUnreclaimedResumes(findings);
|
|
@@ -1331,6 +1347,7 @@ export async function runHeartbeatSweep(): Promise<void> {
|
|
|
1331
1347
|
workflowRuns: 0,
|
|
1332
1348
|
staleRuntimes: 0,
|
|
1333
1349
|
staleOfferedTasks: 0,
|
|
1350
|
+
abandonedDraftTasks: 0,
|
|
1334
1351
|
},
|
|
1335
1352
|
};
|
|
1336
1353
|
// Expiry runs even on a cleanup-only tick: an idle agent whose runtime
|
|
@@ -1396,6 +1413,7 @@ function logFindings(findings: HeartbeatFindings): void {
|
|
|
1396
1413
|
inboxProcessing,
|
|
1397
1414
|
workflowRuns,
|
|
1398
1415
|
staleOfferedTasks,
|
|
1416
|
+
abandonedDraftTasks,
|
|
1399
1417
|
} = findings.staleCleanup;
|
|
1400
1418
|
const totalCleanup =
|
|
1401
1419
|
sessions + reviewingTasks + mentionProcessing + inboxProcessing + workflowRuns;
|
|
@@ -1405,6 +1423,9 @@ function logFindings(findings: HeartbeatFindings): void {
|
|
|
1405
1423
|
if (staleOfferedTasks > 0) {
|
|
1406
1424
|
parts.push(`stale_offers_released=${staleOfferedTasks}`);
|
|
1407
1425
|
}
|
|
1426
|
+
if (abandonedDraftTasks > 0) {
|
|
1427
|
+
parts.push(`abandoned_drafts_promoted=${abandonedDraftTasks}`);
|
|
1428
|
+
}
|
|
1408
1429
|
|
|
1409
1430
|
if (parts.length > 0) {
|
|
1410
1431
|
console.log(`[Heartbeat] Sweep complete: ${parts.join(", ")}`);
|
package/src/http/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/se
|
|
|
9
9
|
import { getEnabledCapabilities, hasCapability } from "@/server";
|
|
10
10
|
import { initAgentMail } from "../agentmail";
|
|
11
11
|
import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
|
|
12
|
+
import { startDbRetention, stopDbRetention } from "../be/db-retention";
|
|
12
13
|
import {
|
|
13
14
|
enqueueAuditRow,
|
|
14
15
|
flushAuditBuffer,
|
|
@@ -107,6 +108,7 @@ import {
|
|
|
107
108
|
parseQueryParams,
|
|
108
109
|
safeRequestUrlForLog,
|
|
109
110
|
setCorsHeaders,
|
|
111
|
+
wireHttpSpanLifecycle,
|
|
110
112
|
} from "./utils";
|
|
111
113
|
import { handleWebhooks } from "./webhooks";
|
|
112
114
|
import { handleWorkflowEvents } from "./workflow-events";
|
|
@@ -213,7 +215,6 @@ const transportActivityUser: McpTransportActivity = globalState.__transportActiv
|
|
|
213
215
|
const httpServer = createHttpServer(async (req, res) => {
|
|
214
216
|
const startTime = performance.now();
|
|
215
217
|
let statusCode = 200;
|
|
216
|
-
let spanEnded = false;
|
|
217
218
|
|
|
218
219
|
// Wrap writeHead to capture status code
|
|
219
220
|
const originalWriteHead = res.writeHead.bind(res);
|
|
@@ -245,51 +246,43 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
245
246
|
await withRemoteContext(req.headers as Record<string, unknown>, async () => {
|
|
246
247
|
const reqPath = req.url?.split("?")[0] ?? "";
|
|
247
248
|
const pathSegments = getPathSegments(req.url || "");
|
|
249
|
+
const hasQueryString = (req.url ?? "").includes("?");
|
|
250
|
+
const hasTrailingSlash = reqPath.length > 1 && reqPath.endsWith("/");
|
|
248
251
|
const skipSpan = reqPath === "/api/poll" && !isPollTracingEnabled();
|
|
249
252
|
// Per OTel HTTP semantic conventions: span name is `{METHOD} {route-template}`
|
|
250
253
|
// and `http.route` carries the bounded-cardinality template so SigNoz can
|
|
251
254
|
// group/filter/aggregate by endpoint as a first-class field. `http.route` is
|
|
252
255
|
// omitted (not fabricated) for unmatched core/MCP/404 paths. Raw path stays
|
|
253
256
|
// on `url.path`.
|
|
254
|
-
const { spanName, httpRoute } = describeRequestRoute(
|
|
257
|
+
const { spanName, httpRoute } = describeRequestRoute(
|
|
258
|
+
req.method,
|
|
259
|
+
pathSegments,
|
|
260
|
+
hasQueryString,
|
|
261
|
+
hasTrailingSlash,
|
|
262
|
+
);
|
|
255
263
|
// Standard OTel HTTP server semconv attributes — host, scheme, protocol
|
|
256
264
|
// version, user-agent (the method/path/route/status are set inline below).
|
|
257
265
|
const semconv = httpServerSemconvAttributes(req);
|
|
258
266
|
const span = skipSpan
|
|
259
267
|
? null
|
|
260
|
-
: startSpan(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
: startSpan(
|
|
269
|
+
spanName,
|
|
270
|
+
{
|
|
271
|
+
"http.request.method": req.method ?? "",
|
|
272
|
+
"url.path": reqPath,
|
|
273
|
+
"url.scheme": semconv["url.scheme"],
|
|
274
|
+
"http.route": httpRoute,
|
|
275
|
+
"server.address": semconv["server.address"],
|
|
276
|
+
"network.protocol.version": semconv["network.protocol.version"],
|
|
277
|
+
"user_agent.original": semconv["user_agent.original"],
|
|
278
|
+
"agent.id": req.headers["x-agent-id"] as string | undefined,
|
|
279
|
+
"agentswarm.component": "api",
|
|
280
|
+
},
|
|
281
|
+
{ kind: "server" },
|
|
282
|
+
);
|
|
271
283
|
|
|
272
284
|
if (span) {
|
|
273
|
-
res
|
|
274
|
-
if (spanEnded) return;
|
|
275
|
-
spanEnded = true;
|
|
276
|
-
span.setAttributes({
|
|
277
|
-
"http.response.status_code": statusCode,
|
|
278
|
-
"agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
|
|
279
|
-
});
|
|
280
|
-
if (statusCode >= 500) {
|
|
281
|
-
span.setStatus({ code: 2, message: `HTTP ${statusCode}` });
|
|
282
|
-
}
|
|
283
|
-
span.end();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
res.on("error", (err) => {
|
|
287
|
-
if (spanEnded) return;
|
|
288
|
-
spanEnded = true;
|
|
289
|
-
span.recordException(err);
|
|
290
|
-
span.setStatus({ code: 2, message: err.message });
|
|
291
|
-
span.end();
|
|
292
|
-
});
|
|
285
|
+
wireHttpSpanLifecycle(res, span, () => statusCode, startTime);
|
|
293
286
|
}
|
|
294
287
|
|
|
295
288
|
// Run request handling inside the HTTP span's active context so any spans
|
|
@@ -449,6 +442,9 @@ async function shutdown() {
|
|
|
449
442
|
// Stop memory expired-row garbage collector
|
|
450
443
|
stopMemoryGc();
|
|
451
444
|
|
|
445
|
+
// Stop opt-in session, agent-log, and event retention before closing SQLite.
|
|
446
|
+
await stopDbRetention();
|
|
447
|
+
|
|
452
448
|
// Stop scratch-script retention garbage collector
|
|
453
449
|
stopScratchScriptGc();
|
|
454
450
|
|
|
@@ -693,6 +689,10 @@ httpServer
|
|
|
693
689
|
// Start expired-memory garbage collector (1-hour tick, immediate first run)
|
|
694
690
|
await startMemoryGc();
|
|
695
691
|
|
|
692
|
+
// Start the opt-in DB retention sweep after config hydration. Every key is
|
|
693
|
+
// read on each tick, so config reloads take effect without a restart.
|
|
694
|
+
await startDbRetention();
|
|
695
|
+
|
|
696
696
|
// (RBAC audit sink is wired pre-listen — see above httpServer.listen.)
|
|
697
697
|
|
|
698
698
|
// Background backfill: re-embed any agent_memory rows with wrong-dimension
|