Files
2026-09-16 19:33:47 +08:00

459 lines
23 KiB
JavaScript

/**
* taskReceiver — pulls tasks the Hub offers this node, scores them, claims the
* one worth doing, and shapes it for the execute queue.
*
* Ported from evolver v1 (`src/gep/taskReceiver.js`), which v2 dropped: the
* adapter kept `listMyTasks` but nothing could fetch, score, claim or complete,
* so a v2 node saw its tasks and could never take one.
*/
import { randomUUID } from 'node:crypto';
import { AtpHubClient } from '@evomap/evolver-adapter-public';
import { createAtpClientFromEnv, resolveAtpSenderId } from './atp.js';
import { completionLedgerPath, defaultReadQueueTask, defaultWriteQueueTask, fileClaim, liveCommitments, rememberUnqueued, unsentCommitments, HUB_QUEUE_PREFIX, } from './taskCommitments.js';
export { COMPLETION_LEDGER_FILENAME, HUB_QUEUE_PREFIX, completionLedgerPath, hubTaskIdOf, readCompletionLedger, } from './taskCommitments.js';
export const TASK_STRATEGIES = ['greedy', 'balanced', 'conservative'];
const STRATEGY_WEIGHTS = {
greedy: { roi: 0.1, capability: 0.05, completion: 0.05, bounty: 0.8 },
balanced: { roi: 0.35, capability: 0.3, completion: 0.2, bounty: 0.15 },
conservative: { roi: 0.25, capability: 0.45, completion: 0.25, bounty: 0.05 },
};
const MIN_COMMITMENT_MS = 5 * 60_000;
const MAX_COMMITMENT_MS = 24 * 60 * 60_000;
const DEADLINE_SAFETY_MS = 60_000;
const DIFFICULTY_DURATION = [
{ threshold: 0.3, durationMs: 15 * 60_000 },
{ threshold: 0.5, durationMs: 30 * 60_000 },
{ threshold: 0.7, durationMs: 60 * 60_000 },
{ threshold: 1.0, durationMs: 120 * 60_000 },
];
const ROI_REFERENCE = 200;
const BOUNTY_REFERENCE = 100;
const SIGNAL_SIMILARITY_FLOOR = 0.15;
const HISTORY_WINDOW = 200;
/**
* The node's own record of what it has finished, folded out of the cycle log: each terminal
* cycle joined to the signals that drove it. Without this, capability matching scores every
* task against an empty history and the strategy weights never touch a real decision.
*/
export function memoryEventsFromCycleLog(events, window = HISTORY_WINDOW) {
const statusByCycle = new Map();
const signalsByCycle = new Map();
for (const event of events) {
const payload = event.payload && typeof event.payload === 'object' ? event.payload : null;
const cycleId = typeof payload?.['cycleId'] === 'string' ? payload['cycleId'] : null;
if (!cycleId)
continue;
if (event.type === 'cycle.solidified')
statusByCycle.set(cycleId, 'success');
else if (event.type === 'cycle.failed')
statusByCycle.set(cycleId, 'failed');
else if (event.type === 'cycle.signals_collected') {
const base = payload?.['baseSignals'] ?? payload?.['signals'];
if (Array.isArray(base))
signalsByCycle.set(cycleId, base.filter((signal) => typeof signal === 'string'));
}
}
return [...statusByCycle.entries()]
.slice(-window)
.map(([cycleId, status]) => ({ status, signals: signalsByCycle.get(cycleId) ?? [] }));
}
export function taskStrategy(env = process.env) {
const raw = String(env.TASK_STRATEGY ?? 'balanced').toLowerCase().trim();
return TASK_STRATEGIES.includes(raw) ? raw : 'balanced';
}
export function minCapabilityMatch(env = process.env) {
const parsed = Number(env.TASK_MIN_CAPABILITY_MATCH);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0.1;
}
export function parseSignals(signals) {
if (Array.isArray(signals))
return signals.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
if (typeof signals === 'string')
return signals.split(/[,|]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
return [];
}
function jaccard(left, right) {
if (left.length === 0 || right.length === 0)
return 0;
const a = new Set(left);
const b = new Set(right);
let shared = 0;
for (const value of a)
if (b.has(value))
shared += 1;
return shared / (a.size + b.size - shared);
}
/** How much this node's own history looks like the task, in [0, 1]. */
export function estimateCapabilityMatch(task, memoryEvents = []) {
const taskSignals = parseSignals(task.signals);
if (taskSignals.length === 0 || memoryEvents.length === 0)
return 0;
const seen = new Set();
const totals = new Map();
for (const event of memoryEvents) {
const signals = parseSignals(event.signals);
if (signals.length === 0)
continue;
for (const signal of signals)
seen.add(signal);
const key = signals.join('|');
const entry = totals.get(key) ?? { total: 0, success: 0 };
entry.total += 1;
if (event.status === 'success')
entry.success += 1;
totals.set(key, entry);
}
const overlap = jaccard(taskSignals, [...seen]);
let weighted = 0;
let weight = 0;
for (const [key, entry] of totals) {
const similarity = jaccard(taskSignals, key.split('|'));
if (similarity < SIGNAL_SIMILARITY_FLOOR)
continue;
// Laplace smoothing keeps a single lucky success from reading as certainty.
weighted += ((entry.success + 1) / (entry.total + 2)) * similarity;
weight += similarity;
}
const successScore = weight > 0 ? weighted / weight : 0.5;
return Math.min(1, overlap * 0.4 + successScore * 0.6);
}
export function localDifficultyEstimate(task) {
const signalFactor = Math.min(parseSignals(task.signals).length / 8, 1);
const words = String(task.title ?? '').split(/\s+/).filter(Boolean).length;
return Math.min(1, signalFactor * 0.6 + Math.min(words / 15, 1) * 0.4);
}
export function scoreTask(task, capabilityMatch, strategy = 'balanced') {
const weights = STRATEGY_WEIGHTS[strategy];
const difficulty = task.complexity_score ?? localDifficultyEstimate(task);
const bounty = task.bounty_amount ?? 0;
const completion = task.historical_completion_rate ?? 0.5;
const roi = Math.min(bounty / (difficulty + 0.1) / ROI_REFERENCE, 1);
const bountyNorm = Math.min(bounty / BOUNTY_REFERENCE, 1);
const composite = weights.roi * roi + weights.capability * capabilityMatch + weights.completion * completion + weights.bounty * bountyNorm;
return {
composite: round(composite, 3),
factors: { roi: round(roi, 2), capability: round(capabilityMatch, 2), completion: round(completion, 2), bounty: round(bountyNorm, 2), difficulty: round(difficulty, 2) },
};
}
function round(value, digits) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/**
* Pick what to work on. A task this node already holds always wins — finishing
* a commitment beats starting a better one.
*/
export function selectBestTask(tasks, nodeId, memoryEvents = [], env = process.env) {
return rankTasks(tasks, nodeId, memoryEvents, env)[0] ?? null;
}
/**
* Every task worth trying, best first. A ranked list rather than one winner: the
* top candidate can turn out to be unusable — a lapsed commitment, a window too
* short to promise — and one bad head must not hide the rest of the batch.
*/
export function rankTasks(tasks, nodeId, memoryEvents = [], env = process.env) {
const mine = tasks
.filter((task) => task.status === 'claimed' && task.claimed_by === nodeId)
.map((task) => ({ task, reason: 'resume' }));
const open = tasks.filter((task) => task.status === 'open');
const strategy = taskStrategy(env);
if (strategy === 'greedy' && memoryEvents.length === 0) {
const byBounty = [...open].sort((left, right) => Number(Boolean(right.bounty_id)) - Number(Boolean(left.bounty_id)) || (right.bounty_amount ?? 0) - (left.bounty_amount ?? 0));
return [...mine, ...byBounty.map((task) => ({ task, reason: 'scored', score: scoreTask(task, 0, strategy) }))];
}
const floor = minCapabilityMatch(env);
const scored = open
.map((task) => ({ task, reason: 'scored', score: scoreTask(task, estimateCapabilityMatch(task, memoryEvents), strategy) }))
.filter((entry) => entry.score.factors.capability >= floor || memoryEvents.length === 0)
.sort((left, right) => right.score.composite - left.score.composite);
return [...mine, ...scored];
}
/**
* What this node can honestly promise: harder tasks get longer, but never past
* the task's own expiry, and never a window too short to be worth claiming.
*/
export function estimateCommitmentDeadline(task, now = Date.now()) {
const difficulty = task.complexity_score ?? localDifficultyEstimate(task);
const band = DIFFICULTY_DURATION.find((entry) => difficulty <= entry.threshold);
const durationMs = Math.max(MIN_COMMITMENT_MS, Math.min(MAX_COMMITMENT_MS, band?.durationMs ?? MAX_COMMITMENT_MS));
let deadline = now + durationMs;
if (task.expires_at) {
const expiresAt = Date.parse(task.expires_at);
if (Number.isFinite(expiresAt) && expiresAt < deadline) {
const adjusted = expiresAt - DEADLINE_SAFETY_MS;
if (adjusted - now < MIN_COMMITMENT_MS)
return null;
deadline = adjusted;
}
}
return new Date(deadline).toISOString();
}
const PRIVACY_OPEN = '[PRIVACY_PARAMS]';
const PRIVACY_CLOSE = '[/PRIVACY_PARAMS]';
/**
* A privacy task carries its parameters as a fenced block in the body. Ported
* from v1 `privacyClient.parsePrivacyParams`; the rest of that client (blob
* upload, sealed execution) has no v2 counterpart yet.
*/
export function detectPrivacyTask(task) {
const body = task.body ?? task.description ?? '';
if (typeof body !== 'string')
return null;
const start = body.indexOf(PRIVACY_OPEN);
const end = body.indexOf(PRIVACY_CLOSE);
if (start === -1 || end === -1 || end <= start)
return null;
const params = new Map();
for (const line of body.slice(start + PRIVACY_OPEN.length, end).split('\n')) {
const trimmed = line.trim();
const colon = trimmed.indexOf(':');
if (colon === -1)
continue;
params.set(trimmed.slice(0, colon).trim(), trimmed.slice(colon + 1).trim());
}
const toolId = params.get('tool_id');
if (!toolId)
return null;
return { toolId, blobIds: (params.get('blob_ids') ?? '').split(',').map((id) => id.trim()).filter(Boolean) };
}
/** Signals an execute-queue task should carry so the loop knows what it is. */
export function taskToSignals(task) {
const signals = parseSignals(task.signals);
const fromTitle = String(task.title ?? '').toLowerCase().split(/[^a-z0-9_]+/).filter((word) => word.length > 3).slice(0, 5);
return [...new Set([...signals, ...fromTitle])];
}
/** Same signals, plus the two a sealed-tool task needs so the loop routes it. */
export function taskToSignalsWithPrivacy(task) {
const signals = taskToSignals(task);
if (!detectPrivacyTask(task))
return signals;
return [...new Set([...signals, 'privacy_computing', 'sealed_tool'])];
}
/**
* One receive pass: ask, choose, claim. Returns what the execute queue should
* run, or why nothing was taken.
*/
export async function receiveTask(client, opts) {
const env = opts.env ?? process.env;
const now = opts.now ?? Date.now;
const fetched = await call(() => client.fetchTasks());
// A failed fetch is not an idle Hub. Saying "none" here hid a 401 behind silence.
if (!fetched.ok)
return { claimed: false, reason: 'fetch_failed', seen: 0, error: errorOf(fetched) };
// A task id lands in a queue filename: anything outside the safe charset (a `..`
// segment above all) would write outside the queue directory, so drop it here.
const offered = Array.isArray(fetched.data?.tasks) ? fetched.data.tasks : [];
const tasks = offered.map(normalizeHubTask).filter((task) => task !== null);
const seen = { seen: tasks.length, ...(offered.length > tasks.length ? { rejected: offered.length - tasks.length } : {}) };
let blocked = null;
for (const selection of rankTasks(tasks, opts.nodeId, opts.memoryEvents ?? [], env)) {
const taken = selection.reason === 'resume'
? resumeClaim(selection, opts, now())
: await takeClaim(client, selection, opts, now());
if (taken.claimed)
return { ...taken, ...seen };
// One unusable head must not hide the rest of the batch: remember why, keep looking.
blocked ??= taken;
}
return blocked ? { ...blocked, ...seen } : { claimed: false, reason: 'none', ...seen };
}
/** A task this node already holds: no second claim, but the promise must still stand. */
function resumeClaim(selection, opts, nowMs) {
const held = opts.claimFor?.(`${HUB_QUEUE_PREFIX}${selection.task.task_id}`);
const deadline = held?.commitmentDeadline ?? commitmentDeadlineOf(selection.task);
// No record of the promise, and the Hub did not restate it. Inventing a fresh window
// here would make a commitment that lapsed weeks ago look alive on every restart, so
// the claim is not resumed — the Hub releases it at its own deadline either way.
if (!deadline) {
return { claimed: false, reason: 'expired', seen: 0, task: selection.task, commitmentDeadline: null };
}
const parsed = Date.parse(deadline);
if (Number.isFinite(parsed) && parsed <= nowMs) {
return { claimed: false, reason: 'expired', seen: 0, task: selection.task, commitmentDeadline: deadline };
}
// Resuming is the same filing, not a new one: keep the generation the claim was filed
// under, or a result from the run already in flight would look stale on return.
return { claimed: true, reason: 'resume', seen: 0, task: selection.task, score: selection.score, queueTask: queueTaskFor(selection.task, opts.repo, deadline, held?.generation ?? newGeneration()) };
}
/**
* The Hub's own view of how long it is holding a task for its claimant. Not `expires_at`:
* the bounty outlives the commitment by days, and resuming on it is how a node keeps
* working on something that was re-dispatched hours ago.
*/
function commitmentDeadlineOf(task) {
const raw = task.commitment_deadline ?? null;
return typeof raw === 'string' && Number.isFinite(Date.parse(raw)) ? raw : null;
}
/** A task no one holds: promise a deadline this node can keep, then take it. */
async function takeClaim(client, selection, opts, nowMs) {
const commitmentDeadline = estimateCommitmentDeadline(selection.task, nowMs);
if (!commitmentDeadline) {
return { claimed: false, reason: 'no_deadline', seen: 0, task: selection.task, score: selection.score, commitmentDeadline: null };
}
const claim = await call(() => client.claimTask(selection.task.task_id, { commitmentDeadline }));
if (!claim.ok) {
return { claimed: false, reason: 'claim_failed', seen: 0, task: selection.task, score: selection.score, commitmentDeadline, error: errorOf(claim) };
}
return { claimed: true, reason: 'scored', seen: 0, task: selection.task, score: selection.score, commitmentDeadline, queueTask: queueTaskFor(selection.task, opts.repo, commitmentDeadline, newGeneration()) };
}
/** The execute-queue entry under this id, if the loop has not taken it yet. */
function queuedTask(queueDir, id) {
return defaultReadQueueTask(queueDir, id);
}
function newGeneration() {
return randomUUID().slice(0, 8);
}
function queueTaskFor(task, repo, commitmentDeadline, generation) {
return {
id: `${HUB_QUEUE_PREFIX}${task.task_id}`,
hubTaskId: task.task_id,
generation,
repo,
target: String(task.title ?? task.task_id).slice(0, 200),
expectedEffect: `complete hub task ${task.task_id}`,
signals: taskToSignalsWithPrivacy(task),
...(commitmentDeadline ? { commitmentDeadline } : {}),
};
}
/** A thrown transport error is the same event as `{ ok: false }` and must read as one. */
async function call(rpc) {
try {
return await rpc();
}
catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
function errorOf(result) {
return result.error ?? `hub ${result.status ?? 0}`;
}
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,128}$/;
export function isSafeTaskId(taskId) {
return typeof taskId === 'string' && SAFE_TASK_ID.test(taskId);
}
/**
* The Hub and the proxies in front of it serialize tasks either way, and the neighbouring
* `/a2a/task/my` reader already accepts both. Read one shape here so a camelCase response
* is not silently an empty task list.
*/
export function normalizeHubTask(raw) {
if (!raw || typeof raw !== 'object')
return null;
const task = raw;
const pick = (...keys) => {
for (const key of keys)
if (task[key] !== undefined && task[key] !== null)
return task[key];
return undefined;
};
const taskId = pick('task_id', 'taskId');
if (!isSafeTaskId(taskId))
return null;
return {
...task,
task_id: taskId,
...(pick('claimed_by', 'claimedBy') === undefined ? {} : { claimed_by: pick('claimed_by', 'claimedBy') }),
...(pick('bounty_amount', 'bountyAmount') === undefined ? {} : { bounty_amount: pick('bounty_amount', 'bountyAmount') }),
...(pick('bounty_id', 'bountyId') === undefined ? {} : { bounty_id: pick('bounty_id', 'bountyId') }),
...(pick('expires_at', 'expiresAt') === undefined ? {} : { expires_at: pick('expires_at', 'expiresAt') }),
...(pick('commitment_deadline', 'commitmentDeadline') === undefined ? {} : { commitment_deadline: pick('commitment_deadline', 'commitmentDeadline') }),
...(pick('complexity_score', 'complexityScore') === undefined ? {} : { complexity_score: pick('complexity_score', 'complexityScore') }),
...(pick('historical_completion_rate', 'historicalCompletionRate') === undefined ? {} : { historical_completion_rate: pick('historical_completion_rate', 'historicalCompletionRate') }),
...(pick('result_asset_id', 'resultAssetId') === undefined ? {} : { result_asset_id: pick('result_asset_id', 'resultAssetId') }),
};
}
const IDLE = { claimed: false, reason: 'none', seen: 0 };
export function isTaskReceiverEnabled(env = process.env) {
return String(env['EVOLVER_TASK_RECEIVER'] ?? 'on').toLowerCase().trim() !== '0';
}
function hubClientOrNull(env, deps) {
try {
return deps.client ?? createAtpClientFromEnv(env);
}
catch {
return null;
}
}
export function resolveTaskReceiver(env = process.env, deps = {}) {
const ledgerPath = deps.completionLedgerPath ?? completionLedgerPath(env);
const now = deps.now ?? (() => Date.now());
const write = deps.writeQueueTask ?? defaultWriteQueueTask;
// One identity, and it is the client's: filtering tasks as one node while claiming and
// completing them as another is how a node skips its own work and takes someone else's.
// With a client present its answer is final — an env or caller value is not bound to the
// credentials that will do the claiming, so it only stands in when there is no client.
const client = hubClientOrNull(env, deps);
const nodeId = client ? client.nodeId?.() : deps.nodeId ?? resolveAtpSenderId(env);
// A promise made in an earlier run outlives the switch, the identity and the process that
// made it, so the debt is carried on every wiring — receiving new work is what turns off.
const commitmentDeps = { ledgerPath, now, write, ...(deps.queueDir ? { queueDir: deps.queueDir } : {}) };
const commitments = client ? liveCommitments(client, commitmentDeps) : unsentCommitments(ledgerPath, now);
const off = (reason) => ({ enabled: false, reason, tick: async () => IDLE, ...commitments });
if (!isTaskReceiverEnabled(env))
return off('off');
if (!nodeId)
return off('no_node');
if (!client)
return off('client_error');
if (!deps.repo || !deps.queueDir)
return off('no_repo');
const repo = deps.repo;
const queueDir = deps.queueDir;
// Bound here so the pass below keeps the narrowing the guards above established.
const hub = client;
const node = nodeId;
// One pass at a time, whoever calls. Two overlapping passes both see a task as
// unclaimed, both claim it, and the Hub commitment ends up with two queue entries.
// autoexec happens to single-flight its tick; the guarantee belongs here, not there.
let inProgress = null;
return {
enabled: true,
...commitments,
tick: async () => {
if (inProgress)
return inProgress;
inProgress = receivePass();
try {
return await inProgress;
}
finally {
inProgress = null;
}
},
};
async function receivePass() {
const received = await receiveTask(hub, { nodeId: node, repo, claimFor: commitments.claimFor, memoryEvents: deps.memoryEvents?.() ?? [], env, now: deps.now });
if (!received.claimed || !received.queueTask)
return received;
// Resuming is not re-filing. A claim already registered under this generation is
// either sitting in the queue or being executed right now; writing it again would
// either be read as a stranger's queue entry or hand the executor a duplicate.
// "Still in the loop's hands" is the queue entry OR a run already under way: the
// executor moves the file out of the queue when it starts, and re-filing then would
// hand it the same task twice.
const held = commitments.claimFor(received.queueTask.id);
const queued = queuedTask(queueDir, received.queueTask.id)?.generation === held?.generation;
const inFlight = deps.inFlight?.(received.queueTask.id) ?? false;
if (received.reason === 'resume' && held !== null && (queued || inFlight))
return received;
// We hold a claim the moment the Hub grants it. Anything that keeps the loop from
// seeing the task leaves the Hub waiting on work that cannot start, so the failure
// is written down and retried on later beats rather than dropped with the claim.
try {
const filed = fileClaim(commitmentDeps, queueDir, received.queueTask);
if (filed === 'name_taken' || filed === 'unrecorded') {
const why = filed === 'name_taken'
? `queue id ${received.queueTask.id} is taken by another task`
: `ownership of ${received.queueTask.id} could not be recorded`;
rememberUnqueued(ledgerPath, received.queueTask, why);
return { ...received, claimed: false, reason: 'queue_failed', error: why };
}
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error);
rememberUnqueued(ledgerPath, received.queueTask, reason);
return { ...received, claimed: false, reason: 'queue_failed', error: reason };
}
return received;
}
}