@inflexa-ai/harness 0.35.3 → 0.35.5

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.
Files changed (35) hide show
  1. package/dist/citations/types.d.ts +2 -2
  2. package/dist/config/environment-stores.d.ts +10 -0
  3. package/dist/contracts/schemas/chat-parts.d.ts +11 -11
  4. package/dist/execution/run-canceler.js +20 -4
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +5 -0
  7. package/dist/lib/metrics.d.ts +63 -5
  8. package/dist/lib/metrics.js +104 -5
  9. package/dist/lib/otel.d.ts +6 -2
  10. package/dist/lib/otel.js +6 -2
  11. package/dist/prompts/planner.js +6 -0
  12. package/dist/prompts/sandbox-standards.d.ts +1 -1
  13. package/dist/prompts/sandbox-standards.js +8 -3
  14. package/dist/sandbox/package-identity.d.ts +208 -0
  15. package/dist/sandbox/package-identity.js +244 -0
  16. package/dist/sandbox/types.d.ts +20 -25
  17. package/dist/schemas/plan-schemas.d.ts +3 -3
  18. package/dist/schemas/validate-plan.js +32 -15
  19. package/dist/schemas/workflow-state.js +3 -1
  20. package/dist/state/data-profile.d.ts +20 -6
  21. package/dist/state/data-profile.js +16 -8
  22. package/dist/state/index.d.ts +1 -1
  23. package/dist/state/schema.d.ts +2 -2
  24. package/dist/tasks/data-profile.js +26 -12
  25. package/dist/tools/execute-analysis.js +37 -20
  26. package/dist/tools/research/inspect-run.d.ts +1 -1
  27. package/dist/tools/sandbox/link-packages.d.ts +6 -6
  28. package/dist/tools/sandbox/link-packages.js +45 -14
  29. package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
  30. package/dist/tools/sandbox/list-available-packages.js +185 -43
  31. package/dist/workflows/execute-analysis.d.ts +4 -1
  32. package/dist/workflows/execute-analysis.js +34 -3
  33. package/dist/workflows/execute-target-assessment.js +59 -28
  34. package/dist/workflows/sandbox-step.js +7 -1
  35. 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 sources by it: a
50
- * second spelling of either would split one track into two headings.
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()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
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
- const key = pkg.name.toLowerCase();
124
- if (open.seen.has(key))
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(key);
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
- /** The `language` filter values, mapped onto the concrete section headings. */
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
- /** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
169
- function renderPackage(entry) {
170
- return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
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
- const index = new Map();
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
- // First writer wins: the sections arrive in a fixed order, so a
206
- // name colliding across tracks resolves to the earliest one.
207
- if (!index.has(pkg.name.toLowerCase()))
208
- index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
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 checked = names.map((requested) => {
212
- const hit = index.get(requested.trim().toLowerCase());
213
- if (!hit)
214
- return { requested, present: false };
215
- // `name` echoes the catalog's canonical spelling — R package names are
216
- // case-sensitive at `library()`, so the exact one is what the caller needs.
217
- // The store identity rides only where the source records it.
218
- return {
219
- requested,
220
- present: true,
221
- name: hit.entry.name,
222
- section: hit.section,
223
- ...(hit.entry.version === undefined ? {} : { version: hit.entry.version }),
224
- ...(hit.entry.storeDir === undefined ? {} : { storeDir: hit.entry.storeDir }),
225
- ...(hit.entry.hash === undefined ? {} : { hash: hit.entry.hash }),
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
- const matchesLanguage = language ? LANGUAGE_MATCHERS[language] : () => true;
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.title))
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
- const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT);
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", "scanpy"]) — returns present/absent plus the language track for each, case-insensitively; this is the cheapest call and the right one for \'is X available?\'. ' +
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 exact package names for presence (case-insensitive). Returns one entry per name: present/absent + the language track it lives in."),
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
@@ -64,8 +67,9 @@ import { join } from "node:path";
64
67
  import { forStep } from "../auth/types.js";
65
68
  import { createNoopLogger } from "../lib/console-logger.js";
66
69
  import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
70
+ import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
67
71
  import { unwrapOrThrow } from "../lib/result.js";
68
- 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";
69
73
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
70
74
  import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
71
75
  import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
@@ -523,8 +527,13 @@ export async function runExecuteAnalysisBody(input, deps) {
523
527
  const rowOutcome = synthesisOutcome;
524
528
  try {
525
529
  const synthEndedAtMs = await DBOS.now();
530
+ const rowUpdate = synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs);
526
531
  await DBOS.runStep(async () => {
527
- unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs)));
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 });
528
537
  }, { name: "persist-synthesis-step" });
529
538
  }
530
539
  catch (err) {
@@ -1010,6 +1019,28 @@ async function collectAndComplete(args) {
1010
1019
  try {
1011
1020
  await DBOS.runStep(async () => {
1012
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 });
1013
1044
  }, { name: "persist-final-status" });
1014
1045
  }
1015
1046
  catch (err) {
@@ -53,6 +53,7 @@ import { readBudgetExceededMarker } from "./target-assessment/lib/llm-step.js";
53
53
  import { assembleSafetyCorroboration } from "./target-assessment/lib/safety-corroboration.js";
54
54
  import { investigateClaims, isClaimInvestigationBudgetExceeded } from "./target-assessment/investigation/index.js";
55
55
  import { recordTerminalReason } from "./target-assessment/metrics.js";
56
+ import { elapsedSinceIso, recordRunCompleted } from "../lib/metrics.js";
56
57
  import { phase4Assemble } from "./target-assessment/phase4-assemble.js";
57
58
  import { DossierDerivedInvariantError, DossierSchemaViolationError, phase5Persist } from "./target-assessment/phase5-persist.js";
58
59
  import { emitProgress } from "./target-assessment/progress.js";
@@ -444,9 +445,19 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
444
445
  }
445
446
  // (§6.1, §6.8) Terminal block. Read the row first; if soft-deleted,
446
447
  // no-op (the user's delete during the run wins).
447
- const row = await DBOS.runStep(async () => unwrapOrThrow(await getAssessment(deps.pool, input.assessmentId, input.organizationId)), {
448
- name: "ta-terminal-read-row",
449
- });
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) });
450
461
  if (row?.status === "deleted") {
451
462
  recordTerminalReason("deleted");
452
463
  return {
@@ -458,9 +469,10 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
458
469
  // (§6.2) Normal completion — happy path wrote `dossierForPersist`.
459
470
  if (!phase0Error && !schemaViolation && !derivedViolation && !unexpectedError && !budgetExceeded && dossierForPersist) {
460
471
  try {
461
- await DBOS.runStep(async () => unwrapOrThrow(await setDossier(deps.pool, input.assessmentId, dossierForPersist)), {
462
- name: "ta-terminal-completed",
463
- });
472
+ await DBOS.runStep(async () => {
473
+ unwrapOrThrow(await setDossier(deps.pool, input.assessmentId, dossierForPersist));
474
+ recordTerminal("completed");
475
+ }, { name: "ta-terminal-completed" });
464
476
  await emitProgress(deps.pool, logger, input.assessmentId, "completed");
465
477
  }
466
478
  catch (err) {
@@ -476,10 +488,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
476
488
  }
477
489
  // (§6.3) Phase 0 throw — target unresolved.
478
490
  if (phase0Error) {
479
- await DBOS.runStep(async () => unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
480
- kind: "target-unresolved",
481
- message: phase0Error instanceof Error ? phase0Error.message : String(phase0Error),
482
- })), { name: "ta-terminal-failed-resolve" });
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" });
483
498
  await emitProgress(deps.pool, logger, input.assessmentId, "failed");
484
499
  await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
485
500
  recordTerminalReason("target-unresolved");
@@ -491,11 +506,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
491
506
  }
492
507
  // (§6.4) Schema violation.
493
508
  if (schemaViolation) {
494
- await DBOS.runStep(async () => unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
495
- kind: "schema-invariant-violation",
496
- message: schemaViolation.message,
497
- details: schemaViolation.issues,
498
- })), { name: "ta-terminal-failed-schema" });
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" });
499
517
  await emitProgress(deps.pool, logger, input.assessmentId, "failed");
500
518
  await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
501
519
  recordTerminalReason("schema-violation");
@@ -507,10 +525,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
507
525
  }
508
526
  // (§6.4 — derived invariant variant — same terminal class, different kind.)
509
527
  if (derivedViolation) {
510
- await DBOS.runStep(async () => unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
511
- kind: "derived-invariant-violation",
512
- message: derivedViolation.message,
513
- })), { name: "ta-terminal-failed-derived" });
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" });
514
535
  await emitProgress(deps.pool, logger, input.assessmentId, "failed");
515
536
  await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
516
537
  recordTerminalReason("derived-invariant-violation");
@@ -529,7 +550,11 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
529
550
  await DBOS.runStep(() => readBudgetExceededMarker(), {
530
551
  name: "ta-terminal-drain-marker",
531
552
  }).catch(() => null);
532
- await DBOS.runStep(async () => unwrapOrThrow(await markAssessmentSuspended(deps.pool, input.assessmentId)), { name: "ta-terminal-suspended" });
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" });
533
558
  await emitProgress(deps.pool, logger, input.assessmentId, "suspended");
534
559
  await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-canceled");
535
560
  const workflowId = DBOS.workflowID;
@@ -552,10 +577,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
552
577
  }
553
578
  // (§6.5) Unexpected throw — bug somewhere, coverage envelope failed.
554
579
  if (unexpectedError) {
555
- await DBOS.runStep(async () => unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
556
- kind: "unexpected-throw",
557
- message: unexpectedError instanceof Error ? unexpectedError.message : String(unexpectedError),
558
- })), { name: "ta-terminal-failed-unexpected" });
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" });
559
587
  await emitProgress(deps.pool, logger, input.assessmentId, "failed");
560
588
  await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
561
589
  recordTerminalReason("unexpected-throw");
@@ -568,10 +596,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
568
596
  // Defensive: this branch is unreachable in practice (one of the
569
597
  // categories above must have fired). Fall through to a generic failed
570
598
  // status so a buggy refactor surfaces visibly.
571
- await DBOS.runStep(async () => unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
572
- kind: "unexpected-throw",
573
- message: "terminal block reached without classified outcome",
574
- })), { name: "ta-terminal-failed-uncategorized" });
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" });
575
606
  recordTerminalReason("unexpected-throw");
576
607
  return {
577
608
  assessmentId: input.assessmentId,
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
30
30
  import { insertStepExecution, updateStepExecution } from "../state/index.js";
31
+ import { recordStepCompleted } from "../lib/metrics.js";
31
32
  import { createNoopLogger } from "../lib/console-logger.js";
32
33
  import { unwrapOrThrow } from "../lib/result.js";
33
34
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
@@ -286,6 +287,7 @@ export async function runSandboxStepBody(input, deps) {
286
287
  attempts: 1,
287
288
  lastErrorClass: errorClass,
288
289
  }));
290
+ recordStepCompleted({ agentId: input.agentId, status: "failed", durationMs });
289
291
  }, {
290
292
  name: errorClass === "lineage_attestation" ? "mark-failed-attestation" : "mark-failed",
291
293
  });
@@ -468,6 +470,7 @@ export async function runSandboxStepBody(input, deps) {
468
470
  finishReason,
469
471
  hitMaxSteps,
470
472
  }));
473
+ recordStepCompleted({ agentId: input.agentId, status: "failed", durationMs });
471
474
  }, { name: "mark-blocked" });
472
475
  await safeEmit({
473
476
  type: "data-step-blocked",
@@ -577,7 +580,9 @@ export async function runSandboxStepBody(input, deps) {
577
580
  await safeRun(logger, () => vectorIndexStepOutputs(deps, postCtx, postArtifacts), "post-step.vector-index");
578
581
  // (9) teardown — destroy sandbox + clear sandbox_ref.
579
582
  await DBOS.runStep(() => deps.sandboxClient.teardown(sandbox), { name: "sandbox.teardown" });
580
- // (10) mark-terminal + emit.
583
+ // (10) mark-terminal + emit. The outcome metric is recorded inside the same
584
+ // step as the ledger row: a replayed body reads the cached step and records
585
+ // nothing again.
581
586
  const durationMs = (await DBOS.now()) - startedAt;
582
587
  await DBOS.runStep(async () => {
583
588
  unwrapOrThrow(await updateStepExecution(deps.pool, input.runId, input.stepId, {
@@ -588,6 +593,7 @@ export async function runSandboxStepBody(input, deps) {
588
593
  finishReason,
589
594
  hitMaxSteps,
590
595
  }));
596
+ recordStepCompleted({ agentId: input.agentId, status: "completed", durationMs });
591
597
  }, { name: "mark-complete" });
592
598
  // Terminal step-detail parts. file-tree + summary + output each get a
593
599
  // stable id per step so the run-stream fold + client both treat them as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",