openvibers/openvibe.codes Commits

Codes C9a: limits, the turn-cap reason, one continuation, Know (#183)

OP OpenVibers committed 14 days ago · bdb1c0c54e · 27 files, +459 −93

* Codes C9a: limits, the turn-cap reason, one continuation at a time, Know

* Codes C9a: the settings limits migration is 0015, after main's 0014

* Merge remote-tracking branch 'origin/main' into codes-c9a

Changes

ddiff --git a/.env.example b/.env.example
iindex 2d586b4..ef7e692 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/engine/README.md b/engine/README.md
iindex beda14d..20197a5 100644
---- a/engine/README.md
++++ b/engine/README.md
@@@ -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
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
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
### `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 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
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 }`.
@@@ -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
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
--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 });
// { hit: 'steps', limits: { steps: 100, minutes: 60, usd: 5 }, used: { steps: 120, minutes: 30, usd: 1.2 } }
ddiff --git a/engine/adapters/base.js b/engine/adapters/base.js
iindex 720e437..5ecb026 100644
---- a/engine/adapters/base.js
++++ b/engine/adapters/base.js
@@@ -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, o
try { 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 };
ddiff --git a/engine/adapters/claude-code.js b/engine/adapters/claude-code.js
iindex 02a83d6..77a8f71 100644
---- a/engine/adapters/claude-code.js
++++ b/engine/adapters/claude-code.js
@@@ -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 || '' };
}
ddiff --git a/engine/adapters/command-code.js b/engine/adapters/command-code.js
iindex ed7c093..5d6e93a 100644
---- a/engine/adapters/command-code.js
++++ b/engine/adapters/command-code.js
@@@ -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 };
}
ddiff --git a/engine/adapters/minimax-code.js b/engine/adapters/minimax-code.js
iindex f03143f..a726f13 100644
---- a/engine/adapters/minimax-code.js
++++ b/engine/adapters/minimax-code.js
@@@ -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');
ddiff --git a/engine/budget.js b/engine/budget.js
iindex d394b97..a8906e3 100644
---- a/engine/budget.js
++++ b/engine/budget.js
@@@ -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 };
ddiff --git a/engine/chain.js b/engine/chain.js
iindex dc95d8a..1f218cd 100644
---- a/engine/chain.js
++++ b/engine/chain.js
@@@ -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 };
ddiff --git a/engine/fragments/git-rule.md b/engine/fragments/git-rule.md
iindex 57a2a9b..8f08f72 100644
---- a/engine/fragments/git-rule.md
++++ b/engine/fragments/git-rule.md
@@@ -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 work
around it.
- Commit only if your brief asks, with `git -c user.name=OpenVibers -c [email protected] commit`. Never
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.
ddiff --git a/engine/index.js b/engine/index.js
iindex a2e9693..c9011df 100644
---- a/engine/index.js
++++ b/engine/index.js
@@@ -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,
ddiff --git a/engine/jobrunner.js b/engine/jobrunner.js
iindex ca6fc0c..c9e511f 100644
---- a/engine/jobrunner.js
++++ b/engine/jobrunner.js
@@@ -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(); },
ddiff --git a/engine/prompt.js b/engine/prompt.js
iindex 795713c..671ee73 100644
---- a/engine/prompt.js
++++ b/engine/prompt.js
@@@ -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 },
ddiff --git a/migrations/0015_user_settings_limits.sql b/migrations/0015_user_settings_limits.sql
nnew file mode 100644
iindex 0000000..315ca3b
---- /dev/null
++++ b/migrations/0015_user_settings_limits.sql
@@@ -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
ddiff --git a/server/config.js b/server/config.js
iindex 5343a42..712922e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/domain/attempts.js b/server/domain/attempts.js
iindex 8873135..f9fbb96 100644
---- a/server/domain/attempts.js
++++ b/server/domain/attempts.js
@@@ -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'). */
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 5152757..156b80e 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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
? {
ddiff --git a/server/domain/user-settings.js b/server/domain/user-settings.js
iindex 10d1a4a..3cf9d96 100644
---- a/server/domain/user-settings.js
++++ b/server/domain/user-settings.js
@@@ -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 };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex f0cb976..a29a51d 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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) {
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex d50a2da..7b4af31 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -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');
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));
});
ddiff --git a/test/attempts.test.js b/test/attempts.test.js
iindex 89e1dcc..3510bb1 100644
---- a/test/attempts.test.js
++++ b/test/attempts.test.js
@@@ -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 () => {
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex fc64539..27d981a 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -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&rsquo;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 });
ddiff --git a/test/engine-adapters.test.js b/test/engine-adapters.test.js
iindex 93c19b5..03b3daf 100644
---- a/test/engine-adapters.test.js
++++ b/test/engine-adapters.test.js
@@@ -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 });
});
ddiff --git a/test/engine-budget.test.js b/test/engine-budget.test.js
iindex 8b54d8d..ab27223 100644
---- a/test/engine-budget.test.js
++++ b/test/engine-budget.test.js
@@@ -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');
ddiff --git a/test/engine-chain.test.js b/test/engine-chain.test.js
iindex ba4f686..512b30e 100644
---- a/test/engine-chain.test.js
++++ b/test/engine-chain.test.js
@@@ -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);
ddiff --git a/test/engine-index.test.js b/test/engine-index.test.js
iindex d43437e..374f34f 100644
---- a/test/engine-index.test.js
++++ b/test/engine-index.test.js
@@@ -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`);
}
});
ddiff --git a/test/engine-prompt.test.js b/test/engine-prompt.test.js
iindex 633b1c1..ed3a229 100644
---- a/test/engine-prompt.test.js
++++ b/test/engine-prompt.test.js
@@@ -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', () => {
ddiff --git a/test/tasks-api.test.js b/test/tasks-api.test.js
iindex af10f2c..ab7592a 100644
---- a/test/tasks-api.test.js
++++ b/test/tasks-api.test.js
@@@ -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');