openvibers/openvibe.codes Commits
Codes C9a: limits, the turn-cap reason, one continuation, Know (#183)
Changes
@@@ -29,3 +29,7 @@ OV_OAUTH_CLIENT_SECRET=# optional: defaults to true in production# COOKIE_SECURE=true++++# 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@@@ -31,6 +31,11 @@ adapterFor('codex').build({ prompt, cwd, model: 'gpt-5-codex', effort: 'medium',The effort is fitted to the harness, never handed over as it came: the 2026-10-29 incident killed a job that copiedCodex's "medium" to Command Code, which accepts off, low, high and max.++The harness's own turn cap and wall clock follow the task's limits (`budget.caps()`): 25 turns above the step limit and++5 minutes past the time limit, or 100000 / no timer when the owner set none. `claude-code` and `command-code` carry it++as `--max-turns`, `minimax-code` as `--max-steps` and `--timeout`; every adapter is stopped by the same wall clock in++`runCli()`. Claude Code also gets `mcp__ov_know` when the machine has the shared Know MCP server registered.++### `jobrunner.js` — one attempt of one harness, the way the portal runs oneThe prompt is assembled (`prompt.js`), the harness is started (`adapters/`), what it prints goes through the transcript@@@ -39,7 +44,9 @@ normaliser (`transcript.js`) into job lines (`joblines.js`), and a budget stop b### `prompt.js` — the prompt every job gets--The git rule, the house rules, the lessons (when a store is reachable), the repo digest (`repomap.js`), then the brief.++The git rule, the house rules, the lessons, the repo digest (`repomap.js`), then the brief. The lessons come from the++shared Know store's own `recall()` (the portal's `know/lessons.js`; on a Node its path is configuration —++`CODES_KNOW_LESSONS` — never a hard-coded home). A store that cannot be read gives no lessons, not an error.### `logins.js` — what this machine has@@@ -74,7 +81,11 @@ The ladder is the portal's, so the portal's jobs keep their routing. Codes supplPure: it takes the attempts and returns the outcome and the next step. It never reads a log folder, a marker file or aprocess table — the caller passes the data in. The failure reasons are the portal's: `limit`, `auth`, `capacity`,--`refused`, `error`, `budget`, `stopped`, `crash`, `timeout`, `incomplete`, `context`, `hung`.++`refused`, `error`, `budget`, `stopped`, `crash`, `timeout`, `incomplete`, `context`, `hung`, `turns`.++++A harness that stops at its own turn cap (`max_turns`, `error_max_turns`, `turn limit_exceeded`) is `turns`: the next++step is `continue` in the same session on the same harness, never `switch`. Only when there is no session to resume may++another runner take it. A continuation of an attempt the owner settled (Fine / Mark handled) is never switched.An attempt is `{ key, runner, name, runnerName, status, updated, error, next, prev, parent, resolvedNote, stopped, budget, wrapUp, facts }`.@@@ -101,11 +112,16 @@ Three limits: steps (tool calls), minutes (wall clock) and dollars. A limit thatprice is never a zero budget. The first limit reached wins. Steps and minutes buy **one** wrap-up turn, so the workalready done lands; dollars stay a hard stop.--Public: `limitsFor(cfg, runner, override)`, `evaluate(used, limits)`, `state(status)`, `wrapUp(hit, opts)`,--`wrapUpPrompt(hit)`, `STATES`.++Public: `limitsFor(cfg, runner, override)`, `caps(limits)`, `evaluate(used, limits)`, `state(status)`,++`wrapUp(hit, opts)`, `wrapUpPrompt(hit)`, `STATES`.```js--const { evaluate, wrapUp } = require('./engine/budget');++const { evaluate, caps, wrapUp } = require('./engine/budget');++++caps({ steps: 350, minutes: 45 });++// { maxTurns: 375, timeoutMs: 3000000, timeout: '50m' }++caps({ steps: null, minutes: null });++// { maxTurns: 100000, timeoutMs: 0, timeout: '100000m' } — no limit means noneevaluate({ steps: 120, minutes: 30, usd: 1.2 }, { steps: 100, minutes: 60, usd: 5 });// { hit: 'steps', limits: { steps: 100, minutes: 60, usd: 5 }, used: { steps: 120, minutes: 30, usd: 1.2 } }@@@ -19,10 +19,44 @@*/const { spawn } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');const readline = require('readline');const { ORDER } = require('../effort');const { scrubDeep } = require('../redact');++/** No limit: the turn cap a harness gets when the owner set none (the portal's 100000). */++const NO_TURN_CAP = 100000;++++/**++ * The harness's own turn cap, from the task's limits (Settings → Limits): the portal's `--max-turns` value.++ * A missing or zero cap means no limit, never the old 150/200 hard-coded ones.++ *++ * @param {number|string} [value] the cap the job runner derived (steps + 25, or none)++ * @returns {string} the number the CLI's argv carries++ */++function turnCap(value) {++ const n = Number(value);++ return String(Number.isFinite(n) && n > 0 ? Math.round(n) : NO_TURN_CAP);++}++++/**++ * Whether this machine has the shared Know MCP server (`ov_know`) registered for Claude Code, so the harness may call++ * its tools (`mcp__ov_know`): `~/.claude.json` → `mcpServers.ov_know`, the portal's own registration.++ *++ * @param {object} [opts]++ * @param {string} [opts.file] the Claude Code config to read (a test, or a Node's own path)++ * @returns {boolean} true when the server is registered++ */++function knowRegistered({ file } = {}) {++ const config = file || process.env.OPENVIBE_CLAUDE_CONFIG || path.join(os.homedir(), '.claude.json');++ try {++ const doc = JSON.parse(fs.readFileSync(config, 'utf8'));++ return !!(doc && doc.mcpServers && doc.mcpServers.ov_know);++ } catch { return false; }++}++/** The reasoning levels each harness accepts (the portal's ds-limits.js / engine/effort.js). */const EFFORT_LEVELS = {'claude-code': ['low', 'medium', 'high', 'xhigh', 'max'],@@@ -73,13 +107,13 @@ function childEnv(extra = {}) {* @param {string} [opts.cwd] the working directory* @param {object} [opts.env] the environment* @param {string} [opts.stdin] what the CLI reads (the brief, for the harnesses that take it on stdin)-- * @param {number} [opts.timeoutMs] how long it may run++ * @param {number} [opts.timeoutMs] how long it may run; 0 (the default) is no limit* @param {AbortSignal} [opts.signal] stops it* @param {function} opts.onLine every stdout line* @param {function} [opts.onEnd] when the process ended ({ code, signal, timedOut, stderr })* @returns {{cancel: function, done: Promise<object>}} the run*/--function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, onLine, onEnd }) {++function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 0, signal, onLine, onEnd }) {let child;let stderrBuf = '';let timedOut = false;@@@ -101,8 +135,9 @@ function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, otry { child.kill('SIGTERM'); } catch { /* gone */ }setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000).unref();};-- const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);-- timer.unref();++ // A wall clock the owner set (the cap is its limit plus five minutes); no limit is no timer at all.++ const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; cancel(); }, timeoutMs) : null;++ if (timer) timer.unref();if (signal) {if (signal.aborted) cancel();else signal.addEventListener('abort', cancel, { once: true });@@@ -176,4 +211,4 @@ const TOOL_NAMES = {task: 'Task', todowrite: 'TodoWrite', todo_list: 'TodoWrite',};--module.exports = { EFFORT_LEVELS, effortFor, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };++module.exports = { EFFORT_LEVELS, NO_TURN_CAP, effortFor, turnCap, knowRegistered, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };@@@ -19,7 +19,7 @@* claude-minimax sets, and what claude-deepseek does for DeepSeek).*/--const { effortFor, claudeFeed, TOOL_NAMES } = require('./base');++const { effortFor, turnCap, knowRegistered, claudeFeed, TOOL_NAMES } = require('./base');/** The permission modes, by the Codes level (read, edit, full). */const MODES = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' };@@@ -43,21 +43,27 @@ function rulesFile(rules) { return rules && rules.trim() ? rules.trim() : null;* @param {string} [ctx.effort] the wanted reasoning level* @param {string} [ctx.resume] the session to continue* @param {string} [ctx.permission] 'read' | 'edit' | 'full'-- * @param {number} [ctx.maxTurns] the turn limit++ * @param {number} [ctx.maxTurns] the turn limit (steps + 25, or none)* @param {string} [ctx.rules] the rules file (appended to the system prompt)* @param {object} [ctx.provider] the provider's environment (base URL, token, model): the cm-style environment++ * @param {boolean} [ctx.know] this machine has the shared Know MCP server (default: read the machine's Claude config)++ * @param {string} [ctx.claudeConfig] the Claude Code config to read for that check (a Node's own path, or a test)* @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run*/function build(ctx = {}) {const effort = effortFor('claude-code', ctx.effort);const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',-- '--max-turns', String(ctx.maxTurns || 200),++ '--max-turns', turnCap(ctx.maxTurns),'--permission-mode', MODES[ctx.permission] || MODES.edit,'--exclude-dynamic-system-prompt-sections'];const rules = rulesFile(ctx.rules);if (rules) argv.push('--append-system-prompt-file', rules);argv.push('--disallowedTools', DENIED.join(' '));-- argv.push('--allowedTools', ALLOWED.join(' '));++ // mcp__ov_know: the shared Know server, when this machine registered it (know/register.js). Claude Code refuses an++ // MCP tool that is not on --allowedTools in a headless run, so the allow list is what makes it callable.++ const allowed = ALLOWED.slice();++ if (ctx.know === true || (ctx.know !== false && knowRegistered({ file: ctx.claudeConfig }))) allowed.push('mcp__ov_know');++ argv.push('--allowedTools', allowed.join(' '));if (ctx.resume) argv.push('--resume', String(ctx.resume));const env = {};const p = ctx.provider || {};@@@ -67,7 +73,7 @@ function build(ctx = {}) {if (ctx.model) env.ANTHROPIC_MODEL = ctx.model;if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort;if (p.mcpConfig) argv.push('--mcp-config', p.mcpConfig);-- if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', ALLOWED.concat(p.extraAllowed).join(' '));++ if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', allowed.concat(p.extraAllowed).join(' '));return { cmd: ctx.bin || 'claude', argv, env, stdin: ctx.prompt || '' };}@@@ -15,7 +15,7 @@const fs = require('fs');const path = require('path');--const { effortFor, claudeFeed, TOOL_NAMES, emitter } = require('./base');++const { effortFor, turnCap, claudeFeed, TOOL_NAMES, emitter } = require('./base');/** The tools a job may not use (the portal's deny list). */const DENY = ['Shell(rm:*)', 'Shell(git push:*)', 'Shell(git commit:*)', 'Shell(git reset:*)', 'Shell(git checkout:*)',@@@ -44,7 +44,7 @@ function build(ctx = {}) {argv.push('--effort', effort || 'high');argv.push('--config', 'compact-mode=fast');argv.push('--output-format', 'json');-- argv.push('--max-turns', String(ctx.maxTurns || 150));++ argv.push('--max-turns', turnCap(ctx.maxTurns));argv.push('--skip-onboarding', '--no-auto-update', '-t');return { cmd: ctx.bin || 'cmd', argv, env: {}, stdin: null };}@@@ -6,15 +6,17 @@* The argv comes from the portal's mm-run.sh:** mcode exec --input - --output-format stream-json --prompt-mode coding --permission MODE --cwd DIR-- * --timeout 45m --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]++ * --timeout TIME --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]* [--model MODEL] [--effort EFFORT] [--session SESSION]** The rules ride in the appended system prompt (the stable, best-cached prefix) and the brief alone is the user turn.* --ptc is Programmatic Tool Calling (the model batches its tool calls in one round trip). The permission is smart for a-- * guarded job and full for the owner's "full" choice.++ * guarded job and full for the owner's "full" choice. The wall clock and the step cap follow the task's limits++ * (engine/budget.js caps): the time limit plus five minutes, and the step limit plus 25, or 100000m / 100000 when the++ * owner set none (the portal's 86e5f95: this used to be a hard-coded 45m / 150 steps).*/--const { effortFor, emitter } = require('./base');++const { effortFor, turnCap, emitter } = require('./base');/*** The argv and the environment for one MiniMax Code run.@@@ -37,8 +39,8 @@ function build(ctx = {}) {const effort = effortFor('minimax-code', ctx.effort);const perm = ctx.permission === 'full' ? 'full' : 'smart';const argv = ['exec', '--input', '-', '--output-format', 'stream-json', '--prompt-mode', 'coding',-- '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '45m',-- '--max-steps', String(ctx.maxSteps || 150)];++ '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '100000m',++ '--max-steps', turnCap(ctx.maxSteps || ctx.maxTurns)];if (ctx.rules && String(ctx.rules).trim()) argv.push('--append-system-prompt-file', String(ctx.rules).trim());if (ctx.out) argv.push('-o', ctx.out);if (ctx.ptc !== false) argv.push('--ptc');@@@ -33,6 +33,35 @@ function limitsFor(cfg, runner, override) {return out;}++/** The harness's own caps sit this far above the owner's limits, so the wrap-up turn still runs. */++const TURN_MARGIN = 25;++const MINUTE_MARGIN = 5;++/** No limit: the harnesses' own cap is effectively none (the portal's 100000 turns). */++const NO_TURN_CAP = 100000;++++/**++ * The caps the harness itself runs under, derived from the task's limits (Settings → Limits).++ *++ * A step limit puts the harness's turn cap 25 above it, so the portal's clean wrap-up comes first; no step limit means++ * no turn cap (100000). A time limit is the wall clock in milliseconds, five minutes longer than the owner's limit; no++ * time limit means no timer at all (`timeoutMs` 0). `timeout` is the same clock in the form MiniMax Code takes.++ *++ * @param {{steps?: number|null, minutes?: number|null}} [limits] the task's limits++ * @returns {{maxTurns: number, timeoutMs: number, timeout: string}} the harness's caps++ */++function caps(limits = {}) {++ const steps = Number(limits && limits.steps);++ const minutes = Number(limits && limits.minutes);++ const hasSteps = Number.isFinite(steps) && steps > 0;++ const hasMinutes = Number.isFinite(minutes) && minutes > 0;++ const timeoutMs = hasMinutes ? (Math.round(minutes * 60) + MINUTE_MARGIN * 60) * 1000 : 0;++ return {++ maxTurns: hasSteps ? Math.round(steps) + TURN_MARGIN : NO_TURN_CAP,++ timeoutMs,++ timeout: timeoutMs ? `${Math.ceil(timeoutMs / 60000)}m` : `${NO_TURN_CAP}m`,++ };++}++/*** Compare what a task spent with its limits. The first limit reached wins (steps, then minutes, then dollars).*@@@ -99,4 +128,4 @@ function wrapUp(hit, opts = {}) {return { action: 'wrap-up', prompt: wrapUpPrompt(hit), why: `one last turn to land the work after the ${hit} limit` };}--module.exports = { STATES, limitsFor, evaluate, state, wrapUp, wrapUpPrompt };++module.exports = { STATES, TURN_MARGIN, MINUTE_MARGIN, NO_TURN_CAP, limitsFor, caps, evaluate, state, wrapUp, wrapUpPrompt };@@@ -55,6 +55,7 @@ const REASON_TEXT = {incomplete: 'it stopped before the work was done',context: 'the context grew too big',hung: 'it went quiet for too long',++ turns: 'stopped at its turn cap, mid-work',};// A job that stopped for a reason the engine can fix by itself: the context is too big, the clock ran out, the agent@@@ -77,6 +78,9 @@ function reason(text) {if (/authenticat|log ?in again|run .?cmd login|\b401\b|api key (has )?expired|invalid api key|unauthori[sz]ed|not signed in/i.test(s)) return 'auth';if (/at capacity|overloaded|\b529\b|\b503\b|temporarily unavailable|server is busy/i.test(s)) return 'capacity';if (/mcode exec failed:.*Internal server error.*\(50[024]\)/i.test(s) || /(?:HTTP(?: status)?|status(?: code)?)\s*[:=]?\s*50[024]\b/i.test(s)) return 'capacity';++ // A harness's own turn cap ("✔ max_turns in 2483s", error_max_turns): the work isn't done and isn't broken. It is++ // continued in its own session, on the same harness, never restarted on another (10-30, cc:codes-c4b-screens).++ if (/\bmax[_ ]turns\b|error_max_turns|turn limit|maximum (?:number of )?turns/i.test(s)) return 'turns';return 'error';}@@@ -411,6 +415,20 @@ function chainEnd(runs) {return settleChain(runs, byKey, carriedMap(runs, byKey), latest).end;}++/** Whether the owner settled an attempt (Fine / Mark handled): its continuation is settled too and is never switched. */++function ownerSettled(run) {++ if (!run) return false;++ if (run.resolvedNote) return true;++ return !!(run.resolved && (run.resolved.how === 'note' || run.resolved.how === 'stopped'));++}++++/** The attempt this one continues, when the owner already settled it. */++function settledParent(runs, end) {++ if (!end || !end.parent) return null;++ const parent = runs.find((r) => r.key === end.parent);++ return ownerSettled(parent) ? parent : null;++}++/** The next step, given the chain's end. */function step(runs, end, opts = {}) {if (!end) return { action: 'none', why: 'there are no attempts' };@@@ -423,6 +441,17 @@ function step(runs, end, opts = {}) {const wrap = budget.wrapUp(hit, { done: !!end.wrapUp });return { action: wrap.action, hit, prompt: wrap.prompt, why: wrap.why };}++ // A harness that stopped at its own turn cap is carried on in its own session, on the same harness, with its++ // context. This comes before every switch: the work is not broken, and another runner would redo it from scratch.++ if (why === 'turns') {++ const session = facts(end).session;++ if (session) return { action: 'continue', session, why: 'the harness stopped at its turn cap, mid-work: continue it in the same session' };++ const settled = settledParent(runs, end);++ if (settled) return { action: 'failed', why: `its earlier attempt was marked handled by the owner (${settled.key}): not switched to another runner` };++ const choice = nextRunner(end, opts);++ if (!choice) return { action: 'failed', why: 'the harness stopped at its turn cap, there is no session to continue, and no other runner can take it now' };++ return { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };++ }// The same session continues the task when it stopped early (crash, timeout, incomplete, hung); a context that// grew too big starts fresh with a compacted brief instead. This comes before the runner switch: a context that// overflowed is not a worker's fault, and another runner would hit the same wall.@@@ -439,6 +468,10 @@ function step(runs, end, opts = {}) {const tries = end.attempt || 1;const max = why === 'error' ? 2 : 4;if (tries >= max) return { action: 'failed', why: `the task was already retried ${tries} times; the owner must decide` };++ // A continuation of an attempt the owner marked Fine is never switched to another runner: it would redo work++ // the owner already landed (10-30, blog-patchnotes-v2).++ const settled = settledParent(runs, end);++ if (settled) return { action: 'failed', why: `its earlier attempt was marked handled by the owner (${settled.key}): not switched to another runner` };const choice = nextRunner(end, opts);if (!choice) return { action: 'failed', why: `no other runner can take it now (${why})` };const out = { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };@@@ -2,8 +2,21 @@- Never copy, mirror, move or re-create git metadata: no `.git` directories made by hand, no `git clone --mirror`, no`git worktree` repairs.--- Never change a remote, `remote.*.mirror`, `core.*` or any other git config. Never push.++- Never change a remote, `remote.*.mirror`, `core.*` or any other git config.++- Push only your own job branch, and only when your brief asks for a PR: `git -c credential.helper="!gh auth git-credential"++ push -u origin <your-branch>`. Never push to main, never force-push, never push someone else's branch.- If git can't write (a permission or read-only error), stop and say so in your report. The main session fixes it. Don't workaround it.- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Neveramend or rebase.++++## Working efficiently (added to every job by the runner)++++- Do the work yourself: never spawn sub-agents or agent teams. The orchestrator already runs jobs in parallel.++- Before editing, read ~/openvibe/agents/ACTIVE.md: never edit a file another running job is editing; if you must, stop and report the conflict.++- Read only what you need: search first (rg/grep), then read line ranges; never re-read a file you already have.++- Keep command output small (tail, head, --quiet); run the narrowest test that proves the change, then the suite once.++- Use the repo map provided above before exploring. At most five web searches per job unless the brief asks for research. Never paste whole files into answers.++- End with a short report, at most 25 lines: what changed (files), how it was checked, what is left. Don't restate the brief.++- Never stop, restart or kill the agent portal (`openvibe-agent-runs`) or other agents' processes. To test portal changes, run a++ second copy on another port (`RUNS_PORT=8791 node server.js`) and stop that copy by its own PID.@@@ -64,6 +64,7 @@ module.exports = {nextStep: chain.nextStep,evaluate: budget.evaluate,limitsFor: budget.limitsFor,++ caps: budget.caps,parse: transcript.parse,parseText: transcript.parseText,fitEffort: effort.fitEffort,@@@ -26,8 +26,8 @@ const transcript = require('./transcript');const joblines = require('./joblines');const budget = require('./budget');--/** How long one attempt may run (the portal's 2700 s). */--const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;++/** The wall clock when the task sets none: no limit, like every other unset limit. */++const DEFAULT_TIMEOUT_MS = 0;/*** Run one attempt.@@@ -40,7 +40,10 @@ const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;* @param {string} [opts.effort] the wanted reasoning level (fitted to the harness)* @param {string} [opts.permission] 'read' | 'edit' | 'full'* @param {string} [opts.resume] the session to continue-- * @param {object} [opts.budget] the limits ({ steps, minutes, usd })++ * @param {object} [opts.budget] the limits ({ steps, minutes, usd }); null means no limit++ * @param {boolean} [opts.know] this machine has the shared Know MCP server (Claude Code gets mcp__ov_know)++ * @param {string} [opts.claudeConfig] the Claude Code config to read for that check++ * @param {string} [opts.knowLessons] the Know store's module (know/lessons.js) for the prompt's lessons* @param {string} [opts.log] the file the feed is written to* @param {function} [opts.onLine] every job line* @param {AbortSignal} [opts.signal] stops the attempt@@@ -58,7 +61,7 @@ async function runAttempt(opts = {}) {// The prompt: the rules, the house rules, the lessons, the repo digest, then the brief.const prompt = opts.systemPrompt === false? { text: String(opts.prompt || ''), parts: [{ name: 'brief', text: String(opts.prompt || '') }] }-- : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env });++ : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env, know: opts.knowLessons });const rulesFile = opts.rulesFile !== null ? path.join(dir, 'rules.md') : null;if (rulesFile) fs.writeFileSync(rulesFile, prompt.parts.filter((p) => p.name !== 'brief').map((p) => p.text).join('\n\n'));@@@ -67,13 +70,22 @@ async function runAttempt(opts = {}) {try { adapter.writeSettings(cwd, { permission: opts.permission || 'edit' }); } catch { /* the job may not write there */ }}++ // The task's limits (Settings → Limits) set the harness's own caps too: its turn cap sits 25 above the step limit++ // and its wall clock 5 minutes past the time limit, or there is neither (engine/budget.js caps()). Passing them to++ // the builder and the process keeps Command Code, Claude Code and MiniMax Code from their old hard-coded 150/200.++ const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);++ const caps = budget.caps(limits);const built = adapter.build({prompt: prompt.text,model: opts.model,effort: opts.effort,resume: opts.resume,permission: opts.permission,-- maxTurns: opts.maxTurns,++ maxTurns: opts.maxTurns != null ? opts.maxTurns : caps.maxTurns,++ maxSteps: opts.maxSteps != null ? opts.maxSteps : (opts.maxTurns != null ? opts.maxTurns : caps.maxTurns),++ timeout: caps.timeout,++ know: opts.know,++ claudeConfig: opts.claudeConfig,cwd,rules: rulesFile,provider: opts.provider,@@@ -82,7 +94,6 @@ async function runAttempt(opts = {}) {});const started = Date.now();-- const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);const state = { from: 0, sessionId: opts.resume || null, usage: null, cost: null, steps: 0, done: false };const feed = adapter.feed({@@@ -115,7 +126,7 @@ async function runAttempt(opts = {}) {// The adapter's own environment (the provider's, the effort), then the job's (the PATH with the CLI on it).env: { ...(opts.env || {}), ...built.env },stdin: built.stdin,-- timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS,++ timeoutMs: opts.timeoutMs != null ? opts.timeoutMs : caps.timeoutMs,signal: opts.signal,onLine: (raw) => { feed.line(raw); pump(); },onEnd: () => { feed.end(); },@@@ -23,39 +23,29 @@ function fragment(name) {}/**-- * The lessons a job gets: what earlier jobs learned here, at most five lines.++ * The lessons a job gets: what earlier jobs learned here, through the shared Know store's own `recall()`++ * (the portal's know/lessons.js; on a Node its path comes from the configuration, never a hard-coded home).*-- * The store is optional and off by default: `OPENVIBE_CODES_LESSONS` names a JSONL file (one lesson per line, with a-- * `text` field and optional `repo` and `tags`). A store that cannot be read gives no lessons, not an error.++ * The store is optional and off by default. A path that cannot be required, a module without recall(), or a recall++ * that throws gives no lessons, never an error: a job must not fail because the memory is unreachable.** @param {object} opts-- * @param {string} [opts.file] the store's path++ * @param {string} [opts.file] the Know module's path (know/lessons.js)* @param {string} [opts.repo] the repo the job works in (its lessons come first)* @param {string} [opts.brief] the brief (its words pick the lessons that fit)++ * @param {object} [opts.env] the environment (CODES_KNOW_LESSONS, or the older OPENVIBE_CODES_LESSONS)* @returns {string} the lessons, as markdown ('' when there are none)*/--function lessons({ file, repo, brief } = {}) {-- const path_ = file || process.env.OPENVIBE_CODES_LESSONS;++function lessons({ file, repo, brief, env = process.env } = {}) {++ const path_ = String(file || (env && (env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS)) || '').trim();if (!path_) return '';-- let raw;-- try { raw = fs.readFileSync(path_, 'utf8'); } catch { return ''; }-- const rows = [];-- for (const line of String(raw).split('\n')) {-- if (!line.trim()) continue;-- let row; try { row = JSON.parse(line); } catch { continue; }-- const text = typeof row.text === 'string' ? row.text.trim() : '';-- if (text) rows.push({ text, repo: row.repo || null, tags: Array.isArray(row.tags) ? row.tags : [] });-- }-- if (!rows.length) return '';-- const words = new Set(String(brief || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));-- const scored = rows.map((r) => {-- let score = r.repo && repo && r.repo === repo ? 10 : 0;-- for (const t of r.tags) if (words.has(String(t).toLowerCase())) score += 2;-- for (const w of words) if (r.text.toLowerCase().includes(w)) score += 1;-- return { r, score };-- }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);-- if (!scored.length) return '';-- return ['### What earlier jobs learned here', ...scored.map((x) => `- ${x.r.text}`)].join('\n');++ let know;++ try { know = require(path.resolve(path_)); } catch { return ''; }++ if (!know || typeof know.recall !== 'function') return '';++ try {++ const block = know.recall(String(brief || ''), repo || '*', 5);++ return typeof block === 'string' ? block.trim() : '';++ } catch { return ''; }}/**@@@ -64,14 +54,15 @@ function lessons({ file, repo, brief } = {}) {* @param {object} opts* @param {string} opts.brief what the task is* @param {string} opts.dir the folder the job works in-- * @param {object} [opts.env] the environment (OPENVIBE_CODES_LESSONS)++ * @param {object} [opts.env] the environment (CODES_KNOW_LESSONS, or the older OPENVIBE_CODES_LESSONS)++ * @param {string} [opts.know] the Know module's path (the Node's config wins over the environment)* @returns {{text: string, parts: object[]}} the prompt and the parts it is made of*/--function assemble({ brief, dir, env = process.env } = {}) {++function assemble({ brief, dir, env = process.env, know } = {}) {const gitRule = fragment('git-rule.md');const houseRules = fragment('house-rules.md');const repo = repomap(dir);-- const learned = lessons({ file: env.OPENVIBE_CODES_LESSONS, repo: repo.repo, brief });++ const learned = lessons({ file: know, repo: repo.repo, brief, env });const parts = [{ name: 'git-rule', text: gitRule },{ name: 'house-rules', text: houseRules },nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- Codes C9a: the person's own limits, in Settings → Limits. Steps per task (tool calls) and minutes per task (wall++-- clock), for every harness at once; null means no limit, never a zero budget. The harness's own caps follow them++-- (engine/budget.js caps): its turn cap sits 25 above the step limit and its wall clock 5 minutes past the time limit.++--++-- Two nullable integers on the settings row, so a person without limits needs no row and the existing defaults stand.++++ALTER TABLE codes_user_settings++ ADD COLUMN IF NOT EXISTS steps integer, -- steps per task: null = no limit++ ADD COLUMN IF NOT EXISTS minutes integer; -- minutes per task: null = no limit@@@ -123,6 +123,9 @@ function load(env = process.env) {// The tokens one attempt may spend, when the task does not say (a run token's budget, Codes C3).tokens: int(env.CODES_TASK_TOKENS, 0),},++ // Know (the shared lessons store, Know K): on a Node this is the portal's know/lessons.js, never a hard-coded++ // home. The path is configuration; a store that cannot be read gives no lessons, never an error.++ 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)) },// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.@@@ -43,7 +43,7 @@ const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard:/** The attempt's own states, and the reasons it may have ended. */const STATES = ['queued', 'running', 'done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];--const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context'];++const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context', 'turns'];const KINDS = ['first', 'continue', 'switch', 'wrap-up', 'restart', 'manual'];/** The engine's own words for a reason: the timeline reads like a person wrote it. */@@@ -63,6 +63,7 @@ const ERROR_TEXT = {stopped: 'stopped on purpose',context: 'the context window is too big',hung: 'it went quiet for too long',++ turns: 'the harness reached max_turns',};/** The engine's status for our state (the two vocabularies are the same, except 'queued' and 'running'). */@@@ -548,12 +548,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou/*** The next attempt now: the owner carries the task on (with a note), or switches to another agent. The task's row* is locked while the attempt is written, so two presses start one attempt and not two.++ *++ * One continuation at a time (the portal's liveContinuation, 10-30): while an attempt is still live — the automatic++ * continue after a crash, a wrap-up, another manual press — that attempt is returned, and no second one is started++ * beside it. Two sessions on one worktree would fight over the same files.*/async function continueTask(row, { note = null, agent = null } = {}) {if (!row || !row.id) return { error: 'no such task' };const attempts = await attemptStore.list(row.id);const ending = attempts[attempts.length - 1];if (!ending) return { error: 'the task has no attempt to carry on' };++ const live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));++ if (live) return { attempt: live, live: true, row };const text = note ? String(note).trim().slice(0, 4000) : '';const plan = agent? {@@@ -1,12 +1,14 @@'use strict';/**-- * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications and the safety-- * level. One row per person in codes_user_settings (migration 0013); a person without a row reads the defaults, so the-- * Settings screen is complete before anything is saved.++ * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications, the safety++ * level and the limits on every task (Codes C9a: steps and minutes, null meaning no limit). One row per person in++ * codes_user_settings (migrations 0013 and 0014); a person without a row reads the defaults, so the Settings screen is++ * complete before anything is saved.** createUserSettings({ store })-- * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety }++ * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,++ * steps, minutes }* save(subject, patch) → the saved row, in the same shape** Only a usr_ subject is accepted: a service token has no preferences of its own to keep.@@@ -15,8 +17,24 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const SAFETY = ['ask', 'project', 'full'];const AGENT = /^[a-z0-9-]{1,40}$/;const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;++/** The most a person may set (the portal's own cap): a number of steps or minutes, or none at all. */++const LIMIT_MAX = 100000;--const DEFAULTS = Object.freeze({ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask' });++const DEFAULTS = Object.freeze({++ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask',++ steps: null, minutes: null,++});++++/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */++function limit(v, field) {++ if (v === undefined) return undefined;++ if (v === null || v === '' || v === 0 || v === '0' || String(v).toLowerCase() === 'off') return null;++ const n = Number(v);++ if (!Number.isFinite(n) || n <= 0 || n > LIMIT_MAX) {++ throw Object.assign(new Error(`${field} is a positive number, or empty for No limit`), { status: 422, code: 'settings.limit_invalid' });++ }++ return Math.round(n);++}function present(row) {if (!row) return { ...DEFAULTS };@@@ -26,6 +44,8 @@ function present(row) {notify_done: row.notify_done !== false,notify_needs: row.notify_needs !== false,safety: SAFETY.includes(row.safety) ? row.safety : 'ask',++ steps: row.steps == null ? null : Number(row.steps),++ minutes: row.minutes == null ? null : Number(row.minutes),};}@@@ -64,17 +84,24 @@ function createUserSettings({ store }) {if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });next.safety = v;}++ // Settings → Limits: steps and minutes per task, for every harness at once. No value means no limit.++ const steps = limit(patch.steps, 'steps per task');++ if (steps !== undefined) next.steps = steps;++ const minutes = limit(patch.minutes, 'minutes per task');++ if (minutes !== undefined) next.minutes = minutes;await db.exec(-- `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, now())++ `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,++ steps, minutes, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,-- notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety, updated_at = now()`,-- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety],++ notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,++ steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, updated_at = now()`,++ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes],);return next;}-- return { get, save, DEFAULTS, SAFETY };++ return { get, save, DEFAULTS, SAFETY, LIMIT_MAX };}--module.exports = { createUserSettings, present, DEFAULTS, SAFETY };++module.exports = { createUserSettings, present, DEFAULTS, SAFETY, LIMIT_MAX };@@@ -460,11 +460,23 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');// The same order the Home picker shows: the machines their work ran on last come first.const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');++ // Settings → Limits (C9a): steps and minutes per task, for every agent at once; No limit means none.++ const limitSelect = (name, value, presets, unit) => {++ const choices = [[null, 'No limit'], ...presets];++ if (value != null && !choices.some(([v]) => v === Number(value))) choices.push([Number(value), Number(value).toLocaleString('en-US')]);++ return `<select name="${name}">${choices.map(([v, label]) => `<option value="${v == null ? '' : v}"${(v == null ? value == null : Number(value) === v) ? ' selected' : ''}>${esc(label)}${v == null ? '' : ` ${unit}`}</option>`).join('')}</select>`;++ };const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings"><div class="c4-form-grid"><label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label><label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label></div>++<h3 class="c4-subhead">Limits</h3>++<div class="c4-form-grid">++<label>Steps per task ${limitSelect('steps', settings.steps, [[200, '200'], [350, '350'], [500, '500'], [1000, '1,000']], 'steps')}</label>++<label>Time per task ${limitSelect('minutes', settings.minutes, [[45, '45'], [90, '90'], [180, '180'], [360, '360']], 'min')}</label>++</div>++<p class="c4-hint">For every agent. The harness's own turn cap and timer follow these; No limit means none. A task that reaches a limit lands its work in one last turn and carries on with a fresh budget.</p><h3 class="c4-subhead">Notifications</h3><div class="c4-checks"><label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>@@@ -487,6 +499,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-notify_done: b.notify_done === '1',notify_needs: b.notify_needs === '1',...(b.safety ? { safety: b.safety } : {}),++ steps: b.steps === undefined ? undefined : b.steps,++ minutes: b.minutes === undefined ? undefined : b.minutes,});return res.redirect(303, '/settings?ok=Settings%20saved');} catch (e) {@@@ -300,7 +300,8 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskif (agent && !harnesses.list().some((a) => a.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses');const out = await taskStore.continueTask(row, { note, agent });if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);-- log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);++ // One continuation at a time: an attempt that is already live answers this press, and no second one starts.++ if (!out.live) log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);return res.json(await timeline(out.row));});@@@ -128,6 +128,24 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;assert.notStrictEqual(last.key, id, 'the follow-up has its own idempotency key');});++ await check('one continuation at a time: a manual continue while the automatic one runs returns the live one', async () => {++ const id = await startTask();++ const result = {++ ok: false, summary: 'I will continue with the rest of it', files: [],++ log: [{ t: 'agent', agent: 'claude-code', session: 'sess-live' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],++ };++ await endAttempt(id, { state: 'failed', result, error: 'the machine went away' });++ const attempts = await attemptsOf(id);++ assert.strictEqual(attempts.length, 2, 'the crash was carried on automatically');++ assert.strictEqual(attempts[1].state, 'queued', 'and its job is live');++ const submits = calls.filter((c) => c[0] === 'submit').length;++ const pressed = await call('POST', `/tasks/${id}/continue`, { body: { note: 'And check the tests.' } });++ assert.strictEqual(pressed.status, 200, JSON.stringify(pressed.json));++ assert.strictEqual(pressed.json.attempts.length, 2, 'no second continuation was started');++ assert.strictEqual(pressed.json.attempts[1].session, 'sess-live', 'the live continuation is the one returned');++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, submits, 'no job was started beside it');++ });++await check('a limit: another agent takes over, with a hand-over note of what the last one did', async () => {const id = await startTask();const result = { ok: false, summary: 'I fixed the parser', files: [{ path: 'src/parse.js', added: 3, removed: 1 }], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };@@@ -215,9 +233,12 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;assert.strictEqual(continued.json.attempts.length, 2);assert.strictEqual(continued.json.attempts[1].kind, 'manual');assert.ok(/carried on by the owner/.test(continued.json.attempts[1].why), continued.json.attempts[1].why);-- // While the task runs again, nobody carries it on a second time.++ // While the task runs again, nobody carries it on a second time: the live attempt answers the press.++ const runsBefore = calls.filter((c) => c[0] === 'submit').length;const again = await call('POST', `/tasks/${id}/continue`, { body: { agent: 'codex' } });-- assert.strictEqual(again.status, 409, 'the task is running again');++ assert.strictEqual(again.status, 200, JSON.stringify(again.json));++ assert.strictEqual(again.json.attempts.length, 2, 'the live attempt is returned, not a second one');++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, runsBefore, 'no job was started');});await check('the auth of every new route: no token, no capability, another person', async () => {@@@ -157,6 +157,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(page.text.includes('<option value="codex" selected'), 'the default agent is kept');assert.ok(page.text.includes('value="project" checked'), 'so is the safety level');assert.ok(!page.text.includes('name="notify_done" value="1" checked'), 'an unticked box stays unticked');++ assert.ok(page.text.includes('name="steps"') && page.text.includes('name="minutes"'), 'the limits are on the page');const other = await t.get('/settings', { as: bo });assert.ok(other.text.includes('<option value="codex">'), 'and it is only this person’s');assert.ok(!other.text.includes('<option value="codex" selected'));@@@ -164,6 +165,31 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(String(bad.headers.get('location')).includes('error='), 'a level Codes does not know is refused');});++ await check('Settings → Limits: steps and minutes per task, with No limit meaning none', async () => {++ const saved = await t.get('/settings', { as: ada, method: 'POST', form: {++ default_agent: 'codex', steps: '350', minutes: '90', notify_done: '1', notify_needs: '1', safety: 'ask',++ } });++ assert.strictEqual(saved.status, 303);++ let settings = await t.ctx.userSettings.get(ada.subject);++ assert.strictEqual(settings.steps, 350);++ assert.strictEqual(settings.minutes, 90);++ const page = await t.get('/settings', { as: ada });++ assert.ok(page.text.includes('<option value="350" selected'), 'the step limit is shown');++ assert.ok(page.text.includes('<option value="90" selected'), 'and the time limit');++ // No limit is an empty value, stored as null, never a zero budget.++ await t.get('/settings', { as: ada, method: 'POST', form: { steps: '', minutes: 'off', notify_done: '1', notify_needs: '1' } });++ settings = await t.ctx.userSettings.get(ada.subject);++ assert.strictEqual(settings.steps, null);++ assert.strictEqual(settings.minutes, null);++ const none = await t.get('/settings', { as: ada });++ assert.ok(none.text.includes('<option value="" selected'), 'No limit is selected');++ // A negative or silly value is refused, and the stored one stays.++ await t.get('/settings', { as: ada, method: 'POST', form: { steps: '500' } });++ const refused = await t.get('/settings', { as: ada, method: 'POST', form: { steps: '-3' } });++ assert.ok(String(refused.headers.get('location')).includes('error='), 'a negative limit is refused');++ assert.strictEqual((await t.ctx.userSettings.get(ada.subject)).steps, 500, 'the saved value is untouched');++ });++await check('Usage counts runs, minutes and tokens per day, and shows no prices', async () => {// The task page seeds attempt 1 for a task it knows; the machine's own numbers belong on it.await t.get(`/tasks/${TASK}`, { as: ada });@@@ -24,6 +24,7 @@ const { minimaxCode } = require('../engine/adapters/minimax-code');const transcript = require('../engine/transcript');const { runAttempt } = require('../engine/jobrunner');const { assemble } = require('../engine/prompt');++const { caps } = require('../engine/budget');/** The DRY_RUN form of an adapter: the command, its arguments and its environment. */function dry(adapter, ctx = {}) {@@@ -155,6 +156,55 @@ function dry(adapter, ctx = {}) {assert.ok(dry(minimaxCode, { permission: 'full' }).argv.includes('full'));});++ await check('the harness caps follow Settings → Limits: the set limits, and No limit means none', () => {++ const set = caps({ steps: 350, minutes: 45 });++ const none = caps({ steps: null, minutes: null });++ const arg = (adapter, ctx, flag) => {++ const argv = dry(adapter, ctx).argv;++ return argv[argv.indexOf(flag) + 1];++ };++ // The three harnesses that had a hard-coded cap now take the owner's (steps + 25).++ assert.equal(arg(claudeCode, set, '--max-turns'), '375');++ assert.equal(arg(commandCode, set, '--max-turns'), '375');++ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...set }, '--max-steps'), '375');++ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...set }, '--timeout'), '50m');++ // No limit means no cap: 100000 turns and no wall clock (the portal's MAXTURNS 100000, JOBTIMEOUT 0).++ assert.equal(arg(claudeCode, none, '--max-turns'), '100000');++ assert.equal(arg(commandCode, none, '--max-turns'), '100000');++ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...none }, '--max-steps'), '100000');++ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...none }, '--timeout'), '100000m');++ // An adapter with no cap of its own gets none either: an absent cap is not the old 150/200.++ assert.equal(arg(claudeCode, { maxTurns: undefined }, '--max-turns'), '100000');++ assert.equal(arg(commandCode, { maxTurns: undefined }, '--max-turns'), '100000');++ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', maxSteps: undefined }, '--max-steps'), '100000');++ });++++ await check('Claude-family harnesses may call the Know MCP tools, when the machine registered the server', () => {++ const allowed = (adapter, know) => {++ const argv = dry(adapter, { know }).argv;++ return argv[argv.indexOf('--allowedTools') + 1].split(' ');++ };++ for (const adapter of [claudeCode, claudeOther]) {++ assert.ok(!allowed(adapter, false).includes('mcp__ov_know'), `${adapter.id}: no Know server, no tools`);++ assert.ok(allowed(adapter, true).includes('mcp__ov_know'), `${adapter.id}: the Know tools are allowed`);++ assert.ok(allowed(adapter, true).includes('Bash'), 'the allow list keeps the tools a job needs');++ }++ // The machine's own registration is what decides it by default: ~/.claude.json → mcpServers.ov_know.++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-registered-'));++ const registered = path.join(dir, 'claude.json');++ fs.writeFileSync(registered, JSON.stringify({ mcpServers: { ov_know: { command: 'node', args: ['know/mcp.js'] } } }));++ const absent = path.join(dir, 'none.json');++ fs.writeFileSync(absent, JSON.stringify({ mcpServers: {} }));++ const list = (file) => {++ const argv = dry(claudeCode, { claudeConfig: file }).argv;++ return argv[argv.indexOf('--allowedTools') + 1].split(' ');++ };++ assert.ok(list(registered).includes('mcp__ov_know'), 'the registered server makes its tools callable');++ assert.ok(!list(absent).includes('mcp__ov_know'), 'an unregistered machine gets no allow it cannot use');++ assert.ok(!list(path.join(dir, 'missing.json')).includes('mcp__ov_know'), 'a config that is not there changes nothing');++ fs.rmSync(dir, { recursive: true, force: true });++ });++await check('the effort is fitted to the harness, never handed over as it came', () => {// The 2026-10-29 incident: Codex's "medium" killed Command Code before its first step.assert.equal(effortFor('command-code', 'medium'), 'high');@@@ -244,17 +294,20 @@ function dry(adapter, ctx = {}) {// A job that works in one repository gets that repository's digest; a folder that holds many gets none.require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');-- const lessonsFile = path.join(dir, 'lessons.jsonl');-- fs.writeFileSync(lessonsFile, JSON.stringify({ text: 'the tests need the database', repo: 'demo', tags: ['tests'] }) + '\n');-- const built = assemble({ brief: 'fix the test', dir, env: { OPENVIBE_CODES_LESSONS: lessonsFile } });++ // On a Node the lessons come from the Know store's module (know/lessons.js), its path from the config.++ const knowFile = path.join(dir, 'know.js');++ fs.writeFileSync(knowFile, "module.exports = { recall: () => '## What earlier jobs learned here (dated)\\n- 2026-10-30 · demo — the tests need the database' };\n");++ const built = assemble({ brief: 'fix the test', dir, env: { CODES_KNOW_LESSONS: knowFile } });const names = built.parts.map((p) => p.name);assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'lessons', 'repo-digest', 'brief']);assert.ok(built.text.indexOf('Git rules') < built.text.indexOf('House rules'));assert.ok(built.text.indexOf('House rules') < built.text.indexOf('learned'));assert.ok(built.text.indexOf('learned') < built.text.indexOf('demo repository'));assert.ok(built.text.trimEnd().endsWith('fix the test'), 'the brief is last');-- // A store that cannot be read is skipped, not an error.-- const none = assemble({ brief: 'x', dir, env: { OPENVIBE_CODES_LESSONS: path.join(dir, 'nope.jsonl') } });++ // The Node's config path wins over the environment, and a store that cannot be read is skipped, not an error.++ const fromConfig = assemble({ brief: 'x', dir, know: knowFile, env: { CODES_KNOW_LESSONS: path.join(dir, 'nope.js') } });++ assert.ok(fromConfig.parts.some((p) => p.name === 'lessons'), 'the config path is read');++ const none = assemble({ brief: 'x', dir, env: { CODES_KNOW_LESSONS: path.join(dir, 'nope.js') } });assert.ok(!none.parts.some((p) => p.name === 'lessons'));fs.rmSync(dir, { recursive: true, force: true });});@@@ -4,7 +4,7 @@const assert = require('node:assert/strict');const { check, done } = require('./helpers/boot');--const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('../engine/budget');++const { limitsFor, caps, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('../engine/budget');(async () => {await check('the limits are the config\'s, then the runner\'s, then the task\'s', () => {@@@ -20,6 +20,18 @@ const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('..assert.equal(evaluate({ steps: 100000 }, limitsFor({}, 'mm')).hit, null);});++ await check('the harness caps follow the limits: 25 turns and 5 minutes above, or none', () => {++ assert.deepEqual(caps({ steps: 350, minutes: 45 }), { maxTurns: 375, timeoutMs: 50 * 60_000, timeout: '50m' });++ assert.deepEqual(caps({ steps: 200, minutes: 90 }), { maxTurns: 225, timeoutMs: 95 * 60_000, timeout: '95m' });++ // No limit means none: the harness's own cap is effectively off, and the wall clock is no timer at all.++ const none = caps({ steps: null, minutes: null });++ assert.equal(none.maxTurns, 100000);++ assert.equal(none.timeoutMs, 0);++ assert.equal(none.timeout, '100000m');++ assert.deepEqual(caps({}), none);++ assert.deepEqual(caps({ steps: 0, minutes: 0 }), none);++ });++await check('the first limit reached wins', () => {const limits = { steps: 100, minutes: 60, usd: 5 };assert.equal(evaluate({ steps: 120, minutes: 90, usd: 9 }, limits).hit, 'steps');@@@ -121,6 +121,39 @@ const carried = (parent, name, at) => ({assert.match(step.why, /crash/);});++ await check('a harness turn cap is continued in its own session, on the same harness, never switched', () => {++ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };++ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };++ for (const error of ['max_turns in 2483s', 'error_max_turns', 'turn limit_exceeded', 'maximum number of turns reached']) {++ const attempts = [{ ...job('cc', 'a', 'failed', now, { error }), facts: { session: 'sess-9' } }];++ const step = chain.nextStep(attempts, ctx);++ assert.equal(step.action, 'continue', error);++ assert.equal(step.session, 'sess-9', error);++ assert.match(step.why, /turn cap/, error);++ }++ // Without a session there is nothing to continue: then, and only then, another runner may take it.++ const noSession = chain.nextStep([job('cc', 'a', 'failed', now, { error: 'max_turns' })], ctx);++ assert.equal(noSession.action, 'switch');++ assert.equal(noSession.runner, 'mm');++ });++++ await check('a continuation of an attempt the owner marked handled is never switched', () => {++ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };++ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };++ const parent = job('cc', 'a', 'failed', now - 5000, { resolvedNote: 'Fine: the work landed in PR 12' });++ for (const error of ['usage limit reached', 'max_turns']) {++ const child = job('mm', 'a-r2', 'failed', now, { parent: 'cc:a', error });++ const step = chain.nextStep([parent, child], ctx);++ assert.notEqual(step.action, 'switch', error);++ assert.equal(step.action, 'failed', error);++ assert.match(step.why, /marked handled by the owner/, error);++ }++ // On a fresh parent that was not settled, the switch still happens.++ const open = job('cc', 'b', 'failed', now - 5000);++ const failed = job('mm', 'b-r2', 'failed', now, { parent: 'cc:b', error: 'usage limit reached' });++ assert.equal(chain.nextStep([open, failed], ctx).action, 'switch');++ });++await check('a context that grew too big starts fresh instead of continuing', () => {const attempts = [{ ...job('cc', 'a', 'failed', now), facts: { apiError: 'prompt is too long: 250000 tokens' } }];const step = chain.nextStep(attempts);@@@ -14,7 +14,7 @@ const DIR = path.join(__dirname, '..', 'engine');for (const name of ['router', 'chain', 'budget', 'transcript', 'effort', 'scope', 'runerror', 'proof', 'review']) {assert.ok(engine[name], `${name} is exported`);}-- for (const name of ['classify', 'pick', 'outcome', 'nextStep', 'evaluate', 'limitsFor', 'parse', 'parseText', 'fitEffort', 'pickModel']) {++ for (const name of ['classify', 'pick', 'outcome', 'nextStep', 'evaluate', 'limitsFor', 'caps', 'parse', 'parseText', 'fitEffort', 'pickModel']) {assert.equal(typeof engine[name], 'function', `${name} is a function`);}});@@@ -10,27 +10,44 @@ const { assemble, lessons, fragment } = require('../engine/prompt');(async () => {await check('the rules and the house rules are Codes\' own fragments', () => {-- assert.ok(fragment('git-rule.md').includes('Git rules'));++ const git = fragment('git-rule.md');++ assert.ok(git.includes('Git rules'));++ // 10-30: a job pushes its own branch when its brief asks for a PR. Never main, never force.++ assert.match(git, /Push only your own job branch, and only when your brief asks for a PR/);++ assert.match(git, /Never push to main, never force-push, never push someone else's branch/);++ assert.doesNotMatch(git, /git config\. Never push\./, 'the old no-push-at-all rule is gone');assert.ok(fragment('house-rules.md').includes('House rules'));assert.equal(fragment('nope.md'), '');});-- await check('the lessons are the ones that fit, at most five', () => {-- const file = path.join(os.tmpdir(), `lessons-${process.pid}.jsonl`);-- const rows = [];-- for (let i = 0; i < 8; i++) rows.push(JSON.stringify({ text: `lesson ${i} about the database`, repo: 'demo', tags: ['database'] }));-- rows.push(JSON.stringify({ text: 'unrelated note' }));-- fs.writeFileSync(file, rows.join('\n') + '\n');++ await check('the lessons come from the Know store, through its own recall()', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-'));++ const file = path.join(dir, 'lessons.js');++ fs.writeFileSync(file, `module.exports = { recall(brief, repo, k) {++ require('fs').writeFileSync(${JSON.stringify(path.join(dir, 'seen.json'))}, JSON.stringify({ brief, repo, k }));++ return '## What earlier jobs learned here (dated)\\n- 2026-10-30 · demo — the tests need the database\\n';++ } };\n`);const got = lessons({ file, repo: 'demo', brief: 'fix the database test' });-- assert.ok(got.includes('What earlier jobs learned here'));-- assert.ok(!got.includes('unrelated note'), 'a lesson that does not fit is left out');-- assert.ok((got.match(/^- /gm) || []).length <= 5);-- fs.rmSync(file, { force: true });++ assert.ok(got.startsWith('## What earlier jobs learned here'), 'the store formats its own block');++ assert.ok(got.trimEnd().endsWith('the database'), 'and its lesson is kept');++ assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(dir, 'seen.json'), 'utf8')),++ { brief: 'fix the database test', repo: 'demo', k: 5 }, 'the store gets the brief, the repo and the cap');++ // The environment is how a Node passes the config path when the caller does not.++ assert.equal(lessons({ brief: 'x', env: { CODES_KNOW_LESSONS: file } }), got);++ fs.rmSync(dir, { recursive: true, force: true });});await check('a store that cannot be reached gives no lessons, not an error', () => {-- assert.equal(lessons({ file: '/nope/lessons.jsonl', brief: 'x' }), '');++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-bad-'));++ const noRecall = path.join(dir, 'no-recall.js');++ fs.writeFileSync(noRecall, 'module.exports = { search: () => "x" };\n');++ const throws = path.join(dir, 'throws.js');++ fs.writeFileSync(throws, 'module.exports = { recall: () => { throw new Error("no store"); } };\n');++ assert.equal(lessons({ file: '/nope/lessons.js', brief: 'x' }), '');++ assert.equal(lessons({ file: noRecall, brief: 'x' }), '');++ assert.equal(lessons({ file: throws, brief: 'x' }), '');assert.equal(lessons({ brief: 'x' }), '');++ fs.rmSync(dir, { recursive: true, force: true });});await check('the prompt is the rules, the house rules, the lessons, the digest, the brief', () => {@@@ -180,6 +180,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(other.status, 404, 'someone else’s task');// A continue starts the next attempt on a task that has ended; stop ends the running one for good.await db.exec("UPDATE run_tasks SET state = 'failed', outcome = 'unfinished' WHERE id = $1", [own.id]);++ await db.exec("UPDATE codes_attempts SET state = 'failed' WHERE task_id = $1", [own.id]);const carried = await asAda('POST', `/tasks/${own.id}/continue`, { note: 'and the tests' });assert.strictEqual(carried.status, 200, JSON.stringify(carried.json));assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');
@@ -29,3 +29,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -29,3 +29,7 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
+
+# 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
@@ -31,6 +31,11 @@ adapterFor('codex').build({ prompt, cwd, model: 'gpt-5-codex', effort: 'medium',
@@ -31,6 +31,11 @@ adapterFor('codex').build({ prompt, cwd, model: 'gpt-5-codex', effort: 'medium',
The effort is fitted to the harness, never handed over as it came: the 2026-10-29 incident killed a job that copied
The effort is fitted to the harness, never handed over as it came: the 2026-10-29 incident killed a job that copied
Codex's "medium" to Command Code, which accepts off, low, high and max.
Codex's "medium" to Command Code, which accepts off, low, high and max.
+The harness's own turn cap and wall clock follow the task's limits (`budget.caps()`): 25 turns above the step limit and
+5 minutes past the time limit, or 100000 / no timer when the owner set none. `claude-code` and `command-code` carry it
+as `--max-turns`, `minimax-code` as `--max-steps` and `--timeout`; every adapter is stopped by the same wall clock in
+`runCli()`. Claude Code also gets `mcp__ov_know` when the machine has the shared Know MCP server registered.
+
### `jobrunner.js` — one attempt of one harness, the way the portal runs one
### `jobrunner.js` — one attempt of one harness, the way the portal runs one
The prompt is assembled (`prompt.js`), the harness is started (`adapters/`), what it prints goes through the transcript
The prompt is assembled (`prompt.js`), the harness is started (`adapters/`), what it prints goes through the transcript
@@ -39,7 +44,9 @@ normaliser (`transcript.js`) into job lines (`joblines.js`), and a budget stop b
@@ -39,7 +44,9 @@ normaliser (`transcript.js`) into job lines (`joblines.js`), and a budget stop b
### `prompt.js` — the prompt every job gets
### `prompt.js` — the prompt every job gets
-The git rule, the house rules, the lessons (when a store is reachable), the repo digest (`repomap.js`), then the brief.
+The git rule, the house rules, the lessons, the repo digest (`repomap.js`), then the brief. The lessons come from the
+shared Know store's own `recall()` (the portal's `know/lessons.js`; on a Node its path is configuration —
+`CODES_KNOW_LESSONS` — never a hard-coded home). A store that cannot be read gives no lessons, not an error.
### `logins.js` — what this machine has
### `logins.js` — what this machine has
@@ -74,7 +81,11 @@ The ladder is the portal's, so the portal's jobs keep their routing. Codes suppl
@@ -74,7 +81,11 @@ The ladder is the portal's, so the portal's jobs keep their routing. Codes suppl
Pure: it takes the attempts and returns the outcome and the next step. It never reads a log folder, a marker file or a
Pure: it takes the attempts and returns the outcome and the next step. It never reads a log folder, a marker file or a
process table — the caller passes the data in. The failure reasons are the portal's: `limit`, `auth`, `capacity`,
process table — the caller passes the data in. The failure reasons are the portal's: `limit`, `auth`, `capacity`,
-`refused`, `error`, `budget`, `stopped`, `crash`, `timeout`, `incomplete`, `context`, `hung`.
+`refused`, `error`, `budget`, `stopped`, `crash`, `timeout`, `incomplete`, `context`, `hung`, `turns`.
+
+A harness that stops at its own turn cap (`max_turns`, `error_max_turns`, `turn limit_exceeded`) is `turns`: the next
+step is `continue` in the same session on the same harness, never `switch`. Only when there is no session to resume may
+another runner take it. A continuation of an attempt the owner settled (Fine / Mark handled) is never switched.
An attempt is `{ key, runner, name, runnerName, status, updated, error, next, prev, parent, resolvedNote, stopped, budget, wrapUp, facts }`.
An attempt is `{ key, runner, name, runnerName, status, updated, error, next, prev, parent, resolvedNote, stopped, budget, wrapUp, facts }`.
@@ -101,11 +112,16 @@ Three limits: steps (tool calls), minutes (wall clock) and dollars. A limit that
@@ -101,11 +112,16 @@ Three limits: steps (tool calls), minutes (wall clock) and dollars. A limit that
price is never a zero budget. The first limit reached wins. Steps and minutes buy **one** wrap-up turn, so the work
price is never a zero budget. The first limit reached wins. Steps and minutes buy **one** wrap-up turn, so the work
already done lands; dollars stay a hard stop.
already done lands; dollars stay a hard stop.
-Public: `limitsFor(cfg, runner, override)`, `evaluate(used, limits)`, `state(status)`, `wrapUp(hit, opts)`,
-`wrapUpPrompt(hit)`, `STATES`.
+Public: `limitsFor(cfg, runner, override)`, `caps(limits)`, `evaluate(used, limits)`, `state(status)`,
+`wrapUp(hit, opts)`, `wrapUpPrompt(hit)`, `STATES`.
```js
```js
-const { evaluate, wrapUp } = require('./engine/budget');
+const { evaluate, caps, wrapUp } = require('./engine/budget');
+
+caps({ steps: 350, minutes: 45 });
+// { maxTurns: 375, timeoutMs: 3000000, timeout: '50m' }
+caps({ steps: null, minutes: null });
+// { maxTurns: 100000, timeoutMs: 0, timeout: '100000m' } — no limit means none
evaluate({ steps: 120, minutes: 30, usd: 1.2 }, { steps: 100, minutes: 60, usd: 5 });
evaluate({ steps: 120, minutes: 30, usd: 1.2 }, { steps: 100, minutes: 60, usd: 5 });
// { hit: 'steps', limits: { steps: 100, minutes: 60, usd: 5 }, used: { steps: 120, minutes: 30, usd: 1.2 } }
// { hit: 'steps', limits: { steps: 100, minutes: 60, usd: 5 }, used: { steps: 120, minutes: 30, usd: 1.2 } }
@@ -19,10 +19,44 @@
@@ -19,10 +19,44 @@
*/
*/
const { spawn } = require('child_process');
const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
const readline = require('readline');
const readline = require('readline');
const { ORDER } = require('../effort');
const { ORDER } = require('../effort');
const { scrubDeep } = require('../redact');
const { scrubDeep } = require('../redact');
+/** No limit: the turn cap a harness gets when the owner set none (the portal's 100000). */
+const NO_TURN_CAP = 100000;
+
+/**
+ * The harness's own turn cap, from the task's limits (Settings → Limits): the portal's `--max-turns` value.
+ * A missing or zero cap means no limit, never the old 150/200 hard-coded ones.
+ *
+ * @param {number|string} [value] the cap the job runner derived (steps + 25, or none)
+ * @returns {string} the number the CLI's argv carries
+ */
+function turnCap(value) {
+ const n = Number(value);
+ return String(Number.isFinite(n) && n > 0 ? Math.round(n) : NO_TURN_CAP);
+}
+
+/**
+ * Whether this machine has the shared Know MCP server (`ov_know`) registered for Claude Code, so the harness may call
+ * its tools (`mcp__ov_know`): `~/.claude.json` → `mcpServers.ov_know`, the portal's own registration.
+ *
+ * @param {object} [opts]
+ * @param {string} [opts.file] the Claude Code config to read (a test, or a Node's own path)
+ * @returns {boolean} true when the server is registered
+ */
+function knowRegistered({ file } = {}) {
+ const config = file || process.env.OPENVIBE_CLAUDE_CONFIG || path.join(os.homedir(), '.claude.json');
+ try {
+ const doc = JSON.parse(fs.readFileSync(config, 'utf8'));
+ return !!(doc && doc.mcpServers && doc.mcpServers.ov_know);
+ } catch { return false; }
+}
+
/** The reasoning levels each harness accepts (the portal's ds-limits.js / engine/effort.js). */
/** The reasoning levels each harness accepts (the portal's ds-limits.js / engine/effort.js). */
const EFFORT_LEVELS = {
const EFFORT_LEVELS = {
'claude-code': ['low', 'medium', 'high', 'xhigh', 'max'],
'claude-code': ['low', 'medium', 'high', 'xhigh', 'max'],
@@ -73,13 +107,13 @@ function childEnv(extra = {}) {
@@ -73,13 +107,13 @@ function childEnv(extra = {}) {
* @param {string} [opts.cwd] the working directory
* @param {string} [opts.cwd] the working directory
* @param {object} [opts.env] the environment
* @param {object} [opts.env] the environment
* @param {string} [opts.stdin] what the CLI reads (the brief, for the harnesses that take it on stdin)
* @param {string} [opts.stdin] what the CLI reads (the brief, for the harnesses that take it on stdin)
- * @param {number} [opts.timeoutMs] how long it may run
+ * @param {number} [opts.timeoutMs] how long it may run; 0 (the default) is no limit
* @param {AbortSignal} [opts.signal] stops it
* @param {AbortSignal} [opts.signal] stops it
* @param {function} opts.onLine every stdout line
* @param {function} opts.onLine every stdout line
* @param {function} [opts.onEnd] when the process ended ({ code, signal, timedOut, stderr })
* @param {function} [opts.onEnd] when the process ended ({ code, signal, timedOut, stderr })
* @returns {{cancel: function, done: Promise<object>}} the run
* @returns {{cancel: function, done: Promise<object>}} the run
*/
*/
-function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, onLine, onEnd }) {
+function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 0, signal, onLine, onEnd }) {
let child;
let child;
let stderrBuf = '';
let stderrBuf = '';
let timedOut = false;
let timedOut = false;
@@ -101,8 +135,9 @@ function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, o
@@ -101,8 +135,9 @@ function runCli({ cmd, argv, cwd, env, stdin, timeoutMs = 45 * 60_000, signal, o
try { child.kill('SIGTERM'); } catch { /* gone */ }
try { child.kill('SIGTERM'); } catch { /* gone */ }
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000).unref();
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000).unref();
};
};
- const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);
- timer.unref();
+ // A wall clock the owner set (the cap is its limit plus five minutes); no limit is no timer at all.
+ const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; cancel(); }, timeoutMs) : null;
+ if (timer) timer.unref();
if (signal) {
if (signal) {
if (signal.aborted) cancel();
if (signal.aborted) cancel();
else signal.addEventListener('abort', cancel, { once: true });
else signal.addEventListener('abort', cancel, { once: true });
@@ -176,4 +211,4 @@ const TOOL_NAMES = {
@@ -176,4 +211,4 @@ const TOOL_NAMES = {
task: 'Task', todowrite: 'TodoWrite', todo_list: 'TodoWrite',
task: 'Task', todowrite: 'TodoWrite', todo_list: 'TodoWrite',
};
};
-module.exports = { EFFORT_LEVELS, effortFor, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };
+module.exports = { EFFORT_LEVELS, NO_TURN_CAP, effortFor, turnCap, knowRegistered, runCli, claudeFeed, childEnv, emitter, TOOL_NAMES };
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
* claude-minimax sets, and what claude-deepseek does for DeepSeek).
* claude-minimax sets, and what claude-deepseek does for DeepSeek).
*/
*/
-const { effortFor, claudeFeed, TOOL_NAMES } = require('./base');
+const { effortFor, turnCap, knowRegistered, claudeFeed, TOOL_NAMES } = require('./base');
/** The permission modes, by the Codes level (read, edit, full). */
/** The permission modes, by the Codes level (read, edit, full). */
const MODES = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' };
const MODES = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' };
@@ -43,21 +43,27 @@ function rulesFile(rules) { return rules && rules.trim() ? rules.trim() : null;
@@ -43,21 +43,27 @@ function rulesFile(rules) { return rules && rules.trim() ? rules.trim() : null;
* @param {string} [ctx.effort] the wanted reasoning level
* @param {string} [ctx.effort] the wanted reasoning level
* @param {string} [ctx.resume] the session to continue
* @param {string} [ctx.resume] the session to continue
* @param {string} [ctx.permission] 'read' | 'edit' | 'full'
* @param {string} [ctx.permission] 'read' | 'edit' | 'full'
- * @param {number} [ctx.maxTurns] the turn limit
+ * @param {number} [ctx.maxTurns] the turn limit (steps + 25, or none)
* @param {string} [ctx.rules] the rules file (appended to the system prompt)
* @param {string} [ctx.rules] the rules file (appended to the system prompt)
* @param {object} [ctx.provider] the provider's environment (base URL, token, model): the cm-style environment
* @param {object} [ctx.provider] the provider's environment (base URL, token, model): the cm-style environment
+ * @param {boolean} [ctx.know] this machine has the shared Know MCP server (default: read the machine's Claude config)
+ * @param {string} [ctx.claudeConfig] the Claude Code config to read for that check (a Node's own path, or a test)
* @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
* @returns {{cmd: string, argv: string[], env: object, stdin: string|null}} what to run
*/
*/
function build(ctx = {}) {
function build(ctx = {}) {
const effort = effortFor('claude-code', ctx.effort);
const effort = effortFor('claude-code', ctx.effort);
const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
- '--max-turns', String(ctx.maxTurns || 200),
+ '--max-turns', turnCap(ctx.maxTurns),
'--permission-mode', MODES[ctx.permission] || MODES.edit,
'--permission-mode', MODES[ctx.permission] || MODES.edit,
'--exclude-dynamic-system-prompt-sections'];
'--exclude-dynamic-system-prompt-sections'];
const rules = rulesFile(ctx.rules);
const rules = rulesFile(ctx.rules);
if (rules) argv.push('--append-system-prompt-file', rules);
if (rules) argv.push('--append-system-prompt-file', rules);
argv.push('--disallowedTools', DENIED.join(' '));
argv.push('--disallowedTools', DENIED.join(' '));
- argv.push('--allowedTools', ALLOWED.join(' '));
+ // mcp__ov_know: the shared Know server, when this machine registered it (know/register.js). Claude Code refuses an
+ // MCP tool that is not on --allowedTools in a headless run, so the allow list is what makes it callable.
+ const allowed = ALLOWED.slice();
+ if (ctx.know === true || (ctx.know !== false && knowRegistered({ file: ctx.claudeConfig }))) allowed.push('mcp__ov_know');
+ argv.push('--allowedTools', allowed.join(' '));
if (ctx.resume) argv.push('--resume', String(ctx.resume));
if (ctx.resume) argv.push('--resume', String(ctx.resume));
const env = {};
const env = {};
const p = ctx.provider || {};
const p = ctx.provider || {};
@@ -67,7 +73,7 @@ function build(ctx = {}) {
@@ -67,7 +73,7 @@ function build(ctx = {}) {
if (ctx.model) env.ANTHROPIC_MODEL = ctx.model;
if (ctx.model) env.ANTHROPIC_MODEL = ctx.model;
if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort;
if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort;
if (p.mcpConfig) argv.push('--mcp-config', p.mcpConfig);
if (p.mcpConfig) argv.push('--mcp-config', p.mcpConfig);
- if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', ALLOWED.concat(p.extraAllowed).join(' '));
+ if (p.extraAllowed && p.extraAllowed.length) argv.push('--allowedTools', allowed.concat(p.extraAllowed).join(' '));
return { cmd: ctx.bin || 'claude', argv, env, stdin: ctx.prompt || '' };
return { cmd: ctx.bin || 'claude', argv, env, stdin: ctx.prompt || '' };
}
}
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const { effortFor, claudeFeed, TOOL_NAMES, emitter } = require('./base');
+const { effortFor, turnCap, claudeFeed, TOOL_NAMES, emitter } = require('./base');
/** The tools a job may not use (the portal's deny list). */
/** The tools a job may not use (the portal's deny list). */
const DENY = ['Shell(rm:*)', 'Shell(git push:*)', 'Shell(git commit:*)', 'Shell(git reset:*)', 'Shell(git checkout:*)',
const DENY = ['Shell(rm:*)', 'Shell(git push:*)', 'Shell(git commit:*)', 'Shell(git reset:*)', 'Shell(git checkout:*)',
@@ -44,7 +44,7 @@ function build(ctx = {}) {
@@ -44,7 +44,7 @@ function build(ctx = {}) {
argv.push('--effort', effort || 'high');
argv.push('--effort', effort || 'high');
argv.push('--config', 'compact-mode=fast');
argv.push('--config', 'compact-mode=fast');
argv.push('--output-format', 'json');
argv.push('--output-format', 'json');
- argv.push('--max-turns', String(ctx.maxTurns || 150));
+ argv.push('--max-turns', turnCap(ctx.maxTurns));
argv.push('--skip-onboarding', '--no-auto-update', '-t');
argv.push('--skip-onboarding', '--no-auto-update', '-t');
return { cmd: ctx.bin || 'cmd', argv, env: {}, stdin: null };
return { cmd: ctx.bin || 'cmd', argv, env: {}, stdin: null };
}
}
@@ -6,15 +6,17 @@
@@ -6,15 +6,17 @@
* The argv comes from the portal's mm-run.sh:
* The argv comes from the portal's mm-run.sh:
*
*
* mcode exec --input - --output-format stream-json --prompt-mode coding --permission MODE --cwd DIR
* mcode exec --input - --output-format stream-json --prompt-mode coding --permission MODE --cwd DIR
- * --timeout 45m --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]
+ * --timeout TIME --max-steps N --append-system-prompt-file RULES -o LAST [--ptc] [--mode lightweight]
* [--model MODEL] [--effort EFFORT] [--session SESSION]
* [--model MODEL] [--effort EFFORT] [--session SESSION]
*
*
* The rules ride in the appended system prompt (the stable, best-cached prefix) and the brief alone is the user turn.
* The rules ride in the appended system prompt (the stable, best-cached prefix) and the brief alone is the user turn.
* --ptc is Programmatic Tool Calling (the model batches its tool calls in one round trip). The permission is smart for a
* --ptc is Programmatic Tool Calling (the model batches its tool calls in one round trip). The permission is smart for a
- * guarded job and full for the owner's "full" choice.
+ * guarded job and full for the owner's "full" choice. The wall clock and the step cap follow the task's limits
+ * (engine/budget.js caps): the time limit plus five minutes, and the step limit plus 25, or 100000m / 100000 when the
+ * owner set none (the portal's 86e5f95: this used to be a hard-coded 45m / 150 steps).
*/
*/
-const { effortFor, emitter } = require('./base');
+const { effortFor, turnCap, emitter } = require('./base');
/**
/**
* The argv and the environment for one MiniMax Code run.
* The argv and the environment for one MiniMax Code run.
@@ -37,8 +39,8 @@ function build(ctx = {}) {
@@ -37,8 +39,8 @@ function build(ctx = {}) {
const effort = effortFor('minimax-code', ctx.effort);
const effort = effortFor('minimax-code', ctx.effort);
const perm = ctx.permission === 'full' ? 'full' : 'smart';
const perm = ctx.permission === 'full' ? 'full' : 'smart';
const argv = ['exec', '--input', '-', '--output-format', 'stream-json', '--prompt-mode', 'coding',
const argv = ['exec', '--input', '-', '--output-format', 'stream-json', '--prompt-mode', 'coding',
- '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '45m',
- '--max-steps', String(ctx.maxSteps || 150)];
+ '--permission', perm, '--cwd', ctx.cwd || process.cwd(), '--timeout', ctx.timeout || '100000m',
+ '--max-steps', turnCap(ctx.maxSteps || ctx.maxTurns)];
if (ctx.rules && String(ctx.rules).trim()) argv.push('--append-system-prompt-file', String(ctx.rules).trim());
if (ctx.rules && String(ctx.rules).trim()) argv.push('--append-system-prompt-file', String(ctx.rules).trim());
if (ctx.out) argv.push('-o', ctx.out);
if (ctx.out) argv.push('-o', ctx.out);
if (ctx.ptc !== false) argv.push('--ptc');
if (ctx.ptc !== false) argv.push('--ptc');
@@ -33,6 +33,35 @@ function limitsFor(cfg, runner, override) {
@@ -33,6 +33,35 @@ function limitsFor(cfg, runner, override) {
return out;
return out;
}
}
+/** The harness's own caps sit this far above the owner's limits, so the wrap-up turn still runs. */
+const TURN_MARGIN = 25;
+const MINUTE_MARGIN = 5;
+/** No limit: the harnesses' own cap is effectively none (the portal's 100000 turns). */
+const NO_TURN_CAP = 100000;
+
+/**
+ * The caps the harness itself runs under, derived from the task's limits (Settings → Limits).
+ *
+ * A step limit puts the harness's turn cap 25 above it, so the portal's clean wrap-up comes first; no step limit means
+ * no turn cap (100000). A time limit is the wall clock in milliseconds, five minutes longer than the owner's limit; no
+ * time limit means no timer at all (`timeoutMs` 0). `timeout` is the same clock in the form MiniMax Code takes.
+ *
+ * @param {{steps?: number|null, minutes?: number|null}} [limits] the task's limits
+ * @returns {{maxTurns: number, timeoutMs: number, timeout: string}} the harness's caps
+ */
+function caps(limits = {}) {
+ const steps = Number(limits && limits.steps);
+ const minutes = Number(limits && limits.minutes);
+ const hasSteps = Number.isFinite(steps) && steps > 0;
+ const hasMinutes = Number.isFinite(minutes) && minutes > 0;
+ const timeoutMs = hasMinutes ? (Math.round(minutes * 60) + MINUTE_MARGIN * 60) * 1000 : 0;
+ return {
+ maxTurns: hasSteps ? Math.round(steps) + TURN_MARGIN : NO_TURN_CAP,
+ timeoutMs,
+ timeout: timeoutMs ? `${Math.ceil(timeoutMs / 60000)}m` : `${NO_TURN_CAP}m`,
+ };
+}
+
/**
/**
* Compare what a task spent with its limits. The first limit reached wins (steps, then minutes, then dollars).
* Compare what a task spent with its limits. The first limit reached wins (steps, then minutes, then dollars).
*
*
@@ -99,4 +128,4 @@ function wrapUp(hit, opts = {}) {
@@ -99,4 +128,4 @@ function wrapUp(hit, opts = {}) {
return { action: 'wrap-up', prompt: wrapUpPrompt(hit), why: `one last turn to land the work after the ${hit} limit` };
return { action: 'wrap-up', prompt: wrapUpPrompt(hit), why: `one last turn to land the work after the ${hit} limit` };
}
}
-module.exports = { STATES, limitsFor, evaluate, state, wrapUp, wrapUpPrompt };
+module.exports = { STATES, TURN_MARGIN, MINUTE_MARGIN, NO_TURN_CAP, limitsFor, caps, evaluate, state, wrapUp, wrapUpPrompt };
@@ -55,6 +55,7 @@ const REASON_TEXT = {
@@ -55,6 +55,7 @@ const REASON_TEXT = {
incomplete: 'it stopped before the work was done',
incomplete: 'it stopped before the work was done',
context: 'the context grew too big',
context: 'the context grew too big',
hung: 'it went quiet for too long',
hung: 'it went quiet for too long',
+ turns: 'stopped at its turn cap, mid-work',
};
};
// A job that stopped for a reason the engine can fix by itself: the context is too big, the clock ran out, the agent
// A job that stopped for a reason the engine can fix by itself: the context is too big, the clock ran out, the agent
@@ -77,6 +78,9 @@ function reason(text) {
@@ -77,6 +78,9 @@ function reason(text) {
if (/authenticat|log ?in again|run .?cmd login|\b401\b|api key (has )?expired|invalid api key|unauthori[sz]ed|not signed in/i.test(s)) return 'auth';
if (/authenticat|log ?in again|run .?cmd login|\b401\b|api key (has )?expired|invalid api key|unauthori[sz]ed|not signed in/i.test(s)) return 'auth';
if (/at capacity|overloaded|\b529\b|\b503\b|temporarily unavailable|server is busy/i.test(s)) return 'capacity';
if (/at capacity|overloaded|\b529\b|\b503\b|temporarily unavailable|server is busy/i.test(s)) return 'capacity';
if (/mcode exec failed:.*Internal server error.*\(50[024]\)/i.test(s) || /(?:HTTP(?: status)?|status(?: code)?)\s*[:=]?\s*50[024]\b/i.test(s)) return 'capacity';
if (/mcode exec failed:.*Internal server error.*\(50[024]\)/i.test(s) || /(?:HTTP(?: status)?|status(?: code)?)\s*[:=]?\s*50[024]\b/i.test(s)) return 'capacity';
+ // A harness's own turn cap ("✔ max_turns in 2483s", error_max_turns): the work isn't done and isn't broken. It is
+ // continued in its own session, on the same harness, never restarted on another (10-30, cc:codes-c4b-screens).
+ if (/\bmax[_ ]turns\b|error_max_turns|turn limit|maximum (?:number of )?turns/i.test(s)) return 'turns';
return 'error';
return 'error';
}
}
@@ -411,6 +415,20 @@ function chainEnd(runs) {
@@ -411,6 +415,20 @@ function chainEnd(runs) {
return settleChain(runs, byKey, carriedMap(runs, byKey), latest).end;
return settleChain(runs, byKey, carriedMap(runs, byKey), latest).end;
}
}
+/** Whether the owner settled an attempt (Fine / Mark handled): its continuation is settled too and is never switched. */
+function ownerSettled(run) {
+ if (!run) return false;
+ if (run.resolvedNote) return true;
+ return !!(run.resolved && (run.resolved.how === 'note' || run.resolved.how === 'stopped'));
+}
+
+/** The attempt this one continues, when the owner already settled it. */
+function settledParent(runs, end) {
+ if (!end || !end.parent) return null;
+ const parent = runs.find((r) => r.key === end.parent);
+ return ownerSettled(parent) ? parent : null;
+}
+
/** The next step, given the chain's end. */
/** The next step, given the chain's 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' };
@@ -423,6 +441,17 @@ function step(runs, end, opts = {}) {
@@ -423,6 +441,17 @@ function step(runs, end, opts = {}) {
const wrap = budget.wrapUp(hit, { done: !!end.wrapUp });
const wrap = budget.wrapUp(hit, { done: !!end.wrapUp });
return { action: wrap.action, hit, prompt: wrap.prompt, why: wrap.why };
return { action: wrap.action, hit, prompt: wrap.prompt, why: wrap.why };
}
}
+ // A harness that stopped at its own turn cap is carried on in its own session, on the same harness, with its
+ // context. This comes before every switch: the work is not broken, and another runner would redo it from scratch.
+ if (why === 'turns') {
+ const session = facts(end).session;
+ if (session) return { action: 'continue', session, why: 'the harness stopped at its turn cap, mid-work: continue it in the same session' };
+ const settled = settledParent(runs, end);
+ if (settled) return { action: 'failed', why: `its earlier attempt was marked handled by the owner (${settled.key}): not switched to another runner` };
+ const choice = nextRunner(end, opts);
+ if (!choice) return { action: 'failed', why: 'the harness stopped at its turn cap, there is no session to continue, and no other runner can take it now' };
+ return { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };
+ }
// The same session continues the task when it stopped early (crash, timeout, incomplete, hung); a context that
// The same session continues the task when it stopped early (crash, timeout, incomplete, hung); a context that
// grew too big starts fresh with a compacted brief instead. This comes before the runner switch: a context that
// grew too big starts fresh with a compacted brief instead. This comes before the runner switch: a context that
// overflowed is not a worker's fault, and another runner would hit the same wall.
// overflowed is not a worker's fault, and another runner would hit the same wall.
@@ -439,6 +468,10 @@ function step(runs, end, opts = {}) {
@@ -439,6 +468,10 @@ function step(runs, end, opts = {}) {
const tries = end.attempt || 1;
const tries = end.attempt || 1;
const max = why === 'error' ? 2 : 4;
const max = why === 'error' ? 2 : 4;
if (tries >= max) return { action: 'failed', why: `the task was already retried ${tries} times; the owner must decide` };
if (tries >= max) return { action: 'failed', why: `the task was already retried ${tries} times; the owner must decide` };
+ // A continuation of an attempt the owner marked Fine is never switched to another runner: it would redo work
+ // the owner already landed (10-30, blog-patchnotes-v2).
+ const settled = settledParent(runs, end);
+ if (settled) return { action: 'failed', why: `its earlier attempt was marked handled by the owner (${settled.key}): not switched to another runner` };
const choice = nextRunner(end, opts);
const choice = nextRunner(end, opts);
if (!choice) return { action: 'failed', why: `no other runner can take it now (${why})` };
if (!choice) return { action: 'failed', why: `no other runner can take it now (${why})` };
const out = { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };
const out = { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };
@@ -2,8 +2,21 @@
@@ -2,8 +2,21 @@
- Never copy, mirror, move or re-create git metadata: no `.git` directories made by hand, no `git clone --mirror`, no
- Never copy, mirror, move or re-create git metadata: no `.git` directories made by hand, no `git clone --mirror`, no
`git worktree` repairs.
`git worktree` repairs.
-- Never change a remote, `remote.*.mirror`, `core.*` or any other git config. Never push.
+- Never change a remote, `remote.*.mirror`, `core.*` or any other git config.
+- Push only your own job branch, and only when your brief asks for a PR: `git -c credential.helper="!gh auth git-credential"
+ push -u origin <your-branch>`. Never push to main, never force-push, never push someone else's branch.
- If git can't write (a permission or read-only error), stop and say so in your report. The main session fixes it. Don't work
- If git can't write (a permission or read-only error), stop and say so in your report. The main session fixes it. Don't work
around it.
around it.
- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Never
- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Never
amend or rebase.
amend or rebase.
+
+## Working efficiently (added to every job by the runner)
+
+- Do the work yourself: never spawn sub-agents or agent teams. The orchestrator already runs jobs in parallel.
+- Before editing, read ~/openvibe/agents/ACTIVE.md: never edit a file another running job is editing; if you must, stop and report the conflict.
+- Read only what you need: search first (rg/grep), then read line ranges; never re-read a file you already have.
+- Keep command output small (tail, head, --quiet); run the narrowest test that proves the change, then the suite once.
+- Use the repo map provided above before exploring. At most five web searches per job unless the brief asks for research. Never paste whole files into answers.
+- End with a short report, at most 25 lines: what changed (files), how it was checked, what is left. Don't restate the brief.
+- Never stop, restart or kill the agent portal (`openvibe-agent-runs`) or other agents' processes. To test portal changes, run a
+ second copy on another port (`RUNS_PORT=8791 node server.js`) and stop that copy by its own PID.
@@ -64,6 +64,7 @@ module.exports = {
@@ -64,6 +64,7 @@ module.exports = {
nextStep: chain.nextStep,
nextStep: chain.nextStep,
evaluate: budget.evaluate,
evaluate: budget.evaluate,
limitsFor: budget.limitsFor,
limitsFor: budget.limitsFor,
+ caps: budget.caps,
parse: transcript.parse,
parse: transcript.parse,
parseText: transcript.parseText,
parseText: transcript.parseText,
fitEffort: effort.fitEffort,
fitEffort: effort.fitEffort,
@@ -26,8 +26,8 @@ const transcript = require('./transcript');
@@ -26,8 +26,8 @@ const transcript = require('./transcript');
const joblines = require('./joblines');
const joblines = require('./joblines');
const budget = require('./budget');
const budget = require('./budget');
-/** How long one attempt may run (the portal's 2700 s). */
-const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;
+/** The wall clock when the task sets none: no limit, like every other unset limit. */
+const DEFAULT_TIMEOUT_MS = 0;
/**
/**
* Run one attempt.
* Run one attempt.
@@ -40,7 +40,10 @@ const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;
@@ -40,7 +40,10 @@ const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000;
* @param {string} [opts.effort] the wanted reasoning level (fitted to the harness)
* @param {string} [opts.effort] the wanted reasoning level (fitted to the harness)
* @param {string} [opts.permission] 'read' | 'edit' | 'full'
* @param {string} [opts.permission] 'read' | 'edit' | 'full'
* @param {string} [opts.resume] the session to continue
* @param {string} [opts.resume] the session to continue
- * @param {object} [opts.budget] the limits ({ steps, minutes, usd })
+ * @param {object} [opts.budget] the limits ({ steps, minutes, usd }); null means no limit
+ * @param {boolean} [opts.know] this machine has the shared Know MCP server (Claude Code gets mcp__ov_know)
+ * @param {string} [opts.claudeConfig] the Claude Code config to read for that check
+ * @param {string} [opts.knowLessons] the Know store's module (know/lessons.js) for the prompt's lessons
* @param {string} [opts.log] the file the feed is written to
* @param {string} [opts.log] the file the feed is written to
* @param {function} [opts.onLine] every job line
* @param {function} [opts.onLine] every job line
* @param {AbortSignal} [opts.signal] stops the attempt
* @param {AbortSignal} [opts.signal] stops the attempt
@@ -58,7 +61,7 @@ async function runAttempt(opts = {}) {
@@ -58,7 +61,7 @@ async function runAttempt(opts = {}) {
// The prompt: the rules, the house rules, the lessons, the repo digest, then the brief.
// The prompt: the rules, the house rules, the lessons, the repo digest, then the brief.
const prompt = opts.systemPrompt === false
const prompt = opts.systemPrompt === false
? { text: String(opts.prompt || ''), parts: [{ name: 'brief', text: String(opts.prompt || '') }] }
? { text: String(opts.prompt || ''), parts: [{ name: 'brief', text: String(opts.prompt || '') }] }
- : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env });
+ : assemble({ brief: opts.prompt, dir: cwd, env: opts.env || process.env, know: opts.knowLessons });
const rulesFile = opts.rulesFile !== null ? path.join(dir, 'rules.md') : null;
const rulesFile = opts.rulesFile !== null ? path.join(dir, 'rules.md') : null;
if (rulesFile) fs.writeFileSync(rulesFile, prompt.parts.filter((p) => p.name !== 'brief').map((p) => p.text).join('\n\n'));
if (rulesFile) fs.writeFileSync(rulesFile, prompt.parts.filter((p) => p.name !== 'brief').map((p) => p.text).join('\n\n'));
@@ -67,13 +70,22 @@ async function runAttempt(opts = {}) {
@@ -67,13 +70,22 @@ async function runAttempt(opts = {}) {
try { adapter.writeSettings(cwd, { permission: opts.permission || 'edit' }); } catch { /* the job may not write there */ }
try { adapter.writeSettings(cwd, { permission: opts.permission || 'edit' }); } catch { /* the job may not write there */ }
}
}
+ // The task's limits (Settings → Limits) set the harness's own caps too: its turn cap sits 25 above the step limit
+ // and its wall clock 5 minutes past the time limit, or there is neither (engine/budget.js caps()). Passing them to
+ // the builder and the process keeps Command Code, Claude Code and MiniMax Code from their old hard-coded 150/200.
+ const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);
+ const caps = budget.caps(limits);
const built = adapter.build({
const built = adapter.build({
prompt: prompt.text,
prompt: prompt.text,
model: opts.model,
model: opts.model,
effort: opts.effort,
effort: opts.effort,
resume: opts.resume,
resume: opts.resume,
permission: opts.permission,
permission: opts.permission,
- maxTurns: opts.maxTurns,
+ maxTurns: opts.maxTurns != null ? opts.maxTurns : caps.maxTurns,
+ maxSteps: opts.maxSteps != null ? opts.maxSteps : (opts.maxTurns != null ? opts.maxTurns : caps.maxTurns),
+ timeout: caps.timeout,
+ know: opts.know,
+ claudeConfig: opts.claudeConfig,
cwd,
cwd,
rules: rulesFile,
rules: rulesFile,
provider: opts.provider,
provider: opts.provider,
@@ -82,7 +94,6 @@ async function runAttempt(opts = {}) {
@@ -82,7 +94,6 @@ async function runAttempt(opts = {}) {
});
});
const started = Date.now();
const started = Date.now();
- const limits = budget.limitsFor({ budget: opts.budget || {} }, opts.agent, opts.budgetOverride);
const state = { from: 0, sessionId: opts.resume || null, usage: null, cost: null, steps: 0, done: false };
const state = { from: 0, sessionId: opts.resume || null, usage: null, cost: null, steps: 0, done: false };
const feed = adapter.feed({
const feed = adapter.feed({
@@ -115,7 +126,7 @@ async function runAttempt(opts = {}) {
@@ -115,7 +126,7 @@ async function runAttempt(opts = {}) {
// The adapter's own environment (the provider's, the effort), then the job's (the PATH with the CLI on it).
// The adapter's own environment (the provider's, the effort), then the job's (the PATH with the CLI on it).
env: { ...(opts.env || {}), ...built.env },
env: { ...(opts.env || {}), ...built.env },
stdin: built.stdin,
stdin: built.stdin,
- timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
+ timeoutMs: opts.timeoutMs != null ? opts.timeoutMs : caps.timeoutMs,
signal: opts.signal,
signal: opts.signal,
onLine: (raw) => { feed.line(raw); pump(); },
onLine: (raw) => { feed.line(raw); pump(); },
onEnd: () => { feed.end(); },
onEnd: () => { feed.end(); },
@@ -23,39 +23,29 @@ function fragment(name) {
@@ -23,39 +23,29 @@ function fragment(name) {
}
}
/**
/**
- * The lessons a job gets: what earlier jobs learned here, at most five lines.
+ * The lessons a job gets: what earlier jobs learned here, through the shared Know store's own `recall()`
+ * (the portal's know/lessons.js; on a Node its path comes from the configuration, never a hard-coded home).
*
*
- * The store is optional and off by default: `OPENVIBE_CODES_LESSONS` names a JSONL file (one lesson per line, with a
- * `text` field and optional `repo` and `tags`). A store that cannot be read gives no lessons, not an error.
+ * The store is optional and off by default. A path that cannot be required, a module without recall(), or a recall
+ * that throws gives no lessons, never an error: a job must not fail because the memory is unreachable.
*
*
* @param {object} opts
* @param {object} opts
- * @param {string} [opts.file] the store's path
+ * @param {string} [opts.file] the Know module's path (know/lessons.js)
* @param {string} [opts.repo] the repo the job works in (its lessons come first)
* @param {string} [opts.repo] the repo the job works in (its lessons come first)
* @param {string} [opts.brief] the brief (its words pick the lessons that fit)
* @param {string} [opts.brief] the brief (its words pick the lessons that fit)
+ * @param {object} [opts.env] the environment (CODES_KNOW_LESSONS, or the older OPENVIBE_CODES_LESSONS)
* @returns {string} the lessons, as markdown ('' when there are none)
* @returns {string} the lessons, as markdown ('' when there are none)
*/
*/
-function lessons({ file, repo, brief } = {}) {
- const path_ = file || process.env.OPENVIBE_CODES_LESSONS;
+function lessons({ file, repo, brief, env = process.env } = {}) {
+ const path_ = String(file || (env && (env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS)) || '').trim();
if (!path_) return '';
if (!path_) return '';
- let raw;
- try { raw = fs.readFileSync(path_, 'utf8'); } catch { return ''; }
- const rows = [];
- for (const line of String(raw).split('\n')) {
- if (!line.trim()) continue;
- let row; try { row = JSON.parse(line); } catch { continue; }
- const text = typeof row.text === 'string' ? row.text.trim() : '';
- if (text) rows.push({ text, repo: row.repo || null, tags: Array.isArray(row.tags) ? row.tags : [] });
- }
- if (!rows.length) return '';
- const words = new Set(String(brief || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));
- const scored = rows.map((r) => {
- let score = r.repo && repo && r.repo === repo ? 10 : 0;
- for (const t of r.tags) if (words.has(String(t).toLowerCase())) score += 2;
- for (const w of words) if (r.text.toLowerCase().includes(w)) score += 1;
- return { r, score };
- }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
- if (!scored.length) return '';
- return ['### What earlier jobs learned here', ...scored.map((x) => `- ${x.r.text}`)].join('\n');
+ let know;
+ try { know = require(path.resolve(path_)); } catch { return ''; }
+ if (!know || typeof know.recall !== 'function') return '';
+ try {
+ const block = know.recall(String(brief || ''), repo || '*', 5);
+ return typeof block === 'string' ? block.trim() : '';
+ } catch { return ''; }
}
}
/**
/**
@@ -64,14 +54,15 @@ function lessons({ file, repo, brief } = {}) {
@@ -64,14 +54,15 @@ function lessons({ file, repo, brief } = {}) {
* @param {object} opts
* @param {object} opts
* @param {string} opts.brief what the task is
* @param {string} opts.brief what the task is
* @param {string} opts.dir the folder the job works in
* @param {string} opts.dir the folder the job works in
- * @param {object} [opts.env] the environment (OPENVIBE_CODES_LESSONS)
+ * @param {object} [opts.env] the environment (CODES_KNOW_LESSONS, or the older OPENVIBE_CODES_LESSONS)
+ * @param {string} [opts.know] the Know module's path (the Node's config wins over the environment)
* @returns {{text: string, parts: object[]}} the prompt and the parts it is made of
* @returns {{text: string, parts: object[]}} the prompt and the parts it is made of
*/
*/
-function assemble({ brief, dir, env = process.env } = {}) {
+function assemble({ brief, dir, env = process.env, know } = {}) {
const gitRule = fragment('git-rule.md');
const gitRule = fragment('git-rule.md');
const houseRules = fragment('house-rules.md');
const houseRules = fragment('house-rules.md');
const repo = repomap(dir);
const repo = repomap(dir);
- const learned = lessons({ file: env.OPENVIBE_CODES_LESSONS, repo: repo.repo, brief });
+ const learned = lessons({ file: know, repo: repo.repo, brief, env });
const parts = [
const parts = [
{ name: 'git-rule', text: gitRule },
{ name: 'git-rule', text: gitRule },
{ name: 'house-rules', text: houseRules },
{ name: 'house-rules', text: houseRules },
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- Codes C9a: the person's own limits, in Settings → Limits. Steps per task (tool calls) and minutes per task (wall
+-- clock), for every harness at once; null means no limit, never a zero budget. The harness's own caps follow them
+-- (engine/budget.js caps): its turn cap sits 25 above the step limit and its wall clock 5 minutes past the time limit.
+--
+-- Two nullable integers on the settings row, so a person without limits needs no row and the existing defaults stand.
+
+ALTER TABLE codes_user_settings
+ ADD COLUMN IF NOT EXISTS steps integer, -- steps per task: null = no limit
+ ADD COLUMN IF NOT EXISTS minutes integer; -- minutes per task: null = no limit
@@ -123,6 +123,9 @@ function load(env = process.env) {
@@ -123,6 +123,9 @@ function load(env = process.env) {
// The tokens one attempt may spend, when the task does not say (a run token's budget, Codes C3).
// The tokens one attempt may spend, when the task does not say (a run token's budget, Codes C3).
tokens: int(env.CODES_TASK_TOKENS, 0),
tokens: int(env.CODES_TASK_TOKENS, 0),
},
},
+ // Know (the shared lessons store, Know K): on a Node this is the portal's know/lessons.js, never a hard-coded
+ // home. The path is configuration; a store that cannot be read gives no lessons, never an error.
+ 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)) },
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
@@ -43,7 +43,7 @@ const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard:
@@ -43,7 +43,7 @@ const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard:
/** The attempt's own states, and the reasons it may have ended. */
/** The attempt's own states, and the reasons it may have ended. */
const STATES = ['queued', 'running', 'done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];
const STATES = ['queued', 'running', 'done', 'failed', 'stopped', 'budget-stopped', 'interrupted'];
-const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context'];
+const REASONS = ['limit', 'auth', 'capacity', 'refused', 'error', 'budget', 'stopped', 'hung', 'context', 'turns'];
const KINDS = ['first', 'continue', 'switch', 'wrap-up', 'restart', 'manual'];
const KINDS = ['first', 'continue', 'switch', 'wrap-up', 'restart', 'manual'];
/** The engine's own words for a reason: the timeline reads like a person wrote it. */
/** The engine's own words for a reason: the timeline reads like a person wrote it. */
@@ -63,6 +63,7 @@ const ERROR_TEXT = {
@@ -63,6 +63,7 @@ const ERROR_TEXT = {
stopped: 'stopped on purpose',
stopped: 'stopped on purpose',
context: 'the context window is too big',
context: 'the context window is too big',
hung: 'it went quiet for too long',
hung: 'it went quiet for too long',
+ turns: 'the harness reached max_turns',
};
};
/** The engine's status for our state (the two vocabularies are the same, except 'queued' and 'running'). */
/** The engine's status for our state (the two vocabularies are the same, except 'queued' and 'running'). */
@@ -548,12 +548,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -548,12 +548,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
/**
/**
* The next attempt now: the owner carries the task on (with a note), or switches to another agent. The task's row
* The next attempt now: the owner carries the task on (with a note), or switches to another agent. The task's row
* is locked while the attempt is written, so two presses start one attempt and not two.
* is locked while the attempt is written, so two presses start one attempt and not two.
+ *
+ * One continuation at a time (the portal's liveContinuation, 10-30): while an attempt is still live — the automatic
+ * continue after a crash, a wrap-up, another manual press — that attempt is returned, and no second one is started
+ * beside it. Two sessions on one worktree would fight over the same files.
*/
*/
async function continueTask(row, { note = null, agent = null } = {}) {
async function continueTask(row, { note = null, agent = null } = {}) {
if (!row || !row.id) return { error: 'no such task' };
if (!row || !row.id) return { error: 'no such task' };
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) return { error: 'the task has no attempt to carry on' };
if (!ending) return { error: 'the task has no attempt to carry on' };
+ const live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));
+ if (live) return { attempt: live, live: true, row };
const text = note ? String(note).trim().slice(0, 4000) : '';
const text = note ? String(note).trim().slice(0, 4000) : '';
const plan = agent
const plan = agent
? {
? {
@@ -1,12 +1,14 @@
@@ -1,12 +1,14 @@
'use strict';
'use strict';
/**
/**
- * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications and the safety
- * level. One row per person in codes_user_settings (migration 0013); a person without a row reads the defaults, so the
- * Settings screen is complete before anything is saved.
+ * A person's workspace choices (Codes C4b): the default agent, the default machine, their notifications, the safety
+ * level and the limits on every task (Codes C9a: steps and minutes, null meaning no limit). One row per person in
+ * codes_user_settings (migrations 0013 and 0014); a person without a row reads the defaults, so the Settings screen is
+ * complete before anything is saved.
*
*
* createUserSettings({ store })
* createUserSettings({ store })
- * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety }
+ * get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
+ * steps, minutes }
* save(subject, patch) → the saved row, in the same shape
* save(subject, patch) → the saved row, in the same shape
*
*
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
@@ -15,8 +17,24 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -15,8 +17,24 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SAFETY = ['ask', 'project', 'full'];
const SAFETY = ['ask', 'project', 'full'];
const AGENT = /^[a-z0-9-]{1,40}$/;
const AGENT = /^[a-z0-9-]{1,40}$/;
const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
const MACHINE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
+/** The most a person may set (the portal's own cap): a number of steps or minutes, or none at all. */
+const LIMIT_MAX = 100000;
-const DEFAULTS = Object.freeze({ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask' });
+const DEFAULTS = Object.freeze({
+ default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask',
+ steps: null, minutes: null,
+});
+
+/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */
+function limit(v, field) {
+ if (v === undefined) return undefined;
+ if (v === null || v === '' || v === 0 || v === '0' || String(v).toLowerCase() === 'off') return null;
+ const n = Number(v);
+ if (!Number.isFinite(n) || n <= 0 || n > LIMIT_MAX) {
+ throw Object.assign(new Error(`${field} is a positive number, or empty for No limit`), { status: 422, code: 'settings.limit_invalid' });
+ }
+ return Math.round(n);
+}
function present(row) {
function present(row) {
if (!row) return { ...DEFAULTS };
if (!row) return { ...DEFAULTS };
@@ -26,6 +44,8 @@ function present(row) {
@@ -26,6 +44,8 @@ function present(row) {
notify_done: row.notify_done !== false,
notify_done: row.notify_done !== false,
notify_needs: row.notify_needs !== false,
notify_needs: row.notify_needs !== false,
safety: SAFETY.includes(row.safety) ? row.safety : 'ask',
safety: SAFETY.includes(row.safety) ? row.safety : 'ask',
+ steps: row.steps == null ? null : Number(row.steps),
+ minutes: row.minutes == null ? null : Number(row.minutes),
};
};
}
}
@@ -64,17 +84,24 @@ function createUserSettings({ store }) {
@@ -64,17 +84,24 @@ function createUserSettings({ store }) {
if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });
if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });
next.safety = v;
next.safety = v;
}
}
+ // Settings → Limits: steps and minutes per task, for every harness at once. No value means no limit.
+ const steps = limit(patch.steps, 'steps per task');
+ if (steps !== undefined) next.steps = steps;
+ const minutes = limit(patch.minutes, 'minutes per task');
+ if (minutes !== undefined) next.minutes = minutes;
await db.exec(
await db.exec(
- `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, now())
+ `INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
+ steps, minutes, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
- notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety, updated_at = now()`,
- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety],
+ notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
+ steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, updated_at = now()`,
+ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes],
);
);
return next;
return next;
}
}
- return { get, save, DEFAULTS, SAFETY };
+ return { get, save, DEFAULTS, SAFETY, LIMIT_MAX };
}
}
-module.exports = { createUserSettings, present, DEFAULTS, SAFETY };
+module.exports = { createUserSettings, present, DEFAULTS, SAFETY, LIMIT_MAX };
@@ -460,11 +460,23 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -460,11 +460,23 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
// The same order the Home picker shows: the machines their work ran on last come first.
// The same order the Home picker shows: the machines their work ran on last come first.
const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');
const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');
+ // Settings → Limits (C9a): steps and minutes per task, for every agent at once; No limit means none.
+ const limitSelect = (name, value, presets, unit) => {
+ const choices = [[null, 'No limit'], ...presets];
+ if (value != null && !choices.some(([v]) => v === Number(value))) choices.push([Number(value), Number(value).toLocaleString('en-US')]);
+ return `<select name="${name}">${choices.map(([v, label]) => `<option value="${v == null ? '' : v}"${(v == null ? value == null : Number(value) === v) ? ' selected' : ''}>${esc(label)}${v == null ? '' : ` ${unit}`}</option>`).join('')}</select>`;
+ };
const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
<div class="c4-form-grid">
<div class="c4-form-grid">
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
</div>
</div>
+<h3 class="c4-subhead">Limits</h3>
+<div class="c4-form-grid">
+<label>Steps per task ${limitSelect('steps', settings.steps, [[200, '200'], [350, '350'], [500, '500'], [1000, '1,000']], 'steps')}</label>
+<label>Time per task ${limitSelect('minutes', settings.minutes, [[45, '45'], [90, '90'], [180, '180'], [360, '360']], 'min')}</label>
+</div>
+<p class="c4-hint">For every agent. The harness's own turn cap and timer follow these; No limit means none. A task that reaches a limit lands its work in one last turn and carries on with a fresh budget.</p>
<h3 class="c4-subhead">Notifications</h3>
<h3 class="c4-subhead">Notifications</h3>
<div class="c4-checks">
<div class="c4-checks">
<label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>
<label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>
@@ -487,6 +499,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -487,6 +499,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
notify_done: b.notify_done === '1',
notify_done: b.notify_done === '1',
notify_needs: b.notify_needs === '1',
notify_needs: b.notify_needs === '1',
...(b.safety ? { safety: b.safety } : {}),
...(b.safety ? { safety: b.safety } : {}),
+ steps: b.steps === undefined ? undefined : b.steps,
+ minutes: b.minutes === undefined ? undefined : b.minutes,
});
});
return res.redirect(303, '/settings?ok=Settings%20saved');
return res.redirect(303, '/settings?ok=Settings%20saved');
} catch (e) {
} catch (e) {
@@ -300,7 +300,8 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -300,7 +300,8 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
if (agent && !harnesses.list().some((a) => a.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses');
if (agent && !harnesses.list().some((a) => a.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses');
const out = await taskStore.continueTask(row, { note, agent });
const out = await taskStore.continueTask(row, { note, agent });
if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
- log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);
+ // One continuation at a time: an attempt that is already live answers this press, and no second one starts.
+ if (!out.live) log.info(`[Codes] task ${row.id} carried on (${agent ? `with ${agent}` : 'by the owner'})`);
return res.json(await timeline(out.row));
return res.json(await timeline(out.row));
});
});
@@ -128,6 +128,24 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -128,6 +128,24 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
assert.notStrictEqual(last.key, id, 'the follow-up has its own idempotency key');
assert.notStrictEqual(last.key, id, 'the follow-up has its own idempotency key');
});
});
+ await check('one continuation at a time: a manual continue while the automatic one runs returns the live one', async () => {
+ const id = await startTask();
+ const result = {
+ ok: false, summary: 'I will continue with the rest of it', files: [],
+ log: [{ t: 'agent', agent: 'claude-code', session: 'sess-live' }, { t: 'done', ok: false, text: 'I will continue with the rest of it' }],
+ };
+ await endAttempt(id, { state: 'failed', result, error: 'the machine went away' });
+ const attempts = await attemptsOf(id);
+ assert.strictEqual(attempts.length, 2, 'the crash was carried on automatically');
+ assert.strictEqual(attempts[1].state, 'queued', 'and its job is live');
+ const submits = calls.filter((c) => c[0] === 'submit').length;
+ const pressed = await call('POST', `/tasks/${id}/continue`, { body: { note: 'And check the tests.' } });
+ assert.strictEqual(pressed.status, 200, JSON.stringify(pressed.json));
+ assert.strictEqual(pressed.json.attempts.length, 2, 'no second continuation was started');
+ assert.strictEqual(pressed.json.attempts[1].session, 'sess-live', 'the live continuation is the one returned');
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, submits, 'no job was started beside it');
+ });
+
await check('a limit: another agent takes over, with a hand-over note of what the last one did', async () => {
await check('a limit: another agent takes over, with a hand-over note of what the last one did', async () => {
const id = await startTask();
const id = await startTask();
const result = { ok: false, summary: 'I fixed the parser', files: [{ path: 'src/parse.js', added: 3, removed: 1 }], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };
const result = { ok: false, summary: 'I fixed the parser', files: [{ path: 'src/parse.js', added: 3, removed: 1 }], log: [{ t: 'done', ok: false, text: 'usage limit reached' }] };
@@ -215,9 +233,12 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -215,9 +233,12 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
assert.strictEqual(continued.json.attempts.length, 2);
assert.strictEqual(continued.json.attempts.length, 2);
assert.strictEqual(continued.json.attempts[1].kind, 'manual');
assert.strictEqual(continued.json.attempts[1].kind, 'manual');
assert.ok(/carried on by the owner/.test(continued.json.attempts[1].why), continued.json.attempts[1].why);
assert.ok(/carried on by the owner/.test(continued.json.attempts[1].why), continued.json.attempts[1].why);
- // While the task runs again, nobody carries it on a second time.
+ // While the task runs again, nobody carries it on a second time: the live attempt answers the press.
+ const runsBefore = calls.filter((c) => c[0] === 'submit').length;
const again = await call('POST', `/tasks/${id}/continue`, { body: { agent: 'codex' } });
const again = await call('POST', `/tasks/${id}/continue`, { body: { agent: 'codex' } });
- assert.strictEqual(again.status, 409, 'the task is running again');
+ assert.strictEqual(again.status, 200, JSON.stringify(again.json));
+ assert.strictEqual(again.json.attempts.length, 2, 'the live attempt is returned, not a second one');
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, runsBefore, 'no job was started');
});
});
await check('the auth of every new route: no token, no capability, another person', async () => {
await check('the auth of every new route: no token, no capability, another person', async () => {
@@ -157,6 +157,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -157,6 +157,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(page.text.includes('<option value="codex" selected'), 'the default agent is kept');
assert.ok(page.text.includes('<option value="codex" selected'), 'the default agent is kept');
assert.ok(page.text.includes('value="project" checked'), 'so is the safety level');
assert.ok(page.text.includes('value="project" checked'), 'so is the safety level');
assert.ok(!page.text.includes('name="notify_done" value="1" checked'), 'an unticked box stays unticked');
assert.ok(!page.text.includes('name="notify_done" value="1" checked'), 'an unticked box stays unticked');
+ assert.ok(page.text.includes('name="steps"') && page.text.includes('name="minutes"'), 'the limits are on the page');
const other = await t.get('/settings', { as: bo });
const other = await t.get('/settings', { as: bo });
assert.ok(other.text.includes('<option value="codex">'), 'and it is only this person’s');
assert.ok(other.text.includes('<option value="codex">'), 'and it is only this person’s');
assert.ok(!other.text.includes('<option value="codex" selected'));
assert.ok(!other.text.includes('<option value="codex" selected'));
@@ -164,6 +165,31 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -164,6 +165,31 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(String(bad.headers.get('location')).includes('error='), 'a level Codes does not know is refused');
assert.ok(String(bad.headers.get('location')).includes('error='), 'a level Codes does not know is refused');
});
});
+ await check('Settings → Limits: steps and minutes per task, with No limit meaning none', async () => {
+ const saved = await t.get('/settings', { as: ada, method: 'POST', form: {
+ default_agent: 'codex', steps: '350', minutes: '90', notify_done: '1', notify_needs: '1', safety: 'ask',
+ } });
+ assert.strictEqual(saved.status, 303);
+ let settings = await t.ctx.userSettings.get(ada.subject);
+ assert.strictEqual(settings.steps, 350);
+ assert.strictEqual(settings.minutes, 90);
+ const page = await t.get('/settings', { as: ada });
+ assert.ok(page.text.includes('<option value="350" selected'), 'the step limit is shown');
+ assert.ok(page.text.includes('<option value="90" selected'), 'and the time limit');
+ // No limit is an empty value, stored as null, never a zero budget.
+ await t.get('/settings', { as: ada, method: 'POST', form: { steps: '', minutes: 'off', notify_done: '1', notify_needs: '1' } });
+ settings = await t.ctx.userSettings.get(ada.subject);
+ assert.strictEqual(settings.steps, null);
+ assert.strictEqual(settings.minutes, null);
+ const none = await t.get('/settings', { as: ada });
+ assert.ok(none.text.includes('<option value="" selected'), 'No limit is selected');
+ // A negative or silly value is refused, and the stored one stays.
+ await t.get('/settings', { as: ada, method: 'POST', form: { steps: '500' } });
+ const refused = await t.get('/settings', { as: ada, method: 'POST', form: { steps: '-3' } });
+ assert.ok(String(refused.headers.get('location')).includes('error='), 'a negative limit is refused');
+ assert.strictEqual((await t.ctx.userSettings.get(ada.subject)).steps, 500, 'the saved value is untouched');
+ });
+
await check('Usage counts runs, minutes and tokens per day, and shows no prices', async () => {
await check('Usage counts runs, minutes and tokens per day, and shows no prices', async () => {
// The task page seeds attempt 1 for a task it knows; the machine's own numbers belong on it.
// The task page seeds attempt 1 for a task it knows; the machine's own numbers belong on it.
await t.get(`/tasks/${TASK}`, { as: ada });
await t.get(`/tasks/${TASK}`, { as: ada });
@@ -24,6 +24,7 @@ const { minimaxCode } = require('../engine/adapters/minimax-code');
@@ -24,6 +24,7 @@ const { minimaxCode } = require('../engine/adapters/minimax-code');
const transcript = require('../engine/transcript');
const transcript = require('../engine/transcript');
const { runAttempt } = require('../engine/jobrunner');
const { runAttempt } = require('../engine/jobrunner');
const { assemble } = require('../engine/prompt');
const { assemble } = require('../engine/prompt');
+const { caps } = require('../engine/budget');
/** The DRY_RUN form of an adapter: the command, its arguments and its environment. */
/** The DRY_RUN form of an adapter: the command, its arguments and its environment. */
function dry(adapter, ctx = {}) {
function dry(adapter, ctx = {}) {
@@ -155,6 +156,55 @@ function dry(adapter, ctx = {}) {
@@ -155,6 +156,55 @@ function dry(adapter, ctx = {}) {
assert.ok(dry(minimaxCode, { permission: 'full' }).argv.includes('full'));
assert.ok(dry(minimaxCode, { permission: 'full' }).argv.includes('full'));
});
});
+ await check('the harness caps follow Settings → Limits: the set limits, and No limit means none', () => {
+ const set = caps({ steps: 350, minutes: 45 });
+ const none = caps({ steps: null, minutes: null });
+ const arg = (adapter, ctx, flag) => {
+ const argv = dry(adapter, ctx).argv;
+ return argv[argv.indexOf(flag) + 1];
+ };
+ // The three harnesses that had a hard-coded cap now take the owner's (steps + 25).
+ assert.equal(arg(claudeCode, set, '--max-turns'), '375');
+ assert.equal(arg(commandCode, set, '--max-turns'), '375');
+ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...set }, '--max-steps'), '375');
+ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...set }, '--timeout'), '50m');
+ // No limit means no cap: 100000 turns and no wall clock (the portal's MAXTURNS 100000, JOBTIMEOUT 0).
+ assert.equal(arg(claudeCode, none, '--max-turns'), '100000');
+ assert.equal(arg(commandCode, none, '--max-turns'), '100000');
+ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...none }, '--max-steps'), '100000');
+ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', ...none }, '--timeout'), '100000m');
+ // An adapter with no cap of its own gets none either: an absent cap is not the old 150/200.
+ assert.equal(arg(claudeCode, { maxTurns: undefined }, '--max-turns'), '100000');
+ assert.equal(arg(commandCode, { maxTurns: undefined }, '--max-turns'), '100000');
+ assert.equal(arg(minimaxCode, { cwd: '/tmp/work', maxSteps: undefined }, '--max-steps'), '100000');
+ });
+
+ await check('Claude-family harnesses may call the Know MCP tools, when the machine registered the server', () => {
+ const allowed = (adapter, know) => {
+ const argv = dry(adapter, { know }).argv;
+ return argv[argv.indexOf('--allowedTools') + 1].split(' ');
+ };
+ for (const adapter of [claudeCode, claudeOther]) {
+ assert.ok(!allowed(adapter, false).includes('mcp__ov_know'), `${adapter.id}: no Know server, no tools`);
+ assert.ok(allowed(adapter, true).includes('mcp__ov_know'), `${adapter.id}: the Know tools are allowed`);
+ assert.ok(allowed(adapter, true).includes('Bash'), 'the allow list keeps the tools a job needs');
+ }
+ // The machine's own registration is what decides it by default: ~/.claude.json → mcpServers.ov_know.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-registered-'));
+ const registered = path.join(dir, 'claude.json');
+ fs.writeFileSync(registered, JSON.stringify({ mcpServers: { ov_know: { command: 'node', args: ['know/mcp.js'] } } }));
+ const absent = path.join(dir, 'none.json');
+ fs.writeFileSync(absent, JSON.stringify({ mcpServers: {} }));
+ const list = (file) => {
+ const argv = dry(claudeCode, { claudeConfig: file }).argv;
+ return argv[argv.indexOf('--allowedTools') + 1].split(' ');
+ };
+ assert.ok(list(registered).includes('mcp__ov_know'), 'the registered server makes its tools callable');
+ assert.ok(!list(absent).includes('mcp__ov_know'), 'an unregistered machine gets no allow it cannot use');
+ assert.ok(!list(path.join(dir, 'missing.json')).includes('mcp__ov_know'), 'a config that is not there changes nothing');
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
await check('the effort is fitted to the harness, never handed over as it came', () => {
await check('the effort is fitted to the harness, never handed over as it came', () => {
// The 2026-10-29 incident: Codex's "medium" killed Command Code before its first step.
// The 2026-10-29 incident: Codex's "medium" killed Command Code before its first step.
assert.equal(effortFor('command-code', 'medium'), 'high');
assert.equal(effortFor('command-code', 'medium'), 'high');
@@ -244,17 +294,20 @@ function dry(adapter, ctx = {}) {
@@ -244,17 +294,20 @@ function dry(adapter, ctx = {}) {
// A job that works in one repository gets that repository's digest; a folder that holds many gets none.
// A job that works in one repository gets that repository's digest; a folder that holds many gets none.
require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });
require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });
fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');
fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n\nA demo repository.\n');
- const lessonsFile = path.join(dir, 'lessons.jsonl');
- fs.writeFileSync(lessonsFile, JSON.stringify({ text: 'the tests need the database', repo: 'demo', tags: ['tests'] }) + '\n');
- const built = assemble({ brief: 'fix the test', dir, env: { OPENVIBE_CODES_LESSONS: lessonsFile } });
+ // On a Node the lessons come from the Know store's module (know/lessons.js), its path from the config.
+ const knowFile = path.join(dir, 'know.js');
+ fs.writeFileSync(knowFile, "module.exports = { recall: () => '## What earlier jobs learned here (dated)\\n- 2026-10-30 · demo — the tests need the database' };\n");
+ const built = assemble({ brief: 'fix the test', dir, env: { CODES_KNOW_LESSONS: knowFile } });
const names = built.parts.map((p) => p.name);
const names = built.parts.map((p) => p.name);
assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'lessons', 'repo-digest', 'brief']);
assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'lessons', 'repo-digest', 'brief']);
assert.ok(built.text.indexOf('Git rules') < built.text.indexOf('House rules'));
assert.ok(built.text.indexOf('Git rules') < built.text.indexOf('House rules'));
assert.ok(built.text.indexOf('House rules') < built.text.indexOf('learned'));
assert.ok(built.text.indexOf('House rules') < built.text.indexOf('learned'));
assert.ok(built.text.indexOf('learned') < built.text.indexOf('demo repository'));
assert.ok(built.text.indexOf('learned') < built.text.indexOf('demo repository'));
assert.ok(built.text.trimEnd().endsWith('fix the test'), 'the brief is last');
assert.ok(built.text.trimEnd().endsWith('fix the test'), 'the brief is last');
- // A store that cannot be read is skipped, not an error.
- const none = assemble({ brief: 'x', dir, env: { OPENVIBE_CODES_LESSONS: path.join(dir, 'nope.jsonl') } });
+ // The Node's config path wins over the environment, and a store that cannot be read is skipped, not an error.
+ const fromConfig = assemble({ brief: 'x', dir, know: knowFile, env: { CODES_KNOW_LESSONS: path.join(dir, 'nope.js') } });
+ assert.ok(fromConfig.parts.some((p) => p.name === 'lessons'), 'the config path is read');
+ const none = assemble({ brief: 'x', dir, env: { CODES_KNOW_LESSONS: path.join(dir, 'nope.js') } });
assert.ok(!none.parts.some((p) => p.name === 'lessons'));
assert.ok(!none.parts.some((p) => p.name === 'lessons'));
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
});
});
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
-const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('../engine/budget');
+const { limitsFor, caps, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('../engine/budget');
(async () => {
(async () => {
await check('the limits are the config\'s, then the runner\'s, then the task\'s', () => {
await check('the limits are the config\'s, then the runner\'s, then the task\'s', () => {
@@ -20,6 +20,18 @@ const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('..
@@ -20,6 +20,18 @@ const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('..
assert.equal(evaluate({ steps: 100000 }, limitsFor({}, 'mm')).hit, null);
assert.equal(evaluate({ steps: 100000 }, limitsFor({}, 'mm')).hit, null);
});
});
+ await check('the harness caps follow the limits: 25 turns and 5 minutes above, or none', () => {
+ assert.deepEqual(caps({ steps: 350, minutes: 45 }), { maxTurns: 375, timeoutMs: 50 * 60_000, timeout: '50m' });
+ assert.deepEqual(caps({ steps: 200, minutes: 90 }), { maxTurns: 225, timeoutMs: 95 * 60_000, timeout: '95m' });
+ // No limit means none: the harness's own cap is effectively off, and the wall clock is no timer at all.
+ const none = caps({ steps: null, minutes: null });
+ assert.equal(none.maxTurns, 100000);
+ assert.equal(none.timeoutMs, 0);
+ assert.equal(none.timeout, '100000m');
+ assert.deepEqual(caps({}), none);
+ assert.deepEqual(caps({ steps: 0, minutes: 0 }), none);
+ });
+
await check('the first limit reached wins', () => {
await check('the first limit reached wins', () => {
const limits = { steps: 100, minutes: 60, usd: 5 };
const limits = { steps: 100, minutes: 60, usd: 5 };
assert.equal(evaluate({ steps: 120, minutes: 90, usd: 9 }, limits).hit, 'steps');
assert.equal(evaluate({ steps: 120, minutes: 90, usd: 9 }, limits).hit, 'steps');
@@ -121,6 +121,39 @@ const carried = (parent, name, at) => ({
@@ -121,6 +121,39 @@ const carried = (parent, name, at) => ({
assert.match(step.why, /crash/);
assert.match(step.why, /crash/);
});
});
+ await check('a harness turn cap is continued in its own session, on the same harness, never switched', () => {
+ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };
+ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };
+ for (const error of ['max_turns in 2483s', 'error_max_turns', 'turn limit_exceeded', 'maximum number of turns reached']) {
+ const attempts = [{ ...job('cc', 'a', 'failed', now, { error }), facts: { session: 'sess-9' } }];
+ const step = chain.nextStep(attempts, ctx);
+ assert.equal(step.action, 'continue', error);
+ assert.equal(step.session, 'sess-9', error);
+ assert.match(step.why, /turn cap/, error);
+ }
+ // Without a session there is nothing to continue: then, and only then, another runner may take it.
+ const noSession = chain.nextStep([job('cc', 'a', 'failed', now, { error: 'max_turns' })], ctx);
+ assert.equal(noSession.action, 'switch');
+ assert.equal(noSession.runner, 'mm');
+ });
+
+ await check('a continuation of an attempt the owner marked handled is never switched', () => {
+ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };
+ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };
+ const parent = job('cc', 'a', 'failed', now - 5000, { resolvedNote: 'Fine: the work landed in PR 12' });
+ for (const error of ['usage limit reached', 'max_turns']) {
+ const child = job('mm', 'a-r2', 'failed', now, { parent: 'cc:a', error });
+ const step = chain.nextStep([parent, child], ctx);
+ assert.notEqual(step.action, 'switch', error);
+ assert.equal(step.action, 'failed', error);
+ assert.match(step.why, /marked handled by the owner/, error);
+ }
+ // On a fresh parent that was not settled, the switch still happens.
+ const open = job('cc', 'b', 'failed', now - 5000);
+ const failed = job('mm', 'b-r2', 'failed', now, { parent: 'cc:b', error: 'usage limit reached' });
+ assert.equal(chain.nextStep([open, failed], ctx).action, 'switch');
+ });
+
await check('a context that grew too big starts fresh instead of continuing', () => {
await check('a context that grew too big starts fresh instead of continuing', () => {
const attempts = [{ ...job('cc', 'a', 'failed', now), facts: { apiError: 'prompt is too long: 250000 tokens' } }];
const attempts = [{ ...job('cc', 'a', 'failed', now), facts: { apiError: 'prompt is too long: 250000 tokens' } }];
const step = chain.nextStep(attempts);
const step = chain.nextStep(attempts);
@@ -14,7 +14,7 @@ const DIR = path.join(__dirname, '..', 'engine');
@@ -14,7 +14,7 @@ const DIR = path.join(__dirname, '..', 'engine');
for (const name of ['router', 'chain', 'budget', 'transcript', 'effort', 'scope', 'runerror', 'proof', 'review']) {
for (const name of ['router', 'chain', 'budget', 'transcript', 'effort', 'scope', 'runerror', 'proof', 'review']) {
assert.ok(engine[name], `${name} is exported`);
assert.ok(engine[name], `${name} is exported`);
}
}
- for (const name of ['classify', 'pick', 'outcome', 'nextStep', 'evaluate', 'limitsFor', 'parse', 'parseText', 'fitEffort', 'pickModel']) {
+ for (const name of ['classify', 'pick', 'outcome', 'nextStep', 'evaluate', 'limitsFor', 'caps', 'parse', 'parseText', 'fitEffort', 'pickModel']) {
assert.equal(typeof engine[name], 'function', `${name} is a function`);
assert.equal(typeof engine[name], 'function', `${name} is a function`);
}
}
});
});
@@ -10,27 +10,44 @@ const { assemble, lessons, fragment } = require('../engine/prompt');
@@ -10,27 +10,44 @@ const { assemble, lessons, fragment } = require('../engine/prompt');
(async () => {
(async () => {
await check('the rules and the house rules are Codes\' own fragments', () => {
await check('the rules and the house rules are Codes\' own fragments', () => {
- assert.ok(fragment('git-rule.md').includes('Git rules'));
+ const git = fragment('git-rule.md');
+ assert.ok(git.includes('Git rules'));
+ // 10-30: a job pushes its own branch when its brief asks for a PR. Never main, never force.
+ assert.match(git, /Push only your own job branch, and only when your brief asks for a PR/);
+ assert.match(git, /Never push to main, never force-push, never push someone else's branch/);
+ assert.doesNotMatch(git, /git config\. Never push\./, 'the old no-push-at-all rule is gone');
assert.ok(fragment('house-rules.md').includes('House rules'));
assert.ok(fragment('house-rules.md').includes('House rules'));
assert.equal(fragment('nope.md'), '');
assert.equal(fragment('nope.md'), '');
});
});
- await check('the lessons are the ones that fit, at most five', () => {
- const file = path.join(os.tmpdir(), `lessons-${process.pid}.jsonl`);
- const rows = [];
- for (let i = 0; i < 8; i++) rows.push(JSON.stringify({ text: `lesson ${i} about the database`, repo: 'demo', tags: ['database'] }));
- rows.push(JSON.stringify({ text: 'unrelated note' }));
- fs.writeFileSync(file, rows.join('\n') + '\n');
+ await check('the lessons come from the Know store, through its own recall()', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-'));
+ const file = path.join(dir, 'lessons.js');
+ fs.writeFileSync(file, `module.exports = { recall(brief, repo, k) {
+ require('fs').writeFileSync(${JSON.stringify(path.join(dir, 'seen.json'))}, JSON.stringify({ brief, repo, k }));
+ return '## What earlier jobs learned here (dated)\\n- 2026-10-30 · demo — the tests need the database\\n';
+ } };\n`);
const got = lessons({ file, repo: 'demo', brief: 'fix the database test' });
const got = lessons({ file, repo: 'demo', brief: 'fix the database test' });
- assert.ok(got.includes('What earlier jobs learned here'));
- assert.ok(!got.includes('unrelated note'), 'a lesson that does not fit is left out');
- assert.ok((got.match(/^- /gm) || []).length <= 5);
- fs.rmSync(file, { force: true });
+ assert.ok(got.startsWith('## What earlier jobs learned here'), 'the store formats its own block');
+ assert.ok(got.trimEnd().endsWith('the database'), 'and its lesson is kept');
+ assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(dir, 'seen.json'), 'utf8')),
+ { brief: 'fix the database test', repo: 'demo', k: 5 }, 'the store gets the brief, the repo and the cap');
+ // The environment is how a Node passes the config path when the caller does not.
+ assert.equal(lessons({ brief: 'x', env: { CODES_KNOW_LESSONS: file } }), got);
+ fs.rmSync(dir, { recursive: true, force: true });
});
});
await check('a store that cannot be reached gives no lessons, not an error', () => {
await check('a store that cannot be reached gives no lessons, not an error', () => {
- assert.equal(lessons({ file: '/nope/lessons.jsonl', brief: 'x' }), '');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'know-bad-'));
+ const noRecall = path.join(dir, 'no-recall.js');
+ fs.writeFileSync(noRecall, 'module.exports = { search: () => "x" };\n');
+ const throws = path.join(dir, 'throws.js');
+ fs.writeFileSync(throws, 'module.exports = { recall: () => { throw new Error("no store"); } };\n');
+ assert.equal(lessons({ file: '/nope/lessons.js', brief: 'x' }), '');
+ assert.equal(lessons({ file: noRecall, brief: 'x' }), '');
+ assert.equal(lessons({ file: throws, brief: 'x' }), '');
assert.equal(lessons({ brief: 'x' }), '');
assert.equal(lessons({ brief: 'x' }), '');
+ fs.rmSync(dir, { recursive: true, force: true });
});
});
await check('the prompt is the rules, the house rules, the lessons, the digest, the brief', () => {
await check('the prompt is the rules, the house rules, the lessons, the digest, the brief', () => {
@@ -180,6 +180,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -180,6 +180,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(other.status, 404, 'someone else’s task');
assert.strictEqual(other.status, 404, 'someone else’s task');
// A continue starts the next attempt on a task that has ended; stop ends the running one for good.
// A continue starts the next attempt on a task that has ended; stop ends the running one for good.
await db.exec("UPDATE run_tasks SET state = 'failed', outcome = 'unfinished' WHERE id = $1", [own.id]);
await db.exec("UPDATE run_tasks SET state = 'failed', outcome = 'unfinished' WHERE id = $1", [own.id]);
+ await db.exec("UPDATE codes_attempts SET state = 'failed' WHERE task_id = $1", [own.id]);
const carried = await asAda('POST', `/tasks/${own.id}/continue`, { note: 'and the tests' });
const carried = await asAda('POST', `/tasks/${own.id}/continue`, { note: 'and the tests' });
assert.strictEqual(carried.status, 200, JSON.stringify(carried.json));
assert.strictEqual(carried.status, 200, JSON.stringify(carried.json));
assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');
assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');