openvibers/openvibe.codes Commits
AL1: a goal task is not ready until its check and an independent verifier accept (#376)
Changes
@@@ -49,6 +49,13 @@ OV_OAUTH_CLIENT_SECRET=#CODES_AI_CATALOG=0#CODES_AI_CATALOG_TTL_MS=300000++# Goals and the independent verifier (AL1), off by default. With CODES_GOALS_ENABLED=1 a task may carry a goal and a++# typed check; the harness runs the check in the task's workspace and a cheap fast model on OpenVibe.AI accepts or++# rejects with a reason. CODES_GOALS_MODEL names that rung (default deepseek-chat).++#CODES_GOALS_ENABLED=0++#CODES_GOALS_MODEL=deepseek-chat++#CODES_GOALS_TIMEOUT_MS=120000++# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a# hard-coded home; without it a job gets no lessons. Never a secret.#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js@@@ -433,7 +433,18 @@ function settledParent(runs, end) {function step(runs, end, opts = {}) {if (!end) return { action: 'none', why: 'there are no attempts' };if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };-- if (end.status === 'done' && end.goal && end.goal.verdict && end.goal.verdict.status === 'rejected') return { action: 'continue', why: end.goal.verdict.reason || 'the verifier rejected the completion proposal' };++ // AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the++ // goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.++ if (end.status === 'done' && end.goal) {++ const verdict = end.goal.verdict || null;++ if (!verdict || verdict.status !== 'accepted') {++ return {++ action: 'continue',++ session: facts(end).session,++ why: (verdict && verdict.reason) || 'the goal has no independent acceptance yet',++ };++ }++ }if (end.status === 'done') return { action: 'done', why: 'the task finished' };const why = end.reason || reason(end.error);if (why === 'stopped') return { action: 'stop', why: 'the task was stopped on purpose' };@@@ -36,7 +36,9 @@ async function verify({ goal, check, proposal, evidence, checkOutput, callVerificonst verdict = String(doc && doc.verdict || '').toLowerCase();return { status: verdict === 'accept' ? 'accepted' : 'rejected', reason: String(doc && doc.reason || 'The verifier did not return a valid acceptance verdict.').slice(0, 4000), check_output: boundedEvidence(checkOutput) };} catch (error) {-- return { status: 'rejected', reason: error && error.name === 'AbortError' ? 'The independent verifier timed out.' : 'The independent verifier failed to produce a verdict.', check_output: boundedEvidence(checkOutput) };++ // The verifier did not run: that is not the worker's failure and it is not an acceptance. A `failed` verdict++ // leaves the task waiting on a person instead of looping the worker against an unavailable model.++ return { status: 'failed', reason: error && error.name === 'AbortError' ? 'The independent verifier timed out.' : 'The independent verifier failed to produce a verdict.', check_output: boundedEvidence(checkOutput) };} finally { clearTimeout(timer); }}nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++/**++ * The deterministic half of a goal (AL1): the check runs in the task's own workspace, not in the model's words.++ *++ * A worker proposes completion; the harness then runs the task's typed check against the clone the worker worked in++ * and carries the output back as evidence. The check is the machine's act, not the agent's: the agent may write the++ * file, but it cannot write the command's exit code. An independent verifier reads this output with a bounded evidence++ * window (engine/goal.js) and accepts or rejects.++ *++ * runCheck(check, { cwd, timeoutMs, fetchImpl }) → { kind, value, ok, output, exit_code?, status?, at }++ *++ * A `command` runs through bash in the workspace; a `url` is a GET and passes on a 2xx; a `rubric` has no++ * deterministic machine check, so its output says the verifier reads the evidence and `ok` is null. An unknown kind++ * fails the check instead of hanging the run.++ */++++const { execFile } = require('child_process');++++/** What a check's own output may take of the result channel: the verifier's evidence window is 256 KB. */++const MAX_OUTPUT = 256 * 1024;++const DEFAULT_TIMEOUT_MS = 120_000;++++/** A string cut to the evidence window, never split mid-character. */++function bounded(value, max = MAX_OUTPUT) {++ const text = typeof value === 'string' ? value : String(value == null ? '' : value);++ const bytes = Buffer.from(text, 'utf8');++ return bytes.length <= max ? text : bytes.subarray(0, max).toString('utf8');++}++++/** One command in the workspace. Rejects only on a check it could not start; a non-zero exit is an answer. */++function command(value, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {++ return new Promise((resolve) => {++ execFile('/bin/bash', ['-lc', value], {++ cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT * 2, env: { ...env, CI: '1', GIT_TERMINAL_PROMPT: '0' },++ }, (err, stdout, stderr) => {++ const out = `${String(stdout || '')}${String(stderr || '')}`;++ const timedOut = !!(err && (err.killed || err.signal === 'SIGTERM'));++ resolve({++ ok: !err,++ exit_code: err && typeof err.code === 'number' ? err.code : (err ? null : 0),++ timed_out: timedOut,++ output: bounded(out.trim() ? out : (err ? String(err.message) : '(no output)')),++ });++ });++ });++}++++/** One GET of the URL assertion. 2xx is the check's pass; anything else is its answer. */++async function url(value, { fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {++ let target;++ try { target = new URL(value); } catch { return { ok: false, output: 'the check is not a URL' }; }++ if (!/^https?:$/.test(target.protocol)) return { ok: false, output: 'the check URL must be http or https' };++ if (typeof fetchImpl !== 'function') return { ok: false, output: 'this machine cannot fetch the check URL' };++ try {++ const res = await fetchImpl(target.toString(), { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });++ const body = await res.text().catch(() => '');++ return {++ ok: res.ok,++ status: res.status,++ output: bounded(`HTTP ${res.status} ${res.statusText || ''}\n${String(body || '').slice(0, 4000)}`),++ };++ } catch (e) {++ return { ok: false, output: bounded(`the URL check failed: ${e && e.message ? e.message : e}`) };++ }++}++++/**++ * Run one typed check. Never throws for a check that fails: a failure is the answer the verifier needs.++ *++ * @param {object} check { kind, value }++ * @param {object} [what] { cwd, timeoutMs, fetchImpl, env }++ * @returns {Promise<object>} the check's own record, suitable as evidence++ */++async function runCheck(check, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch, env = process.env } = {}) {++ const kind = String((check && check.kind) || '');++ const value = String((check && check.value) || '');++ const at = new Date().toISOString();++ if (!value.trim()) return { kind, value, ok: false, output: 'the check is empty', at };++ if (kind === 'command') {++ if (!cwd) return { kind, value, ok: false, output: 'the check has no workspace to run in', at };++ return { kind, value, at, ...(await command(value, { cwd, timeoutMs, env })) };++ }++ if (kind === 'url') return { kind, value, at, ...(await url(value, { fetchImpl, timeoutMs: Math.min(timeoutMs, 30_000) })) };++ if (kind === 'rubric') return { kind, value, ok: null, output: 'A rubric has no machine check: the independent verifier reads the evidence.', at };++ return { kind, value, ok: false, output: `unknown check kind ${JSON.stringify(kind)}`, at };++}++++module.exports = { runCheck, bounded, MAX_OUTPUT, DEFAULT_TIMEOUT_MS };@@@ -21,6 +21,8 @@ const path = require('path');const { execFile } = require('child_process');const { collectProof } = require('../engine/proof');const { createCheckpoints, toolPaths } = require('./checkpoints');++const { runCheck } = require('./goal-check');++const { boundedEvidence } = require('../engine/goal');const JOB_PROTOCOL = '1.0.0';/** The agents a conductor's turn may run on: every harness Codes offers. */@@@ -257,6 +259,18 @@ function parseArgs(raw, env = process.env) {tokens: Number.isFinite(Number(a.limits.tokens)) && Number(a.limits.tokens) > 0 ? Math.min(1000000, Number(a.limits.tokens)) : null,usd: Number.isFinite(Number(a.limits.usd)) && Number(a.limits.usd) > 0 ? Math.min(100, Number(a.limits.usd)) : null,} : null;++ // AL1: a task's goal and its typed check. The harness runs the check itself, in the clone, after the worker's++ // turn; the model never supplies the exit code. Unknown or malformed goals are dropped, never guessed at.++ const rawGoal = a.goal && typeof a.goal === 'object' && !Array.isArray(a.goal) ? a.goal : null;++ const goalCheck = rawGoal && rawGoal.check && typeof rawGoal.check === 'object' ? rawGoal.check : null;++ const goal = rawGoal && typeof rawGoal.goal === 'string' && rawGoal.goal.trim()++ && goalCheck && ['command', 'url', 'rubric'].includes(goalCheck.kind) && typeof goalCheck.value === 'string' && goalCheck.value.trim()++ ? {++ goal: rawGoal.goal.trim().slice(0, 4000),++ check: { kind: goalCheck.kind, value: goalCheck.value.trim().slice(0, 4000) },++ budget: rawGoal.budget && typeof rawGoal.budget === 'object' && !Array.isArray(rawGoal.budget) ? rawGoal.budget : {},++ no_progress_limit: Number.isInteger(rawGoal.no_progress_limit) ? rawGoal.no_progress_limit : 3,++ } : null;return {kind, repo: repo ? repo.toString() : null, ref, prompt, permission, push,// The task this job is an attempt of, when the server names it: the checkpoint refs carry it.@@@ -274,6 +288,7 @@ function parseArgs(raw, env = process.env) {...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts....(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),++ ...(goal ? { goal } : {}),};}@@@ -841,6 +856,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmif (last && last.disclosure && typeof last.disclosure === 'object') result.disclosure = last.disclosure;const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};result.tokens = Number(last && (last.total_tokens || last.tokens)) || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0);++ if (last && Number.isFinite(Number(last.turns))) result.turns = Number(last.turns);// The session the next message resumes: a conductor's turn ends, its conversation does not.if (job.kind === 'conductor' && last && last.session_id) result.session = String(last.session_id).slice(0, 200);// A conductor works in the owner's own folder: its changes are theirs, not a patch for a pull request.@@@ -882,6 +898,33 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmresult.proof = { items: [], outcome: null };}}++ // The goal's own check (AL1): the machine runs it in the clone, after the worker's turn, and the result carries++ // the output as evidence. It never decides alone: the independent verifier reads the evidence server-side.++ if (job.goal && job.kind === 'code') {++ say({ t: 'step', text: 'Running the goal check' });++ try {++ const checked = await runCheck(job.goal.check, { cwd: work, fetchImpl });++ const evidence = boundedEvidence(JSON.stringify({++ check: { kind: checked.kind, value: checked.value, ok: checked.ok, exit_code: checked.exit_code, status: checked.status, output: checked.output },++ summary: answer.slice(0, 4000),++ files: result.files.slice(0, 200).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),++ proof: result.proof ? result.proof.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary })) : null,++ }));++ result.goal = {++ goal: job.goal.goal,++ check: job.goal.check,++ proposal: answer.slice(0, 4000),++ check_output: checked.output,++ evidence,++ checked: { ok: checked.ok, exit_code: checked.exit_code == null ? null : checked.exit_code, status: checked.status == null ? null : checked.status, at: checked.at },++ };++ say({ t: 'note', level: checked.ok === false ? 'warning' : 'info', text: `The goal check ${checked.ok === true ? 'passed' : checked.ok === false ? 'did not pass' : 'is for the verifier'}: ${checked.kind} ${checked.value.slice(0, 300)}` });++ if (checked.output) say({ t: 'note', level: 'info', text: `Check output: ${checked.output.slice(-2000)}` });++ } catch (e) {++ result.goal = { goal: job.goal.goal, check: job.goal.check, proposal: answer.slice(0, 4000), check_output: '', evidence: '', error: scrub(e.message) };++ say({ t: 'note', level: 'warning', text: `The goal check could not run: ${scrub(e.message)}` });++ }++ }if (job.push && result.ok && result.files.length) {say({ t: 'step', text: 'Pushing a new branch' });try {nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- AL1: the honest end state of a goal task (ready, waiting, blocked, failed, dropped, stopped_by_budget), and the++-- model turns an attempt used so a goal's turn budget can be counted across attempts.++ALTER TABLE run_tasks++ ADD COLUMN IF NOT EXISTS end_state text;++ALTER TABLE codes_attempts++ ADD COLUMN IF NOT EXISTS turns integer;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0","pg": "^8.23.0"@@@ -1149,9 +1149,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.384.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",-- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",++ "version": "0.398.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0",++ "integrity": "sha512-M8zjqBYnyV3tC5ozeznk58dxeztYxH7BkRFdB24abBLWxYWGL/G3cR1bhruDpZLb27dVZidM5GZ8gSggYAm7qg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0","pg": "^8.23.0"@@@ -137,7 +137,7 @@ async function createApp(opts = {}) {const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });// The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.const skills = opts.skills || createSkills({ store, sharing, log });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, goalVerifier: opts.goalVerifier || null, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -174,8 +174,13 @@ function load(env = process.env) {know: { lessons: String(env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS || '').trim() },// The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },-- // Goal and independent-verification flow stays opt-in until task creation and persistence are wired end to end.-- goals: { enabled: env.CODES_GOALS_ENABLED === '1' },++ // Goal and independent-verification flow (AL1) stays opt-in: a task without a goal behaves exactly as before.++ // The verifier is a cheap fast model on OpenVibe.AI; CODES_GOALS_MODEL may name another rung.++ goals: {++ enabled: env.CODES_GOALS_ENABLED === '1',++ model: env.CODES_GOALS_MODEL || 'deepseek-chat',++ timeoutMs: Math.max(1000, int(env.CODES_GOALS_TIMEOUT_MS, 120000)),++ },// The conductor (Codes C9b): one long Claude Code session on the person's own machine. A turn's wall time (the// session may work through a plan), and the harness a sub-task gets when the conductor names none (never an// Anthropic one: the conductor is the only Anthropic session).@@@ -190,7 +190,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })}/** An attempt ended: its state, why, what it cost. */-- async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, session = null, model = null, machine = null, budget_hit = null, finished_at = null } = {}) {++ async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, turns = null, session = null, model = null, machine = null, budget_hit = null, finished_at = null } = {}) {return await db.one(`UPDATE codes_attempts SET state = $2,reason = COALESCE($3, reason),@@@ -202,9 +202,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })model = COALESCE($9, model),machine = COALESCE($10, machine),budget_hit = COALESCE($11, budget_hit),++ turns = COALESCE($13, turns),finished_at = COALESCE($12::timestamptz, finished_at)WHERE id = $1 RETURNING *`,-- [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, budget_hit, finished_at || store.iso()],++ [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, budget_hit, finished_at || store.iso(), turns],);}@@@ -303,8 +304,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })return null;}-- /** One attempt as the engine sees it. */-- function engineAttempt(a, all, facts = null) {++ /** One attempt as the engine sees it. A task's goal travels on the attempt whose turn just ended. */++ function engineAttempt(a, all, facts = null, goal = null) {const n = Number(a.n) || 1;const reason = a.reason && REASONS.includes(a.reason) ? a.reason : null;const next = all.find((x) => x.follow_up_of && x.follow_up_of === a.id);@@@ -327,6 +328,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })prev: prev ? prev.id : null,parent: a.follow_up_of || null,stopped: a.state === 'stopped',++ ...(goal ? { goal } : {}),++ ...(Number.isFinite(Number(a.turns)) ? { turns: Number(a.turns) } : {}),budget: reason === 'budget' ? { hit: a.budget_hit || 'steps' } : undefined,wrapUp: a.kind === 'wrap-up',hung: reason === 'hung',@@@ -365,6 +368,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })return `\n\n---\n## Continuation (written by openvibe.codes)\n\nAn earlier attempt${ending.agent ? ` on ${nameOf(ending.agent)}` : ''} ${words}. Keep the work it finished, do not redo it, and continue from where it stopped. If the task is already complete, check it and report that.\n`;}++ /**++ * The note a rejected goal's own session gets: the verifier's reason and the check's own output, so the worker++ * repairs the actual failure rather than being told "not done" again.++ */++ function goalContinuationNote(verdict) {++ const output = verdict && verdict.check_output ? String(verdict.check_output) : '';++ return `\n\n---\n## The goal check did not pass (written by openvibe.codes)\n\n`++ + `The independent verifier rejected the completion proposal: ${String((verdict && verdict.reason) || 'no reason was given').slice(0, 4000)}\n`++ + (output ? `\nThe check's own output:\n\n\`\`\`\n${output.slice(-8000)}\n\`\`\`\n` : '')++ + '\nKeep the work, do not redo it. Make the check pass, then propose completion again in this same conversation.\n';++ }++/** The note a new agent gets: what changed, what it said, where it stopped. */function handoverNote(ending, result, words) {const d = result && typeof result === 'object' ? result : {};@@@ -377,6 +392,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })+ 'Keep the work it did, do not redo it, and finish the task.\n';}++ /** The task's goal, as the goal contract shaped it, when it belongs to this ending and has a verdict. */++ function a_id_goal(task, ending) {++ let goal = task && task.goal;++ if (typeof goal === 'string') { try { goal = JSON.parse(goal); } catch { goal = null; } }++ if (!goal || typeof goal !== 'object' || Array.isArray(goal)) return null;++ let verdict = goal.verdict;++ if (typeof verdict === 'string') { try { verdict = JSON.parse(verdict); } catch { verdict = null; } }++ if (verdict && (typeof verdict !== 'object' || Array.isArray(verdict))) verdict = null;++ if (verdict && verdict.attempt_id && ending && verdict.attempt_id !== ending.id) return null;++ return goal;++ }++/*** What happens after an attempt ends: the engine decides, this turns the decision into a plan.*@@@ -391,7 +418,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })const ctx = routing(fleet);const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));const facts = factsOf(result);-- const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));++ // The goal (AL1) belongs to the attempt that just ended: the engine reads its verdict, and only its verdict,++ // before it may call a done attempt done. A verdict left by an earlier attempt never decides this one.++ const endingGoal = a_id_goal(task, ending);++ const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null, a.id === ending.id ? endingGoal : null));const out = chain.outcome(engineAttempts, ctx);const step = out.step || { action: 'none' };const reason = ending.reason || out.reason || 'error';@@@ -420,6 +450,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })if (continues >= maxContinues) {return { outcome: 'unfinished', note: `the task was carried on ${continues} times already; it needs a person` };}++ const rejected = endingGoal && endingGoal.verdict && endingGoal.verdict.status === 'rejected' ? endingGoal.verdict : null;++ if (rejected) {++ return {++ kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,++ prompt: String(task.prompt || '') + goalContinuationNote(rejected),++ why: `the verifier rejected the completion proposal: ${rejected.reason}`,++ };++ }return {kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,prompt: String(task.prompt || '') + continuationNote(ending, words),nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * The independent verifier (AL1): a cheap, fast model on OpenVibe.AI reads a bounded evidence window and answers++ * accept or reject with a reason.++ *++ * Codes never asks the worker's own model to grade its own work: the verifier is a separate call on a cheap rung++ * (deepseek-chat by default), made by Codes with its own Network token, not the task's. The prompt is built by++ * engine/goal.js, which cuts the evidence to 256 KB and treats it as data. A verifier that cannot answer is a++ * `failed` verdict, never an acceptance: a task without an independent verdict is not ready.++ *++ * createGoalVerifier({ config, fetchImpl, log })++ * verify({ goal, check, proposal, evidence, checkOutput }) → { status, reason, check_output, model }++ *++ * The service token is minted from Codes' own client credentials for OpenVibe.AI (`ai.run.create`), the same grant its++ * catalog and connection pages use. No provider key reaches Codes, and none is logged here.++ */++++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { verify } = require('../../engine/goal');++++/** The cheap rung: one request, at most a short answer. The env may name another (CODES_GOALS_MODEL). */++const DEFAULT_MODEL = 'deepseek-chat';++++/** The answer's text from either dialect OpenVibe.AI speaks. */++function answerText(doc) {++ if (!doc || typeof doc !== 'object') return '';++ const choice = doc.choices && doc.choices[0];++ if (choice && choice.message && typeof choice.message.content === 'string') return choice.message.content;++ if (Array.isArray(doc.content)) return doc.content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).filter(Boolean).join(' ');++ if (typeof doc.result === 'string') return doc.result;++ return '';++}++++function createGoalVerifier({ config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');++ const model = String((config.goals && config.goals.model) || DEFAULT_MODEL);++ const scope = (config.ai && config.ai.scope) || 'ai.credential.manage ai.run.create';++ const tokens = config.oauth && config.oauth.clientSecret++ ? createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl || 'http://127.0.0.1:4000'}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: (config.ai && config.ai.audience) || 'openvibe.ai',++ scope,++ fetch: fetchImpl,++ })++ : null;++++ /** One request to AI; the OpenAI dialect first, then the Messages dialect for a provider that speaks only that. */++ async function call({ prompt, signal }) {++ if (!tokens) throw new Error('Codes has no Network client secret, so no independent verifier can run');++ const headers = { ...(await tokens.authHeaders()), 'content-type': 'application/json', accept: 'application/json' };++ const ask = async (path, body) => {++ const res = await fetchImpl(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal });++ const doc = await res.json().catch(() => null);++ return { res, doc };++ };++ let { res, doc } = await ask('/v1/chat/completions', {++ model, messages: [{ role: 'user', content: prompt }], temperature: 0, max_tokens: 300,++ });++ if (res && res.status === 404) {++ ({ res, doc } = await ask('/v1/messages', {++ model, max_tokens: 300, messages: [{ role: 'user', content: prompt }],++ }));++ }++ if (!res || !res.ok) {++ const err = doc && doc.error && typeof doc.error === 'object' ? doc.error : null;++ const code = String((err && err.code) || (doc && doc.code) || '') || null;++ throw new Error(`OpenVibe.AI refused the goal verifier (HTTP ${res && res.status}${code ? `, ${code}` : ''})`);++ }++ const text = answerText(doc);++ if (!text) throw new Error('the goal verifier returned no answer');++ return text;++ }++++ /**++ * Verify one completion proposal. Never throws: a verifier that could not run is a `failed` verdict with its++ * reason, so the task can say it is waiting on a person rather than call itself ready.++ */++ async function verifyGoal({ goal, check, proposal = '', evidence = '', checkOutput = '' } = {}) {++ try {++ const verdict = await verify({++ goal, check, proposal, evidence, checkOutput,++ callVerifier: call,++ timeoutMs: Number(config.goals && config.goals.timeoutMs) || undefined,++ });++ return { ...verdict, model };++ } catch (e) {++ log.warn(`[Codes] goal verifier failed: ${String((e && e.message) || e).slice(0, 300)}`);++ return {++ status: 'failed',++ reason: `The independent verifier could not run: ${String((e && e.message) || e).slice(0, 500)}`,++ check_output: String(checkOutput || '').slice(0, 262144),++ model,++ };++ }++ }++++ return { verify: verifyGoal, model };++}++++module.exports = { createGoalVerifier, answerText, DEFAULT_MODEL };@@@ -138,16 +138,21 @@ function usageOf(result) {else if (!model && line.model) model = String(line.model);} else if (line.t === 'done' && line.resolved_model) model = String(line.resolved_model);}++ const turns = Number.isFinite(Number(d.turns)) ? Number(d.turns) : (() => {++ const done = log.filter((l) => l && l.t === 'done' && Number.isFinite(Number(l.turns))).slice(-1)[0];++ return done ? Number(done.turns) : null;++ })();return {cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,tokens: tokens || null,steps: steps || null,++ turns: turns || null,session: session || null,model: model || null,};}--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null }) {++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, goalVerifier = null }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).@@@ -158,6 +163,16 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.const reportClient = report || require('../report').createReportClient({ config, log });const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });++ // AL1's independent verifier: created lazily, only when goals are on and an ending actually needs it.++ let goalVerifierClient = goalVerifier;++ const verifierClient = () => {++ if (!goalVerifierClient && config.goals && config.goals.enabled) {++ try { goalVerifierClient = require('./goal-verifier').createGoalVerifier({ config, log }); } catch (e) { log.warn(`[Codes] goal verifier unavailable: ${e.message}`); }++ }++ return goalVerifierClient;++ };++ // One verification per ending, even when two events deliver the same ending.++ const verifying = new Set();/*** The conductor's turns (server/domain/conductor.js). Wired by the app after both exist, because the conductor* starts Run jobs and this module is what applies their events: a constructor argument would be a cycle.@@@ -492,7 +507,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait attemptStore.end(attempt.id, {state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),reason: hit ? 'budget' : why,-- cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,++ cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, turns: usage.turns, session: usage.session,model: usage.model, machine: machineOf(next.job),budget_hit: hit,finished_at: updated.finished_at || store.iso(),@@@ -658,7 +673,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });const failed = await store.tx(async () => {-- const row = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5++ const row = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, end_state = CASE WHEN goal IS NOT NULL THEN 'failed' ELSE end_state END, finished_at = $4::timestamptz, updated_at = $5WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);await taskOutbox.emitTask(row, 'queued');if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {@@@ -746,6 +761,88 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou}}++ /**++ * What the goal's budgets say the task has spent (AL1): tokens, model turns and active minutes, summed over every++ * attempt so far. Null while there is room left; the sentence a person reads when there is not.++ */++ function goalBudgetHit(attempts, goal, task) {++ const budget = goal && goal.budget && typeof goal.budget === 'object' && !Array.isArray(goal.budget) ? goal.budget : {};++ if (!Object.keys(budget).length) return null;++ const tokens = (attempts || []).reduce((n, a) => n + (Number(a.tokens) || 0), 0);++ if (budget.tokens && tokens >= Number(budget.tokens)) return `the task spent ${tokens} of its ${budget.tokens} tokens`;++ const turns = (attempts || []).reduce((n, a) => n + (Number(a.turns) || 0), 0) || (attempts || []).length;++ if (budget.turns && turns >= Number(budget.turns)) return `the task used ${turns} of its ${budget.turns} turns`;++ let minutes = 0;++ for (const a of attempts || []) {++ // The clock starts when the attempt did: a row with no start time is not an hour of work, and an old++ // wall-clock stamp is not this task's activity.++ const started = a.started_at ? Date.parse(String(a.started_at)) : (a.created_at ? Date.parse(String(a.created_at)) : 0);++ const finished = a.finished_at ? Date.parse(String(a.finished_at)) : 0;++ if (started && finished > started) minutes += (finished - started) / 60000;++ }++ if (!minutes && task && task.started_at) minutes = Math.max(0, (store.now() - Date.parse(String(task.started_at))) / 60000);++ if (budget.active_minutes && minutes >= Number(budget.active_minutes)) return `the task was active ${Math.round(minutes)} of its ${budget.active_minutes} minutes`;++ return null;++ }++++ /** The task ends with an honest end state (AL1): ready, waiting, blocked, failed, dropped or stopped by budget. */++ async function finishGoal(row, { outcome, note, end_state }) {++ const saved = await db.one(`UPDATE run_tasks SET outcome = $2, outcome_note = $3, end_state = $4, updated_at = $5++ WHERE id = $1 RETURNING *`, [row.id, outcome, note, end_state, store.now()]);++ await notifyOutcome(saved);++ await recordPlacementOutcome(saved);++ return saved;++ }++++ /**++ * The independent verification of one ending (AL1). The harness ran the typed check in the task's workspace; this++ * sends its bounded output to a cheap model through OpenVibe.AI and keeps the reasoned verdict with the task.++ * One verdict per ending: a redelivered ending returns the verdict already written.++ */++ async function verifyGoalEnding(row, ending, goal) {++ const prior = asObject(goal.verdict);++ if (prior && prior.attempt_id === ending.id) return { verdict: prior, goal, row };++ const key = `${row.id}:${ending.id}`;++ if (verifying.has(key)) return { verdict: prior, goal, row, skipped: true };++ verifying.add(key);++ try {++ const result = asObject(row.result) || {};++ const fromJob = asObject(result.goal) || {};++ const checkOutput = String(fromJob.check_output || '').slice(0, 262144);++ const proposal = String(fromJob.proposal || result.summary || '').slice(0, 4000);++ const evidence = String(fromJob.evidence || '').slice(0, 262144);++ let verdict;++ if (!Object.keys(fromJob).length) {++ verdict = { status: 'failed', reason: 'The harness did not run the goal check, so there is no evidence to verify.' };++ } else if (fromJob.error) {++ verdict = { status: 'failed', reason: `The goal check could not run: ${String(fromJob.error).slice(0, 500)}` };++ } else {++ const verifier = verifierClient();++ verdict = verifier++ ? await verifier.verify({ goal: goal.goal, check: goal.check, proposal, evidence, checkOutput })++ : { status: 'failed', reason: 'No independent verifier is configured, so the task needs a person.' };++ }++ verdict = { ...verdict, attempt_id: ending.id, at: store.iso() };++ // The no-progress limit: the same check output and reason twice in a row means the worker did not move.++ const signature = `${verdict.status}++${verdict.reason}++${String(verdict.check_output || '').slice(0, 50000)}`;++ const no_progress = verdict.status === 'rejected' && goal.last_rejection === signature ? (Number(goal.no_progress) || 0) + 1 : verdict.status === 'rejected' ? 1 : 0;++ const merged = {++ ...goal, proposal, check_output: checkOutput, verdict,++ ...(verdict.status === 'rejected' ? { last_rejection: signature, no_progress } : {}),++ };++ const saved = await db.one('UPDATE run_tasks SET goal = $2::jsonb, goal_verdict = $3::jsonb, updated_at = $4 WHERE id = $1 RETURNING *',++ [row.id, JSON.stringify(merged), JSON.stringify(verdict), store.now()]);++ const limit = Math.max(1, Number(goal.no_progress_limit) || 3);++ return {++ verdict, goal: merged, row: saved, no_progress,++ blocked: verdict.status === 'rejected' && no_progress >= limit,++ budgetHit: verdict.status === 'rejected' ? goalBudgetHit(await attemptStore.list(row.id), merged, saved) : null,++ };++ } finally { verifying.delete(key); }++ }++/*** An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the* decision out — a follow-up attempt, or the task's outcome in plain words.@@@ -755,7 +852,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou* carried one job on twice).*/async function afterEnd(taskId) {-- const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);++ let row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);if (!row) return null;if (!TERMINAL.has(row.state)) return row;if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on@@@ -778,6 +875,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst attempts = await attemptStore.list(row.id);const ending = attempts[attempts.length - 1];if (!ending || !ENDED.has(ending.state)) return row;++ // AL1: a goal task's "done" is a completion proposal. The check already ran in the task's workspace; an++ // independent verifier now accepts or rejects it, and the task cannot be ready without an acceptance.++ const goal = asObject(row.goal);++ if (goal && config.goals && config.goals.enabled && ending.state === 'done') {++ const checked = await verifyGoalEnding(row, ending, goal);++ if (checked.skipped) return row;++ row = checked.row;++ if (checked.verdict.status === 'failed') {++ return await finishGoal(row, { outcome: 'unfinished', note: `waiting: ${checked.verdict.reason}`, end_state: 'waiting' });++ }++ if (checked.blocked) {++ return await finishGoal(row, { outcome: 'unfinished', note: `blocked: the verifier rejected the completion ${checked.no_progress} times without progress: ${checked.verdict.reason}`, end_state: 'blocked' });++ }++ if (checked.budgetHit) {++ return await finishGoal(row, { outcome: 'unfinished', note: `stopped by budget: ${checked.budgetHit}`, end_state: 'stopped_by_budget' });++ }++ }// Know K5: what this attempt taught (its Learned: lines, a failure, the command that failed, the commands the// repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is// idempotent per (subject, kind, text), so a redelivered ending adds no second row.@@@ -790,10 +904,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst collected = await recordProof(row, ending);const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });if (plan.outcome) {-- // The outcome in plain words: what the engine decided, and what the proof says about it.-- const outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });-- const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',-- [row.id, outcome.state, outcome.note, store.now()]);++ // The outcome in plain words: what the engine decided, and what the proof says about it. A goal task also++ // carries its honest end state: ready only with an accepted verdict, stopped by budget when the budget ran out.++ let outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });++ const savedGoal = asObject(row.goal);++ const goalVerdict = goal && savedGoal ? asObject(savedGoal.verdict) : null;++ // An accepted goal is done even when another proof item (a review, a screenshot) failed: the goal's own++ // independent verdict is the outcome, and the failed item is said in the proof below it.++ if (goal && plan.outcome === 'done' && goalVerdict && goalVerdict.status === 'accepted') outcome = { state: 'done', note: 'the task finished' };++ const budgetEnd = ending.reason === 'budget' || /budget/i.test(String(outcome.note || ''));++ const endState = goal++ ? (plan.outcome === 'done' && goalVerdict && goalVerdict.status === 'accepted' ? 'ready'++ : budgetEnd ? 'stopped_by_budget'++ : plan.outcome === 'stopped' ? 'dropped'++ : plan.outcome === 'unfinished' ? 'waiting' : 'failed')++ : null;++ const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, end_state = $4, updated_at = $5 WHERE id = $1 RETURNING *',++ [row.id, outcome.state, outcome.note, endState, store.now()]);await notifyOutcome(saved);await recordPlacementOutcome(saved);// Agent Streams S3: the finished task's report, from its proof. A report that cannot be written never@@@ -804,6 +931,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oureturn saved;}if (!plan.kind) return row;++ // The goal's budgets are checked before the next turn starts: a spent budget ends the task here, with its reason.++ if (goal) {++ const hit = goalBudgetHit(attempts, asObject(row.goal) || goal, row);++ if (hit) return await finishGoal(row, { outcome: 'unfinished', note: `stopped by budget: ${hit}`, end_state: 'stopped_by_budget' });++ }const done = await startFollowUp(row, plan, ending);return done.row || row;}@@@ -853,7 +985,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouasync function startFollowUp(row, plan, ending) {const left = await remainingBudget(row.id);if (left.exhausted) {-- const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,++ const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2,++ end_state = CASE WHEN goal IS NOT NULL THEN 'stopped_by_budget' ELSE end_state END,++ updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, budgetLeftNote(left), store.now()]);await recordPlacementOutcome(stopped);return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };@@@ -894,7 +1028,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });const updated = await db.one(`UPDATE run_tasks SET job_id = $2, state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL,-- finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,++ end_state = NULL, finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, job.id, store.now()]);await taskOutbox.emitTask(updated, row.state);return { attempt, created: true, job, row: updated };@@@ -945,7 +1079,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou// of it, the attempt does not start and the task ends at its budget.const left = await remainingBudget(row.id);if (left.exhausted) {-- const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,++ const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2,++ end_state = CASE WHEN goal IS NOT NULL THEN 'stopped_by_budget' ELSE end_state END,++ updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, budgetLeftNote(left), store.now()]);await recordPlacementOutcome(stopped);return { error: 'the task has no budget left', row: stopped };@@@ -960,7 +1096,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou});attempt = made.attempt;if (claims.length) await filesStore.link(row.subject, claims, { taskId: row.id, attemptN: Number(attempt.n), messageId: turnId });-- await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,++ await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, end_state = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,[row.id, store.now()]);return { row: current };});@@@ -1026,6 +1162,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),...(plan.session ? { resume: plan.session } : {}),...(plan.attachments && plan.attachments.length ? { attachments: plan.attachments } : {}),++ // AL1: every turn of a goal task runs the same check in the workspace it worked in.++ ...(asObject(row.goal) ? { goal: asObject(row.goal) } : {}),}));}@@@ -1046,6 +1184,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouif (TERMINAL.has(current.state) && current.outcome) return { error: 'the task has already ended', row: current };const updated = await db.one(`UPDATE run_tasks SET state = 'cancelled', outcome = 'stopped', outcome_note = $2,++ end_state = CASE WHEN goal IS NOT NULL THEN 'dropped' ELSE end_state END,finished_at = COALESCE(finished_at, $3::timestamptz), updated_at = $4 WHERE id = $1 RETURNING *`,[row.id, 'the task was stopped on purpose', store.iso(), store.now()]);await taskOutbox.emitTask(updated, current.state);@@@ -1063,11 +1202,13 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oufunction present(row) {const result = TERMINAL.has(row.state) ? presentResult(asObject(row.result)) : undefined;++ const goal = asObject(row.goal);++ const verdict = asObject(row.goal_verdict) || (goal ? asObject(goal.verdict) : null);return drop({id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),error: row.error ? String(row.error).slice(0, 1000) : null, result,-- ...(row.goal ? { goal: asObject(row.goal), goal_verdict: asObject(row.goal_verdict) || null } : {}),++ ...(goal ? { goal: { ...goal, ...(verdict ? { verdict } : {}) } } : {}),});}@@@ -81,13 +81,25 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to start a task');if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');const b = req.body || {};-- const check = validate('codes.task-start-request@1', b);++ // AL1: a goal and its typed check are optional. The goal is validated here (it is not part of the closed start++ // request while the switch is off) and travels to the harness, which runs the check in the task's workspace.++ let goal = null;++ if (b.goal != null) {++ if (!config.goals.enabled) return problem(req, res, 409, 'codes.goals_disabled', 'goal tasks are disabled');++ const g = b.goal, limits = g && typeof g === 'object' && !Array.isArray(g) ? (g.budget || {}) : {};++ if (!g || typeof g !== 'object' || Array.isArray(g) || typeof g.goal !== 'string' || !g.goal.trim() || g.goal.length > 4000 || !g.check || !['command', 'url', 'rubric'].includes(g.check.kind) || typeof g.check.value !== 'string' || !g.check.value.trim() || g.check.value.length > 4000) return problem(req, res, 422, 'codes.goal_invalid', 'goal requires a condition and a typed check');++ if (!limits || typeof limits !== 'object' || Array.isArray(limits) || Object.entries(limits).some(([k, v]) => !['tokens', 'turns', 'active_minutes'].includes(k) || !Number.isInteger(v) || v < 1) || (g.no_progress_limit != null && (!Number.isInteger(g.no_progress_limit) || g.no_progress_limit < 1 || g.no_progress_limit > 20))) return problem(req, res, 422, 'codes.goal_invalid', 'goal budgets and no-progress limit must be positive bounded integers');++ goal = { goal: g.goal.trim(), check: { kind: g.check.kind, value: g.check.value.trim() }, budget: limits, no_progress_limit: g.no_progress_limit || 3, proposal: null, evidence: null, verdict: null };++ }++ const bodyForValidate = { ...b };++ delete bodyForValidate.goal;++ const check = validate('codes.task-start-request@1', bodyForValidate);if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');let args;try {args = parseArgs(JSON.stringify({-- repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit',++ repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit', ...(goal ? { goal } : {}),...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),}));} catch (e) {@@@ -29,6 +29,15 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;/** The task's outcome in the words a person reads. */const OUTCOME_TEXT = { done: 'Done', unfinished: "Didn't finish", stopped: 'Stopped by you' };++/** AL1's honest end states, in the same words. A task without a goal keeps the outcome above. */++const END_STATE_TEXT = {++ ready: 'Ready', waiting: 'Waiting', blocked: 'Blocked', failed: 'Failed', dropped: 'Dropped by you', stopped_by_budget: 'Stopped by budget',++};++const asObject = (v) => {++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;++ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }++ return null;++};/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */function repoName(url) {@@@ -246,6 +255,20 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat</section>`;}++ /** A goal task's own record (AL1): the condition, its typed check, the check's output and the verifier's verdict. */++ function goalHtml(row) {++ const goal = asObject(row.goal);++ if (!goal) return '';++ const verdict = asObject(row.goal_verdict) || asObject(goal.verdict);++ return html`<section class="run-goal" data-goal>++<h2>The goal and its check</h2>++<p data-goal-text>${goal.goal || ''}</p>++<p class="muted">Check (${goal.check && goal.check.kind}): <code>${goal.check && goal.check.value}</code></p>++${verdict ? html`<p data-goal-verdict data-status="${verdict.status}"><b>${verdict.status}</b>${verdict.reason ? ` · ${verdict.reason}` : ''}${verdict.model ? html` <span class="muted">(${verdict.model})</span>` : ''}</p>` : html`<p class="muted">No independent verdict yet.</p>`}++${verdict && verdict.check_output ? html`<h3>The check's output</h3><pre tabindex="0" class="run-goal-output" data-goal-check-output>${verdict.check_output}</pre>` : ''}++</section>`;++ }++/** The task's timeline: every attempt and why it happened. The data-* hooks are for the design of stage C4. */async function timelineHtml(row) {const attempts = await taskStore.attempts.list(row.id);@@@ -253,10 +276,11 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat// The outcome in plain words: the label, and the note beside it — unless the note already says it// ("Done with proof: …" says "Done" already, so it is not said twice).const note = row.outcome_note || '';-- const label = row.outcome ? (OUTCOME_TEXT[row.outcome] || row.outcome) : '';++ const endState = row.end_state || null;++ const label = endState ? (END_STATE_TEXT[endState] || endState) : (row.outcome ? (OUTCOME_TEXT[row.outcome] || row.outcome) : '');const says = !!label && note.toLowerCase().startsWith(label.toLowerCase());-- const outcome = row.outcome-- ? html`<p class="run-outcome" data-outcome="${row.outcome}"><b>${says ? note : [label, note].filter(Boolean).join(' ')}</b></p>`++ const outcome = (endState || row.outcome)++ ? html`<p class="run-outcome" data-outcome="${endState || row.outcome}"${endState ? raw(' data-end-state') : ''}><b>${says ? note : [label, note].filter(Boolean).join(' ')}</b></p>`: '';return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}"><h2>The attempts</h2>@@@ -317,6 +341,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}<pre tabindex="0" class="run-prompt">${row.prompt}</pre>${await timelineHtml(row)}++${goalHtml(row)}${await proofHtml(row)}${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;@@@ -145,7 +145,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskreturn {...taskStore.present(row),attempts: attempts.map((a) => taskStore.attempts.present(a)),-- outcome: { state: row.outcome || null, note: row.outcome_note || null },++ outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null },};};@@@ -325,7 +325,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskif (state === 'needs') where.sql += " AND outcome = 'unfinished'";if (state === 'done') where.sql += " AND state IN ('succeeded', 'failed', 'cancelled', 'expired') AND outcome IS DISTINCT FROM 'unfinished'";const rows = await db.many(`SELECT * FROM run_tasks WHERE ${where.sql} ORDER BY created_at DESC LIMIT $${where.args.length + 1}`, [...where.args, limit]);-- return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.outcome || null, note: row.outcome_note || null } })) });++ return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null } })) });});r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {@@@ -14,6 +14,9 @@ const goal = require('../engine/goal');assert.match(seen, /person’s own recorded input/);const invalid = await goal.verify({ goal: 'approved', check: {}, callVerifier: async () => 'maybe' });assert.equal(invalid.status, 'rejected');++ // A verifier that did not run is neither an acceptance nor the worker's failure: the task waits on a person.++ const failed = await goal.verify({ goal: 'file exists', check: { kind: 'command', value: 'test -f x' }, callVerifier: async () => { throw new Error('AI is down'); } });++ assert.equal(failed.status, 'failed');});done();})();nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const { runCheck } = require('../harness/goal-check');++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'goal-check-'));++ try {++ await check('a command check runs in the workspace and its exit code is the machine\'s answer', async () => {++ fs.writeFileSync(path.join(dir, 'ok.js'), 'module.exports = 1;\n');++ const good = await runCheck({ kind: 'command', value: 'node --check ok.js' }, { cwd: dir });++ assert.equal(good.ok, true, good.output);++ assert.equal(good.exit_code, 0);++ const bad = await runCheck({ kind: 'command', value: 'node --check missing.js' }, { cwd: dir });++ assert.equal(bad.ok, false);++ assert.notEqual(bad.exit_code, 0);++ assert.match(bad.output, /missing\.js/);++ });++++ await check('a URL check passes only on a 2xx', async () => {++ const pass = await runCheck({ kind: 'url', value: 'https://example.test/ok' }, {++ fetchImpl: async () => ({ ok: true, status: 200, statusText: 'OK', text: async () => '{}' }),++ });++ assert.equal(pass.ok, true);++ assert.equal(pass.status, 200);++ const fail = await runCheck({ kind: 'url', value: 'https://example.test/no' }, {++ fetchImpl: async () => ({ ok: false, status: 503, statusText: 'No', text: async () => 'down' }),++ });++ assert.equal(fail.ok, false);++ assert.equal(fail.status, 503);++ });++++ await check('a rubric has no machine verdict and an unknown kind fails closed', async () => {++ const rubric = await runCheck({ kind: 'rubric', value: 'the copy is honest' }, { cwd: dir });++ assert.equal(rubric.ok, null);++ const unknown = await runCheck({ kind: 'vibes', value: 'x' }, { cwd: dir });++ assert.equal(unknown.ok, false);++ });++ } finally {++ fs.rmSync(dir, { recursive: true, force: true });++ }++ done();++})();@@@ -41,7 +41,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = await require('./db').testDb();-- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });++ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; OpenVibe Git repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person','server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person','server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',++ 'server/domain/goal-verifier.js': 'OpenVibe.AI (CODES_AI_URL, configured) POST /v1/chat/completions, or /v1/messages on a 404, for the independent goal verifier: the operator-set base and fixed paths only, never a URL from a person or a developer','server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model','server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person','server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a lnk_ shape, never a URL from a person',nnew file mode 100644@@@ -0,0 +1,198 @@++'use strict';++++/**++ * AL1: a goal task is not done on the worker's word. The harness runs the typed check in the workspace and the result++ * carries its output; Codes sends a bounded evidence window to an independent cheap model, and only an acceptance ends++ * the task ready. A rejection resumes the same session; a spent goal budget ends the task stopped by budget; a task++ * with no goal behaves exactly as before.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const submitted = [];++ const jobs = new Map();++ let counter = 0;++ const run = {++ enabled: true,++ async submit(o) {++ counter++;++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2K${String(counter).padStart(2, '0')}`;++ const parsed = JSON.parse(JSON.stringify(o.args));++ jobs.set(id, { id, state: 'queued', args: parsed });++ submitted.push({ id, args: parsed, key: o.key });++ return { id, state: 'queued' };++ },++ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },++ async cancel(jobId) { const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },++ async streamUrl() { throw new Error('not used'); },++ };++ // The independent verifier: a fake in the tests, so no model is called. It answers in order from `next`.++ const asked = [];++ let next = [];++ const goalVerifier = {++ model: 'deepseek-chat',++ async verify(what) { asked.push(what); return next.shift() || { status: 'rejected', reason: 'nothing configured', check_output: what.checkOutput }; },++ };++ const t = await boot({ run, goalVerifier, env: { CODES_GOALS_ENABLED: '1', CODES_TASK_STEPS: '12' } });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];++ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);++++ const call = async (method, path, { body } = {}) => {++ const res = await fetch(`${t.base}/api/v1${path}`, {++ method,++ headers: { Accept: 'application/json', Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject, ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body === undefined ? undefined : JSON.stringify(body),++ });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);++ const attemptsOf = async (id) => await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [id]);++++ const GOAL = {++ goal: 'The file notes.txt exists and is not empty.',++ check: { kind: 'command', value: 'test -s notes.txt' },++ budget: { tokens: 1000000, turns: 12, active_minutes: 60 },++ };++++ /** A task through the API, with or without a goal. */++ const startTask = async (goal = GOAL) => {++ const body = { repo: 'https://github.com/octocat/Hello-World', prompt: 'Write notes.txt.', idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` };++ if (goal) body.goal = goal;++ const r = await call('POST', '/tasks', { body });++ assert.strictEqual(r.status, 201, JSON.stringify(r.json));++ return r.json.task.id;++ };++++ /** The worker's turn ends: the job's state is applied and the engine decides (what the events consumer does). */++ const endAttempt = async (id, { state = 'succeeded', go = null, session = 'sess-1', turns = 2 } = {}) => {++ const row = await rowOf(id);++ const result = {++ ok: state === 'succeeded', summary: 'I wrote notes.txt.', files: [{ path: 'notes.txt', added: 1, removed: 0 }],++ turns, log: [{ t: 'agent', agent: row.agent, session }, { t: 'done', ok: state === 'succeeded', text: 'I wrote notes.txt.' }],++ ...(go ? { goal: go } : {}),++ };++ const job = { id: row.job_id, state, result };++ const updated = await t.ctx.tasks.applyEvent(row, { state, job });++ await t.ctx.tasks.afterEnd(updated.id);++ return { row: await rowOf(id), attempts: await attemptsOf(id) };++ };++++ const acceptedCheck = { check_output: 'exit 0\n(no output)', checked: { ok: true }, proposal: 'I wrote notes.txt.', evidence: '{"check":{"ok":true}}' };++ const rejectedCheck = { check_output: 'exit 1\nnotes.txt is empty', checked: { ok: false }, proposal: 'I wrote notes.txt.', evidence: '{"check":{"ok":false}}' };++++ try {++ // A person's service task runs in the project of their latest task on /run: seed one, as a first visit would.++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, agent, state, created_at, updated_at)++ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J90',++ 'https://github.com/octocat/Hello-World', 'the first one', 'claude-code', 'succeeded', 1, 2)`, [ada.subject]);++++ await check('a passing check and an accepting verifier end the task ready, with the evidence kept', async () => {++ next = [{ status: 'accepted', reason: 'the check passed and the evidence matches', check_output: acceptedCheck.check_output }];++ const id = await startTask();++ await endAttempt(id, { go: acceptedCheck });++ const row = await rowOf(id);++ assert.ok(row.goal, 'the task kept its goal');++ assert.strictEqual(row.end_state, 'ready');++ assert.strictEqual(row.outcome, 'done');++ const verdict = row.goal_verdict;++ assert.strictEqual(verdict.status, 'accepted');++ assert.strictEqual(verdict.check_output, acceptedCheck.check_output);++ assert.ok(verdict.reason);++ const shown = await call('GET', `/tasks/${id}`);++ assert.strictEqual(shown.json.outcome.state, 'ready');++ assert.strictEqual(shown.json.goal.verdict.status, 'accepted');++ assert.strictEqual(shown.json.goal.check_output, acceptedCheck.check_output);++ assert.strictEqual((await attemptsOf(id)).length, 1, 'an accepted goal starts no follow-up');++ assert.strictEqual(asked.length, 1, 'the verifier was asked once');++ assert.match(asked[0].checkOutput, /exit 0/);++ assert.strictEqual(submitted[0].args.goal.check.value, GOAL.check.value, 'the harness got the check');++ const page = await t.get(`/run/tasks/${id}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /The goal and its check/);++ assert.match(page.text, /exit 0/);++ assert.match(page.text, /accepted/);++ });++++ await check('a failing check is rejected and the SAME session is resumed with the check output', async () => {++ next = [{ status: 'rejected', reason: 'notes.txt is empty, so the check failed', check_output: rejectedCheck.check_output }];++ const id = await startTask();++ const { row, attempts } = await endAttempt(id, { go: rejectedCheck, session: 'sess-goal-7' });++ assert.strictEqual(attempts.length, 2, 'the task was carried on');++ assert.strictEqual(attempts[1].kind, 'continue');++ assert.strictEqual(attempts[1].agent, attempts[0].agent, 'the same worker');++ assert.strictEqual(attempts[1].session, 'sess-goal-7', 'the same session');++ const resume = submitted[submitted.length - 1].args.resume;++ assert.strictEqual(resume, 'sess-goal-7');++ assert.strictEqual(submitted[submitted.length - 1].args.goal.check.value, GOAL.check.value, 'the same check runs again in the workspace');++ assert.match(submitted[submitted.length - 1].args.prompt, /notes\.txt is empty/);++ assert.match(submitted[submitted.length - 1].args.prompt, /exit 1/);++ assert.strictEqual(row.state, 'queued', 'the follow-up is running');++ assert.strictEqual(row.goal.no_progress, 1);++ });++++ await check('a spent turn budget ends the task stopped by budget, with the reason', async () => {++ next = [{ status: 'rejected', reason: 'still empty', check_output: rejectedCheck.check_output }];++ const id = await startTask({ ...GOAL, budget: { turns: 1 } });++ const { row, attempts } = await endAttempt(id, { go: rejectedCheck, turns: 7 });++ assert.strictEqual(attempts.length, 1, 'no follow-up is bought with a spent budget');++ assert.strictEqual(row.outcome, 'unfinished');++ assert.strictEqual(row.end_state, 'stopped_by_budget');++ assert.match(row.outcome_note, /stopped by budget: the task used 7 of its 1 turns/);++ const shown = await call('GET', `/tasks/${id}`);++ assert.strictEqual(shown.json.outcome.state, 'stopped_by_budget');++ });++++ await check('a verifier that could not run leaves the task waiting, never ready', async () => {++ next = [{ status: 'failed', reason: 'the model was unavailable', check_output: acceptedCheck.check_output }];++ const id = await startTask();++ const { row, attempts } = await endAttempt(id, { go: acceptedCheck });++ assert.strictEqual(attempts.length, 1);++ assert.strictEqual(row.end_state, 'waiting');++ assert.match(row.outcome_note, /waiting: the model was unavailable/);++ });++++ await check('the no-progress limit blocks the task instead of looping the same failure', async () => {++ const id = await startTask({ ...GOAL, no_progress_limit: 2, budget: { tokens: 1000000, turns: 10 } });++ next = [{ status: 'rejected', reason: 'same failure', check_output: 'exit 1\nsame output' }];++ const first = await endAttempt(id, { go: rejectedCheck });++ assert.strictEqual(first.attempts.length, 2, 'the first rejection continues');++ next = [{ status: 'rejected', reason: 'same failure', check_output: 'exit 1\nsame output' }];++ const second = await endAttempt(id, { go: rejectedCheck });++ assert.strictEqual(second.attempts.length, 2, 'the second identical rejection starts nothing');++ assert.strictEqual(second.row.end_state, 'blocked');++ assert.match(second.row.outcome_note, /blocked: the verifier rejected the completion 2 times without progress/);++ });++++ await check('a task with no goal behaves exactly as before: done on the worker, no verifier', async () => {++ const before = asked.length;++ const id = await startTask(null);++ const { row, attempts } = await endAttempt(id);++ assert.strictEqual(asked.length, before, 'the verifier is never asked without a goal');++ assert.strictEqual(row.outcome, 'done');++ assert.strictEqual(row.end_state, null);++ assert.strictEqual(attempts.length, 1);++ });++++ await check('a goal is refused while the switch is off', async () => {++ const other = await boot({ run, env: { CODES_GOALS_ENABLED: '0' } });++ try {++ const otherAda = other.network.addUser('ada');++ const otherActor = other.network.mintService('svc:actor', 'openvibe.codes', CAPS);++ const r = await fetch(`${other.base}/api/v1/tasks`, {++ method: 'POST',++ headers: { Accept: 'application/json', Authorization: `Bearer ${otherActor}`, 'X-OV-Subject': otherAda.subject, 'Content-Type': 'application/json' },++ body: JSON.stringify({ repo: 'https://github.com/octocat/Hello-World', prompt: 'x', goal: GOAL, idempotency_key: 'act_switch_off' }),++ });++ assert.strictEqual(r.status, 409);++ const body = await r.json();++ assert.strictEqual(body.code, 'codes.goals_disabled');++ } finally { await other.close(); }++ });++ } finally {++ await t.close();++ }++ done();++})();
@@ -49,6 +49,13 @@ OV_OAUTH_CLIENT_SECRET=
@@ -49,6 +49,13 @@ OV_OAUTH_CLIENT_SECRET=
#CODES_AI_CATALOG=0
#CODES_AI_CATALOG=0
#CODES_AI_CATALOG_TTL_MS=300000
#CODES_AI_CATALOG_TTL_MS=300000
+# Goals and the independent verifier (AL1), off by default. With CODES_GOALS_ENABLED=1 a task may carry a goal and a
+# typed check; the harness runs the check in the task's workspace and a cheap fast model on OpenVibe.AI accepts or
+# rejects with a reason. CODES_GOALS_MODEL names that rung (default deepseek-chat).
+#CODES_GOALS_ENABLED=0
+#CODES_GOALS_MODEL=deepseek-chat
+#CODES_GOALS_TIMEOUT_MS=120000
+
# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a
# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a
# hard-coded home; without it a job gets no lessons. Never a secret.
# hard-coded home; without it a job gets no lessons. Never a secret.
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
@@ -433,7 +433,18 @@ function settledParent(runs, end) {
@@ -433,7 +433,18 @@ function settledParent(runs, end) {
function step(runs, end, opts = {}) {
function step(runs, end, opts = {}) {
if (!end) return { action: 'none', why: 'there are no attempts' };
if (!end) return { action: 'none', why: 'there are no attempts' };
if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
- if (end.status === 'done' && end.goal && end.goal.verdict && end.goal.verdict.status === 'rejected') return { action: 'continue', why: end.goal.verdict.reason || 'the verifier rejected the completion proposal' };
+ // AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the
+ // goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.
+ if (end.status === 'done' && end.goal) {
+ const verdict = end.goal.verdict || null;
+ if (!verdict || verdict.status !== 'accepted') {
+ return {
+ action: 'continue',
+ session: facts(end).session,
+ why: (verdict && verdict.reason) || 'the goal has no independent acceptance yet',
+ };
+ }
+ }
if (end.status === 'done') return { action: 'done', why: 'the task finished' };
if (end.status === 'done') return { action: 'done', why: 'the task finished' };
const why = end.reason || reason(end.error);
const why = end.reason || reason(end.error);
if (why === 'stopped') return { action: 'stop', why: 'the task was stopped on purpose' };
if (why === 'stopped') return { action: 'stop', why: 'the task was stopped on purpose' };
@@ -36,7 +36,9 @@ async function verify({ goal, check, proposal, evidence, checkOutput, callVerifi
@@ -36,7 +36,9 @@ async function verify({ goal, check, proposal, evidence, checkOutput, callVerifi
const verdict = String(doc && doc.verdict || '').toLowerCase();
const verdict = String(doc && doc.verdict || '').toLowerCase();
return { status: verdict === 'accept' ? 'accepted' : 'rejected', reason: String(doc && doc.reason || 'The verifier did not return a valid acceptance verdict.').slice(0, 4000), check_output: boundedEvidence(checkOutput) };
return { status: verdict === 'accept' ? 'accepted' : 'rejected', reason: String(doc && doc.reason || 'The verifier did not return a valid acceptance verdict.').slice(0, 4000), check_output: boundedEvidence(checkOutput) };
} catch (error) {
} catch (error) {
- return { status: 'rejected', reason: error && error.name === 'AbortError' ? 'The independent verifier timed out.' : 'The independent verifier failed to produce a verdict.', check_output: boundedEvidence(checkOutput) };
+ // The verifier did not run: that is not the worker's failure and it is not an acceptance. A `failed` verdict
+ // leaves the task waiting on a person instead of looping the worker against an unavailable model.
+ return { status: 'failed', reason: error && error.name === 'AbortError' ? 'The independent verifier timed out.' : 'The independent verifier failed to produce a verdict.', check_output: boundedEvidence(checkOutput) };
} finally { clearTimeout(timer); }
} finally { clearTimeout(timer); }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * The deterministic half of a goal (AL1): the check runs in the task's own workspace, not in the model's words.
+ *
+ * A worker proposes completion; the harness then runs the task's typed check against the clone the worker worked in
+ * and carries the output back as evidence. The check is the machine's act, not the agent's: the agent may write the
+ * file, but it cannot write the command's exit code. An independent verifier reads this output with a bounded evidence
+ * window (engine/goal.js) and accepts or rejects.
+ *
+ * runCheck(check, { cwd, timeoutMs, fetchImpl }) → { kind, value, ok, output, exit_code?, status?, at }
+ *
+ * A `command` runs through bash in the workspace; a `url` is a GET and passes on a 2xx; a `rubric` has no
+ * deterministic machine check, so its output says the verifier reads the evidence and `ok` is null. An unknown kind
+ * fails the check instead of hanging the run.
+ */
+
+const { execFile } = require('child_process');
+
+/** What a check's own output may take of the result channel: the verifier's evidence window is 256 KB. */
+const MAX_OUTPUT = 256 * 1024;
+const DEFAULT_TIMEOUT_MS = 120_000;
+
+/** A string cut to the evidence window, never split mid-character. */
+function bounded(value, max = MAX_OUTPUT) {
+ const text = typeof value === 'string' ? value : String(value == null ? '' : value);
+ const bytes = Buffer.from(text, 'utf8');
+ return bytes.length <= max ? text : bytes.subarray(0, max).toString('utf8');
+}
+
+/** One command in the workspace. Rejects only on a check it could not start; a non-zero exit is an answer. */
+function command(value, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {
+ return new Promise((resolve) => {
+ execFile('/bin/bash', ['-lc', value], {
+ cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT * 2, env: { ...env, CI: '1', GIT_TERMINAL_PROMPT: '0' },
+ }, (err, stdout, stderr) => {
+ const out = `${String(stdout || '')}${String(stderr || '')}`;
+ const timedOut = !!(err && (err.killed || err.signal === 'SIGTERM'));
+ resolve({
+ ok: !err,
+ exit_code: err && typeof err.code === 'number' ? err.code : (err ? null : 0),
+ timed_out: timedOut,
+ output: bounded(out.trim() ? out : (err ? String(err.message) : '(no output)')),
+ });
+ });
+ });
+}
+
+/** One GET of the URL assertion. 2xx is the check's pass; anything else is its answer. */
+async function url(value, { fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
+ let target;
+ try { target = new URL(value); } catch { return { ok: false, output: 'the check is not a URL' }; }
+ if (!/^https?:$/.test(target.protocol)) return { ok: false, output: 'the check URL must be http or https' };
+ if (typeof fetchImpl !== 'function') return { ok: false, output: 'this machine cannot fetch the check URL' };
+ try {
+ const res = await fetchImpl(target.toString(), { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
+ const body = await res.text().catch(() => '');
+ return {
+ ok: res.ok,
+ status: res.status,
+ output: bounded(`HTTP ${res.status} ${res.statusText || ''}\n${String(body || '').slice(0, 4000)}`),
+ };
+ } catch (e) {
+ return { ok: false, output: bounded(`the URL check failed: ${e && e.message ? e.message : e}`) };
+ }
+}
+
+/**
+ * Run one typed check. Never throws for a check that fails: a failure is the answer the verifier needs.
+ *
+ * @param {object} check { kind, value }
+ * @param {object} [what] { cwd, timeoutMs, fetchImpl, env }
+ * @returns {Promise<object>} the check's own record, suitable as evidence
+ */
+async function runCheck(check, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch, env = process.env } = {}) {
+ const kind = String((check && check.kind) || '');
+ const value = String((check && check.value) || '');
+ const at = new Date().toISOString();
+ if (!value.trim()) return { kind, value, ok: false, output: 'the check is empty', at };
+ if (kind === 'command') {
+ if (!cwd) return { kind, value, ok: false, output: 'the check has no workspace to run in', at };
+ return { kind, value, at, ...(await command(value, { cwd, timeoutMs, env })) };
+ }
+ if (kind === 'url') return { kind, value, at, ...(await url(value, { fetchImpl, timeoutMs: Math.min(timeoutMs, 30_000) })) };
+ if (kind === 'rubric') return { kind, value, ok: null, output: 'A rubric has no machine check: the independent verifier reads the evidence.', at };
+ return { kind, value, ok: false, output: `unknown check kind ${JSON.stringify(kind)}`, at };
+}
+
+module.exports = { runCheck, bounded, MAX_OUTPUT, DEFAULT_TIMEOUT_MS };
@@ -21,6 +21,8 @@ const path = require('path');
@@ -21,6 +21,8 @@ const path = require('path');
const { execFile } = require('child_process');
const { execFile } = require('child_process');
const { collectProof } = require('../engine/proof');
const { collectProof } = require('../engine/proof');
const { createCheckpoints, toolPaths } = require('./checkpoints');
const { createCheckpoints, toolPaths } = require('./checkpoints');
+const { runCheck } = require('./goal-check');
+const { boundedEvidence } = require('../engine/goal');
const JOB_PROTOCOL = '1.0.0';
const JOB_PROTOCOL = '1.0.0';
/** The agents a conductor's turn may run on: every harness Codes offers. */
/** The agents a conductor's turn may run on: every harness Codes offers. */
@@ -257,6 +259,18 @@ function parseArgs(raw, env = process.env) {
@@ -257,6 +259,18 @@ function parseArgs(raw, env = process.env) {
tokens: Number.isFinite(Number(a.limits.tokens)) && Number(a.limits.tokens) > 0 ? Math.min(1000000, Number(a.limits.tokens)) : null,
tokens: Number.isFinite(Number(a.limits.tokens)) && Number(a.limits.tokens) > 0 ? Math.min(1000000, Number(a.limits.tokens)) : null,
usd: Number.isFinite(Number(a.limits.usd)) && Number(a.limits.usd) > 0 ? Math.min(100, Number(a.limits.usd)) : null,
usd: Number.isFinite(Number(a.limits.usd)) && Number(a.limits.usd) > 0 ? Math.min(100, Number(a.limits.usd)) : null,
} : null;
} : null;
+ // AL1: a task's goal and its typed check. The harness runs the check itself, in the clone, after the worker's
+ // turn; the model never supplies the exit code. Unknown or malformed goals are dropped, never guessed at.
+ const rawGoal = a.goal && typeof a.goal === 'object' && !Array.isArray(a.goal) ? a.goal : null;
+ const goalCheck = rawGoal && rawGoal.check && typeof rawGoal.check === 'object' ? rawGoal.check : null;
+ const goal = rawGoal && typeof rawGoal.goal === 'string' && rawGoal.goal.trim()
+ && goalCheck && ['command', 'url', 'rubric'].includes(goalCheck.kind) && typeof goalCheck.value === 'string' && goalCheck.value.trim()
+ ? {
+ goal: rawGoal.goal.trim().slice(0, 4000),
+ check: { kind: goalCheck.kind, value: goalCheck.value.trim().slice(0, 4000) },
+ budget: rawGoal.budget && typeof rawGoal.budget === 'object' && !Array.isArray(rawGoal.budget) ? rawGoal.budget : {},
+ no_progress_limit: Number.isInteger(rawGoal.no_progress_limit) ? rawGoal.no_progress_limit : 3,
+ } : null;
return {
return {
kind, repo: repo ? repo.toString() : null, ref, prompt, permission, push,
kind, repo: repo ? repo.toString() : null, ref, prompt, permission, push,
// The task this job is an attempt of, when the server names it: the checkpoint refs carry it.
// The task this job is an attempt of, when the server names it: the checkpoint refs carry it.
@@ -274,6 +288,7 @@ function parseArgs(raw, env = process.env) {
@@ -274,6 +288,7 @@ function parseArgs(raw, env = process.env) {
...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),
...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
+ ...(goal ? { goal } : {}),
};
};
}
}
@@ -841,6 +856,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -841,6 +856,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
if (last && last.disclosure && typeof last.disclosure === 'object') result.disclosure = last.disclosure;
if (last && last.disclosure && typeof last.disclosure === 'object') result.disclosure = last.disclosure;
const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};
const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};
result.tokens = Number(last && (last.total_tokens || last.tokens)) || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0);
result.tokens = Number(last && (last.total_tokens || last.tokens)) || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0);
+ if (last && Number.isFinite(Number(last.turns))) result.turns = Number(last.turns);
// The session the next message resumes: a conductor's turn ends, its conversation does not.
// The session the next message resumes: a conductor's turn ends, its conversation does not.
if (job.kind === 'conductor' && last && last.session_id) result.session = String(last.session_id).slice(0, 200);
if (job.kind === 'conductor' && last && last.session_id) result.session = String(last.session_id).slice(0, 200);
// A conductor works in the owner's own folder: its changes are theirs, not a patch for a pull request.
// A conductor works in the owner's own folder: its changes are theirs, not a patch for a pull request.
@@ -882,6 +898,33 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -882,6 +898,33 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.proof = { items: [], outcome: null };
result.proof = { items: [], outcome: null };
}
}
}
}
+ // The goal's own check (AL1): the machine runs it in the clone, after the worker's turn, and the result carries
+ // the output as evidence. It never decides alone: the independent verifier reads the evidence server-side.
+ if (job.goal && job.kind === 'code') {
+ say({ t: 'step', text: 'Running the goal check' });
+ try {
+ const checked = await runCheck(job.goal.check, { cwd: work, fetchImpl });
+ const evidence = boundedEvidence(JSON.stringify({
+ check: { kind: checked.kind, value: checked.value, ok: checked.ok, exit_code: checked.exit_code, status: checked.status, output: checked.output },
+ summary: answer.slice(0, 4000),
+ files: result.files.slice(0, 200).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
+ proof: result.proof ? result.proof.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary })) : null,
+ }));
+ result.goal = {
+ goal: job.goal.goal,
+ check: job.goal.check,
+ proposal: answer.slice(0, 4000),
+ check_output: checked.output,
+ evidence,
+ checked: { ok: checked.ok, exit_code: checked.exit_code == null ? null : checked.exit_code, status: checked.status == null ? null : checked.status, at: checked.at },
+ };
+ say({ t: 'note', level: checked.ok === false ? 'warning' : 'info', text: `The goal check ${checked.ok === true ? 'passed' : checked.ok === false ? 'did not pass' : 'is for the verifier'}: ${checked.kind} ${checked.value.slice(0, 300)}` });
+ if (checked.output) say({ t: 'note', level: 'info', text: `Check output: ${checked.output.slice(-2000)}` });
+ } catch (e) {
+ result.goal = { goal: job.goal.goal, check: job.goal.check, proposal: answer.slice(0, 4000), check_output: '', evidence: '', error: scrub(e.message) };
+ say({ t: 'note', level: 'warning', text: `The goal check could not run: ${scrub(e.message)}` });
+ }
+ }
if (job.push && result.ok && result.files.length) {
if (job.push && result.ok && result.files.length) {
say({ t: 'step', text: 'Pushing a new branch' });
say({ t: 'step', text: 'Pushing a new branch' });
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- AL1: the honest end state of a goal task (ready, waiting, blocked, failed, dropped, stopped_by_budget), and the
+-- model turns an attempt used so a goal's turn budget can be counted across attempts.
+ALTER TABLE run_tasks
+ ADD COLUMN IF NOT EXISTS end_state text;
+ALTER TABLE codes_attempts
+ ADD COLUMN IF NOT EXISTS turns integer;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1149,9 +1149,9 @@
@@ -1149,9 +1149,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.384.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
+ "version": "0.398.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0",
+ "integrity": "sha512-M8zjqBYnyV3tC5ozeznk58dxeztYxH7BkRFdB24abBLWxYWGL/G3cR1bhruDpZLb27dVZidM5GZ8gSggYAm7qg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.398.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.143.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.197.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -137,7 +137,7 @@ async function createApp(opts = {}) {
@@ -137,7 +137,7 @@ async function createApp(opts = {}) {
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
// The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.
// The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.
const skills = opts.skills || createSkills({ store, sharing, log });
const skills = opts.skills || createSkills({ store, sharing, log });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, goalVerifier: opts.goalVerifier || null, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -174,8 +174,13 @@ function load(env = process.env) {
@@ -174,8 +174,13 @@ function load(env = process.env) {
know: { lessons: String(env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS || '').trim() },
know: { lessons: String(env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS || '').trim() },
// The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.
// The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.
task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },
task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },
- // Goal and independent-verification flow stays opt-in until task creation and persistence are wired end to end.
- goals: { enabled: env.CODES_GOALS_ENABLED === '1' },
+ // Goal and independent-verification flow (AL1) stays opt-in: a task without a goal behaves exactly as before.
+ // The verifier is a cheap fast model on OpenVibe.AI; CODES_GOALS_MODEL may name another rung.
+ goals: {
+ enabled: env.CODES_GOALS_ENABLED === '1',
+ model: env.CODES_GOALS_MODEL || 'deepseek-chat',
+ timeoutMs: Math.max(1000, int(env.CODES_GOALS_TIMEOUT_MS, 120000)),
+ },
// The conductor (Codes C9b): one long Claude Code session on the person's own machine. A turn's wall time (the
// The conductor (Codes C9b): one long Claude Code session on the person's own machine. A turn's wall time (the
// session may work through a plan), and the harness a sub-task gets when the conductor names none (never an
// session may work through a plan), and the harness a sub-task gets when the conductor names none (never an
// Anthropic one: the conductor is the only Anthropic session).
// Anthropic one: the conductor is the only Anthropic session).
@@ -190,7 +190,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -190,7 +190,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
}
}
/** An attempt ended: its state, why, what it cost. */
/** An attempt ended: its state, why, what it cost. */
- async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, session = null, model = null, machine = null, budget_hit = null, finished_at = null } = {}) {
+ async function end(id, { state = 'failed', reason = null, why = null, cost_usd = null, tokens = null, steps = null, turns = null, session = null, model = null, machine = null, budget_hit = null, finished_at = null } = {}) {
return await db.one(
return await db.one(
`UPDATE codes_attempts SET state = $2,
`UPDATE codes_attempts SET state = $2,
reason = COALESCE($3, reason),
reason = COALESCE($3, reason),
@@ -202,9 +202,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -202,9 +202,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
model = COALESCE($9, model),
model = COALESCE($9, model),
machine = COALESCE($10, machine),
machine = COALESCE($10, machine),
budget_hit = COALESCE($11, budget_hit),
budget_hit = COALESCE($11, budget_hit),
+ turns = COALESCE($13, turns),
finished_at = COALESCE($12::timestamptz, finished_at)
finished_at = COALESCE($12::timestamptz, finished_at)
WHERE id = $1 RETURNING *`,
WHERE id = $1 RETURNING *`,
- [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, budget_hit, finished_at || store.iso()],
+ [id, state, reason, why, cost_usd == null ? null : String(cost_usd), tokens, steps, session, model, machine, budget_hit, finished_at || store.iso(), turns],
);
);
}
}
@@ -303,8 +304,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -303,8 +304,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
return null;
return null;
}
}
- /** One attempt as the engine sees it. */
- function engineAttempt(a, all, facts = null) {
+ /** One attempt as the engine sees it. A task's goal travels on the attempt whose turn just ended. */
+ function engineAttempt(a, all, facts = null, goal = null) {
const n = Number(a.n) || 1;
const n = Number(a.n) || 1;
const reason = a.reason && REASONS.includes(a.reason) ? a.reason : null;
const reason = a.reason && REASONS.includes(a.reason) ? a.reason : null;
const next = all.find((x) => x.follow_up_of && x.follow_up_of === a.id);
const next = all.find((x) => x.follow_up_of && x.follow_up_of === a.id);
@@ -327,6 +328,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -327,6 +328,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
prev: prev ? prev.id : null,
prev: prev ? prev.id : null,
parent: a.follow_up_of || null,
parent: a.follow_up_of || null,
stopped: a.state === 'stopped',
stopped: a.state === 'stopped',
+ ...(goal ? { goal } : {}),
+ ...(Number.isFinite(Number(a.turns)) ? { turns: Number(a.turns) } : {}),
budget: reason === 'budget' ? { hit: a.budget_hit || 'steps' } : undefined,
budget: reason === 'budget' ? { hit: a.budget_hit || 'steps' } : undefined,
wrapUp: a.kind === 'wrap-up',
wrapUp: a.kind === 'wrap-up',
hung: reason === 'hung',
hung: reason === 'hung',
@@ -365,6 +368,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -365,6 +368,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
return `\n\n---\n## Continuation (written by openvibe.codes)\n\nAn earlier attempt${ending.agent ? ` on ${nameOf(ending.agent)}` : ''} ${words}. Keep the work it finished, do not redo it, and continue from where it stopped. If the task is already complete, check it and report that.\n`;
return `\n\n---\n## Continuation (written by openvibe.codes)\n\nAn earlier attempt${ending.agent ? ` on ${nameOf(ending.agent)}` : ''} ${words}. Keep the work it finished, do not redo it, and continue from where it stopped. If the task is already complete, check it and report that.\n`;
}
}
+ /**
+ * The note a rejected goal's own session gets: the verifier's reason and the check's own output, so the worker
+ * repairs the actual failure rather than being told "not done" again.
+ */
+ function goalContinuationNote(verdict) {
+ const output = verdict && verdict.check_output ? String(verdict.check_output) : '';
+ return `\n\n---\n## The goal check did not pass (written by openvibe.codes)\n\n`
+ + `The independent verifier rejected the completion proposal: ${String((verdict && verdict.reason) || 'no reason was given').slice(0, 4000)}\n`
+ + (output ? `\nThe check's own output:\n\n\`\`\`\n${output.slice(-8000)}\n\`\`\`\n` : '')
+ + '\nKeep the work, do not redo it. Make the check pass, then propose completion again in this same conversation.\n';
+ }
+
/** The note a new agent gets: what changed, what it said, where it stopped. */
/** The note a new agent gets: what changed, what it said, where it stopped. */
function handoverNote(ending, result, words) {
function handoverNote(ending, result, words) {
const d = result && typeof result === 'object' ? result : {};
const d = result && typeof result === 'object' ? result : {};
@@ -377,6 +392,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -377,6 +392,18 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
+ 'Keep the work it did, do not redo it, and finish the task.\n';
+ 'Keep the work it did, do not redo it, and finish the task.\n';
}
}
+ /** The task's goal, as the goal contract shaped it, when it belongs to this ending and has a verdict. */
+ function a_id_goal(task, ending) {
+ let goal = task && task.goal;
+ if (typeof goal === 'string') { try { goal = JSON.parse(goal); } catch { goal = null; } }
+ if (!goal || typeof goal !== 'object' || Array.isArray(goal)) return null;
+ let verdict = goal.verdict;
+ if (typeof verdict === 'string') { try { verdict = JSON.parse(verdict); } catch { verdict = null; } }
+ if (verdict && (typeof verdict !== 'object' || Array.isArray(verdict))) verdict = null;
+ if (verdict && verdict.attempt_id && ending && verdict.attempt_id !== ending.id) return null;
+ return goal;
+ }
+
/**
/**
* What happens after an attempt ends: the engine decides, this turns the decision into a plan.
* What happens after an attempt ends: the engine decides, this turns the decision into a plan.
*
*
@@ -391,7 +418,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -391,7 +418,10 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
const ctx = routing(fleet);
const ctx = routing(fleet);
const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));
const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));
const facts = factsOf(result);
const facts = factsOf(result);
- const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));
+ // The goal (AL1) belongs to the attempt that just ended: the engine reads its verdict, and only its verdict,
+ // before it may call a done attempt done. A verdict left by an earlier attempt never decides this one.
+ const endingGoal = a_id_goal(task, ending);
+ const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null, a.id === ending.id ? endingGoal : null));
const out = chain.outcome(engineAttempts, ctx);
const out = chain.outcome(engineAttempts, ctx);
const step = out.step || { action: 'none' };
const step = out.step || { action: 'none' };
const reason = ending.reason || out.reason || 'error';
const reason = ending.reason || out.reason || 'error';
@@ -420,6 +450,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -420,6 +450,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
if (continues >= maxContinues) {
if (continues >= maxContinues) {
return { outcome: 'unfinished', note: `the task was carried on ${continues} times already; it needs a person` };
return { outcome: 'unfinished', note: `the task was carried on ${continues} times already; it needs a person` };
}
}
+ const rejected = endingGoal && endingGoal.verdict && endingGoal.verdict.status === 'rejected' ? endingGoal.verdict : null;
+ if (rejected) {
+ return {
+ kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,
+ prompt: String(task.prompt || '') + goalContinuationNote(rejected),
+ why: `the verifier rejected the completion proposal: ${rejected.reason}`,
+ };
+ }
return {
return {
kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,
kind: 'continue', agent: ending.agent, model: ending.model, session: step.session || ending.session || null,
prompt: String(task.prompt || '') + continuationNote(ending, words),
prompt: String(task.prompt || '') + continuationNote(ending, words),
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * The independent verifier (AL1): a cheap, fast model on OpenVibe.AI reads a bounded evidence window and answers
+ * accept or reject with a reason.
+ *
+ * Codes never asks the worker's own model to grade its own work: the verifier is a separate call on a cheap rung
+ * (deepseek-chat by default), made by Codes with its own Network token, not the task's. The prompt is built by
+ * engine/goal.js, which cuts the evidence to 256 KB and treats it as data. A verifier that cannot answer is a
+ * `failed` verdict, never an acceptance: a task without an independent verdict is not ready.
+ *
+ * createGoalVerifier({ config, fetchImpl, log })
+ * verify({ goal, check, proposal, evidence, checkOutput }) → { status, reason, check_output, model }
+ *
+ * The service token is minted from Codes' own client credentials for OpenVibe.AI (`ai.run.create`), the same grant its
+ * catalog and connection pages use. No provider key reaches Codes, and none is logged here.
+ */
+
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { verify } = require('../../engine/goal');
+
+/** The cheap rung: one request, at most a short answer. The env may name another (CODES_GOALS_MODEL). */
+const DEFAULT_MODEL = 'deepseek-chat';
+
+/** The answer's text from either dialect OpenVibe.AI speaks. */
+function answerText(doc) {
+ if (!doc || typeof doc !== 'object') return '';
+ const choice = doc.choices && doc.choices[0];
+ if (choice && choice.message && typeof choice.message.content === 'string') return choice.message.content;
+ if (Array.isArray(doc.content)) return doc.content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).filter(Boolean).join(' ');
+ if (typeof doc.result === 'string') return doc.result;
+ return '';
+}
+
+function createGoalVerifier({ config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');
+ const model = String((config.goals && config.goals.model) || DEFAULT_MODEL);
+ const scope = (config.ai && config.ai.scope) || 'ai.credential.manage ai.run.create';
+ const tokens = config.oauth && config.oauth.clientSecret
+ ? createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl || 'http://127.0.0.1:4000'}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: (config.ai && config.ai.audience) || 'openvibe.ai',
+ scope,
+ fetch: fetchImpl,
+ })
+ : null;
+
+ /** One request to AI; the OpenAI dialect first, then the Messages dialect for a provider that speaks only that. */
+ async function call({ prompt, signal }) {
+ if (!tokens) throw new Error('Codes has no Network client secret, so no independent verifier can run');
+ const headers = { ...(await tokens.authHeaders()), 'content-type': 'application/json', accept: 'application/json' };
+ const ask = async (path, body) => {
+ const res = await fetchImpl(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal });
+ const doc = await res.json().catch(() => null);
+ return { res, doc };
+ };
+ let { res, doc } = await ask('/v1/chat/completions', {
+ model, messages: [{ role: 'user', content: prompt }], temperature: 0, max_tokens: 300,
+ });
+ if (res && res.status === 404) {
+ ({ res, doc } = await ask('/v1/messages', {
+ model, max_tokens: 300, messages: [{ role: 'user', content: prompt }],
+ }));
+ }
+ if (!res || !res.ok) {
+ const err = doc && doc.error && typeof doc.error === 'object' ? doc.error : null;
+ const code = String((err && err.code) || (doc && doc.code) || '') || null;
+ throw new Error(`OpenVibe.AI refused the goal verifier (HTTP ${res && res.status}${code ? `, ${code}` : ''})`);
+ }
+ const text = answerText(doc);
+ if (!text) throw new Error('the goal verifier returned no answer');
+ return text;
+ }
+
+ /**
+ * Verify one completion proposal. Never throws: a verifier that could not run is a `failed` verdict with its
+ * reason, so the task can say it is waiting on a person rather than call itself ready.
+ */
+ async function verifyGoal({ goal, check, proposal = '', evidence = '', checkOutput = '' } = {}) {
+ try {
+ const verdict = await verify({
+ goal, check, proposal, evidence, checkOutput,
+ callVerifier: call,
+ timeoutMs: Number(config.goals && config.goals.timeoutMs) || undefined,
+ });
+ return { ...verdict, model };
+ } catch (e) {
+ log.warn(`[Codes] goal verifier failed: ${String((e && e.message) || e).slice(0, 300)}`);
+ return {
+ status: 'failed',
+ reason: `The independent verifier could not run: ${String((e && e.message) || e).slice(0, 500)}`,
+ check_output: String(checkOutput || '').slice(0, 262144),
+ model,
+ };
+ }
+ }
+
+ return { verify: verifyGoal, model };
+}
+
+module.exports = { createGoalVerifier, answerText, DEFAULT_MODEL };
@@ -138,16 +138,21 @@ function usageOf(result) {
@@ -138,16 +138,21 @@ function usageOf(result) {
else if (!model && line.model) model = String(line.model);
else if (!model && line.model) model = String(line.model);
} else if (line.t === 'done' && line.resolved_model) model = String(line.resolved_model);
} else if (line.t === 'done' && line.resolved_model) model = String(line.resolved_model);
}
}
+ const turns = Number.isFinite(Number(d.turns)) ? Number(d.turns) : (() => {
+ const done = log.filter((l) => l && l.t === 'done' && Number.isFinite(Number(l.turns))).slice(-1)[0];
+ return done ? Number(done.turns) : null;
+ })();
return {
return {
cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
tokens: tokens || null,
tokens: tokens || null,
steps: steps || null,
steps: steps || null,
+ turns: turns || null,
session: session || null,
session: session || null,
model: model || null,
model: model || null,
};
};
}
}
-function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null }) {
+function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, goalVerifier = null }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@ -158,6 +163,16 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -158,6 +163,16 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.
// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.
const reportClient = report || require('../report').createReportClient({ config, log });
const reportClient = report || require('../report').createReportClient({ config, log });
const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });
const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });
+ // AL1's independent verifier: created lazily, only when goals are on and an ending actually needs it.
+ let goalVerifierClient = goalVerifier;
+ const verifierClient = () => {
+ if (!goalVerifierClient && config.goals && config.goals.enabled) {
+ try { goalVerifierClient = require('./goal-verifier').createGoalVerifier({ config, log }); } catch (e) { log.warn(`[Codes] goal verifier unavailable: ${e.message}`); }
+ }
+ return goalVerifierClient;
+ };
+ // One verification per ending, even when two events deliver the same ending.
+ const verifying = new Set();
/**
/**
* The conductor's turns (server/domain/conductor.js). Wired by the app after both exist, because the conductor
* The conductor's turns (server/domain/conductor.js). Wired by the app after both exist, because the conductor
* starts Run jobs and this module is what applies their events: a constructor argument would be a cycle.
* starts Run jobs and this module is what applies their events: a constructor argument would be a cycle.
@@ -492,7 +507,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -492,7 +507,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await attemptStore.end(attempt.id, {
await attemptStore.end(attempt.id, {
state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),
state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),
reason: hit ? 'budget' : why,
reason: hit ? 'budget' : why,
- cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,
+ cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, turns: usage.turns, session: usage.session,
model: usage.model, machine: machineOf(next.job),
model: usage.model, machine: machineOf(next.job),
budget_hit: hit,
budget_hit: hit,
finished_at: updated.finished_at || store.iso(),
finished_at: updated.finished_at || store.iso(),
@@ -658,7 +673,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -658,7 +673,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
const failed = await store.tx(async () => {
const failed = await store.tx(async () => {
- const row = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5
+ const row = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, end_state = CASE WHEN goal IS NOT NULL THEN 'failed' ELSE end_state END, finished_at = $4::timestamptz, updated_at = $5
WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);
WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);
await taskOutbox.emitTask(row, 'queued');
await taskOutbox.emitTask(row, 'queued');
if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {
if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {
@@ -746,6 +761,88 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -746,6 +761,88 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
}
}
}
+ /**
+ * What the goal's budgets say the task has spent (AL1): tokens, model turns and active minutes, summed over every
+ * attempt so far. Null while there is room left; the sentence a person reads when there is not.
+ */
+ function goalBudgetHit(attempts, goal, task) {
+ const budget = goal && goal.budget && typeof goal.budget === 'object' && !Array.isArray(goal.budget) ? goal.budget : {};
+ if (!Object.keys(budget).length) return null;
+ const tokens = (attempts || []).reduce((n, a) => n + (Number(a.tokens) || 0), 0);
+ if (budget.tokens && tokens >= Number(budget.tokens)) return `the task spent ${tokens} of its ${budget.tokens} tokens`;
+ const turns = (attempts || []).reduce((n, a) => n + (Number(a.turns) || 0), 0) || (attempts || []).length;
+ if (budget.turns && turns >= Number(budget.turns)) return `the task used ${turns} of its ${budget.turns} turns`;
+ let minutes = 0;
+ for (const a of attempts || []) {
+ // The clock starts when the attempt did: a row with no start time is not an hour of work, and an old
+ // wall-clock stamp is not this task's activity.
+ const started = a.started_at ? Date.parse(String(a.started_at)) : (a.created_at ? Date.parse(String(a.created_at)) : 0);
+ const finished = a.finished_at ? Date.parse(String(a.finished_at)) : 0;
+ if (started && finished > started) minutes += (finished - started) / 60000;
+ }
+ if (!minutes && task && task.started_at) minutes = Math.max(0, (store.now() - Date.parse(String(task.started_at))) / 60000);
+ if (budget.active_minutes && minutes >= Number(budget.active_minutes)) return `the task was active ${Math.round(minutes)} of its ${budget.active_minutes} minutes`;
+ return null;
+ }
+
+ /** The task ends with an honest end state (AL1): ready, waiting, blocked, failed, dropped or stopped by budget. */
+ async function finishGoal(row, { outcome, note, end_state }) {
+ const saved = await db.one(`UPDATE run_tasks SET outcome = $2, outcome_note = $3, end_state = $4, updated_at = $5
+ WHERE id = $1 RETURNING *`, [row.id, outcome, note, end_state, store.now()]);
+ await notifyOutcome(saved);
+ await recordPlacementOutcome(saved);
+ return saved;
+ }
+
+ /**
+ * The independent verification of one ending (AL1). The harness ran the typed check in the task's workspace; this
+ * sends its bounded output to a cheap model through OpenVibe.AI and keeps the reasoned verdict with the task.
+ * One verdict per ending: a redelivered ending returns the verdict already written.
+ */
+ async function verifyGoalEnding(row, ending, goal) {
+ const prior = asObject(goal.verdict);
+ if (prior && prior.attempt_id === ending.id) return { verdict: prior, goal, row };
+ const key = `${row.id}:${ending.id}`;
+ if (verifying.has(key)) return { verdict: prior, goal, row, skipped: true };
+ verifying.add(key);
+ try {
+ const result = asObject(row.result) || {};
+ const fromJob = asObject(result.goal) || {};
+ const checkOutput = String(fromJob.check_output || '').slice(0, 262144);
+ const proposal = String(fromJob.proposal || result.summary || '').slice(0, 4000);
+ const evidence = String(fromJob.evidence || '').slice(0, 262144);
+ let verdict;
+ if (!Object.keys(fromJob).length) {
+ verdict = { status: 'failed', reason: 'The harness did not run the goal check, so there is no evidence to verify.' };
+ } else if (fromJob.error) {
+ verdict = { status: 'failed', reason: `The goal check could not run: ${String(fromJob.error).slice(0, 500)}` };
+ } else {
+ const verifier = verifierClient();
+ verdict = verifier
+ ? await verifier.verify({ goal: goal.goal, check: goal.check, proposal, evidence, checkOutput })
+ : { status: 'failed', reason: 'No independent verifier is configured, so the task needs a person.' };
+ }
+ verdict = { ...verdict, attempt_id: ending.id, at: store.iso() };
+ // The no-progress limit: the same check output and reason twice in a row means the worker did not move.
+ const signature = `${verdict.status}
+${verdict.reason}
+${String(verdict.check_output || '').slice(0, 50000)}`;
+ const no_progress = verdict.status === 'rejected' && goal.last_rejection === signature ? (Number(goal.no_progress) || 0) + 1 : verdict.status === 'rejected' ? 1 : 0;
+ const merged = {
+ ...goal, proposal, check_output: checkOutput, verdict,
+ ...(verdict.status === 'rejected' ? { last_rejection: signature, no_progress } : {}),
+ };
+ const saved = await db.one('UPDATE run_tasks SET goal = $2::jsonb, goal_verdict = $3::jsonb, updated_at = $4 WHERE id = $1 RETURNING *',
+ [row.id, JSON.stringify(merged), JSON.stringify(verdict), store.now()]);
+ const limit = Math.max(1, Number(goal.no_progress_limit) || 3);
+ return {
+ verdict, goal: merged, row: saved, no_progress,
+ blocked: verdict.status === 'rejected' && no_progress >= limit,
+ budgetHit: verdict.status === 'rejected' ? goalBudgetHit(await attemptStore.list(row.id), merged, saved) : null,
+ };
+ } finally { verifying.delete(key); }
+ }
+
/**
/**
* An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the
* An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the
* decision out — a follow-up attempt, or the task's outcome in plain words.
* decision out — a follow-up attempt, or the task's outcome in plain words.
@@ -755,7 +852,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -755,7 +852,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
* carried one job on twice).
* carried one job on twice).
*/
*/
async function afterEnd(taskId) {
async function afterEnd(taskId) {
- const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
+ let row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
if (!row) return null;
if (!row) return null;
if (!TERMINAL.has(row.state)) return row;
if (!TERMINAL.has(row.state)) return row;
if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on
if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on
@@ -778,6 +875,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -778,6 +875,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const attempts = await attemptStore.list(row.id);
const attempts = await attemptStore.list(row.id);
const ending = attempts[attempts.length - 1];
const ending = attempts[attempts.length - 1];
if (!ending || !ENDED.has(ending.state)) return row;
if (!ending || !ENDED.has(ending.state)) return row;
+ // AL1: a goal task's "done" is a completion proposal. The check already ran in the task's workspace; an
+ // independent verifier now accepts or rejects it, and the task cannot be ready without an acceptance.
+ const goal = asObject(row.goal);
+ if (goal && config.goals && config.goals.enabled && ending.state === 'done') {
+ const checked = await verifyGoalEnding(row, ending, goal);
+ if (checked.skipped) return row;
+ row = checked.row;
+ if (checked.verdict.status === 'failed') {
+ return await finishGoal(row, { outcome: 'unfinished', note: `waiting: ${checked.verdict.reason}`, end_state: 'waiting' });
+ }
+ if (checked.blocked) {
+ return await finishGoal(row, { outcome: 'unfinished', note: `blocked: the verifier rejected the completion ${checked.no_progress} times without progress: ${checked.verdict.reason}`, end_state: 'blocked' });
+ }
+ if (checked.budgetHit) {
+ return await finishGoal(row, { outcome: 'unfinished', note: `stopped by budget: ${checked.budgetHit}`, end_state: 'stopped_by_budget' });
+ }
+ }
// Know K5: what this attempt taught (its Learned: lines, a failure, the command that failed, the commands the
// Know K5: what this attempt taught (its Learned: lines, a failure, the command that failed, the commands the
// repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is
// repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is
// idempotent per (subject, kind, text), so a redelivered ending adds no second row.
// idempotent per (subject, kind, text), so a redelivered ending adds no second row.
@@ -790,10 +904,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -790,10 +904,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const collected = await recordProof(row, ending);
const collected = await recordProof(row, ending);
const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });
const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });
if (plan.outcome) {
if (plan.outcome) {
- // The outcome in plain words: what the engine decided, and what the proof says about it.
- const outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });
- const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
- [row.id, outcome.state, outcome.note, store.now()]);
+ // The outcome in plain words: what the engine decided, and what the proof says about it. A goal task also
+ // carries its honest end state: ready only with an accepted verdict, stopped by budget when the budget ran out.
+ let outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });
+ const savedGoal = asObject(row.goal);
+ const goalVerdict = goal && savedGoal ? asObject(savedGoal.verdict) : null;
+ // An accepted goal is done even when another proof item (a review, a screenshot) failed: the goal's own
+ // independent verdict is the outcome, and the failed item is said in the proof below it.
+ if (goal && plan.outcome === 'done' && goalVerdict && goalVerdict.status === 'accepted') outcome = { state: 'done', note: 'the task finished' };
+ const budgetEnd = ending.reason === 'budget' || /budget/i.test(String(outcome.note || ''));
+ const endState = goal
+ ? (plan.outcome === 'done' && goalVerdict && goalVerdict.status === 'accepted' ? 'ready'
+ : budgetEnd ? 'stopped_by_budget'
+ : plan.outcome === 'stopped' ? 'dropped'
+ : plan.outcome === 'unfinished' ? 'waiting' : 'failed')
+ : null;
+ const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, end_state = $4, updated_at = $5 WHERE id = $1 RETURNING *',
+ [row.id, outcome.state, outcome.note, endState, store.now()]);
await notifyOutcome(saved);
await notifyOutcome(saved);
await recordPlacementOutcome(saved);
await recordPlacementOutcome(saved);
// Agent Streams S3: the finished task's report, from its proof. A report that cannot be written never
// Agent Streams S3: the finished task's report, from its proof. A report that cannot be written never
@@ -804,6 +931,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -804,6 +931,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return saved;
return saved;
}
}
if (!plan.kind) return row;
if (!plan.kind) return row;
+ // The goal's budgets are checked before the next turn starts: a spent budget ends the task here, with its reason.
+ if (goal) {
+ const hit = goalBudgetHit(attempts, asObject(row.goal) || goal, row);
+ if (hit) return await finishGoal(row, { outcome: 'unfinished', note: `stopped by budget: ${hit}`, end_state: 'stopped_by_budget' });
+ }
const done = await startFollowUp(row, plan, ending);
const done = await startFollowUp(row, plan, ending);
return done.row || row;
return done.row || row;
}
}
@@ -853,7 +985,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -853,7 +985,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
async function startFollowUp(row, plan, ending) {
async function startFollowUp(row, plan, ending) {
const left = await remainingBudget(row.id);
const left = await remainingBudget(row.id);
if (left.exhausted) {
if (left.exhausted) {
- const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
+ const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2,
+ end_state = CASE WHEN goal IS NOT NULL THEN 'stopped_by_budget' ELSE end_state END,
+ updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, budgetLeftNote(left), store.now()]);
[row.id, budgetLeftNote(left), store.now()]);
await recordPlacementOutcome(stopped);
await recordPlacementOutcome(stopped);
return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };
return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };
@@ -894,7 +1028,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -894,7 +1028,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
const updated = await db.one(
const updated = await db.one(
`UPDATE run_tasks SET job_id = $2, state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL,
`UPDATE run_tasks SET job_id = $2, state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL,
- finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,
+ end_state = NULL, finished_at = NULL, updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, job.id, store.now()]);
[row.id, job.id, store.now()]);
await taskOutbox.emitTask(updated, row.state);
await taskOutbox.emitTask(updated, row.state);
return { attempt, created: true, job, row: updated };
return { attempt, created: true, job, row: updated };
@@ -945,7 +1079,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -945,7 +1079,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// of it, the attempt does not start and the task ends at its budget.
// of it, the attempt does not start and the task ends at its budget.
const left = await remainingBudget(row.id);
const left = await remainingBudget(row.id);
if (left.exhausted) {
if (left.exhausted) {
- const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
+ const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2,
+ end_state = CASE WHEN goal IS NOT NULL THEN 'stopped_by_budget' ELSE end_state END,
+ updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, budgetLeftNote(left), store.now()]);
[row.id, budgetLeftNote(left), store.now()]);
await recordPlacementOutcome(stopped);
await recordPlacementOutcome(stopped);
return { error: 'the task has no budget left', row: stopped };
return { error: 'the task has no budget left', row: stopped };
@@ -960,7 +1096,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -960,7 +1096,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
});
attempt = made.attempt;
attempt = made.attempt;
if (claims.length) await filesStore.link(row.subject, claims, { taskId: row.id, attemptN: Number(attempt.n), messageId: turnId });
if (claims.length) await filesStore.link(row.subject, claims, { taskId: row.id, attemptN: Number(attempt.n), messageId: turnId });
- await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,
+ await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, end_state = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,
[row.id, store.now()]);
[row.id, store.now()]);
return { row: current };
return { row: current };
});
});
@@ -1026,6 +1162,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -1026,6 +1162,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(plan.session ? { resume: plan.session } : {}),
...(plan.session ? { resume: plan.session } : {}),
...(plan.attachments && plan.attachments.length ? { attachments: plan.attachments } : {}),
...(plan.attachments && plan.attachments.length ? { attachments: plan.attachments } : {}),
+ // AL1: every turn of a goal task runs the same check in the workspace it worked in.
+ ...(asObject(row.goal) ? { goal: asObject(row.goal) } : {}),
}));
}));
}
}
@@ -1046,6 +1184,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -1046,6 +1184,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (TERMINAL.has(current.state) && current.outcome) return { error: 'the task has already ended', row: current };
if (TERMINAL.has(current.state) && current.outcome) return { error: 'the task has already ended', row: current };
const updated = await db.one(
const updated = await db.one(
`UPDATE run_tasks SET state = 'cancelled', outcome = 'stopped', outcome_note = $2,
`UPDATE run_tasks SET state = 'cancelled', outcome = 'stopped', outcome_note = $2,
+ end_state = CASE WHEN goal IS NOT NULL THEN 'dropped' ELSE end_state END,
finished_at = COALESCE(finished_at, $3::timestamptz), updated_at = $4 WHERE id = $1 RETURNING *`,
finished_at = COALESCE(finished_at, $3::timestamptz), updated_at = $4 WHERE id = $1 RETURNING *`,
[row.id, 'the task was stopped on purpose', store.iso(), store.now()]);
[row.id, 'the task was stopped on purpose', store.iso(), store.now()]);
await taskOutbox.emitTask(updated, current.state);
await taskOutbox.emitTask(updated, current.state);
@@ -1063,11 +1202,13 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -1063,11 +1202,13 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
function present(row) {
function present(row) {
const result = TERMINAL.has(row.state) ? presentResult(asObject(row.result)) : undefined;
const result = TERMINAL.has(row.state) ? presentResult(asObject(row.result)) : undefined;
+ const goal = asObject(row.goal);
+ const verdict = asObject(row.goal_verdict) || (goal ? asObject(goal.verdict) : null);
return drop({
return drop({
id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,
id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,
agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),
agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),
error: row.error ? String(row.error).slice(0, 1000) : null, result,
error: row.error ? String(row.error).slice(0, 1000) : null, result,
- ...(row.goal ? { goal: asObject(row.goal), goal_verdict: asObject(row.goal_verdict) || null } : {}),
+ ...(goal ? { goal: { ...goal, ...(verdict ? { verdict } : {}) } } : {}),
});
});
}
}
@@ -81,13 +81,25 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
@@ -81,13 +81,25 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to start a task');
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to start a task');
if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
const b = req.body || {};
const b = req.body || {};
- const check = validate('codes.task-start-request@1', b);
+ // AL1: a goal and its typed check are optional. The goal is validated here (it is not part of the closed start
+ // request while the switch is off) and travels to the harness, which runs the check in the task's workspace.
+ let goal = null;
+ if (b.goal != null) {
+ if (!config.goals.enabled) return problem(req, res, 409, 'codes.goals_disabled', 'goal tasks are disabled');
+ const g = b.goal, limits = g && typeof g === 'object' && !Array.isArray(g) ? (g.budget || {}) : {};
+ if (!g || typeof g !== 'object' || Array.isArray(g) || typeof g.goal !== 'string' || !g.goal.trim() || g.goal.length > 4000 || !g.check || !['command', 'url', 'rubric'].includes(g.check.kind) || typeof g.check.value !== 'string' || !g.check.value.trim() || g.check.value.length > 4000) return problem(req, res, 422, 'codes.goal_invalid', 'goal requires a condition and a typed check');
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits) || Object.entries(limits).some(([k, v]) => !['tokens', 'turns', 'active_minutes'].includes(k) || !Number.isInteger(v) || v < 1) || (g.no_progress_limit != null && (!Number.isInteger(g.no_progress_limit) || g.no_progress_limit < 1 || g.no_progress_limit > 20))) return problem(req, res, 422, 'codes.goal_invalid', 'goal budgets and no-progress limit must be positive bounded integers');
+ goal = { goal: g.goal.trim(), check: { kind: g.check.kind, value: g.check.value.trim() }, budget: limits, no_progress_limit: g.no_progress_limit || 3, proposal: null, evidence: null, verdict: null };
+ }
+ const bodyForValidate = { ...b };
+ delete bodyForValidate.goal;
+ const check = validate('codes.task-start-request@1', bodyForValidate);
if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');
if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');
let args;
let args;
try {
try {
args = parseArgs(JSON.stringify({
args = parseArgs(JSON.stringify({
- repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit',
+ repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit', ...(goal ? { goal } : {}),
...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),
...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),
}));
}));
} catch (e) {
} catch (e) {
@@ -29,6 +29,15 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -29,6 +29,15 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
/** The task's outcome in the words a person reads. */
/** The task's outcome in the words a person reads. */
const OUTCOME_TEXT = { done: 'Done', unfinished: "Didn't finish", stopped: 'Stopped by you' };
const OUTCOME_TEXT = { done: 'Done', unfinished: "Didn't finish", stopped: 'Stopped by you' };
+/** AL1's honest end states, in the same words. A task without a goal keeps the outcome above. */
+const END_STATE_TEXT = {
+ ready: 'Ready', waiting: 'Waiting', blocked: 'Blocked', failed: 'Failed', dropped: 'Dropped by you', stopped_by_budget: 'Stopped by budget',
+};
+const asObject = (v) => {
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
+ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
+ return null;
+};
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
function repoName(url) {
function repoName(url) {
@@ -246,6 +255,20 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
@@ -246,6 +255,20 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
</section>`;
</section>`;
}
}
+ /** A goal task's own record (AL1): the condition, its typed check, the check's output and the verifier's verdict. */
+ function goalHtml(row) {
+ const goal = asObject(row.goal);
+ if (!goal) return '';
+ const verdict = asObject(row.goal_verdict) || asObject(goal.verdict);
+ return html`<section class="run-goal" data-goal>
+<h2>The goal and its check</h2>
+<p data-goal-text>${goal.goal || ''}</p>
+<p class="muted">Check (${goal.check && goal.check.kind}): <code>${goal.check && goal.check.value}</code></p>
+${verdict ? html`<p data-goal-verdict data-status="${verdict.status}"><b>${verdict.status}</b>${verdict.reason ? ` · ${verdict.reason}` : ''}${verdict.model ? html` <span class="muted">(${verdict.model})</span>` : ''}</p>` : html`<p class="muted">No independent verdict yet.</p>`}
+${verdict && verdict.check_output ? html`<h3>The check's output</h3><pre tabindex="0" class="run-goal-output" data-goal-check-output>${verdict.check_output}</pre>` : ''}
+</section>`;
+ }
+
/** The task's timeline: every attempt and why it happened. The data-* hooks are for the design of stage C4. */
/** The task's timeline: every attempt and why it happened. The data-* hooks are for the design of stage C4. */
async function timelineHtml(row) {
async function timelineHtml(row) {
const attempts = await taskStore.attempts.list(row.id);
const attempts = await taskStore.attempts.list(row.id);
@@ -253,10 +276,11 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
@@ -253,10 +276,11 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
// The outcome in plain words: the label, and the note beside it — unless the note already says it
// The outcome in plain words: the label, and the note beside it — unless the note already says it
// ("Done with proof: …" says "Done" already, so it is not said twice).
// ("Done with proof: …" says "Done" already, so it is not said twice).
const note = row.outcome_note || '';
const note = row.outcome_note || '';
- const label = row.outcome ? (OUTCOME_TEXT[row.outcome] || row.outcome) : '';
+ const endState = row.end_state || null;
+ const label = endState ? (END_STATE_TEXT[endState] || endState) : (row.outcome ? (OUTCOME_TEXT[row.outcome] || row.outcome) : '');
const says = !!label && note.toLowerCase().startsWith(label.toLowerCase());
const says = !!label && note.toLowerCase().startsWith(label.toLowerCase());
- const outcome = row.outcome
- ? html`<p class="run-outcome" data-outcome="${row.outcome}"><b>${says ? note : [label, note].filter(Boolean).join(' ')}</b></p>`
+ const outcome = (endState || row.outcome)
+ ? html`<p class="run-outcome" data-outcome="${endState || row.outcome}"${endState ? raw(' data-end-state') : ''}><b>${says ? note : [label, note].filter(Boolean).join(' ')}</b></p>`
: '';
: '';
return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">
return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">
<h2>The attempts</h2>
<h2>The attempts</h2>
@@ -317,6 +341,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
@@ -317,6 +341,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
${await timelineHtml(row)}
${await timelineHtml(row)}
+${goalHtml(row)}
${await proofHtml(row)}
${await proofHtml(row)}
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
@@ -145,7 +145,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -145,7 +145,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return {
return {
...taskStore.present(row),
...taskStore.present(row),
attempts: attempts.map((a) => taskStore.attempts.present(a)),
attempts: attempts.map((a) => taskStore.attempts.present(a)),
- outcome: { state: row.outcome || null, note: row.outcome_note || null },
+ outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null },
};
};
};
};
@@ -325,7 +325,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -325,7 +325,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
if (state === 'needs') where.sql += " AND outcome = 'unfinished'";
if (state === 'needs') where.sql += " AND outcome = 'unfinished'";
if (state === 'done') where.sql += " AND state IN ('succeeded', 'failed', 'cancelled', 'expired') AND outcome IS DISTINCT FROM 'unfinished'";
if (state === 'done') where.sql += " AND state IN ('succeeded', 'failed', 'cancelled', 'expired') AND outcome IS DISTINCT FROM 'unfinished'";
const rows = await db.many(`SELECT * FROM run_tasks WHERE ${where.sql} ORDER BY created_at DESC LIMIT $${where.args.length + 1}`, [...where.args, limit]);
const rows = await db.many(`SELECT * FROM run_tasks WHERE ${where.sql} ORDER BY created_at DESC LIMIT $${where.args.length + 1}`, [...where.args, limit]);
- return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.outcome || null, note: row.outcome_note || null } })) });
+ return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null } })) });
});
});
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
@@ -14,6 +14,9 @@ const goal = require('../engine/goal');
@@ -14,6 +14,9 @@ const goal = require('../engine/goal');
assert.match(seen, /person’s own recorded input/);
assert.match(seen, /person’s own recorded input/);
const invalid = await goal.verify({ goal: 'approved', check: {}, callVerifier: async () => 'maybe' });
const invalid = await goal.verify({ goal: 'approved', check: {}, callVerifier: async () => 'maybe' });
assert.equal(invalid.status, 'rejected');
assert.equal(invalid.status, 'rejected');
+ // A verifier that did not run is neither an acceptance nor the worker's failure: the task waits on a person.
+ const failed = await goal.verify({ goal: 'file exists', check: { kind: 'command', value: 'test -f x' }, callVerifier: async () => { throw new Error('AI is down'); } });
+ assert.equal(failed.status, 'failed');
});
});
done();
done();
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const { runCheck } = require('../harness/goal-check');
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'goal-check-'));
+ try {
+ await check('a command check runs in the workspace and its exit code is the machine\'s answer', async () => {
+ fs.writeFileSync(path.join(dir, 'ok.js'), 'module.exports = 1;\n');
+ const good = await runCheck({ kind: 'command', value: 'node --check ok.js' }, { cwd: dir });
+ assert.equal(good.ok, true, good.output);
+ assert.equal(good.exit_code, 0);
+ const bad = await runCheck({ kind: 'command', value: 'node --check missing.js' }, { cwd: dir });
+ assert.equal(bad.ok, false);
+ assert.notEqual(bad.exit_code, 0);
+ assert.match(bad.output, /missing\.js/);
+ });
+
+ await check('a URL check passes only on a 2xx', async () => {
+ const pass = await runCheck({ kind: 'url', value: 'https://example.test/ok' }, {
+ fetchImpl: async () => ({ ok: true, status: 200, statusText: 'OK', text: async () => '{}' }),
+ });
+ assert.equal(pass.ok, true);
+ assert.equal(pass.status, 200);
+ const fail = await runCheck({ kind: 'url', value: 'https://example.test/no' }, {
+ fetchImpl: async () => ({ ok: false, status: 503, statusText: 'No', text: async () => 'down' }),
+ });
+ assert.equal(fail.ok, false);
+ assert.equal(fail.status, 503);
+ });
+
+ await check('a rubric has no machine verdict and an unknown kind fails closed', async () => {
+ const rubric = await runCheck({ kind: 'rubric', value: 'the copy is honest' }, { cwd: dir });
+ assert.equal(rubric.ok, null);
+ const unknown = await runCheck({ kind: 'vibes', value: 'x' }, { cwd: dir });
+ assert.equal(unknown.ok, false);
+ });
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ done();
+})();
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
+ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; OpenVibe Git repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; OpenVibe Git repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
+ 'server/domain/goal-verifier.js': 'OpenVibe.AI (CODES_AI_URL, configured) POST /v1/chat/completions, or /v1/messages on a 404, for the independent goal verifier: the operator-set base and fixed paths only, never a URL from a person or a developer',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a lnk_ shape, never a URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a lnk_ shape, never a URL from a person',
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
+'use strict';
+
+/**
+ * AL1: a goal task is not done on the worker's word. The harness runs the typed check in the workspace and the result
+ * carries its output; Codes sends a bounded evidence window to an independent cheap model, and only an acceptance ends
+ * the task ready. A rejection resumes the same session; a spent goal budget ends the task stopped by budget; a task
+ * with no goal behaves exactly as before.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const submitted = [];
+ const jobs = new Map();
+ let counter = 0;
+ const run = {
+ enabled: true,
+ async submit(o) {
+ counter++;
+ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2K${String(counter).padStart(2, '0')}`;
+ const parsed = JSON.parse(JSON.stringify(o.args));
+ jobs.set(id, { id, state: 'queued', args: parsed });
+ submitted.push({ id, args: parsed, key: o.key });
+ return { id, state: 'queued' };
+ },
+ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },
+ async cancel(jobId) { const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ // The independent verifier: a fake in the tests, so no model is called. It answers in order from `next`.
+ const asked = [];
+ let next = [];
+ const goalVerifier = {
+ model: 'deepseek-chat',
+ async verify(what) { asked.push(what); return next.shift() || { status: 'rejected', reason: 'nothing configured', check_output: what.checkOutput }; },
+ };
+ const t = await boot({ run, goalVerifier, env: { CODES_GOALS_ENABLED: '1', CODES_TASK_STEPS: '12' } });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];
+ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);
+
+ const call = async (method, path, { body } = {}) => {
+ const res = await fetch(`${t.base}/api/v1${path}`, {
+ method,
+ headers: { Accept: 'application/json', Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
+ const attemptsOf = async (id) => await db.many('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC', [id]);
+
+ const GOAL = {
+ goal: 'The file notes.txt exists and is not empty.',
+ check: { kind: 'command', value: 'test -s notes.txt' },
+ budget: { tokens: 1000000, turns: 12, active_minutes: 60 },
+ };
+
+ /** A task through the API, with or without a goal. */
+ const startTask = async (goal = GOAL) => {
+ const body = { repo: 'https://github.com/octocat/Hello-World', prompt: 'Write notes.txt.', idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}` };
+ if (goal) body.goal = goal;
+ const r = await call('POST', '/tasks', { body });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.json));
+ return r.json.task.id;
+ };
+
+ /** The worker's turn ends: the job's state is applied and the engine decides (what the events consumer does). */
+ const endAttempt = async (id, { state = 'succeeded', go = null, session = 'sess-1', turns = 2 } = {}) => {
+ const row = await rowOf(id);
+ const result = {
+ ok: state === 'succeeded', summary: 'I wrote notes.txt.', files: [{ path: 'notes.txt', added: 1, removed: 0 }],
+ turns, log: [{ t: 'agent', agent: row.agent, session }, { t: 'done', ok: state === 'succeeded', text: 'I wrote notes.txt.' }],
+ ...(go ? { goal: go } : {}),
+ };
+ const job = { id: row.job_id, state, result };
+ const updated = await t.ctx.tasks.applyEvent(row, { state, job });
+ await t.ctx.tasks.afterEnd(updated.id);
+ return { row: await rowOf(id), attempts: await attemptsOf(id) };
+ };
+
+ const acceptedCheck = { check_output: 'exit 0\n(no output)', checked: { ok: true }, proposal: 'I wrote notes.txt.', evidence: '{"check":{"ok":true}}' };
+ const rejectedCheck = { check_output: 'exit 1\nnotes.txt is empty', checked: { ok: false }, proposal: 'I wrote notes.txt.', evidence: '{"check":{"ok":false}}' };
+
+ try {
+ // A person's service task runs in the project of their latest task on /run: seed one, as a first visit would.
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, agent, state, created_at, updated_at)
+ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J90',
+ 'https://github.com/octocat/Hello-World', 'the first one', 'claude-code', 'succeeded', 1, 2)`, [ada.subject]);
+
+ await check('a passing check and an accepting verifier end the task ready, with the evidence kept', async () => {
+ next = [{ status: 'accepted', reason: 'the check passed and the evidence matches', check_output: acceptedCheck.check_output }];
+ const id = await startTask();
+ await endAttempt(id, { go: acceptedCheck });
+ const row = await rowOf(id);
+ assert.ok(row.goal, 'the task kept its goal');
+ assert.strictEqual(row.end_state, 'ready');
+ assert.strictEqual(row.outcome, 'done');
+ const verdict = row.goal_verdict;
+ assert.strictEqual(verdict.status, 'accepted');
+ assert.strictEqual(verdict.check_output, acceptedCheck.check_output);
+ assert.ok(verdict.reason);
+ const shown = await call('GET', `/tasks/${id}`);
+ assert.strictEqual(shown.json.outcome.state, 'ready');
+ assert.strictEqual(shown.json.goal.verdict.status, 'accepted');
+ assert.strictEqual(shown.json.goal.check_output, acceptedCheck.check_output);
+ assert.strictEqual((await attemptsOf(id)).length, 1, 'an accepted goal starts no follow-up');
+ assert.strictEqual(asked.length, 1, 'the verifier was asked once');
+ assert.match(asked[0].checkOutput, /exit 0/);
+ assert.strictEqual(submitted[0].args.goal.check.value, GOAL.check.value, 'the harness got the check');
+ const page = await t.get(`/run/tasks/${id}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /The goal and its check/);
+ assert.match(page.text, /exit 0/);
+ assert.match(page.text, /accepted/);
+ });
+
+ await check('a failing check is rejected and the SAME session is resumed with the check output', async () => {
+ next = [{ status: 'rejected', reason: 'notes.txt is empty, so the check failed', check_output: rejectedCheck.check_output }];
+ const id = await startTask();
+ const { row, attempts } = await endAttempt(id, { go: rejectedCheck, session: 'sess-goal-7' });
+ assert.strictEqual(attempts.length, 2, 'the task was carried on');
+ assert.strictEqual(attempts[1].kind, 'continue');
+ assert.strictEqual(attempts[1].agent, attempts[0].agent, 'the same worker');
+ assert.strictEqual(attempts[1].session, 'sess-goal-7', 'the same session');
+ const resume = submitted[submitted.length - 1].args.resume;
+ assert.strictEqual(resume, 'sess-goal-7');
+ assert.strictEqual(submitted[submitted.length - 1].args.goal.check.value, GOAL.check.value, 'the same check runs again in the workspace');
+ assert.match(submitted[submitted.length - 1].args.prompt, /notes\.txt is empty/);
+ assert.match(submitted[submitted.length - 1].args.prompt, /exit 1/);
+ assert.strictEqual(row.state, 'queued', 'the follow-up is running');
+ assert.strictEqual(row.goal.no_progress, 1);
+ });
+
+ await check('a spent turn budget ends the task stopped by budget, with the reason', async () => {
+ next = [{ status: 'rejected', reason: 'still empty', check_output: rejectedCheck.check_output }];
+ const id = await startTask({ ...GOAL, budget: { turns: 1 } });
+ const { row, attempts } = await endAttempt(id, { go: rejectedCheck, turns: 7 });
+ assert.strictEqual(attempts.length, 1, 'no follow-up is bought with a spent budget');
+ assert.strictEqual(row.outcome, 'unfinished');
+ assert.strictEqual(row.end_state, 'stopped_by_budget');
+ assert.match(row.outcome_note, /stopped by budget: the task used 7 of its 1 turns/);
+ const shown = await call('GET', `/tasks/${id}`);
+ assert.strictEqual(shown.json.outcome.state, 'stopped_by_budget');
+ });
+
+ await check('a verifier that could not run leaves the task waiting, never ready', async () => {
+ next = [{ status: 'failed', reason: 'the model was unavailable', check_output: acceptedCheck.check_output }];
+ const id = await startTask();
+ const { row, attempts } = await endAttempt(id, { go: acceptedCheck });
+ assert.strictEqual(attempts.length, 1);
+ assert.strictEqual(row.end_state, 'waiting');
+ assert.match(row.outcome_note, /waiting: the model was unavailable/);
+ });
+
+ await check('the no-progress limit blocks the task instead of looping the same failure', async () => {
+ const id = await startTask({ ...GOAL, no_progress_limit: 2, budget: { tokens: 1000000, turns: 10 } });
+ next = [{ status: 'rejected', reason: 'same failure', check_output: 'exit 1\nsame output' }];
+ const first = await endAttempt(id, { go: rejectedCheck });
+ assert.strictEqual(first.attempts.length, 2, 'the first rejection continues');
+ next = [{ status: 'rejected', reason: 'same failure', check_output: 'exit 1\nsame output' }];
+ const second = await endAttempt(id, { go: rejectedCheck });
+ assert.strictEqual(second.attempts.length, 2, 'the second identical rejection starts nothing');
+ assert.strictEqual(second.row.end_state, 'blocked');
+ assert.match(second.row.outcome_note, /blocked: the verifier rejected the completion 2 times without progress/);
+ });
+
+ await check('a task with no goal behaves exactly as before: done on the worker, no verifier', async () => {
+ const before = asked.length;
+ const id = await startTask(null);
+ const { row, attempts } = await endAttempt(id);
+ assert.strictEqual(asked.length, before, 'the verifier is never asked without a goal');
+ assert.strictEqual(row.outcome, 'done');
+ assert.strictEqual(row.end_state, null);
+ assert.strictEqual(attempts.length, 1);
+ });
+
+ await check('a goal is refused while the switch is off', async () => {
+ const other = await boot({ run, env: { CODES_GOALS_ENABLED: '0' } });
+ try {
+ const otherAda = other.network.addUser('ada');
+ const otherActor = other.network.mintService('svc:actor', 'openvibe.codes', CAPS);
+ const r = await fetch(`${other.base}/api/v1/tasks`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', Authorization: `Bearer ${otherActor}`, 'X-OV-Subject': otherAda.subject, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ repo: 'https://github.com/octocat/Hello-World', prompt: 'x', goal: GOAL, idempotency_key: 'act_switch_off' }),
+ });
+ assert.strictEqual(r.status, 409);
+ const body = await r.json();
+ assert.strictEqual(body.code, 'codes.goals_disabled');
+ } finally { await other.close(); }
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();