v0.42.45.0 feat(sync): delta-aware cost estimator — stop wedging the daily cron (#2139) (#2224)

* feat(core): shared computeSyncDelta + spend-posture module (#2139)

sync-delta.ts: ONE implementation of "what changed since last_commit",
consumed by both the sync executor and the inline cost estimator so the
gate's dollar figure can't drift from what the sync imports.

spend-posture.ts: spend.posture config + parseUsdLimit/formatUsdLimit
off-switch parsing (off/unlimited/none → Infinity; undefined at the budget
boundary so ledger rows never serialize null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): delta-aware cost estimator + non-TTY auto-defer + per-source failure acks (#2139)

The inline-embed cost gate was a ~400x phantom: it priced the entire tree
whenever the working tree was dirty (always, on an active brain), then blocked
the daily cron with exit 2. Now:

- performSyncInner + the estimator both route through computeSyncDelta, so the
  estimate mirrors execution (fetch-first delta; dirty-but-caught-up tree → $0).
- shouldBlockSync is posture-aware; non-TTY above floor AUTO-DEFERS embeds to
  capped backfill jobs (exit 0) instead of wedging — single shared
  runInlineCostGate on both --all and single-source paths.
- --full prices delta + stale backlog (full sync sweeps it inline).
- off/unlimited on the cost knobs; tokenmax bypasses the backfill cap (still
  ledgered) but never the cooldown.
- --skip-failed/--retry-failed scoped per source; the D15 parallel refusal is
  lifted (the #1939 ledger is per-source + lock-serialized).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(config): register spend-control keys + validate spend.posture (#2139)

Adds spend.posture + the five previously --force-only spend knobs to
KNOWN_CONFIG_KEYS so `config set` accepts them directly (removes the
archaeology the issue complained about), and rejects invalid spend.posture
values at set time with a paste-ready hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reindex,enrich,onboard): spend.posture across the remaining cost gates (#2139)

reindex-code: tokenmax makes the cost gate informational; --max-cost accepts
off/unlimited. enrich + onboard --auto: tokenmax lifts the refuse-without-cap
guardrail and runs UNCAPPED (spend still ledgered by BudgetTracker). Explicit
--max-usd always wins over posture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139)

New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial
tests (auto-defer instead of exit 2, posture, off-switch, format split,
single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off
+ tokenmax-bypass + cooldown-still-refuses; config known-key acceptance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spend-controls): single spend-control surface + ref-map + follow-up TODOs (#2139)

New docs/operations/spend-controls.md (every gate, key, default, off switch,
posture interaction); CLAUDE.md reference-map row; two P3 follow-up TODOs
(measured chunk-count gating, per-source defer granularity). llms bundles
regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(spend): SSRF-harden estimator fetch + complete off/uncapped across reindex/enrich/onboard (#2139)

Ship-stage codex pre-landing review caught four P1s in the secondary cost gates:

- The delta estimator's fetch-first ran `git fetch` through the plain git()
  helper, bypassing the GIT_SSRF_FLAGS + GIT_TERMINAL_PROMPT=0 hardening that
  real sync uses. Added `fetchRemote()` to git-remote.ts (same flags as
  pullRepo) and route the estimator through it — a cost preview / dry-run can
  no longer hit a remote through a less-protected path.
- `reindex --max-cost off`, `enrich --max-usd off`, `onboard --auto --max-usd
  off` were parsed but didn't actually proceed/uncap. Now: explicit off (and
  spend.posture=tokenmax) proceed past the confirmation/missing-cap refusal AND
  run uncapped. enrich threads an Infinity sentinel mapped to "no BudgetTracker
  ceiling" (never raw Infinity → no null in audit rows); reindex/onboard use
  their native undefined=uncapped path. Spend still ledgered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.45.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(KEY_FILES): update sync/embedding/git-remote/reindex entries to post-#2139 truth

document-release pass: the cost-gate entries described the pre-#2139 behavior
(full-tree-ceiling estimator, --skip-failed-rejects-under-parallel, exit-2
confirmation gate). Updated to current truth — delta-aware estimator via the
shared computeSyncDelta, per-source failure acks under parallel, non-TTY
auto-defer (no exit 2), posture-aware shouldBlockSync. Added entries for the
two new core modules (sync-delta.ts, spend-posture.ts) + fetchRemote on
git-remote.ts + reindex --max-cost off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-16 15:08:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 090bb53203
commit 5c49225e4b
28 changed files with 1748 additions and 288 deletions
+9
View File
@@ -29,6 +29,15 @@ describe('KNOWN_CONFIG_KEYS', () => {
expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent');
});
test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => {
expect(KNOWN_CONFIG_KEYS).toContain('spend.posture');
expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd');
expect(KNOWN_CONFIG_KEYS).toContain('sync.federated_v2');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_cooldown_min');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd_per_source_24h');
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd');
});
test('no duplicate entries', () => {
const set = new Set(KNOWN_CONFIG_KEYS);
expect(set.size).toBe(KNOWN_CONFIG_KEYS.length);
+48
View File
@@ -155,6 +155,54 @@ describe('submitEmbedBackfill — 24h spend cap', () => {
expect(result.status).toBe('spend_capped');
expect(result.spendCapUsd).toBe(5);
});
// v0.42.42.0 (#2139): off-switch + tokenmax bypass.
test('cap "off" → submits even at huge spend (Infinity cap never tripped)', async () => {
await engine.setConfig(SPEND_CAP_CONFIG_KEY, 'off');
const result = await submitEmbedBackfill(engine, 'default', {
reason: 'unit',
spend24hFn: async () => 1e9,
});
expect(result.status).toBe('submitted');
});
test('0 falls back to the default cap (off semantics ≠ 0)', async () => {
await engine.setConfig(SPEND_CAP_CONFIG_KEY, '0');
const result = await submitEmbedBackfill(engine, 'default', {
reason: 'unit',
spend24hFn: async () => 25, // == default $25 → capped
});
expect(result.status).toBe('spend_capped');
expect(result.spendCapUsd).toBe(25);
});
test('spend.posture=tokenmax bypasses the cap, marks spendCapBypassed', async () => {
const result = await submitEmbedBackfill(engine, 'default', {
reason: 'unit',
postureOverride: 'tokenmax',
spendCapUsdOverride: 25,
spend24hFn: async () => 100, // way over cap
});
expect(result.status).toBe('submitted');
expect(result.spendCapBypassed).toBe(true);
expect(result.spend24hUsd).toBe(100);
});
test('tokenmax does NOT bypass the cooldown (axis split — churn protection stays)', async () => {
const queue = new MinionQueue(engine);
const job = await queue.add('embed-backfill', { sourceId: 'default' }, {});
await engine.executeRaw(
`UPDATE minion_jobs SET status='completed', finished_at=NOW() - INTERVAL '1 minute' WHERE id=$1`,
[job.id],
);
const result = await submitEmbedBackfill(engine, 'default', {
reason: 'unit',
postureOverride: 'tokenmax',
cooldownMinOverride: 10,
spend24hFn: async () => 1e9,
});
expect(result.status).toBe('cooldown'); // posture lifts the cap, NOT the cooldown
});
});
describe('submitEmbedBackfill — source isolation', () => {
+53
View File
@@ -0,0 +1,53 @@
/**
* v0.42.42.0 (#2139) — `--max-usd off` / `--max-cost off` uncapped-switch pins
* across enrich / onboard / reindex (the T6 secondary cost gates).
*
* The enrich arg parser carries the real logic (off → Infinity sentinel, mapped
* to "no BudgetTracker ceiling" in runEnrichCore), so it gets a direct unit test.
* reindex + onboard detect `off` inline at the CLI dispatch and proceed past the
* confirmation / missing-cap refusal; those are pinned as source-level regression
* guards (the codex pre-landing review found all three half-built — these keep
* them from silently regressing without standing up full CLI+gateway harnesses).
*/
import { describe, test, expect } from 'bun:test';
import { parseArgs } from '../src/commands/enrich.ts';
describe('enrich parseArgs — --max-usd off → uncapped (Infinity sentinel)', () => {
test('off / unlimited / none (case-insensitive) → Infinity', () => {
for (const v of ['off', 'OFF', 'unlimited', 'none', 'None']) {
expect(parseArgs(['--max-usd', v]).maxCostUsd).toBe(Infinity);
}
// --max-cost-usd alias too.
expect(parseArgs(['--max-cost-usd', 'off']).maxCostUsd).toBe(Infinity);
});
test('finite positive number passes through; absent → undefined; garbage → undefined', () => {
expect(parseArgs(['--max-usd', '5']).maxCostUsd).toBe(5);
expect(parseArgs([]).maxCostUsd).toBeUndefined();
expect(parseArgs(['--max-usd', 'abc']).maxCostUsd).toBeUndefined();
expect(parseArgs(['--max-usd', '0']).maxCostUsd).toBeUndefined(); // non-positive ignored
});
});
describe('reindex / onboard off-switch dispatch (regression guards)', () => {
test('reindex-code: --max-cost off proceeds past the confirmation gate', async () => {
const src = await Bun.file(new URL('../src/commands/reindex-code.ts', import.meta.url)).text();
// off sets maxCostOff and the gate proceeds when (tokenmax || maxCostOff).
expect(src).toMatch(/maxCostOff\s*=\s*true/);
expect(src).toMatch(/posture === 'tokenmax' \|\| maxCostOff/);
});
test('onboard: --max-usd off lifts the --auto missing-cap refusal', async () => {
const src = await Bun.file(new URL('../src/commands/onboard.ts', import.meta.url)).text();
expect(src).toMatch(/maxUsdOff\s*=/);
// refusal skipped when (maxUsdOff || tokenmax).
expect(src).toMatch(/maxUsdOff \|\| tokenmax/);
});
test('enrich: uncapped path maps the Infinity sentinel to no BudgetTracker ceiling', async () => {
const src = await Bun.file(new URL('../src/commands/enrich.ts', import.meta.url)).text();
// The sentinel must become `undefined` at the tracker (never raw Infinity,
// which serializes to null in audit rows).
expect(src).toMatch(/opts\.maxCostUsd === Infinity \? undefined/);
expect(src).toMatch(/uncapped \? Infinity : parsed\.maxCostUsd/);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* v0.42.42.0 (#2139) — estimateInlineNewTokens ladder coverage.
*
* The inline cost estimator now MIRRORS EXECUTION (delta, not full-tree
* ceiling) so the gate's dollar figure stops being a ~400x phantom on a busy
* brain. Real temp git repos, no PGLite. estimateInlineNewTokens is exported
* from commands/sync.ts; CHUNKER_VERSION is the live chunker version.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { estimateInlineNewTokens } from '../src/commands/sync.ts';
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
const CURRENT = String(CHUNKER_VERSION);
let repo: string;
function commitAll(msg: string): string {
execSync('git add -A', { cwd: repo, stdio: 'pipe' });
execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' });
return execSync('git rev-parse HEAD', { cwd: repo, stdio: 'pipe' }).toString().trim();
}
function src(over: Partial<{ last_commit: string | null; chunker_version: string | null; config: Record<string, unknown> }> = {}) {
return {
local_path: repo,
config: over.config ?? {},
last_commit: over.last_commit ?? null,
chunker_version: over.chunker_version ?? null,
};
}
beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'gbrain-est-'));
execSync('git init', { cwd: repo, stdio: 'pipe' });
execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' });
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
mkdirSync(join(repo, 'topics'), { recursive: true });
});
afterEach(() => {
if (repo) rmSync(repo, { recursive: true, force: true });
});
describe('estimateInlineNewTokens — ladder', () => {
test('chunker drift → full-tree ceiling even with an empty git delta', () => {
writeFileSync(join(repo, 'topics/a.md'), 'some body content here');
const head = commitAll('base');
// git unchanged (last_commit == HEAD) but chunker drifted → must NOT be 0.
const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: 'STALE-0' })], CURRENT);
expect(r.tokens).toBeGreaterThan(0);
expect(r.estimateKind).toBe('ceiling');
expect(r.ceilingReasons).toContain('chunker_drift');
expect(r.changedSources).toBe(1);
});
test('[D2A headline] HEAD==last_commit + current chunker + DIRTY tree → 0 (unchanged)', () => {
writeFileSync(join(repo, 'topics/a.md'), 'body');
const head = commitAll('base');
// Dirty the tree (untracked scratch + uncommitted edit) — attached sync
// imports nothing, so the estimate must be 0. This is the exact pre-fix
// false-fire shape.
writeFileSync(join(repo, 'scratch.tmp'), 'agent scratch');
writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit');
const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: CURRENT })], CURRENT);
expect(r.tokens).toBe(0);
expect(r.estimateKind).toBe('unchanged');
expect(r.unchangedSources).toBe(1);
});
test('first sync (last_commit null) → full-tree ceiling', () => {
writeFileSync(join(repo, 'topics/a.md'), 'body content');
commitAll('base');
const r = estimateInlineNewTokens([src({ last_commit: null, chunker_version: CURRENT })], CURRENT);
expect(r.tokens).toBeGreaterThan(0);
expect(r.estimateKind).toBe('ceiling');
expect(r.ceilingReasons).toContain('first_sync');
});
test('delta rung: only changed committed files priced; deletes cost 0', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(400));
writeFileSync(join(repo, 'topics/b.md'), 'b'.repeat(400));
const base = commitAll('base');
writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(800)); // modify
rmSync(join(repo, 'topics/b.md')); // delete → 0
commitAll('change');
const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT);
expect(r.estimateKind).toBe('delta');
expect(r.tokens).toBeGreaterThan(0); // a.md priced
// A pure-delete delta would be 0; here a.md modify keeps it > 0. Sanity:
// the magnitude is delta-scale (one ~800-char file), not full-tree.
});
test('delta rung: non-syncable changed file is filtered out (markdown strategy)', () => {
writeFileSync(join(repo, 'topics/a.md'), 'md');
const base = commitAll('base');
writeFileSync(join(repo, 'notes.txt'), 'x'.repeat(4000)); // .txt under markdown strategy → not syncable
commitAll('add txt');
const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT);
// No syncable changes → delta priced at 0 tokens (but the source changed).
expect(r.tokens).toBe(0);
expect(r.estimateKind).toBe('delta');
});
test('syncEnabled:false sources are skipped (neither changed nor unchanged)', () => {
writeFileSync(join(repo, 'topics/a.md'), 'body');
commitAll('base');
const r = estimateInlineNewTokens([src({ last_commit: null, config: { syncEnabled: false } })], CURRENT);
expect(r.tokens).toBe(0);
expect(r.changedSources).toBe(0);
expect(r.unchangedSources).toBe(0);
});
test('mixed: one ceiling source + one unchanged source → estimateKind mixed-or-ceiling, reasons captured', () => {
writeFileSync(join(repo, 'topics/a.md'), 'body');
const head = commitAll('base');
const r = estimateInlineNewTokens(
[
src({ last_commit: null, chunker_version: CURRENT }), // first_sync ceiling
src({ last_commit: head, chunker_version: CURRENT }), // unchanged
],
CURRENT,
);
expect(r.ceilingReasons).toContain('first_sync');
expect(r.unchangedSources).toBe(1);
// hadCeiling true, hadDelta false → 'ceiling' aggregate.
expect(r.estimateKind).toBe('ceiling');
});
test('missing local_path source contributes nothing', () => {
const r = estimateInlineNewTokens(
[{ local_path: null, config: {}, last_commit: null, chunker_version: CURRENT }],
CURRENT,
);
expect(r.tokens).toBe(0);
expect(r.changedSources).toBe(0);
});
});
+115 -28
View File
@@ -1,15 +1,17 @@
/**
* v0.41.31 — `gbrain sync --all` cost-gate wiring regressions (PGLite).
* `gbrain sync` cost-gate wiring regressions (PGLite).
*
* Pure shouldBlockSync / willEmbedSynchronously logic is pinned in
* test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in
* runSync's --all path:
* Pure shouldBlockSync / willEmbedSynchronously / parseUsdLimit logic is pinned
* in test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in
* runSync's --all AND single-source paths:
*
* R-1 (headline): deferred-embed sync --all, non-TTY, with backlog →
* emits gate:'deferred_notice' and NEVER exit 2 (the cron-blocking
* bug this release fixes).
* R-2 (protection): inline-embed sync --all (--serial), non-TTY, above
* floor → still exit 2 with gate:'confirmation_required'.
* emits gate:'deferred_notice' and NEVER exit 2.
* R-2 (v0.42.42.0, #2139): inline sync --all (--serial), non-TTY, above floor
* → AUTO-DEFERS (exit 0, gate:'auto_deferred_embeds') and enqueues an
* embed-backfill job. The exit-2 wedge is gone.
* R-3: chunker drift → full-tree CEILING estimate, auto-defers (not exit 2).
* + posture tokenmax, off-switch, format split (#1784/D3A), single-source gate.
*
* Serial-quarantined: stubs process.exit + console.log (process-global).
*/
@@ -21,10 +23,18 @@ import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runSources } from '../src/commands/sources.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway, __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
import type { ChunkInput } from '../src/core/types.ts';
/** Offline embed stub so inline-proceed paths (posture tokenmax) don't network. */
function stubOfflineEmbed(): void {
__setEmbedTransportForTests(async ({ values }: any) => ({
embeddings: values.map(() => new Array(1536).fill(0)),
usage: { tokens: 0 },
}) as any);
}
let engine: PGLiteEngine;
let repoPath: string;
let headSha: string;
@@ -64,6 +74,7 @@ beforeEach(async () => {
});
afterEach(() => {
__setEmbedTransportForTests(null);
resetGateway();
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
@@ -115,39 +126,43 @@ describe('v0.41.31 — sync --all cost gate wiring', () => {
expect(stdout).toContain('"gate":"deferred_notice"');
}, 60_000);
test('R-2: inline sync --all (--serial) above floor still exit 2', async () => {
test('R-2 (#2139): inline sync --all (--serial) above floor AUTO-DEFERS (exit 0, never exit 2) + enqueues backfill', async () => {
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
// Floor 0 → any nonzero inline cost blocks. Source is unsynced
// (last_commit NULL) so estimateInlineNewTokens sees it as changed →
// full-tree tokens > 0 → costUsd > 0 > floor.
// Floor 0 → any nonzero inline cost trips the gate. Source is unsynced
// (last_commit NULL) → first-sync ceiling > 0 > floor.
await engine.setConfig('sync.cost_gate_min_usd', '0');
// --serial forces inline even with v2 on. --json → non-TTY exit-2 path.
// --serial forces inline even with v2 on. --json → non-TTY path → AUTO-DEFER.
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).toBe(2);
expect(stdout).toContain('"gate":"confirmation_required"');
expect(exitCode).not.toBe(2);
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
expect(stdout).not.toContain('"gate":"confirmation_required"');
// The run PROCEEDED to import (the wedge is gone) — embeds were deferred,
// not blocked. (The embed-backfill enqueue wiring + its graceful
// missing-table tolerance is pinned in embed-backfill-submit.test.ts; the
// minion_jobs table isn't provisioned in this gate-wiring harness.)
expect(stdout).toContain('"sync_status":"first_sync"');
}, 60_000);
test('R-3: inline, git-unchanged source but STALE chunker_version still estimates (not $0)', async () => {
// The unchanged-source short-circuit requires HEAD==last_commit AND clean
// tree AND chunker_version == current. Here git is unchanged but the
// chunker drifted, so the source must NOT be treated as 0 — sync would
// re-chunk + re-embed everything. floor=0 so any nonzero cost blocks.
test('R-3 (#2139): chunker drift → full-tree CEILING estimate, auto-defers (not exit 2)', async () => {
// git unchanged (HEAD==last_commit) but chunker drifted → the source must
// NOT price $0 (sync would re-chunk + re-embed everything). The estimate is
// the full-tree ceiling; the gate auto-defers rather than wedging.
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, 'STALE-0']);
await engine.setConfig('sync.cost_gate_min_usd', '0');
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).toBe(2);
expect(stdout).toContain('"gate":"confirmation_required"');
expect(exitCode).not.toBe(2);
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
expect(stdout).toContain('"estimateKind":"ceiling"');
}, 60_000);
test('R-3 control: inline, git-unchanged + CURRENT chunker_version short-circuits to $0 (no exit 2)', async () => {
// Same setup but chunker_version matches current → the source IS unchanged
// → contributes 0 new-content tokens → below floor → proceeds (no block).
// Proves the short-circuit fires when (and only when) everything matches.
test('R-3 control: git-unchanged + CURRENT chunker → $0 estimate, below floor (no auto-defer)', async () => {
// Mirrors the executor's up_to_date predicate: HEAD==last_commit AND chunker
// matches → 0 new tokens → below floor → proceeds without deferring.
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]);
await engine.setConfig('sync.cost_gate_min_usd', '0');
@@ -155,6 +170,78 @@ describe('v0.41.31 — sync --all cost gate wiring', () => {
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).not.toContain('"gate":"confirmation_required"');
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
expect(stdout).toContain('"estimateKind":"unchanged"');
}, 60_000);
test('headline regression: HEAD==last_commit + DIRTY untracked file → $0, no gate (the false-fire)', async () => {
// The exact pre-fix false-fire: a busy brain's working tree is never
// git-clean, but the commits are caught up. The OLD estimator priced the
// whole tree (158M-token phantom); the new one mirrors execution → $0.
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]);
// Dirty the tree with an untracked non-syncable scratch file (agents/crons
// write constantly) — attached-HEAD sync never imports it.
writeFileSync(join(repoPath, 'scratch.tmp'), 'uncommitted agent scratch');
writeFileSync(join(repoPath, 'topics/foo.md'), 'uncommitted edit, not staged');
await engine.setConfig('sync.cost_gate_min_usd', '0');
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
expect(stdout).toContain('"estimateKind":"unchanged"');
}, 60_000);
test('spend.posture=tokenmax → proceeds inline, gate:posture_tokenmax (informational)', async () => {
stubOfflineEmbed(); // inline embed proceeds — keep it off the network.
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.setConfig('sync.cost_gate_min_usd', '0');
await engine.setConfig('spend.posture', 'tokenmax');
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).toContain('"gate":"posture_tokenmax"');
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
}, 60_000);
test('sync.cost_gate_min_usd=off → floor renders "unlimited", never blocks', async () => {
stubOfflineEmbed();
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.setConfig('sync.cost_gate_min_usd', 'off');
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).toContain('"floorUsd":"unlimited"');
expect(stdout).not.toContain('"gate":"auto_deferred_embeds"');
}, 60_000);
test('format split (#1784/D3A): non-TTY WITHOUT --json emits human text, no JSON envelope', async () => {
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.setConfig('sync.cost_gate_min_usd', '0');
// No --json: above floor in a non-TTY session → human auto-defer text.
const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).not.toContain('"gate":'); // no JSON envelope without --json
expect(stdout.toLowerCase()).toContain('deferring embeds');
expect(stdout).toContain('spend.posture'); // self-describing hint present
}, 60_000);
test('single-source sync gets the same gate (auto-defers above floor, exit 0)', async () => {
await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']);
await engine.setConfig('sync.cost_gate_min_usd', '0');
// Single-source (no --all): unsynced → ceiling > 0 → non-TTY auto-defer.
const { exitCode, stdout } = await runSyncCaptured(['--source', 'vault', '--json', '--no-pull']);
expect(exitCode).not.toBe(2);
expect(stdout).toContain('"gate":"auto_deferred_embeds"');
// The gate now exists on the single-source path (was ungated before
// #2139) and proceeds to import rather than blocking.
expect(stdout.toLowerCase()).toContain('imported');
}, 60_000);
});
+74
View File
@@ -23,6 +23,13 @@ import {
import { lookupEmbeddingPrice } from '../src/core/embedding-pricing.ts';
import { resetGateway } from '../src/core/ai/gateway.ts';
import { estimateTokens } from '../src/core/chunkers/code.ts';
import {
parseUsdLimit,
formatUsdLimit,
usdLimitToCap,
normalizeSpendPosture,
isValidSpendPosture,
} from '../src/core/spend-posture.ts';
describe('Layer 8 D1 — embedding cost model', () => {
// The estimateEmbeddingCostUsd tests assert the gateway-UNCONFIGURED OpenAI
@@ -127,6 +134,73 @@ describe('v0.41.31 — shouldBlockSync (cost-gate decision)', () => {
expect(shouldBlockSync(0.0001, 0, 'inline')).toBe(true);
expect(shouldBlockSync(0, 0, 'inline')).toBe(false);
});
// v0.42.42.0 (#2139): posture + Infinity-floor behavior.
test('spend.posture=tokenmax never blocks, even above floor', () => {
expect(shouldBlockSync(999, 0.5, 'inline', 'tokenmax')).toBe(false);
expect(shouldBlockSync(999, 0, 'inline', 'tokenmax')).toBe(false);
});
test('default posture (gated) preserves the legacy decision', () => {
expect(shouldBlockSync(0.51, 0.5, 'inline', 'gated')).toBe(true);
expect(shouldBlockSync(0.03, 0.5, 'inline', 'gated')).toBe(false);
});
test('off/unlimited floor (Infinity) is never exceeded → never blocks', () => {
expect(shouldBlockSync(999, Infinity, 'inline')).toBe(false);
expect(shouldBlockSync(1e9, Infinity, 'inline', 'gated')).toBe(false);
});
});
describe('v0.42.42.0 (#2139) — spend-posture USD-limit parsing', () => {
test('off / unlimited / none (case-insensitive) → Infinity', () => {
for (const v of ['off', 'OFF', 'unlimited', 'Unlimited', 'none', 'NONE', ' off ']) {
expect(parseUsdLimit(v, 25)).toBe(Infinity);
}
});
test('finite positive numbers pass through', () => {
expect(parseUsdLimit('5', 25)).toBe(5);
expect(parseUsdLimit(0.5, 25)).toBe(0.5);
expect(parseUsdLimit('100000', 25)).toBe(100000);
});
test('0 falls back to default unless allowZero', () => {
expect(parseUsdLimit('0', 25)).toBe(25); // backfill cap: off ≠ 0
expect(parseUsdLimit('0', 0.5, { allowZero: true })).toBe(0); // floor: 0 = block-on-any
});
test('garbage / negative / empty / null → default', () => {
expect(parseUsdLimit('abc', 25)).toBe(25);
expect(parseUsdLimit('-3', 25)).toBe(25);
expect(parseUsdLimit('', 25)).toBe(25);
expect(parseUsdLimit(null, 25)).toBe(25);
expect(parseUsdLimit(undefined, 0.5)).toBe(0.5);
});
test('formatUsdLimit: Infinity → "unlimited" (never the JSON.stringify=null trap), finite passthrough', () => {
expect(formatUsdLimit(Infinity)).toBe('unlimited');
expect(formatUsdLimit(5)).toBe(5);
expect(formatUsdLimit(0)).toBe(0);
// The trap this guards: raw Infinity serializes to null.
expect(JSON.stringify({ cap: Infinity })).toBe('{"cap":null}');
expect(JSON.stringify({ cap: formatUsdLimit(Infinity) })).toBe('{"cap":"unlimited"}');
});
test('usdLimitToCap: Infinity → undefined (no cap), finite passthrough', () => {
expect(usdLimitToCap(Infinity)).toBeUndefined();
expect(usdLimitToCap(10)).toBe(10);
});
test('normalizeSpendPosture: only tokenmax is tokenmax; everything else gated', () => {
expect(normalizeSpendPosture('tokenmax')).toBe('tokenmax');
expect(normalizeSpendPosture('TokenMax')).toBe('tokenmax');
expect(normalizeSpendPosture('gated')).toBe('gated');
expect(normalizeSpendPosture('max')).toBe('gated');
expect(normalizeSpendPosture('')).toBe('gated');
expect(normalizeSpendPosture(null)).toBe('gated');
expect(normalizeSpendPosture(42)).toBe('gated');
});
test('isValidSpendPosture accepts gated/tokenmax (case-insensitive), rejects the rest', () => {
expect(isValidSpendPosture('gated')).toBe(true);
expect(isValidSpendPosture('tokenmax')).toBe(true);
expect(isValidSpendPosture('TokenMax')).toBe(true); // normalized lowercase
expect(isValidSpendPosture('max')).toBe(false);
expect(isValidSpendPosture('')).toBe(false);
expect(isValidSpendPosture(7)).toBe(false);
});
});
describe('Layer 8 D1 — estimateTokens (exported from chunkers/code.ts)', () => {
+154
View File
@@ -0,0 +1,154 @@
/**
* v0.42.42.0 (#2139) — computeSyncDelta unit coverage.
*
* The shared diff/manifest helper that BOTH the sync executor and the inline
* cost estimator route through (so the gate's dollar figure can't drift from
* what the sync imports). Real temp git repos; no PGLite, no env writes
* (R1/R2-clean). The git-runner seam drives the unavailable branches.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, renameSync } from 'fs';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import {
computeSyncDelta,
buildDetachedWorkingTreeManifest,
_setGitRunnerForTests,
} from '../src/core/sync-delta.ts';
let repo: string;
function git(args: string): string {
return execSync(`git ${args}`, { cwd: repo, stdio: 'pipe' }).toString().trim();
}
function commitAll(msg: string): string {
execSync('git add -A', { cwd: repo, stdio: 'pipe' });
execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' });
return git('rev-parse HEAD');
}
beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'gbrain-delta-'));
execSync('git init', { cwd: repo, stdio: 'pipe' });
execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' });
execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' });
mkdirSync(join(repo, 'topics'), { recursive: true });
});
afterEach(() => {
_setGitRunnerForTests(null);
if (repo) rmSync(repo, { recursive: true, force: true });
});
describe('computeSyncDelta — commit diff', () => {
test('A/M/D classified; only committed changes in the manifest', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
writeFileSync(join(repo, 'topics/b.md'), 'b');
const base = commitAll('base');
writeFileSync(join(repo, 'topics/a.md'), 'a-edited'); // modify
writeFileSync(join(repo, 'topics/c.md'), 'c'); // add
rmSync(join(repo, 'topics/b.md')); // delete
const head = commitAll('change');
const r = computeSyncDelta(repo, base, head);
expect(r.status).toBe('ok');
if (r.status !== 'ok') return;
expect(r.manifest.modified).toContain('topics/a.md');
expect(r.manifest.added).toContain('topics/c.md');
expect(r.manifest.deleted).toContain('topics/b.md');
});
test('rename → destination path on the renamed list', () => {
writeFileSync(join(repo, 'topics/old.md'), 'x'.repeat(200));
const base = commitAll('base');
renameSync(join(repo, 'topics/old.md'), join(repo, 'topics/new.md'));
const head = commitAll('rename');
const r = computeSyncDelta(repo, base, head);
expect(r.status).toBe('ok');
if (r.status !== 'ok') return;
expect(r.manifest.renamed.map(x => x.to)).toContain('topics/new.md');
});
test('[D2A] attached HEAD: dirty tracked + untracked files are NOT in the manifest', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
const base = commitAll('base');
const head = git('rev-parse HEAD'); // HEAD == base, no new commits
// Dirty the tree: an uncommitted edit + an untracked scratch file.
writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit');
writeFileSync(join(repo, 'scratch.tmp'), 'untracked');
const r = computeSyncDelta(repo, base, head); // not detached → commit diff only
expect(r.status).toBe('ok');
if (r.status !== 'ok') return;
expect(r.manifest.added).toHaveLength(0);
expect(r.manifest.modified).toHaveLength(0);
expect(r.manifest.deleted).toHaveLength(0);
});
});
describe('computeSyncDelta — detached HEAD merges the working-tree manifest', () => {
test('detached + working-tree changes → merged into the manifest', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
const base = commitAll('base');
// Detach HEAD and dirty the tree.
execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' });
writeFileSync(join(repo, 'topics/a.md'), 'detached edit'); // tracked modify
writeFileSync(join(repo, 'topics/new.md'), 'new'); // untracked add
const r = computeSyncDelta(repo, base, base, { detached: true });
expect(r.status).toBe('ok');
if (r.status !== 'ok') return;
expect(r.manifest.modified).toContain('topics/a.md');
expect(r.manifest.added).toContain('topics/new.md'); // untracked picked up on detached
});
test('buildDetachedWorkingTreeManifest: clean detached tree → empty manifest', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
const base = commitAll('base');
execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' });
const m = buildDetachedWorkingTreeManifest(repo);
expect(m.added).toHaveLength(0);
expect(m.modified).toHaveLength(0);
});
});
describe('computeSyncDelta — fail-open ladder', () => {
test('bogus anchor SHA → unavailable: anchor_missing', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
const head = commitAll('base');
const r = computeSyncDelta(repo, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', head);
expect(r.status).toBe('unavailable');
if (r.status === 'unavailable') expect(r.reason).toBe('anchor_missing');
});
test('non-ancestor anchor still diffs (the #1970 property)', () => {
// git diff A..B is endpoint-tree, no ancestry requirement.
writeFileSync(join(repo, 'topics/a.md'), 'a');
const base = commitAll('base');
// Rewrite history: amend creates a new commit not descended from `base`,
// but `base` is still on disk (reflog) → diffable.
writeFileSync(join(repo, 'topics/a.md'), 'rewritten');
execSync('git add -A && git commit --amend -m rewritten', { cwd: repo, stdio: 'pipe' });
const head = git('rev-parse HEAD');
expect(head).not.toBe(base);
const r = computeSyncDelta(repo, base, head);
expect(r.status).toBe('ok'); // orphaned-but-present anchor is still diffable
});
test('injected git failure on the diff → unavailable: diff_failed', () => {
writeFileSync(join(repo, 'topics/a.md'), 'a');
const base = commitAll('base');
const head = git('rev-parse HEAD');
_setGitRunnerForTests((_repo, args) => {
if (args[0] === 'cat-file') return 'commit'; // anchor reachable
if (args[0] === 'diff') throw new Error('simulated oversized diff / timeout');
return '';
});
const r = computeSyncDelta(repo, base, head);
expect(r.status).toBe('unavailable');
if (r.status === 'unavailable') expect(r.reason).toBe('diff_failed');
});
});
+3 -3
View File
@@ -170,10 +170,10 @@ describe('Bug 9 — sync.ts CLI flag wiring', () => {
// performSync's inner ack path only fires when failedFiles.length > 0
// in the current run. This test pins the up-front ack at the top of
// runSync so the flag means "ack whatever is currently flagged".
// v0.42.42.0 (#2139, D13C): the pre-ack is now scoped PER SOURCE — `--all`
// acks every source, single-source acks only its own.
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
// Ensure the up-front check exists before the syncAll / performSync
// dispatch, gated on skipFailed.
expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?unacknowledgedSyncFailures\(\)[\s\S]*?acknowledgeSyncFailures\(\)/);
expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?syncAll \? acknowledgeFailures\(\) : acknowledgeFailures\(sourceId\)/);
});
test('acknowledgeSyncFailures clears stale failures end-to-end', async () => {