diff --git a/src/executor.ts b/src/executor.ts index 5cfa3fd..f5b23da 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -638,6 +638,11 @@ Do the task rigorously and respond with ONLY a single JSON object matching the r EXECUTION REALITY — you cannot run code. This run has no shell, no interpreter, and no sandbox; the only tool you have is writing the PROGRESS.md file described below. Reasoning, analysis, proof work, and mathematics you can and should do directly. But if the deliverable requires EXECUTING code (running a search, a simulation, a numerical sweep), do not pretend to run it and never present imagined program output as computed fact — the correct deliverable is a decomposition (below): one subtask that WRITES a small, reviewable program (a code contribution that gets human-reviewed, merged, and pinned by commit SHA), then sandboxed chunk subtasks that actually execute it on donated CPU. +WHAT YOU ESTABLISH OUTLIVES YOU — the next agent on this problem sees the target's working set, not your reasoning. Two keys inside "state_update" control it, and it is MERGED per key, so send only what you changed and never echo back the whole thing: + "facts": [""] — anything this run SETTLED: a range verified, a case closed, a bound proved, an approach ruled out with the reason. These are appended permanently and cannot be overwritten by a later run, so they are where a result belongs if it should still be true in a month. Write them so they stand alone, with the numbers in them ("delta_n < 0 for all n <= 10^6 at c in {2,4}"), not as references to this task. A dead end IS a fact worth recording — it stops the next agent repeating it. + "$retract": [""] — delete a working-set key that is no longer true: a plan that has been carried out, scaffolding from an attempt that failed, a "NEXT: ..." note describing work now done. Omitting a key leaves it in place, so stale scaffolding survives forever unless you name it here. If you notice the working set describing a next step you just completed or disproved, retract it. +Ordinary keys (cursors, partial tables, scratch) stay in "state_update" as before and simply overwrite their previous value. + CODE CONTRIBUTIONS — when the task's deliverable is code (a search program, a verifier, a tool), add a "code_contribution" key to your JSON object: "code_contribution": {"title": "", "description": "", "files": [{"path": "", "content": ""}]} diff --git a/src/operations.ts b/src/operations.ts index de3d651..6b7fc77 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -1,6 +1,7 @@ import { type Client, query, withTransaction } from './db.js'; import { recordEvent } from './funnel.js'; import { blockAt, ONBOARDING_CANDIDATES, ONBOARDING_MAX_CENTS } from './goldbach.js'; +import { synthesizeSummary } from './summary.js'; /** * Domain error carrying the HTTP status the server layer should surface. Lets @@ -541,6 +542,36 @@ 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) { + throw new OpError( + CONFLICT, + 'decomposition_pending_review', + 'This task already has a decomposition proposal awaiting peer review. ' + + 'Review that proposal instead — re-proposing spends credit on a split ' + + 'nobody has ruled on yet.', + ); + } + // 3. Claim the task. Guard on status='open' so a concurrent winner causes // 0 rows affected -> 409. const claim = await client.query( @@ -1701,12 +1732,18 @@ export async function submitResult( ...(submittedCode ? { code_contribution: boundedProposal(submittedCode) } : {}), } : artifact; + // Never write a blank feed row. An agent that omits `summary` used to land + // an empty string on the public feed — 8 of firstproof-c4's contributions + // read as blank lines, including real analytical results. Work units have + // had a synthesized fallback since v0.3.9; model tasks never did. The + // synthesized line is the task title plus a few headline scalars, which is + // always more use to a reader than nothing. const bookedSummary = salvage ? `Proposed a decomposition that failed validation: ${salvage.errors.join('; ')}`.slice( 0, MAX_SUMMARY_CHARS, ) - : summary; + : summary || synthesizeSummary(upd.rows[0].title, result ?? artifact); const contrib = await client.query<{ id: number }>( `INSERT INTO contributions (task_id, target_id, dev_id, outcome, summary, artifact_uri, artifact, cost_cents, raw_usage) @@ -2387,12 +2424,33 @@ export async function getDevStats(devId: string): Promise { }; } +/** + * SQL predicate: this task has a decomposition proposal still awaiting peer + * review. Such a task is not real work for anyone — the next step is reviewing + * the proposal, not producing another one — so it is refused at checkout and + * hidden from both pool listings. `` is the tasks alias at the call site. + */ +function pendingDecompositionSql(alias: string): string { + return `EXISTS ( + SELECT 1 FROM contributions c + WHERE c.task_id = ${alias}.id + AND c.outcome = 'decomposition' + AND EXISTS ( + SELECT 1 FROM tasks r + WHERE r.spec ? 'review_of' + AND jsonb_typeof(r.spec->'review_of') = 'number' + AND (r.spec->>'review_of')::bigint = c.id + AND NOT EXISTS (SELECT 1 FROM contributions rc WHERE rc.task_id = r.id) + ) + )`; +} + export async function listOpenTasks(filter: OpenTaskFilter = {}): Promise { // A task under a lapsed lock belongs in this listing — reclaim before // reading so stranded work is visible to the next poll, not just to the // 5-minute cron sweep. await reclaimLapsedLocks(); - const conditions: string[] = [`status = 'open'`]; + const conditions: string[] = [`status = 'open'`, `NOT ${pendingDecompositionSql('tasks')}`]; const params: unknown[] = []; if (filter.maxCostCents !== undefined) { @@ -3035,6 +3093,7 @@ export async function listAvailableTasks( FROM tasks k JOIN targets t ON t.id = k.target_id WHERE k.status = 'open' + AND NOT ${pendingDecompositionSql('k')} AND k.sensitivity = 'public' -- Onboarding tasks are minted per dev, so they are not "available" to -- browse: showing them would advertise work nobody else can claim. diff --git a/src/summary.ts b/src/summary.ts new file mode 100644 index 0000000..57dade1 --- /dev/null +++ b/src/summary.ts @@ -0,0 +1,44 @@ +// Feed-line synthesis. Pure string work, no Node built-ins — operations.ts runs +// in the Cloudflare Worker, so this cannot live next to the sandbox code in +// workunit.ts (which imports node:child_process and would drag it into the +// Worker bundle). +// +// Every contribution on the public feed needs a line a reader can act on. When +// whatever produced it supplied no summary of its own, we build one from the +// task title plus a few headline scalars, rather than writing a blank row. + +const SUMMARY_MAX_CHARS = 200; +const SUMMARY_MAX_FIELDS = 4; +const SUMMARY_MAX_VALUE_CHARS = 40; +/** Envelope plumbing — never headline material for a human reading the feed. */ +const SUMMARY_SKIP_KEYS = new Set(['summary', 'outcome', 'state_update', 'artifact_uri']); + +/** + * A readable feed line for a result whose producer gave no summary: the task + * title plus up to a few headline scalar fields. Deterministic and generic — + * shallow numeric/boolean/short-string values in the object's own key order, + * skipping arrays, nested objects, and huge strings — so any output shape + * produces something readable without the platform knowing its schema. + */ +export function synthesizeSummary(title: string, result: unknown): string { + const fields: string[] = []; + if (result !== null && typeof result === 'object' && !Array.isArray(result)) { + for (const [key, value] of Object.entries(result as Record)) { + if (fields.length >= SUMMARY_MAX_FIELDS) break; + if (SUMMARY_SKIP_KEYS.has(key)) continue; + if (typeof value === 'number' && Number.isFinite(value)) { + fields.push(`${key}: ${value}`); + } else if (typeof value === 'boolean') { + fields.push(`${key}: ${value}`); + } else if (typeof value === 'string') { + const v = value.trim(); + if (v.length > 0 && v.length <= SUMMARY_MAX_VALUE_CHARS && !v.includes('\n')) { + fields.push(`${key}: ${v}`); + } + } + // arrays, nested objects, and huge strings are never headline material + } + } + const s = fields.length > 0 ? `${title} — ${fields.join(', ')}` : title; + return s.length > SUMMARY_MAX_CHARS ? `${s.slice(0, SUMMARY_MAX_CHARS - 1)}…` : s; +} diff --git a/src/workunit.ts b/src/workunit.ts index d7c15f3..08a579d 100644 --- a/src/workunit.ts +++ b/src/workunit.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { ExecResult, ExecTask, Executor } from './executor.js'; +import { synthesizeSummary } from './summary.js'; // Work-unit execution — the CPU half of the "folding@home driven by code" // design (CODE_CONTRIB.md). A task whose spec carries `code` names merged @@ -305,42 +306,12 @@ 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. -const SUMMARY_MAX_CHARS = 200; -const SUMMARY_MAX_FIELDS = 4; -const SUMMARY_MAX_VALUE_CHARS = 40; -/** Continuation/envelope keys are routing metadata, not headline results. */ -const SUMMARY_SKIP_KEYS = new Set(['summary', 'outcome', 'state_update', 'artifact_uri']); - /** - * Build a compact human summary from a work unit's JSON result: the task title - * plus up to a few headline scalar fields. Deterministic and generic — shallow - * numeric/boolean/short-string values in the object's own key order, skipping - * arrays, nested objects, and huge strings — so any driver's output produces a - * readable feed line without the platform knowing its schema. Used only when - * the executed program supplied no `summary` of its own. + * 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 function synthesizeWorkUnitSummary(title: string, result: unknown): string { - const fields: string[] = []; - if (result !== null && typeof result === 'object' && !Array.isArray(result)) { - for (const [key, value] of Object.entries(result as Record)) { - if (fields.length >= SUMMARY_MAX_FIELDS) break; - if (SUMMARY_SKIP_KEYS.has(key)) continue; - if (typeof value === 'number' && Number.isFinite(value)) { - fields.push(`${key}: ${value}`); - } else if (typeof value === 'boolean') { - fields.push(`${key}: ${value}`); - } else if (typeof value === 'string') { - const v = value.trim(); - if (v.length > 0 && v.length <= SUMMARY_MAX_VALUE_CHARS && !v.includes('\n')) { - fields.push(`${key}: ${v}`); - } - } - // arrays, nested objects, and huge strings are never headline material - } - } - const s = fields.length > 0 ? `${title} — ${fields.join(', ')}` : title; - return s.length > SUMMARY_MAX_CHARS ? `${s.slice(0, SUMMARY_MAX_CHARS - 1)}…` : s; -} +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 { diff --git a/test/proposal-loop.test.ts b/test/proposal-loop.test.ts new file mode 100644 index 0000000..699c717 --- /dev/null +++ b/test/proposal-loop.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { closePool, pool } from '../src/db.js'; +import { + checkoutTask, + listAvailableTasks, + listOpenTasks, + OpError, + submitResult, +} from '../src/operations.js'; +import { createDev, createTask, resetDb, setBudget, setVerified } from './helpers.js'; + +// Two bugs found by running real work through the 0.5.0 runner against +// firstproof-c4: +// +// 1. A task whose decomposition proposal was still unreviewed stayed +// claimable, so it could be re-proposed without limit. That target +// collected EIGHT proposals of the same split — the agent's own summaries +// counting them off as "third pass", "fourth pass", "fifth pass" — each +// burning a volunteer's credit and minting another review task for a +// second volunteer to clear. +// 2. An agent that omitted `summary` wrote an empty string to the public +// feed. Eight of that target's contributions render as blank rows, +// including a real analytical result. + +afterAll(closePool); +beforeEach(resetDb); + +/** A public, slugged conjecture — listAvailableTasks only surfaces those. */ +async function conjecture(slug: string): Promise { + const { rows } = await pool.query( + `INSERT INTO targets (name, slug, kind, status) VALUES ($1, $1, 'conjecture', 'open') + RETURNING id`, + [slug], + ); + return rows[0].id; +} + +async function summaryOf(taskId: string): Promise { + const { rows } = await pool.query( + `SELECT summary FROM contributions WHERE task_id = $1 ORDER BY id DESC LIMIT 1`, + [taskId], + ); + return rows[0].summary; +} + +const PROPOSAL = { + decomposition: { + subtasks: [ + { title: 'Half one', prompt: 'do the first half', max_cost_cents: 100 }, + { title: 'Half two', prompt: 'do the second half', max_cost_cents: 100 }, + ], + }, +}; + +async function proposer(handle = 'proposer') { + const dev = await createDev(handle); + await setBudget(dev, 100_000); + await setVerified(dev); + return dev; +} + +describe('a task awaiting decomposition review is not claimable', () => { + it('refuses a second proposal on the same task, naming the reason', async () => { + const target = await conjecture('loop'); + const dev = await proposer(); + const task = await createTask(target, { max: 200 }); + + await checkoutTask(dev, task); + const first = await submitResult(dev, task, PROPOSAL, 20, null, { + outcome: 'decomposition', + summary: 'splitting it', + }); + expect(first.review_task_id).toBeDefined(); + // The parent returns to the pool by design — the proposal is inert until + // reviewed and the work still needs doing… + expect(first.status).toBe('open'); + + // …but it must not be claimable, or the next runner just proposes again. + await expect(checkoutTask(dev, task)).rejects.toThrow(OpError); + await expect(checkoutTask(dev, task)).rejects.toThrow(/awaiting peer review/); + }); + + it('hides it from both pool listings, so no runner even tries', async () => { + const target = await conjecture('hidden'); + const dev = await proposer(); + const task = await createTask(target, { max: 200 }); + + expect((await listOpenTasks()).map((t) => t.id)).toContain(task); + expect((await listAvailableTasks()).map((t) => t.id)).toContain(task); + + await checkoutTask(dev, task); + await submitResult(dev, task, PROPOSAL, 20, null, { outcome: 'decomposition', summary: 's' }); + + expect((await listOpenTasks()).map((t) => t.id)).not.toContain(task); + expect((await listAvailableTasks()).map((t) => t.id)).not.toContain(task); + }); + + it('becomes claimable again once the review rejects the split', async () => { + const target = await conjecture('reopen'); + const dev = await proposer(); + const reviewer = await proposer('reviewer'); + const task = await createTask(target, { max: 200 }); + + await checkoutTask(dev, task); + const res = await submitResult(dev, task, PROPOSAL, 20, null, { + outcome: 'decomposition', + summary: 's', + }); + await expect(checkoutTask(dev, task)).rejects.toThrow(/awaiting peer review/); + + // The reviewer rules against the split; the next agent gets their reasons + // and should be able to propose a better one. + await checkoutTask(reviewer, res.review_task_id!); + await submitResult( + reviewer, + res.review_task_id!, + { approve: false, reasons: 'caps padded' }, + 5, + null, + ); + + await expect(checkoutTask(dev, task)).resolves.toBeDefined(); + }); + + it('does not block an unrelated task on the same target', async () => { + const target = await conjecture('sibling'); + const dev = await proposer(); + const proposed = await createTask(target, { max: 200 }); + const other = await createTask(target, { max: 200 }); + + await checkoutTask(dev, proposed); + await submitResult(dev, proposed, PROPOSAL, 20, null, { + outcome: 'decomposition', + summary: 's', + }); + + expect((await listOpenTasks()).map((t) => t.id)).toContain(other); + await expect(checkoutTask(dev, other)).resolves.toBeDefined(); + }); +}); + +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'); + const dev = await createDev('quiet'); + await setBudget(dev, 100_000); + const task = await createTask(target, { max: 200, title: 'Analytical δn table' }); + + await checkoutTask(dev, task); + // No `summary` — exactly what the real δn run did. + await submitResult(dev, task, { delta_n: -25, vacuous: true }, 13, null, { + outcome: 'candidate_solution', + }); + + const line = await summaryOf(task); + expect(line).not.toBe(''); + expect(line).toContain('Analytical δn table'); + expect(line).toContain('delta_n: -25'); + }); + + it('still says something when the result carries no headline scalars at all', async () => { + const target = await conjecture('bare'); + const dev = await createDev('bare-dev'); + await setBudget(dev, 100_000); + const task = await createTask(target, { max: 200, title: 'A task with a name' }); + await checkoutTask(dev, task); + await submitResult(dev, task, { rows: [[1, 2]] }, 5, null, { outcome: 'progress' }); + expect(await summaryOf(task)).toBe('A task with a name'); + }); + + it('leaves a supplied summary exactly as given', async () => { + const target = await conjecture('given'); + const dev = await createDev('talker'); + await setBudget(dev, 100_000); + const task = await createTask(target, { max: 200, title: 'Some task' }); + await checkoutTask(dev, task); + await submitResult(dev, task, { x: 1 }, 5, null, { + outcome: 'progress', + summary: 'my own words', + }); + expect(await summaryOf(task)).toBe('my own words'); + }); +});