diff --git a/openwiki-facts/source-facts.json b/openwiki-facts/source-facts.json index 9686e0eb..fb4ef78b 100644 --- a/openwiki-facts/source-facts.json +++ b/openwiki-facts/source-facts.json @@ -255,67 +255,67 @@ }, { "name": "parseHermesIncremental", - "evidence": "src/lib/rollout.js:3699" + "evidence": "src/lib/rollout.js:3757" }, { "name": "parseKiroCliIncremental", - "evidence": "src/lib/rollout.js:4403" + "evidence": "src/lib/rollout.js:4461" }, { "name": "parseKimiIncremental", - "evidence": "src/lib/rollout.js:5048" + "evidence": "src/lib/rollout.js:5106" }, { "name": "parseKimiCodeIncremental", - "evidence": "src/lib/rollout.js:5239" + "evidence": "src/lib/rollout.js:5297" }, { "name": "parseCodebuddyIncremental", - "evidence": "src/lib/rollout.js:5463" + "evidence": "src/lib/rollout.js:5521" }, { "name": "parseRoocodeIncremental", - "evidence": "src/lib/rollout.js:5966" + "evidence": "src/lib/rollout.js:6024" }, { "name": "parseZedIncremental", - "evidence": "src/lib/rollout.js:6283" + "evidence": "src/lib/rollout.js:6341" }, { "name": "parseGooseIncremental", - "evidence": "src/lib/rollout.js:6600" + "evidence": "src/lib/rollout.js:6658" }, { "name": "parseDroidIncremental", - "evidence": "src/lib/rollout.js:6998" + "evidence": "src/lib/rollout.js:7056" }, { "name": "parseKilocodeIncremental", - "evidence": "src/lib/rollout.js:7245" + "evidence": "src/lib/rollout.js:7303" }, { "name": "parseOmpIncremental", - "evidence": "src/lib/rollout.js:7381" + "evidence": "src/lib/rollout.js:7439" }, { "name": "parsePiIncremental", - "evidence": "src/lib/rollout.js:7634" + "evidence": "src/lib/rollout.js:7692" }, { "name": "parseCraftIncremental", - "evidence": "src/lib/rollout.js:7918" + "evidence": "src/lib/rollout.js:7976" }, { "name": "parseCopilotIncremental", - "evidence": "src/lib/rollout.js:8282" + "evidence": "src/lib/rollout.js:8340" }, { "name": "parseGrokBuildIncremental", - "evidence": "src/lib/rollout.js:8748" + "evidence": "src/lib/rollout.js:8806" }, { "name": "parseAntigravityIncremental", - "evidence": "src/lib/rollout.js:8920" + "evidence": "src/lib/rollout.js:8978" } ] } diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 3a5af05c..4599eccc 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -3446,6 +3446,7 @@ const HERMES_TOKEN_TOTAL_FIELDS = [ "reasoning_output_tokens", "total_tokens", "billable_total_tokens", + "conversation_count", ]; function hermesRowTotals(row) { @@ -3570,12 +3571,16 @@ async function appendHermesReconciliationRows({ queuePath, queueStatePath, canon } } -// One-time, fail-closed correction for the v0.39.48 adoption gate. That gate +// One-time, per-bucket correction for the v0.39.48 adoption gate. That gate // intentionally avoided replaying legacy aggregate snapshots, which preserved // the grand total but left already-counted mixed-model usage on sessions.model. -// Rebuild only when the authoritative Hermes model rows have exactly the same -// token total as the existing Hermes accumulator; otherwise leave data intact -// and let the normal incremental path catch up before retrying next sync. +// Compares each canonical (model, half-hour) bucket against the existing +// accumulator instead of requiring global grand-total equality: production +// installs cannot satisfy exact equality (source retention deletes ingested +// sessions, live sessions grow between reads). Differing canonical buckets are +// appended as latest-wins correction rows after the consumed upload offset; +// existing buckets with no canonical counterpart that predate retained source +// coverage are zeroed out explicitly. async function reconcileHermesMixedModelUsage({ dbPaths, cursors, queuePath, queueStatePath, hermesState, updatedAt, sqliteOptions }) { if (!cursors || typeof cursors !== "object") return { status: "invalid_cursor" }; const migrations = cursors.migrations && typeof cursors.migrations === "object" ? cursors.migrations : {}; @@ -3604,13 +3609,22 @@ async function reconcileHermesMixedModelUsage({ dbPaths, cursors, queuePath, que } const hourlyState = normalizeHourlyState(cursors.hourly); - const existingTotals = initTotals(); - for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) { - if (parseBucketKey(key).source === "hermes") addTotals(existingTotals, bucket?.totals); + // Retained source coverage boundary: the oldest session start still present in + // any authoritative database. Existing buckets whose hour starts before this + // boundary cannot have a surviving source row, so they are provably orphans. + let retentionBoundaryEpochSec = null; + for (const { rows } of rowsBySource) { + for (const row of rows) { + const startedAt = Number(row.started_at); + if (Number.isFinite(startedAt) && startedAt > 0) { + retentionBoundaryEpochSec = retentionBoundaryEpochSec == null + ? startedAt + : Math.min(retentionBoundaryEpochSec, startedAt); + } + } } const canonicalBuckets = {}; - const authoritativeTotals = initTotals(); for (const { rows } of rowsBySource) { for (const row of rows) { const totals = hermesRowTotals(row); @@ -3621,46 +3635,82 @@ async function reconcileHermesMixedModelUsage({ dbPaths, cursors, queuePath, que const key = bucketKey("hermes", model, hourStart); const bucket = canonicalBuckets[key] ||= { totals: initTotals(), queuedKey: null }; addTotals(bucket.totals, totals); - addTotals(authoritativeTotals, totals); } } - if (!sameHermesTokenTotals(existingTotals, authoritativeTotals)) { - cursors.migrations ||= {}; - cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY] = { - status: "blocked_total_mismatch", - updatedAt, - existingTotalTokens: existingTotals.total_tokens, - authoritativeTotalTokens: authoritativeTotals.total_tokens, - }; - return cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY]; + function bucketHourStartToEpochSec(hourStart) { + const parsedMs = Date.parse(`${hourStart}Z`.replace(/Z+$/, "Z")); + return Number.isFinite(parsedMs) ? parsedMs / 1000 : null; } - for (const key of Object.keys(hourlyState.buckets || {})) { - if (parseBucketKey(key).source === "hermes") delete hourlyState.buckets[key]; - } - for (const key of Object.keys(hourlyState.groupQueued || {})) { - if (key.startsWith("hermes|")) delete hourlyState.groupQueued[key]; - } - const canonicalRows = []; + const existingHermesKeys = new Set(Object.keys(hourlyState.buckets || {}) + .filter((key) => parseBucketKey(key).source === "hermes")); + + const correctionRows = []; + let bucketsCorrected = 0; + let bucketsSkipped = 0; + let orphansZeroed = 0; + let stalePlacementsZeroed = 0; + + // Corrections for canonical buckets that differ from the existing accumulator. for (const [key, bucket] of Object.entries(canonicalBuckets)) { const { model, hourStart } = parseBucketKey(key); + const existingBucket = hourlyState.buckets[key]; + if (existingBucket && sameHermesTokenTotals(existingBucket?.totals, bucket.totals)) { + bucketsSkipped += 1; + continue; + } bucket.queuedKey = totalsKey(bucket.totals); hourlyState.buckets[key] = bucket; - canonicalRows.push(JSON.stringify({ + correctionRows.push(JSON.stringify({ source: "hermes", model, hour_start: hourStart, ...bucket.totals, })); + bucketsCorrected += 1; + } + + // Zero out existing buckets with no canonical counterpart. Outside retained + // coverage they are provably orphans (source rows deleted after ingestion). + // Inside coverage they are stale placements: the incremental path spread a + // session's cumulative totals across observation-time buckets while the + // canonical view places them at the session's current last_seen bucket. + // Latest-wins upserts make the zero row retire the stale placement without + // rewriting queue history; unfinished sessions re-emit growth at their new + // bucket on the next incremental sync. + for (const key of existingHermesKeys) { + if (canonicalBuckets[key]) continue; + const { model, hourStart } = parseBucketKey(key); + const existingBucketTotal = Number(hourlyState.buckets[key]?.totals?.total_tokens || 0); + if (existingBucketTotal === 0) continue; + const hourStartSec = bucketHourStartToEpochSec(hourStart); + const provablyOrphaned = hourStartSec != null + && retentionBoundaryEpochSec != null + && hourStartSec < retentionBoundaryEpochSec; + const zeroTotals = initTotals(); + hourlyState.buckets[key] = { totals: zeroTotals, queuedKey: totalsKey(zeroTotals) }; + correctionRows.push(JSON.stringify({ + source: "hermes", + model, + hour_start: hourStart, + ...zeroTotals, + })); + if (provablyOrphaned) { + orphansZeroed += 1; + } else { + stalePlacementsZeroed += 1; + } } - await appendHermesReconciliationRows({ - queuePath, - queueStatePath, - canonicalRows, - updatedAt, - }); + if (correctionRows.length > 0) { + await appendHermesReconciliationRows({ + queuePath, + queueStatePath, + canonicalRows: correctionRows, + updatedAt, + }); + } const nextHermesState = hermesState && typeof hermesState === "object" ? hermesState : {}; for (const { profileName, rows } of rowsBySource) { @@ -3677,13 +3727,21 @@ async function reconcileHermesMixedModelUsage({ dbPaths, cursors, queuePath, que ); } } + for (const key of Object.keys(hourlyState.groupQueued || {})) { + if (key.startsWith("hermes|")) delete hourlyState.groupQueued[key]; + } cursors.hourly = hourlyState; cursors.migrations ||= {}; cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY] = { status: "applied", appliedAt: updatedAt, - totalTokens: authoritativeTotals.total_tokens, - bucketsRebuilt: canonicalRows.length, + totalTokens: Object.values(canonicalBuckets).reduce( + (sum, bucket) => sum + Number(bucket.totals.total_tokens || 0), 0), + bucketsRebuilt: Object.keys(canonicalBuckets).length, + bucketsCorrected, + bucketsSkipped, + orphansZeroed, + stalePlacementsZeroed, }; return cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY]; } diff --git a/test/rollout-parser.test.js b/test/rollout-parser.test.js index 3788af07..7441ace8 100644 --- a/test/rollout-parser.test.js +++ b/test/rollout-parser.test.js @@ -3152,11 +3152,339 @@ test("parseHermesIncremental reconciles already-ingested mixed-model Hermes buck dbPath, cursors: mismatchCursors, queuePath: mismatchQueuePath, + queueStatePath, reconcileHistorical: true, }); assert.equal(mismatchResult.eventsAggregated, 0); - assert.equal(mismatchCursors.migrations.hermesMixedModelReconciliationV1.status, "blocked_total_mismatch"); - assert.equal(await fs.readFile(mismatchQueuePath, "utf8"), `${mismatchRow}\n`, "mismatch must not rewrite queue history"); + // Per-bucket semantics: the differing bucket is corrected via an appended + // latest-wins row; queue history behind the offset stays untouched. + assert.equal(mismatchCursors.migrations.hermesMixedModelReconciliationV1.status, "applied"); + // Original queue rows behind the offset must remain byte-identical. + assert.equal( + (await fs.readFile(mismatchQueuePath, "utf8")).slice(0, `${mismatchRow}\n`.length), + `${mismatchRow}\n`, + "mismatch must not rewrite queue history", + ); + const correctedLatest = new Map(); + for (const row of await readJsonLines(mismatchQueuePath)) { + if (row.source === "hermes") correctedLatest.set(`${row.model}|${row.hour_start}`, row); + } + assert.equal(correctedLatest.get(`gpt-5.6-sol|${bucketStart}`)?.total_tokens, 1150); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("parseHermesIncremental per-bucket reconciliation appends only differing buckets without requiring global equality", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tt-hermes-per-bucket-")); + try { + const dbPath = path.join(tmp, "state.db"); + const queuePath = path.join(tmp, "queue.jsonl"); + const queueStatePath = path.join(tmp, "queue.state.json"); + // Session inside retained coverage (started after retention boundary). + const startedAt = 1775993700; + const endedAt = 1775994000; + const bucketStart = new Date(Math.floor(endedAt / 1800) * 1800 * 1000).toISOString(); + // Retention boundary: DB keeps sessions from this epoch onward. + const retentionBoundaryEpoch = startedAt - 3600; + createHermesDb(dbPath, [{ + id: "misattributed", + model: "gpt-5.6-sol", + started_at: startedAt, + ended_at: endedAt, + input_tokens: 1000, + output_tokens: 100, + cache_read_tokens: 500, + cache_write_tokens: 20, + reasoning_tokens: 30, + message_count: 10, + }]); + cp.execFileSync("sqlite3", [dbPath, ` + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + first_seen REAL, + last_seen REAL + ); + INSERT INTO session_model_usage + (session_id, model, api_call_count, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, first_seen, last_seen) + VALUES + ('misattributed', 'gpt-5.6-sol', 7, 700, 70, 350, 10, 20, ${startedAt}, ${endedAt}), + ('misattributed', 'gpt-5.6-terra', 3, 300, 30, 150, 10, 10, ${startedAt}, ${endedAt}); + `]); + + const legacyTotals = { + input_tokens: 1000, + cached_input_tokens: 500, + cache_creation_input_tokens: 20, + output_tokens: 100, + reasoning_output_tokens: 30, + total_tokens: 1650, + billable_total_tokens: 1650, + conversation_count: 10, + }; + // Orphan bucket predates the retention boundary: no canonical counterpart will exist. + const orphanBucketStart = new Date(retentionBoundaryEpoch * 1000).toISOString(); + const orphanTotals = { ...legacyTotals }; + // Existing sol bucket differs from canonical split; terra bucket absent entirely. + const existingTotals = { ...legacyTotals }; + const cursors = { + version: 1, + hermes: { modelUsageVersion: 1 }, + hourly: { + version: 3, + buckets: { + [`hermes|gpt-5.6-sol|${bucketStart}`]: { totals: existingTotals, queuedKey: "legacy" }, + [`hermes|gpt-5.5|${orphanBucketStart}`]: { totals: orphanTotals, queuedKey: "legacy" }, + }, + groupQueued: {}, + }, + }; + const originalRows = [ + JSON.stringify({ source: "hermes", model: "gpt-5.6-sol", hour_start: bucketStart, ...existingTotals }), + JSON.stringify({ source: "hermes", model: "gpt-5.5", hour_start: orphanBucketStart, ...orphanTotals }), + ]; + const originalQueue = `${originalRows.join("\n")}\n`; + await fs.writeFile(queuePath, originalQueue); + await fs.writeFile(queueStatePath, `${JSON.stringify({ offset: originalQueue.length, retained: "must-survive" })}\n`); + + const result = await parseHermesIncremental({ + dbPath, + cursors, + queuePath, + queueStatePath, + reconcileHistorical: true, + }); + assert.equal(result.eventsAggregated, 0); + assert.equal(cursors.migrations?.hermesMixedModelReconciliationV1?.status, "applied"); + + const latest = new Map(); + for (const row of await readJsonLines(queuePath)) { + if (row.source === "hermes") latest.set(`${row.model}|${row.hour_start}`, row); + } + const sol = latest.get(`gpt-5.6-sol|${bucketStart}`); + const terra = latest.get(`gpt-5.6-terra|${bucketStart}`); + const orphan = latest.get(`gpt-5.5|${orphanBucketStart}`); + assert.ok(sol, "differing bucket must get a correction row"); + assert.equal(sol.total_tokens, 1150); + assert.ok(terra, "missing bucket must get a correction row"); + assert.equal(terra.total_tokens, 500); + assert.ok(orphan, "orphan key stays resolvable via its zero-out row"); + assert.equal(orphan.total_tokens, 0, "orphan outside retained coverage must be zeroed out"); + assert.equal(sol.conversation_count, 7); + assert.equal(terra.conversation_count, 3); + + const resetQueueState = JSON.parse(await fs.readFile(queueStatePath, "utf8")); + assert.equal(resetQueueState.offset, originalQueue.length, "corrections must remain after the already-uploaded queue offset"); + assert.equal(resetQueueState.retained, "must-survive"); + const uploadableTail = (await fs.readFile(queuePath, "utf8")).slice(resetQueueState.offset); + assert.ok(uploadableTail.includes('"model":"gpt-5.6-sol"')); + assert.ok(uploadableTail.includes('"model":"gpt-5.6-terra"')); + assert.ok(uploadableTail.includes('"model":"gpt-5.5"')); + + // Idempotent rerun with reconcileHistorical again. + const beforeSecondRun = (await readJsonLines(queuePath)).length; + await parseHermesIncremental({ + dbPath, + cursors, + queuePath, + queueStatePath, + reconcileHistorical: true, + }); + assert.equal((await readJsonLines(queuePath)).length, beforeSecondRun, "second run must not append new rows"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("parseHermesIncremental per-bucket reconciliation zeroes stale observation placements of long-running sessions", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tt-hermes-stale-placement-")); + try { + const dbPath = path.join(tmp, "state.db"); + const queuePath = path.join(tmp, "queue.jsonl"); + const queueStatePath = path.join(tmp, "queue.state.json"); + // A long-running session whose incremental sync spread cumulative totals + // across two observation buckets; canonical places everything at the last one. + const startedAt = 1775993700; + const endedAt = startedAt + 7200; + const firstObservedBucket = new Date(Math.floor((endedAt - 3600) / 1800) * 1800 * 1000).toISOString(); + const finalBucket = new Date(Math.floor(endedAt / 1800) * 1800 * 1000).toISOString(); + createHermesDb(dbPath, [{ + id: "long-runner", + model: "gpt-5.6-sol", + started_at: startedAt, + ended_at: endedAt, + input_tokens: 700, + output_tokens: 70, + cache_read_tokens: 350, + cache_write_tokens: 10, + reasoning_tokens: 20, + message_count: 7, + }]); + cp.execFileSync("sqlite3", [dbPath, ` + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + first_seen REAL, + last_seen REAL + ); + INSERT INTO session_model_usage VALUES + ('long-runner', 'gpt-5.6-sol', 7, 700, 70, 350, 10, 20, ${startedAt}, ${endedAt - 60}); + `]); + const staleTotals = { + input_tokens: 400, cached_input_tokens: 200, cache_creation_input_tokens: 10, + output_tokens: 40, reasoning_output_tokens: 12, + total_tokens: 662, billable_total_tokens: 662, conversation_count: 4, + }; + const cursors = { + version: 1, + hermes: { modelUsageVersion: 1 }, + hourly: { + version: 3, + buckets: { + [`hermes|gpt-5.6-sol|${firstObservedBucket}`]: { totals: { ...staleTotals }, queuedKey: "legacy" }, + }, + groupQueued: {}, + }, + }; + const originalQueue = `${JSON.stringify({ source: "hermes", model: "gpt-5.6-sol", hour_start: firstObservedBucket, ...staleTotals })}\n`; + await fs.writeFile(queuePath, originalQueue); + await fs.writeFile(queueStatePath, `${JSON.stringify({ offset: originalQueue.length })}\n`); + + const result = await parseHermesIncremental({ + dbPath, + cursors, + queuePath, + queueStatePath, + reconcileHistorical: true, + }); + assert.equal(result.eventsAggregated, 0); + assert.equal(cursors.migrations?.hermesMixedModelReconciliationV1?.status, "applied"); + assert.ok((cursors.migrations.hermesMixedModelReconciliationV1.stalePlacementsZeroed ?? 0) >= 1); + + const latest = new Map(); + for (const row of await readJsonLines(queuePath)) { + if (row.source === "hermes") latest.set(`${row.model}|${row.hour_start}`, row); + } + assert.equal( + latest.get(`gpt-5.6-sol|${firstObservedBucket}`)?.total_tokens, 0, + "stale observation placement must be retired with a zero-out latest-wins row", + ); + assert.equal(latest.get(`gpt-5.6-sol|${finalBucket}`)?.total_tokens, 1150); + // Latest-wins Day view conserves the session total across both buckets. + assert.equal(0 + 1150, 1150); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("parseHermesIncremental per-bucket reconciliation skips buckets that already match and tolerates live growth", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tt-hermes-live-growth-")); + try { + const dbPath = path.join(tmp, "state.db"); + const queuePath = path.join(tmp, "queue.jsonl"); + const startedAt = 1775993700; + const endedAt = 1775994000; + const bucketStart = new Date(Math.floor(endedAt / 1800) * 1800 * 1000).toISOString(); + createHermesDb(dbPath, [{ + id: "stable", + model: "gpt-5.6-sol", + started_at: startedAt, + ended_at: endedAt, + input_tokens: 700, + output_tokens: 70, + cache_read_tokens: 350, + cache_write_tokens: 10, + reasoning_tokens: 20, + message_count: 7, + }, { + id: "live", + model: "gpt-5.6-terra", + started_at: startedAt + 60, + ended_at: null, + input_tokens: 100, + output_tokens: 10, + cache_read_tokens: 50, + cache_write_tokens: 0, + reasoning_tokens: 5, + message_count: 1, + }]); + cp.execFileSync("sqlite3", [dbPath, ` + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + first_seen REAL, + last_seen REAL + ); + INSERT INTO session_model_usage + (session_id, model, api_call_count, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, first_seen, last_seen) + VALUES + ('stable', 'gpt-5.6-sol', 7, 700, 70, 350, 10, 20, ${startedAt}, ${endedAt}), + ('live', 'gpt-5.6-terra', 1, 100, 10, 50, 0, 5, ${startedAt + 60}, ${endedAt - 30}); + `]); + // The 'live' session is unfinished in DB but was ingested earlier with smaller totals; + // its authoritative total has since grown — global equality can never hold, yet the + // stable sol bucket matches exactly and must not be re-appended. + const matchedTotals = { + input_tokens: 700, + cached_input_tokens: 350, + cache_creation_input_tokens: 10, + output_tokens: 70, + reasoning_output_tokens: 20, + total_tokens: 1150, + billable_total_tokens: 1150, + conversation_count: 7, + }; + const cursors = { + version: 1, + hermes: { modelUsageVersion: 1 }, + hourly: { + version: 3, + buckets: { + [`hermes|gpt-5.6-sol|${bucketStart}`]: { totals: { ...matchedTotals }, queuedKey: "legacy" }, + }, + groupQueued: {}, + }, + }; + const originalQueue = `${JSON.stringify({ source: "hermes", model: "gpt-5.6-sol", hour_start: bucketStart, ...matchedTotals })}\n`; + await fs.writeFile(queuePath, originalQueue); + const queueStatePath = path.join(tmp, "queue.state.json"); + await fs.writeFile(queueStatePath, `${JSON.stringify({ offset: originalQueue.length })}\n`); + + const result = await parseHermesIncremental({ + dbPath, + cursors, + queuePath, + queueStatePath, + reconcileHistorical: true, + }); + assert.equal(cursors.migrations?.hermesMixedModelReconciliationV1?.status, "applied"); + // Only the live/changed bucket may be appended; the matched bucket must be skipped. + const appended = (await readJsonLines(queuePath)).slice((await fs.readFile(queueStatePath, "utf8") ? JSON.parse(await fs.readFile(queueStatePath, "utf8")).offset : originalQueue.length)); + const tailText = (await fs.readFile(queuePath, "utf8")).slice(JSON.parse(await fs.readFile(queueStatePath, "utf8")).offset); + for (const line of tailText.split("\n").filter(Boolean)) { + const row = JSON.parse(line); + assert.notEqual(row.model, "gpt-5.6-sol", "matching bucket must not be re-appended"); + } + assert.ok(tailText.includes('"model":"gpt-5.6-terra"'), "changed bucket must be corrected"); } finally { await fs.rm(tmp, { recursive: true, force: true }); }