@inflexa-ai/harness 0.35.2 → 0.35.3

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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Span policy of the harness TracerProvider: which DBOS traces are recorded,
3
+ * which spans reach the exporter, and the names the exported spans carry.
4
+ *
5
+ * DBOS names a workflow span after the registered workflow and a step span
6
+ * after the step function, and it runs each body under its span, so every
7
+ * child (a step, a patched fetch, a pg query) sits under the workflow span.
8
+ * Three shapes of that output are noise in a trace store, and each is stopped
9
+ * in the process, before export, rather than in a collector downstream. The
10
+ * decisions live where the workflows and steps are written; this module holds
11
+ * the mechanism only:
12
+ *
13
+ * - A module that registers a housekeeping workflow declares it untraced with
14
+ * `untracedWorkflow(name)` next to its registration. The sampler drops a
15
+ * root span that carries such a name at creation, and `ParentBased`
16
+ * sampling carries the decision to every child.
17
+ * - A replayed step re-emits its span with `cached=true`, which DBOS sets
18
+ * after the span has started. A sampler cannot see that, so
19
+ * `DbosSpanProcessor` drops the span at `onEnd`.
20
+ * - A step whose DBOS name embeds an id calls `stableSpan` as the first
21
+ * statement of its body. The helper renames the active DBOS span to the
22
+ * stable prefix and puts the id in an `inflexa.*` attribute. The DBOS step
23
+ * name itself is untouched: DBOS compares it against the recorded name on
24
+ * replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
25
+ * would break every workflow in flight across a deploy.
26
+ */
27
+ import { type Attributes, type Context } from "@opentelemetry/api";
28
+ import { type ReadableSpan, type Sampler, type Span, type SpanProcessor } from "@opentelemetry/sdk-trace-base";
29
+ /**
30
+ * Declare a registered DBOS workflow untraced: no root span for it is
31
+ * recorded, and no child span under it. Call it next to the
32
+ * `DBOS.registerWorkflow` that gives the workflow the same name.
33
+ */
34
+ export declare function untracedWorkflow(name: string): void;
35
+ /**
36
+ * The sampler of the harness TracerProvider. A root span named after an
37
+ * untraced workflow is not recorded; every other root is. A span with a parent
38
+ * takes the parent's decision (the standard `ParentBased` behaviour), so a DBOS
39
+ * step, a fetch, or a pg span under a dropped root is dropped with it, and a
40
+ * remote parent (an inbound `traceparent`) is honoured as usual.
41
+ */
42
+ export declare function createHarnessSampler(): Sampler;
43
+ /** The plan step id a `compose-step-seed` step carries. */
44
+ export declare const ATTR_INFLEXA_STEP_ID = "inflexa.step_id";
45
+ /** The provider tool-use id of a tool step. */
46
+ export declare const ATTR_INFLEXA_TOOL_USE_ID = "inflexa.tool_use_id";
47
+ /** The exec id (`${workflowId}:${stepId}:${fnId}`) of a sandbox exec step. */
48
+ export declare const ATTR_INFLEXA_EXEC_ID = "inflexa.exec_id";
49
+ /** The 1-based attempt counter of a sandbox poll, pull, or liveness-probe step. */
50
+ export declare const ATTR_INFLEXA_ATTEMPT = "inflexa.attempt";
51
+ /** The per-item key (a ChEMBL or NCT id) of a target-assessment fan-out step. */
52
+ export declare const ATTR_INFLEXA_FANOUT_KEY = "inflexa.fanout_key";
53
+ /**
54
+ * Rename the DBOS span of the step that is running to `name` and put its id
55
+ * in `attributes`. Call it as the first statement of a step body whose DBOS
56
+ * name embeds an id, with that DBOS name as `dbosName`.
57
+ *
58
+ * DBOS runs the body under its span, so with tracing on the active span is
59
+ * the step span. With tracing off DBOS opens no span, and the active span, if
60
+ * there is one, belongs to whoever started the workflow or the chat turn. The
61
+ * helper therefore renames a span only when its name is `dbosName`, and it
62
+ * leaves a missing or non-recording span alone.
63
+ */
64
+ export declare function stableSpan(dbosName: string, name: string, attributes: Attributes): void;
65
+ /**
66
+ * Sits in front of the exporting processor and forwards every span except a
67
+ * replayed one (`cached=true`), which it drops at `onEnd`.
68
+ */
69
+ export declare class DbosSpanProcessor implements SpanProcessor {
70
+ private readonly next;
71
+ constructor(next: SpanProcessor);
72
+ onStart(span: Span, parentContext: Context): void;
73
+ onEnding(span: Span): void;
74
+ onEnd(span: ReadableSpan): void;
75
+ forceFlush(): Promise<void>;
76
+ shutdown(): Promise<void>;
77
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Span policy of the harness TracerProvider: which DBOS traces are recorded,
3
+ * which spans reach the exporter, and the names the exported spans carry.
4
+ *
5
+ * DBOS names a workflow span after the registered workflow and a step span
6
+ * after the step function, and it runs each body under its span, so every
7
+ * child (a step, a patched fetch, a pg query) sits under the workflow span.
8
+ * Three shapes of that output are noise in a trace store, and each is stopped
9
+ * in the process, before export, rather than in a collector downstream. The
10
+ * decisions live where the workflows and steps are written; this module holds
11
+ * the mechanism only:
12
+ *
13
+ * - A module that registers a housekeeping workflow declares it untraced with
14
+ * `untracedWorkflow(name)` next to its registration. The sampler drops a
15
+ * root span that carries such a name at creation, and `ParentBased`
16
+ * sampling carries the decision to every child.
17
+ * - A replayed step re-emits its span with `cached=true`, which DBOS sets
18
+ * after the span has started. A sampler cannot see that, so
19
+ * `DbosSpanProcessor` drops the span at `onEnd`.
20
+ * - A step whose DBOS name embeds an id calls `stableSpan` as the first
21
+ * statement of its body. The helper renames the active DBOS span to the
22
+ * stable prefix and puts the id in an `inflexa.*` attribute. The DBOS step
23
+ * name itself is untouched: DBOS compares it against the recorded name on
24
+ * replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
25
+ * would break every workflow in flight across a deploy.
26
+ */
27
+ import { trace } from "@opentelemetry/api";
28
+ import { ParentBasedSampler, SamplingDecision, } from "@opentelemetry/sdk-trace-base";
29
+ /**
30
+ * Names of the DBOS workflows whose traces are not recorded. A registering
31
+ * module adds its own name at registration, which runs at boot inside
32
+ * `assembleCoreRuntime`, before `DBOS.launch` starts the first workflow. The
33
+ * sampler reads the set on every root span, so the set is complete before it
34
+ * is consulted, and a name added later applies from the next root span on.
35
+ */
36
+ const untracedWorkflowNames = new Set();
37
+ /**
38
+ * Declare a registered DBOS workflow untraced: no root span for it is
39
+ * recorded, and no child span under it. Call it next to the
40
+ * `DBOS.registerWorkflow` that gives the workflow the same name.
41
+ */
42
+ export function untracedWorkflow(name) {
43
+ untracedWorkflowNames.add(name);
44
+ }
45
+ /** Root decision only: `NOT_RECORD` for an untraced workflow, `RECORD_AND_SAMPLED` for every other root. */
46
+ class UntracedWorkflowRootSampler {
47
+ shouldSample(_context, _traceId, spanName) {
48
+ return {
49
+ decision: untracedWorkflowNames.has(spanName) ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED,
50
+ };
51
+ }
52
+ toString() {
53
+ return "UntracedWorkflowRootSampler";
54
+ }
55
+ }
56
+ /**
57
+ * The sampler of the harness TracerProvider. A root span named after an
58
+ * untraced workflow is not recorded; every other root is. A span with a parent
59
+ * takes the parent's decision (the standard `ParentBased` behaviour), so a DBOS
60
+ * step, a fetch, or a pg span under a dropped root is dropped with it, and a
61
+ * remote parent (an inbound `traceparent`) is honoured as usual.
62
+ */
63
+ export function createHarnessSampler() {
64
+ return new ParentBasedSampler({ root: new UntracedWorkflowRootSampler() });
65
+ }
66
+ /** The plan step id a `compose-step-seed` step carries. */
67
+ export const ATTR_INFLEXA_STEP_ID = "inflexa.step_id";
68
+ /** The provider tool-use id of a tool step. */
69
+ export const ATTR_INFLEXA_TOOL_USE_ID = "inflexa.tool_use_id";
70
+ /** The exec id (`${workflowId}:${stepId}:${fnId}`) of a sandbox exec step. */
71
+ export const ATTR_INFLEXA_EXEC_ID = "inflexa.exec_id";
72
+ /** The 1-based attempt counter of a sandbox poll, pull, or liveness-probe step. */
73
+ export const ATTR_INFLEXA_ATTEMPT = "inflexa.attempt";
74
+ /** The per-item key (a ChEMBL or NCT id) of a target-assessment fan-out step. */
75
+ export const ATTR_INFLEXA_FANOUT_KEY = "inflexa.fanout_key";
76
+ /**
77
+ * Rename the DBOS span of the step that is running to `name` and put its id
78
+ * in `attributes`. Call it as the first statement of a step body whose DBOS
79
+ * name embeds an id, with that DBOS name as `dbosName`.
80
+ *
81
+ * DBOS runs the body under its span, so with tracing on the active span is
82
+ * the step span. With tracing off DBOS opens no span, and the active span, if
83
+ * there is one, belongs to whoever started the workflow or the chat turn. The
84
+ * helper therefore renames a span only when its name is `dbosName`, and it
85
+ * leaves a missing or non-recording span alone.
86
+ */
87
+ export function stableSpan(dbosName, name, attributes) {
88
+ const span = trace.getActiveSpan();
89
+ if (span === undefined || !span.isRecording())
90
+ return;
91
+ // The API `Span` has no name accessor; the SDK span the provider hands out does.
92
+ if (span.name !== dbosName)
93
+ return;
94
+ span.updateName(name);
95
+ span.setAttributes(attributes);
96
+ }
97
+ /**
98
+ * Sits in front of the exporting processor and forwards every span except a
99
+ * replayed one (`cached=true`), which it drops at `onEnd`.
100
+ */
101
+ export class DbosSpanProcessor {
102
+ next;
103
+ constructor(next) {
104
+ this.next = next;
105
+ }
106
+ onStart(span, parentContext) {
107
+ this.next.onStart(span, parentContext);
108
+ }
109
+ onEnding(span) {
110
+ this.next.onEnding?.(span);
111
+ }
112
+ onEnd(span) {
113
+ if (span.attributes.cached === true)
114
+ return;
115
+ this.next.onEnd(span);
116
+ }
117
+ forceFlush() {
118
+ return this.next.forceFlush();
119
+ }
120
+ shutdown() {
121
+ return this.next.shutdown();
122
+ }
123
+ }
@@ -5,7 +5,12 @@
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set.
8
+ * is set. The provider carries the span policy of otel-spans.ts: the
9
+ * root span of a workflow declared with `untracedWorkflow` is not
10
+ * recorded (and its children with it), a replayed `cached=true` span
11
+ * is dropped before export, and a step body that calls `stableSpan`
12
+ * is exported under a stable name with its id in an `inflexa.*`
13
+ * attribute.
9
14
  *
10
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
package/dist/lib/otel.js CHANGED
@@ -5,7 +5,12 @@
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set.
8
+ * is set. The provider carries the span policy of otel-spans.ts: the
9
+ * root span of a workflow declared with `untracedWorkflow` is not
10
+ * recorded (and its children with it), a replayed `cached=true` span
11
+ * is dropped before export, and a step body that calls `stableSpan`
12
+ * is exported under a stable name with its id in an `inflexa.*`
13
+ * attribute.
9
14
  *
10
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
@@ -25,6 +30,7 @@
25
30
  */
26
31
  import { context, propagation, metrics, trace } from "@opentelemetry/api";
27
32
  import { createNoopLogger } from "./console-logger.js";
33
+ import { createHarnessSampler, DbosSpanProcessor } from "./otel-spans.js";
28
34
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
29
35
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
30
36
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
@@ -92,10 +98,11 @@ export function initOtel(options = {}) {
92
98
  const spanProcessors = [];
93
99
  if (endpoint) {
94
100
  const base = endpoint.replace(/\/+$/, "");
95
- spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` })));
101
+ spanProcessors.push(new DbosSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` }))));
96
102
  }
97
103
  const tracerProvider = new NodeTracerProvider({
98
104
  resource,
105
+ sampler: createHarnessSampler(),
99
106
  spanProcessors,
100
107
  });
101
108
  tracerProvider.register();
@@ -3,6 +3,7 @@ import { jsonSchema, tool as aiTool, } from "ai";
3
3
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
+ import { ATTR_INFLEXA_TOOL_USE_ID, stableSpan } from "../lib/otel-spans.js";
6
7
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
7
8
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
8
9
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
@@ -70,7 +71,13 @@ export async function runAgent(agent, initial, session, opts) {
70
71
  session,
71
72
  signal,
72
73
  emit,
73
- runStep: (name, fn) => runStep(`${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`, fn),
74
+ runStep: (name, fn) => {
75
+ const stepName = `${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`;
76
+ return runStep(stepName, () => {
77
+ stableSpan(stepName, `tool:${tu.toolName}:${name}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
78
+ return fn();
79
+ });
80
+ },
74
81
  ask,
75
82
  turnUsage,
76
83
  });
@@ -540,7 +547,11 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
540
547
  }
541
548
  await Promise.all(stepTools.map(({ tu, idx }) => {
542
549
  const startedAt = performance.now();
543
- return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding)).then((settled) => {
550
+ const stepName = toolStepName(tu.toolName, tu.toolCallId);
551
+ return runStep(stepName, () => {
552
+ stableSpan(stepName, `tool:${tu.toolName}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
553
+ return dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
554
+ }).then((settled) => {
544
555
  durations[idx] = elapsedMs(startedAt);
545
556
  const dispatched = readDispatchedStep(settled);
546
557
  results[idx] = dispatched.result;
@@ -55,13 +55,14 @@ export interface DbosConfig {
55
55
  * Attribute names follow the `dbos.*` semantic-convention layout
56
56
  * (`otelAttributeFormat: "semconv"`).
57
57
  *
58
- * Known SDK behavior a host must plan for:
59
- * - a recovered workflow starts a new root trace, with no link to the trace
60
- * of the original execution
61
- * - every already-completed step re-emits a `cached=true` span on replay,
62
- * so a restart with many in-flight steps bursts spans; size
63
- * `OTEL_BSP_MAX_QUEUE_SIZE` for it
64
- * - step span names embed the step, tool-use, or exec id
58
+ * The harness TracerProvider shapes what the SDK emits (`lib/otel-spans.ts`):
59
+ * a workflow declared with `untracedWorkflow` is not recorded, a replayed
60
+ * `cached=true` span is dropped before export, and a step body that calls
61
+ * `stableSpan` is exported under a stable name with its id in an
62
+ * `inflexa.*` attribute. The step name DBOS records is unchanged.
63
+ *
64
+ * Known SDK behavior a host must plan for: a recovered workflow starts a
65
+ * new root trace, with no link to the trace of the original execution.
65
66
  */
66
67
  readonly tracingEnabled?: boolean;
67
68
  }
@@ -55,6 +55,7 @@
55
55
  * varied between replays would desynchronise the recorded function-ID sequence.
56
56
  */
57
57
  import { DBOS } from "@dbos-inc/dbos-sdk";
58
+ import { ATTR_INFLEXA_ATTEMPT, ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
58
59
  import { digestBody } from "./digest.js";
59
60
  import { signCallback, verifyCallback } from "./hmac.js";
60
61
  import { createEscalationPolicy, probeLiveness, syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
@@ -182,9 +183,11 @@ export async function awaitExecCallback(ref, execId, emit, deadlineMs, options =
182
183
  /** Ask the sandbox directly. Returns null when the exec has not finished. */
183
184
  const pull = async () => {
184
185
  pullAttempt += 1;
185
- const pulled = await runStep(() => pullExecResult(fetchImpl, ref, execId), {
186
- name: `sandbox.pull-exec-result.${execId}.${pullAttempt}`,
187
- });
186
+ const pullStepName = `sandbox.pull-exec-result.${execId}.${pullAttempt}`;
187
+ const pulled = await runStep(() => {
188
+ stableSpan(pullStepName, "sandbox.pull-exec-result", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: pullAttempt });
189
+ return pullExecResult(fetchImpl, ref, execId);
190
+ }, { name: pullStepName });
188
191
  if (pulled.kind !== "completed")
189
192
  return null;
190
193
  // Identical treatment to a pushed completion: the bytes were signed by
@@ -341,9 +344,11 @@ export async function awaitExecPoll(ref, execId, emit, deadlineMs, options = {})
341
344
  let probeAttempt = 0;
342
345
  while (true) {
343
346
  pollAttempt += 1;
344
- const polled = await runStep(() => pollExecOnce(fetchImpl, ref, execId, cursor), {
345
- name: `sandbox.poll-exec-result.${execId}.${pollAttempt}`,
346
- });
347
+ const pollStepName = `sandbox.poll-exec-result.${execId}.${pollAttempt}`;
348
+ const polled = await runStep(() => {
349
+ stableSpan(pollStepName, "sandbox.poll-exec-result", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: pollAttempt });
350
+ return pollExecOnce(fetchImpl, ref, execId, cursor);
351
+ }, { name: pollStepName });
347
352
  if (polled.kind === "ok") {
348
353
  // Identical treatment to a pushed completion: the bytes were signed by
349
354
  // the same secret, so a forgery is as fatal here as it is there.
@@ -385,9 +390,11 @@ export async function awaitExecPoll(ref, execId, emit, deadlineMs, options = {})
385
390
  }
386
391
  if (isAlive && escalation.onPoll(polled.kind === "ok" ? "ok" : "unavailable")) {
387
392
  probeAttempt += 1;
388
- const verdict = await runStep(() => probeLiveness(isAlive, ref), {
389
- name: `sandbox.probe-liveness.${execId}.${probeAttempt}`,
390
- });
393
+ const probeStepName = `sandbox.probe-liveness.${execId}.${probeAttempt}`;
394
+ const verdict = await runStep(() => {
395
+ stableSpan(probeStepName, "sandbox.probe-liveness", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: probeAttempt });
396
+ return probeLiveness(isAlive, ref);
397
+ }, { name: probeStepName });
391
398
  // Only an observably dead machine fails the exec — the result lives
392
399
  // in that machine and nowhere else, so it is unrecoverable and the
393
400
  // synthetic failure races nothing. `alive` (live-but-slow exec,
@@ -18,7 +18,9 @@
18
18
  */
19
19
  import { DBOS } from "@dbos-inc/dbos-sdk";
20
20
  import { createNoopLogger } from "../lib/console-logger.js";
21
+ import { untracedWorkflow } from "../lib/otel-spans.js";
21
22
  const SWEEP_CRON = "0 */5 * * * *";
23
+ const SWEEP_WORKFLOW = "dbos-notifications-sweep";
22
24
  const DELETE_BATCH_LIMIT = 10_000;
23
25
  export async function sweepStaleNotifications(deps) {
24
26
  const logger = (deps.logger ?? createNoopLogger()).named("notification-sweep");
@@ -68,9 +70,10 @@ export function registerNotificationSweep(deps) {
68
70
  // a workflow registration. Register, then schedule the same callable.
69
71
  const sweep = DBOS.registerWorkflow(async () => {
70
72
  await sweepStaleNotifications({ deleteStale, logger: deps.logger });
71
- }, { name: "dbos-notifications-sweep" });
73
+ }, { name: SWEEP_WORKFLOW });
74
+ untracedWorkflow(SWEEP_WORKFLOW);
72
75
  DBOS.registerScheduled(sweep, {
73
- name: "dbos-notifications-sweep",
76
+ name: SWEEP_WORKFLOW,
74
77
  crontab: SWEEP_CRON,
75
78
  });
76
79
  }
@@ -21,9 +21,11 @@
21
21
  */
22
22
  import { DBOS } from "@dbos-inc/dbos-sdk";
23
23
  import { createNoopLogger } from "../lib/console-logger.js";
24
+ import { untracedWorkflow } from "../lib/otel-spans.js";
24
25
  import { unwrapOrThrow } from "../lib/result.js";
25
26
  import { reconcileReapedSandbox } from "../state/active-sandboxes.js";
26
27
  const REAPER_CRON = "0 */5 * * * *";
28
+ const REAPER_WORKFLOW = "sandbox-reaper";
27
29
  const DEFAULT_GRACE_MS = 10 * 60_000;
28
30
  /**
29
31
  * Workflow statuses that mean "still owns its sandbox — never reap". Must
@@ -152,9 +154,10 @@ export function registerSandboxReaper(deps) {
152
154
  graceMs: deps.graceMs,
153
155
  logger: deps.logger,
154
156
  }), { name: "sandbox-reaper-sweep" });
155
- }, { name: "sandbox-reaper" });
157
+ }, { name: REAPER_WORKFLOW });
158
+ untracedWorkflow(REAPER_WORKFLOW);
156
159
  DBOS.registerScheduled(reaper, {
157
- name: "sandbox-reaper",
160
+ name: REAPER_WORKFLOW,
158
161
  crontab: REAPER_CRON,
159
162
  });
160
163
  }
@@ -17,6 +17,7 @@
17
17
  * untouched.
18
18
  */
19
19
  import { DBOS } from "@dbos-inc/dbos-sdk";
20
+ import { ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
20
21
  import { EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
21
22
  import { signCallback } from "./hmac.js";
22
23
  const SUBMIT_HTTP_TIMEOUT_MS = 30_000;
@@ -71,7 +72,9 @@ export async function submitExec(ref, body, deps = {}) {
71
72
  stdoutByteCap: body.stdoutByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
72
73
  stderrByteCap: body.stderrByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
73
74
  };
74
- await runStep(() => postExec(fetchImpl, ref, capped), {
75
- name: `sandbox.submit-exec.${body.execId}`,
76
- });
75
+ const stepName = `sandbox.submit-exec.${body.execId}`;
76
+ await runStep(() => {
77
+ stableSpan(stepName, "sandbox.submit-exec", { [ATTR_INFLEXA_EXEC_ID]: body.execId });
78
+ return postExec(fetchImpl, ref, capped);
79
+ }, { name: stepName });
77
80
  }
@@ -21,11 +21,14 @@
21
21
  import { createHash } from "node:crypto";
22
22
  import { DBOS } from "@dbos-inc/dbos-sdk";
23
23
  import { createNoopLogger } from "../lib/console-logger.js";
24
+ import { untracedWorkflow } from "../lib/otel-spans.js";
24
25
  import { unwrapOrThrow } from "../lib/result.js";
25
26
  import { workflowIdFromExec } from "./exec-id.js";
26
27
  import { syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
27
28
  export const SHARD_COUNT = 8;
28
29
  const WATCHDOG_CRON = "*/60 * * * * *";
30
+ const WATCHDOG_PARENT_WORKFLOW = "sandbox-watchdog-parent";
31
+ const WATCHDOG_SHARD_WORKFLOW = "sandbox-watchdog-shard";
29
32
  /** Stable hash → shard mapping, exported for tests. */
30
33
  export function shardIndex(sandboxId, shardCount = SHARD_COUNT) {
31
34
  const digest = createHash("sha1").update(sandboxId).digest();
@@ -131,7 +134,8 @@ export function registerWatchdog(deps) {
131
134
  },
132
135
  logger: deps.logger,
133
136
  });
134
- }, { name: "sandbox-watchdog-shard" });
137
+ }, { name: WATCHDOG_SHARD_WORKFLOW });
138
+ untracedWorkflow(WATCHDOG_SHARD_WORKFLOW);
135
139
  // The scheduled target must itself be a registered workflow — DBOS's
136
140
  // scheduler loop skips (and errors on) any `@scheduled` function that lacks
137
141
  // a workflow registration. Register, then schedule the same callable.
@@ -148,9 +152,10 @@ export function registerWatchdog(deps) {
148
152
  continue;
149
153
  await DBOS.startWorkflow(childCheckShard)(shard);
150
154
  }
151
- }, { name: "sandbox-watchdog-parent" });
155
+ }, { name: WATCHDOG_PARENT_WORKFLOW });
156
+ untracedWorkflow(WATCHDOG_PARENT_WORKFLOW);
152
157
  DBOS.registerScheduled(watchdogParent, {
153
- name: "sandbox-watchdog-parent",
158
+ name: WATCHDOG_PARENT_WORKFLOW,
154
159
  crontab: WATCHDOG_CRON,
155
160
  });
156
161
  }
@@ -63,6 +63,7 @@ import { mkdir } from "node:fs/promises";
63
63
  import { join } from "node:path";
64
64
  import { forStep } from "../auth/types.js";
65
65
  import { createNoopLogger } from "../lib/console-logger.js";
66
+ import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
66
67
  import { unwrapOrThrow } from "../lib/result.js";
67
68
  import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
68
69
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
@@ -770,7 +771,11 @@ async function runSchedulerLoop(args) {
770
771
  // `completed`, so this is the first (and only) moment their results
771
772
  // can be named. Checkpointed so a replay re-dispatches the child
772
773
  // with a byte-identical prompt instead of re-reading the DB/disk.
773
- const prompt = await DBOS.runStep(() => composeStepSeed({ input, stepId, runId, deps }), { name: `compose-step-seed:${stepId}` });
774
+ const seedStepName = `compose-step-seed:${stepId}`;
775
+ const prompt = await DBOS.runStep(() => {
776
+ stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
777
+ return composeStepSeed({ input, stepId, runId, deps });
778
+ }, { name: seedStepName });
774
779
  const baseChildInput = buildChildInput({
775
780
  input,
776
781
  stepId,
@@ -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";
@@ -278,9 +279,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
278
279
  }))
279
280
  : [];
280
281
  async function runFanoutBlock(items, stepKindForName, keyOf, fn) {
281
- return Promise.all(items.map((item) => DBOS.runStep(() => fn(item), {
282
- name: `ta-fanout:${stepKindForName}:${keyOf(item)}`,
283
- }).catch((err) => wrapCollectorThrow(err))));
282
+ return Promise.all(items.map((item) => {
283
+ const key = keyOf(item);
284
+ const stepName = `ta-fanout:${stepKindForName}:${key}`;
285
+ return DBOS.runStep(() => {
286
+ stableSpan(stepName, `ta-fanout:${stepKindForName}`, { [ATTR_INFLEXA_FANOUT_KEY]: key });
287
+ return fn(item);
288
+ }, { name: stepName }).catch((err) => wrapCollectorThrow(err));
289
+ }));
284
290
  }
285
291
  const [perModulatorFaersResults, perTrialAesResults, perModulatorPolypharmResults, perClassDrugAesResults] = await Promise.all([
286
292
  runFanoutBlock(triageShortlist, "modulator-faers", (item) => item.moleculeChemblId, faersForOneModulator),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.35.2",
3
+ "version": "0.35.3",
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",