diff --git a/bin/vendor-patch-diff b/bin/vendor-patch-diff new file mode 100644 index 000000000..8ace47e2f --- /dev/null +++ b/bin/vendor-patch-diff @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# Diff vendored packages against a pristine upstream checkout at the SHA +# recorded in docs/VENDORING.md. Prints only local divergences (the lines +# that are ours). Markers in vendor/ are navigation; this diff is proof. +# +# Usage: +# bin/vendor-patch-diff [--upstream PATH] [package...] +# +# package is a vendor directory basename (e.g. intx-inference) or an +# @intx/* name. With no packages, diffs every row in VENDORING.md that +# carries local patches. +# +# Upstream clone resolution (first hit wins): +# 1. --upstream PATH +# 2. $INTERCHANGE_UPSTREAM +# 3. ../interchange relative to the repo root +# 4. ../../interchange relative to the repo root +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDORING="$REPO_ROOT/docs/VENDORING.md" + +usage() { + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +UPSTREAM="" +PACKAGES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --upstream) + [[ $# -ge 2 ]] || usage + UPSTREAM="$2" + shift 2 + ;; + -h|--help) + usage + ;; + --) + shift + PACKAGES+=("$@") + break + ;; + -*) + echo "unknown flag: $1" >&2 + usage + ;; + *) + PACKAGES+=("$1") + shift + ;; + esac +done + +if [[ -z "$UPSTREAM" ]]; then + if [[ -n "${INTERCHANGE_UPSTREAM:-}" ]]; then + UPSTREAM="$INTERCHANGE_UPSTREAM" + elif [[ -d "$REPO_ROOT/../interchange/.git" || -f "$REPO_ROOT/../interchange/.git" ]]; then + UPSTREAM="$(cd "$REPO_ROOT/../interchange" && pwd)" + elif [[ -d "$REPO_ROOT/../../interchange/.git" || -f "$REPO_ROOT/../../interchange/.git" ]]; then + UPSTREAM="$(cd "$REPO_ROOT/../../interchange" && pwd)" + else + echo "error: no upstream Interchange clone found." >&2 + echo "Pass --upstream PATH or set INTERCHANGE_UPSTREAM." >&2 + exit 1 + fi +fi + +if [[ ! -d "$UPSTREAM" ]]; then + echo "error: upstream path is not a directory: $UPSTREAM" >&2 + exit 1 +fi +if ! git -C "$UPSTREAM" rev-parse --git-dir >/dev/null 2>&1; then + echo "error: upstream path is not a git repository: $UPSTREAM" >&2 + exit 1 +fi + +# Rows look like: +# | `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `SHA` | 2026-08-08 | Yes — see … | +# Capture: name, vendor path, sha, local-patches cell. +mapfile -t ROWS < <( + awk -F'|' ' + /^\| `@intx\// { + name=$2; vendor=$3; sha=$5; patches=$7 + gsub(/^ +| +$/, "", name) + gsub(/^ +| +$/, "", vendor) + gsub(/^ +| +$/, "", sha) + gsub(/^ +| +$/, "", patches) + gsub(/`/, "", name) + gsub(/`/, "", vendor) + gsub(/`/, "", sha) + gsub(/\/$/, "", vendor) + print name "\t" vendor "\t" sha "\t" patches + } + ' "$VENDORING" +) + +if [[ ${#ROWS[@]} -eq 0 ]]; then + echo "error: no vendored packages parsed from $VENDORING" >&2 + exit 1 +fi + +normalize_pkg() { + local raw="$1" + raw="${raw#@intx/}" + raw="${raw#vendor/}" + raw="${raw%/}" + if [[ "$raw" == intx-* ]]; then + echo "$raw" + else + echo "intx-$raw" + fi +} + +# Build the work list: either explicit packages, or every "Yes" local-patches row. +declare -a WORK_NAMES=() +declare -a WORK_VENDORS=() +declare -a WORK_SHAS=() + +if [[ ${#PACKAGES[@]} -eq 0 ]]; then + for row in "${ROWS[@]}"; do + IFS=$'\t' read -r name vendor sha patches <<<"$row" + if [[ "$patches" == Yes* ]]; then + WORK_NAMES+=("$name") + WORK_VENDORS+=("$vendor") + WORK_SHAS+=("$sha") + fi + done +else + for want in "${PACKAGES[@]}"; do + want_norm="$(normalize_pkg "$want")" + found=0 + for row in "${ROWS[@]}"; do + IFS=$'\t' read -r name vendor sha patches <<<"$row" + vendor_base="${vendor#vendor/}" + if [[ "$(normalize_pkg "$name")" == "$want_norm" || "$vendor_base" == "$want_norm" ]]; then + WORK_NAMES+=("$name") + WORK_VENDORS+=("$vendor") + WORK_SHAS+=("$sha") + found=1 + break + fi + done + if [[ $found -eq 0 ]]; then + echo "error: package not listed in $VENDORING: $want" >&2 + exit 1 + fi + done +fi + +if [[ ${#WORK_NAMES[@]} -eq 0 ]]; then + echo "error: no packages selected (no local-patches rows, or empty filter)" >&2 + exit 1 +fi + +# Map @intx/ → packages/ in the upstream monorepo. +upstream_pkg_path() { + local name="$1" + echo "packages/${name#@intx/}" +} + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/vendor-patch-diff.XXXXXX")" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT + +OVERALL=0 + +for i in "${!WORK_NAMES[@]}"; do + name="${WORK_NAMES[$i]}" + vendor="${WORK_VENDORS[$i]}" + sha="${WORK_SHAS[$i]}" + up_path="$(upstream_pkg_path "$name")" + vendor_src="$REPO_ROOT/$vendor/src" + + if [[ ! -d "$vendor_src" ]]; then + echo "error: missing vendored src: $vendor_src" >&2 + exit 1 + fi + + if ! git -C "$UPSTREAM" cat-file -e "${sha}^{commit}" 2>/dev/null; then + echo "error: upstream $UPSTREAM has no commit $sha" >&2 + echo "Fetch that commit into the clone, then re-run." >&2 + exit 1 + fi + + if ! git -C "$UPSTREAM" cat-file -e "${sha}:${up_path}" 2>/dev/null; then + echo "error: $up_path does not exist at $sha in $UPSTREAM" >&2 + exit 1 + fi + + pristine="$TMP/pristine-$i" + mkdir -p "$pristine" + # Extract only the package tree at the recorded SHA — read-only on upstream. + git -C "$UPSTREAM" archive "$sha" "$up_path" | tar -x -C "$pristine" + + pristine_src="$pristine/$up_path/src" + if [[ ! -d "$pristine_src" ]]; then + echo "error: archived tree has no src/: $pristine_src" >&2 + exit 1 + fi + + echo "### $name upstream=$sha vendor=$vendor" + echo "# pristine: $up_path/src vs $vendor/src" + echo + + # Unified diff of source only. diff exits 1 on differences — that is success + # for this tool (we expect patches). Exit 2 is a real error. + set +e + diff -ruN "$pristine_src" "$vendor_src" + code=$? + set -e + if [[ $code -eq 0 ]]; then + echo "(no source differences)" + elif [[ $code -eq 1 ]]; then + OVERALL=1 + else + echo "error: diff failed for $name (exit $code)" >&2 + exit 1 + fi + echo +done + +# Exit 0 when there are differences (the usual patched case) so pipelines +# treat a successful run as success. Exit 3 when everything is verbatim — +# surprising for a "show our patches" tool, so callers can detect it. +if [[ $OVERALL -eq 0 ]]; then + echo "note: no local source patches found for selected packages." >&2 + exit 3 +fi +exit 0 diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 6f7c49728..09285dba7 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -116,10 +116,14 @@ paths will show 100% upstream-authored lines. `@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 entry in -`vendor/intx-inference/PATCHES.md`, so `grep -rn "Locally patched" vendor/intx-inference/src` -finds every divergence, and a diff against a fresh upstream checkout at the -same commit should show ONLY those marked lines changed. +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 +`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 +does not resolve to a ledger heading, or if a ledger heading has no marker. ## Re-syncing a vendored package to a newer upstream commit @@ -132,19 +136,21 @@ same commit should show ONLY those marked lines changed. diff the two `package.json` files by hand). Run `bun install`, `bun run typecheck`, `bun run build`, `bun run test`. 3. For a **patched** package (`@intx/inference`): before overwriting - anything, diff the current vendored `src/` against the upstream tag or - commit it was last synced from, to re-derive the exact patch content (do - not trust `PATCHES.md`'s prose alone — diff the code). Then overwrite - `src/` with the new upstream commit's source, and re-apply each patch - from the ledger by hand against the new file shapes. For each patch, - confirm from the new upstream source whether it: (a) still applies - as-is, (b) needs adapting to a changed surrounding shape, or (c) has been - subsumed by an equivalent upstream fix and can be dropped — verify (c) by - reading the new upstream code, never by assumption. Update - `PATCHES.md` to reflect what actually landed, including any patches - dropped as superseded and why. Run the full gate - (`typecheck`/`build`/`test`) and do not consider the sync complete until - it passes clean. + 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 + prose alone. Then overwrite `src/` with the new upstream commit's + source, and re-apply each patch from the ledger by hand against the + new file shapes. For each patch, confirm from the new upstream source + whether it: (a) still applies as-is, (b) needs adapting to a changed + surrounding shape, or (c) has been subsumed by an equivalent upstream + fix and can be dropped — verify (c) by reading the new upstream code, + never by assumption. Update `PATCHES.md` and the site-specific + `Locally patched` markers to reflect what actually landed, including + any patches dropped as superseded and why. Run the full gate + (`typecheck`/`build`/`test`, including + `tests/unit/vendor-patch-ledger.test.ts`) and do not consider the sync + complete until it passes clean. 4. Because `@intx/inference`, `@intx/types`, and `@intx/storage-isogit` are coupled (see above), a re-sync that moves any one of their commit hashes should move all three together, even if only one had code changes worth diff --git a/tests/unit/vendor-patch-ledger.test.ts b/tests/unit/vendor-patch-ledger.test.ts new file mode 100644 index 000000000..cab2c9203 --- /dev/null +++ b/tests/unit/vendor-patch-ledger.test.ts @@ -0,0 +1,155 @@ +/** + * Correspondence between `Locally patched` markers under vendor/ and the + * site-specific headings in each package's PATCHES.md ledger (CL-5720). + * + * Every marker anchor must resolve to a real `## ` heading; every + * ledger heading must have at least one marker. Markers are navigation — + * `bin/vendor-patch-diff` is the authoritative proof of which lines are ours. + */ + +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +const repoRoot = join(import.meta.dirname, "../.."); +const vendorRoot = join(repoRoot, "vendor"); + +const MARKER_RE = + /Locally patched\s*[—-]\s*see\s+(vendor\/[^#\s]+\/PATCHES\.md)#([A-Za-z0-9][A-Za-z0-9_-]*)/g; +const HEADING_RE = /^## ([A-Za-z0-9][A-Za-z0-9_-]*)\s*$/gm; + +async function listFilesRecursive(dir: string): Promise { + const out: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + out.push(...(await listFilesRecursive(full))); + } else if (entry.isFile()) { + out.push(full); + } + } + return out; +} + +async function patchedPackages(): Promise { + const entries = await readdir(vendorRoot, { withFileTypes: true }); + const packages: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const ledger = join(vendorRoot, entry.name, "PATCHES.md"); + try { + await stat(ledger); + packages.push(entry.name); + } catch { + // no ledger + } + } + return packages.sort(); +} + +type Marker = { file: string; anchor: string; line: number }; + +async function collectMarkers(pkgDir: string): Promise { + const srcDir = join(pkgDir, "src"); + let files: string[]; + try { + files = await listFilesRecursive(srcDir); + } catch { + return []; + } + const markers: Marker[] = []; + for (const file of files) { + if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file)) continue; + const text = await readFile(file, "utf8"); + const rel = relative(repoRoot, file); + let match: RegExpExecArray | null; + MARKER_RE.lastIndex = 0; + while ((match = MARKER_RE.exec(text)) !== null) { + const before = text.slice(0, match.index); + const line = before.split("\n").length; + markers.push({ file: rel, anchor: match[2]!, line }); + } + } + return markers; +} + +async function collectHeadings(ledgerPath: string): Promise { + const text = await readFile(ledgerPath, "utf8"); + const headings: string[] = []; + let match: RegExpExecArray | null; + HEADING_RE.lastIndex = 0; + while ((match = HEADING_RE.exec(text)) !== null) { + headings.push(match[1]!); + } + return headings; +} + +describe("vendor patch ledger correspondence (CL-5720)", () => { + test("every Locally patched marker anchor resolves; every ledger heading has a marker", async () => { + const packages = await patchedPackages(); + expect(packages.length).toBeGreaterThan(0); + + const failures: string[] = []; + + for (const pkg of packages) { + const pkgDir = join(vendorRoot, pkg); + const ledgerRel = `vendor/${pkg}/PATCHES.md`; + const ledgerPath = join(repoRoot, ledgerRel); + const headings = await collectHeadings(ledgerPath); + const headingSet = new Set(headings); + const markers = await collectMarkers(pkgDir); + + if (headings.length === 0) { + failures.push(`${ledgerRel}: no ## site-specific headings found`); + } + if (markers.length === 0) { + failures.push(`vendor/${pkg}/src: no Locally patched markers found`); + } + + // Duplicate headings would make anchors ambiguous. + const seen = new Set(); + for (const h of headings) { + if (seen.has(h)) { + failures.push(`${ledgerRel}: duplicate heading #${h}`); + } + seen.add(h); + } + + for (const m of markers) { + const expectedLedger = `vendor/${pkg}/PATCHES.md`; + // Path inside the marker comment must point at this package's ledger. + // Re-check via the raw comment is overkill; anchor membership is enough + // when we only scan this package's src. + if (!headingSet.has(m.anchor)) { + failures.push( + `${m.file}:${m.line}: marker #${m.anchor} has no matching ## heading in ${ledgerRel}`, + ); + } + void expectedLedger; + } + + const markedAnchors = new Set(markers.map((m) => m.anchor)); + for (const h of headings) { + if (!markedAnchors.has(h)) { + failures.push( + `${ledgerRel}: heading #${h} has no Locally patched marker under vendor/${pkg}/src`, + ); + } + } + } + + expect(failures).toEqual([]); + }); + + test("PATCHES.md states that the SHA-diff is authoritative", async () => { + const packages = await patchedPackages(); + for (const pkg of packages) { + const text = await readFile(join(vendorRoot, pkg, "PATCHES.md"), "utf8"); + expect(text.toLowerCase()).toContain("authoritative"); + expect(text).toContain("bin/vendor-patch-diff"); + } + }); +}); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index fb3fce3c0..3defd6145 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -1,145 +1,177 @@ # Patch ledger — vendor/intx-inference -Every divergence from the upstream Interchange source at the commit recorded -in `docs/VENDORING.md`. Each entry is a real bug fix or capability upstream -does not carry; none is a workaround for something upstream has since fixed -(each was re-verified against upstream HEAD when this package was last -synced). A patched file carries a one-line `Locally patched — see -vendor/intx-inference/PATCHES.md#` comment at each patched location so -a diff against upstream shows exactly which lines are ours. - -To re-derive this ledger after a re-sync: diff the pre-sync `src/` against -the new upstream checkout at the same paths; every hunk that survives the -diff is a patch that needs re-applying (or, if upstream has since absorbed -the same fix, dropping — verify by reading the new upstream code, not by -assuming). - -## adapter.ts - -Adds `StreamTerminalDetector`/`ProviderAdapter.isStreamTerminal`. The OpenAI -Responses protocol marks completion with a semantic `response.completed` -event and holds the connection open rather than closing the socket or -sending `[DONE]`; without this, a client reading the stream hangs waiting for -a socket close that never comes. Consumed by `harness.ts`'s SSE loop. - -## assembly.ts - -Resolves `contextTransforms` from either the direct assembly config value or -`deps.contextTransforms` (`resolvedContextTransforms = contextTransforms ?? -deps.contextTransforms`). The published `@intx/agent` forwards `deps` into -reactor assembly verbatim and exposes no dedicated field for transforms; -riding `deps` reaches the vendored assembly without requiring a change to -the published package. - -## errors.ts - -`classifyAbortError` takes an optional `reason` argument and carries it as -`raw: { origin: reason }` on the returned `InferenceError`. `reason` mirrors -`AbortSignal.reason` from the send path (e.g. `user-stop` / +**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-inference/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, and a +marker that drifts over mixed code is still only a marker. + +Every divergence from upstream at that commit is a real bug fix or +capability upstream does not carry; none is a workaround for something +upstream has since fixed (each was re-verified against upstream HEAD when +this package was last synced). Each entry below is a site-specific anchor +matched by one or more markers in `src/`. + +To re-derive this ledger after a re-sync: run `bin/vendor-patch-diff`, then +confirm every hunk still maps to an entry here (or, if upstream has since +absorbed the same fix, drop the entry and its markers — verify by reading +the new upstream code, not by assuming). + +## adapter-ts-stream-terminal-detector + +`adapter.ts` — Adds `StreamTerminalDetector`/`ProviderAdapter.isStreamTerminal`. +The OpenAI Responses protocol marks completion with a semantic +`response.completed` event and holds the connection open rather than closing +the socket or sending `[DONE]`; without this, a client reading the stream +hangs waiting for a socket close that never comes. Consumed by `harness.ts`'s +SSE loop. + +## assembly-ts-deps-context-transforms + +`assembly.ts` — Resolves `contextTransforms` from either the direct assembly +config value or `deps.contextTransforms` (`resolvedContextTransforms = +contextTransforms ?? deps.contextTransforms`). The published `@intx/agent` +forwards `deps` into reactor assembly verbatim and exposes no dedicated field +for transforms; riding `deps` reaches the vendored assembly without requiring +a change to the published package. + +## errors-ts-classify-abort-reason + +`errors.ts` — `classifyAbortError` takes an optional `reason` argument and +carries it as `raw: { origin: reason }` on the returned `InferenceError`. +`reason` mirrors `AbortSignal.reason` from the send path (e.g. `user-stop` / `internal-recovery`), giving callers the abort's origin instead of an undifferentiated "inference aborted". Called with `signal?.reason` from all four abort-check sites in `harness.ts`. -## harness.ts - -Three independent fixes: - -- **Dependencies.contextTransforms** — carries the field `assembly.ts` - reads off `deps` (see above). -- **`classifyAbortError(signal?.reason)`** — passes the abort reason through - at all four sites that classify an aborted signal. -- **Inactivity timer armed only on semantic progress** — the watchdog used to - re-arm on every raw SSE chunk; a provider that sends keep-alive bytes - forever without a terminal event never tripped it, pinning the caller - indefinitely. Now it re-arms only when `adapter.parseResponse` actually - produces events from a chunk. -- **`isStreamTerminal` consulted in the SSE loop** — stops reading once - `adapter.isStreamTerminal?.(sseData)` returns true, for protocols whose - end-of-turn is a semantic event rather than `[DONE]` or socket close. -- **`runInference`'s commitment-boundary streaming redesign** — the - published wrapper buffers an entire attempt and flushes it only once the - attempt's terminal shape (done/error) is known, which means no event - reaches the caller until the whole response has arrived even on a - successful first attempt. The vendored version streams every event to the - caller as it arrives once the attempt "commits" (its first content-bearing - event — the first text/thinking delta, tool call, image, etc.); only the - handful of pre-commit metadata events (`inference.start`, - `inference.usage`) are buffered, so retry stays possible up to the first - real token without holding a whole response in memory. A retryable failure - after commitment can no longer discard already-streamed output, so retry - is suppressed there and the error surfaces on the live stream. See - `isCommitting` and the docblock on `runInference`. - -## reactor.ts - -Five independent fixes, all inside `tryCorrelate` / the cycle-commit path: - -- **`correlatingIds` leak on every successful correlated resume** — the - in-flight marker was deleted on the three failure exits of - `tryCorrelate` but never on the three success dispatch paths - (`redispatch` / `error_result` / `gate-cleared`), leaking one `Set` entry - per correlated message for the life of the process. Wrapped the whole - critical section in `try/finally` so every exit clears it. -- **`ExtendedInferenceOptions.ephemeralTurns`** — turns appended to the - materialized prompt for one inference call only, never written to durable - history, so transient director guidance does not touch the cached - transcript prefix. No native equivalent exists upstream. -- **Checkpoint after a tool cycle that appends to history** — `executeTools` - now calls `commitCycle()` when `addToHistory` is true, so an interrupt - that rebuilds the agent from the store reloads the completed tool - exchange instead of losing an uncommitted tool turn (context previously - committed only at cycle terminals). -- **`afterCheckpoint` fires only for a director-requested checkpoint** — the - hasWork-only auto-commit after `executeTools` is internal durability - plumbing, not a checkpoint the caller asked for; without gating on - `hasOverride` (`pendingMessage !== null`), a director that checkpoints in - a later `decide()` call got `afterCheckpoint` invoked twice for what is, - from its perspective, a single checkpoint. -- **Skip re-serializing unchanged history on checkpoint** — `commitCycle` - now compares `stateManager.getTurnsRevision()` against the revision most - recently written and skips `contextStore.writeTurns` when nothing - changed, avoiding an O(history) re-serialize (including historical - tool-output blobs) on every checkpoint. - -The `void track(p)` → `track(p)` change at three call sites removes a -redundant `void` operator with no behavioral effect (kept from the prior -sync for consistency). +## harness-ts-context-transforms -Two 0.2.2-era patches are **not carried** because upstream HEAD has already -absorbed the underlying fix: an unhandled-rejection guard around -`tryCorrelate` in `deliver()` (upstream's `deliver()` now wraps the whole -correlation dispatch in try/catch and routes failures through -`closeMessageRun`, superseding the vendored version), and a reactor-level -`inference.retry` emission around same-source retry/failover (upstream -moved retry entirely into `harness.ts`'s `runInference` wrapper, which now -emits `inference.retry` itself before the commitment boundary — see -harness.ts above; a reactor-level emission would double the event). +`harness.ts` — `Dependencies.contextTransforms` carries the field +`assembly.ts` reads off `deps` (see assembly-ts-deps-context-transforms). + +## harness-ts-inactivity-on-semantic-progress + +`harness.ts` — Inactivity timer armed only on semantic progress. The watchdog +used to re-arm on every raw SSE chunk; a provider that sends keep-alive bytes +forever without a terminal event never tripped it, pinning the caller +indefinitely. Now it re-arms only when `adapter.parseResponse` actually +produces events from a chunk. + +## harness-ts-is-stream-terminal + +`harness.ts` — `isStreamTerminal` consulted in the SSE loop. Stops reading +once `adapter.isStreamTerminal?.(sseData)` returns true, for protocols whose +end-of-turn is a semantic event rather than `[DONE]` or socket close. + +## harness-ts-commitment-boundary-streaming + +`harness.ts` — `runInference`'s commitment-boundary streaming redesign. The +published wrapper buffers an entire attempt and flushes it only once the +attempt's terminal shape (done/error) is known, which means no event reaches +the caller until the whole response has arrived even on a successful first +attempt. The vendored version streams every event to the caller as it arrives +once the attempt "commits" (its first content-bearing event — the first +text/thinking delta, tool call, image, etc.); only the handful of pre-commit +metadata events (`inference.start`, `inference.usage`) are buffered, so retry +stays possible up to the first real token without holding a whole response in +memory. A retryable failure after commitment can no longer discard +already-streamed output, so retry is suppressed there and the error surfaces +on the live stream. See `isCommitting` and the docblock on `runInference`. + +## harness-ts-is-committing + +`harness.ts` — `isCommitting` helper used by the commitment-boundary redesign +above. Classifies which events count as commitment (everything except +pre-commit metadata). + +## reactor-ts-ephemeral-turns + +`reactor.ts` — `ExtendedInferenceOptions.ephemeralTurns`: turns appended to +the materialized prompt for one inference call only, never written to durable +history, so transient director guidance does not touch the cached transcript +prefix. No native equivalent exists upstream. `index.ts` re-exports the type +(mechanical; no separate marker). + +## reactor-ts-correlating-ids-leak -## sse.ts +`reactor.ts` — `correlatingIds` leak on every successful correlated resume. +The in-flight marker was deleted on the three failure exits of `tryCorrelate` +but never on the three success dispatch paths (`redispatch` / `error_result` / +`gate-cleared`), leaking one `Set` entry per correlated message for the life +of the process. Wrapped the whole critical section in `try/finally` so every +exit clears it. -`MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer and throws -instead of growing unbounded — an unbounded run of bytes with no newline is -indistinguishable from a stuck or hostile stream and would otherwise OOM the -process. +## reactor-ts-checkpoint-after-tool-cycle -## state.ts +`reactor.ts` — Checkpoint after a tool cycle that appends to history. +`executeTools` now calls `commitCycle()` when `addToHistory` is true, so an +interrupt that rebuilds the agent from the store reloads the completed tool +exchange instead of losing an uncommitted tool turn (context previously +committed only at cycle terminals). -`deepFreeze`s appended turns and tracks a `turnsRevision` counter so -`ReactorState.snapshot()`'s `turns` becomes a lazy, memoized getter instead -of a `structuredClone` on every director decision. High-frequency events -(`tool.done`, `inference.error`) reach directors that never inspect `turns`, -so the prior eager deep-clone made per-event cost scale with session length. -`getTurnsRevision()` also backs `reactor.ts`'s checkpoint-skip optimization -above. +## reactor-ts-skip-unchanged-history -## index.ts +`reactor.ts` — Skip re-serializing unchanged history on checkpoint. +`commitCycle` now compares `stateManager.getTurnsRevision()` against the +revision most recently written and skips `contextStore.writeTurns` when +nothing changed, avoiding an O(history) re-serialize (including historical +tool-output blobs) on every checkpoint. -Re-exports `ExtendedInferenceOptions` from `reactor.ts` (mechanical; follows -that file's patch). +## reactor-ts-after-checkpoint-director-only -## providers/google-genai-files.ts +`reactor.ts` — `afterCheckpoint` fires only for a director-requested +checkpoint. The hasWork-only auto-commit after `executeTools` is internal +durability plumbing, not a checkpoint the caller asked for; without gating on +`hasOverride` (`pendingMessage !== null`), a director that checkpoints in a +later `decide()` call got `afterCheckpoint` invoked twice for what is, from +its perspective, a single checkpoint. -Casts `opts.bytes as unknown as BodyInit` — DOM lib's `BodyInit` type is -narrower than Node's `Uint8Array` typing, but `fetch` accepts the bytes at -runtime. Worth filing upstream as a real typing gap rather than carrying -indefinitely. +## reactor-ts-last-written-turns-revision + +`reactor.ts` — `lastWrittenTurnsRevision` state backing the skip-rewrite +optimization (reactor-ts-skip-unchanged-history). Tracks the turns revision +most recently serialized to the context store. + +## sse-ts-max-line-length + +`sse.ts` — `MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer +and throws instead of growing unbounded — an unbounded run of bytes with no +newline is indistinguishable from a stuck or hostile stream and would +otherwise OOM the process. + +## state-ts-deep-freeze-turns-revision + +`state.ts` — `deepFreeze`s appended turns and tracks a `turnsRevision` +counter so `ReactorState.snapshot()`'s `turns` becomes a lazy, memoized +getter instead of a `structuredClone` on every director decision. +High-frequency events (`tool.done`, `inference.error`) reach directors that +never inspect `turns`, so the prior eager deep-clone made per-event cost +scale with session length. `getTurnsRevision()` also backs +`reactor.ts`'s checkpoint-skip optimization. + +## google-genai-files-ts-body-init-cast + +`providers/google-genai-files.ts` — Casts `opts.bytes as unknown as BodyInit` +— DOM lib's `BodyInit` type is narrower than Node's `Uint8Array` typing, but +`fetch` accepts the bytes at runtime. Worth filing upstream as a real typing +gap rather than carrying indefinitely. + +--- + +The `void track(p)` → `track(p)` change at three call sites in `reactor.ts` +removes a redundant `void` operator with no behavioral effect (kept from the +prior sync for consistency); it is not marked. + +Two 0.2.2-era patches are **not carried** because upstream HEAD has already +absorbed the underlying fix: an unhandled-rejection guard around +`tryCorrelate` in `deliver()` (upstream's `deliver()` now wraps the whole +correlation dispatch in try/catch and routes failures through +`closeMessageRun`, superseding the vendored version), and a reactor-level +`inference.retry` emission around same-source retry/failover (upstream moved +retry entirely into `harness.ts`'s `runInference` wrapper, which now emits +`inference.retry` itself before the commitment boundary — see +harness-ts-commitment-boundary-streaming; a reactor-level emission would +double the event). diff --git a/vendor/intx-inference/src/adapter.ts b/vendor/intx-inference/src/adapter.ts index 0e73fd713..608f5cf69 100644 --- a/vendor/intx-inference/src/adapter.ts +++ b/vendor/intx-inference/src/adapter.ts @@ -80,7 +80,7 @@ export type PacingExtractor = (headers: Headers) => number | undefined; // waits for socket close hangs. Adapters for those protocols implement this so // the harness stops reading once the terminal event is processed. // -// Locally patched — see vendor/intx-inference/PATCHES.md#adapter-ts +// Locally patched — see vendor/intx-inference/PATCHES.md#adapter-ts-stream-terminal-detector export type StreamTerminalDetector = (sseData: string) => boolean; export type ProviderAdapter = { diff --git a/vendor/intx-inference/src/assembly.ts b/vendor/intx-inference/src/assembly.ts index 7e8d1f269..ac81c736b 100644 --- a/vendor/intx-inference/src/assembly.ts +++ b/vendor/intx-inference/src/assembly.ts @@ -237,7 +237,7 @@ export function createReactorAssembly( // A direct value wins so callers composing their own assembly are // unaffected by whatever a shared deps object carries. // - // Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts-deps-context-transforms const resolvedContextTransforms = contextTransforms ?? deps.contextTransforms; // exactOptionalPropertyTypes is on: only set optional keys when defined. diff --git a/vendor/intx-inference/src/errors.ts b/vendor/intx-inference/src/errors.ts index cf489ef06..93cc78c86 100644 --- a/vendor/intx-inference/src/errors.ts +++ b/vendor/intx-inference/src/errors.ts @@ -47,7 +47,7 @@ export function classifyNetworkError(cause: unknown): InferenceError { * `origin` mirrors AbortSignal.reason from the send path * (e.g. intercode `user-stop` / `internal-recovery` string literals). * - * Locally patched — see vendor/intx-inference/PATCHES.md#errors-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#errors-ts-classify-abort-reason */ export type ClassifiedAbortRaw = { origin: unknown }; diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index b8d228705..d3ff1985f 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -123,7 +123,7 @@ export type Dependencies = { * field for transforms; riding `deps` reaches the vendored assembly * without modifying the published package. * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-context-transforms */ readonly contextTransforms?: ContextTransform[]; readonly [HarnessId]?: symbol; @@ -537,7 +537,7 @@ async function* runSingleAttempt( // trickles keep-alives forever without a terminal event never trips the // watchdog and pins the caller indefinitely. // - // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-inactivity-on-semantic-progress if (rawEvents.length > 0) { armInactivity(); } @@ -547,7 +547,7 @@ async function* runSingleAttempt( // next read forever. Stop once the terminal event's own events (e.g. // its usage) have been processed above. // - // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-stream-terminal if (adapter.isStreamTerminal?.(sseData)) { return; } @@ -1361,7 +1361,7 @@ async function* runSingleAttempt( * `inference.error` from an attempt the policy chose to retry (only * uncommitted attempts are ever retried). * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-commitment-boundary-streaming * * Caller-visible seqs stay contiguous across retries. Each attempt * runs against a private seq allocator; the wrapper re-stamps every @@ -1612,7 +1612,7 @@ export async function* runInference( * `inference.error`, and `inference.retry` are terminal or wrapper-owned * and are handled by `runInference` before this predicate is consulted. * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-committing */ function isCommitting(event: InferenceEvent): boolean { switch (event.type) { diff --git a/vendor/intx-inference/src/providers/google-genai-files.ts b/vendor/intx-inference/src/providers/google-genai-files.ts index 1bdf855e8..d1fe2e3e5 100644 --- a/vendor/intx-inference/src/providers/google-genai-files.ts +++ b/vendor/intx-inference/src/providers/google-genai-files.ts @@ -170,7 +170,7 @@ export async function uploadGoogleGenAIFile( method: "POST", headers, // DOM lib BodyInit is narrower than Node's Uint8Array typing; fetch accepts bytes. - // Locally patched — see vendor/intx-inference/PATCHES.md#google-genai-files-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#google-genai-files-ts-body-init-cast body: opts.bytes as unknown as BodyInit, }; // `RequestInit.signal` is typed as `AbortSignal | null` under diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index 60480cd38..80279f9da 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -77,7 +77,7 @@ function assertNever(x: never): never { * for one inference only and never written to durable history, so transient * director guidance leaves the cached transcript prefix untouched. * - * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-ephemeral-turns */ export type ExtendedInferenceOptions = InferenceOptions & { ephemeralTurns?: ConversationTurn[]; @@ -473,7 +473,7 @@ export function createReactor(config: ReactorConfig): Reactor { // The success path used to leave the id in the set forever, leaking one // entry per correlated message for the life of the session. // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-correlating-ids-leak try { if (correlationValidator !== undefined) { let valid: boolean; @@ -892,7 +892,7 @@ export function createReactor(config: ReactorConfig): Reactor { // the persisted prefix is well-formed rather than an assistant turn with // unanswered tool calls. // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-checkpoint-after-tool-cycle if (addToHistory) { await commitCycle(); } @@ -980,7 +980,7 @@ export function createReactor(config: ReactorConfig): Reactor { const message = buildCycleMessage(); try { - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-skip-unchanged-history const currentRevision = stateManager.getTurnsRevision(); if (currentRevision !== lastWrittenTurnsRevision) { await contextStore.writeTurns(stateManager.getTurns()); @@ -1010,7 +1010,7 @@ export function createReactor(config: ReactorConfig): Reactor { // action that produced the work) gets afterCheckpoint invoked twice // for what is, from the director's perspective, a single checkpoint. // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-after-checkpoint-director-only if (afterCheckpoint !== undefined && hasOverride) { try { await afterCheckpoint(); @@ -1474,7 +1474,7 @@ export function createReactor(config: ReactorConfig): Reactor { // than re-serializing the entire (potentially large) conversation and its // historical tool-output blobs. // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-last-written-turns-revision let lastWrittenTurnsRevision = 0; async function initiateShutdown(): Promise { diff --git a/vendor/intx-inference/src/sse.ts b/vendor/intx-inference/src/sse.ts index 601355c22..b8dcf5b02 100644 --- a/vendor/intx-inference/src/sse.ts +++ b/vendor/intx-inference/src/sse.ts @@ -19,7 +19,7 @@ const decoder = new TextDecoder(); // memory. The limit is on `buffer.length` (UTF-16 code units), which bounds the // retained string regardless of the source encoding's bytes-per-character. // -// Locally patched — see vendor/intx-inference/PATCHES.md#sse-ts +// Locally patched — see vendor/intx-inference/PATCHES.md#sse-ts-max-line-length const MAX_LINE_LENGTH = 16 * 1024 * 1024; export async function* parseSSE( diff --git a/vendor/intx-inference/src/state.ts b/vendor/intx-inference/src/state.ts index f1d39b44c..642c5471e 100644 --- a/vendor/intx-inference/src/state.ts +++ b/vendor/intx-inference/src/state.ts @@ -22,7 +22,7 @@ export type ReactorStateManager = ReturnType; * deep-cloning the whole history on every director decision. Freezing costs * O(turn size) once at append; cloning cost O(total history) per snapshot. * - * Locally patched — see vendor/intx-inference/PATCHES.md#state-ts + * Locally patched — see vendor/intx-inference/PATCHES.md#state-ts-deep-freeze-turns-revision */ function deepFreeze(value: T): T { if (value === null || typeof value !== "object" || Object.isFrozen(value)) {