openvibers/openvibe.codes Commits

AL1: a goal task is not ready until its check and an independent verifier accept (#376)

OP OpenVibers committed 3 days ago · 7800364028 · 21 files, +767 −36

* Goals: the check runs in the workspace, an independent verifier decides, budgets end the task

* Merge remote-tracking branch 'origin/main' into feat/al1b-goal-loop

* Merge main: the goal migration takes the next free number

Changes

ddiff --git a/.env.example b/.env.example
iindex 417ad41..b49870a 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/engine/chain.js b/engine/chain.js
iindex 97ffafb..be61764 100644
---- a/engine/chain.js
++++ b/engine/chain.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' };
ddiff --git a/engine/goal.js b/engine/goal.js
iindex ccdbd18..7220dbd 100644
---- a/engine/goal.js
++++ b/engine/goal.js
@@@ -36,7 +36,9 @@ async function verify({ goal, check, proposal, evidence, checkOutput, callVerifi
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) };
} 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); }
}
ddiff --git a/harness/goal-check.js b/harness/goal-check.js
nnew file mode 100644
iindex 0000000..be02699
---- /dev/null
++++ b/harness/goal-check.js
@@@ -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 };
ddiff --git a/harness/job.js b/harness/job.js
iindex fce26c9..12c273e 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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, tm
if (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, tm
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) {
say({ t: 'step', text: 'Pushing a new branch' });
try {
ddiff --git a/migrations/0033_goal_end_state.sql b/migrations/0033_goal_end_state.sql
nnew file mode 100644
iindex 0000000..20f41af
---- /dev/null
++++ b/migrations/0033_goal_end_state.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 5f457dd..2cb3429 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 99656a4..a2a22fa 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex e74bb9f..2f43d65 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/config.js b/server/config.js
iindex 9e305cc..eb890a3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/server/domain/attempts.js b/server/domain/attempts.js
iindex f673dab..73afbd3 100644
---- a/server/domain/attempts.js
++++ b/server/domain/attempts.js
@@@ -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),
ddiff --git a/server/domain/goal-verifier.js b/server/domain/goal-verifier.js
nnew file mode 100644
iindex 0000000..8974d9e
---- /dev/null
++++ b/server/domain/goal-verifier.js
@@@ -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 };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex e4226d8..4aed88d 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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, ou
await 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, ou
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() });
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()]);
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, ou
const 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, ou
const 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, ou
return 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, ou
async 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, ou
await 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, ou
if (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, ou
function 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 } : {}) } } : {}),
});
}
ddiff --git a/server/http/me-api.js b/server/http/me-api.js
iindex e4c6eff..d9dc3ad 100644
---- a/server/http/me-api.js
++++ b/server/http/me-api.js
@@@ -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) {
ddiff --git a/server/http/run.js b/server/http/run.js
iindex a64d165..ea01b13 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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) })}`}`;
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 476ec4f..db803ff 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -145,7 +145,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return {
...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, task
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'";
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) => {
ddiff --git a/test/engine-goal.test.js b/test/engine-goal.test.js
iindex f24ff87..25849a8 100644
---- a/test/engine-goal.test.js
++++ b/test/engine-goal.test.js
@@@ -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();
})();
ddiff --git a/test/goal-check.test.js b/test/goal-check.test.js
nnew file mode 100644
iindex 0000000..e8a3f97
---- /dev/null
++++ b/test/goal-check.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 82a22d7..7619a50 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 2ac6c84..2ed1864 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
ddiff --git a/test/task-goals.test.js b/test/task-goals.test.js
nnew file mode 100644
iindex 0000000..69f56a2
---- /dev/null
++++ b/test/task-goals.test.js
@@@ -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();
++})();