diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 682ae891e..42bf768d3 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -22,19 +22,19 @@ points straight at `./src/*.ts` files rather than a `dist/` build. ## What's vendored -| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | -| -------------------------------------------------- | ------------------------------------- | ------------- | ------------------------------------------ | ---------- | -------------------------------------------- | -| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-inference/PATCHES.md` | -| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/agent` | `vendor/intx-agent/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/workflow-host` (partial — `adapters/` only) | `vendor/intx-workflow-host/adapters/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | +| -------------------------------------------------- | ------------------------------------- | ------------- | ------------------------------------------ | ---------- | ------------------------------------------------- | +| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-inference/PATCHES.md` | +| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-types/PATCHES.md` | +| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-storage-isogit/PATCHES.md` | +| `@intx/agent` | `vendor/intx-agent/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-agent/PATCHES.md` | +| `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| `@intx/workflow-host` (partial — `adapters/` only) | `vendor/intx-workflow-host/adapters/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | ## Provenance, ownership, and kill dates @@ -48,9 +48,9 @@ package; the date is the deadline even if it is not. | Vendor path | Upstream repo | Upstream commit | Patched | Why not the published package | Owner | Proposed kill date | | ------------------------------------- | ----------------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | | `vendor/intx-inference/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Local fixes not yet upstream | runtime | 2027-03-07 or when patches land upstream and publish | -| `vendor/intx-types/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | -| `vendor/intx-storage-isogit/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | -| `vendor/intx-agent/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/agent@>=0.4.0` publishes | +| `vendor/intx-types/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-storage-isogit/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-agent/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/agent@>=0.4.0` publishes | | `vendor/intx-authz/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/authz@>=0.4.0` publishes | | `vendor/intx-log/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/log@>=0.4.0` publishes | | `vendor/intx-tools-posix/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/tools-posix@>=0.4.0` publishes | @@ -118,9 +118,11 @@ The 2026-09-07 sync also vendored the remaining four consumed packages (`@intx/agent`, `@intx/authz`, `@intx/log`, `@intx/tools-posix`) at the same upstream commit, completing the set: every `@intx/*` package this repo imports now resolves to vendored source. None of the four carried -local patches at vendoring time; their trees are verbatim upstream -copies. `@intx/tools-lsp` remains on published npm (`0.3.0`) — it is a -thin adapter whose transitive `@intx/*` dependencies resolve to the +local patches at vendoring time. `@intx/agent` later gained the error-seq +patches ledgered in `vendor/intx-agent/PATCHES.md`; the other three remain +verbatim upstream copies. `@intx/tools-lsp` remains on published npm +(`0.3.0`) — it is a thin adapter whose transitive `@intx/*` dependencies +resolve to the vendored workspaces via root `overrides`, so it tracks the vendored set without being vendored itself. Published transitive dependencies that stay on npm (`@intx/crypto`, `@intx/inference-discovery`, @@ -187,13 +189,14 @@ those packages now resolves to the single root instance. As of this sync, ## Patched vs. verbatim -Of the eleven vendored paths, exactly one carries local patches: -`@intx/inference` — every divergence is a real fix not yet upstream, listed -one-per-patch in `vendor/intx-inference/PATCHES.md`. The other ten paths -(nine whole packages plus the partial `vendor/intx-workflow-host/adapters/` +Of the eleven vendored paths, four carry local patches: +`@intx/inference`, `@intx/types`, `@intx/storage-isogit`, and +`@intx/agent` — every divergence is a real fix not yet upstream, listed +one-per-patch in that package's `PATCHES.md`. The other seven paths +(six whole packages plus the partial `vendor/intx-workflow-host/adapters/` tree) are verbatim copies of upstream — no modifications. A diff against a pristine upstream checkout at the same commit and paths will show 100% -upstream-authored lines. +upstream-authored lines for those seven. ## Notable upstream shape changes carried by the 2026-08-22 sync @@ -229,12 +232,12 @@ different reason — the vendored mime sources import `@intx/crypto`, which stays on published npm, and `Bun.build` cannot resolve that bare specifier from inside the vendor workspace. -`@intx/inference` carries local patches — real fixes not yet present -upstream, not workarounds for something upstream has since fixed. Every -patched location carries a one-line comment naming its site-specific entry -in `vendor/intx-inference/PATCHES.md` (e.g. `#reactor-ts-correlating-ids-leak`), -so `grep -rn "Locally patched" vendor/intx-inference/src` finds every -divergence. **Markers are navigation; the SHA-diff is proof.** Run +Patched packages carry local fixes not yet present upstream, not +workarounds for something upstream has since fixed. Every patched +location carries a one-line comment naming its site-specific entry in +that package's `PATCHES.md` (e.g. `#reactor-ts-correlating-ids-leak`), +so `grep -rn "Locally patched" vendor/*/src` finds every divergence. +**Markers are navigation; the SHA-diff is proof.** Run `bin/vendor-patch-diff` against a pristine upstream checkout at the recorded SHA to print exactly the lines that are ours. A correspondence test (`tests/unit/vendor-patch-ledger.test.ts`) fails if a marker anchor @@ -253,7 +256,8 @@ does not resolve to a ledger heading, or if a ledger heading has no marker. `adapters/substrate-mailbox-store.ts` and `adapters/substrate-mailbox-store.test.ts`). Run `bun install`, `bun run typecheck`, `bun run build`, `bun run test`. -3. For a **patched** package (`@intx/inference`): before overwriting +3. For a **patched** package (`@intx/inference`, `@intx/types`, + `@intx/storage-isogit`, `@intx/agent`): before overwriting anything, run `bin/vendor-patch-diff` (optionally `--upstream /path/to/interchange`) to re-derive the exact local divergences against the recorded SHA — do not trust `PATCHES.md`'s diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 93d28cff0..b456e68d1 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -143,6 +143,7 @@ function stubAuditStore(): AuditStore { commitAudit: async () => undefined, commitErrors: async () => undefined, loadAudit: async () => [], + loadErrors: async () => [], }; } diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 691c70834..e511a4b94 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -800,6 +800,7 @@ describe("createSessionStores", () => { expect(typeof audit.commitAudit).toBe("function"); expect(typeof audit.commitErrors).toBe("function"); expect(typeof audit.loadAudit).toBe("function"); + expect(typeof audit.loadErrors).toBe("function"); }); }); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index e2b16f2f0..45e6e5e5b 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -848,6 +848,7 @@ export async function createSessionStores( commitErrors: (records, signal) => withResolvedDirLock(dir, () => base.commitErrors(records, signal)), loadAudit: (sessionId, signal) => base.loadAudit(sessionId, signal), + loadErrors: (sessionId, signal) => base.loadErrors(sessionId, signal), }; return { storage: store, audit: store }; diff --git a/vendor/intx-agent/PATCHES.md b/vendor/intx-agent/PATCHES.md new file mode 100644 index 000000000..d4c1bc3e8 --- /dev/null +++ b/vendor/intx-agent/PATCHES.md @@ -0,0 +1,42 @@ +# Patch ledger — vendor/intx-agent + +**The SHA-diff is authoritative; markers are navigation.** + +Recorded upstream commit lives in `docs/VENDORING.md`. A pristine checkout +at that SHA, diffed against `vendor/intx-agent/src`, is the only proof of +which lines are ours — run `bin/vendor-patch-diff` to produce it. The +`Locally patched — see …#` comments and the entries below are +signposts that point into that diff; they do not define its extent. + +## agent-ts-resume-error-seq + +`agent.ts` — `createAgent` resumes `errorSeq` from `auditStore.loadErrors` +so a rebuilt agent does not reuse seq 0 and collide with files the +previous assembly already committed. If `loadErrors` throws, assembly +still succeeds and seq starts at 0; a later colliding flush is dropped +by `agent-ts-duplicate-error-flush` rather than failing the session. + +**Disposition:** Promotion candidate. **Removal path:** Upstream PR to +`createAgent` that resumes the durable error sequence; then drop this +entry and its marker. + +## agent-ts-duplicate-error-flush + +`agent.ts` — `flushErrors` treats `Duplicate error record:` from +`commitErrors` as already-durable instead of failing `afterCheckpoint`. +Only the record named by the colliding `/-` +key is dropped; the rest of the batch is retried in the same flush, so +a stale-seq assembly flushing `[seq0/dup, seq1/fresh]` still persists +the fresh record. An unparseable key falls back to dropping the batch. +Identical-bytes exact retries still commit normally upstream. + +**Disposition:** Promotion candidate. **Removal path:** Upstream PR with +the same duplicate-flush handling; then drop this entry and its marker. + +## testing-audit-noop-ts-load-errors + +`testing/audit-noop.ts` — No-op `AuditStore` implements `loadErrors` as +an empty array so it satisfies the patched `AuditStore` contract. + +**Disposition:** Companion of `runtime-ts-audit-store-load-errors`. +**Removal path:** Ships out with the types patch. diff --git a/vendor/intx-agent/src/agent.ts b/vendor/intx-agent/src/agent.ts index 03b8bbe69..0a1b3c3a0 100644 --- a/vendor/intx-agent/src/agent.ts +++ b/vendor/intx-agent/src/agent.ts @@ -512,10 +512,38 @@ export async function createAgent( // the next flush, so a fourth caller arriving after the follow-up // begins still observes a clean state and starts its own flush. const accumulatedErrors: ErrorRecord[] = []; + // Resume from durable records so a rebuilt agent does not reuse seq 0 + // and collide with files the previous assembly already committed. + // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-resume-error-seq let errorSeq = 0; + try { + for (const record of await auditStore.loadErrors(sessionId)) { + if (record.seq >= errorSeq) errorSeq = record.seq + 1; + } + } catch { + logger.warn`loadErrors failed during assembly; starting error seq at 0`; + } let flushInProgress: Promise | undefined; let pendingFollowUp: Promise | undefined; + // File key mirror of the isogit store's error filename scheme + // (`state/errors//-.json`, seq padded to + // 8, unsafe category chars replaced). Maps a + // `Duplicate error record: ` collision back to the batch member + // that caused it so only that record is dropped. + function errorFileKey(record: ErrorRecord): string { + const seq = String(record.seq).padStart(8, "0"); + const category = record.category.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `${record.sessionId}/${seq}-${category}`; + } + + function dropFromAccumulator(records: readonly ErrorRecord[]): void { + for (const record of records) { + const index = accumulatedErrors.indexOf(record); + if (index !== -1) accumulatedErrors.splice(index, 1); + } + } + function flushErrors(): Promise { if (flushInProgress !== undefined) { // If another caller already arranged a follow-up flush after @@ -549,8 +577,39 @@ export async function createAgent( // expectation is that commitErrors failures are transient. flushInProgress = (async () => { try { - await auditStore.commitErrors(batch); - accumulatedErrors.splice(0, count); + let remaining = batch; + for (;;) { + try { + await auditStore.commitErrors(remaining); + } catch (cause) { + // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-duplicate-error-flush + if ( + cause instanceof Error && + cause.message.startsWith("Duplicate error record:") + ) { + const key = cause.message + .slice("Duplicate error record:".length) + .trim(); + const index = remaining.findIndex( + (record) => errorFileKey(record) === key, + ); + if (index === -1) { + logger.warn`duplicate error record already stored; dropping the colliding batch`; + dropFromAccumulator(remaining); + return; + } + const [colliding] = remaining.splice(index, 1); + if (colliding !== undefined) + dropFromAccumulator([colliding]); + logger.warn`duplicate error record already stored; dropping the colliding record`; + if (remaining.length === 0) return; + continue; + } + throw cause; + } + dropFromAccumulator(remaining); + return; + } } finally { flushInProgress = undefined; } diff --git a/vendor/intx-agent/src/audit-integration.test.ts b/vendor/intx-agent/src/audit-integration.test.ts index 579fa3549..6022070c4 100644 --- a/vendor/intx-agent/src/audit-integration.test.ts +++ b/vendor/intx-agent/src/audit-integration.test.ts @@ -71,6 +71,9 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return committedAudit.flat(); }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, getCommittedAudit() { return committedAudit; }, diff --git a/vendor/intx-agent/src/flush-errors.test.ts b/vendor/intx-agent/src/flush-errors.test.ts index 5d9b83e0e..7064d5d6d 100644 --- a/vendor/intx-agent/src/flush-errors.test.ts +++ b/vendor/intx-agent/src/flush-errors.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type } from "arktype"; +import { createDefaultDependencies } from "@intx/inference/providers"; import { createInboundMessage } from "@intx/mime"; import { createIsogitStore } from "@intx/storage-isogit/node"; import type { AuditRecord, ErrorRecord } from "@intx/types/audit"; @@ -59,6 +60,9 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, getCommittedErrors() { return committedErrors; }, @@ -91,6 +95,69 @@ function makeFailFirstAuditStore(): FailingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, + getCommittedErrors() { + return committedErrors; + }, + }; +} + +function makeDuplicateErrorAuditStore(): FailingAuditStore { + return { + async commitAudit(_records: AuditRecord[]): Promise { + // No-op. + }, + async commitErrors(records: ErrorRecord[]): Promise { + throw new Error( + `Duplicate error record: ${records[0]?.sessionId ?? "session"}/00000000-credential_failure`, + ); + }, + async loadAudit(_sessionId: string): Promise { + return []; + }, + async loadErrors(_sessionId: string): Promise { + return []; + }, + getCommittedErrors() { + return []; + }, + }; +} + +// Audit store that collides on the first batch's leading record only, +// simulating a stale-seq assembly flushing [seq0/dup, seq1/fresh]: the +// first `commitErrors` throws `Duplicate error record` naming the +// colliding record's file key, and the retry succeeds. +function makePartialDuplicateAuditStore(): FailingAuditStore { + const committedErrors: ErrorRecord[][] = []; + let firstAttempt = true; + return { + async commitAudit(_records: AuditRecord[]): Promise { + // No-op. + }, + async commitErrors(records: ErrorRecord[]): Promise { + if (firstAttempt) { + firstAttempt = false; + const colliding = records[0]; + const seq = String(colliding?.seq ?? 0).padStart(8, "0"); + const category = (colliding?.category ?? "").replace( + /[^a-zA-Z0-9_-]/g, + "_", + ); + throw new Error( + `Duplicate error record: ${colliding?.sessionId ?? "session"}/${seq}-${category}`, + ); + } + committedErrors.push([...records]); + }, + async loadAudit(_sessionId: string): Promise { + return []; + }, + async loadErrors(_sessionId: string): Promise { + return []; + }, getCommittedErrors() { return committedErrors; }, @@ -153,6 +220,86 @@ async function waitForReactorDone( } } +const FORBIDDEN_DEPS = { + ...createDefaultDependencies(), + fetch: async () => + new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }), +}; + +function credentialFailureDirectors(): BaseEnv["directors"] { + return makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + return [caps.checkpoint("after-error"), caps.done()]; + } + return caps.done(); + }, + ); +} + +function forbiddenAgentDef(id: string) { + return defineAgent({ + id, + systemPrompt: "test", + tools: [], + capabilities: [], + inference: { + sources: [ + { + provider: UNREACHABLE_SOURCE.provider, + model: UNREACHABLE_SOURCE.model, + }, + ], + }, + }); +} + +function duplicateFlushFailures( + events: ReadonlyArray<{ type: string; data?: unknown }>, +): ReadonlyArray<{ type: string; data?: unknown }> { + return events.filter((event) => { + if (event.type !== "reactor.error") return false; + return JSON.stringify(event.data ?? {}).includes("Duplicate error record"); + }); +} + +async function runForbiddenCycle(opts: { + workdir: string; + sessionId: string; + agentId: string; +}): Promise<{ events: Array<{ type: string; data?: unknown }> }> { + const store = await createIsogitStore(opts.workdir); + const env: BaseEnv = { + sources: [UNREACHABLE_SOURCE], + defaultSource: UNREACHABLE_SOURCE.id, + storage: store, + workdir: opts.workdir, + audit: store, + authorize: permissiveAuthorize(), + directors: credentialFailureDirectors(), + sessionId: opts.sessionId, + deps: FORBIDDEN_DEPS, + }; + const agent = await createAgent(forbiddenAgentDef(opts.agentId), env); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + return { events }; +} + describe("agent error flushing", () => { let workDir: string; @@ -470,4 +617,169 @@ describe("agent error flushing", () => { expect(batches.length).toBe(1); expect(batches[0]?.[0]?.source).toBe("reactor"); }); + + test("two credential_failure errors in one session persist without failing the run", async () => { + const sessionId = "session-credential-once"; + let inferenceErrors = 0; + const store = await createIsogitStore(workDir); + const env: BaseEnv = { + sources: [UNREACHABLE_SOURCE], + defaultSource: UNREACHABLE_SOURCE.id, + storage: store, + workdir: workDir, + audit: store, + authorize: permissiveAuthorize(), + directors: makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + inferenceErrors += 1; + if (inferenceErrors === 1) { + return [caps.checkpoint("after-first"), caps.infer()]; + } + return [caps.checkpoint("after-second"), caps.done()]; + } + return caps.done(); + }, + ), + sessionId, + deps: FORBIDDEN_DEPS, + }; + const agent = await createAgent(forbiddenAgentDef("cred-flush-once"), env); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + const records = (await store.loadErrors(sessionId)).filter( + (record) => record.category === "credential_failure", + ); + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => record.seq)).size).toBe(2); + }); + + test("two credential_failure errors persist across re-assembly without failing the session", async () => { + const sessionId = "session-credential"; + const first = await runForbiddenCycle({ + workdir: workDir, + sessionId, + agentId: "cred-flush-1", + }); + const second = await runForbiddenCycle({ + workdir: workDir, + sessionId, + agentId: "cred-flush-2", + }); + + expect(duplicateFlushFailures(first.events)).toEqual([]); + expect(duplicateFlushFailures(second.events)).toEqual([]); + const store = await createIsogitStore(workDir); + const records = (await store.loadErrors(sessionId)).filter( + (record) => record.category === "credential_failure", + ); + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => record.seq)).size).toBe(2); + }); + + test("a duplicate error record from commitErrors does not fail the session", async () => { + const audit = makeDuplicateErrorAuditStore(); + const directors = credentialFailureDirectors(); + const def = forbiddenAgentDef("cred-flush-duplicate"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + }); + + test("a partial duplicate collision drops only the colliding record", async () => { + // A stale-seq assembly flushing [seq0/dup, seq1/fresh] must persist + // the fresh record: the first commit names only the colliding key, + // so the flush drops that record and retries the rest. + const audit = makePartialDuplicateAuditStore(); + let inferenceErrors = 0; + const directors = makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + inferenceErrors += 1; + if (inferenceErrors === 1) return caps.infer(); + return [caps.checkpoint("after-second"), caps.done()]; + } + return caps.done(); + }, + ); + const def = forbiddenAgentDef("cred-flush-partial-duplicate"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + const persisted = audit.getCommittedErrors().flat(); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.seq).toBe(1); + }); + + test("createAgent still assembles when loadErrors throws", async () => { + const audit = makeRecordingAuditStore(); + audit.loadErrors = async () => { + throw new Error("simulated loadErrors failure"); + }; + const directors = credentialFailureDirectors(); + const def = forbiddenAgentDef("cred-flush-load-errors"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + expect(audit.getCommittedErrors().flat().length).toBeGreaterThan(0); + }); }); diff --git a/vendor/intx-agent/src/testing/audit-noop.test.ts b/vendor/intx-agent/src/testing/audit-noop.test.ts index f2658edcb..741e5a8ea 100644 --- a/vendor/intx-agent/src/testing/audit-noop.test.ts +++ b/vendor/intx-agent/src/testing/audit-noop.test.ts @@ -18,6 +18,11 @@ describe("noopAuditStore", () => { expect(await store.loadAudit("sess")).toEqual([]); }); + test("loadErrors returns an empty array", async () => { + const store = noopAuditStore(); + expect(await store.loadErrors("sess")).toEqual([]); + }); + test("each call returns a fresh object", () => { expect(noopAuditStore()).not.toBe(noopAuditStore()); }); diff --git a/vendor/intx-agent/src/testing/audit-noop.ts b/vendor/intx-agent/src/testing/audit-noop.ts index 612d682a7..0b1b42adc 100644 --- a/vendor/intx-agent/src/testing/audit-noop.ts +++ b/vendor/intx-agent/src/testing/audit-noop.ts @@ -25,5 +25,9 @@ export function noopAuditStore(): AuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + // Locally patched — see vendor/intx-agent/PATCHES.md#testing-audit-noop-ts-load-errors + async loadErrors(_sessionId: string): Promise { + return []; + }, }; } diff --git a/vendor/intx-inference/src/assembly.test.ts b/vendor/intx-inference/src/assembly.test.ts index 4eb384d09..91f1aa69a 100644 --- a/vendor/intx-inference/src/assembly.test.ts +++ b/vendor/intx-inference/src/assembly.test.ts @@ -198,6 +198,9 @@ function makeRecordingAuditStore(): AuditStore & { async commitErrors() { /* noop */ }, + async loadErrors() { + return []; + }, getCommitted() { return committed; }, diff --git a/vendor/intx-storage-isogit/PATCHES.md b/vendor/intx-storage-isogit/PATCHES.md new file mode 100644 index 000000000..cc1791a24 --- /dev/null +++ b/vendor/intx-storage-isogit/PATCHES.md @@ -0,0 +1,19 @@ +# Patch ledger — vendor/intx-storage-isogit + +**The SHA-diff is authoritative; markers are navigation.** + +Recorded upstream commit lives in `docs/VENDORING.md`. A pristine checkout +at that SHA, diffed against `vendor/intx-storage-isogit/src`, is the only +proof of which lines are ours — run `bin/vendor-patch-diff` to produce it. +The `Locally patched — see …#` comments and the entries below are +signposts that point into that diff; they do not define its extent. + +## store-ts-load-errors + +`store.ts` — Implements `AuditStore.loadErrors` by reading +`state/errors//*.json`, validating each file as `ErrorRecord`, +and returning records ordered by seq. Missing session directories return +`[]`. Companion to `runtime-ts-audit-store-load-errors` in `@intx/types`. + +**Disposition:** Promotion candidate. **Removal path:** Upstream PR adding +the same loader; then drop this entry and its marker. diff --git a/vendor/intx-storage-isogit/src/store.test.ts b/vendor/intx-storage-isogit/src/store.test.ts index 112e17809..5983509a6 100644 --- a/vendor/intx-storage-isogit/src/store.test.ts +++ b/vendor/intx-storage-isogit/src/store.test.ts @@ -692,6 +692,38 @@ describe("error store", () => { ), ).toHaveLength(1); }); + + test("loadErrors round-trips records ordered by seq", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + const later = makeErrorRecord({ seq: 2, category: "retryable" }); + const earlier = makeErrorRecord({ seq: 1, category: "credential_failure" }); + + await store.commitErrors([later]); + await store.commitErrors([earlier]); + + expect(await store.loadErrors("session-1")).toEqual([earlier, later]); + }); + + test("loadErrors returns empty array for nonexistent session", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + expect(await store.loadErrors("no-such-session")).toEqual([]); + }); + + test("rejects sessionId with path traversal on loadErrors", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + let thrown: Error | undefined; + try { + await store.loadErrors("../escape"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); }); describe("audit and error durability retries", () => { diff --git a/vendor/intx-storage-isogit/src/store.ts b/vendor/intx-storage-isogit/src/store.ts index 3cbb096e5..c01ff9dcc 100644 --- a/vendor/intx-storage-isogit/src/store.ts +++ b/vendor/intx-storage-isogit/src/store.ts @@ -17,8 +17,9 @@ import { import { type } from "arktype"; import { AuditRecord, + ErrorRecord, type AuditRecord as AuditRecordType, - type ErrorRecord, + type ErrorRecord as ErrorRecordType, } from "@intx/types/audit"; import { AUTHOR } from "./init"; import type { CommitSigner } from "./signer"; @@ -788,7 +789,7 @@ export class IsogitStore } async commitErrors( - records: ErrorRecord[], + records: ErrorRecordType[], _signal?: AbortSignal, ): Promise { if (records.length === 0) return; @@ -862,4 +863,43 @@ export class IsogitStore records.sort((a, b) => a.seq - b.seq); return records; } + + // Locally patched — see vendor/intx-storage-isogit/PATCHES.md#store-ts-load-errors + async loadErrors( + sessionId: string, + _signal?: AbortSignal, + ): Promise { + assertSafeSegment(sessionId, "sessionId"); + const sessionDir = this.runtime.path.join(this.dir, ERRORS_DIR, sessionId); + + let entries: string[]; + try { + entries = await this.runtime.fs.readdir(sessionDir); + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) { + return []; + } + throw cause; + } + + const records: ErrorRecordType[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const fullPath = this.runtime.path.join(sessionDir, entry); + const raw = await this.runtime.fs.readTextFile(fullPath); + const parsed = JSON.parse(raw) as unknown; + const result = ErrorRecord(parsed); + if (result instanceof type.errors) { + throw new Error(`Invalid error record in ${entry}: ${result.summary}`); + } + records.push(result); + } + + records.sort((a, b) => a.seq - b.seq); + return records; + } } diff --git a/vendor/intx-types/PATCHES.md b/vendor/intx-types/PATCHES.md new file mode 100644 index 000000000..e1243c670 --- /dev/null +++ b/vendor/intx-types/PATCHES.md @@ -0,0 +1,19 @@ +# Patch ledger — vendor/intx-types + +**The SHA-diff is authoritative; markers are navigation.** + +Recorded upstream commit lives in `docs/VENDORING.md`. A pristine checkout +at that SHA, diffed against `vendor/intx-types/src`, is the only proof of +which lines are ours — run `bin/vendor-patch-diff` to produce it. The +`Locally patched — see …#` comments and the entries below are +signposts that point into that diff; they do not define its extent. + +## runtime-ts-audit-store-load-errors + +`runtime.ts` — `AuditStore` grows `loadErrors(sessionId, signal?)` so a +rebuilt agent can resume the durable error sequence instead of reusing +seq 0. Companion to `store-ts-load-errors` in `@intx/storage-isogit` and +`agent-ts-resume-error-seq` in `@intx/agent`. + +**Disposition:** Promotion candidate. **Removal path:** Upstream PR adding +`loadErrors` to `AuditStore`; then drop this entry and its marker. diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts index 3ab030c2f..cb9ab2647 100644 --- a/vendor/intx-types/src/runtime.ts +++ b/vendor/intx-types/src/runtime.ts @@ -2837,6 +2837,14 @@ export interface AuditStore { * and shutdown with all error records accumulated since the last flush. */ commitErrors(records: ErrorRecord[], signal?: AbortSignal): Promise; + + /** + * Load error records for a session. Returns all records matching + * the given sessionId, ordered by seq. + * + * Locally patched — see vendor/intx-types/PATCHES.md#runtime-ts-audit-store-load-errors + */ + loadErrors(sessionId: string, signal?: AbortSignal): Promise; } // ---------------------------------------------------------------------------