@inflexa-ai/harness 0.45.0 → 0.45.1

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 (122) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +5 -2
  10. package/dist/app/approve-plan.js +1 -1
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/config/environment-stores.d.ts +4 -2
  13. package/dist/contracts/chat-events.d.ts +12 -2
  14. package/dist/contracts/chat-events.js +1 -1
  15. package/dist/contracts/chat-frame.js +6 -2
  16. package/dist/contracts/index.d.ts +1 -1
  17. package/dist/contracts/schemas/chat-events.d.ts +25 -0
  18. package/dist/contracts/schemas/chat-events.js +7 -0
  19. package/dist/contracts/schemas/chat-parts.d.ts +39 -0
  20. package/dist/contracts/schemas/chat-parts.js +17 -0
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  24. package/dist/execution/dbos-workflow-purger.js +21 -14
  25. package/dist/execution/run-canceler.d.ts +33 -11
  26. package/dist/execution/run-canceler.js +129 -27
  27. package/dist/execution/run-end-record.d.ts +30 -0
  28. package/dist/execution/run-end-record.js +27 -0
  29. package/dist/index.d.ts +9 -5
  30. package/dist/index.js +11 -1
  31. package/dist/loop/call-guard.d.ts +50 -0
  32. package/dist/loop/call-guard.js +90 -0
  33. package/dist/loop/compaction.d.ts +5 -0
  34. package/dist/loop/compaction.js +17 -0
  35. package/dist/loop/run-agent.d.ts +9 -0
  36. package/dist/loop/run-agent.js +15 -1
  37. package/dist/loop/run-to-terminal.js +4 -1
  38. package/dist/loop/types.d.ts +6 -0
  39. package/dist/memory/conversation-display-recorder.d.ts +0 -2
  40. package/dist/memory/conversation-display-recorder.js +28 -3
  41. package/dist/memory/thread-history.d.ts +34 -7
  42. package/dist/memory/thread-history.js +151 -42
  43. package/dist/prompts/briefing.d.ts +57 -3
  44. package/dist/prompts/briefing.js +129 -2
  45. package/dist/prompts/planner.js +13 -2
  46. package/dist/provenance/seam.d.ts +23 -15
  47. package/dist/provenance/seam.js +7 -5
  48. package/dist/providers/ai-sdk.js +87 -7
  49. package/dist/providers/types.d.ts +6 -0
  50. package/dist/reference-data/catalog.js +141 -2
  51. package/dist/report-model/authoring-grammar.d.ts +26 -26
  52. package/dist/sandbox/image-packages.d.ts +26 -4
  53. package/dist/sandbox/image-packages.js +18 -4
  54. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  55. package/dist/schemas/plan-schemas.d.ts +57 -3
  56. package/dist/schemas/plan-schemas.js +1 -1
  57. package/dist/schemas/workflow-state.d.ts +113 -0
  58. package/dist/schemas/workflow-state.js +54 -0
  59. package/dist/state/artifacts.d.ts +17 -1
  60. package/dist/state/artifacts.js +21 -3
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +1 -1
  63. package/dist/state/runs.d.ts +43 -9
  64. package/dist/state/runs.js +85 -14
  65. package/dist/state/step-executions.d.ts +9 -0
  66. package/dist/state/step-executions.js +22 -0
  67. package/dist/tools/ad-hoc-router.d.ts +2 -0
  68. package/dist/tools/ad-hoc-router.js +18 -2
  69. package/dist/tools/capability-data.d.ts +36 -0
  70. package/dist/tools/capability-data.js +73 -0
  71. package/dist/tools/execute-analysis.d.ts +37 -9
  72. package/dist/tools/execute-analysis.js +131 -21
  73. package/dist/tools/knowledge/check.d.ts +60 -0
  74. package/dist/tools/knowledge/check.js +92 -0
  75. package/dist/tools/knowledge/client.d.ts +382 -0
  76. package/dist/tools/knowledge/client.js +302 -0
  77. package/dist/tools/knowledge/environment.d.ts +78 -0
  78. package/dist/tools/knowledge/environment.js +105 -0
  79. package/dist/tools/knowledge/index.d.ts +49 -0
  80. package/dist/tools/knowledge/index.js +70 -0
  81. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  82. package/dist/tools/knowledge/local-slots.js +177 -0
  83. package/dist/tools/knowledge/pin.d.ts +14 -0
  84. package/dist/tools/knowledge/pin.js +17 -0
  85. package/dist/tools/knowledge/recommend.d.ts +92 -0
  86. package/dist/tools/knowledge/recommend.js +98 -0
  87. package/dist/tools/knowledge/situation.d.ts +143 -0
  88. package/dist/tools/knowledge/situation.js +164 -0
  89. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  90. package/dist/tools/knowledge/skeleton.js +202 -0
  91. package/dist/tools/knowledge/template.d.ts +137 -0
  92. package/dist/tools/knowledge/template.js +267 -0
  93. package/dist/tools/research/generate-plan.d.ts +23 -0
  94. package/dist/tools/research/generate-plan.js +203 -12
  95. package/dist/tools/research/guarded-address.d.ts +20 -0
  96. package/dist/tools/research/guarded-address.js +148 -0
  97. package/dist/tools/research/searxng-engine.d.ts +40 -0
  98. package/dist/tools/research/searxng-engine.js +120 -0
  99. package/dist/tools/research/web-fetch.d.ts +124 -0
  100. package/dist/tools/research/web-fetch.js +627 -0
  101. package/dist/tools/research/web-search.d.ts +75 -0
  102. package/dist/tools/research/web-search.js +112 -0
  103. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  104. package/dist/tools/sandbox/list-available-packages.js +12 -4
  105. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  106. package/dist/tools/sandbox/list-available-refs.js +60 -3
  107. package/dist/tools/sandbox/report-blocker.js +5 -1
  108. package/dist/tools/terminal-text.d.ts +11 -0
  109. package/dist/tools/terminal-text.js +19 -0
  110. package/dist/tools/workspace/decision-record.d.ts +81 -0
  111. package/dist/tools/workspace/decision-record.js +117 -0
  112. package/dist/tools/workspace/edit-file.d.ts +3 -1
  113. package/dist/tools/workspace/edit-file.js +20 -2
  114. package/dist/tools/workspace/execute-command.d.ts +17 -0
  115. package/dist/tools/workspace/execute-command.js +28 -1
  116. package/dist/tools/workspace/index.d.ts +1 -0
  117. package/dist/tools/workspace/index.js +1 -0
  118. package/dist/tools/workspace/mutator.d.ts +1 -1
  119. package/dist/workflows/execute-analysis.d.ts +58 -18
  120. package/dist/workflows/execute-analysis.js +273 -58
  121. package/dist/workflows/sandbox-step.d.ts +12 -0
  122. package/package.json +5 -1
@@ -59,12 +59,16 @@
59
59
  * 5. `synthesizeFindings` (single sequential block, no sandbox)
60
60
  * 6. `collectAndComplete` (terminal — runs on ALL paths)
61
61
  * - determine final status from completed/cancelled/failed counts
62
- * - update `cortex_runs.status` + `error`, and inside that same step record
63
- * the run outcome metric plus the outcome of every settled step whose
64
- * child never wrote its own terminal row (a cancelled child, or one that
65
- * died before `failStep`)
66
- * - close running charge with matching reason
67
- * - revoke run authorization via the injected `runAuthorizer` seam
62
+ * - update `cortex_runs.status` + `error`. When the write changes the row,
63
+ * the same step emits `run_completed` before the commit, records the run
64
+ * outcome metric plus the outcome of every settled step whose child never
65
+ * wrote its own terminal row (a cancelled child, or one that died before
66
+ * `failStep`), closes the running charge with the matching outcome, and
67
+ * revokes the run authorization through the injected `runAuthorizer`
68
+ * - the write retries with backoff on a failed transaction. When the write is
69
+ * refused, or fails on each attempt, close and revoke in their own steps, then
70
+ * emit `run_completed`. After a failed write the row stays `running`, so the
71
+ * body records its run end as a workflow event for the run canceler
68
72
  * - emit terminal stream part
69
73
  * - WRITES NOTHING to the conversation thread (results are pull-only
70
74
  * via `inspectRun`)
@@ -74,17 +78,18 @@ import { err, ok } from "neverthrow";
74
78
  import { mkdir } from "node:fs/promises";
75
79
  import { join } from "node:path";
76
80
  import { forStep } from "../auth/types.js";
81
+ import { RUN_END_EVENT_KEY } from "../execution/run-end-record.js";
77
82
  import { createNoopLogger } from "../lib/console-logger.js";
78
83
  import { deliverNotice, passGate } from "../lib/hooks.js";
79
84
  import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
80
85
  import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
81
- import { describeDbError } from "../lib/db-result.js";
86
+ import { describeDbError, withTransaction } from "../lib/db-result.js";
82
87
  import { unwrapOrThrow } from "../lib/result.js";
83
88
  import { countArtifactsByRuns, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
84
89
  import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
85
90
  import { flattenDataPart } from "../contracts/chat-frame.js";
86
91
  import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
87
- import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory } from "../prompts/briefing.js";
92
+ import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory, } from "../prompts/briefing.js";
88
93
  import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
89
94
  import { isChatDataPart, synthesisEmission } from "../sandbox/sandbox-step-translate.js";
90
95
  import { synthesizeRun } from "../app/synthesize-run.js";
@@ -183,7 +188,7 @@ function observeRunGuarded(deps, observation) {
183
188
  * projection/timeout-propagation tests.
184
189
  */
185
190
  export function buildChildInput(args) {
186
- const { input, stepId, level, runId, workflowId, prompt } = args;
191
+ const { input, stepId, level, runId, workflowId, prompt, templateBinding } = args;
187
192
  const resources = input.resourcesByStepId[stepId];
188
193
  if (!resources) {
189
194
  throw new Error(`executeAnalysis: step "${stepId}" missing from resourcesByStepId — every step must declare resources`);
@@ -205,6 +210,9 @@ export function buildChildInput(args) {
205
210
  // genuinely declared nothing, and it silently deletes every same-run edge
206
211
  // the step was entitled to.
207
212
  dependsOn: planStep.depends_on,
213
+ // Composed beside the seed, thus the same checkpoint carries both and a
214
+ // replay hands the child the binding its briefing describes.
215
+ ...(templateBinding ? { templateBinding } : {}),
208
216
  level,
209
217
  prompt,
210
218
  parentWorkflowId: workflowId,
@@ -222,13 +230,18 @@ export function buildChildInput(args) {
222
230
  * rendered any earlier (at plan submission, say) cannot mention them, and the
223
231
  * agent is left to rediscover its own upstream.
224
232
  *
225
- * REPLAY: the caller wraps this in a `DBOS.runStep`, so the composed string is
226
- * checkpointed and a replay returns it verbatim — the DB and disk are not read
227
- * again and cannot drift the seed under a recovered run. Never call it
228
- * unwrapped from the workflow body.
233
+ * The template contract of a grounded step is read with the package, and the
234
+ * plan settings are bound to it: the seed describes the binding, and the child
235
+ * input carries it, from one checkpoint. The child fetches nothing.
236
+ *
237
+ * REPLAY: the caller wraps this in a `DBOS.runStep`, so the composed seed is
238
+ * checkpointed and a replay returns it verbatim — the DB, the disk, and the
239
+ * knowledge service are not read again and cannot drift the seed under a
240
+ * recovered run. Never call it unwrapped from the workflow body.
229
241
  */
230
242
  export async function composeStepSeed(args) {
231
- return composeStepBriefing(await gatherStepPackage(args));
243
+ const { briefing, templateBinding } = await gatherStepPackage(args);
244
+ return { prompt: composeStepBriefing(briefing), ...(templateBinding ? { templateBinding } : {}) };
232
245
  }
233
246
  /**
234
247
  * Do every read of one step's package and give the data its seed is rendered
@@ -242,6 +255,9 @@ export async function composeStepSeed(args) {
242
255
  * so an edit during a run reaches each later step, and two steps of one run
243
256
  * can carry different copies.
244
257
  *
258
+ * The contract of the template a grounded step renders is read here too, from
259
+ * the knowledge client, and the plan settings are bound to it.
260
+ *
245
261
  * A read failure throws (house rules): a swallowed error would be frozen into
246
262
  * the checkpointed seed and served to every replay of this step.
247
263
  */
@@ -282,12 +298,151 @@ export async function gatherStepPackage(args) {
282
298
  agentByStepId: input.agentByStepId,
283
299
  pool: deps.pool,
284
300
  });
301
+ const contract = await resolveTemplateContract(step, deps.knowledge);
302
+ return {
303
+ briefing: {
304
+ step,
305
+ workspace,
306
+ profile: profile?.result ?? null,
307
+ upstream,
308
+ memory: { goal: memory.goal, constraints: memory.constraints },
309
+ ...contract.briefing,
310
+ },
311
+ ...(contract.binding ? { templateBinding: contract.binding } : {}),
312
+ };
313
+ }
314
+ /** A served contract carries no `match`; the two refusals do. */
315
+ function isTemplateContract(answer) {
316
+ return !("match" in answer);
317
+ }
318
+ /**
319
+ * Fetch the contract of the template a step grounds on, and bind the plan
320
+ * settings to it. A step without a template has no template part. A step
321
+ * with one and no way to read it (no client, no answer, or an unknown
322
+ * template) gets the reason, thus the seed reports the absence rather than
323
+ * hiding it, and no binding.
324
+ */
325
+ async function resolveTemplateContract(step, client) {
326
+ const ref = step.grounding?.template;
327
+ if (!ref)
328
+ return { briefing: {} };
329
+ if (!client)
330
+ return { briefing: { templateNotRetrieved: "no knowledge client is bound" } };
331
+ const answer = await client.contract(ref);
332
+ if (!isTemplateContract(answer)) {
333
+ const reason = answer.match === "unavailable"
334
+ ? `the knowledge service did not answer: ${answer.reason}`
335
+ : `the knowledge service refused the lookup: ${answer.message}`;
336
+ return { briefing: { templateNotRetrieved: reason } };
337
+ }
338
+ return bindTemplateContract(ref, answer, step.grounding?.settings ?? [], {
339
+ stepId: step.id,
340
+ claims: step.grounding?.claims ?? [],
341
+ ...(step.grounding?.snapshot && step.grounding.snapshot !== "none" ? { snapshot: step.grounding.snapshot } : {}),
342
+ });
343
+ }
344
+ /** The version of a template reference (`tpl-x@1.0.0` gives `1.0.0`), or `undefined` when the reference has none. */
345
+ function versionOfRef(ref) {
346
+ const at = ref.indexOf("@");
347
+ return at < 0 ? undefined : ref.slice(at + 1);
348
+ }
349
+ /** Two setting values agree when their JSON agrees: the values are scalars or lists of strings. */
350
+ function sameSettingValue(a, b) {
351
+ return JSON.stringify(a) === JSON.stringify(b);
352
+ }
353
+ /**
354
+ * Whether a plan value is a value of the slot, and not a policy word. A
355
+ * procedure parameter and a template slot share a name, but not always a
356
+ * value domain: `min_samples = smallest_group_size` names a rule the agent
357
+ * applies, and the slot takes the resulting integer. Only a member of the
358
+ * enum of the slot, a number for a number slot, or a boolean for a boolean
359
+ * slot binds. A free-text slot never binds, because the host cannot tell a
360
+ * policy word from a value there.
361
+ */
362
+ function fitsSlot(slot, value) {
363
+ if (slot.enum)
364
+ return typeof value === "string" && slot.enum.includes(value);
365
+ if (slot.type === "integer" || slot.type === "number")
366
+ return typeof value === "number";
367
+ if (slot.type === "boolean")
368
+ return typeof value === "boolean";
369
+ return false;
370
+ }
371
+ function bindTemplateContract(ref, contract, settings, grounding) {
372
+ const refVersion = versionOfRef(ref);
373
+ const versionMatches = refVersion === undefined || refVersion === contract.version;
374
+ const adaptable = contract.parameters.filter((slot) => slot.adaptable);
375
+ const adaptableNames = new Set(adaptable.map((slot) => slot.name));
376
+ const pinnedByName = new Map(contract.parameters.filter((slot) => !slot.adaptable).map((slot) => [slot.name, slot]));
377
+ const bound = [];
378
+ const unbound = [];
379
+ const slots = {};
380
+ const sources = {};
381
+ for (const setting of settings) {
382
+ const shown = `\`${setting.name}\` = ${JSON.stringify(setting.value)} (${setting.step})`;
383
+ if (!versionMatches) {
384
+ unbound.push(`${shown}: not bound, because the served version differs from the plan`);
385
+ continue;
386
+ }
387
+ if (adaptableNames.has(setting.name)) {
388
+ const slot = adaptable.find((candidate) => candidate.name === setting.name);
389
+ if (slot && !fitsSlot(slot, setting.value)) {
390
+ unbound.push(`${shown}: a policy of the plan, not a value of the \`${setting.name}\` slot (${slot.type}); state the value it gives`);
391
+ continue;
392
+ }
393
+ const prior = slots[setting.name];
394
+ if (prior !== undefined) {
395
+ if (!sameSettingValue(prior, setting.value))
396
+ unbound.push(`${shown}: differs from the bound value ${JSON.stringify(prior)} of the same slot`);
397
+ continue;
398
+ }
399
+ slots[setting.name] = setting.value;
400
+ if (setting.source)
401
+ sources[setting.name] = setting.source;
402
+ bound.push({ name: setting.name, value: setting.value, source: setting.source ?? "plan" });
403
+ continue;
404
+ }
405
+ const pinned = pinnedByName.get(setting.name);
406
+ if (pinned) {
407
+ const pinnedValue = pinned.default === undefined ? "a fixed value" : JSON.stringify(pinned.default);
408
+ unbound.push(sameSettingValue(pinned.default, setting.value)
409
+ ? `${shown}: pinned by the template at the same value`
410
+ : `${shown}: the template pins \`${setting.name}\` at ${pinnedValue}, and the plan value cannot be sent`);
411
+ continue;
412
+ }
413
+ unbound.push(`${shown}: no slot of the template carries it`);
414
+ }
285
415
  return {
286
- step,
287
- workspace,
288
- profile: profile?.result ?? null,
289
- upstream,
290
- memory: { goal: memory.goal, constraints: memory.constraints },
416
+ briefing: {
417
+ template: {
418
+ ref,
419
+ version_served: contract.version,
420
+ slots: adaptable,
421
+ inputs: (contract.inputs ?? []).map((input) => ({
422
+ name: input.name,
423
+ path: input.path,
424
+ ...(input.description ? { description: input.description } : {}),
425
+ })),
426
+ bound,
427
+ unbound_settings: unbound,
428
+ },
429
+ },
430
+ ...(versionMatches
431
+ ? {
432
+ binding: {
433
+ template: ref,
434
+ slots,
435
+ sources,
436
+ step: grounding.stepId,
437
+ claims: grounding.claims,
438
+ ...(grounding.snapshot ? { snapshot: grounding.snapshot } : {}),
439
+ // The local slots of the contract, thus the tool binds a path or a column name on the machine
440
+ // without a second read of the contract.
441
+ local: contract.parameters.filter((slot) => slot.local === true),
442
+ adaptable: adaptable.map((slot) => slot.name),
443
+ },
444
+ }
445
+ : {}),
291
446
  };
292
447
  }
293
448
  /**
@@ -861,17 +1016,21 @@ async function runSchedulerLoop(args) {
861
1016
  // can be named. Checkpointed so a replay re-dispatches the child
862
1017
  // with a byte-identical prompt instead of re-reading the DB/disk.
863
1018
  const seedStepName = `compose-step-seed:${stepId}`;
864
- const prompt = await DBOS.runStep(() => {
1019
+ const checkpointed = await DBOS.runStep(() => {
865
1020
  stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
866
1021
  return composeStepSeed({ input, stepId, runId, deps });
867
1022
  }, { name: seedStepName });
1023
+ // A run recovered from a checkpoint written when this step returned the
1024
+ // bare seed string replays that string: it carries no binding.
1025
+ const seed = typeof checkpointed === "string" ? { prompt: checkpointed } : checkpointed;
868
1026
  const baseChildInput = buildChildInput({
869
1027
  input,
870
1028
  stepId,
871
1029
  level: levels.get(stepId) ?? 0,
872
1030
  runId,
873
1031
  workflowId,
874
- prompt,
1032
+ prompt: seed.prompt,
1033
+ ...(seed.templateBinding ? { templateBinding: seed.templateBinding } : {}),
875
1034
  });
876
1035
  const childInput = {
877
1036
  ...baseChildInput,
@@ -1052,6 +1211,9 @@ async function cancelInFlight(inFlight, cause, canceledByParent) {
1052
1211
  recordCancelledChild({ cause });
1053
1212
  }
1054
1213
  }
1214
+ // ── (6) collectAndComplete ────────────────────────────────────────────
1215
+ /** The waits between the attempts are 1, 2, 4, and 8 seconds. */
1216
+ const FINAL_STATUS_WRITE_RETRY = { retriesAllowed: true, maxAttempts: 5, intervalSeconds: 1, backoffRate: 2 };
1055
1217
  async function collectAndComplete(args) {
1056
1218
  const { input, runId, workflowId, startedAtMs, completed, failed, canceled, suspension, failureReason, forceFailed, deps } = args;
1057
1219
  // A forced failure is terminal and never a resumable suspension.
@@ -1080,18 +1242,9 @@ async function collectAndComplete(args) {
1080
1242
  : suspended !== null
1081
1243
  ? "workflow-suspended"
1082
1244
  : "workflow-canceled";
1083
- // Terminal clock read + run-completion provenance, emitted BEFORE the `cortex_runs`
1084
- // status write below — and thus before any of the terminal cleanup steps. The CLI
1085
- // watches the run by POLLING that row (`run.ts` `waitForRunTerminal`) and shuts down
1086
- // the instant it leaves `running`, flushing provenance and then `process.exit()`ing;
1087
- // emitting the record only after the status write races that shutdown and can drop
1088
- // the run's terminal provenance entirely. Emitting first makes the record dirty
1089
- // (synchronously, via the bus) before the row can be observed terminal, so the CLI's
1090
- // flush always captures it. Not step-wrapped — a DBOS recovery replay must re-fire it;
1091
- // `DBOS.now()` is checkpointed, so the span stays replay-stable (see `RunProvenanceEvent`).
1092
- // The duration measures from the `run_started` read threaded in as `startedAtMs`.
1245
+ const finalError = failureReason ?? (status === "canceled" ? "external_cancel" : null);
1093
1246
  const terminalAtMs = await DBOS.now();
1094
- emitProvenanceGuarded(deps, {
1247
+ const emitRunEnd = () => emitProvenanceGuarded(deps, {
1095
1248
  type: "run_completed",
1096
1249
  analysisId: input.analysisId,
1097
1250
  runId,
@@ -1099,12 +1252,58 @@ async function collectAndComplete(args) {
1099
1252
  atMs: terminalAtMs,
1100
1253
  durationMs: terminalAtMs - startedAtMs,
1101
1254
  }, input.runSession);
1102
- await ledgerStep(logger.with({ status }), "persist-final-status", async () => {
1103
- const written = await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" ? "external_cancel" : null));
1104
- if (written.isErr())
1105
- return written;
1106
- // The outcome metrics ride inside the step that persists the terminal status: DBOS
1107
- // caches the step, so a recovered body records nothing again. A step's own terminal
1255
+ // Ownership defaults to true for inputs persisted before the field existed (a
1256
+ // workflow recovered across this deploy) — those were always Cortex-owned.
1257
+ const authorization = {
1258
+ runSession: input.runSession,
1259
+ ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
1260
+ };
1261
+ const closeCharge = () => deliverNotice(logger.with({ chargeOutcome: chargeOutcome.kind }), "RunCharge.close", deps.runCharge.close({
1262
+ analysisId: input.analysisId,
1263
+ runId,
1264
+ outcome: chargeOutcome,
1265
+ session: input.runSession,
1266
+ }));
1267
+ const revokeAuthorization = () => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason));
1268
+ // The writer that wins the row owns the run end: the `run_completed` emit, the charge close,
1269
+ // and the revoke. The step does all three when its write changes the row. An engine cancel
1270
+ // cannot stop a step that started, so a won write is never cut before these acts.
1271
+ //
1272
+ // The emit rides inside the transaction of the write. The update holds the row lock until the
1273
+ // commit, so the run canceler waits and then loses, and no reader sees the terminal row before
1274
+ // the emit. The cli polls the row and flushes the provenance when it leaves `running`; an emit
1275
+ // after the commit can miss that flush. `emitRunEvent` returns `void`, so the emit completes before the commit.
1276
+ //
1277
+ // DBOS retries only a thrown error, so an attempt before the last throws on a failed transaction.
1278
+ // The last attempt returns "failed" instead: the body takes the failed path with no `try`, and a
1279
+ // failure of DBOS to record a won step cannot be mistaken for a failed write. A commit can fail
1280
+ // after the emit, so `emittedInStep` keeps the later attempts from emitting again.
1281
+ let emittedInStep = false;
1282
+ const finalWrite = await DBOS.runStep(async () => {
1283
+ const stepLog = logger.with({ status }).named("persist-final-status");
1284
+ const written = await withTransaction(deps.pool, "runs.persistFinalStatus", (client) => updateRunStatus(client, runId, status, finalError).map((changed) => {
1285
+ if (changed && !emittedInStep) {
1286
+ emitRunEnd();
1287
+ emittedInStep = true;
1288
+ }
1289
+ return changed;
1290
+ }));
1291
+ if (written.isErr()) {
1292
+ const attempt = DBOS.stepStatus;
1293
+ const isLastAttempt = attempt?.currentAttempt === undefined || attempt.maxAttempts === undefined || attempt.currentAttempt >= attempt.maxAttempts;
1294
+ const failure = { error: describeDbError(written.error), ...stepLog.errorFields(written.error.cause) };
1295
+ if (isLastAttempt) {
1296
+ stepLog.error("ledger write failed", failure);
1297
+ return "failed";
1298
+ }
1299
+ stepLog.warn("ledger write failed, the step retries", { ...failure, attempt: attempt.currentAttempt });
1300
+ throw new Error(`ledger write failed: ${describeDbError(written.error)}`, { cause: written.error.cause });
1301
+ }
1302
+ // A refused write means an earlier write already set a terminal status: the run
1303
+ // canceler's, or an earlier run of this step before a crash. That writer owns the run end.
1304
+ if (!written.value)
1305
+ return "refused";
1306
+ // DBOS caches the step, so a recovered body records nothing again. A step's own terminal
1108
1307
  // row already carried its metric; the residue counted here is every settled step
1109
1308
  // without one — a child the cancel cut before `mark-canceled` (the cancel check runs
1110
1309
  // before a step body), or one that died before `failStep`. The ledger read is kept
@@ -1121,8 +1320,41 @@ async function collectAndComplete(args) {
1121
1320
  recordStepCompleted({ agentId: row.agentId, status: "failed" });
1122
1321
  }
1123
1322
  recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
1124
- return written;
1125
- });
1323
+ await closeCharge();
1324
+ await revokeAuthorization();
1325
+ return "won";
1326
+ }, { name: "persist-final-status", ...FINAL_STATUS_WRITE_RETRY });
1327
+ // A refused write leaves two owners. When the canceler won, its engine cancel came before its
1328
+ // write, so `close-running-charge` throws the cancel and the body does no act of the run end.
1329
+ // When a recovery runs the status step again after its own write landed, no cancel occurred and
1330
+ // the body completes the run end here. A failed write leaves the row `running` with no owner,
1331
+ // and the body still closes and revokes.
1332
+ let closed = false;
1333
+ let revoked = false;
1334
+ if (finalWrite !== "won") {
1335
+ closed = await DBOS.runStep(closeCharge, { name: "close-running-charge" });
1336
+ revoked = await DBOS.runStep(revokeAuthorization, { name: "revoke-run-auth" });
1337
+ }
1338
+ // The step emits only in the execution that ran it. A won step that DBOS gives from the cache
1339
+ // did not run, and a refused or failed write did not emit; the body fires the run end here
1340
+ // with the same checkpointed values. It is not step-wrapped, so a recovery replay fires it again.
1341
+ if (!emittedInStep)
1342
+ emitRunEnd();
1343
+ // After a failed write the row is not terminal, so the body records its run end for the run
1344
+ // canceler. The record comes after the acts: a cancel before it stops the body and the canceler
1345
+ // then owns the run end. A cancel is the only error that goes back to DBOS; a failed record must
1346
+ // not undo an otherwise-complete run, like the sibling terminal steps.
1347
+ if (finalWrite === "failed") {
1348
+ const recorded = { status, error: finalError, closed, revoked };
1349
+ try {
1350
+ await DBOS.setEvent(RUN_END_EVENT_KEY, recorded);
1351
+ }
1352
+ catch (error) {
1353
+ if (error instanceof DBOSErrors.DBOSWorkflowCancelledError)
1354
+ throw error;
1355
+ logger.error("run end record failed, the run canceler acts as for a run with no record", { status, ...logger.errorFields(error) });
1356
+ }
1357
+ }
1126
1358
  // Record the run's synthesis outcome AFTER the status write — a reader that
1127
1359
  // sees a terminal status then also sees whether (and why) synthesis ran. A
1128
1360
  // null outcome means synthesis never ran; leave the columns NULL ("unknown").
@@ -1142,24 +1374,7 @@ async function collectAndComplete(args) {
1142
1374
  if (suspended !== null) {
1143
1375
  await ledgerStep(logger, "suspend-analysis", () => suspendAnalysisQuery(deps.pool, input.analysisId));
1144
1376
  }
1145
- await DBOS.runStep(() => deliverNotice(logger.with({ chargeOutcome: chargeOutcome.kind }), "RunCharge.close", deps.runCharge.close({
1146
- analysisId: input.analysisId,
1147
- runId,
1148
- outcome: chargeOutcome,
1149
- session: input.runSession,
1150
- })), { name: "close-running-charge" });
1151
- // Revoke the run authorization for the terminal run state. Ownership
1152
- // defaults to true for inputs persisted before the field existed (a
1153
- // workflow recovered across this deploy) — those were always Cortex-owned.
1154
- const authorization = {
1155
- runSession: input.runSession,
1156
- ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
1157
- };
1158
- await DBOS.runStep(() => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason)), {
1159
- name: "revoke-run-auth",
1160
- });
1161
- // The `run_completed` provenance was already emitted above (before the status write, to
1162
- // beat the CLI's poll-and-shutdown). These branches only fan out the UI stream part, whose
1377
+ // The run end was emitted above. These branches only fan out the UI stream part, whose
1163
1378
  // completed/failed shapes genuinely differ — the provenance record does not.
1164
1379
  if (status === "completed" || status === "partial") {
1165
1380
  const artifactCount = await DBOS.runStep(async () => await countArtifactsByRuns(deps.pool, input.analysisId, [runId])
@@ -49,6 +49,7 @@ import type { SandboxRef } from "../sandbox/types.js";
49
49
  import { ProvenanceCollector } from "../provenance/collector.js";
50
50
  import { type BlockerHolder } from "../tools/sandbox/report-blocker.js";
51
51
  import { type FileMetadataCell } from "../tools/sandbox/submit-file-metadata.js";
52
+ import type { TemplateBinding } from "../tools/knowledge/template.js";
52
53
  /**
53
54
  * Input the parent hands the child. Must be JSON-serialisable — DBOS
54
55
  * persists this as the workflow's input row.
@@ -70,6 +71,17 @@ export interface SandboxStepInput {
70
71
  * rather than admitting unstable ones.
71
72
  */
72
73
  readonly dependsOn?: readonly string[];
74
+ /**
75
+ * The plan settings bound to the template of this step, composed by the
76
+ * parent at dispatch beside the seed. The agent factory hands it to
77
+ * `knowledge_template`, which merges the bound slots under the model
78
+ * values and refuses a silent change of one.
79
+ *
80
+ * Optional for the same reason as `dependsOn`: a durable input that
81
+ * predates the field recovers without it, and a step without a grounded
82
+ * template has nothing to bind. Absence means the model values ride alone.
83
+ */
84
+ readonly templateBinding?: TemplateBinding;
73
85
  /** Topological level — persisted on `cortex_step_executions.wave`. */
74
86
  readonly level: number;
75
87
  /** User-content prompt the agent receives as its initial message. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.45.0",
3
+ "version": "0.45.1",
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",
@@ -61,6 +61,7 @@
61
61
  "@fontsource/ibm-plex-mono": "5.1.1",
62
62
  "@inflexa-ai/tsprov": "0.6.0",
63
63
  "@kubernetes/client-node": "^1.4.0",
64
+ "@mozilla/readability": "^0.6.0",
64
65
  "@opentelemetry/api": "^1.9.1",
65
66
  "@opentelemetry/core": "^2.9.0",
66
67
  "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0",
@@ -72,6 +73,7 @@
72
73
  "@opentelemetry/semantic-conventions": "^1.43.0",
73
74
  "@types/dockerode": "^3.3.47",
74
75
  "@types/pg": "^8.20.0",
76
+ "@types/turndown": "^5.0.6",
75
77
  "ag-grid-community": "36.1.0",
76
78
  "ai": "^7.0.113",
77
79
  "cheerio": "^1.2.0",
@@ -82,6 +84,7 @@
82
84
  "hyparquet": "^1.28.1",
83
85
  "js-tiktoken": "^1.0.21",
84
86
  "kysely": "^0.29.6",
87
+ "linkedom": "^0.18.14",
85
88
  "mime": "^4.1.0",
86
89
  "nanoid": "^5.1.16",
87
90
  "neverthrow": "^8.2.0",
@@ -90,6 +93,7 @@
90
93
  "pdf-parse": "^2.4.5",
91
94
  "pg": "^8.22.0",
92
95
  "puppeteer-core": "^23.11.1",
96
+ "turndown": "^7.2.4",
93
97
  "zod": "^4.4.3"
94
98
  },
95
99
  "overrides": {