@inflexa-ai/harness 0.35.2 → 0.35.4
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/citations/types.d.ts +2 -2
- package/dist/config/environment-stores.d.ts +10 -0
- package/dist/contracts/schemas/chat-parts.d.ts +11 -11
- package/dist/execution/run-canceler.js +20 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/lib/metrics.d.ts +63 -5
- package/dist/lib/metrics.js +104 -5
- package/dist/lib/otel-spans.d.ts +77 -0
- package/dist/lib/otel-spans.js +123 -0
- package/dist/lib/otel.d.ts +12 -3
- package/dist/lib/otel.js +15 -4
- package/dist/loop/run-agent.js +13 -2
- package/dist/prompts/planner.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +8 -3
- package/dist/runtime/dbos.d.ts +8 -7
- package/dist/sandbox/await-exec.js +16 -9
- package/dist/sandbox/notification-sweep.js +5 -2
- package/dist/sandbox/package-identity.d.ts +208 -0
- package/dist/sandbox/package-identity.js +244 -0
- package/dist/sandbox/reaper.js +5 -2
- package/dist/sandbox/submit-exec.js +6 -3
- package/dist/sandbox/types.d.ts +20 -25
- package/dist/sandbox/watchdog.js +8 -3
- package/dist/schemas/plan-schemas.d.ts +3 -3
- package/dist/schemas/validate-plan.js +32 -15
- package/dist/schemas/workflow-state.js +3 -1
- package/dist/state/data-profile.d.ts +20 -6
- package/dist/state/data-profile.js +16 -8
- package/dist/state/index.d.ts +1 -1
- package/dist/state/schema.d.ts +2 -2
- package/dist/tasks/data-profile.js +26 -12
- package/dist/tools/execute-analysis.js +37 -20
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/sandbox/link-packages.d.ts +6 -6
- package/dist/tools/sandbox/link-packages.js +45 -14
- package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
- package/dist/tools/sandbox/list-available-packages.js +185 -43
- package/dist/workflows/execute-analysis.d.ts +4 -1
- package/dist/workflows/execute-analysis.js +40 -4
- package/dist/workflows/execute-target-assessment.js +68 -31
- package/dist/workflows/sandbox-step.js +7 -1
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { ok } from "neverthrow";
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
import { defineTool } from "../define-tool.js";
|
|
25
|
+
import { identityAddress, identityKey, identityOf, parseQuery, resolveQuery, } from "../../sandbox/package-identity.js";
|
|
25
26
|
import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
26
27
|
import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
27
28
|
import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
@@ -46,8 +47,9 @@ const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FIL
|
|
|
46
47
|
/**
|
|
47
48
|
* The two headings that a lock track and the image record can both carry.
|
|
48
49
|
* Declared once, because {@link LANGUAGE_MATCHERS} keys the `language` filter
|
|
49
|
-
* on the heading text, and {@link foldSections} merges two
|
|
50
|
-
* second spelling of either would split one track into two
|
|
50
|
+
* of these two on the heading text, and {@link foldSections} merges two
|
|
51
|
+
* sources by it: a second spelling of either would split one track into two
|
|
52
|
+
* headings.
|
|
51
53
|
*/
|
|
52
54
|
const CLI_TITLE = "System tools (CLI)";
|
|
53
55
|
const NODE_TITLE = "Node (npm)";
|
|
@@ -59,6 +61,20 @@ const TRACK_TITLES = {
|
|
|
59
61
|
github: "R (GitHub)",
|
|
60
62
|
node: NODE_TITLE,
|
|
61
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* The ecosystem of one lock subtree. `python` gives the Python track, and
|
|
66
|
+
* `cran`, `bioconductor`, and `github` give the R track — three subtrees of
|
|
67
|
+
* one ecosystem, because a repository is a source and not an identity rule.
|
|
68
|
+
*
|
|
69
|
+
* A subtree that names neither, such as `node`, carries no track: its packages
|
|
70
|
+
* are not in the pool of the two ecosystems, and a `names` lookup matches them
|
|
71
|
+
* by their rendered name.
|
|
72
|
+
*/
|
|
73
|
+
export function trackOfLockSubtree(subtree) {
|
|
74
|
+
if (subtree === "python")
|
|
75
|
+
return "python";
|
|
76
|
+
return subtree === "cran" || subtree === "bioconductor" || subtree === "github" ? "r" : undefined;
|
|
77
|
+
}
|
|
62
78
|
/**
|
|
63
79
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
64
80
|
* the known tracks first, then an unknown track at its first occurrence.
|
|
@@ -77,7 +93,12 @@ export function lockSections(lock) {
|
|
|
77
93
|
else
|
|
78
94
|
byTrack.set(pkg.track, [entry]);
|
|
79
95
|
}
|
|
80
|
-
return [...byTrack.entries()]
|
|
96
|
+
return [...byTrack.entries()]
|
|
97
|
+
.filter(([, packages]) => packages.length > 0)
|
|
98
|
+
.map(([subtree, packages]) => {
|
|
99
|
+
const track = trackOfLockSubtree(subtree);
|
|
100
|
+
return { title: TRACK_TITLES[subtree] ?? subtree, packages, ...(track === undefined ? {} : { track }) };
|
|
101
|
+
});
|
|
81
102
|
}
|
|
82
103
|
/**
|
|
83
104
|
* Group the image record into its two sections, in the order of the record.
|
|
@@ -116,18 +137,22 @@ function foldSections(sections) {
|
|
|
116
137
|
for (const section of sections) {
|
|
117
138
|
let open = byTitle.get(section.title);
|
|
118
139
|
if (!open) {
|
|
119
|
-
open = { title: section.title, packages: [], seen: new Set() };
|
|
140
|
+
open = { title: section.title, track: section.track, packages: [], seen: new Set() };
|
|
120
141
|
byTitle.set(section.title, open);
|
|
121
142
|
}
|
|
122
143
|
for (const pkg of section.packages) {
|
|
123
|
-
|
|
124
|
-
|
|
144
|
+
// An untracked section holds an executable or a node module, and
|
|
145
|
+
// the identity rule of a package track does not reach either one.
|
|
146
|
+
// Such a row folds by case alone, thus two sources that spell one
|
|
147
|
+
// executable differently still render one row.
|
|
148
|
+
const seen = section.track === undefined ? pkg.name.toLowerCase() : pkg.name;
|
|
149
|
+
if (open.seen.has(seen))
|
|
125
150
|
continue;
|
|
126
|
-
open.seen.add(
|
|
151
|
+
open.seen.add(seen);
|
|
127
152
|
open.packages.push(pkg);
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
|
-
return [...byTitle.values()].map(({ title, packages }) => ({ title, packages }));
|
|
155
|
+
return [...byTitle.values()].map(({ title, track, packages }) => ({ title, packages, ...(track === undefined ? {} : { track }) }));
|
|
131
156
|
}
|
|
132
157
|
/**
|
|
133
158
|
* Default cap on a listing — high enough that the real store is never truncated.
|
|
@@ -156,21 +181,59 @@ const UNAVAILABLE_NOTE = "Package list not available — the inventory of the fa
|
|
|
156
181
|
*/
|
|
157
182
|
const POOL_UNAVAILABLE_NOTE = "Package list not available — the package pool could not be read, so what the store holds is UNKNOWN. " +
|
|
158
183
|
"Do not assume a package is present, and do not report a package as absent.";
|
|
159
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* The `language` filter of the two sections that carry no track, mapped onto
|
|
186
|
+
* their concrete headings. The `r` and `python` values read `section.track`
|
|
187
|
+
* instead, because a package track is data and a heading is display text.
|
|
188
|
+
*/
|
|
160
189
|
const LANGUAGE_MATCHERS = {
|
|
161
|
-
// The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
|
|
162
|
-
r: (t) => /^r\b/i.test(t),
|
|
163
|
-
python: (t) => /^python\b/i.test(t),
|
|
164
190
|
// `CLI_TITLE` — the conda-installed bioinformatics executables.
|
|
165
191
|
cli: (t) => /system tools|\bcli\b/i.test(t),
|
|
166
192
|
node: (t) => /^node\b/i.test(t),
|
|
167
193
|
};
|
|
168
|
-
/**
|
|
169
|
-
function
|
|
170
|
-
|
|
194
|
+
/** Whether one section answers the `language` filter of a call. */
|
|
195
|
+
function matchesLanguage(section, language) {
|
|
196
|
+
if (language === undefined)
|
|
197
|
+
return true;
|
|
198
|
+
if (language === "r" || language === "python")
|
|
199
|
+
return section.track === language;
|
|
200
|
+
return LANGUAGE_MATCHERS[language](section.title);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The identity names that the Python track and the R track both hold.
|
|
204
|
+
*
|
|
205
|
+
* A plan entry of such a name cannot say which package it means, thus the
|
|
206
|
+
* link pass refuses it as a collision. The listing marks each of these rows
|
|
207
|
+
* with the prefixed forms, and the planner writes one of them. A pair of two
|
|
208
|
+
* spellings, such as `decoupler` and `decoupleR`, needs no mark, because the
|
|
209
|
+
* two identities differ and the spelling settles the track.
|
|
210
|
+
*/
|
|
211
|
+
function bothTrackNames(sections) {
|
|
212
|
+
const python = new Set();
|
|
213
|
+
const r = new Set();
|
|
214
|
+
for (const section of sections) {
|
|
215
|
+
if (section.track === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
const holder = section.track === "python" ? python : r;
|
|
218
|
+
for (const pkg of section.packages)
|
|
219
|
+
holder.add(identityOf(section.track, pkg.name).name);
|
|
220
|
+
}
|
|
221
|
+
return new Set([...python].filter((name) => r.has(name)));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* One entry as the listing renders it: `name==version` where the source pins
|
|
225
|
+
* a version, the bare name otherwise. A name that both tracks hold carries
|
|
226
|
+
* the two forms a plan writes, because a bare entry of it refuses the launch.
|
|
227
|
+
*/
|
|
228
|
+
function renderPackage(entry, track, bothTracks) {
|
|
229
|
+
const rendered = entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
|
|
230
|
+
if (track === undefined)
|
|
231
|
+
return rendered;
|
|
232
|
+
const identity = identityOf(track, entry.name).name;
|
|
233
|
+
return bothTracks.has(identity) ? `${rendered} [both tracks — write python:${identity} or r:${identity}]` : rendered;
|
|
171
234
|
}
|
|
172
235
|
/** Render sections to the agent-facing listing, bounded at `limit` packages. */
|
|
173
|
-
function renderListing(sections, limit) {
|
|
236
|
+
function renderListing(sections, limit, bothTracks) {
|
|
174
237
|
const lines = [];
|
|
175
238
|
let returned = 0;
|
|
176
239
|
for (const section of sections) {
|
|
@@ -183,13 +246,52 @@ function renderListing(sections, limit) {
|
|
|
183
246
|
}
|
|
184
247
|
const shown = section.packages.slice(0, room);
|
|
185
248
|
returned += shown.length;
|
|
186
|
-
lines.push(`\n## ${section.title}`, ` ${shown.map(renderPackage).join(", ")}`);
|
|
249
|
+
lines.push(`\n## ${section.title}`, ` ${shown.map((entry) => renderPackage(entry, section.track, bothTracks)).join(", ")}`);
|
|
187
250
|
const hidden = section.packages.length - shown.length;
|
|
188
251
|
if (hidden > 0)
|
|
189
252
|
lines.push(` … and ${hidden} more in this section (raise \`limit\` or narrow with \`query\`).`);
|
|
190
253
|
}
|
|
191
254
|
return { content: lines.join("\n").trim(), returned };
|
|
192
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* A {@link PoolIndex} over the rows of the tracked sections. The suggestion of
|
|
258
|
+
* the ladder reads `rIdentitiesFoldingTo`, and the fold of an identity is its
|
|
259
|
+
* store address — thus the census suggests exactly what the store holds.
|
|
260
|
+
*/
|
|
261
|
+
function poolIndexOf(held) {
|
|
262
|
+
const keys = new Set(held.map((row) => identityKey(row.identity)));
|
|
263
|
+
// Keyed by the identity KEY, not by the address: two R sections can list
|
|
264
|
+
// one package, and a suggestion must count that package once. Two R
|
|
265
|
+
// identities of one address stay two entries, and the ladder then gives no
|
|
266
|
+
// suggestion, because a guess between them is a coin flip.
|
|
267
|
+
const rByKey = new Map();
|
|
268
|
+
for (const row of held) {
|
|
269
|
+
if (row.identity.track !== "r")
|
|
270
|
+
continue;
|
|
271
|
+
rByKey.set(identityKey(row.identity), row.identity);
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
has: (identity) => keys.has(identityKey(identity)),
|
|
275
|
+
rIdentitiesFoldingTo: (fold) => [...rByKey.values()].filter((identity) => identityAddress(identity) === fold),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* One present answer of the `names` path. `name` echoes the spelling that the
|
|
280
|
+
* source records, because an R name is case-sensitive at `library()` and the
|
|
281
|
+
* caller imports that exact one. The store identity rides only where the
|
|
282
|
+
* source records it.
|
|
283
|
+
*/
|
|
284
|
+
function presentEntry(requested, row) {
|
|
285
|
+
return {
|
|
286
|
+
requested,
|
|
287
|
+
present: true,
|
|
288
|
+
name: row.entry.name,
|
|
289
|
+
section: row.section,
|
|
290
|
+
...(row.entry.version === undefined ? {} : { version: row.entry.version }),
|
|
291
|
+
...(row.entry.storeDir === undefined ? {} : { storeDir: row.entry.storeDir }),
|
|
292
|
+
...(row.entry.hash === undefined ? {} : { hash: row.entry.hash }),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
193
295
|
/**
|
|
194
296
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
195
297
|
* is only the file read plus this call.
|
|
@@ -199,41 +301,77 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
199
301
|
// Presence check — the cheap, targeted path. Answers "is X available, and in
|
|
200
302
|
// which track" without returning the catalog.
|
|
201
303
|
if (names && names.length > 0) {
|
|
202
|
-
|
|
304
|
+
// A tracked section holds packages of the pool, thus its rows answer
|
|
305
|
+
// through `resolveQuery` — the same ladder the link of the embedder
|
|
306
|
+
// runs, so the census and the link never disagree. An untracked section
|
|
307
|
+
// (system tools, node packages) holds no package of an ecosystem, thus
|
|
308
|
+
// its rows match their rendered name without case: an identity rule
|
|
309
|
+
// covers a distribution, and `BCFtools` must still reach `bcftools`.
|
|
310
|
+
const held = [];
|
|
311
|
+
const untracked = new Map();
|
|
203
312
|
for (const section of sections) {
|
|
204
313
|
for (const pkg of section.packages) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
314
|
+
if (section.track === undefined) {
|
|
315
|
+
const lowered = pkg.name.toLowerCase();
|
|
316
|
+
const rows = untracked.get(lowered) ?? [];
|
|
317
|
+
rows.push({ entry: pkg, section: section.title });
|
|
318
|
+
untracked.set(lowered, rows);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
held.push({ entry: pkg, section: section.title, identity: identityOf(section.track, pkg.name) });
|
|
209
322
|
}
|
|
210
323
|
}
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// The
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
324
|
+
const pool = poolIndexOf(held);
|
|
325
|
+
const bySection = new Map();
|
|
326
|
+
for (const row of held)
|
|
327
|
+
if (!bySection.has(identityKey(row.identity)))
|
|
328
|
+
bySection.set(identityKey(row.identity), row);
|
|
329
|
+
const checked = names.flatMap((requested) => {
|
|
330
|
+
// The entry speaks the one grammar, thus `python:igraph` reads as
|
|
331
|
+
// the Python track and not as a package named `python:igraph`. The
|
|
332
|
+
// listing and the planner both write that form.
|
|
333
|
+
const parsed = parseQuery(requested);
|
|
334
|
+
// A census reports a state, it never refuses a call: an entry that
|
|
335
|
+
// is not a query names nothing that the pool holds, thus it answers
|
|
336
|
+
// absent and the caller reads its own entry back.
|
|
337
|
+
if (parsed.isErr())
|
|
338
|
+
return [{ requested, present: false }];
|
|
339
|
+
const query = parsed.value;
|
|
340
|
+
const hits = [];
|
|
341
|
+
const resolution = resolveQuery(query, pool);
|
|
342
|
+
// An ambiguous spelling answers once for each track, because the
|
|
343
|
+
// caller asked about exactly that pair.
|
|
344
|
+
const identities = resolution.kind === "resolved" ? [resolution.identity] : resolution.kind === "ambiguous" ? [resolution.python, resolution.r] : [];
|
|
345
|
+
for (const identity of identities) {
|
|
346
|
+
const row = bySection.get(identityKey(identity));
|
|
347
|
+
if (row !== undefined)
|
|
348
|
+
hits.push(presentEntry(requested, row));
|
|
349
|
+
}
|
|
350
|
+
// A track-qualified entry names one ecosystem, thus it never
|
|
351
|
+
// reaches an executable or a node module.
|
|
352
|
+
if (query.track === undefined) {
|
|
353
|
+
for (const row of untracked.get(query.spelling.toLowerCase()) ?? []) {
|
|
354
|
+
hits.push(presentEntry(requested, { entry: row.entry, section: row.section }));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (hits.length > 0)
|
|
358
|
+
return hits;
|
|
359
|
+
const suggestion = resolution.kind === "unknown" ? resolution.suggestion : undefined;
|
|
360
|
+
return [{ requested, present: false, ...(suggestion === undefined ? {} : { suggestion: suggestion.name }) }];
|
|
227
361
|
});
|
|
228
362
|
return { available: true, checked };
|
|
229
363
|
}
|
|
230
|
-
|
|
364
|
+
// A browse filter, not an identity rule: the lower case here makes a
|
|
365
|
+
// substring search forgiving, and it never decides which package a name
|
|
366
|
+
// means. The `names` path above owns that decision, and `resolveQuery` is
|
|
367
|
+
// the one place that folds.
|
|
231
368
|
const needle = query?.trim().toLowerCase();
|
|
232
369
|
const filtered = sections
|
|
233
|
-
.filter((s) => matchesLanguage(s
|
|
370
|
+
.filter((s) => matchesLanguage(s, language))
|
|
234
371
|
.map((s) => ({
|
|
235
372
|
title: s.title,
|
|
236
373
|
packages: needle ? s.packages.filter((p) => p.name.toLowerCase().includes(needle)) : s.packages,
|
|
374
|
+
...(s.track === undefined ? {} : { track: s.track }),
|
|
237
375
|
}))
|
|
238
376
|
.filter((s) => s.packages.length > 0);
|
|
239
377
|
const total = filtered.reduce((n, s) => n + s.packages.length, 0);
|
|
@@ -247,7 +385,9 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
247
385
|
content: `No packages match this filter${scope ? ` (${scope})` : ""}. Nothing can be installed at runtime.`,
|
|
248
386
|
};
|
|
249
387
|
}
|
|
250
|
-
|
|
388
|
+
// The mark reads every section, not the filtered set: a `language` listing
|
|
389
|
+
// must still say that the other track holds the same name.
|
|
390
|
+
const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT, bothTrackNames(sections));
|
|
251
391
|
return { available: true, total, returned, hasMore: returned < total, content };
|
|
252
392
|
}
|
|
253
393
|
/**
|
|
@@ -275,7 +415,9 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
275
415
|
id: "list_available_packages",
|
|
276
416
|
description: scopeSentence +
|
|
277
417
|
"A full listing is small (a few hundred packages) and is returned whole by default, so a listing you get back is the complete set unless `hasMore` says otherwise. " +
|
|
278
|
-
'`names`: check specific packages (e.g. ["Seurat", "
|
|
418
|
+
'`names`: check specific packages — each entry is a query in the one grammar, `[python:|r:]<name>` (e.g. ["Seurat", "python:igraph"]) — returns present/absent plus the language track for each; this is the cheapest call and the right one for \'is X available?\'. ' +
|
|
419
|
+
"A Python name matches under PEP 503, thus `scikit_learn` reaches `scikit-learn`. An R name matches its DESCRIPTION spelling exactly, thus `seurat` answers absent and carries `Seurat` as the suggestion. " +
|
|
420
|
+
"A name that the Python track and the R track both hold answers once for each track. " +
|
|
279
421
|
"`query`: case-insensitive substring filter over package names. " +
|
|
280
422
|
"`language`: restrict to one track (r | python | cli | node). " +
|
|
281
423
|
"`limit`: cap the packages listed; the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
|
|
@@ -285,7 +427,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
285
427
|
.array(z.string())
|
|
286
428
|
.max(100)
|
|
287
429
|
.optional()
|
|
288
|
-
.describe("Check these
|
|
430
|
+
.describe("Check these packages for presence. Each entry is a query in the one grammar, `[python:|r:]<name>`. Returns one entry for each track that holds the name: present/absent + the language track it lives in. An absent name carries the spelling the pool holds, where there is one."),
|
|
289
431
|
query: z.string().optional().describe("Case-insensitive substring filter over package names."),
|
|
290
432
|
language: z.enum(["r", "python", "cli", "node"]).optional().describe("Restrict results to one language track."),
|
|
291
433
|
limit: z
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
* 5. `synthesizeFindings` (single sequential block, no sandbox)
|
|
52
52
|
* 6. `collectAndComplete` (terminal — runs on ALL paths)
|
|
53
53
|
* - determine final status from completed/cancelled/failed counts
|
|
54
|
-
* - update `cortex_runs.status` + `error
|
|
54
|
+
* - update `cortex_runs.status` + `error`, and inside that same step record
|
|
55
|
+
* the run outcome metric plus the outcome of every settled step whose
|
|
56
|
+
* child never wrote its own terminal row (a cancelled child, or one that
|
|
57
|
+
* died before `failStep`)
|
|
55
58
|
* - close running charge with matching reason
|
|
56
59
|
* - revoke run authorization via the injected `runAuthorizer` seam
|
|
57
60
|
* - emit terminal stream part
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
* 5. `synthesizeFindings` (single sequential block, no sandbox)
|
|
52
52
|
* 6. `collectAndComplete` (terminal — runs on ALL paths)
|
|
53
53
|
* - determine final status from completed/cancelled/failed counts
|
|
54
|
-
* - update `cortex_runs.status` + `error
|
|
54
|
+
* - update `cortex_runs.status` + `error`, and inside that same step record
|
|
55
|
+
* the run outcome metric plus the outcome of every settled step whose
|
|
56
|
+
* child never wrote its own terminal row (a cancelled child, or one that
|
|
57
|
+
* died before `failStep`)
|
|
55
58
|
* - close running charge with matching reason
|
|
56
59
|
* - revoke run authorization via the injected `runAuthorizer` seam
|
|
57
60
|
* - emit terminal stream part
|
|
@@ -63,8 +66,10 @@ import { mkdir } from "node:fs/promises";
|
|
|
63
66
|
import { join } from "node:path";
|
|
64
67
|
import { forStep } from "../auth/types.js";
|
|
65
68
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
69
|
+
import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
|
|
70
|
+
import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
|
|
66
71
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
67
|
-
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
72
|
+
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
68
73
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
69
74
|
import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
|
|
70
75
|
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
@@ -522,8 +527,13 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
522
527
|
const rowOutcome = synthesisOutcome;
|
|
523
528
|
try {
|
|
524
529
|
const synthEndedAtMs = await DBOS.now();
|
|
530
|
+
const rowUpdate = synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs);
|
|
525
531
|
await DBOS.runStep(async () => {
|
|
526
|
-
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID,
|
|
532
|
+
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, rowUpdate));
|
|
533
|
+
// Recorded inside the step that writes the row, so a replayed body records nothing again.
|
|
534
|
+
const outcome = stepOutcomeOf(rowUpdate.status);
|
|
535
|
+
if (outcome !== undefined)
|
|
536
|
+
recordStepCompleted({ agentId: SYNTHESIS_AGENT_ID, status: outcome, durationMs: synthEndedAtMs - synthStartedAtMs });
|
|
527
537
|
}, { name: "persist-synthesis-step" });
|
|
528
538
|
}
|
|
529
539
|
catch (err) {
|
|
@@ -770,7 +780,11 @@ async function runSchedulerLoop(args) {
|
|
|
770
780
|
// `completed`, so this is the first (and only) moment their results
|
|
771
781
|
// can be named. Checkpointed so a replay re-dispatches the child
|
|
772
782
|
// with a byte-identical prompt instead of re-reading the DB/disk.
|
|
773
|
-
const
|
|
783
|
+
const seedStepName = `compose-step-seed:${stepId}`;
|
|
784
|
+
const prompt = await DBOS.runStep(() => {
|
|
785
|
+
stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
|
|
786
|
+
return composeStepSeed({ input, stepId, runId, deps });
|
|
787
|
+
}, { name: seedStepName });
|
|
774
788
|
const baseChildInput = buildChildInput({
|
|
775
789
|
input,
|
|
776
790
|
stepId,
|
|
@@ -1005,6 +1019,28 @@ async function collectAndComplete(args) {
|
|
|
1005
1019
|
try {
|
|
1006
1020
|
await DBOS.runStep(async () => {
|
|
1007
1021
|
unwrapOrThrow(await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" && !budgetExceeded ? "external_cancel" : null)));
|
|
1022
|
+
// The outcome metrics ride inside the step that persists the terminal status: DBOS
|
|
1023
|
+
// caches the step, so a recovered body records nothing again. A step's own terminal
|
|
1024
|
+
// row already carried its metric; the residue counted here is every settled step
|
|
1025
|
+
// without one — a child the cancel cut before `mark-canceled` (the cancel check runs
|
|
1026
|
+
// before a step body), or one that died before `failStep`. The ledger read is kept
|
|
1027
|
+
// apart from the status write: a failed read costs the metrics, never the write.
|
|
1028
|
+
let stepRows = [];
|
|
1029
|
+
try {
|
|
1030
|
+
stepRows = unwrapOrThrow(await queryStepsByRun(deps.pool, runId));
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
logger.warn("run metrics: step ledger read failed, residual step outcomes skipped", logger.errorFields(err));
|
|
1034
|
+
}
|
|
1035
|
+
for (const row of stepRows) {
|
|
1036
|
+
if (row.completedAt !== null)
|
|
1037
|
+
continue;
|
|
1038
|
+
if (canceled.has(row.stepId))
|
|
1039
|
+
recordStepCompleted({ agentId: row.agentId, status: "canceled" });
|
|
1040
|
+
else if (failed.has(row.stepId))
|
|
1041
|
+
recordStepCompleted({ agentId: row.agentId, status: "failed" });
|
|
1042
|
+
}
|
|
1043
|
+
recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
|
|
1008
1044
|
}, { name: "persist-final-status" });
|
|
1009
1045
|
}
|
|
1010
1046
|
catch (err) {
|
|
@@ -37,6 +37,7 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
|
37
37
|
import { z } from "zod";
|
|
38
38
|
import { forSubAgent } from "../auth/types.js";
|
|
39
39
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
40
|
+
import { ATTR_INFLEXA_FANOUT_KEY, stableSpan } from "../lib/otel-spans.js";
|
|
40
41
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
41
42
|
import { durableStep } from "../loop/run-step.js";
|
|
42
43
|
import { createPubMedTool } from "../tools/bio/pubmed.js";
|
|
@@ -52,6 +53,7 @@ import { readBudgetExceededMarker } from "./target-assessment/lib/llm-step.js";
|
|
|
52
53
|
import { assembleSafetyCorroboration } from "./target-assessment/lib/safety-corroboration.js";
|
|
53
54
|
import { investigateClaims, isClaimInvestigationBudgetExceeded } from "./target-assessment/investigation/index.js";
|
|
54
55
|
import { recordTerminalReason } from "./target-assessment/metrics.js";
|
|
56
|
+
import { elapsedSinceIso, recordRunCompleted } from "../lib/metrics.js";
|
|
55
57
|
import { phase4Assemble } from "./target-assessment/phase4-assemble.js";
|
|
56
58
|
import { DossierDerivedInvariantError, DossierSchemaViolationError, phase5Persist } from "./target-assessment/phase5-persist.js";
|
|
57
59
|
import { emitProgress } from "./target-assessment/progress.js";
|
|
@@ -278,9 +280,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
278
280
|
}))
|
|
279
281
|
: [];
|
|
280
282
|
async function runFanoutBlock(items, stepKindForName, keyOf, fn) {
|
|
281
|
-
return Promise.all(items.map((item) =>
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
return Promise.all(items.map((item) => {
|
|
284
|
+
const key = keyOf(item);
|
|
285
|
+
const stepName = `ta-fanout:${stepKindForName}:${key}`;
|
|
286
|
+
return DBOS.runStep(() => {
|
|
287
|
+
stableSpan(stepName, `ta-fanout:${stepKindForName}`, { [ATTR_INFLEXA_FANOUT_KEY]: key });
|
|
288
|
+
return fn(item);
|
|
289
|
+
}, { name: stepName }).catch((err) => wrapCollectorThrow(err));
|
|
290
|
+
}));
|
|
284
291
|
}
|
|
285
292
|
const [perModulatorFaersResults, perTrialAesResults, perModulatorPolypharmResults, perClassDrugAesResults] = await Promise.all([
|
|
286
293
|
runFanoutBlock(triageShortlist, "modulator-faers", (item) => item.moleculeChemblId, faersForOneModulator),
|
|
@@ -438,9 +445,19 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
438
445
|
}
|
|
439
446
|
// (§6.1, §6.8) Terminal block. Read the row first; if soft-deleted,
|
|
440
447
|
// no-op (the user's delete during the run wins).
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
448
|
+
//
|
|
449
|
+
// Each terminal path records its `cortex.run.completed` outcome INSIDE the
|
|
450
|
+
// DBOS step that persists it, so a recovery replay reads the cached step
|
|
451
|
+
// and records nothing again. The duration runs from the row's `created_at`.
|
|
452
|
+
// The deleted path writes no row, so its record rides the read step itself.
|
|
453
|
+
const row = await DBOS.runStep(async () => {
|
|
454
|
+
const read = unwrapOrThrow(await getAssessment(deps.pool, input.assessmentId, input.organizationId));
|
|
455
|
+
if (read?.status === "deleted") {
|
|
456
|
+
recordRunCompleted({ workflow: "target_assessment", status: "canceled", durationMs: elapsedSinceIso(read.createdAt) });
|
|
457
|
+
}
|
|
458
|
+
return read;
|
|
459
|
+
}, { name: "ta-terminal-read-row" });
|
|
460
|
+
const recordTerminal = (status) => recordRunCompleted({ workflow: "target_assessment", status, durationMs: elapsedSinceIso(row?.createdAt) });
|
|
444
461
|
if (row?.status === "deleted") {
|
|
445
462
|
recordTerminalReason("deleted");
|
|
446
463
|
return {
|
|
@@ -452,9 +469,10 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
452
469
|
// (§6.2) Normal completion — happy path wrote `dossierForPersist`.
|
|
453
470
|
if (!phase0Error && !schemaViolation && !derivedViolation && !unexpectedError && !budgetExceeded && dossierForPersist) {
|
|
454
471
|
try {
|
|
455
|
-
await DBOS.runStep(async () =>
|
|
456
|
-
|
|
457
|
-
|
|
472
|
+
await DBOS.runStep(async () => {
|
|
473
|
+
unwrapOrThrow(await setDossier(deps.pool, input.assessmentId, dossierForPersist));
|
|
474
|
+
recordTerminal("completed");
|
|
475
|
+
}, { name: "ta-terminal-completed" });
|
|
458
476
|
await emitProgress(deps.pool, logger, input.assessmentId, "completed");
|
|
459
477
|
}
|
|
460
478
|
catch (err) {
|
|
@@ -470,10 +488,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
470
488
|
}
|
|
471
489
|
// (§6.3) Phase 0 throw — target unresolved.
|
|
472
490
|
if (phase0Error) {
|
|
473
|
-
await DBOS.runStep(async () =>
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
491
|
+
await DBOS.runStep(async () => {
|
|
492
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
493
|
+
kind: "target-unresolved",
|
|
494
|
+
message: phase0Error instanceof Error ? phase0Error.message : String(phase0Error),
|
|
495
|
+
}));
|
|
496
|
+
recordTerminal("failed");
|
|
497
|
+
}, { name: "ta-terminal-failed-resolve" });
|
|
477
498
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
478
499
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
479
500
|
recordTerminalReason("target-unresolved");
|
|
@@ -485,11 +506,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
485
506
|
}
|
|
486
507
|
// (§6.4) Schema violation.
|
|
487
508
|
if (schemaViolation) {
|
|
488
|
-
await DBOS.runStep(async () =>
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
509
|
+
await DBOS.runStep(async () => {
|
|
510
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
511
|
+
kind: "schema-invariant-violation",
|
|
512
|
+
message: schemaViolation.message,
|
|
513
|
+
details: schemaViolation.issues,
|
|
514
|
+
}));
|
|
515
|
+
recordTerminal("failed");
|
|
516
|
+
}, { name: "ta-terminal-failed-schema" });
|
|
493
517
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
494
518
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
495
519
|
recordTerminalReason("schema-violation");
|
|
@@ -501,10 +525,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
501
525
|
}
|
|
502
526
|
// (§6.4 — derived invariant variant — same terminal class, different kind.)
|
|
503
527
|
if (derivedViolation) {
|
|
504
|
-
await DBOS.runStep(async () =>
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
528
|
+
await DBOS.runStep(async () => {
|
|
529
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
530
|
+
kind: "derived-invariant-violation",
|
|
531
|
+
message: derivedViolation.message,
|
|
532
|
+
}));
|
|
533
|
+
recordTerminal("failed");
|
|
534
|
+
}, { name: "ta-terminal-failed-derived" });
|
|
508
535
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
509
536
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
510
537
|
recordTerminalReason("derived-invariant-violation");
|
|
@@ -523,7 +550,11 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
523
550
|
await DBOS.runStep(() => readBudgetExceededMarker(), {
|
|
524
551
|
name: "ta-terminal-drain-marker",
|
|
525
552
|
}).catch(() => null);
|
|
526
|
-
await DBOS.runStep(async () =>
|
|
553
|
+
await DBOS.runStep(async () => {
|
|
554
|
+
unwrapOrThrow(await markAssessmentSuspended(deps.pool, input.assessmentId));
|
|
555
|
+
// A budget pause records as `canceled`, the same status the analysis run ledger gives it.
|
|
556
|
+
recordTerminal("canceled");
|
|
557
|
+
}, { name: "ta-terminal-suspended" });
|
|
527
558
|
await emitProgress(deps.pool, logger, input.assessmentId, "suspended");
|
|
528
559
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-canceled");
|
|
529
560
|
const workflowId = DBOS.workflowID;
|
|
@@ -546,10 +577,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
546
577
|
}
|
|
547
578
|
// (§6.5) Unexpected throw — bug somewhere, coverage envelope failed.
|
|
548
579
|
if (unexpectedError) {
|
|
549
|
-
await DBOS.runStep(async () =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
580
|
+
await DBOS.runStep(async () => {
|
|
581
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
582
|
+
kind: "unexpected-throw",
|
|
583
|
+
message: unexpectedError instanceof Error ? unexpectedError.message : String(unexpectedError),
|
|
584
|
+
}));
|
|
585
|
+
recordTerminal("failed");
|
|
586
|
+
}, { name: "ta-terminal-failed-unexpected" });
|
|
553
587
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
554
588
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
555
589
|
recordTerminalReason("unexpected-throw");
|
|
@@ -562,10 +596,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
562
596
|
// Defensive: this branch is unreachable in practice (one of the
|
|
563
597
|
// categories above must have fired). Fall through to a generic failed
|
|
564
598
|
// status so a buggy refactor surfaces visibly.
|
|
565
|
-
await DBOS.runStep(async () =>
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
599
|
+
await DBOS.runStep(async () => {
|
|
600
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
601
|
+
kind: "unexpected-throw",
|
|
602
|
+
message: "terminal block reached without classified outcome",
|
|
603
|
+
}));
|
|
604
|
+
recordTerminal("failed");
|
|
605
|
+
}, { name: "ta-terminal-failed-uncategorized" });
|
|
569
606
|
recordTerminalReason("unexpected-throw");
|
|
570
607
|
return {
|
|
571
608
|
assessmentId: input.assessmentId,
|