@inflexa-ai/harness 0.4.1 → 0.4.2

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.
@@ -60,9 +60,20 @@ export declare function generateStepSummaryAndWrite(deps: PostStepPipelineDeps,
60
60
  */
61
61
  export declare function reconcileAndRegisterStepArtifacts(deps: PostStepPipelineDeps, postCtx: PostStepContext, manifest: readonly ArtifactManifestEntry[]): Promise<readonly ArtifactManifestEntry[]>;
62
62
  /**
63
- * Vector-index the threaded file descriptions + summary into the per-analysis
64
- * pgvector store. Best-effort (see the artifact-manifest spec): indexing degrades on any failure
65
- * without failing the step — the internal try/catch logs + swallows.
63
+ * Vector-index the threaded file descriptions + the step summary into the
64
+ * per-analysis pgvector store. Best-effort (see the artifact-manifest spec):
65
+ * indexing degrades without failing the step.
66
+ *
67
+ * Degradation is per-item. Index setup (ensuring the index exists and building
68
+ * the store) is all-or-nothing — a setup failure logs and skips the stage,
69
+ * because without an index there is nowhere to index into. Past setup, each
70
+ * file description and the summary is embedded and upserted under its own
71
+ * failure boundary, so one rejected input (e.g. an over-length document the
72
+ * embedding backend rejects) costs only its own entry and every other item is
73
+ * still attempted. A partial index is invisible at the search surface — it
74
+ * returns fewer hits, never an error — so each per-item failure logs the item
75
+ * id and input text length, and when any item fails a final record carries the
76
+ * indexed/failed counts as the only signal that degradation occurred.
66
77
  */
67
78
  export declare function vectorIndexStepOutputs(deps: PostStepPipelineDeps, postCtx: PostStepContext, artifacts: PostStepArtifacts): Promise<void>;
68
79
  /**
@@ -144,60 +144,88 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
144
144
  return reconciled.manifest;
145
145
  }
146
146
  /**
147
- * Vector-index the threaded file descriptions + summary into the per-analysis
148
- * pgvector store. Best-effort (see the artifact-manifest spec): indexing degrades on any failure
149
- * without failing the step — the internal try/catch logs + swallows.
147
+ * Vector-index the threaded file descriptions + the step summary into the
148
+ * per-analysis pgvector store. Best-effort (see the artifact-manifest spec):
149
+ * indexing degrades without failing the step.
150
+ *
151
+ * Degradation is per-item. Index setup (ensuring the index exists and building
152
+ * the store) is all-or-nothing — a setup failure logs and skips the stage,
153
+ * because without an index there is nowhere to index into. Past setup, each
154
+ * file description and the summary is embedded and upserted under its own
155
+ * failure boundary, so one rejected input (e.g. an over-length document the
156
+ * embedding backend rejects) costs only its own entry and every other item is
157
+ * still attempted. A partial index is invisible at the search surface — it
158
+ * returns fewer hits, never an error — so each per-item failure logs the item
159
+ * id and input text length, and when any item fails a final record carries the
160
+ * indexed/failed counts as the only signal that degradation occurred.
150
161
  */
151
162
  export async function vectorIndexStepOutputs(deps, postCtx, artifacts) {
152
163
  const { input } = postCtx;
153
164
  const { metadataEntries, summary, reconciledManifest } = artifacts;
165
+ const logger = (deps.logger ?? createNoopLogger()).named("post-step").named("vector-index").with({ runId: input.runId, stepId: input.stepId });
166
+ let vectorStore;
167
+ let indexName;
154
168
  try {
169
+ // Setup is all-or-nothing: without an index there is nothing to index into.
155
170
  await ensureSearchIndex(deps.pool, input.analysisId, deps.embedding.dimensions);
156
- const vectorStore = createVectorStore(deps.pool);
157
- const indexName = searchIndexName(input.analysisId);
158
- const embedOne = async (text) => {
159
- const [vec] = unwrapOrThrow(await deps.embedding.embed([text], postCtx.session));
160
- if (!vec)
161
- throw new Error("vectorIndexStepOutputs: empty embedding response");
162
- return vec;
163
- };
164
- const dbPathPrefix = `runs/${input.runId}/${input.stepId}/`;
165
- const survivingPaths = new Set(reconciledManifest.map((a) => a.path));
166
- const liveMetadata = metadataEntries.filter((e) => survivingPaths.has(e.dbPath.slice(dbPathPrefix.length)));
167
- for (const entry of liveMetadata) {
168
- const embedding = await embedOne(entry.description);
169
- unwrapOrThrow(await vectorStore.upsert({
170
- indexName,
171
- vectors: [embedding],
172
- metadata: [{ text: entry.description, type: "output", ...entry.metadata }],
173
- ids: [`/${input.analysisId}/${entry.dbPath}`],
174
- }));
171
+ vectorStore = createVectorStore(deps.pool);
172
+ indexName = searchIndexName(input.analysisId);
173
+ }
174
+ catch (err) {
175
+ logger.warn("indexing failed", logger.errorFields(err));
176
+ return;
177
+ }
178
+ const embedOne = async (text) => {
179
+ const [vec] = unwrapOrThrow(await deps.embedding.embed([text], postCtx.session));
180
+ if (!vec)
181
+ throw new Error("vectorIndexStepOutputs: empty embedding response");
182
+ return vec;
183
+ };
184
+ /** Index one item, absorbing its failure so the rest of the step still lands. */
185
+ const indexOne = async (id, text, metadata) => {
186
+ try {
187
+ const embedding = await embedOne(text);
188
+ unwrapOrThrow(await vectorStore.upsert({ indexName, vectors: [embedding], metadata: [metadata], ids: [id] }));
189
+ return true;
175
190
  }
176
- if (summary && summary.markdown.trim().length > 0) {
177
- const relPath = `runs/${input.runId}/${input.stepId}/output/summary.md`;
178
- const summaryDbPath = `/${input.analysisId}/${relPath}`;
179
- const summaryEmbedding = await embedOne(summary.markdown);
180
- unwrapOrThrow(await vectorStore.upsert({
181
- indexName,
182
- vectors: [summaryEmbedding],
183
- metadata: [
184
- {
185
- text: summary.markdown,
186
- type: "summary",
187
- stepId: input.stepId,
188
- runId: input.runId,
189
- agentId: input.agentId,
190
- path: relPath,
191
- },
192
- ],
193
- ids: [summaryDbPath],
194
- }));
191
+ catch (err) {
192
+ // Text length rides as a field because over-length input is the known
193
+ // failure driver, and the description/summary itself stays out of logs.
194
+ logger.warn("indexing failed", { id, textLength: text.length, ...logger.errorFields(err) });
195
+ return false;
195
196
  }
197
+ };
198
+ const dbPathPrefix = `runs/${input.runId}/${input.stepId}/`;
199
+ const survivingPaths = new Set(reconciledManifest.map((a) => a.path));
200
+ const liveMetadata = metadataEntries.filter((e) => survivingPaths.has(e.dbPath.slice(dbPathPrefix.length)));
201
+ let indexed = 0;
202
+ let failed = 0;
203
+ for (const entry of liveMetadata) {
204
+ const ok = await indexOne(`/${input.analysisId}/${entry.dbPath}`, entry.description, { text: entry.description, type: "output", ...entry.metadata });
205
+ if (ok)
206
+ indexed++;
207
+ else
208
+ failed++;
196
209
  }
197
- catch (err) {
198
- const logger = (deps.logger ?? createNoopLogger()).named("post-step").named("vector-index");
199
- logger.warn("indexing failed", { runId: input.runId, stepId: input.stepId, ...logger.errorFields(err) });
210
+ if (summary && summary.markdown.trim().length > 0) {
211
+ const relPath = `runs/${input.runId}/${input.stepId}/output/summary.md`;
212
+ const ok = await indexOne(`/${input.analysisId}/${relPath}`, summary.markdown, {
213
+ text: summary.markdown,
214
+ type: "summary",
215
+ stepId: input.stepId,
216
+ runId: input.runId,
217
+ agentId: input.agentId,
218
+ path: relPath,
219
+ });
220
+ if (ok)
221
+ indexed++;
222
+ else
223
+ failed++;
200
224
  }
225
+ // A partial index is invisible at the search surface — it returns fewer hits,
226
+ // never an error — so the count of what did not land is the only signal.
227
+ if (failed > 0)
228
+ logger.warn("indexed with failures", { indexed, failed });
201
229
  }
202
230
  /**
203
231
  * Assemble the step-detail payload (file tree, summary markdown, output file
@@ -17,9 +17,9 @@ export interface ReconcileManifestInput {
17
17
  /** Collector backing the manifest; mutated to reflect drops/rehashes. */
18
18
  collector: ProvenanceCollector;
19
19
  /**
20
- * Operational logging seam; omitted falls back to no-op. Attestation is
21
- * fail-fast, so what this records — which entry was dropped, which input
22
- * could not be hashed — is the account of why a step died.
20
+ * Operational logging seam; omitted falls back to no-op. What this records
21
+ * — which entry or input was dropped, which input could not be hashed — is
22
+ * the sole account of why lineage changed or a step died.
23
23
  */
24
24
  logger?: Logger;
25
25
  }
@@ -23,7 +23,7 @@ import { stat } from "node:fs/promises";
23
23
  import path from "node:path";
24
24
  import { createNoopLogger } from "../lib/console-logger.js";
25
25
  import { computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
26
- import { artifactReconcileDropped } from "../lib/metrics.js";
26
+ import { artifactReconcileDropped, lineageInputDropped } from "../lib/metrics.js";
27
27
  export async function reconcileManifestWithDisk(input) {
28
28
  const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
29
29
  const logger = (input.logger ?? createNoopLogger()).named("reconcile-manifest").with({ runId, stepId, agentId });
@@ -74,7 +74,7 @@ export async function reconcileManifestWithDisk(input) {
74
74
  const hash = await computeSha256File(absPath);
75
75
  reconciled.push({ ...entry, hash, size: info.size });
76
76
  }
77
- await fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, logger);
77
+ await fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, agentId, logger);
78
78
  return { manifest: reconciled, droppedCount };
79
79
  }
80
80
  /**
@@ -87,35 +87,47 @@ export async function reconcileManifestWithDisk(input) {
87
87
  *
88
88
  * `artifacts`-source reads are the step's OWN files (mutating, dropped by the
89
89
  * registration translator), so they are not attested and not hashed here.
90
- * Lineage attestation is fail-fast: an input we cannot hash (gone, or resolving
91
- * outside the analysis tree) throws rather than registering a hashless edge.
90
+ * Lineage attestation is fail-fast for drift: an input that should be on disk
91
+ * and is not (ENOENT) throws rather than registering a hashless edge. A read
92
+ * that resolves OUTSIDE the analysis tree is dropped from lineage instead —
93
+ * the sandbox can legitimately open paths above its mount (`/{resourceId}/..`
94
+ * is the container root) and a capture layer may report them; that is out of
95
+ * scope, not drift, and failing the step over it killed real analyses in the
96
+ * field. Dropping still registers no hashless edge, which is the invariant.
92
97
  */
93
- async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, logger) {
98
+ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, agentId, logger) {
94
99
  const resourceRoot = path.resolve(workspaceRoot);
95
100
  for (const ref of collector.getTrackedInputs()) {
96
101
  if (ref.source === "artifacts")
97
102
  continue;
98
103
  if (hasValidContentHash(ref.hash))
99
104
  continue;
100
- // Each throw below is logged before it is raised. The thrown message reaches
101
- // `failStep` as one opaque string and everything downstream of that is
102
- // scrubbed, so naming the site and the offending ref HERE is what makes a
103
- // fail-fast attestation distinguishable from a registry rejection after the
104
- // fact — the read's `source` in particular says whether the step declared
105
+ // Every throw below is logged before it is raised, and every drop is
106
+ // logged as it happens. The thrown message reaches `failStep` as one
107
+ // opaque string and everything downstream of that is scrubbed, so naming
108
+ // the site and the offending ref HERE is the only account of why a step
109
+ // died — the read's `source` in particular says whether the step declared
105
110
  // this input at all.
106
111
  const attestation = { path: ref.path, source: ref.source, refRunId: ref.runId, refStepId: ref.stepId };
107
112
  // `ref.path` is the absolute container path `/{resourceId}/...`; strip the
108
113
  // mount segment and map the tail onto the host workspace root. `path.join`
109
114
  // normalizes any `..`, and the bound confines the read to the analysis tree.
115
+ // An out-of-tree resolution is dropped, not thrown: it is not an attestable
116
+ // lineage edge, but it is also not drift — mirrors the out-of-bounds output
117
+ // skip in `reconcileManifestWithDisk` and the directory drop below.
110
118
  const containerPrefix = `/${resourceId}`;
111
119
  if (ref.path !== containerPrefix && !ref.path.startsWith(containerPrefix + "/")) {
112
- logger.error("input read resolves outside the analysis tree", { ...attestation, throwSite: "container-prefix-bound" });
113
- throw new Error(`[reconcile-manifest] input read resolves outside the analysis tree: path=${ref.path} stepId=${stepId} runId=${runId}`);
120
+ logger.warn("dropping out-of-tree input from lineage", { ...attestation, boundSite: "container-prefix" });
121
+ lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "container-prefix" });
122
+ collector.dropInput(ref);
123
+ continue;
114
124
  }
115
125
  const hostPath = path.join(resourceRoot, ref.path.slice(containerPrefix.length + 1));
116
126
  if (hostPath !== resourceRoot && !hostPath.startsWith(resourceRoot + path.sep)) {
117
- logger.error("input read resolves outside the analysis tree", { ...attestation, hostPath, throwSite: "workspace-root-bound" });
118
- throw new Error(`[reconcile-manifest] input read resolves outside the analysis tree: path=${ref.path} stepId=${stepId} runId=${runId}`);
127
+ logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "workspace-root" });
128
+ lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "workspace-root" });
129
+ collector.dropInput(ref);
130
+ continue;
119
131
  }
120
132
  let info;
121
133
  try {
@@ -138,6 +150,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
138
150
  // non-file drop above. A genuinely missing FILE (ENOENT) still fails fast
139
151
  // as drift; a directory is not drift.
140
152
  logger.debug("dropping non-file input from lineage", attestation);
153
+ lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "directory" });
141
154
  collector.dropInput(ref);
142
155
  continue;
143
156
  }
@@ -3,5 +3,7 @@
3
3
  *
4
4
  * Instruments:
5
5
  * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
+ * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
6
7
  */
7
8
  export declare const artifactReconcileDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
9
+ export declare const lineageInputDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Instruments:
5
5
  * - cortex.artifact.reconcile.dropped — counter for missing manifest entries
6
+ * - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
6
7
  */
7
8
  import { metrics } from "@opentelemetry/api";
8
9
  const meter = metrics.getMeter("cortex");
@@ -10,3 +11,8 @@ export const artifactReconcileDropped = meter.createCounter("cortex.artifact.rec
10
11
  description: "Manifest entries dropped because their on-disk file was missing at " +
11
12
  "registration time (writes-then-deletes, renames). Tagged by agent_id, step_id.",
12
13
  });
14
+ export const lineageInputDropped = meter.createCounter("cortex.artifact.reconcile.input_dropped", {
15
+ description: "Tracked input reads dropped from lineage at reconcile because they are " +
16
+ "not content-attestable files of the analysis (directory reads, " +
17
+ "out-of-tree resolutions). Tagged by agent_id, step_id, reason.",
18
+ });
@@ -86,7 +86,9 @@ export declare class ProvenanceCollector {
86
86
  * Called by sandbox exec provenance frame processing.
87
87
  *
88
88
  * @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
89
- * @param relativePath - Path relative to the mount root
89
+ * @param relativePath - Path relative to the mount root; or, for a frame
90
+ * report naming somewhere outside the mount, the verbatim absolute path
91
+ * (see `stripMountPrefix`)
90
92
  * @param hash - SHA-256 hash of the file content, or null if file no longer exists
91
93
  * @param context - Explicit classification context from the caller. When provided,
92
94
  * the collector uses it directly instead of classifying from the path.
@@ -124,10 +126,11 @@ export declare class ProvenanceCollector {
124
126
  * Drop an input ref from lineage entirely — removes it from the tracked-input
125
127
  * map and from every record's `inputs` array (identity match, since the refs
126
128
  * are shared objects). `reconcileManifestWithDisk` calls this for a read that
127
- * resolves to a directory (e.g. an `ls` / `list.files` of a mount): the
128
- * inotify frame tracks the open, but a directory is not a content-attestable
129
- * file artifact and must never reach registration. Mirrors the output-side
130
- * non-file drop.
129
+ * is not a content-attestable file of this analysis: one resolving to a
130
+ * directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
131
+ * tracks as an open), or one resolving outside the analysis tree (e.g.
132
+ * `/{resourceId}/..`, the container root). Neither may reach registration.
133
+ * Mirrors the output-side non-file and out-of-bounds drops.
131
134
  */
132
135
  dropInput(ref: InputRef): void;
133
136
  /**
@@ -121,7 +121,9 @@ export class ProvenanceCollector {
121
121
  * Called by sandbox exec provenance frame processing.
122
122
  *
123
123
  * @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
124
- * @param relativePath - Path relative to the mount root
124
+ * @param relativePath - Path relative to the mount root; or, for a frame
125
+ * report naming somewhere outside the mount, the verbatim absolute path
126
+ * (see `stripMountPrefix`)
125
127
  * @param hash - SHA-256 hash of the file content, or null if file no longer exists
126
128
  * @param context - Explicit classification context from the caller. When provided,
127
129
  * the collector uses it directly instead of classifying from the path.
@@ -133,7 +135,12 @@ export class ProvenanceCollector {
133
135
  return existing;
134
136
  // Use explicit context if provided, otherwise fall back to path classification
135
137
  const classification = context ?? classifyReadPath(relativePath, this.stepId, this.runId, this.dependsOn);
136
- const fullPath = `${mountPath}/${relativePath}`;
138
+ // A still-absolute `relativePath` is a report the caller could not
139
+ // relativize — it names somewhere outside the mount. It rides verbatim:
140
+ // prepending the mount root would forge an in-tree name for a foreign
141
+ // path, and reconcile would then see phantom drift (a missing file,
142
+ // which fails the step) instead of an out-of-tree read (which it drops).
143
+ const fullPath = relativePath.startsWith("/") ? relativePath : `${mountPath}/${relativePath}`;
137
144
  // Resolve fileId for data-source inputs via materialized file metadata map
138
145
  let fileId;
139
146
  if (classification.source === "data" && this.inputFileIdMap) {
@@ -240,10 +247,11 @@ export class ProvenanceCollector {
240
247
  * Drop an input ref from lineage entirely — removes it from the tracked-input
241
248
  * map and from every record's `inputs` array (identity match, since the refs
242
249
  * are shared objects). `reconcileManifestWithDisk` calls this for a read that
243
- * resolves to a directory (e.g. an `ls` / `list.files` of a mount): the
244
- * inotify frame tracks the open, but a directory is not a content-attestable
245
- * file artifact and must never reach registration. Mirrors the output-side
246
- * non-file drop.
250
+ * is not a content-attestable file of this analysis: one resolving to a
251
+ * directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
252
+ * tracks as an open), or one resolving outside the analysis tree (e.g.
253
+ * `/{resourceId}/..`, the container root). Neither may reach registration.
254
+ * Mirrors the output-side non-file and out-of-bounds drops.
247
255
  */
248
256
  dropInput(ref) {
249
257
  for (const [key, val] of this.inputAccesses) {
@@ -14,7 +14,13 @@
14
14
  */
15
15
  import type { ProvenanceFrame } from "../sandbox/types.js";
16
16
  import { type ProvenanceCollector } from "./collector.js";
17
- /** Strip the `/{resourceId}/` mount prefix from an absolute frame path. */
17
+ /**
18
+ * Strip the `/{resourceId}/` mount prefix from an absolute frame path.
19
+ * Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
20
+ * in-mount name lands on its canonical relative form. A path not under the
21
+ * mount comes back unchanged — still absolute — and the collector carries it
22
+ * verbatim (see `trackInputAccess`).
23
+ */
18
24
  export declare function stripMountPrefix(mountRoot: string, absPath: string): string;
19
25
  export interface FeedExecFrameArgs {
20
26
  readonly collector: ProvenanceCollector;
@@ -13,10 +13,16 @@
13
13
  * It never throws — provenance is best-effort and must not fail an exec.
14
14
  */
15
15
  import { classifyReadPath } from "./collector.js";
16
- /** Strip the `/{resourceId}/` mount prefix from an absolute frame path. */
16
+ /**
17
+ * Strip the `/{resourceId}/` mount prefix from an absolute frame path.
18
+ * Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
19
+ * in-mount name lands on its canonical relative form. A path not under the
20
+ * mount comes back unchanged — still absolute — and the collector carries it
21
+ * verbatim (see `trackInputAccess`).
22
+ */
17
23
  export function stripMountPrefix(mountRoot, absPath) {
18
24
  const prefix = mountRoot.endsWith("/") ? mountRoot : `${mountRoot}/`;
19
- return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath;
25
+ return absPath.startsWith(prefix) ? absPath.slice(prefix.length).replace(/^\/+/, "") : absPath;
20
26
  }
21
27
  /**
22
28
  * Translate one exec's frame into a collector record. Per-command input
@@ -10,9 +10,12 @@
10
10
  * is required to disambiguate): the gateway emits `402` exclusively for
11
11
  * `budget_exceeded` and `403` for a blocked tenant. Provider-originated
12
12
  * failures (429, 5xx, connection) surface with their own status or as a
13
- * network error with no status at all.
13
+ * network error with no status at all. A `401` is `auth`: the credential the
14
+ * host put behind the call is expired, revoked, or absent, and only a human
15
+ * re-authenticating fixes it — which is a different remedy from every other
16
+ * non-retryable 4xx, so it is a different kind.
14
17
  */
15
- export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
18
+ export type ProviderErrorKind = "auth" | "budget" | "tenant-blocked" | "provider";
16
19
  /**
17
20
  * The provider error value channel — a `DomainError`-conforming
18
21
  * discriminated union mirroring `ProviderErrorKind`. `chat` / `embed` return
@@ -23,6 +26,11 @@ export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
23
26
  * step boundary.
24
27
  */
25
28
  export type ProviderError = {
29
+ readonly type: "auth";
30
+ readonly retryable: false;
31
+ readonly message: string;
32
+ readonly cause?: unknown;
33
+ } | {
26
34
  readonly type: "budget";
27
35
  readonly retryable: false;
28
36
  readonly message: string;
@@ -72,6 +80,7 @@ export interface ProviderErrorClassification {
72
80
  /**
73
81
  * Classify a provider failure by origin.
74
82
  *
83
+ * - Provider `401` → `auth`, not retryable.
75
84
  * - Billing-gateway `402` → `budget`, not retryable.
76
85
  * - Billing-gateway `403` → `tenant-blocked`, not retryable.
77
86
  * - Provider `429` / `5xx` / connection errors → `provider`, retryable.
@@ -10,7 +10,10 @@
10
10
  * is required to disambiguate): the gateway emits `402` exclusively for
11
11
  * `budget_exceeded` and `403` for a blocked tenant. Provider-originated
12
12
  * failures (429, 5xx, connection) surface with their own status or as a
13
- * network error with no status at all.
13
+ * network error with no status at all. A `401` is `auth`: the credential the
14
+ * host put behind the call is expired, revoked, or absent, and only a human
15
+ * re-authenticating fixes it — which is a different remedy from every other
16
+ * non-retryable 4xx, so it is a different kind.
14
17
  */
15
18
  /**
16
19
  * Structural guard for an already-constructed `ProviderError` value. A
@@ -22,7 +25,9 @@ export function isProviderError(value) {
22
25
  if (typeof value !== "object" || value === null)
23
26
  return false;
24
27
  const v = value;
25
- return (v.type === "budget" || v.type === "tenant-blocked" || v.type === "provider") && typeof v.retryable === "boolean" && typeof v.message === "string";
28
+ return ((v.type === "auth" || v.type === "budget" || v.type === "tenant-blocked" || v.type === "provider") &&
29
+ typeof v.retryable === "boolean" &&
30
+ typeof v.message === "string");
26
31
  }
27
32
  /**
28
33
  * Turn a caught SDK throwable into a `ProviderError` value. Routes through
@@ -42,6 +47,14 @@ export function toProviderError(e, workload) {
42
47
  return e;
43
48
  const { kind, retryable } = classifyProviderError(e);
44
49
  const detail = e instanceof Error ? e.message : String(e);
50
+ if (kind === "auth") {
51
+ return {
52
+ type: "auth",
53
+ retryable: false,
54
+ message: `Provider rejected the credential for ${workload} — it is expired, revoked, or absent: ${detail}`,
55
+ cause: e,
56
+ };
57
+ }
45
58
  if (kind === "budget") {
46
59
  return {
47
60
  type: "budget",
@@ -109,6 +122,7 @@ function looksLikeConnectionError(err) {
109
122
  /**
110
123
  * Classify a provider failure by origin.
111
124
  *
125
+ * - Provider `401` → `auth`, not retryable.
112
126
  * - Billing-gateway `402` → `budget`, not retryable.
113
127
  * - Billing-gateway `403` → `tenant-blocked`, not retryable.
114
128
  * - Provider `429` / `5xx` / connection errors → `provider`, retryable.
@@ -116,6 +130,13 @@ function looksLikeConnectionError(err) {
116
130
  */
117
131
  export function classifyProviderError(e) {
118
132
  const status = extractStatus(e);
133
+ // `auth` is its own kind rather than a plain non-retryable 4xx because the
134
+ // remedy is categorically different: the request was well-formed and the
135
+ // credential behind it is not, so no amount of re-issuing or rephrasing
136
+ // helps — a human has to re-authenticate. Callers surface that; the generic
137
+ // 4xx branch below would tell them the request was wrong, which is false.
138
+ if (status === 401)
139
+ return { kind: "auth", retryable: false };
119
140
  if (status === 402)
120
141
  return { kind: "budget", retryable: false };
121
142
  if (status === 403)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",