Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["<one durable claim per entry>"] — 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": ["<key>"] — 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": "<short title>", "description": "<what it does and how to check it>",
"files": [{"path": "<repo-relative path>", "content": "<full file content>"}]}
Expand Down
63 changes: 61 additions & 2 deletions src/operations.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -541,6 +542,36 @@ async function attemptCheckout(devId: string, taskId: string): Promise<CheckoutR
);
}

// 2b. Refuse a task whose last decomposition proposal is still unreviewed.
//
// A `decomposition` submit returns the parent to the pool — the proposal
// is inert until a peer approves it, and the work still needs doing. But
// nothing stopped the parent being claimed and re-proposed immediately,
// so a task could accumulate proposals indefinitely: firstproof-c4's
// "Simulate slim(Δ)" collected EIGHT, the agent's own summaries counting
// them off as "third pass", "fourth pass", "fifth pass". Each one burns a
// volunteer's credit and mints another review task that costs a second
// volunteer to dispose of, so the waste compounds on both sides.
//
// This is the checkout gate because that is where the money is committed
// — refusing at submit would be too late, the run has already happened.
// The block lifts the moment the proposal has been REVIEWED — approved
// (subtasks published, parent superseded) or rejected (the next agent
// gets the reviewer's reasons and can propose a better split).
const pending = await client.query<{ blocked: boolean }>(
`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<TaskRow & { kind: string }>(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -2387,12 +2424,33 @@ export async function getDevStats(devId: string): Promise<DevStats> {
};
}

/**
* 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. `<T>` 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<TaskRow[]> {
// 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) {
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions src/summary.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)) {
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;
}
39 changes: 5 additions & 34 deletions src/workunit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>)) {
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 {
Expand Down
Loading
Loading