@inflexa-ai/harness 0.28.0 → 0.28.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.
@@ -49,6 +49,15 @@ logger = createNoopLogger()) {
49
49
  await upsertArtifacts(db, localEntries);
50
50
  const localPaths = new Set(localEntries.map((e) => e.path));
51
51
  const result = await registry.register(input, session);
52
+ // The registry excluded these from `failed` by its own severity judgement, so
53
+ // this is their only record. Logged here rather than left to the registry:
54
+ // the seam is implementable by an embedder, and a rejection does not get to
55
+ // go unrecorded because one implementation chose not to log it.
56
+ if (result.notCounted && result.notCounted.length > 0) {
57
+ log.warn("registry rejections excluded from the failure count", {
58
+ rejected: result.notCounted.map((f) => ({ path: f.path, error: f.error })),
59
+ });
60
+ }
52
61
  let externalRegistered = 0;
53
62
  for (const reg of result.registered) {
54
63
  // Skip files the registry also returned that we didn't upsert locally
@@ -64,6 +64,20 @@ export interface ExternalRegistrationResult {
64
64
  * back a whole batch (`failed` then carries one summary entry).
65
65
  */
66
66
  failedCount: number;
67
+ /**
68
+ * Rejections the registry judged non-terminal and deliberately kept out of
69
+ * `failed`/`failedCount`: rows it never attempted, so nothing registered and
70
+ * no bytes are at risk. Reported rather than dropped — excluding a rejection
71
+ * decides only that it is not worth failing a step over, never that it goes
72
+ * unrecorded. Keeping them out of `failed` is what lets the fail-fast message
73
+ * list only the paths that actually cost the step something; surfacing them
74
+ * here is what keeps the registry's verdict checkable against what the
75
+ * external system actually said.
76
+ */
77
+ notCounted?: ReadonlyArray<{
78
+ path: string;
79
+ error: string;
80
+ }>;
67
81
  }
68
82
  export interface ArtifactRegistry {
69
83
  /**
@@ -56,10 +56,12 @@ export declare function generateStepSummaryAndWrite(deps: PostStepPipelineDeps,
56
56
  /**
57
57
  * Reconcile the manifest against disk (drop phantoms, rehash) and register the
58
58
  * survivors with the local ledger + the injected `ArtifactRegistry`. Fail-fast
59
- * (see the artifact-manifest spec): a non-zero `externalFailed` is a persistent rejection that
60
- * orphans real outputs, so it throws with the per-file detail (the OSS
61
- * filesystem registry returns `externalFailed: 0`, so it never trips). Returns
62
- * the reconciled manifest.
59
+ * (see the artifact-manifest spec): `externalFailed` counts only the terminal
60
+ * rejections — a rejected output whose bytes exist nowhere but the step tree,
61
+ * plus anything rejected as a consequence of one — so a non-zero count means
62
+ * real outputs went unregistered, and it throws with the per-file detail (the
63
+ * OSS filesystem registry returns `externalFailed: 0`, so it never trips).
64
+ * Returns the reconciled manifest.
63
65
  */
64
66
  export declare function reconcileAndRegisterStepArtifacts(deps: PostStepPipelineDeps, postCtx: PostStepContext, manifest: readonly ArtifactManifestEntry[]): Promise<readonly ArtifactManifestEntry[]>;
65
67
  /**
@@ -95,10 +95,12 @@ export async function generateStepSummaryAndWrite(deps, postCtx, manifest) {
95
95
  /**
96
96
  * Reconcile the manifest against disk (drop phantoms, rehash) and register the
97
97
  * survivors with the local ledger + the injected `ArtifactRegistry`. Fail-fast
98
- * (see the artifact-manifest spec): a non-zero `externalFailed` is a persistent rejection that
99
- * orphans real outputs, so it throws with the per-file detail (the OSS
100
- * filesystem registry returns `externalFailed: 0`, so it never trips). Returns
101
- * the reconciled manifest.
98
+ * (see the artifact-manifest spec): `externalFailed` counts only the terminal
99
+ * rejections — a rejected output whose bytes exist nowhere but the step tree,
100
+ * plus anything rejected as a consequence of one — so a non-zero count means
101
+ * real outputs went unregistered, and it throws with the per-file detail (the
102
+ * OSS filesystem registry returns `externalFailed: 0`, so it never trips).
103
+ * Returns the reconciled manifest.
102
104
  */
103
105
  export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest) {
104
106
  const { input, session, lineageCollector } = postCtx;
@@ -122,11 +124,18 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
122
124
  collector: lineageCollector,
123
125
  }, session, deps.logger);
124
126
  if (reg.externalFailed > 0) {
125
- const wholeActivityFailed = reg.externalRegistered === 0;
127
+ // The registry commits per leaf and per activity, so accepted and rejected
128
+ // rows arriving together is ordinary and `externalRegistered` is what
129
+ // genuinely landed. Zero of it means this batch registered nothing at all:
130
+ // no row carries an `artifact_id`, so the byte-sync that follows selects
131
+ // none of them and every output stays local-only. That reads as a payload-
132
+ // or credential-shaped rejection rather than a per-file one, so it rides on
133
+ // the message and as its own field.
134
+ const noneRegistered = reg.externalRegistered === 0;
126
135
  const detail = reg.failureDetails.map((f) => `${f.path}: ${f.error}`).join("\n ");
127
136
  const msg = `[post-step.reconcile] external registration failed for ${input.stepId}: ` +
128
- `${reg.externalFailed} row(s) rejected, ${reg.externalRegistered}/${reg.localCount} local artifact(s) registered` +
129
- (wholeActivityFailed ? " (WHOLE ACTIVITY ROLLED BACK — outputs orphaned)" : "") +
137
+ `${reg.externalFailed} terminal rejection(s), ${reg.externalRegistered}/${reg.localCount} local artifact(s) registered` +
138
+ (noneRegistered ? " (nothing in this batch registered)" : "") +
130
139
  (detail ? `\n ${detail}` : "");
131
140
  // Logged as fields as well as thrown: the throw reaches `failStep` as one
132
141
  // opaque string, so the per-path rejections are only queryable from here.
@@ -138,7 +147,7 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
138
147
  externalFailed: reg.externalFailed,
139
148
  externalRegistered: reg.externalRegistered,
140
149
  localCount: reg.localCount,
141
- wholeActivityFailed,
150
+ noneRegistered,
142
151
  failures: reg.failureDetails,
143
152
  });
144
153
  throw new Error(msg);
@@ -500,16 +500,46 @@ export async function runSandboxStepBody(input, deps) {
500
500
  // outputs, so it fails the step loudly instead of finishing green. Transient
501
501
  // managed-root errors are already retried in the client, so anything thrown here is
502
502
  // persistent — tear down, mark failed, and re-raise so the parent's fail-fast
503
- // cascade fires (mirrors the agent-loop failure path).
503
+ // cascade fires (mirrors the agent-loop failure path). The two operations are
504
+ // sequenced independently: byte-sync is attempted on both arms, so a rejected
505
+ // registration still uploads the bytes of whatever DID register.
504
506
  await emitActivity("persisting", "Registering artifacts");
507
+ // A bare call, deliberately not `DBOS.runStep`-wrapped: it is reachable from
508
+ // two arms below, and a checkpointed step reached from a conditional arm
509
+ // would shift the body's function-ID sequence between the two paths (see the
510
+ // harness-durable-runtime spec).
511
+ const syncStepArtifacts = () => deps.artifactRegistry.sync({
512
+ resourceId: input.analysisId,
513
+ runId: input.runId,
514
+ stepId: input.stepId,
515
+ }, session);
505
516
  let reconciledManifest;
506
517
  try {
507
518
  reconciledManifest = await reconcileAndRegisterStepArtifacts(deps, postCtx, manifest);
508
- await deps.artifactRegistry.sync({
509
- resourceId: input.analysisId,
510
- runId: input.runId,
511
- stepId: input.stepId,
512
- }, session);
519
+ }
520
+ catch (registrationErr) {
521
+ // Registration and byte-sync are independent, and a registry rejection is
522
+ // partial: the rows that DID register carry `artifact_id` with `file_id`
523
+ // still NULL, which is exactly what `queryUnsyncedStepArtifacts` selects.
524
+ // Syncing anyway uploads their bytes (rows that never registered are not
525
+ // selected, so this stays idempotent under re-execution); skipping it is
526
+ // what strands them registered-but-byteless.
527
+ try {
528
+ await syncStepArtifacts();
529
+ }
530
+ catch (syncErr) {
531
+ // A cancellation is control-flow, not a sync failure — re-raise it
532
+ // verbatim, as every other stage in this body does. Any real failure
533
+ // is swallowed so it cannot displace the registration rejection as the
534
+ // step's cause; this record is the only account of it.
535
+ if (syncErr instanceof DBOSErrors.DBOSWorkflowCancelledError)
536
+ throw syncErr;
537
+ logger.named("post-step.sync").warn("failed after a registration rejection (non-fatal)", logger.errorFields(syncErr));
538
+ }
539
+ throw await failStep("lineage_attestation", registrationErr);
540
+ }
541
+ try {
542
+ await syncStepArtifacts();
513
543
  }
514
544
  catch (err) {
515
545
  throw await failStep("lineage_attestation", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.28.0",
3
+ "version": "0.28.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",