diff --git a/migrations/016_workflow_indexes.sql b/migrations/016_workflow_indexes.sql new file mode 100644 index 0000000..129464f --- /dev/null +++ b/migrations/016_workflow_indexes.sql @@ -0,0 +1,24 @@ +-- 016: Indexes for the reads the state-governor work added. +-- +-- Both back queries that now run on hot paths — one on every conjecture page +-- view, one inside the checkout transaction on the money path — and neither had +-- an index behind it. + +-- deriveNextSteps scans every task on a target on every page view, and +-- getTargetProgress's metrics block does four more counts keyed the same way. +-- Without this they are sequential scans of `tasks`, which grows without bound. +CREATE INDEX IF NOT EXISTS idx_tasks_target_status ON tasks (target_id, status); + +-- pendingDecompositionSql resolves a review task from the proposal contribution +-- it reviews, and `review_of` lives inside the spec JSONB. Unindexed, each +-- decomposition contribution on the task costs a full scan of `tasks` — and +-- firstproof-c4 alone has eight. Partial + expression index so it stays small: +-- only review tasks carry the key at all. +-- +-- (The honest fix is a real `review_of_contribution_id` column with a foreign +-- key, which would make this an indexable join instead of a JSONB probe. That +-- is a larger migration touching the eight sites that read spec.review_of, and +-- is deliberately left for its own change.) +CREATE INDEX IF NOT EXISTS idx_tasks_review_of + ON tasks (((spec->>'review_of')::bigint)) + WHERE spec ? 'review_of'; diff --git a/src/executor.ts b/src/executor.ts index f5b23da..785440a 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1627,21 +1627,19 @@ function salvageCrashedRun( ? 'Partial output was captured and is attached for the next agent to continue from.' : 'No partial output survived the failure; the burned spend is recorded so the donation is not lost.'); - // Merge-don't-clobber, exactly as the timeout salvage does. - const prior = task.target_state; - const mergeable = prior == null || (typeof prior === 'object' && !Array.isArray(prior)); - const state_update = - salvage && mergeable - ? { - ...(prior as Record | null | undefined), - crash_salvage: { - task_id: task.task_id, - reason: info.reason, - source, - partial: salvage.slice(0, SALVAGE_STATE_CHARS), - }, - } - : undefined; + // Only our own key, exactly as the timeout salvage does — the server merges + // per key, so echoing the prior state back is both unnecessary and the way a + // stale key outlives the attempt that wrote it. + const state_update = salvage + ? { + crash_salvage: { + task_id: task.task_id, + reason: info.reason, + source, + partial: salvage.slice(0, SALVAGE_STATE_CHARS), + }, + } + : undefined; return { result: { diff --git a/src/operations.ts b/src/operations.ts index 6b7fc77..4440011 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -480,10 +480,15 @@ async function attemptCheckout(devId: string, taskId: string): Promise( `SELECT id, max_cost_cents, status, sensitivity, onboarding_dev_id, - (status = 'locked' AND lock_expires_at < now()) AS lock_lapsed + (status = 'locked' AND lock_expires_at < now()) AS lock_lapsed, + ${pendingDecompositionSql('tasks')} AS decomposition_pending FROM tasks WHERE id = $1`, [taskId], ); @@ -558,11 +563,7 @@ async function attemptCheckout(devId: string, taskId: string): Promise( - `SELECT ${pendingDecompositionSql('t')} AS blocked FROM tasks t WHERE t.id = $1`, - [taskId], - ); - if (pending.rows[0]?.blocked) { + if (task.decomposition_pending) { throw new OpError( CONFLICT, 'decomposition_pending_review', @@ -966,10 +967,21 @@ function boundedProposal(proposed: unknown): unknown { /** Reserved keys in a state_update — routed, never stored in the blob. */ const STATE_FACTS_KEY = 'facts'; const STATE_RETRACT_KEY = '$retract'; +/** + * Platform-written, describing the value stored RIGHT NOW rather than history — + * so it is cleared on every merge and re-added by boundedStateUpdate only if + * this write actually drops something. Otherwise the first overflow would brand + * the working set forever, which is the stickiness this whole change removes. + */ +const STATE_DROPPED_KEY = '_dropped'; export interface ParsedStateUpdate { - /** The merged working set to store on targets.state. */ - state: Record; + /** + * The merged working set to store on targets.state. `unknown`, not an object + * type: a non-object update replaces wholesale, so this really can be a + * scalar or an array. + */ + state: unknown; /** Claims to append to target_facts. */ facts: string[]; /** Working-set keys the agent explicitly dropped. */ @@ -1001,7 +1013,7 @@ export function mergeStateUpdate(existing: unknown, update: unknown): ParsedStat const isPlain = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); if (!isPlain(update)) { - return { state: update as Record, facts: [], retracted: [] }; + return { state: update, facts: [], retracted: [] }; } // Facts may be given as plain strings or as {claim} objects — an agent @@ -1021,57 +1033,87 @@ export function mergeStateUpdate(existing: unknown, update: unknown): ParsedStat ? rawRetract.filter((k): k is string => typeof k === 'string' && k.length > 0) : []; - // A truncation marker is a tombstone the platform wrote, not a working set an - // agent built, so the next real update REPLACES it rather than merging into - // it. Merging would pin `truncated`/`note`/`tail` in place permanently — a - // fresh species of exactly the sticky debris this function exists to end. - const isTombstone = isPlain(existing) && existing.truncated === true && 'tail' in existing; - const base = isPlain(existing) && !isTombstone ? { ...existing } : {}; + const base = isPlain(existing) ? { ...existing } : {}; for (const [k, v] of Object.entries(update)) { if (k === STATE_FACTS_KEY || k === STATE_RETRACT_KEY) continue; base[k] = v; } for (const k of retracted) delete base[k]; - // Never let a retired reserved key survive in the stored blob. + // Never let a reserved key survive in the stored blob. delete base[STATE_FACTS_KEY]; delete base[STATE_RETRACT_KEY]; + delete base[STATE_DROPPED_KEY]; return { state: base, facts, retracted }; } /** - * Bound a state_update to MAX_STATE_BYTES by truncating instead of rejecting. - * By the time a submit carries an oversized state the volunteer's tokens are - * already burned, so failing the whole submit over a size cap would discard - * real work AND leave the real spend unbooked (the old behaviour). Keep the - * TAIL of the serialized state — new material (e.g. a salvage merged in beside - * accumulated state) lands at the end of the object, so the newest content is - * what survives — under an explicit marker, never silently. + * Bound the merged working set to MAX_STATE_BYTES by DROPPING WHOLE KEYS, not + * by slicing the serialized bytes. + * + * Rejecting is not an option: by the time a submit carries an oversized state + * the volunteer's tokens are already burned, so failing over a size cap would + * discard real work and leave real spend unbooked. + * + * This used to keep the byte TAIL of the JSON and store it under + * {truncated, note, tail}. That produced something no agent could read (a raw + * slice is not parseable JSON) and, worse, replaced the working set with an + * object of alien keys — which then forced mergeStateUpdate to duck-type the + * marker so the next real update wouldn't inherit it. Dropping keys keeps the + * stored value a genuine working set at every size, so no special case is + * needed anywhere. What was dropped is named in `_dropped`, never silent. + * + * Byte-preserving mattered when established results lived in this blob. They + * live in target_facts now, outside the cap entirely, so what remains here is + * scratch — and losing the biggest piece of scratch is the cheapest possible + * thing to lose. */ function boundedStateUpdate(proposed: unknown): { state: unknown; truncated: boolean } { - let json: string; + const fits = (v: unknown) => { + try { + return Buffer.byteLength(JSON.stringify(v) ?? 'null') <= MAX_STATE_BYTES; + } catch { + return false; + } + }; try { - json = JSON.stringify(proposed) ?? 'null'; + JSON.stringify(proposed); } catch { return { - state: { truncated: true, note: 'state_update was not JSON-serializable' }, + state: { [STATE_DROPPED_KEY]: [''] }, truncated: true, }; } - if (Buffer.byteLength(json) <= MAX_STATE_BYTES) return { state: proposed, truncated: false }; - // Slice bytes, not chars, so a multibyte-heavy state can't sneak past the cap; - // the wrapper below adds ~200 bytes, so keep comfortable head-room. - const buf = Buffer.from(json, 'utf8'); - const tail = buf.subarray(buf.length - (MAX_STATE_BYTES - 512)).toString('utf8'); - return { - state: { - truncated: true, - note: - `state_update JSON exceeded ${MAX_STATE_BYTES} bytes; the tail (newest content) ` + - `was preserved and the rest dropped`, - tail, - }, - truncated: true, - }; + if (fits(proposed)) return { state: proposed, truncated: false }; + // A non-object oversized state has no keys to drop — there is nothing to keep. + if (!proposed || typeof proposed !== 'object' || Array.isArray(proposed)) { + return { state: { [STATE_DROPPED_KEY]: [''] }, truncated: true }; + } + + // Drop the most expensive keys first, so the largest number of small, + // readable keys survives. + const kept: Record = { ...(proposed as Record) }; + const bySize = Object.keys(kept) + .map((k) => { + let size: number; + try { + size = Buffer.byteLength(JSON.stringify(kept[k]) ?? 'null'); + } catch { + size = Number.POSITIVE_INFINITY; + } + return { k, size }; + }) + .sort((a, b) => b.size - a.size); + const dropped: string[] = []; + for (const { k } of bySize) { + if (fits({ ...kept, [STATE_DROPPED_KEY]: [...dropped, k] })) break; + delete kept[k]; + dropped.push(k); + } + // Everything was too big even alone — keep the marker rather than nothing. + if (!fits({ ...kept, [STATE_DROPPED_KEY]: dropped })) { + return { state: { [STATE_DROPPED_KEY]: dropped }, truncated: true }; + } + return { state: { ...kept, [STATE_DROPPED_KEY]: dropped }, truncated: true }; } /** Per-file cap on code embedded into a review task's prompt. */ @@ -1845,14 +1887,18 @@ export async function submitResult( targetId, JSON.stringify(bounded.state), ]); - // Facts are append-only and deduped by claim, so a replayed submit adds - // nothing and two agents establishing the same thing collapse to one row. - for (const claim of parsed.facts) { + // Append-only and deduped by claim, so a replayed submit adds nothing and + // two agents establishing the same thing collapse to one row. ONE + // statement, not one per claim: this runs while holding FOR UPDATE on the + // targets row, and nothing caps how many claims an agent may send — a + // per-claim round trip would block every concurrent submit on the + // conjecture for as long as the agent cared to make it. + if (parsed.facts.length > 0) { await client.query( `INSERT INTO target_facts (target_id, claim, established_by) - VALUES ($1, $2, $3) + SELECT $1, c, $3 FROM unnest($2::text[]) AS c ON CONFLICT (target_id, md5(claim)) DO NOTHING`, - [targetId, claim, contrib.rows[0].id], + [targetId, parsed.facts, contrib.rows[0].id], ); } } @@ -2627,6 +2673,12 @@ export interface TargetNextSteps { claimable: { id: string; title: string; kind: string; max_cost_cents: number }[]; /** Submitted, waiting on verification or review — work in flight, not lost. */ awaiting_verification: number; + /** + * Open tasks NOT offered because their own decomposition proposal is out for + * peer review. Counted rather than silently omitted — the next step for these + * is reviewing the split, and that review task is claimable in its own right. + */ + awaiting_decomposition_review: number; /** * Tasks whose lock expired: claimed, never finished, back in the pool. A * large number here is the signal that something is systematically failing @@ -2717,8 +2769,11 @@ export async function getTargetProgress(slug: string): Promise { kind: string; max_cost_cents: string; status: string; + blocked: boolean; + listable: boolean; }>( - `SELECT id, title, kind::text AS kind, max_cost_cents, status::text AS status + // `blocked` uses the same predicate the checkout gate does. Without it this + // listing advertised work that checkout then refused — the conjecture page + // was still offering firstproof-c4's "Simulate slim(Δ)" after the gate + // started rejecting it. One definition, so the two can't drift again. + // + // `listable` carries the SAME two exclusions listAvailableTasks applies, + // and for the same reason: this rides an UNAUTHENTICATED page, so a + // per-dev onboarding task (claimable by nobody else) or a non-public task + // on a public conjecture must not be advertised. They stay in the status + // counts — they are real work in flight — but never in `claimable`. + `SELECT id, title, kind::text AS kind, max_cost_cents, status::text AS status, + ${pendingDecompositionSql('tasks')} AS blocked, + (onboarding_dev_id IS NULL AND sensitivity = 'public') AS listable FROM tasks WHERE target_id = $1 AND status IN ('open', 'submitted', 'locked', 'expired') ORDER BY max_cost_cents, created_at`, [targetId], ); - const claimable = rows - .filter((r) => r.status === 'open') - .map((r) => ({ - id: r.id, - title: r.title, - kind: r.kind, - max_cost_cents: Number(r.max_cost_cents), - })); - const awaiting = rows.filter((r) => r.status === 'submitted').length; - const inFlight = rows.filter((r) => r.status === 'locked').length; + // One pass rather than five filters over the same rows. + const claimable: TargetNextSteps['claimable'] = []; + let awaiting = 0; + let inFlight = 0; + let expired = 0; + // Reported rather than silently dropped: a task missing from `claimable` + // because its split is out for review is a fact about the target, not an + // absence. Its review task is itself claimable, so the work is still visible. + let blockedOnReview = 0; + for (const r of rows) { + if (r.status === 'submitted') awaiting++; + else if (r.status === 'locked') inFlight++; + else if (r.status === 'expired') expired++; + else if (r.status === 'open') { + if (r.blocked) blockedOnReview++; + else if (r.listable) { + claimable.push({ + id: r.id, + title: r.title, + kind: r.kind, + max_cost_cents: Number(r.max_cost_cents), + }); + } + } + } return { claimable, awaiting_verification: awaiting, - expired: rows.filter((r) => r.status === 'expired').length, - stalled: claimable.length === 0 && awaiting === 0 && inFlight === 0, + awaiting_decomposition_review: blockedOnReview, + expired, + stalled: claimable.length === 0 && awaiting === 0 && inFlight === 0 && blockedOnReview === 0, }; } diff --git a/src/workunit.ts b/src/workunit.ts index 08a579d..7a55755 100644 --- a/src/workunit.ts +++ b/src/workunit.ts @@ -306,13 +306,6 @@ export function mergeWorkUnitInput(specInput: unknown, targetState: unknown): un // contributions rendered "(no summary)" on the public feed because the executed // program's JSON carried results but no `summary` string, and the feed needed a // manual DB repair. A work unit's submit now always carries a summary. -/** - * Back-compat alias. The implementation moved to src/summary.ts so the control - * plane can share it — operations.ts needs the same fallback for model tasks, - * and cannot import this file (node:child_process) inside the Worker. - */ -export const synthesizeWorkUnitSummary = synthesizeSummary; - /** Pull a well-formed work-unit spec out of a task spec, or null. */ export function extractWorkUnit(spec: unknown): WorkUnitSpec | null { const code = (spec as { code?: unknown } | null)?.code as WorkUnitSpec | undefined; @@ -656,7 +649,7 @@ export class WorkUnitExecutor implements Executor { summary: typeof interpreted.summary === 'string' && interpreted.summary.trim().length > 0 ? interpreted.summary - : synthesizeWorkUnitSummary(task.title, interpreted.result), + : synthesizeSummary(task.title, interpreted.result), actual_cost_cents: 0, // CPU donation — no token spend to book raw_usage: rawUsage(), }; diff --git a/test/contributions.test.ts b/test/contributions.test.ts index 60a3a5f..ae61d5b 100644 --- a/test/contributions.test.ts +++ b/test/contributions.test.ts @@ -224,9 +224,8 @@ describe('contributions / resumable tasks', () => { await checkoutTask(dev, task); // An oversized state_update arrives AFTER the tokens are burned, so - // rejecting the submit would lose both the work and the booking. It is - // truncated (tail kept — the newest content) with an explicit marker, and - // the submit succeeds. + // rejecting the submit would lose both the work and the booking. The + // oversized KEY is dropped by name and the submit succeeds. const sub = await submitResult(dev, task, null, 50, null, { outcome: 'progress', summary: 'y'.repeat(5000), @@ -235,12 +234,13 @@ describe('contributions / resumable tasks', () => { expect(sub.state_truncated).toBe(true); expect(sub.spent_applied).toBe(50); // booked, not rolled back - // The stored state carries the truncation note and preserves the tail — - // where the newest keys land in JSON serialization order. + // Whole keys go, largest first, and what remains is still a real working + // set: the small readable key survives as a VALUE the next agent can read, + // rather than being buried inside a raw byte slice of JSON. const { rows: t } = await pool.query(`SELECT state FROM targets WHERE id = $1`, [target]); - expect(t[0].state.truncated).toBe(true); - expect(t[0].state.note).toContain('exceeded'); - expect(t[0].state.tail).toContain('the frontier moved to 1e9'); + expect(t[0].state.newest).toBe('the frontier moved to 1e9'); + expect(t[0].state._dropped).toEqual(['blob']); // named, never silent + expect(t[0].state.blob).toBeUndefined(); expect(Buffer.byteLength(JSON.stringify(t[0].state))).toBeLessThanOrEqual(64 * 1024); // A very long summary truncates rather than storing whole (as before). @@ -256,6 +256,12 @@ describe('contributions / resumable tasks', () => { }); expect(sub2.state_truncated).toBeUndefined(); const { rows: t2 } = await pool.query(`SELECT state FROM targets WHERE id = $1`, [target]); - expect(t2[0].state).toEqual({ frontier: 'small and tidy' }); + // Merged, so the surviving key from the earlier write is still there — and + // `_dropped` is gone, because it describes the value stored now and this + // write dropped nothing. + expect(t2[0].state).toEqual({ + newest: 'the frontier moved to 1e9', + frontier: 'small and tidy', + }); }); }); diff --git a/test/proposal-loop.test.ts b/test/proposal-loop.test.ts index 699c717..c3df618 100644 --- a/test/proposal-loop.test.ts +++ b/test/proposal-loop.test.ts @@ -139,6 +139,43 @@ describe('a task awaiting decomposition review is not claimable', () => { }); }); +describe('next_steps agrees with the checkout gate', () => { + it('stops advertising a task the gate would refuse, and says why', async () => { + const target = await conjecture('agree'); + const dev = await proposer(); + const task = await createTask(target, { max: 200 }); + const ops = await import('../src/operations.js'); + + let ns = (await ops.getTargetProgress('agree'))!.next_steps; + expect(ns.claimable.map((c) => c.id)).toContain(task); + expect(ns.awaiting_decomposition_review).toBe(0); + + await checkoutTask(dev, task); + await submitResult(dev, task, PROPOSAL, 20, null, { outcome: 'decomposition', summary: 's' }); + + ns = (await ops.getTargetProgress('agree'))!.next_steps; + // The page must not offer work checkout will reject… + expect(ns.claimable.map((c) => c.id)).not.toContain(task); + // …and the omission is reported, not silent. + expect(ns.awaiting_decomposition_review).toBe(1); + // The review task IS claimable — that's the actual next step. + expect(ns.claimable.length).toBe(1); + expect(ns.claimable[0].title).toMatch(/^Review a proposed decomposition/); + expect(ns.stalled).toBe(false); + }); + + it('is not stalled when the only open work is blocked pending review', async () => { + const target = await conjecture('notstalled'); + const dev = await proposer(); + const task = await createTask(target, { max: 200 }); + await checkoutTask(dev, task); + await submitResult(dev, task, PROPOSAL, 20, null, { outcome: 'decomposition', summary: 's' }); + const ops = await import('../src/operations.js'); + const ns = (await ops.getTargetProgress('notstalled'))!.next_steps; + expect(ns.stalled).toBe(false); // there is work — reviewing the split + }); +}); + describe('a contribution never lands on the feed with a blank summary', () => { it('synthesizes from the task title and headline scalars when the agent omits one', async () => { const target = await conjecture('blank'); diff --git a/test/state-governor.test.ts b/test/state-governor.test.ts index 31da172..3a1653b 100644 --- a/test/state-governor.test.ts +++ b/test/state-governor.test.ts @@ -85,8 +85,8 @@ describe('mergeStateUpdate (pure)', () => { }); it('replaces wholesale for a non-object update, as before', () => { - expect(mergeStateUpdate({ a: 1 }, 42).state).toBe(42 as never); - expect(mergeStateUpdate({ a: 1 }, [1, 2]).state).toEqual([1, 2] as never); + expect(mergeStateUpdate({ a: 1 }, 42).state).toBe(42); + expect(mergeStateUpdate({ a: 1 }, [1, 2]).state).toEqual([1, 2]); expect(mergeStateUpdate({ a: 1 }, null).state).toBeNull(); }); @@ -94,22 +94,6 @@ describe('mergeStateUpdate (pure)', () => { expect(mergeStateUpdate(null, { a: 1 }).state).toEqual({ a: 1 }); expect(mergeStateUpdate('legacy', { a: 1 }).state).toEqual({ a: 1 }); }); - - it('replaces a truncation tombstone rather than merging into it', () => { - // The platform writes {truncated, note, tail} when a state blows the cap. - // Merging over it would pin those three keys forever — the same sticky - // debris this whole change exists to stop, reintroduced by the fix. - const tomb = { truncated: true, note: 'exceeded 65536 bytes', tail: '…' }; - expect(mergeStateUpdate(tomb, { frontier: 'small and tidy' }).state).toEqual({ - frontier: 'small and tidy', - }); - // An agent's own `truncated` key (no tail) is ordinary content and merges. - expect(mergeStateUpdate({ truncated: true, cursor: 5 }, { a: 1 }).state).toEqual({ - truncated: true, - cursor: 5, - a: 1, - }); - }); }); describe('facts survive submits that used to clobber them', () => { @@ -147,11 +131,13 @@ describe('facts survive submits that used to clobber them', () => { const dev = await createDev('bloater'); await setBudget(dev, 100_000); await work(conj.id, dev, { facts: ['this must survive'] }); - // A working set well past MAX_STATE_BYTES (64 KB). - await work(conj.id, dev, { blob: 'x'.repeat(80 * 1024) }); + // A working set well past MAX_STATE_BYTES (64 KB), alongside a small key. + await work(conj.id, dev, { blob: 'x'.repeat(80 * 1024), cursor: 42 }); const p = (await getTargetProgress('bigstate'))!; - // The blob got truncated; the fact did not, because it is not in the blob. - expect((p.state as any).truncated).toBe(true); + // The oversized key was dropped by name; the small one and the fact both + // survive — the fact because it was never in the blob at all. + expect((p.state as any)._dropped).toEqual(['blob']); + expect((p.state as any).cursor).toBe(42); expect(p.facts.map((f) => f.claim)).toEqual(['this must survive']); }); }); diff --git a/test/workunit.test.ts b/test/workunit.test.ts index d956ed9..e1fbe38 100644 --- a/test/workunit.test.ts +++ b/test/workunit.test.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { ExecTask } from '../src/executor.js'; +import { synthesizeSummary } from '../src/summary.js'; import { CONTAINER_ENGINE_ENV, containerEngineStatusLine, @@ -9,7 +10,6 @@ import { LEAN4_IMAGE, mergeWorkUnitInput, resolveContainerEngine, - synthesizeWorkUnitSummary, WorkUnitExecutor, } from '../src/workunit.js'; @@ -381,7 +381,7 @@ describe('work-unit summary synthesis', () => { p_slim_gt_delta_n: 1, fifth_field: 99, // beyond the 4-field cap — never included }; - expect(synthesizeWorkUnitSummary('slim_sim n=1000 c=2', result)).toBe( + expect(synthesizeSummary('slim_sim n=1000 c=2', result)).toBe( 'slim_sim n=1000 c=2 — giant_fraction: 0.8, slimness_mean: 2.458, delta_n: -21, p_slim_gt_delta_n: 1', ); }); @@ -396,22 +396,20 @@ describe('work-unit summary synthesis', () => { status: 'ok', count: 7, }; - expect(synthesizeWorkUnitSummary('sweep', result)).toBe('sweep — status: ok, count: 7'); + expect(synthesizeSummary('sweep', result)).toBe('sweep — status: ok, count: 7'); }); it('falls back to the bare title when the result has no headline scalars', () => { - expect(synthesizeWorkUnitSummary('chunk 3/64', { rows: [[1]], deep: { a: 1 } })).toBe( - 'chunk 3/64', - ); - expect(synthesizeWorkUnitSummary('chunk 3/64', 'not an object')).toBe('chunk 3/64'); - expect(synthesizeWorkUnitSummary('chunk 3/64', null)).toBe('chunk 3/64'); + expect(synthesizeSummary('chunk 3/64', { rows: [[1]], deep: { a: 1 } })).toBe('chunk 3/64'); + expect(synthesizeSummary('chunk 3/64', 'not an object')).toBe('chunk 3/64'); + expect(synthesizeSummary('chunk 3/64', null)).toBe('chunk 3/64'); }); it('truncates cleanly at the cap', () => { const result = Object.fromEntries( Array.from({ length: 4 }, (_, i) => [`really_long_field_name_number_${i}`, 1e15 + i]), ); - const s = synthesizeWorkUnitSummary(`a long chunk title ${'x'.repeat(120)}`, result); + const s = synthesizeSummary(`a long chunk title ${'x'.repeat(120)}`, result); expect(s.length).toBeLessThanOrEqual(200); expect(s.endsWith('…')).toBe(true); });