From 0fbb744323c50d00543066f0e462feee8dc5e3f7 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:40:35 -0400 Subject: [PATCH 001/105] feat: add provider adapter interface --- lib/provider-adapters.mjs | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/provider-adapters.mjs diff --git a/lib/provider-adapters.mjs b/lib/provider-adapters.mjs new file mode 100644 index 0000000..63218fa --- /dev/null +++ b/lib/provider-adapters.mjs @@ -0,0 +1,84 @@ +const LOCAL_MODEL_PATTERNS = [/^ollama(?:-|\/)/i, /^local(?:-|\/)/i, /-local$/i]; + +export class ProviderAdapter { + constructor({ id, aliases = [], local = false, timeoutKey = id } = {}) { + if (!id) throw new Error('ProviderAdapter requires id'); + this.id = id; + this.aliases = aliases; + this.local = local; + this.timeoutKey = timeoutKey; + } + + matches(modelId, explicitProvider) { + const provider = String(explicitProvider || '').toLowerCase(); + if (provider === this.id || this.aliases.includes(provider)) return true; + return this.modelProvider(modelId) === this.id; + } + + modelProvider(modelId) { + const value = String(modelId || ''); + if (!value) return 'unknown'; + if (LOCAL_MODEL_PATTERNS.some(pattern => pattern.test(value))) return 'local'; + const slash = value.indexOf('/'); + return slash > 0 ? value.slice(0, slash).toLowerCase() : 'unknown'; + } + + normalizeError({ stdout = '', stderr = '', exitCode = 0 } = {}) { + const combined = `${stderr}\n${stdout}`; + const statusMatch = combined.match(/\b(400|401|403|404|408|409|410|422|429|500|502|503|504)\b/); + const codeMatch = combined.match(/\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|UNAUTHORIZED|FORBIDDEN|BILLING_DISABLED|SAFETY_REJECTION)\b/); + const sessionMatch = combined.match(/session(?:ID|Id| id)?[\s:=]+([a-zA-Z0-9_-]+)/); + return { + statusCode: statusMatch ? Number(statusMatch[1]) : undefined, + code: codeMatch ? codeMatch[1] : undefined, + provider: this.id, + sessionId: sessionMatch ? sessionMatch[1] : undefined, + message: combined.slice(0, 2000), + exitCode + }; + } +} + +export class LocalProviderAdapter extends ProviderAdapter { + constructor() { + super({ id: 'local', aliases: ['ollama', 'rx580-llama'], local: true, timeoutKey: 'local' }); + } +} + +const adapters = new Map(); + +export function registerProviderAdapter(adapter) { + if (!(adapter instanceof ProviderAdapter)) { + throw new TypeError('registerProviderAdapter expects ProviderAdapter'); + } + adapters.set(adapter.id, adapter); + for (const alias of adapter.aliases) adapters.set(alias, adapter); + return adapter; +} + +export function listProviderAdapters() { + return [...new Set(adapters.values())]; +} + +export function resolveProviderAdapter(modelId, explicitProvider) { + const explicit = String(explicitProvider || '').toLowerCase(); + if (explicit && adapters.has(explicit)) return adapters.get(explicit); + for (const adapter of listProviderAdapters()) { + if (adapter.matches(modelId, explicitProvider)) return adapter; + } + const prefix = String(modelId || '').split('/')[0].toLowerCase(); + return adapters.get(prefix) || new ProviderAdapter({ id: prefix && prefix !== modelId ? prefix : 'unknown' }); +} + +export function deriveProviderFromModel(modelId, explicitProvider) { + return resolveProviderAdapter(modelId, explicitProvider).id; +} + +export function providerTimeoutKey(modelId, explicitProvider) { + return resolveProviderAdapter(modelId, explicitProvider).timeoutKey; +} + +for (const id of ['opencode', 'opencode-go', 'nvidia', 'cerebras', 'groq', 'openrouter', 'openai']) { + registerProviderAdapter(new ProviderAdapter({ id })); +} +registerProviderAdapter(new LocalProviderAdapter()); From 8f87ddf2c57289d4dbbde1b825dd9f6e5534130e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:40:58 -0400 Subject: [PATCH 002/105] feat: add structured event logging --- lib/event-log.mjs | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 lib/event-log.mjs diff --git a/lib/event-log.mjs b/lib/event-log.mjs new file mode 100644 index 0000000..42db095 --- /dev/null +++ b/lib/event-log.mjs @@ -0,0 +1,89 @@ +import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export const EVENT_SCHEMA_VERSION = '1.0.0'; + +const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|private[_-]?key|authorization|cookie|credential)/i; +const VALUE_PATTERNS = [ + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, + /\bsk-[A-Za-z0-9_-]{20,}\b/g, + /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)[^\s]+/gi +]; + +export function redactSensitive(value, key = '') { + if (SECRET_KEY.test(String(key))) return '[REDACTED]'; + if (typeof value === 'string') { + let output = value; + for (const pattern of VALUE_PATTERNS) output = output.replace(pattern, match => match.includes('=') ? `${match.split('=')[0]}=[REDACTED]` : '[REDACTED]'); + return output.length > 16000 ? `${output.slice(0, 16000)}…[TRUNCATED]` : output; + } + if (Array.isArray(value)) return value.map(item => redactSensitive(item)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, redactSensitive(child, childKey)])); + } + return value; +} + +export function createAgentLoopEvent({ type, taskId, stage, role, modelId, data = {}, timestamp = new Date().toISOString() } = {}) { + if (!type) throw new Error('Event type is required'); + if (!taskId) throw new Error('Event taskId is required'); + return { + schemaVersion: EVENT_SCHEMA_VERSION, + eventId: randomUUID(), + timestamp, + type, + taskId, + ...(stage ? { stage } : {}), + ...(role ? { role } : {}), + ...(modelId ? { modelId } : {}), + data: redactSensitive(data) + }; +} + +export function defaultEventLogPath(cwd = process.cwd()) { + return resolve(cwd, '.opencode', 'agent-loop-state', 'events.jsonl'); +} + +export class AgentLoopEventLogger { + constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd) } = {}) { + if (!taskId) throw new Error('AgentLoopEventLogger requires taskId'); + this.taskId = taskId; + this.path = path; + mkdirSync(dirname(path), { recursive: true }); + } + + emit(type, details = {}) { + const event = createAgentLoopEvent({ type, taskId: this.taskId, ...details }); + appendFileSync(this.path, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 }); + return event; + } + + query({ type, stage, role, modelId, limit = 200 } = {}) { + let lines = []; + try { lines = readFileSync(this.path, 'utf8').split(/\r?\n/).filter(Boolean); } catch { return []; } + const events = []; + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.taskId !== this.taskId) continue; + if (type && event.type !== type) continue; + if (stage && event.stage !== stage) continue; + if (role && event.role !== role) continue; + if (modelId && event.modelId !== modelId) continue; + events.push(event); + } catch {} + } + return events.slice(-Math.max(1, Math.min(limit, 5000))); + } +} + +export function writeJsonAtomic(path, value) { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} From 63a2d440992ce56c6a96860380757cc7cf439714 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:41:15 -0400 Subject: [PATCH 003/105] feat: persist task budget ledgers --- lib/budget-store.mjs | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 lib/budget-store.mjs diff --git a/lib/budget-store.mjs b/lib/budget-store.mjs new file mode 100644 index 0000000..9f0c7ad --- /dev/null +++ b/lib/budget-store.mjs @@ -0,0 +1,53 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +export const BUDGET_STORE_VERSION = 1; + +export function defaultBudgetStatePath(cwd = process.cwd()) { + return resolve(cwd, '.opencode', 'agent-loop-state', 'budgets.json'); +} + +function sanitizeLedger(taskId, ledger) { + if (!taskId || !ledger || typeof ledger !== 'object') return null; + return { + taskId, + createdAtMs: Number(ledger.createdAtMs) || Date.now(), + updatedAtMs: Number(ledger.updatedAtMs) || Date.now(), + used: ledger.used || {}, + steps: ledger.steps || {}, + models: ledger.models || {}, + workflowCalls: Number(ledger.workflowCalls) || 0, + workflowStages: ledger.workflowStages || {}, + exceeded: ledger.exceeded === true, + exceededReasons: Array.isArray(ledger.exceededReasons) ? ledger.exceededReasons : [], + unknownPricingModels: Array.isArray(ledger.unknownPricingModels) ? ledger.unknownPricingModels : [] + }; +} + +export function loadBudgetState(path = defaultBudgetStatePath()) { + if (!existsSync(path)) return new Map(); + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + if (parsed.version !== BUDGET_STORE_VERSION || typeof parsed.ledgers !== 'object') return new Map(); + const entries = []; + for (const [taskId, ledger] of Object.entries(parsed.ledgers)) { + const sanitized = sanitizeLedger(taskId, ledger); + if (sanitized) entries.push([taskId, sanitized]); + } + return new Map(entries); + } catch { + return new Map(); + } +} + +export function saveBudgetState(ledgers, path = defaultBudgetStatePath()) { + mkdirSync(dirname(path), { recursive: true }); + const payload = { + version: BUDGET_STORE_VERSION, + savedAt: new Date().toISOString(), + ledgers: Object.fromEntries([...ledgers.entries()].map(([taskId, ledger]) => [taskId, sanitizeLedger(taskId, ledger)])) + }; + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} From 43549f8c5aa878d010d134bc76dce268e5920d16 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:41:27 -0400 Subject: [PATCH 004/105] feat: define structured event schema --- config/agent-loop-event.schema.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 config/agent-loop-event.schema.json diff --git a/config/agent-loop-event.schema.json b/config/agent-loop-event.schema.json new file mode 100644 index 0000000..4a221f3 --- /dev/null +++ b/config/agent-loop-event.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wryan2986/opencode-agent-loop/schemas/agent-loop-event-1.0.0.json", + "title": "OpenCode Agent Loop Event", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "eventId", "timestamp", "type", "taskId", "data"], + "properties": { + "schemaVersion": { "const": "1.0.0" }, + "eventId": { "type": "string", "minLength": 8 }, + "timestamp": { "type": "string", "format": "date-time" }, + "type": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "taskId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "stage": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "modelId": { "type": "string", "minLength": 1 }, + "data": { "type": "object" } + } +} From 2f5b5b251b03a80d3aa3bb96f92280376fe253a9 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:41:42 -0400 Subject: [PATCH 005/105] feat: add event log query utility --- scripts/query-events.mjs | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scripts/query-events.mjs diff --git a/scripts/query-events.mjs b/scripts/query-events.mjs new file mode 100644 index 0000000..b4c9cc4 --- /dev/null +++ b/scripts/query-events.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defaultEventLogPath } from '../lib/event-log.mjs'; + +const args = process.argv.slice(2); +const options = {}; +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') options.help = true; + else if (arg.startsWith('--')) options[arg.slice(2)] = args[index + 1], index += 1; +} + +if (options.help) { + console.log(`Usage: node scripts/query-events.mjs [options]\n\nOptions:\n --file PATH\n --task TASK_ID\n --type EVENT_TYPE\n --stage STAGE\n --role ROLE\n --model MODEL_ID\n --limit N\n --format json|jsonl|summary`); + process.exit(0); +} + +const path = resolve(options.file || defaultEventLogPath()); +let lines; +try { lines = readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean); } +catch (error) { + console.error(`Unable to read event log ${path}: ${error.message}`); + process.exit(1); +} + +const events = []; +for (const line of lines) { + try { + const event = JSON.parse(line); + if (options.task && event.taskId !== options.task) continue; + if (options.type && event.type !== options.type) continue; + if (options.stage && event.stage !== options.stage) continue; + if (options.role && event.role !== options.role) continue; + if (options.model && event.modelId !== options.model) continue; + events.push(event); + } catch {} +} + +const limit = Math.max(1, Math.min(Number(options.limit) || 200, 5000)); +const selected = events.slice(-limit); +const format = options.format || 'summary'; +if (format === 'json') console.log(JSON.stringify(selected, null, 2)); +else if (format === 'jsonl') for (const event of selected) console.log(JSON.stringify(event)); +else for (const event of selected) console.log(`${event.timestamp} ${event.taskId} ${event.type}${event.stage ? ` stage=${event.stage}` : ''}${event.modelId ? ` model=${event.modelId}` : ''}`); From d1f1a51ba214c046b105f0fb0a63f2d312d423b5 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:41:50 -0400 Subject: [PATCH 006/105] docs: add custom provider adapter example --- examples/provider-adapter.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/provider-adapter.mjs diff --git a/examples/provider-adapter.mjs b/examples/provider-adapter.mjs new file mode 100644 index 0000000..2e6642b --- /dev/null +++ b/examples/provider-adapter.mjs @@ -0,0 +1,18 @@ +import { ProviderAdapter, registerProviderAdapter } from '../lib/provider-adapters.mjs'; + +class ExampleProviderAdapter extends ProviderAdapter { + constructor() { + super({ id: 'example', aliases: ['example-cloud'], timeoutKey: 'example' }); + } + + normalizeError(result) { + const normalized = super.normalizeError(result); + if (/daily allowance/i.test(normalized.message)) { + normalized.statusCode = 429; + normalized.code = 'DAILY_QUOTA_EXHAUSTED'; + } + return normalized; + } +} + +registerProviderAdapter(new ExampleProviderAdapter()); From 09f46a2efef1d70b9af275dd44def84fe4a86522 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:42:03 -0400 Subject: [PATCH 007/105] docs: document provider adapters --- docs/provider-adapters.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/provider-adapters.md diff --git a/docs/provider-adapters.md b/docs/provider-adapters.md new file mode 100644 index 0000000..64f37ca --- /dev/null +++ b/docs/provider-adapters.md @@ -0,0 +1,39 @@ +# Provider adapters + +Provider-specific behavior is isolated behind `ProviderAdapter` implementations in `lib/provider-adapters.mjs`. + +An adapter provides: + +- provider identity and aliases +- local/unmetered classification +- timeout configuration key +- provider-error normalization +- model-ID matching + +Built-in adapters cover OpenCode, OpenCode Go, NVIDIA, Cerebras, Groq, OpenRouter, OpenAI, and local/Ollama model IDs. + +## Custom adapter + +Create a subclass and register it before running workers: + +```js +import { ProviderAdapter, registerProviderAdapter } from './lib/provider-adapters.mjs'; + +class AcmeAdapter extends ProviderAdapter { + constructor() { + super({ id: 'acme', aliases: ['acme-ai'], timeoutKey: 'acme' }); + } + + normalizeError(result) { + const normalized = super.normalizeError(result); + // Add provider-specific status and quota mappings here. + return normalized; + } +} + +registerProviderAdapter(new AcmeAdapter()); +``` + +See `examples/provider-adapter.mjs` for a complete example. + +Add the provider's timeout to `provider_timeouts_ms` and `smoke_test_provider_timeout_ms`. Model entries remain in `config/model-registry.json`; role order remains in `config/free-first-pools.json`. From 98a4a85711c070fb442336e4652d287e2511b20b Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:42:13 -0400 Subject: [PATCH 008/105] docs: document structured event logging --- docs/event-logging.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/event-logging.md diff --git a/docs/event-logging.md b/docs/event-logging.md new file mode 100644 index 0000000..f24307e --- /dev/null +++ b/docs/event-logging.md @@ -0,0 +1,31 @@ +# Structured event logging + +The runtime writes append-only JSON Lines events to: + +```text +/.opencode/agent-loop-state/events.jsonl +``` + +Each event conforms to `config/agent-loop-event.schema.json` and includes: + +- schema version and unique event ID +- timestamp and task ID +- event type +- optional stage, role, and model ID +- redacted structured data + +Typical event types include `workflow.started`, `stage.started`, `model.attempt.started`, `model.attempt.completed`, `budget.updated`, `budget.exceeded`, `provider.cooldown`, `stage.completed`, and `workflow.completed`. + +## Querying + +```bash +node scripts/query-events.mjs --task feature-auth-20260722 --format summary +node scripts/query-events.mjs --type budget.exceeded --format json +node scripts/query-events.mjs --model opencode/deepseek-v4-flash-free --limit 50 +``` + +The event stream is intended for debugging, audit history, budget recovery, performance analysis, and later dashboard integrations. It is local runtime state and must not be committed. + +## Redaction + +The logger recursively redacts fields with credential-like names and common token, bearer, private-key, and environment-secret formats. Redaction is defense in depth, not permission to log secrets deliberately. Worker prompts, environment files, credentials, and production data should never be emitted. From 217f521998618e95dd4461c7a9129e71f7665342 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:42:31 -0400 Subject: [PATCH 009/105] docs: add platform support guide --- docs/platforms.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/platforms.md diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..d79cab6 --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,38 @@ +# Platform support + +The runtime is written in Node.js and is tested on Linux, macOS, and Windows. Shell-based installation and agent validation still require a POSIX-compatible shell. + +## Linux + +Linux is the primary supported environment. Run the standard installer: + +```bash +bash scripts/install.sh +``` + +## macOS + +Install Node.js 18 or newer and a patched OpenCode build, then run the standard installer from Terminal. Both Intel and Apple Silicon use the same package files; the OpenCode executable itself must match the machine architecture. + +## Windows + +Supported workflows are: + +1. **WSL 2** — recommended; follow the Linux instructions inside WSL. +2. **Git Bash** — supported for installation and shell validation when Node and the patched OpenCode executable are available on `PATH`. +3. **Native PowerShell/cmd** — the Node runtime and tests are portable, but `scripts/install.sh` and Bash validation scripts are not native PowerShell installers. + +Do not mix Windows and WSL configuration directories. Install and run OpenCode in the same environment where the agent loop is installed. + +## Verification + +On every platform: + +```bash +node --version +npm ci +npm test +npm run validate:portable +``` + +The cross-platform CI matrix runs the portable test suite on Ubuntu, macOS, and Windows. Linux CI additionally runs Bash permission and installation-oriented checks. From 63997c5fbebb59e0d552713877d0a8d214410122 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:15 -0400 Subject: [PATCH 010/105] docs: expand runtime troubleshooting --- docs/troubleshooting.md | 96 ++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dcaed63..5420980 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,78 +1,74 @@ # Troubleshooting -## Common Issues +## All providers are unresponsive -### "All providers unresponsive" +1. Run `opencode models` and confirm the configured model IDs exist. +2. Verify network connectivity and provider credentials. +3. Check runtime cooldown state under `.opencode/agent-loop-state/`. +4. Query recent provider and model-attempt events. +5. Wait for the configured cooldown or select a different provider. -**Cause**: No model provider responded to the smoke test. +```bash +node scripts/query-events.mjs --task --format summary +``` -**Solutions**: -1. Check your OpenCode installation: `opencode models` -2. Verify network connectivity -3. Check if models are in cooldown: check `config/free-first-pools-state.json` -4. Wait for cooldown to expire (configurable in `free-first-config.json`) -5. Try a different model provider +## A task stops with `BUDGET_EXCEEDED` -### "/feature does nothing" +Do not restart the request under a new task ID. Inspect the returned budget snapshot and event stream: -**Cause**: The /feature command requires the TUI (interactive mode). +```bash +node scripts/query-events.mjs --task --type budget.exceeded --format json +``` -**Solutions**: -1. Run `opencode` (without arguments) to enter TUI mode -2. Type `/feature ` in the chat -3. Or use `/loop ` for the agent_loop tool path +Raise a budget only after reviewing why the task consumed it. Persistent budget state lives under `.opencode/agent-loop-state/` and survives plugin restarts. -### "Tests fail with no output" +## `/feature` does nothing -**Cause**: Missing test dependencies or incorrect test configuration. +The slash command requires OpenCode's interactive TUI. Start `opencode`, then enter `/feature `. Use `/loop ` for direct access to the custom tool. -**Solutions**: -1. Run `npm install` to ensure dependencies are installed -2. Check the project's `AGENTS.md` for test commands -3. Run `bash scripts/validate.sh` to check your setup +## Tests fail with no output -### "Model X not found" +1. Run `npm ci` in this package. +2. Read the target project's `AGENTS.md` for its exact commands. +3. Run `npm run validate` in the agent-loop package. +4. Inspect the attempt log and structured event log. -**Cause**: The model might not be available in your OpenCode installation. +## A model is not found -**Solutions**: -1. Run `opencode models` to list available models -2. Check `config/free-first-pools.json` for enabled models -3. Check `config/model-registry.json` for the model entry +1. Run `opencode models`. +2. Check `config/free-first-pools.json` for the role. +3. Check `config/model-registry.json` for retirement or disabled status. +4. Confirm the provider adapter recognizes the model prefix. -### "Commit blocked by pre-existing changes" +## A local model times out too quickly -**Cause**: There are unstaged or uncommitted changes in the working tree. +Local and Ollama IDs use the `local` timeout key, including IDs without a slash such as `ollama-9b-local`. Adjust `general.local_model_request_timeout_seconds` or `provider_timeouts_ms.local`. -**Solutions**: -1. Commit or stash your changes before running /feature -2. The orchestrator will not discard unrelated changes +## Retry behavior is unexpected -## Debugging +`maxRetries` controls retries of the same model for transient failures. Provider failover is separate. Authentication, billing, safety, invalid-request, task-quality, and budget failures are terminal and are not retried. -### Enable verbose logging +## A commit is blocked by pre-existing changes -Agent loop logs are written to `.opencode/agent-loop-logs/`. Check these files for detailed execution traces: +The orchestrator preserves unrelated changes. Commit or stash them yourself, or ensure the intended feature diff can be staged independently. The loop must not discard unrelated work. -```bash -ls .opencode/agent-loop-logs/ -``` +## The OpenCode patch no longer applies -### Run individual steps +Run the OpenCode compatibility workflow manually. It clones the configured upstream revision, checks the patch, installs Bun dependencies, builds a standalone binary, and runs the compatibility probe. A clean `git apply` is necessary but not sufficient; build and behavior checks must pass. -You can test individual agent roles with the agent_loop tool: +## Windows path or shell errors -```bash -# Test model availability -opencode run "smoke test" +Use WSL 2 or Git Bash for installation. The Node runtime is tested natively on Windows, but Bash installation and permission-validation scripts require a POSIX-compatible shell. See [Platform Support](platforms.md). -# Run just the test step -opencode run "test the implementation" -``` +## Logs and sensitive material + +Runtime logging recursively redacts credential-like fields and common token, bearer, environment-secret, and private-key formats. This is defense in depth. Never place credentials or production data in prompts, and review logs before sharing them. -### Validate configuration +Attempt logs are written under `.opencode/agent-loop-logs/`. Structured events and persistent budgets are under `.opencode/agent-loop-state/`. + +## Validate the installation ```bash -bash scripts/validate.sh -bash scripts/validate-agent-configs.sh -``` \ No newline at end of file +npm ci +npm run validate +``` From d27f09f71b3de6931c04b384c1fbc5dafe966f8d Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:41 -0400 Subject: [PATCH 011/105] test: cover v0.2 reliability features --- tests/reliability-v020-tests.mjs | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/reliability-v020-tests.mjs diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs new file mode 100644 index 0000000..ba50e32 --- /dev/null +++ b/tests/reliability-v020-tests.mjs @@ -0,0 +1,68 @@ +import { strict as assert } from 'node:assert'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { deriveProviderFromModel, providerTimeoutKey, ProviderAdapter, registerProviderAdapter, resolveProviderAdapter } from '../lib/provider-adapters.mjs'; +import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; +import { loadBudgetState, saveBudgetState } from '../lib/budget-store.mjs'; +import { resolveTimeoutMs } from '../runtime/opencode-worker-runner.mjs'; + +function testLocalProviderResolution() { + assert.equal(deriveProviderFromModel('ollama-9b-local'), 'local'); + assert.equal(providerTimeoutKey('ollama-9b-local'), 'local'); + assert.equal(resolveTimeoutMs({ model: 'ollama-9b-local', providerTimeouts: { local: 420000, default: 30000 } }), 420000); +} + +function testCustomProviderAdapter() { + registerProviderAdapter(new ProviderAdapter({ id: 'acme', aliases: ['acme-cloud'], timeoutKey: 'acme-slow' })); + assert.equal(resolveProviderAdapter('acme/model').id, 'acme'); + assert.equal(resolveProviderAdapter('anything/model', 'acme-cloud').timeoutKey, 'acme-slow'); +} + +function testRecursiveRedaction() { + const redacted = redactSensitive({ + authorization: 'Bearer secret-value', + nested: { apiKey: 'abc123', harmless: 'ok', output: 'TOKEN=do-not-log' }, + values: ['github_pat_abcdefghijklmnopqrstuvwxyz123456'] + }); + assert.equal(redacted.authorization, '[REDACTED]'); + assert.equal(redacted.nested.apiKey, '[REDACTED]'); + assert.equal(redacted.nested.harmless, 'ok'); + assert.doesNotMatch(redacted.nested.output, /do-not-log/); + assert.equal(redacted.values[0], '[REDACTED]'); +} + +function testStructuredEvents() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-events-')); + const path = resolve(dir, 'events.jsonl'); + const logger = new AgentLoopEventLogger({ taskId: 'task-a', path }); + logger.emit('stage.started', { stage: 'build', data: { token: 'secret', ok: true } }); + const events = logger.query({ stage: 'build' }); + assert.equal(events.length, 1); + assert.equal(events[0].schemaVersion, '1.0.0'); + assert.equal(events[0].data.token, '[REDACTED]'); + assert.equal(JSON.parse(readFileSync(path, 'utf8').trim()).type, 'stage.started'); + rmSync(dir, { recursive: true, force: true }); +} + +function testPersistentBudgetStore() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-budget-store-')); + const path = resolve(dir, 'budgets.json'); + const ledgers = new Map([['task-a', { + taskId: 'task-a', createdAtMs: 1, updatedAtMs: 2, + used: { total: 25 }, steps: {}, models: {}, workflowCalls: 3, + workflowStages: { build: 1, test: 1, review: 1 }, exceeded: false, + exceededReasons: [], unknownPricingModels: [] + }]]); + saveBudgetState(ledgers, path); + const loaded = loadBudgetState(path); + assert.equal(loaded.get('task-a').used.total, 25); + assert.equal(loaded.get('task-a').workflowCalls, 3); + rmSync(dir, { recursive: true, force: true }); +} + +for (const test of [testLocalProviderResolution, testCustomProviderAdapter, testRecursiveRedaction, testStructuredEvents, testPersistentBudgetStore]) { + test(); + console.log(`OK: ${test.name}`); +} +console.log('reliability-v020-tests: passed'); From f7bd4b5537625ae38224bf6a11631992e7c52c46 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:59 -0400 Subject: [PATCH 012/105] ci: add OpenCode patch compatibility workflow --- .github/workflows/opencode-patch-compat.yml | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/opencode-patch-compat.yml diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml new file mode 100644 index 0000000..822f7a1 --- /dev/null +++ b/.github/workflows/opencode-patch-compat.yml @@ -0,0 +1,69 @@ +name: OpenCode patch compatibility + +on: + workflow_dispatch: + inputs: + opencode_ref: + description: OpenCode branch, tag, or commit to test + required: false + default: dev + schedule: + - cron: '23 6 * * 1' + +permissions: + contents: read + +jobs: + compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + OPENCODE_REF: ${{ inputs.opencode_ref || 'dev' }} + steps: + - name: Check out agent loop + uses: actions/checkout@v4 + + - name: Check out OpenCode + uses: actions/checkout@v4 + with: + repository: anomalyco/opencode + ref: ${{ env.OPENCODE_REF }} + path: upstream-opencode + fetch-depth: 1 + + - name: Verify patch applies + working-directory: upstream-opencode + run: git apply --check ../patches/opencode-subagent-failure-exposure.patch + + - name: Apply patch + working-directory: upstream-opencode + run: git apply ../patches/opencode-subagent-failure-exposure.patch + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + + - name: Install upstream dependencies + working-directory: upstream-opencode + run: bun install --frozen-lockfile + + - name: Build standalone OpenCode + working-directory: upstream-opencode + run: bun ./packages/opencode/script/build.ts --single + + - name: Verify patched source contracts + run: node scripts/check-opencode-patch-contract.mjs upstream-opencode + + - name: Record tested revision + if: always() + run: | + git -C upstream-opencode rev-parse HEAD > opencode-tested-revision.txt + printf 'ref=%s\n' "$OPENCODE_REF" >> opencode-tested-revision.txt + + - name: Upload compatibility evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-patch-compatibility + path: opencode-tested-revision.txt From 7bba5b2a3bc7125c369499c84f1d7cdcb78106d1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:44:21 -0400 Subject: [PATCH 013/105] ci: verify patched OpenCode contracts --- scripts/check-opencode-patch-contract.mjs | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 scripts/check-opencode-patch-contract.mjs diff --git a/scripts/check-opencode-patch-contract.mjs b/scripts/check-opencode-patch-contract.mjs new file mode 100644 index 0000000..959010a --- /dev/null +++ b/scripts/check-opencode-patch-contract.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(process.argv[2] || 'upstream-opencode'); +const checks = [ + ['packages/opencode/src/cli/cmd/run.ts', ['enhanceErrorForJson', 'classification', 'retryAfterMs', 'providerID', 'modelID']], + ['packages/opencode/src/session/message-v2.ts', ['providerID', 'modelID']], + ['packages/opencode/src/session/processor.ts', ['taskFailure']], + ['packages/opencode/src/session/prompt.ts', ['taskFailure']], + ['packages/opencode/test/cli/run/error-metadata.test.ts', ['rate-limit', 'retryAfterMs']] +]; + +let failed = false; +for (const [relative, markers] of checks) { + const path = resolve(root, relative); + if (!existsSync(path)) { + console.error(`FAIL: missing patched file ${relative}`); + failed = true; + continue; + } + const content = readFileSync(path, 'utf8'); + for (const marker of markers) { + if (!content.includes(marker)) { + console.error(`FAIL: ${relative} missing marker ${marker}`); + failed = true; + } + } + if (!failed) console.log(`OK: ${relative}`); +} + +if (failed) process.exit(1); +console.log('OpenCode patch contract verified.'); From 72ea8ff063233e412d1abc0e3bd2b9681a36d2a3 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:44:30 -0400 Subject: [PATCH 014/105] ci: test portable runtime across operating systems --- .github/workflows/cross-platform.yml | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/cross-platform.yml diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml new file mode 100644 index 0000000..ba2150a --- /dev/null +++ b/.github/workflows/cross-platform.yml @@ -0,0 +1,39 @@ +name: Cross-platform runtime + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [20, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run validate:portable + + bash-installation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run validate From fc6e0e6ca5c5312ec7cbceb6a3564f9a0d223946 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:45:56 -0400 Subject: [PATCH 015/105] feat: persist budgets and cap workflow calls --- lib/budget-manager.mjs | 242 +++++++++++++++++++++-------------------- 1 file changed, 126 insertions(+), 116 deletions(-) diff --git a/lib/budget-manager.mjs b/lib/budget-manager.mjs index 569b12a..dd29aa9 100644 --- a/lib/budget-manager.mjs +++ b/lib/budget-manager.mjs @@ -1,10 +1,12 @@ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defaultBudgetStatePath, loadBudgetState, saveBudgetState } from './budget-store.mjs'; -const ledgers = new Map(); - +const stores = new Map(); const TOKEN_FIELDS = ['input', 'output', 'reasoning', 'cacheRead', 'cacheWrite']; const DEFAULT_LEDGER_TTL_MINUTES = 24 * 60; const DEFAULT_MAX_TRACKED_TASKS = 1000; +const DEFAULT_MAX_WORKFLOW_CALLS = 12; function finiteNonNegative(value, fallback = 0) { const number = Number(value); @@ -36,6 +38,10 @@ function emptyUsage() { }; } +function normalizeStoredUsage(value = {}) { + return { ...emptyUsage(), ...Object.fromEntries(Object.entries(value).map(([key, item]) => [key, finiteNonNegative(item)])) }; +} + export function normalizeTokenUsage(raw = {}) { const cache = raw.cache || {}; const usage = { @@ -50,11 +56,8 @@ export function normalizeTokenUsage(raw = {}) { } function loadJson(path, fallback) { - try { - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return fallback; - } + try { return JSON.parse(readFileSync(path, 'utf8')); } + catch { return fallback; } } function modelMap(registry = {}) { @@ -123,103 +126,111 @@ export function estimateUsageCost({ modelId, usage, registry = {}, unknownPricin const normalized = normalizeTokenUsage(usage); const model = modelMap(registry).get(modelId); const pricing = normalizedPricing(model, unknownPricing); - const perMillion = 1_000_000; const estimatedCostUsd = ( normalized.input * pricing.input_per_million_usd + normalized.output * pricing.output_per_million_usd + normalized.reasoning * pricing.reasoning_per_million_usd + normalized.cacheRead * pricing.cache_read_per_million_usd + normalized.cacheWrite * pricing.cache_write_per_million_usd - ) / perMillion; - - return { - estimatedCostUsd, - pricing, - pricingKnown: pricing.source !== 'unknown-model-fallback', - modelFound: Boolean(model) - }; + ) / 1_000_000; + return { estimatedCostUsd, pricing, pricingKnown: pricing.source !== 'unknown-model-fallback', modelFound: Boolean(model) }; } -function budgetSettings(config = {}) { +function budgetSettings(config = {}, cwd = process.cwd()) { const raw = config.budgets || {}; - const ttlMinutes = positiveInteger(raw.ledger_ttl_minutes, DEFAULT_LEDGER_TTL_MINUTES); + const configuredPath = raw.state_path || '.opencode/agent-loop-state/budgets.json'; return { enabled: raw.enabled !== false, maxTokens: optionalLimit(raw.max_tokens_per_task), maxInputTokens: optionalLimit(raw.max_input_tokens_per_task), maxOutputTokens: optionalLimit(raw.max_output_tokens_per_task), maxCostUsd: optionalLimit(raw.max_cost_usd_per_task), + maxWorkflowCalls: optionalLimit(raw.max_workflow_calls_per_task) ?? DEFAULT_MAX_WORKFLOW_CALLS, unknownPricing: raw.unknown_paid_model_pricing || {}, failClosedOnUnknownPricing: raw.fail_closed_on_unknown_pricing === true, - ledgerTtlMs: ttlMinutes * 60 * 1000, - maxTrackedTasks: positiveInteger(raw.max_tracked_tasks, DEFAULT_MAX_TRACKED_TASKS) + ledgerTtlMs: positiveInteger(raw.ledger_ttl_minutes, DEFAULT_LEDGER_TTL_MINUTES) * 60 * 1000, + maxTrackedTasks: positiveInteger(raw.max_tracked_tasks, DEFAULT_MAX_TRACKED_TASKS), + persistenceEnabled: raw.persist_state !== false, + statePath: process.env.AGENT_LOOP_BUDGET_STATE_PATH || resolve(cwd, configuredPath) + }; +} + +function normalizeLedger(taskId, ledger = {}) { + const normalizeGroups = groups => Object.fromEntries(Object.entries(groups || {}).map(([key, usage]) => [key, normalizeStoredUsage(usage)])); + return { + taskId, + createdAtMs: Number(ledger.createdAtMs) || Date.now(), + updatedAtMs: Number(ledger.updatedAtMs) || Date.now(), + used: normalizeStoredUsage(ledger.used), + steps: normalizeGroups(ledger.steps), + models: normalizeGroups(ledger.models), + workflowCalls: finiteNonNegative(ledger.workflowCalls), + workflowStages: Object.fromEntries(Object.entries(ledger.workflowStages || {}).map(([key, value]) => [key, finiteNonNegative(value)])), + exceeded: ledger.exceeded === true, + exceededReasons: Array.isArray(ledger.exceededReasons) ? ledger.exceededReasons : [], + unknownPricingModels: Array.isArray(ledger.unknownPricingModels) ? ledger.unknownPricingModels : [] }; } +function getStore(settings) { + const key = settings.persistenceEnabled ? settings.statePath : ':memory:'; + if (!stores.has(key)) { + const loaded = settings.persistenceEnabled ? loadBudgetState(settings.statePath) : new Map(); + stores.set(key, new Map([...loaded].map(([taskId, ledger]) => [taskId, normalizeLedger(taskId, ledger)]))); + } + return stores.get(key); +} + +function persist(settings, store) { + if (settings.persistenceEnabled) saveBudgetState(store, settings.statePath); +} + export function pruneBudgetLedgers({ now = Date.now(), maxAgeMs = DEFAULT_LEDGER_TTL_MINUTES * 60 * 1000, maxEntries = DEFAULT_MAX_TRACKED_TASKS, - preserveTaskId = null + preserveTaskId = null, + statePath = null } = {}) { - const effectiveAge = Number.isFinite(maxAgeMs) && maxAgeMs >= 0 ? maxAgeMs : DEFAULT_LEDGER_TTL_MINUTES * 60 * 1000; - const effectiveMax = positiveInteger(maxEntries, DEFAULT_MAX_TRACKED_TASKS); + const selected = statePath ? [[statePath, stores.get(statePath)]] : [...stores.entries()]; let expired = 0; let overflow = 0; - - for (const [taskId, ledger] of ledgers) { - if (taskId === preserveTaskId) continue; - if (now - ledger.updatedAtMs > effectiveAge) { - ledgers.delete(taskId); - expired += 1; + let remaining = 0; + for (const [, store] of selected) { + if (!store) continue; + for (const [taskId, ledger] of store) { + if (taskId !== preserveTaskId && now - ledger.updatedAtMs > maxAgeMs) { + store.delete(taskId); + expired += 1; + } } - } - - if (ledgers.size > effectiveMax) { - const oldest = [...ledgers.entries()] - .filter(([taskId]) => taskId !== preserveTaskId) - .sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); - while (ledgers.size > effectiveMax && oldest.length > 0) { - const [taskId] = oldest.shift(); - if (ledgers.delete(taskId)) overflow += 1; + if (store.size > maxEntries) { + const oldest = [...store.entries()].filter(([taskId]) => taskId !== preserveTaskId).sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); + while (store.size > maxEntries && oldest.length) { + const [taskId] = oldest.shift(); + if (store.delete(taskId)) overflow += 1; + } } + remaining += store.size; } - - return { expired, overflow, remaining: ledgers.size }; + return { expired, overflow, remaining }; } function ensureLedger(taskId, settings) { + const store = getStore(settings); const now = Date.now(); - pruneBudgetLedgers({ - now, - maxAgeMs: settings.ledgerTtlMs, - maxEntries: settings.maxTrackedTasks, - preserveTaskId: taskId - }); - - if (!ledgers.has(taskId)) { - ledgers.set(taskId, { - taskId, - createdAtMs: now, - updatedAtMs: now, - used: emptyUsage(), - steps: {}, - models: {}, - exceeded: false, - exceededReasons: [], - unknownPricingModels: [] - }); + for (const [id, ledger] of store) { + if (id !== taskId && now - ledger.updatedAtMs > settings.ledgerTtlMs) store.delete(id); } - - const ledger = ledgers.get(taskId); + if (!store.has(taskId)) store.set(taskId, normalizeLedger(taskId)); + const ledger = store.get(taskId); ledger.updatedAtMs = now; - pruneBudgetLedgers({ - now, - maxAgeMs: settings.ledgerTtlMs, - maxEntries: settings.maxTrackedTasks, - preserveTaskId: taskId - }); - return ledger; + if (store.size > settings.maxTrackedTasks) { + const oldest = [...store.entries()].filter(([id]) => id !== taskId).sort((a, b) => a[1].updatedAtMs - b[1].updatedAtMs); + while (store.size > settings.maxTrackedTasks && oldest.length) store.delete(oldest.shift()[0]); + } + persist(settings, store); + return { ledger, store }; } function addUsage(target, usage, costs) { @@ -235,43 +246,46 @@ function remaining(limit, used) { } export class BudgetTracker { - constructor({ taskId, step = 'unknown', config = {}, registry = {} } = {}) { + constructor({ taskId, step = 'unknown', config = {}, registry = {}, cwd = process.cwd() } = {}) { if (!taskId) throw new Error('BudgetTracker requires taskId'); this.taskId = taskId; this.step = step; - this.settings = budgetSettings(config); + this.settings = budgetSettings(config, cwd); this.registry = registry; - this.ledger = ensureLedger(taskId, this.settings); + const ensured = ensureLedger(taskId, this.settings); + this.ledger = ensured.ledger; + this.store = ensured.store; } - static fromFiles({ taskId, step, configPath, registryPath } = {}) { - const config = loadJson(configPath, {}); - const registry = loadJson(registryPath, { models: [] }); - return new BudgetTracker({ taskId, step, config, registry }); + static fromFiles({ taskId, step, configPath, registryPath, cwd = process.cwd() } = {}) { + return new BudgetTracker({ + taskId, + step, + config: loadJson(configPath, {}), + registry: loadJson(registryPath, { models: [] }), + cwd + }); } canContinue() { return !this.settings.enabled || !this.ledger.exceeded; } + recordWorkflowCall(stage = this.step) { + this.ledger.workflowCalls += 1; + this.ledger.workflowStages[stage || 'unknown'] = (this.ledger.workflowStages[stage || 'unknown'] || 0) + 1; + this.ledger.updatedAtMs = Date.now(); + this.evaluate(); + persist(this.settings, this.store); + return this.snapshot(); + } + recordUsage({ modelId, usage, reportedCostUsd = 0, step = this.step } = {}) { const normalized = normalizeTokenUsage(usage); if (normalized.total === 0 && finiteNonNegative(reportedCostUsd) === 0) return this.snapshot(); - - const estimate = estimateUsageCost({ - modelId, - usage: normalized, - registry: this.registry, - unknownPricing: this.settings.unknownPricing - }); + const estimate = estimateUsageCost({ modelId, usage: normalized, registry: this.registry, unknownPricing: this.settings.unknownPricing }); const reported = finiteNonNegative(reportedCostUsd); - const billable = Math.max(estimate.estimatedCostUsd, reported); - const costs = { - estimatedCostUsd: estimate.estimatedCostUsd, - reportedCostUsd: reported, - billableCostUsd: billable - }; - + const costs = { estimatedCostUsd: estimate.estimatedCostUsd, reportedCostUsd: reported, billableCostUsd: Math.max(estimate.estimatedCostUsd, reported) }; const stepKey = step || 'unknown'; const modelKey = modelId || 'unknown'; this.ledger.steps[stepKey] ||= emptyUsage(); @@ -280,12 +294,9 @@ export class BudgetTracker { addUsage(this.ledger.steps[stepKey], normalized, costs); addUsage(this.ledger.models[modelKey], normalized, costs); this.ledger.updatedAtMs = Date.now(); - - if (!estimate.pricingKnown && !this.ledger.unknownPricingModels.includes(modelKey)) { - this.ledger.unknownPricingModels.push(modelKey); - } - + if (!estimate.pricingKnown && !this.ledger.unknownPricingModels.includes(modelKey)) this.ledger.unknownPricingModels.push(modelKey); this.evaluate(); + persist(this.settings, this.store); return this.snapshot(); } @@ -293,22 +304,13 @@ export class BudgetTracker { if (!this.settings.enabled) return; const reasons = []; const used = this.ledger.used; - if (this.settings.maxTokens !== null && used.total > this.settings.maxTokens) { - reasons.push(`total token budget exceeded (${used.total} > ${this.settings.maxTokens})`); - } - if (this.settings.maxInputTokens !== null && used.input > this.settings.maxInputTokens) { - reasons.push(`input token budget exceeded (${used.input} > ${this.settings.maxInputTokens})`); - } - if (this.settings.maxOutputTokens !== null && used.output + used.reasoning > this.settings.maxOutputTokens) { - reasons.push(`output token budget exceeded (${used.output + used.reasoning} > ${this.settings.maxOutputTokens})`); - } - if (this.settings.maxCostUsd !== null && used.billableCostUsd > this.settings.maxCostUsd) { - reasons.push(`cost budget exceeded ($${used.billableCostUsd.toFixed(6)} > $${this.settings.maxCostUsd.toFixed(6)})`); - } - if (this.settings.failClosedOnUnknownPricing && this.ledger.unknownPricingModels.length > 0) { - reasons.push(`pricing unavailable for: ${this.ledger.unknownPricingModels.join(', ')}`); - } - if (reasons.length > 0) { + if (this.settings.maxTokens !== null && used.total > this.settings.maxTokens) reasons.push(`total token budget exceeded (${used.total} > ${this.settings.maxTokens})`); + if (this.settings.maxInputTokens !== null && used.input > this.settings.maxInputTokens) reasons.push(`input token budget exceeded (${used.input} > ${this.settings.maxInputTokens})`); + if (this.settings.maxOutputTokens !== null && used.output + used.reasoning > this.settings.maxOutputTokens) reasons.push(`output token budget exceeded (${used.output + used.reasoning} > ${this.settings.maxOutputTokens})`); + if (this.settings.maxCostUsd !== null && used.billableCostUsd > this.settings.maxCostUsd) reasons.push(`cost budget exceeded ($${used.billableCostUsd.toFixed(6)} > $${this.settings.maxCostUsd.toFixed(6)})`); + if (this.settings.maxWorkflowCalls !== null && this.ledger.workflowCalls > this.settings.maxWorkflowCalls) reasons.push(`workflow call budget exceeded (${this.ledger.workflowCalls} > ${this.settings.maxWorkflowCalls})`); + if (this.settings.failClosedOnUnknownPricing && this.ledger.unknownPricingModels.length) reasons.push(`pricing unavailable for: ${this.ledger.unknownPricingModels.join(', ')}`); + if (reasons.length) { this.ledger.exceeded = true; this.ledger.exceededReasons = [...new Set([...this.ledger.exceededReasons, ...reasons])]; } @@ -320,11 +322,14 @@ export class BudgetTracker { maxTokens: this.settings.maxTokens, maxInputTokens: this.settings.maxInputTokens, maxOutputTokens: this.settings.maxOutputTokens, - maxCostUsd: this.settings.maxCostUsd + maxCostUsd: this.settings.maxCostUsd, + maxWorkflowCalls: this.settings.maxWorkflowCalls }; return { enabled: this.settings.enabled, - scope: 'delegated-workers', + scope: 'workflow-calls-and-delegated-workers', + parentModelUsageIncluded: false, + persistent: this.settings.persistenceEnabled, taskId: this.taskId, createdAt: new Date(this.ledger.createdAtMs).toISOString(), updatedAt: new Date(this.ledger.updatedAtMs).toISOString(), @@ -332,11 +337,13 @@ export class BudgetTracker { exceededReasons: [...this.ledger.exceededReasons], limits, used, + workflow: { calls: this.ledger.workflowCalls, stages: structuredClone(this.ledger.workflowStages) }, remaining: { tokens: remaining(limits.maxTokens, used.total), inputTokens: remaining(limits.maxInputTokens, used.input), outputTokens: remaining(limits.maxOutputTokens, used.output + used.reasoning), - costUsd: remaining(limits.maxCostUsd, used.billableCostUsd) + costUsd: remaining(limits.maxCostUsd, used.billableCostUsd), + workflowCalls: remaining(limits.maxWorkflowCalls, this.ledger.workflowCalls) }, steps: structuredClone(this.ledger.steps), models: structuredClone(this.ledger.models), @@ -346,10 +353,13 @@ export class BudgetTracker { } export function getBudgetLedgerCount() { - return ledgers.size; + return [...stores.values()].reduce((sum, store) => sum + store.size, 0); } export function resetBudgetLedger(taskId) { - if (taskId) ledgers.delete(taskId); - else ledgers.clear(); + for (const [key, store] of stores) { + if (taskId) store.delete(taskId); + else store.clear(); + if (key !== ':memory:') saveBudgetState(store, key); + } } From c58fa99415136366b6af61dd5454755dd9e638dc Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:48:10 -0400 Subject: [PATCH 016/105] fix: resolve providers and executables portably --- runtime/opencode-worker-runner.mjs | 67 ++++++++++++------------------ 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/runtime/opencode-worker-runner.mjs b/runtime/opencode-worker-runner.mjs index b73cbbd..0b8dd9f 100644 --- a/runtime/opencode-worker-runner.mjs +++ b/runtime/opencode-worker-runner.mjs @@ -1,7 +1,11 @@ import { spawn } from 'node:child_process'; import { appendFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { normalizeTokenUsage } from '../lib/budget-manager.mjs'; +import { + deriveProviderFromModel as deriveProvider, + providerTimeoutKey, + resolveProviderAdapter +} from '../lib/provider-adapters.mjs'; const activeChildren = new Set(); @@ -15,34 +19,29 @@ export function terminateActiveWorkers(signal = 'SIGTERM') { } } -export function extractProviderError({ stdout = '', stderr = '', exitCode = 0 } = {}) { +export function extractProviderError({ stdout = '', stderr = '', exitCode = 0, model } = {}) { const combined = `${stderr}\n${stdout}`; - const statusMatch = combined.match(/\b(410|429|500|502|503|504|401|403|404)\b/); - const codeMatch = combined.match(/\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|UNAUTHORIZED|FORBIDDEN)\b/); - const providerMatch = combined.match(/provider[\s:]+([a-z0-9._-]+)/i); - const modelMatch = combined.match(/model[\s:]+([a-z0-9._/-]+(?::free)?)/i); - const sessionMatch = combined.match(/session(?:ID|Id| id)?[\s:=]+([a-zA-Z0-9_-]+)/); + const providerMatch = combined.match(/provider(?:ID|Id| id)?[\s:=]+([a-z0-9._-]+)/i); + const modelMatch = combined.match(/model(?:ID|Id| id)?[\s:=]+([a-z0-9._/-]+(?::free)?)/i); + const adapter = resolveProviderAdapter(model || modelMatch?.[1], providerMatch?.[1]); + const normalized = adapter.normalizeError({ stdout, stderr, exitCode }); return { - statusCode: statusMatch ? Number(statusMatch[1]) : undefined, - code: codeMatch ? codeMatch[1] : undefined, - provider: providerMatch ? providerMatch[1] : undefined, - model: modelMatch ? modelMatch[1] : undefined, - sessionId: sessionMatch ? sessionMatch[1] : undefined, + ...normalized, + provider: providerMatch?.[1] || normalized.provider, + model: modelMatch?.[1] || model, message: combined.slice(0, 2000), exitCode }; } export function deriveProviderFromModel(model) { - if (!model || typeof model !== 'string') return 'unknown'; - const parts = model.split('/'); - return parts.length >= 2 ? parts[0] : 'unknown'; + return deriveProvider(model); } export function resolveTimeoutMs({ model, timeoutMs, providerTimeouts = {}, latencyTimeoutMapping = {} }) { if (timeoutMs != null && timeoutMs > 0) return timeoutMs; - const provider = deriveProviderFromModel(model); - const providerTier = providerTimeouts[provider]; + const timeoutKey = providerTimeoutKey(model); + const providerTier = providerTimeouts[timeoutKey]; if (providerTier != null && providerTier > 0) return providerTier; const fallback = providerTimeouts.default; if (fallback != null && fallback > 0) return fallback; @@ -83,12 +82,12 @@ function addUsage(target, usage) { target.total += usage.total; } -function extractSessionId(stdout, stderr) { +function extractSessionId(stdout, stderr, model) { for (const event of parseJsonEvents(stdout)) { const id = event.sessionID || event.sessionId || event.session?.id || event.properties?.sessionID; if (id) return id; } - return extractProviderError({ stdout, stderr }).sessionId; + return extractProviderError({ stdout, stderr, model }).sessionId; } export async function runOpenCodeWorker({ @@ -115,7 +114,6 @@ export async function runOpenCodeWorker({ if (!prompt) throw new Error('runOpenCodeWorker requires prompt'); const effectiveTimeoutMs = resolveTimeoutMs({ model, timeoutMs, providerTimeouts, latencyTimeoutMapping }); - const args = [ 'run', '--agent', agent, @@ -135,13 +133,7 @@ export async function runOpenCodeWorker({ AGENT_LOOP_CHILD: '1' }; - // Ensure executable is absolute - if (executable === 'opencode' && !process.env.AGENT_LOOP_WORKER_EXECUTABLE) { - executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || '/usr/local/bin/opencode'; - } - if (!cwd || cwd === '') { - cwd = process.env.AGENT_LOOP_PROJECT_DIR || process.cwd(); - } + if (!cwd) cwd = process.env.AGENT_LOOP_PROJECT_DIR || process.cwd(); return await new Promise((resolve) => { const child = spawn(executable, args, { @@ -196,16 +188,12 @@ export async function runOpenCodeWorker({ for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('{')) continue; - try { - recordUsageEvent(JSON.parse(trimmed)); - } catch { - // Ignore non-JSON output mixed into the stream. - } + try { recordUsageEvent(JSON.parse(trimmed)); } + catch { /* Ignore non-JSON output mixed into the stream. */ } } if (flush && tail.trim().startsWith('{')) { - try { - recordUsageEvent(JSON.parse(tail.trim())); - } catch {} + try { recordUsageEvent(JSON.parse(tail.trim())); } + catch {} } } @@ -250,7 +238,6 @@ export async function runOpenCodeWorker({ stdout += text; consumeJsonOutput(text); lastActivityAt = Date.now(); - // Throttled tail callback for TUI metadata if (stdoutTailCallback) { const now = Date.now(); if (now - lastTailTs >= TAIL_THROTTLE_MS) { @@ -264,16 +251,16 @@ export async function runOpenCodeWorker({ lastActivityAt = Date.now(); }); - // Periodic progress flush const progressInterval = setInterval(flushProgress, 5000); child.on('error', error => { clearTimeout(timeout); clearInterval(progressInterval); activeChildren.delete(child); + if (signal) signal.removeEventListener?.('abort', abort); flushProgress(); - const providerError = extractProviderError({ stdout, stderr: `${stderr}\n${error.message}`, exitCode: -1 }); consumeJsonOutput('', true); + const providerError = extractProviderError({ stdout, stderr: `${stderr}\n${error.message}`, exitCode: -1, model }); resolve({ success: false, stdout, stderr, exitCode: -1, signal: null, error, timedOut: false, lastActivityAt, @@ -290,8 +277,8 @@ export async function runOpenCodeWorker({ if (signal) signal.removeEventListener?.('abort', abort); flushProgress(); consumeJsonOutput('', true); - const providerError = extractProviderError({ stdout, stderr, exitCode }); - const session = extractSessionId(stdout, stderr); + const providerError = extractProviderError({ stdout, stderr, exitCode, model }); + const session = extractSessionId(stdout, stderr, model); resolve({ success: exitCode === 0 && !timedOut && !budgetExceeded, stdout, From b2649fe2fe795d04962c0711066cf6d042db5d10 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:49:14 -0400 Subject: [PATCH 017/105] feat: enforce retries and structured runtime events --- runtime/execute-agent-task.mjs | 301 +++++++++++++++++++-------------- 1 file changed, 176 insertions(+), 125 deletions(-) diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index 1ea7622..4af041b 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -7,6 +7,7 @@ import { loadPoolConfig, normalizePoolConfig, isModelCoolingDown } from '../lib/ import { runOpenCodeWorker, resolveTimeoutMs } from './opencode-worker-runner.mjs'; import { reapExpiredCooldowns } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; +import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(__dirname, '..'); @@ -14,17 +15,15 @@ const DEFAULT_LOG_DIR = resolve(PACKAGE_ROOT, '.opencode', 'agent-loop-logs'); const DEFAULT_REGISTRY_PATH = resolve(PACKAGE_ROOT, 'config/model-registry.json'); function loadFreeFirstConfig(configPath) { - try { - return JSON.parse(readFileSync(configPath, 'utf-8')); - } catch { - return {}; - } + try { return JSON.parse(readFileSync(configPath, 'utf-8')); } + catch { return {}; } } function extractProviderTimeoutConfig(config) { - const providerTimeouts = config?.provider_timeouts_ms || {}; - const latencyTimeoutMapping = config?.latency_timeout_mapping || {}; - return { providerTimeouts, latencyTimeoutMapping }; + return { + providerTimeouts: config?.provider_timeouts_ms || {}, + latencyTimeoutMapping: config?.latency_timeout_mapping || {} + }; } const ROLE_POOL = { @@ -64,14 +63,44 @@ function tieredModels(normalized) { } function redact(text) { - return String(text || '') - .replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)[^\s]+/gi, '$1[REDACTED]') - .replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[REDACTED]') - .slice(0, 4000); + return String(redactSensitive(String(text || ''))).slice(0, 4000); } function writeAttemptLog(logPath, event) { - appendFileSync(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`, 'utf8'); + appendFileSync(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...redactSensitive(event) })}\n`, 'utf8'); +} + +function eventTypeForRouting(type) { + const map = { + 'attempt-start': 'model.attempt.started', + 'attempt-success': 'model.attempt.completed', + 'attempt-retryable-failure': 'model.attempt.failed', + 'attempt-task-failure': 'stage.task-failed', + 'provider-wide-failure': 'provider.cooldown', + 'skip-provider-cooldown': 'provider.skipped', + 'budget-denied': 'budget.exceeded', + 'budget-exceeded': 'budget.exceeded', + 'paid-denied': 'paid-fallback.denied', + 'cooldown-reap': 'provider.cooldown-reaped' + }; + return map[type] || 'routing.event'; +} + +function sleep(ms, signal) { + if (!ms || ms <= 0) return Promise.resolve(); + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(resolvePromise, ms); + if (signal) { + const abort = () => { + clearTimeout(timer); + const error = new Error('Retry wait cancelled'); + error.code = 'USER_CANCELLED'; + reject(error); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + } + }); } export async function executeAgentTask({ @@ -93,20 +122,21 @@ export async function executeAgentTask({ forceModels, progressCallback, budgetTaskId = taskId, - budgetStep = role + budgetStep = role, + maxRetries = 0, + eventLogger }) { if (!role) throw new Error('executeAgentTask requires role'); if (!agent) throw new Error('executeAgentTask requires agent'); if (!prompt) throw new Error('executeAgentTask requires prompt'); + const retryLimit = Number.isInteger(maxRetries) ? Math.max(0, Math.min(maxRetries, 5)) : 0; + const events = eventLogger || new AgentLoopEventLogger({ taskId: budgetTaskId, cwd }); mkdirSync(logDir, { recursive: true }); const logPath = resolve(logDir, `${taskId}-${role}.jsonl`); writeFileSync(logPath, '', 'utf8'); - // Reap expired cooldowns before selecting models — ensures recently-expired - // models are available again. reapExpiredCooldowns(); - const poolName = resolvePoolName(role); const poolConfig = loadPoolConfig(poolsPath); const normalized = normalizePoolConfig(poolConfig, { role: poolName }); @@ -114,17 +144,13 @@ export async function executeAgentTask({ if (Array.isArray(forceModels) && forceModels.length > 0) { const forceSet = new Set(forceModels); - models = models.filter(m => forceSet.has(m.modelId)); + models = models.filter(model => forceSet.has(model.modelId)); if (models.length === 0) { + events.emit('stage.blocked', { stage: budgetStep, role, data: { code: 'ALL_MODELS_FAILED', reason: 'Smoke-responsive models were filtered out.' } }); return { - status: 'failed', - code: 'ALL_MODELS_FAILED', - taskId, role, agent, poolName, - attemptedModels: [], - successfulModel: null, - attemptDetails: [], - stepStartedAt: new Date().toISOString(), - logPath, + status: 'failed', code: 'ALL_MODELS_FAILED', taskId, role, agent, poolName, + attemptedModels: [], successfulModel: null, attemptDetails: [], + stepStartedAt: new Date().toISOString(), logPath, summary: 'All responsive models from smoke test were filtered out before work began.' }; } @@ -136,16 +162,17 @@ export async function executeAgentTask({ taskId: budgetTaskId, step: budgetStep, configPath, - registryPath + registryPath, + cwd }); const failover = new FailoverHandler(configPath, poolsPath); await failover.loadConfig(); await failover.loadPools(); const paidFallback = new PaidFallbackController(configPath); - const attemptDetails = []; - let stepStartedAt = new Date().toISOString(); + const stepStartedAt = new Date().toISOString(); + events.emit('stage.started', { stage: budgetStep, role, data: { agent, poolName, retryLimit, candidateModels: models.map(model => model.modelId) } }); const result = await failover.runWithFailover({ taskId, @@ -154,7 +181,15 @@ export async function executeAgentTask({ models, paidFallback, paidApproved: metadata.paidApproved === true || process.env.AGENT_LOOP_PAID_APPROVED === '1', - log: event => writeAttemptLog(logPath, event), + log: event => { + writeAttemptLog(logPath, event); + events.emit(eventTypeForRouting(event.type), { + stage: budgetStep, + role, + modelId: event.modelId, + data: { ...event, type: undefined } + }); + }, canAttempt: async () => { const budget = budgetTracker.snapshot(); return budgetTracker.canContinue() @@ -163,112 +198,127 @@ export async function executeAgentTask({ }, invoke: async ({ modelId, tier, attemptIndex, attempts }) => { const checkpoint = attempts.length > 0 - ? `\n\nPrevious provider attempts failed. Before editing, inspect repository state and git diff. Attempted models: ${attempts.map(a => a.modelId).join(', ')}.` + ? `\n\nPrevious provider attempts failed. Before editing, inspect repository state and git diff. Attempted models: ${attempts.map(item => item.modelId).join(', ')}.` : ''; const workerPrompt = `${prompt}${checkpoint}`; - const attemptLog = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}.log`); - const progressLogPath = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}-progress.jsonl`); - const attemptStartedAt = new Date().toISOString(); - const effectiveTimeoutMs = resolveTimeoutMs({ model: modelId, timeoutMs, providerTimeouts, latencyTimeoutMapping }); - if (progressCallback) { - progressCallback({ title: `Trying ${modelId.split('/').pop()}...`, metadata: { model: modelId, tier, action: 'starting', attempt: attemptIndex + 1, timeoutMs: effectiveTimeoutMs } }); - await new Promise(r => setImmediate(r)); - } const modelLabel = modelId.split('/').pop(); - const stdoutTailCallback = (tail) => { + let finalInvocation = null; + + for (let retryIndex = 0; retryIndex <= retryLimit; retryIndex += 1) { + if (!budgetTracker.canContinue()) { + const budget = budgetTracker.snapshot(); + return { success: false, code: 'BUDGET_EXCEEDED', budgetExceeded: true, budget }; + } + + const suffix = retryIndex > 0 ? `-retry-${retryIndex}` : ''; + const attemptLog = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}${suffix}.log`); + const progressLogPath = resolve(logDir, `${taskId}-${role}-attempt-${attemptIndex + 1}${suffix}-progress.jsonl`); + const attemptStartedAt = new Date().toISOString(); + const effectiveTimeoutMs = resolveTimeoutMs({ model: modelId, timeoutMs, providerTimeouts, latencyTimeoutMapping }); + events.emit('model.invocation.started', { stage: budgetStep, role, modelId, data: { tier, attemptIndex, retryIndex, timeoutMs: effectiveTimeoutMs } }); + if (progressCallback) { - progressCallback({ title: modelLabel, metadata: { model: modelId, tail, action: 'progress', attempt: attemptIndex + 1 } }); + progressCallback({ title: `Trying ${modelLabel}${retryIndex ? ` retry ${retryIndex}` : ''}...`, metadata: { model: modelId, tier, action: 'starting', attempt: attemptIndex + 1, retry: retryIndex, timeoutMs: effectiveTimeoutMs } }); + await new Promise(resolvePromise => setImmediate(resolvePromise)); } - }; - let usageRecordedIncrementally = false; - const invocation = await workerAdapter({ - cwd, - agent, - model: modelId, - prompt: workerPrompt, - timeoutMs: effectiveTimeoutMs, - env: { - ...env, - AGENT_LOOP_PARENT_SESSION: parentSessionId || '', - AGENT_LOOP_TASK_ID: taskId, - AGENT_LOOP_ROLE: role, - AGENT_LOOP_MODEL: modelId, - AGENT_LOOP_WORKER_EXECUTABLE: process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode' - }, - signal, - title: taskId, - progressLogPath, - providerTimeouts, - latencyTimeoutMapping, - stdoutTailCallback, - onUsage: ({ usage, reportedCostUsd }) => { - usageRecordedIncrementally = true; - const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: budgetStep }); - writeAttemptLog(logPath, { type: 'budget-usage', taskId: budgetTaskId, step: budgetStep, modelId, usage, budget }); - if (progressCallback) { - progressCallback({ title: `Budget: ${budget.used.total} tokens / ${budget.used.billableCostUsd.toFixed(4)}`, metadata: { action: 'budget', budget } }); + + const stdoutTailCallback = tail => { + if (progressCallback) progressCallback({ title: modelLabel, metadata: { model: modelId, tail, action: 'progress', attempt: attemptIndex + 1, retry: retryIndex } }); + }; + let usageRecordedIncrementally = false; + const invocation = await workerAdapter({ + cwd, + agent, + model: modelId, + prompt: workerPrompt, + timeoutMs: effectiveTimeoutMs, + env: { + ...env, + AGENT_LOOP_PARENT_SESSION: parentSessionId || '', + AGENT_LOOP_TASK_ID: budgetTaskId, + AGENT_LOOP_ROLE: role, + AGENT_LOOP_MODEL: modelId, + AGENT_LOOP_WORKER_EXECUTABLE: process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode' + }, + signal, + title: taskId, + progressLogPath, + providerTimeouts, + latencyTimeoutMapping, + stdoutTailCallback, + onUsage: ({ usage, reportedCostUsd }) => { + usageRecordedIncrementally = true; + const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: budgetStep }); + writeAttemptLog(logPath, { type: 'budget-usage', taskId: budgetTaskId, step: budgetStep, modelId, usage, budget }); + events.emit(budget.exceeded ? 'budget.exceeded' : 'budget.updated', { stage: budgetStep, role, modelId, data: { usage, budget } }); + if (progressCallback) progressCallback({ title: `Budget: ${budget.used.total} tokens / $${budget.used.billableCostUsd.toFixed(4)}`, metadata: { action: 'budget', budget } }); + return budget; } - return budget; - } - }); - if (!usageRecordedIncrementally && invocation?.usage) { - invocation.budget = budgetTracker.recordUsage({ - modelId, - usage: invocation.usage, - reportedCostUsd: invocation.reportedCostUsd, - step: budgetStep }); + + if (!usageRecordedIncrementally && invocation?.usage) { + invocation.budget = budgetTracker.recordUsage({ modelId, usage: invocation.usage, reportedCostUsd: invocation.reportedCostUsd, step: budgetStep }); + } + const budget = budgetTracker.snapshot(); + if (budget.exceeded) { + invocation.success = false; + invocation.code = 'BUDGET_EXCEEDED'; + invocation.budgetExceeded = true; + invocation.budget = budget; + } + + writeFileSync(attemptLog, [ + `model=${modelId}`, + `tier=${tier}`, + `retryIndex=${retryIndex}`, + `timeoutMs=${effectiveTimeoutMs}`, + `exitCode=${invocation.exitCode ?? ''}`, + `usage=${JSON.stringify(invocation.usage || {})}`, + `reportedCostUsd=${invocation.reportedCostUsd || 0}`, + `budgetExceeded=${invocation.budgetExceeded === true}`, + '--- stdout ---', redact(invocation.stdout), + '--- stderr ---', redact(invocation.stderr) + ].join('\n'), 'utf8'); + + const detail = { + modelId, tier, attemptIndex: attemptIndex + 1, retryIndex, + startedAt: attemptStartedAt, finishedAt: new Date().toISOString(), + timedOut: Boolean(invocation.timedOut), + lastActivityAt: invocation.lastActivityAt ? new Date(invocation.lastActivityAt).toISOString() : null, + exitCode: invocation.exitCode, success: Boolean(invocation.success), code: invocation.code || null, + usage: invocation.usage || null, reportedCostUsd: invocation.reportedCostUsd || 0, + budgetExceeded: invocation.budgetExceeded === true, + budget: invocation.budget || budgetTracker.snapshot(), attemptLog, progressLogPath + }; + attemptDetails.push(detail); + events.emit(invocation.success ? 'model.invocation.completed' : 'model.invocation.failed', { stage: budgetStep, role, modelId, data: detail }); + finalInvocation = invocation; + + if (invocation.success || invocation.budgetExceeded || invocation.code === 'BUDGET_EXCEEDED') break; + const classification = failover.classifyFailure(invocation); + if (!classification.retryable || retryIndex >= retryLimit) break; + const delayMs = failover.getBackoffDelay(retryIndex); + events.emit('model.retry.scheduled', { stage: budgetStep, role, modelId, data: { retryIndex: retryIndex + 1, delayMs, reason: classification.reason } }); + await sleep(delayMs, signal); } - const budget = budgetTracker.snapshot(); - if (budget.exceeded) { - invocation.success = false; - invocation.code = 'BUDGET_EXCEEDED'; - invocation.budgetExceeded = true; - invocation.budget = budget; - } - writeFileSync(attemptLog, [ - `model=${modelId}`, - `tier=${tier}`, - `timeoutMs=${effectiveTimeoutMs}`, - `exitCode=${invocation.exitCode ?? ''}`, - `usage=${JSON.stringify(invocation.usage || {})}`, - `reportedCostUsd=${invocation.reportedCostUsd || 0}`, - `budgetExceeded=${invocation.budgetExceeded === true}`, - '--- stdout ---', - redact(invocation.stdout), - '--- stderr ---', - redact(invocation.stderr) - ].join('\n'), 'utf8'); - writeAttemptLog(logPath, { type: 'attempt-output', modelId, tier, attemptLog, progressLogPath, timedOut: invocation.timedOut }); - const detail = { - modelId, - tier, - attemptIndex: attemptIndex + 1, - startedAt: attemptStartedAt, - finishedAt: new Date().toISOString(), - timedOut: !!invocation.timedOut, - lastActivityAt: invocation.lastActivityAt ? new Date(invocation.lastActivityAt).toISOString() : null, - exitCode: invocation.exitCode, - success: !!invocation.success, - code: invocation.code || null, - usage: invocation.usage || null, - reportedCostUsd: invocation.reportedCostUsd || 0, - budgetExceeded: invocation.budgetExceeded === true, - budget: invocation.budget || budgetTracker.snapshot(), - logPath: attemptLog, - progressLogPath - }; - attemptDetails.push(detail); - const status = invocation.success ? 'ok' : invocation.timedOut ? 'timeout' : 'failed'; - const reason = invocation.success ? '' : invocation.code || 'error'; + + const status = finalInvocation?.success ? 'ok' : finalInvocation?.timedOut ? 'timeout' : 'failed'; if (progressCallback) { - progressCallback({ title: `${modelId.split('/').pop()}: ${status}${reason ? ' (' + reason + ')' : ''}`, metadata: { model: modelId, action: 'result', status, code: invocation.code || null, timedOut: !!invocation.timedOut, elapsed: new Date() - new Date(attemptStartedAt) } }); - await new Promise(r => setImmediate(r)); + progressCallback({ title: `${modelLabel}: ${status}${finalInvocation?.code ? ` (${finalInvocation.code})` : ''}`, metadata: { model: modelId, action: 'result', status, code: finalInvocation?.code || null, timedOut: Boolean(finalInvocation?.timedOut) } }); + await new Promise(resolvePromise => setImmediate(resolvePromise)); } - return invocation; + return finalInvocation; } }); + const finalBudget = budgetTracker.snapshot(); + events.emit(result.status === 'completed' ? 'stage.completed' : 'stage.failed', { + stage: budgetStep, + role, + modelId: result.successfulModel, + data: { status: result.status, code: result.code || null, attemptedModels: result.attemptedModels || [], budget: finalBudget } + }); + return { ...result, taskId, @@ -276,10 +326,11 @@ export async function executeAgentTask({ agent, poolName, logPath, + eventLogPath: events.path, stepStartedAt, attemptDetails, - attemptedModels: result.attemptedModels || result.attempts?.map(a => a.modelId) || [], + attemptedModels: result.attemptedModels || result.attempts?.map(item => item.modelId) || [], successfulModel: result.successfulModel || null, - budget: budgetTracker.snapshot() + budget: finalBudget }; } From cc9f5b45f588a9da5644c20f497d8d29007f54af Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:50:07 -0400 Subject: [PATCH 018/105] feat: guard workflow calls and emit lifecycle events --- runtime/agent-loop-controller.mjs | 196 ++++++++++++++++++++---------- 1 file changed, 130 insertions(+), 66 deletions(-) diff --git a/runtime/agent-loop-controller.mjs b/runtime/agent-loop-controller.mjs index 1bb6f8f..683c9c3 100644 --- a/runtime/agent-loop-controller.mjs +++ b/runtime/agent-loop-controller.mjs @@ -1,9 +1,10 @@ import { executeAgentTask, resolvePoolName } from './execute-agent-task.mjs'; import { assertCanStartLoop, getCurrentDepth } from './recursion-guard.mjs'; import { loadPoolConfig, normalizePoolConfig, isModelCoolingDown } from '../lib/pool-normalizer.mjs'; -import { runSmokeTest, filterResponsiveModels } from '../lib/smoke-test.mjs'; +import { runSmokeTest } from '../lib/smoke-test.mjs'; import { reapExpiredCooldowns, filterRetiredModels, loadRegistry } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; +import { AgentLoopEventLogger } from '../lib/event-log.mjs'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,25 +16,26 @@ const DEFAULT_POOLS_PATH = resolve(PACKAGE_ROOT, 'config/free-first-pools.json') const DEFAULT_REGISTRY_PATH = resolve(PACKAGE_ROOT, 'config/model-registry.json'); function loadConfig(path) { - try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; } + try { return JSON.parse(readFileSync(path, 'utf-8')); } + catch { return {}; } } function yieldToEventLoop() { - return new Promise(resolve => setImmediate(resolve)); + return new Promise(resolvePromise => setImmediate(resolvePromise)); } -async function updateProgress(pc, title, metadata) { - if (!pc) return; +async function updateProgress(callback, title, metadata) { + if (!callback) return; try { - pc({ title, metadata: { ...metadata, _t: Date.now() } }); + callback({ title, metadata: { ...metadata, _t: Date.now() } }); await yieldToEventLoop(); } catch {} } const MODE_STEPS = { - smoke: { role: 'builder', agent: 'build-worker', label: 'smoke' }, - build: { role: 'builder', agent: 'build-worker', label: 'build' }, - test: { role: 'test', agent: 'test', label: 'test' }, + smoke: { role: 'builder', agent: 'build-worker', label: 'smoke' }, + build: { role: 'builder', agent: 'build-worker', label: 'build' }, + test: { role: 'test', agent: 'test', label: 'test' }, review: { role: 'reviewer', agent: 'review', label: 'review' }, escalate: { role: 'escalation', agent: 'escalation', label: 'escalate' } }; @@ -52,6 +54,26 @@ function classifyStep(label, result) { return 'passed'; } +function budgetFailure({ taskId, budget, smokeResults = null, eventLogPath = '' }) { + return { + status: 'failed', + code: 'BUDGET_EXCEEDED', + taskId, + summary: budget.exceededReasons.join('; ') || 'Task budget exceeded.', + successfulModel: null, + attemptedModels: [], + changedFiles: [], + smokeResults, + budget, + eventLogPath, + tests: { status: 'not-run', commands: [] }, + review: { status: 'not-run' }, + requiresUserInput: false, + logPath: '', + steps: [{ step: 'budget', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] + }; +} + export async function runAgentLoop({ task, mode = 'build', @@ -81,24 +103,33 @@ export async function runAgentLoop({ const config = loadConfig(DEFAULT_CONFIG_PATH); const smokeTestEnabled = config?.general?.smoke_test_enabled !== false; + const events = new AgentLoopEventLogger({ taskId, cwd }); const budgetTracker = BudgetTracker.fromFiles({ taskId, - step: 'smoke', + step: mode, configPath: DEFAULT_CONFIG_PATH, - registryPath: DEFAULT_REGISTRY_PATH - }); - const onSmokeUsage = ({ modelId, usage, reportedCostUsd }) => budgetTracker.recordUsage({ - modelId, - usage, - reportedCostUsd, - step: 'smoke' + registryPath: DEFAULT_REGISTRY_PATH, + cwd }); + const callBudget = budgetTracker.recordWorkflowCall(mode); + events.emit('workflow.call', { stage: mode, data: { mode, maxRetries, budget: callBudget } }); + if (!budgetTracker.canContinue()) { + events.emit('budget.exceeded', { stage: mode, data: { budget: callBudget } }); + return budgetFailure({ taskId, budget: callBudget, eventLogPath: events.path }); + } + + const onSmokeUsage = ({ modelId, usage, reportedCostUsd }) => { + const budget = budgetTracker.recordUsage({ modelId, usage, reportedCostUsd, step: 'smoke' }); + events.emit(budget.exceeded ? 'budget.exceeded' : 'budget.updated', { stage: 'smoke', modelId, data: { usage, budget } }); + return budget; + }; + + events.emit('stage.started', { stage: mode, data: { mode, taskLength: task.length } }); - // --- Smoke test mode: test models and return results --- if (mode === 'smoke') { reapExpiredCooldowns(); - const poolConf = loadPoolConfig(DEFAULT_POOLS_PATH); - const normalized = normalizePoolConfig(poolConf, { role: resolvePoolName('builder') }); + const poolConfig = loadPoolConfig(DEFAULT_POOLS_PATH); + const normalized = normalizePoolConfig(poolConfig, { role: resolvePoolName('builder') }); const registry = loadRegistry(); const freeModels = []; for (const tier of ['freeCloud', 'openRouterFree', 'trialCreditModels']) { @@ -106,9 +137,8 @@ export async function runAgentLoop({ if (!isModelCoolingDown(normalized, modelId)) freeModels.push({ modelId, tier }); } } - // Filter out retired models so smoke tests don't waste call on them - const activeModels = filterRetiredModels(freeModels.map(m => m.modelId), registry); - const filteredModels = freeModels.filter(m => activeModels.includes(m.modelId)); + const activeModels = filterRetiredModels(freeModels.map(item => item.modelId), registry); + const filteredModels = freeModels.filter(item => activeModels.includes(item.modelId)); let smokeResults = null; if (filteredModels.length > 0) { await updateProgress(progressCallback, 'Smoke testing free models...', { models: filteredModels.length, status: 'smoke-test' }); @@ -117,44 +147,48 @@ export async function runAgentLoop({ providerTimeouts: config?.provider_timeouts_ms || {}, smokeTestTimeoutMs: config?.general?.smoke_test_default_timeout_ms || 30000, smokeTestProviderTimeouts: config?.smoke_test_provider_timeout_ms || {}, - cwd, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, signal, progressCallback, onUsage: onSmokeUsage + cwd, + env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, + signal, + progressCallback, + onUsage: onSmokeUsage }); + events.emit('smoke.completed', { stage: 'smoke', data: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } }); await updateProgress(progressCallback, `Smoke test: ${smokeResults.responsive.length}/${smokeResults.responsive.length + smokeResults.unresponsive.length} responsive`, { smoke: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive }, status: 'smoke-done' }); } if (smokeResults?.budgetExceeded || !budgetTracker.canContinue()) { const budget = budgetTracker.snapshot(); - return { - status: 'failed', code: 'BUDGET_EXCEEDED', taskId, - summary: budget.exceededReasons.join('; ') || 'Task budget exceeded during smoke test.', - successfulModel: null, attemptedModels: [], changedFiles: [], - smokeResults, budget, - tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, - requiresUserInput: false, logPath: '', steps: [{ step: 'smoke', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] - }; + events.emit('budget.exceeded', { stage: 'smoke', data: { budget } }); + return budgetFailure({ taskId, budget, smokeResults, eventLogPath: events.path }); } - const hasResponsive = smokeResults && smokeResults.responsive.length > 0; + const hasResponsive = Boolean(smokeResults?.responsive.length); + events.emit(hasResponsive ? 'stage.completed' : 'stage.failed', { stage: 'smoke', data: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] } }); return { status: hasResponsive ? 'completed' : 'failed', - taskId, summary: hasResponsive ? `Smoke test: ${smokeResults.responsive.length} responsive models` : 'All providers unresponsive', - successfulModel: null, attemptedModels: [], changedFiles: [], + taskId, + summary: hasResponsive ? `Smoke test: ${smokeResults.responsive.length} responsive models` : 'All providers unresponsive', + successfulModel: null, + attemptedModels: [], + changedFiles: [], smokeResults: smokeResults ? { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } : null, - tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, - requiresUserInput: !hasResponsive, logPath: '', + tests: { status: 'not-run', commands: [] }, + review: { status: 'not-run' }, + requiresUserInput: !hasResponsive, + logPath: '', + eventLogPath: events.path, budget: budgetTracker.snapshot(), - steps: [{ step: 'smoke', status: hasResponsive ? 'completed' : 'failed', attemptedModels: (smokeResults?.responsive || []).map(r => r.modelId), smokeResults: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] }, budget: budgetTracker.snapshot() }] + steps: [{ step: 'smoke', status: hasResponsive ? 'completed' : 'failed', attemptedModels: (smokeResults?.responsive || []).map(item => item.modelId), smokeResults: { responsive: smokeResults?.responsive || [], unresponsive: smokeResults?.unresponsive || [] }, budget: budgetTracker.snapshot() }] }; } - // --- Regular mode: run smoke test if no forceModels provided --- let smokeResults = null; if (Array.isArray(forceModels) && forceModels.length > 0) { - // forceModels from the caller are pre-filtered; trust caller - smokeResults = { responsive: forceModels.map(id => ({ modelId: id })), unresponsive: [] }; + smokeResults = { responsive: forceModels.map(modelId => ({ modelId })), unresponsive: [] }; } else if (smokeTestEnabled) { reapExpiredCooldowns(); - const poolConf = loadPoolConfig(DEFAULT_POOLS_PATH); + const poolConfig = loadPoolConfig(DEFAULT_POOLS_PATH); const firstPool = resolvePoolName(MODE_STEPS[mode].role); - const normalized = normalizePoolConfig(poolConf, { role: firstPool }); + const normalized = normalizePoolConfig(poolConfig, { role: firstPool }); const registry = loadRegistry(); const freeModels = []; for (const tier of ['freeCloud', 'openRouterFree', 'trialCreditModels']) { @@ -162,67 +196,93 @@ export async function runAgentLoop({ if (!isModelCoolingDown(normalized, modelId)) freeModels.push({ modelId, tier }); } } - // Filter out retired models - const activeIds = filterRetiredModels(freeModels.map(m => m.modelId), registry); - const filteredModels = freeModels.filter(m => activeIds.includes(m.modelId)); + const activeIds = filterRetiredModels(freeModels.map(item => item.modelId), registry); + const filteredModels = freeModels.filter(item => activeIds.includes(item.modelId)); if (filteredModels.length > 0) { await updateProgress(progressCallback, 'Smoke testing free models...', { models: filteredModels.length, status: 'smoke-test' }); smokeResults = await runSmokeTest({ - models: filteredModels, providerTimeouts: config?.provider_timeouts_ms || {}, + models: filteredModels, + providerTimeouts: config?.provider_timeouts_ms || {}, smokeTestTimeoutMs: config?.general?.smoke_test_default_timeout_ms || 30000, smokeTestProviderTimeouts: config?.smoke_test_provider_timeout_ms || {}, - cwd, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, signal, progressCallback, onUsage: onSmokeUsage + cwd, + env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, + signal, + progressCallback, + onUsage: onSmokeUsage }); + events.emit('smoke.completed', { stage: mode, data: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } }); await updateProgress(progressCallback, `Smoke test: ${smokeResults.responsive.length}/${smokeResults.responsive.length + smokeResults.unresponsive.length} responsive`, { smoke: { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive }, status: 'smoke-done' }); } if (smokeResults?.budgetExceeded || !budgetTracker.canContinue()) { const budget = budgetTracker.snapshot(); - return { status: 'failed', code: 'BUDGET_EXCEEDED', taskId, summary: budget.exceededReasons.join('; ') || 'Task budget exceeded during smoke test.', successfulModel: null, attemptedModels: [], changedFiles: [], smokeResults, budget, tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, requiresUserInput: false, logPath: '', steps: [{ step: 'smoke', status: 'failed', code: 'BUDGET_EXCEEDED', budget }] }; + events.emit('budget.exceeded', { stage: mode, data: { budget } }); + return budgetFailure({ taskId, budget, smokeResults, eventLogPath: events.path }); } if (smokeResults && smokeResults.responsive.length === 0 && smokeResults.unresponsive.length > 0) { await updateProgress(progressCallback, 'All providers unresponsive', { status: 'blocked' }); - return { status: 'failed', taskId, summary: 'All free providers are unresponsive.', successfulModel: null, attemptedModels: [], changedFiles: [], smokeResults: { responsive: [], unresponsive: smokeResults.unresponsive }, tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, requiresUserInput: false, logPath: '', steps: [] }; + events.emit('stage.blocked', { stage: mode, data: { code: 'ALL_PROVIDERS_UNRESPONSIVE', smokeResults } }); + return { + status: 'failed', taskId, summary: 'All free providers are unresponsive.', + successfulModel: null, attemptedModels: [], changedFiles: [], + smokeResults: { responsive: [], unresponsive: smokeResults.unresponsive }, + tests: { status: 'not-run', commands: [] }, review: { status: 'not-run' }, + requiresUserInput: false, logPath: '', eventLogPath: events.path, + budget: budgetTracker.snapshot(), steps: [] + }; } } - // --- Execute single step --- const step = MODE_STEPS[mode]; const prompt = [ - `User request:\n${task}`, '', `Mode: ${mode}`, `Step: ${step.label}`, `Task ID: ${taskId}`, '', + `User request:\n${task}`, + '', + `Mode: ${mode}`, + `Step: ${step.label}`, + `Task ID: ${taskId}`, + '', 'Inspect the repository state before changing anything. Return a concise structured result with evidence. Do not call the agent_loop tool.' ].join('\n'); - await updateProgress(progressCallback, `${step.label} step...`, { step: step.label, status: 'running' }); const executeOptions = { - taskId: `${taskId}-${step.label}`, role: step.role, agent: step.agent, prompt, - cwd, parentSessionId, metadata, timeoutMs, signal, workerAdapter, + taskId: `${taskId}-${step.label}`, + role: step.role, + agent: step.agent, + prompt, + cwd, + parentSessionId, + metadata, + timeoutMs, + signal, + workerAdapter, env: { ...env, AGENT_LOOP_DEPTH: String(getCurrentDepth() + 1) }, progressCallback, budgetTaskId: taskId, budgetStep: step.label, - registryPath: DEFAULT_REGISTRY_PATH + registryPath: DEFAULT_REGISTRY_PATH, + maxRetries, + eventLogger: events }; - if (smokeResults && smokeResults.responsive.length > 0) { - // Filter smoke-responsive models through registry retirement check so - // a model that is retired or disabled does not get passed as a candidate. + if (smokeResults?.responsive.length) { const registry = loadRegistry(); - const responsiveIds = smokeResults.responsive.map(r => r.modelId); + const responsiveIds = smokeResults.responsive.map(item => item.modelId); const activeIds = filterRetiredModels(responsiveIds, registry); executeOptions.forceModels = activeIds.length > 0 ? activeIds : responsiveIds; } const result = await executeAgentTask(executeOptions); const stepStatus = classifyStep(step.label, result); - await updateProgress(progressCallback, `${step.label}: ${stepStatus}`, { step: step.label, status: stepStatus, attemptedModels: result.attemptedModels, successfulModel: result.successfulModel }); + const finalBudget = result.budget || budgetTracker.snapshot(); + events.emit(stepStatus === 'passed' ? 'workflow.call.completed' : 'workflow.call.failed', { stage: step.label, role: step.role, modelId: result.successfulModel, data: { code: result.code || null, budget: finalBudget } }); return { status: stepStatus === 'passed' ? 'completed' : 'failed', code: result.code || null, taskId, summary: result.code === 'BUDGET_EXCEEDED' - ? (result.budget?.exceededReasons?.join('; ') || 'Task budget exceeded.') + ? (finalBudget.exceededReasons?.join('; ') || 'Task budget exceeded.') : stepStatus === 'passed' ? `Agent loop completed (${step.label}).` : `${step.label} step did not pass.`, successfulModel: result.successfulModel || null, attemptedModels: result.attemptedModels || [], @@ -232,14 +292,18 @@ export async function runAgentLoop({ review: { status: step.label === 'review' ? stepStatus : 'not-run' }, requiresUserInput: result.code === 'FREE_MODELS_EXHAUSTED', logPath: result.logPath || '', - budget: result.budget || budgetTracker.snapshot(), + eventLogPath: events.path, + budget: finalBudget, steps: [{ - step: step.label, status: stepStatus, + step: step.label, + status: stepStatus, successfulModel: result.successfulModel, attemptedModels: result.attemptedModels, - code: result.code, logPath: result.logPath, + code: result.code, + logPath: result.logPath, + eventLogPath: events.path, attemptDetails: result.attemptDetails || null, - budget: result.budget || budgetTracker.snapshot(), + budget: finalBudget, smokeResults: smokeResults ? { responsive: smokeResults.responsive, unresponsive: smokeResults.unresponsive } : null, stepStartedAt: new Date().toISOString() }] From 5ac0160518cecdb9c1e4e643b4066aeb9d85c29b Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:51:19 -0400 Subject: [PATCH 019/105] feat: configure persistent budgets and events --- config/free-first-config.json | 105 ++++++++++------------------------ 1 file changed, 30 insertions(+), 75 deletions(-) diff --git a/config/free-first-config.json b/config/free-first-config.json index f6136c6..72a867b 100644 --- a/config/free-first-config.json +++ b/config/free-first-config.json @@ -1,6 +1,6 @@ { "$schema": "./free-first-config-schema.json", - "version": "1.2.0", + "version": "2.0.0", "general": { "free_first": true, "allow_paid_fallback": true, @@ -14,26 +14,14 @@ "paid_fallback_max_calls_per_task": 3, "paid_fallback_max_calls_global": 10, "paid_fallback_requires_approval": false, - "paid_fallback_allowed_roles": [ - "orchestrator", - "builder", - "reviewer", - "test-fixer" - ], + "paid_fallback_allowed_roles": ["orchestrator", "builder", "reviewer", "test-fixer"], "trial_credit_models_enabled": false, "smoke_test_enabled": true, "smoke_test_prompt": "Reply with exactly one word: ok", "smoke_test_default_timeout_ms": 30000, - "default_free_classifications": [ - "recurring free tier", - "promotional free" - ], - "sensitive_task_classifications": [ - "standard" - ], - "local_only_task_classifications": [ - "trusted-provider-only" - ] + "default_free_classifications": ["recurring free tier", "promotional free"], + "sensitive_task_classifications": ["standard"], + "local_only_task_classifications": ["trusted-provider-only"] }, "cooldowns": { "single_rate_limit_minutes": 10, @@ -49,37 +37,14 @@ }, "privacy": { "privacy_map": { - "standard": { - "sensitive_tasks_allowed": false, - "local_only": false - }, - "data-used-for-improvement": { - "sensitive_tasks_allowed": false, - "local_only": false - }, - "trial-only-logged": { - "sensitive_tasks_allowed": false, - "local_only": false - }, - "data-retained-for-improvement": { - "sensitive_tasks_allowed": false, - "local_only": false - }, - "trusted-provider-only": { - "sensitive_tasks_allowed": true, - "local_only": false - }, - "local-private": { - "sensitive_tasks_allowed": true, - "local_only": true - } + "standard": {"sensitive_tasks_allowed": false, "local_only": false}, + "data-used-for-improvement": {"sensitive_tasks_allowed": false, "local_only": false}, + "trial-only-logged": {"sensitive_tasks_allowed": false, "local_only": false}, + "data-retained-for-improvement": {"sensitive_tasks_allowed": false, "local_only": false}, + "trusted-provider-only": {"sensitive_tasks_allowed": true, "local_only": false}, + "local-private": {"sensitive_tasks_allowed": true, "local_only": true} }, - "local_preferred_classifications": [ - "local-only", - "sensitive", - "private-repository", - "contains-private-logs" - ], + "local_preferred_classifications": ["local-only", "sensitive", "private-repository", "contains-private-logs"], "local_fallback_requires_privacy_filter": true }, "retry": { @@ -87,29 +52,9 @@ "base_delay_ms": 1000, "max_delay_ms": 30000, "jitter_factor": 0.1, - "retryable_http_codes": [ - 410, - 429, - 500, - 502, - 503, - 504 - ], - "retryable_errors": [ - "ECONNRESET", - "ETIMEDOUT", - "ECONNREFUSED", - "EPIPE" - ], - "non_retryable_errors": [ - "UNAUTHORIZED", - "FORBIDDEN", - "SAFETY_REJECTION", - "BILLING_DISABLED", - "INVALID_REQUEST", - "USER_CANCELLED", - "BUDGET_EXCEEDED" - ] + "retryable_http_codes": [410, 429, 500, 502, 503, 504], + "retryable_errors": ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE"], + "non_retryable_errors": ["UNAUTHORIZED", "FORBIDDEN", "SAFETY_REJECTION", "BILLING_DISABLED", "INVALID_REQUEST", "USER_CANCELLED", "BUDGET_EXCEEDED"] }, "model_registry_path": "./config/model-registry.json", "pools_path": "./config/free-first-pools.json", @@ -118,20 +63,22 @@ "groq": 30000, "cerebras": 60000, "opencode": 60000, + "opencode-go": 60000, "openrouter": 60000, + "openai": 300000, + "local": 420000, "default": 300000 }, - "latency_timeout_mapping": { - "fast": 120000, - "medium": 300000, - "slow": 600000 - }, + "latency_timeout_mapping": {"fast": 120000, "medium": 300000, "slow": 600000}, "smoke_test_provider_timeout_ms": { "nvidia": 15000, "groq": 15000, "cerebras": 15000, "opencode": 15000, + "opencode-go": 15000, "openrouter": 15000, + "openai": 15000, + "local": 30000, "default": 15000 }, "task_classification": { @@ -147,7 +94,10 @@ "max_input_tokens_per_task": 200000, "max_output_tokens_per_task": 50000, "max_cost_usd_per_task": 1, + "max_workflow_calls_per_task": 12, "fail_closed_on_unknown_pricing": false, + "persist_state": true, + "state_path": ".opencode/agent-loop-state/budgets.json", "ledger_ttl_minutes": 1440, "max_tracked_tasks": 1000, "unknown_paid_model_pricing": { @@ -157,5 +107,10 @@ "cache_read_per_million_usd": 1, "cache_write_per_million_usd": 5 } + }, + "events": { + "enabled": true, + "path": ".opencode/agent-loop-state/events.jsonl", + "schema_version": "1.0.0" } } From ccdcb2595c6c40fe69caf2cd51095d4e54edc1c7 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:51:41 -0400 Subject: [PATCH 020/105] feat: validate v0.2 runtime policy --- config/free-first-config-schema.json | 216 +++++++-------------------- 1 file changed, 53 insertions(+), 163 deletions(-) diff --git a/config/free-first-config-schema.json b/config/free-first-config-schema.json index df7030f..6201722 100644 --- a/config/free-first-config-schema.json +++ b/config/free-first-config-schema.json @@ -1,117 +1,36 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/wryan2986/opencode-agent-loop/config/free-first-config-schema.json", - "title": "OpenCode Agent Loop free-first configuration", + "title": "OpenCode Agent Loop configuration", "type": "object", - "required": [ - "version", - "general", - "retry", - "model_registry_path", - "pools_path", - "budgets" - ], + "required": ["version", "general", "retry", "model_registry_path", "pools_path", "budgets", "events"], "properties": { - "$schema": { - "type": "string" - }, - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "general": { - "type": "object" - }, - "cooldowns": { - "type": "object" - }, - "privacy": { - "type": "object" - }, + "$schema": {"type": "string"}, + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "general": {"type": "object"}, + "cooldowns": {"type": "object"}, + "privacy": {"type": "object"}, "retry": { "type": "object", - "required": [ - "max_retries", - "retryable_http_codes", - "retryable_errors", - "non_retryable_errors" - ], + "required": ["max_retries", "retryable_http_codes", "retryable_errors", "non_retryable_errors"], "properties": { - "max_retries": { - "type": "integer", - "minimum": 0 - }, - "base_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "max_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "jitter_factor": { - "type": "number", - "minimum": 0 - }, - "retryable_http_codes": { - "type": "array", - "items": { - "type": "integer" - }, - "uniqueItems": true - }, - "retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "non_retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } + "max_retries": {"type": "integer", "minimum": 0, "maximum": 5}, + "base_delay_ms": {"type": "integer", "minimum": 0}, + "max_delay_ms": {"type": "integer", "minimum": 0}, + "jitter_factor": {"type": "number", "minimum": 0}, + "retryable_http_codes": {"type": "array", "items": {"type": "integer"}, "uniqueItems": true}, + "retryable_errors": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "non_retryable_errors": {"type": "array", "items": {"type": "string"}, "uniqueItems": true} }, "additionalProperties": true }, - "model_registry_path": { - "type": "string", - "minLength": 1 - }, - "pools_path": { - "type": "string", - "minLength": 1 - }, - "provider_timeouts_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "latency_timeout_mapping": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "smoke_test_provider_timeout_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "task_classification": { - "type": "object" - }, - "lightweight_profiles": { - "type": "object" - }, + "model_registry_path": {"type": "string", "minLength": 1}, + "pools_path": {"type": "string", "minLength": 1}, + "provider_timeouts_ms": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, + "latency_timeout_mapping": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, + "smoke_test_provider_timeout_ms": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, + "task_classification": {"type": "object"}, + "lightweight_profiles": {"type": "object"}, "budgets": { "type": "object", "required": [ @@ -120,45 +39,37 @@ "max_input_tokens_per_task", "max_output_tokens_per_task", "max_cost_usd_per_task", + "max_workflow_calls_per_task", "fail_closed_on_unknown_pricing", + "persist_state", + "state_path", "ledger_ttl_minutes", "max_tracked_tasks", "unknown_paid_model_pricing" ], "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_input_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_output_tokens_per_task": { - "type": ["integer", "null"], - "minimum": 0 - }, - "max_cost_usd_per_task": { - "type": ["number", "null"], - "minimum": 0 - }, - "fail_closed_on_unknown_pricing": { - "type": "boolean" - }, - "ledger_ttl_minutes": { - "type": "integer", - "minimum": 1 - }, - "max_tracked_tasks": { - "type": "integer", - "minimum": 1 - }, - "unknown_paid_model_pricing": { - "$ref": "#/$defs/pricing" - } + "enabled": {"type": "boolean"}, + "max_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, + "max_input_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, + "max_output_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, + "max_cost_usd_per_task": {"type": ["number", "null"], "minimum": 0}, + "max_workflow_calls_per_task": {"type": ["integer", "null"], "minimum": 1}, + "fail_closed_on_unknown_pricing": {"type": "boolean"}, + "persist_state": {"type": "boolean"}, + "state_path": {"type": "string", "minLength": 1}, + "ledger_ttl_minutes": {"type": "integer", "minimum": 1}, + "max_tracked_tasks": {"type": "integer", "minimum": 1}, + "unknown_paid_model_pricing": {"$ref": "#/$defs/pricing"} + }, + "additionalProperties": false + }, + "events": { + "type": "object", + "required": ["enabled", "path", "schema_version"], + "properties": { + "enabled": {"type": "boolean"}, + "path": {"type": "string", "minLength": 1}, + "schema_version": {"const": "1.0.0"} }, "additionalProperties": false } @@ -166,34 +77,13 @@ "$defs": { "pricing": { "type": "object", - "required": [ - "input_per_million_usd", - "output_per_million_usd", - "reasoning_per_million_usd", - "cache_read_per_million_usd", - "cache_write_per_million_usd" - ], + "required": ["input_per_million_usd", "output_per_million_usd", "reasoning_per_million_usd", "cache_read_per_million_usd", "cache_write_per_million_usd"], "properties": { - "input_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "output_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "reasoning_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_read_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_write_per_million_usd": { - "type": "number", - "minimum": 0 - } + "input_per_million_usd": {"type": "number", "minimum": 0}, + "output_per_million_usd": {"type": "number", "minimum": 0}, + "reasoning_per_million_usd": {"type": "number", "minimum": 0}, + "cache_read_per_million_usd": {"type": "number", "minimum": 0}, + "cache_write_per_million_usd": {"type": "number", "minimum": 0} }, "additionalProperties": false } From 044d1b88fdfac7e9869ac177c9aa6a8d79eeb617 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:52:27 -0400 Subject: [PATCH 021/105] chore: add v0.2 release migration --- scripts/apply-v020-release.mjs | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 scripts/apply-v020-release.mjs diff --git a/scripts/apply-v020-release.mjs b/scripts/apply-v020-release.mjs new file mode 100644 index 0000000..dd6918b --- /dev/null +++ b/scripts/apply-v020-release.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +function read(path) { return fs.readFileSync(path, 'utf8'); } +function write(path, content) { fs.writeFileSync(path, content); console.log(`updated ${path}`); } +function replace(path, before, after) { + const current = read(path); + if (!current.includes(before)) throw new Error(`missing expected text in ${path}: ${before.slice(0, 100)}`); + write(path, current.replace(before, after)); +} +function updateJson(path, mutate) { + const value = JSON.parse(read(path)); + mutate(value); + write(path, `${JSON.stringify(value, null, 2)}\n`); +} + +// Tests should persist only when policy explicitly enables it. +replace('lib/budget-manager.mjs', "persistenceEnabled: raw.persist_state !== false,", "persistenceEnabled: raw.persist_state === true,"); + +// Use adapters for provider-wide cooldown and portable checkpoint paths. +let failover = read('lib/failover-handler.mjs'); +failover = failover.replace( + "import { resolve } from 'node:path';\n", + "import { resolve } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { deriveProviderFromModel } from './provider-adapters.mjs';\n" +); +failover = failover.replace( + "const RETRYABLE_SYSTEM_ERRORS =", + "function checkpointPath(taskId) {\n const safe = String(taskId || 'task').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);\n return resolve(tmpdir(), `task-checkpoint-${safe}.json`);\n}\n\nconst RETRYABLE_SYSTEM_ERRORS =" +); +failover = failover.replaceAll("modelId.split('/')[0]", "deriveProviderFromModel(modelId)"); +failover = failover.replaceAll("`/tmp/task-checkpoint-${taskId}.json`", "checkpointPath(taskId)"); +write('lib/failover-handler.mjs', failover); + +// Package metadata and portable scripts. +updateJson('package.json', pkg => { + pkg.version = '0.2.0'; + const reliability = 'node tests/reliability-v020-tests.mjs'; + if (!pkg.scripts.test.includes(reliability)) pkg.scripts.test = `${reliability} && ${pkg.scripts.test}`; + pkg.scripts.events = 'node scripts/query-events.mjs'; + pkg.scripts['validate:portable'] = 'npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test'; + pkg.scripts.validate = 'npm run validate:agents && npm run validate:portable'; +}); +updateJson('package-lock.json', lock => { + lock.packages[''].version = '0.2.0'; +}); + +// Ignore durable runtime state. +let gitignore = read('.gitignore'); +if (!gitignore.includes('.opencode/agent-loop-state/')) { + gitignore = gitignore.replace('.opencode/agent-loop-logs/\n', '.opencode/agent-loop-logs/\n.opencode/agent-loop-state/\n'); + write('.gitignore', gitignore); +} + +// Public tool output and descriptions. +let plugin = read('.opencode/plugins/agent-loop.js'); +plugin = plugin.replace(" logPath: result.logPath,\n budget:", " logPath: result.logPath,\n eventLogPath: result.eventLogPath || undefined,\n budget:"); +plugin = plugin.replace(" eventLogPath: s.eventLogPath || undefined,\n budget:", " eventLogPath: s.eventLogPath || undefined,\n budget:"); +plugin = plugin.replace('or escalate (GPT-5.5 diagnosis)', 'or escalate (GPT-5.6 diagnosis)'); +plugin = plugin.replace('across build, test, and review calls.', 'across smoke, build, test, review, fix, and escalation calls.'); +write('.opencode/plugins/agent-loop.js', plugin); + +// Bound paid parent orchestration turns. +replace('agents/orchestrator.md', 'steps: 200', 'steps: 100'); + +// Budget policy validator additions. +let validator = read('scripts/check-budget-config.mjs'); +validator = validator.replace( + " nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true });\n", + " nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true });\n nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true, nullable: true });\n if (budgets.max_workflow_calls_per_task !== null && budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1');\n if (typeof budgets.persist_state !== 'boolean') fail('budgets.persist_state must be boolean');\n if (typeof budgets.state_path !== 'string' || !budgets.state_path) fail('budgets.state_path must be a non-empty string');\n" +); +validator = validator.replace( + "if (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) {", + "if (config.provider_timeouts_ms?.local !== config.general?.local_model_request_timeout_seconds * 1000) {\n fail('provider_timeouts_ms.local must match general.local_model_request_timeout_seconds');\n}\nif (!config.events || config.events.schema_version !== '1.0.0') fail('events.schema_version must be 1.0.0');\n\nif (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) {" +); +write('scripts/check-budget-config.mjs', validator); + +console.log('v0.2 release migration applied'); From 5ab59d66123d4bb2cff3ef86a7a0696150eb4b03 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:52:39 -0400 Subject: [PATCH 022/105] chore: apply v0.2 release migration --- .github/workflows/apply-v020-release.yml | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/apply-v020-release.yml diff --git a/.github/workflows/apply-v020-release.yml b/.github/workflows/apply-v020-release.yml new file mode 100644 index 0000000..13af42f --- /dev/null +++ b/.github/workflows/apply-v020-release.yml @@ -0,0 +1,38 @@ +name: Apply v0.2 release migration + +on: + push: + branches: + - release/v0.2.0-reliability + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Apply migration + run: node scripts/apply-v020-release.mjs + - name: Install dependencies + run: npm ci + - name: Validate + run: npm run validate + - name: Commit generated changes + run: | + git add -A + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "feat: finish v0.2 reliability foundations" + git push origin HEAD:release/v0.2.0-reliability From e9aaea9b125aa5554dd35d6623c0f832db924452 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:53:01 +0000 Subject: [PATCH 023/105] feat: finish v0.2 reliability foundations --- .gitignore | 1 + .opencode/plugins/agent-loop.js | 5 +++-- agents/orchestrator.md | 2 +- lib/budget-manager.mjs | 2 +- lib/failover-handler.mjs | 17 ++++++++++++----- package-lock.json | 2 +- package.json | 8 +++++--- scripts/check-budget-config.mjs | 9 +++++++++ 8 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 38e31d9..e3908ac 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ Thumbs.db !.opencode/command/feature.md .opencode/node_modules/ .opencode/agent-loop-logs/ +.opencode/agent-loop-state/ *.bak # Benchmark output (gitignored temp location) diff --git a/.opencode/plugins/agent-loop.js b/.opencode/plugins/agent-loop.js index 7e89c8c..8ced407 100644 --- a/.opencode/plugins/agent-loop.js +++ b/.opencode/plugins/agent-loop.js @@ -40,6 +40,7 @@ function concise(result) { review: result.review, requiresUserInput: result.requiresUserInput === true, logPath: result.logPath, + eventLogPath: result.eventLogPath || undefined, budget: result.budget || undefined, steps }; @@ -52,10 +53,10 @@ export default async function AgentLoopPlugin() { description: 'Run the OpenCode agent-loop runtime for a development task. Use for non-trivial build/test/review work; do not use for simple questions.', args: { task: tool.schema.string().min(1).describe('The complete user request to run through the agent loop.'), - mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.5 diagnosis)'), + mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.6 diagnosis)'), maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum task-level retry cycles. Provider failover is handled separately.'), models: tool.schema.array(tool.schema.string()).optional().describe('Pre-verified model IDs from a prior smoke call to restrict which models are used.'), - taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across build, test, and review calls.') + taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across smoke, build, test, review, fix, and escalation calls.') }, async execute(args, context) { if (process.env.AGENT_LOOP_CHILD === '1') { diff --git a/agents/orchestrator.md b/agents/orchestrator.md index 6ae6392..5c5c1ff 100644 --- a/agents/orchestrator.md +++ b/agents/orchestrator.md @@ -3,7 +3,7 @@ mode: primary model: opencode-go/deepseek-v4-flash temperature: 0.1 reasoning_effort: medium -steps: 200 +steps: 100 description: > Orchestrates a complete feature lifecycle. It inspects and plans the work, obtains approval, then runs smoke, build, test, review, and escalation stages diff --git a/lib/budget-manager.mjs b/lib/budget-manager.mjs index dd29aa9..3f5d403 100644 --- a/lib/budget-manager.mjs +++ b/lib/budget-manager.mjs @@ -150,7 +150,7 @@ function budgetSettings(config = {}, cwd = process.cwd()) { failClosedOnUnknownPricing: raw.fail_closed_on_unknown_pricing === true, ledgerTtlMs: positiveInteger(raw.ledger_ttl_minutes, DEFAULT_LEDGER_TTL_MINUTES) * 60 * 1000, maxTrackedTasks: positiveInteger(raw.max_tracked_tasks, DEFAULT_MAX_TRACKED_TASKS), - persistenceEnabled: raw.persist_state !== false, + persistenceEnabled: raw.persist_state === true, statePath: process.env.AGENT_LOOP_BUDGET_STATE_PATH || resolve(cwd, configuredPath) }; } diff --git a/lib/failover-handler.mjs b/lib/failover-handler.mjs index 77e61e9..96dbf82 100644 --- a/lib/failover-handler.mjs +++ b/lib/failover-handler.mjs @@ -11,6 +11,8 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { deriveProviderFromModel } from './provider-adapters.mjs'; import { reapExpiredCooldowns, applyCooldown, @@ -35,6 +37,11 @@ import { * @property {string} exactNextAction */ +function checkpointPath(taskId) { + const safe = String(taskId || 'task').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128); + return resolve(tmpdir(), `task-checkpoint-${safe}.json`); +} + const RETRYABLE_SYSTEM_ERRORS = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE']; const RETRYABLE_MESSAGE_PATTERNS = [ /rate\s*limit/i, @@ -246,7 +253,7 @@ export class FailoverHandler { const tier = model.tier || model.poolTier || 'freeCloud'; // Skip if this model's provider is in cooldown (provider-wide failure) - const provider = modelId.split('/')[0]; + const provider = deriveProviderFromModel(modelId); if (providerCooldowns[provider] && Date.now() < providerCooldowns[provider]) { log({ type: 'skip-provider-cooldown', taskId, role, modelId, provider, cooldownUntil: new Date(providerCooldowns[provider]).toISOString() }); continue; @@ -360,7 +367,7 @@ export class FailoverHandler { // If provider-wide failure, mark provider cooldown if (classification.providerScope === 'provider-wide') { - const provider = modelId.split('/')[0]; + const provider = deriveProviderFromModel(modelId); const cooldownMs = this.config?.cooldowns?.provider_wide_failure_minutes || 10; providerCooldowns[provider] = Date.now() + cooldownMs * 60 * 1000; log({ type: 'provider-wide-failure', taskId, role, modelId, provider, cooldownMinutes: cooldownMs }); @@ -556,7 +563,7 @@ export class FailoverHandler { * @returns {Promise} */ async saveCheckpoint(taskId, data) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); const payload = { savedAt: new Date().toISOString(), data @@ -571,7 +578,7 @@ export class FailoverHandler { * @returns {Promise} The saved data, or null if no checkpoint exists */ async loadCheckpoint(taskId) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); if (!existsSync(filePath)) return null; const raw = readFileSync(filePath, 'utf-8'); try { @@ -589,7 +596,7 @@ export class FailoverHandler { * @returns {Promise} */ async clearCheckpoint(taskId) { - const filePath = `/tmp/task-checkpoint-${taskId}.json`; + const filePath = checkpointPath(taskId); if (existsSync(filePath)) { unlinkSync(filePath); } diff --git a/package-lock.json b/package-lock.json index b2d6b69..eb46b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "@opencode-ai/plugin": "1.18.4" }, "name": "opencode-agent-loop", - "version": "0.1.1" + "version": "0.2.0" }, "node_modules/@ai-sdk/provider": { "version": "3.0.8", diff --git a/package.json b/package.json index bc264c4..b74e4d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-agent-loop", - "version": "0.1.1", + "version": "0.2.0", "description": "A structured multi-agent feature-development loop for OpenCode with free-first subagent routing and controlled paid fallback.", "type": "module", "private": true, @@ -17,12 +17,14 @@ "node": ">=18" }, "scripts": { - "test": "node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", + "test": "node tests/reliability-v020-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", "validate:agents": "bash scripts/validate-agent-configs.sh", "validate:routing": "node scripts/check-routing-defaults.mjs", "validate:budget": "node scripts/check-budget-config.mjs", "validate:feature": "node scripts/check-feature-contract.mjs", - "validate": "npm run validate:agents && npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test" + "validate": "npm run validate:agents && npm run validate:portable", + "events": "node scripts/query-events.mjs", + "validate:portable": "npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test" }, "dependencies": { "@opencode-ai/plugin": "1.18.4" diff --git a/scripts/check-budget-config.mjs b/scripts/check-budget-config.mjs index 0c84654..348e285 100644 --- a/scripts/check-budget-config.mjs +++ b/scripts/check-budget-config.mjs @@ -39,6 +39,10 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { nonNegativeNumber(budgets.max_input_tokens_per_task, 'budgets.max_input_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_output_tokens_per_task, 'budgets.max_output_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true }); + nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true, nullable: true }); + if (budgets.max_workflow_calls_per_task !== null && budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1'); + if (typeof budgets.persist_state !== 'boolean') fail('budgets.persist_state must be boolean'); + if (typeof budgets.state_path !== 'string' || !budgets.state_path) fail('budgets.state_path must be a non-empty string'); nonNegativeNumber(budgets.ledger_ttl_minutes, 'budgets.ledger_ttl_minutes', { integer: true }); nonNegativeNumber(budgets.max_tracked_tasks, 'budgets.max_tracked_tasks', { integer: true }); if (budgets.ledger_ttl_minutes < 1) fail('budgets.ledger_ttl_minutes must be at least 1'); @@ -76,6 +80,11 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { } } +if (config.provider_timeouts_ms?.local !== config.general?.local_model_request_timeout_seconds * 1000) { + fail('provider_timeouts_ms.local must match general.local_model_request_timeout_seconds'); +} +if (!config.events || config.events.schema_version !== '1.0.0') fail('events.schema_version must be 1.0.0'); + if (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) { fail('retry.non_retryable_errors must include BUDGET_EXCEEDED'); } From 25faa8fcfef9f9d2f1429dc65d5f499312832ea9 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:54:05 -0400 Subject: [PATCH 024/105] docs: update roadmap for v0.2 --- docs/roadmap.md | 119 ++++++++++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 932b0c2..7ffa13a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,72 +1,73 @@ # Roadmap ## Vision -A reliable, safe, and extensible open-source framework for autonomous software-development task orchestration that works with any model provider and any project. -## Version 0.1 — Initial Public Release (Current) -**Focus**: Sanitized, documented, testable foundation. +A reliable, safe, and extensible open-source framework for autonomous software-development task orchestration that works with multiple model providers and project types. + +## Version 0.1 — Public foundation + +**Focus:** Sanitized, documented, testable foundation. - [x] Sanitized public release with no private references - [x] Core orchestration loop with stage enforcement -- [x] 10 specialized agent roles -- [x] Paid-primary model routing with multi-provider failover -- [x] Configuration validation and schema -- [x] Deterministic test fixtures (routing, runtime, integration) -- [x] Comprehensive documentation (architecture, providers, safety, etc.) -- [x] CI workflow with GitHub Actions -- [x] Security policy and vulnerability reporting -- [x] Contribution guidelines and code of conduct -- [x] CLI commands: /feature, /loop, /loop-init -- [x] Example fixtures for common workflows - -## Version 0.2 — Provider Adapters and Improved Resilience -**Focus**: Provider abstraction, improved failover, structured events. - -- [ ] Provider adapter interface for easier third-party model integration -- [ ] Structured event log schema for audit and debugging -- [ ] Improved checkpointing with partial progress recovery -- [ ] Budget enforcement (token and cost limits per task) -- [ ] Better retry strategies with exponential backoff configuration -- [ ] Additional example workflows (database migration, refactoring) -- [ ] Windows compatibility testing and fixes -- [ ] macOS compatibility validation - -## Version 0.3 — Plugin System and Observability -**Focus**: Extensibility, monitoring, safety hardening. - -- [ ] Plugin system for custom agent roles and workflow hooks -- [ ] Reusable evaluation harness for benchmarking agent performance -- [ ] Optional web dashboard for task progress visualization -- [ ] Stronger sandboxing (container-based execution) -- [ ] Community-contributed role packs -- [ ] Expanded local-model support documentation -- [ ] i18n of documentation - -## Version 0.4 — Enterprise Readiness -**Focus**: Scale, audit, compliance. +- [x] Specialized cloud and local roles +- [x] Paid orchestrator with free-first delegated-role routing +- [x] Deterministic routing, runtime, integration, and security tests +- [x] CI, contribution, security, and release workflows +- [x] `/feature`, `/loop`, and `/loop-init` commands + +## Version 0.2 — Reliability, portability, and observability + +**Focus:** Durable budgets, provider abstraction, structured events, and cross-platform validation. + +- [x] Configurable token, cost, and workflow-call budgets +- [x] Persistent budget state across plugin and process restarts +- [x] Same-model transient retries with exponential backoff and jitter +- [x] Provider adapter interface and custom adapter example +- [x] Local/Ollama provider recognition and timeout handling +- [x] Versioned structured event schema and append-only event log +- [x] Event query utility and recursive log redaction +- [x] Portable checkpoint paths +- [x] Linux, macOS, Windows, WSL, and Git Bash guidance +- [x] Cross-platform Node runtime CI +- [x] Scheduled OpenCode patch compatibility workflow +- [x] Runtime configuration schema and integration-contract validation + +## Version 0.3 — Recovery and evaluation + +**Focus:** Deeper resilience, performance measurement, and operational visibility. + +- [ ] Resumable workflow controller with durable stage checkpoints +- [ ] Automatic recovery from interrupted build/test/review stages +- [ ] Reusable evaluation harness for agent quality, latency, and cost +- [ ] Optional task-progress dashboard backed by structured events +- [ ] Container-based execution profiles for stronger isolation +- [ ] Community-contributed role and provider packs +- [ ] Expanded local-model benchmarking and hardware guidance + +## Version 0.4 — Team and enterprise workflows + +**Focus:** Scale, audit, and administrative controls. - [ ] Multi-project orchestration - [ ] Persistent task queues and backlog processing -- [ ] Role-based access control for team use -- [ ] Audit trail with tamper-evident logging -- [ ] Compliance documentation (SOC2, GDPR) -- [ ] SLA monitoring for model providers +- [ ] Team role-based access control +- [ ] Tamper-evident audit storage +- [ ] Provider SLA and spend monitoring - [ ] SSO and team authentication integration +- [ ] Compliance deployment guidance ## Version 1.0 — Stable API -**Focus**: API stability, production hardening, ecosystem growth. - -- [ ] Stable, versioned configuration schema -- [ ] Backward-compatible migration paths -- [ ] Extensive integration testing across providers -- [ ] Performance benchmarks and optimization -- [ ] Official package registries (npm, Homebrew, Docker) -- [ ] Third-party provider certification program - -## How to Influence the Roadmap -- Open GitHub issues with the "enhancement" label -- Participate in GitHub Discussions -- Submit pull requests for planned features -- Share your use cases and requirements - -The roadmap is a living document. Priorities may shift based on community feedback, security requirements, and contributor availability. \ No newline at end of file + +**Focus:** Long-term compatibility and ecosystem growth. + +- [ ] Stable, versioned public APIs +- [ ] Backward-compatible configuration migrations +- [ ] Extensive live-provider integration testing +- [ ] Published performance and reliability benchmarks +- [ ] Official npm, Homebrew, and container distributions +- [ ] Upstream or extension-based replacement for the OpenCode source patch + +## How to influence the roadmap + +Open an issue describing the use case, safety constraints, expected behavior, and evidence that would prove completion. The roadmap is a living document; reliability and security issues take priority over adding more agent roles. From 67466125e39d02de6c3348b7a02793fbdc5dbe45 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:55:01 -0400 Subject: [PATCH 025/105] docs: document v0.2 policy and state --- docs/configuration.md | 117 ++++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 60 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 966254e..343d3e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,114 +1,111 @@ # Configuration -OpenCode Agent Loop separates stable model metadata, role ordering, and runtime policy into three JSON files. +OpenCode Agent Loop separates stable model metadata, role ordering, runtime policy, and generated state. -## Files +## Stable configuration ### `config/free-first-config.json` -Controls global behavior such as: - -- whether free-first routing is enabled -- whether paid fallback is allowed -- retry and cooldown durations -- smoke-test behavior -- privacy classifications -- paid-call limits -- delegated-worker token and cost budgets - -The file references `config/free-first-config-schema.json` for editor validation. Treat it as policy. Do not store provider keys or other credentials in it. +Controls free-first and paid-fallback policy, retries, cooldowns, provider timeouts, privacy classifications, budgets, state retention, and structured event logging. Treat it as policy; never store credentials in it. ### `config/free-first-pools.json` -Defines the ordered models available to each role. Every pool uses ordered failover. A typical pool contains: - -1. one or more free models from different providers -2. optional local models -3. one or more paid fallbacks - -Runtime health data should not be committed to this file. Fields such as `cooldown_until`, `consecutive_failures`, and `last_failure_reason` belong in an ignored runtime-state file. +Defines ordered model candidates for every role. A pool normally contains free models from different providers, optional local models, and controlled paid fallbacks. Runtime cooldowns and failure counters do not belong in this committed file. ### `config/model-registry.json` -Stores model capabilities and data-handling metadata, including: +Stores stable model identity, capability, privacy, retirement, and pricing metadata. -- provider and model ID -- context and output limits -- tool and vision support -- coding, orchestration, review, and debugging scores -- privacy classification -- whether sensitive code is allowed -- paid equivalent or fallback information -- structured pricing where known +### `config/free-first-config-schema.json` -The registry is descriptive configuration, not a runtime log. Health-check timestamps and failure counters should be initialized to clean values in version control. +Defines the supported policy structure. CI also runs semantic checks that JSON Schema alone cannot express. ## Routing strategy -The intended default strategy is: +The default strategy is: - paid DeepSeek V4 Flash for the parent orchestrator -- free-first pools for delegated build, test, review, and related worker calls -- paid fallback only after suitable free providers are unavailable or fail +- free-first pools for delegated build, test, review, exploration, and reconciliation work +- local models for suitable low-cost or private tasks +- controlled paid fallback after suitable free/local choices fail or are unavailable - GPT-5.6 Luna for explicit escalation and difficult diagnosis -The `/feature` command must make delegated calls through the `agent_loop` custom tool. Direct use of OpenCode's built-in `task` tool bypasses this package's router, failover controller, and budget ledger. +The `/feature` command must make delegated calls through `agent_loop`. Direct use of OpenCode's built-in `task` tool bypasses this package's router and safety controls. + +Provider adapters normalize identity, timeout selection, and provider-specific errors. See [Provider Adapters](provider-adapters.md). + +## Retry policy + +`maxRetries` controls retries of the same model after transient provider or network failures. Retries use exponential backoff plus jitter and count toward the task budget. Provider failover begins only after same-model retries are exhausted. + +Authentication, billing, safety, invalid-request, task-quality, cancellation, and budget failures are terminal. ## Task budgets -The `budgets` section in `config/free-first-config.json` enforces a shared limit for one stable task ID across smoke, build, test, review, escalation, and provider-failover attempts. +One stable task ID shares a ledger across smoke, build, test, review, fixes, escalation, and provider failover. Default limits are: -- 250,000 total tokens +- 250,000 delegated-worker tokens - 200,000 input tokens - 50,000 output plus reasoning tokens -- $1.00 estimated or provider-reported cost +- $1.00 estimated or provider-reported delegated-worker cost +- 12 workflow calls -OpenCode `step_finish` events provide input, output, reasoning, cache-read, cache-write, and provider-reported cost data. The runtime tracks those values by task step and model. Cost estimation uses structured `pricing` fields in `config/model-registry.json`; legacy price text is supported for compatibility. Unknown paid-model prices use the conservative fallback rates in `unknown_paid_model_pricing` unless `fail_closed_on_unknown_pricing` is enabled. +The greater of estimated and provider-reported cost is charged. Unknown paid-model prices use `unknown_paid_model_pricing` unless fail-closed behavior is enabled. -The reported `scope` is `delegated-workers`. The parent orchestrator model's own conversation usage is not included in this ledger, so the budget snapshot must not be presented as the complete cost of the entire OpenCode session. +### Persistence -When any limit is crossed, the active worker is terminated, failover stops, and the structured result returns `code: "BUDGET_EXCEEDED"` with the complete worker-budget snapshot. The runtime continues recording any final usage events emitted while the process shuts down so the ledger does not understate the completed request. +With `persist_state: true`, ledgers are atomically stored at: -Reuse the same optional `taskId` in every sequential `agent_loop` call belonging to one feature. Starting a new task ID after exhaustion would bypass the configured safety limit and is prohibited by the `/feature` contract. +```text +/.opencode/agent-loop-state/budgets.json +``` + +They survive OpenCode and plugin restarts. `ledger_ttl_minutes` and `max_tracked_tasks` bound retained state. `AGENT_LOOP_BUDGET_STATE_PATH` can override the path for testing or managed deployments. -### Ledger retention +A snapshot reports limits, remaining allowance, token and cost usage, workflow calls by stage, per-stage and per-model totals, unknown-pricing models, and exceeded reasons. -Budget ledgers live in the long-running plugin process so separate stage calls can share totals. To prevent unbounded memory growth: +`BUDGET_EXCEEDED` is terminal for the task ID. Starting the same work with a replacement ID is a budget bypass and is prohibited. -- `ledger_ttl_minutes` expires inactive ledgers; default: 1,440 minutes -- `max_tracked_tasks` caps retained ledgers; default: 1,000 tasks +### Parent-orchestrator scope -Expired or oldest inactive ledgers are pruned when a new tracker is created. These values affect in-memory accounting only; they do not delete repository logs. +The runtime limits parent orchestration with a workflow-call ceiling and a reduced maximum turn count. OpenCode does not currently expose parent-session token and cost events to this plugin, so precise parent-model tokens and dollars are not included. Results state `parentModelUsageIncluded: false` rather than claiming a complete task-cost total. -## Provider cooldowns +## Structured events -Rate-limit and transient availability failures may affect an entire provider. When a provider-wide failure is detected: +The default append-only stream is: + +```text +/.opencode/agent-loop-state/events.jsonl +``` -1. record the failure in runtime state -2. skip other models from that provider during the cooldown -3. select the next enabled model from a different provider -4. preserve task checkpoint context before retrying +Events conform to `config/agent-loop-event.schema.json` and cover stages, model attempts, retries, provider cooldowns, budget updates, and completion. See [Structured Event Logging](event-logging.md). -Do not commit local cooldown state back into the repository. +## Provider timeouts + +Timeouts are selected through provider adapters. Local IDs such as `ollama-9b-local` resolve to the `local` timeout even without a slash. The configured local timeout matches `general.local_model_request_timeout_seconds`. ## Privacy classifications -Tasks are classified before routing. Sensitive tasks must not be sent to providers whose registry entry disallows sensitive code. Local-only tasks must remain on approved local models. +Sensitive tasks must not go to providers whose registry entries disallow sensitive code. Local-only work must remain on approved local models. Configuration cannot replace review of current provider data policies. -Configuration cannot replace provider-contract review. Verify each provider's current data policy before using it with confidential code. +## Runtime state -## Environment variables +Generated state is ignored by Git: -See `.env.example` for supported environment variables. Keep secrets in the environment or an external secret manager. Never add API keys to JSON configuration, agent Markdown, logs, or issue reports. +- `.opencode/agent-loop-state/` +- `.opencode/agent-loop-logs/` +- role-pool health state files -## Validation +Do not commit these files or share them without reviewing them for sensitive material. -Run: +## Validation ```bash +npm ci npm run validate +npm run validate:portable ``` -The validation command checks repository structure, agent permissions, routing defaults, the budget policy and referenced schema, `/feature` integration, documentation links, and the deterministic test suites. \ No newline at end of file +The portable command runs Node-based checks on Linux, macOS, and Windows. Full validation additionally runs Bash permission checks. From 9eeeb4017956d1a1ba738d8a40c1f634c5b902d1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:55:32 -0400 Subject: [PATCH 026/105] docs: describe v0.2 runtime architecture --- docs/architecture.md | 122 +++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 81 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 12fb4b1..66fc48a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,11 +2,11 @@ ## Overview -OpenCode Agent Loop coordinates specialized agents through a gated software-development lifecycle: +OpenCode Agent Loop coordinates a gated development lifecycle: -`plan → approve → baseline test → implement → verify and review → fix or escalate → commit` +`plan → approve → smoke → build → test → review → fix or escalate → commit` -The parent orchestrator remains responsible for inspection, planning, approval, stage order, and the final commit. Delegated model execution is centralized in the `agent_loop` custom tool so routing, failover, paid-fallback controls, and worker-budget enforcement cannot be bypassed. +The paid parent orchestrator handles inspection, planning, approval, stage order, and the final commit. Every delegated model call goes through `agent_loop`, which centralizes provider adapters, free-first routing, retries, failover, budget enforcement, and structured events. ```text User request @@ -14,118 +14,78 @@ The parent orchestrator remains responsible for inspection, planning, approval, v +--------------------------------------+ | Parent orchestrator | -| Inspects, plans, requests approval | -| Reuses one stable feature task ID | +| Plans and reuses one stable task ID | +| Turn cap + workflow-call budget | +--------------------------------------+ | v +--------------------------------------+ -| agent_loop custom tool | +| agent_loop runtime | | Smoke | Build | Test | Review | -| Escalate | Failover | Budget guard | +| Retry | Failover | Escalate | +--------------------------------------+ | v +--------------------------------------+ -| Specialized worker processes | -| Free-first pools and paid fallback | +| Reliability services | +| Provider adapters | Persistent budget| +| Structured events | Checkpoints | ++--------------------------------------+ + | + v ++--------------------------------------+ +| OpenCode worker processes | +| Free/local pools -> paid fallback | +--------------------------------------+ ``` -## Workflow state machine - -The normal path is: - -1. **PLANNING** — inspect the repository, discover commands, and define acceptance criteria -2. **AWAITING_APPROVAL** — present the plan and wait for explicit approval -3. **SMOKE_TESTING** — identify responsive worker models through `agent_loop` -4. **IMPLEMENTING** — run the approved build request with the stable task ID -5. **VERIFYING** — run post-change verification through the test role -6. **REVIEWING** — independently inspect the diff, tests, security, and acceptance criteria -7. **READY_TO_COMMIT** — confirm gates, inspect staged content, and create a focused commit -8. **COMPLETED** — report results and budget scope - -Conditional states are: +## Workflow contract -- **FIXING** — a bounded build call corrects failed test or review findings, then returns to verification and review -- **ESCALATING** — a stronger diagnostic role investigates repeated non-budget failures -- **BLOCKED** — progress requires user input, an unavailable dependency, or an exhausted budget +A feature uses one stable task ID across every stage. `BUDGET_EXCEEDED` is terminal: the orchestrator must not continue under a replacement ID or bypass `agent_loop` with direct task delegation. -`BUDGET_EXCEEDED` is terminal. The orchestrator must not continue under a fresh task ID or fall back to direct task delegation. +Test and review are independent gates. Every fix cycle returns through both. Only the parent orchestrator may create the final local commit, and it never pushes automatically. ## Agent roles -The repository contains cloud and local roles. The exact number may change as agents are added or retired, so documentation describes capabilities rather than relying on a fixed count. +The exact number of roles may change. Current capabilities include orchestration, standard and trivial building, testing, independent review, exploration, reconciliation, escalation, and local private or low-cost work. See [Agent Roles](agent-roles.md). -| Role | Responsibility | Typical access | -|------|----------------|----------------| -| Orchestrator | Inspection, planning, stage enforcement, final commit | Read, `agent_loop`, limited Git | -| Build worker | Approved implementation | Read, edit, bounded shell | -| Trivial builder | Small bounded changes | Read, edit, bounded shell | -| Test agent | Baseline and post-change verification | Read, test edits, bounded shell | -| Review agent | Independent review | Read-only | -| Explore agent | Codebase research | Read-only | -| Reconcile agent | Integrate overlapping work | Read, edit, bounded Git | -| Escalation agent | Diagnose repeated failures | Read, edit, optionally web | -| Local agents | Sensitive, offline, or low-cost bounded work | Role-specific, local model only | +## Routing and retries -See [Agent Roles](agent-roles.md). +Provider adapters identify models, select timeout keys, and normalize provider errors. Transient failures can retry the same model using exponential backoff and jitter. After retries are exhausted, failover moves to another eligible provider. Non-retryable task, auth, billing, safety, cancellation, and budget failures stop immediately. -## Model routing +Free-first worker pools may include local models and controlled paid fallback. GPT-5.6 Luna is reserved for explicit escalation. -The default design uses two routing policies: +## Budgets -- **Parent orchestrator:** paid DeepSeek V4 Flash for dependable coordination -- **Delegated workers:** free-first ordered pools, local models where appropriate, then controlled paid fallback +Budget state is persisted atomically per project under `.opencode/agent-loop-state/`. The ledger covers delegated tokens and cost plus the number of parent workflow calls. The parent orchestrator also has a bounded turn count. -GPT-5.6 Luna is reserved for explicit escalation and difficult diagnosis rather than routine work. +OpenCode does not currently expose parent-session token and cost events to this plugin, so snapshots state `parentModelUsageIncluded: false`. They must not be described as complete end-to-end dollar totals. -Provider failures are classified before failover. Rate limits and transient availability failures may trigger provider-wide cooldowns; isolated request, safety, or billing failures should only affect the relevant model or credential path. +## Structured events -## Runtime architecture +The runtime emits versioned JSON Lines events for workflow calls, stages, attempts, retries, model selection, cooldowns, budget changes, and completion. Events are recursively redacted and queryable with `scripts/query-events.mjs`. See [Structured Event Logging](event-logging.md). -```text -OpenCode TUI - | - +-- /feature --> orchestrator - | - stable taskId - | - v - agent_loop tool - | - v - agent-loop-controller.mjs - | - +----------+----------+ - | | | - failover paid guard budget guard - | | | - +----------+----------+ - | - v - OpenCode worker process -``` - -The plugin entry point is `.opencode/plugins/agent-loop.js`. Runtime execution is centralized under `runtime/`, with routing and budget helpers under `lib/`. +## State and recovery -The budget ledger covers delegated worker processes. The parent orchestrator model's own message usage is outside that ledger and must be reported separately as an untracked scope limitation. - -## Configuration and state - -Stable configuration: +Stable configuration lives in: - `config/free-first-config.json` - `config/free-first-config-schema.json` - `config/free-first-pools.json` - `config/model-registry.json` -Transient health data, cooldowns, failure counts, task checkpoints, and worker logs should be written to ignored runtime-state files rather than committed configuration. Budget ledgers are retained in memory only for the configured TTL and maximum task count. +Ignored runtime state includes: + +- persistent budgets +- structured events +- provider cooldowns +- portable task checkpoints +- attempt and progress logs -See [Configuration](configuration.md). +The v0.2 foundations make interrupted-state reconstruction possible; full automatic stage resume remains planned for v0.3. -## Trust boundaries +## Compatibility and trust boundaries -Agent permissions reduce accidental damage but are not an operating-system sandbox. Shell permissions must use explicit command patterns and deny destructive Git operations. Run untrusted work in a container or VM and review provider data policies before sending confidential code. +The portable Node runtime is tested on Linux, macOS, and Windows. Bash installation and permission validation require Linux, WSL, macOS, or Git Bash. A scheduled workflow builds the patched OpenCode revision and verifies required source contracts. -See [Safety Model](safety-model.md). \ No newline at end of file +Agent permissions and redaction reduce risk but are not an operating-system sandbox. Run untrusted repositories in a container or VM, keep credentials out of prompts, and review provider data policies. From 8c149590780b25ee39c3e5bf7ee8394c66bcfd3c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:56:28 -0400 Subject: [PATCH 027/105] docs: present v0.2 reliability features --- README.md | 150 +++++++++++++++++++++++++++--------------------------- 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 5703d9c..25b7826 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,44 @@ # OpenCode Agent Loop -[![Project Status](https://img.shields.io/badge/status-pre--release-yellow?style=for-the-badge)](https://github.com/wryan2986/opencode-agent-loop) +[![Project Status](https://img.shields.io/badge/status-v0.2.0--pre--release-yellow?style=for-the-badge)](https://github.com/wryan2986/opencode-agent-loop) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A reusable OpenCode agent-loop package for structured feature development with specialized planning, building, testing, review, recovery, and local agents. +A reusable OpenCode agent-loop package for structured feature development with specialized planning, building, testing, independent review, recovery, and local agents. > **Independent project:** OpenCode Agent Loop is a community project. It is not built, maintained, or endorsed by the OpenCode team. -The workflow is: +`plan → approve → smoke → build → test → review → fix/escalate → commit` -`plan → approve → baseline test → build → verify → review → fix/escalate → commit` - -The orchestrator uses a paid DeepSeek model for reliable coordination. Subagent roles use free-first model pools with automatic provider failover and controlled paid fallback. +The parent orchestrator uses paid DeepSeek for coordination. Delegated workers use free-first model pools with same-model retries, provider failover, local-model support, persistent budgets, structured events, and controlled paid fallback. ## Important prerequisite -This pre-release currently requires a **patched OpenCode build**. Stock OpenCode does not expose every subagent failure signal required by the orchestrator's failover logic. +This pre-release requires a **patched OpenCode build**. Stock OpenCode does not expose every subagent failure signal required for reliable failover. -Read [Required OpenCode Fork](docs/opencode-fork.md) before installation. The loop may incorrectly treat failed subagent work as successful without the patch. +Read [Required OpenCode Patch](docs/opencode-fork.md) before installation. A scheduled compatibility workflow verifies that the patch applies, builds, and retains its required source contracts against the configured upstream revision. ## Quick start -### 1. Prepare the patched OpenCode build +### 1. Prepare patched OpenCode -Follow [docs/opencode-fork.md](docs/opencode-fork.md), place the resulting `opencode` executable in your `PATH`, and verify it: +Follow [docs/opencode-fork.md](docs/opencode-fork.md), place the resulting executable on `PATH`, and verify it: ```bash opencode --version ``` -### 2. Clone this repository +### 2. Install the agent loop ```bash git clone https://github.com/wryan2986/opencode-agent-loop.git cd opencode-agent-loop -``` - -### 3. Install the package - -```bash bash scripts/install.sh -``` - -### 4. Activate it in the current shell - -```bash source ~/.bashrc # or ~/.zshrc ``` -### 5. Initialize a project +Windows users should prefer WSL 2 or use Git Bash. See [Platform Support](docs/platforms.md). + +### 3. Initialize a project ```bash cd /path/to/your/project @@ -56,53 +46,76 @@ opencode /loop-init ``` -### 6. Run a feature +### 4. Run a feature ```text /feature Implement user authentication ``` -## Architecture +## Reliability architecture ```text User request | v +--------------------------------------+ -| Orchestrator | -| Paid DeepSeek V4 Flash | -| Plans, delegates, enforces stages | +| Parent orchestrator | +| Plans and reuses one stable task ID | ++--------------------------------------+ + | + v ++--------------------------------------+ +| agent_loop runtime | +| Retry | Route | Failover | Budget | +--------------------------------------+ | v +--------------------------------------+ -| Free-first role routing | -| Test, build, review, explore | -| Free models -> controlled fallback | +| Provider adapters and worker pools | +| Free/local models -> paid fallback | +--------------------------------------+ | v +--------------------------------------+ -| Model registry and role pools | -| Capability, privacy, cooldown state | +| Persistent state and events | +| Budget ledger | JSONL audit stream | +--------------------------------------+ ``` -The routing configuration lives in: +Stable configuration lives in: -- `config/free-first-config.json` — global routing and fallback policy +- `config/free-first-config.json` — routing, retry, timeout, budget, and event policy - `config/free-first-pools.json` — ordered model pools by role -- `config/model-registry.json` — capabilities and privacy classifications +- `config/model-registry.json` — capabilities, privacy, retirement, and pricing metadata +- `config/free-first-config-schema.json` — supported policy schema +- `config/agent-loop-event.schema.json` — versioned event schema -See [Architecture](docs/architecture.md) and [Configuration](docs/configuration.md) for details. +See [Architecture](docs/architecture.md) and [Configuration](docs/configuration.md). ## Commands | Command | Description | |---------|-------------| -| `/feature ` | Run the staged feature workflow through the orchestrator | -| `/loop ` | Run the `agent_loop` custom tool directly | -| `/loop-init` | Add the project-specific agent-loop files to a repository | +| `/feature ` | Run the complete approved workflow through the orchestrator | +| `/loop ` | Run one `agent_loop` role call directly | +| `/loop-init` | Install project-specific agent-loop files | +| `npm run events -- --task ` | Query the local structured event log | + +## v0.2 safeguards + +- one stable task ID across all feature stages +- persistent token, cost, and workflow-call budgets +- terminal budget exhaustion with no replacement-ID bypass +- bounded paid parent orchestration turns +- same-model transient retries with exponential backoff and jitter +- provider adapters for identity, timeout, and error normalization +- local/Ollama timeout handling even for model IDs without `/` +- append-only, versioned, recursively redacted event logs +- portable checkpoint paths and cross-platform Node CI +- scheduled patched-OpenCode compatibility builds +- independent test and review gates before the final local commit + +Budget dollar and token totals cover delegated workers. The parent orchestrator's precise token and dollar use is not available from OpenCode's plugin event surface, so results state that limitation explicitly. ## Project structure @@ -110,69 +123,58 @@ See [Architecture](docs/architecture.md) and [Configuration](docs/configuration. opencode-agent-loop/ ├── agents/ Agent definitions ├── commands/ OpenCode slash commands -├── config/ Routing and model configuration -├── lib/ Routing and failover modules -├── runtime/ Runtime controller and worker execution -├── .opencode/ Plugin and project-local command integration +├── config/ Policy, pools, registry, and schemas +├── lib/ Routing, adapters, budgets, events, and failover +├── runtime/ Controller and worker execution +├── .opencode/ Plugin and project-local commands ├── skills/ Reusable project-analysis skills ├── templates/ Files installed into target projects -├── docs/ Architecture, configuration, safety, and usage -├── tests/ Deterministic automated tests -├── scripts/ Install, validation, and maintenance utilities -├── opencode.json Package-level OpenCode configuration -├── CHANGELOG.md Release history -├── CONTRIBUTING.md Contribution guidelines -├── SECURITY.md Vulnerability-reporting policy -└── LICENSE MIT License +├── docs/ Architecture, operations, safety, and usage +├── examples/ Extension examples +├── tests/ Deterministic and integration tests +├── scripts/ Installation, validation, query, and maintenance tools +└── .github/workflows/ CI, release, compatibility, and platform matrices ``` ## Safety model -The package applies several layers of protection: +The package requires explicit approval before implementation, routes workers through a budget-enforced runtime, runs independent testing and review, blocks automatic pushes, denies destructive Git commands, guards against recursion, and filters providers by privacy policy. -- explicit approval before implementation -- baseline and post-change testing -- independent read-only review -- no automatic push -- explicit Bash denials for destructive Git commands -- recursion guards for worker agents -- privacy-aware model filtering -- state checkpoints for interrupted work - -Prompt and permission controls are not an OS sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). +Prompt permissions and redaction are not an operating-system sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). ## Validation ```bash npm ci -npm test -bash scripts/validate.sh -node scripts/check-doc-links.mjs +npm run validate +npm run validate:portable ``` +The full command includes Bash permission checks. The portable command is exercised on Linux, macOS, and Windows. + ## Documentation - [Architecture](docs/architecture.md) - [Agent Roles](docs/agent-roles.md) - [Configuration](docs/configuration.md) +- [Provider Adapters](docs/provider-adapters.md) +- [Structured Event Logging](docs/event-logging.md) +- [Platform Support](docs/platforms.md) +- [Troubleshooting](docs/troubleshooting.md) - [Model Providers](docs/providers.md) - [Safety Model](docs/safety-model.md) -- [Required OpenCode Fork](docs/opencode-fork.md) +- [Required OpenCode Patch](docs/opencode-fork.md) - [TUI Integration](docs/tui-agent-loop-integration.md) - [Roadmap](docs/roadmap.md) -## Contributing - -Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request. - -## Security +## Contributing and security -Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md). +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Report vulnerabilities privately according to [SECURITY.md](SECURITY.md). ## License -Distributed under the MIT License. See [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). --- -This is a pre-release project. Features and APIs may change before v1.0.0. \ No newline at end of file +This is a pre-release project. APIs and configuration may change before v1.0.0. From 8f9f2a812d0b4b5515b8fc16b821d991ca1c0aa4 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:56:50 -0400 Subject: [PATCH 028/105] docs: document automated patch compatibility --- docs/opencode-fork.md | 72 ++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/docs/opencode-fork.md b/docs/opencode-fork.md index bc31582..9c5cf40 100644 --- a/docs/opencode-fork.md +++ b/docs/opencode-fork.md @@ -1,69 +1,63 @@ # Required OpenCode Patch -OpenCode Agent Loop currently requires a patched OpenCode build. The stock release does not expose all subagent failure information needed for reliable model failover. +OpenCode Agent Loop currently requires a patched OpenCode build. The stock release does not expose all subagent failure information needed for reliable failover. ## Why the patch is required -When a delegated agent fails because of a provider error, rate limit, timeout, quota problem, or non-retryable request error, the orchestrator must receive a structured failure. Without the patch, some failed task-tool calls can appear successful to the caller. - -That can cause the workflow to: - -- advance after an implementation failed -- skip model failover -- misclassify transient and permanent provider errors -- verify or review an incomplete change +Delegated provider errors, rate limits, timeouts, quota failures, and non-retryable request failures must reach the parent as structured failures. Without the patch, a failed child call can appear successful and allow the workflow to advance incorrectly. ## Patch contents -The repository includes: - ```text patches/opencode-subagent-failure-exposure.patch ``` -The patch adds or updates behavior for: - -- task-level model overrides -- provider-error classification -- subagent retry and failure metadata -- non-retryable free-usage-limit handling -- child-session failure propagation -- resilient session-title generation +The patch covers task-level model overrides, provider-error classification, retry metadata, free-usage-limit behavior, child-session failure propagation, and resilient session titles. ## Build process -OpenCode's source repository and build commands can change. Follow the current upstream development instructions for prerequisites and building, then apply this repository's patch before compiling. - -A typical flow is: +OpenCode's source layout and build procedure can change. Follow the current upstream development instructions for the checked-out revision, then apply this patch before compiling. ```bash -# Clone the current official OpenCode source repository. git clone https://github.com/anomalyco/opencode.git cd opencode - -# Apply the patch from this repository. git apply /absolute/path/to/opencode-agent-loop/patches/opencode-subagent-failure-exposure.patch - -# Install and build using the commands documented by upstream. +# Install and build using the checked-out revision's upstream instructions. ``` -Do not assume an old `npm run build` command remains valid. Use the package manager and build procedure specified by the checked-out OpenCode revision. +Do not assume an old npm build command remains valid. + +## Automated compatibility workflow + +`.github/workflows/opencode-patch-compat.yml` runs weekly and can be started manually with an OpenCode branch, tag, or commit. + +It: + +1. checks out the requested upstream revision +2. verifies and applies the patch +3. installs upstream Bun dependencies +4. builds a standalone OpenCode binary +5. verifies the patched source contracts +6. publishes the exact tested revision as a workflow artifact + +A passing scheduled run is useful evidence, but installations should still use a known tested revision rather than assuming the newest upstream commit is compatible. -## Verify the patched behavior +## Verify behavior -At minimum, verify these cases before using the loop on important work: +Before important use, verify: -1. A subagent provider failure is surfaced as a structured failure. -2. A rate limit is classified separately from authentication or billing errors. -3. Non-retryable failures stop retrying promptly. -4. A free-usage-limit failure allows the orchestrator to select another model. -5. A task-level model override reaches the child session. -6. Cancelling or aborting a child task does not erase its failure metadata. +1. provider failures surface as structured failures +2. rate limits differ from authentication and billing failures +3. non-retryable failures stop promptly +4. free-usage exhaustion permits another eligible model +5. task-level model overrides reach the child session +6. cancellation preserves failure metadata +7. JSON usage and cost events remain available to budget enforcement -Run the relevant upstream task, retry, session, and provider tests for the exact revision you built. +Run the relevant upstream task, retry, session, provider, and CLI tests for the exact revision you built. ## Compatibility warning -This patch is revision-sensitive. It may fail to apply cleanly after upstream changes, or it may apply while no longer producing the intended behavior. Treat a clean `git apply` as necessary but not sufficient; run the verification tests. +The patch is revision-sensitive. It may fail to apply, fail to build, or apply while no longer producing the intended behavior. Treat `git apply` success as necessary but insufficient. -The long-term goal is to remove this requirement by relying on equivalent upstream behavior or a stable provider/task extension interface. +The long-term goal is to replace the patch with equivalent upstream behavior or a stable provider/task extension interface. From d6cf0196105e23f569bc1f8737de121cb36ce0ad Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:26 -0400 Subject: [PATCH 029/105] docs: finalize v0.2.0 changelog --- CHANGELOG.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b67f03..afca94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,41 @@ ## [Unreleased] +## [0.2.0] - 2026-07-22 + ### Added -- Added configurable per-task token and cost budgets with live OpenCode usage tracking, model-registry pricing estimates, failover interruption, and structured budget output. Closes #4. -- Added the referenced free-first configuration schema and automated budget/feature contract validation. -- Added bounded in-memory budget-ledger retention with configurable TTL and task-count limits. +- Added configurable token, cost, and workflow-call budgets shared by one stable task ID. +- Added atomic budget persistence across OpenCode and plugin restarts with TTL and task-count retention limits. +- Added same-model transient retries with exponential backoff and jitter before provider failover. +- Added provider adapters for provider identity, aliases, timeout selection, local-model recognition, and error normalization. +- Added a custom provider-adapter example and extension documentation. +- Added a versioned structured event schema, append-only JSONL event stream, recursive redaction, and query utility. Closes #5. +- Added Linux, macOS, Windows, WSL, and Git Bash guidance plus a cross-platform Node CI matrix. Closes #6. +- Added scheduled and manually configurable patched-OpenCode compatibility builds. +- Added portable task-checkpoint paths. +- Added a workflow-call ceiling and reduced parent-orchestrator turn cap. +- Added v0.2 configuration, event, feature-contract, and reliability validation. + +### Changed + +- Routed every `/feature` worker stage through the budget-enforced `agent_loop` tool. +- Moved provider-specific runtime behavior behind the provider-adapter interface. Closes #7. +- Updated runtime state layout under `.opencode/agent-loop-state/`. +- Updated package and policy schema versions to 0.2.0 and 2.0.0 respectively. +- Updated architecture, configuration, platform, troubleshooting, event, and provider documentation. ### Fixed -- Treat local-unmetered models as zero-cost and account for a final usage event even when the JSON stream has no trailing newline. -- Route `/feature` delegation through the budget-enforced `agent_loop` tool and require one stable task ID across all stages. -- Continue recording usage emitted during worker shutdown after a budget-triggered termination. -- Mark budget totals explicitly as delegated-worker scope so parent-orchestrator usage is not misrepresented. +- Made the public `maxRetries` option perform actual same-model retries for transient failures. +- Corrected timeout selection for local/Ollama model IDs without a slash. +- Removed the hardcoded `/usr/local/bin/opencode` executable path so PATH resolution works across operating systems. +- Treat local-unmetered models as zero-cost. +- Account for final usage events emitted without a trailing newline or during budget-triggered shutdown. +- Stop retries, failover, and escalation immediately after `BUDGET_EXCEEDED`. +- Prevent replacement task IDs from being used to evade an exhausted feature budget. +- Strengthened recursive redaction for structured event and attempt logs. +- Clarified that exact parent-orchestrator token and dollar usage is unavailable, while parent workflow calls and turns are bounded. ## [0.1.1] - 2026-07-22 From 59424c856e37d2f740684162d8ff4faf5d55bbef Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:46 -0400 Subject: [PATCH 030/105] chore: remove one-time v0.2 migration workflow --- .github/workflows/apply-v020-release.yml | 38 ------------------------ 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/apply-v020-release.yml diff --git a/.github/workflows/apply-v020-release.yml b/.github/workflows/apply-v020-release.yml deleted file mode 100644 index 13af42f..0000000 --- a/.github/workflows/apply-v020-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Apply v0.2 release migration - -on: - push: - branches: - - release/v0.2.0-reliability - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Apply migration - run: node scripts/apply-v020-release.mjs - - name: Install dependencies - run: npm ci - - name: Validate - run: npm run validate - - name: Commit generated changes - run: | - git add -A - if git diff --cached --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "feat: finish v0.2 reliability foundations" - git push origin HEAD:release/v0.2.0-reliability From 92101c38b653db22e438145460b94cd0b1efbb04 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:54 -0400 Subject: [PATCH 031/105] chore: remove one-time v0.2 migration script --- scripts/apply-v020-release.mjs | 78 ---------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 scripts/apply-v020-release.mjs diff --git a/scripts/apply-v020-release.mjs b/scripts/apply-v020-release.mjs deleted file mode 100644 index dd6918b..0000000 --- a/scripts/apply-v020-release.mjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -function read(path) { return fs.readFileSync(path, 'utf8'); } -function write(path, content) { fs.writeFileSync(path, content); console.log(`updated ${path}`); } -function replace(path, before, after) { - const current = read(path); - if (!current.includes(before)) throw new Error(`missing expected text in ${path}: ${before.slice(0, 100)}`); - write(path, current.replace(before, after)); -} -function updateJson(path, mutate) { - const value = JSON.parse(read(path)); - mutate(value); - write(path, `${JSON.stringify(value, null, 2)}\n`); -} - -// Tests should persist only when policy explicitly enables it. -replace('lib/budget-manager.mjs', "persistenceEnabled: raw.persist_state !== false,", "persistenceEnabled: raw.persist_state === true,"); - -// Use adapters for provider-wide cooldown and portable checkpoint paths. -let failover = read('lib/failover-handler.mjs'); -failover = failover.replace( - "import { resolve } from 'node:path';\n", - "import { resolve } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { deriveProviderFromModel } from './provider-adapters.mjs';\n" -); -failover = failover.replace( - "const RETRYABLE_SYSTEM_ERRORS =", - "function checkpointPath(taskId) {\n const safe = String(taskId || 'task').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);\n return resolve(tmpdir(), `task-checkpoint-${safe}.json`);\n}\n\nconst RETRYABLE_SYSTEM_ERRORS =" -); -failover = failover.replaceAll("modelId.split('/')[0]", "deriveProviderFromModel(modelId)"); -failover = failover.replaceAll("`/tmp/task-checkpoint-${taskId}.json`", "checkpointPath(taskId)"); -write('lib/failover-handler.mjs', failover); - -// Package metadata and portable scripts. -updateJson('package.json', pkg => { - pkg.version = '0.2.0'; - const reliability = 'node tests/reliability-v020-tests.mjs'; - if (!pkg.scripts.test.includes(reliability)) pkg.scripts.test = `${reliability} && ${pkg.scripts.test}`; - pkg.scripts.events = 'node scripts/query-events.mjs'; - pkg.scripts['validate:portable'] = 'npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test'; - pkg.scripts.validate = 'npm run validate:agents && npm run validate:portable'; -}); -updateJson('package-lock.json', lock => { - lock.packages[''].version = '0.2.0'; -}); - -// Ignore durable runtime state. -let gitignore = read('.gitignore'); -if (!gitignore.includes('.opencode/agent-loop-state/')) { - gitignore = gitignore.replace('.opencode/agent-loop-logs/\n', '.opencode/agent-loop-logs/\n.opencode/agent-loop-state/\n'); - write('.gitignore', gitignore); -} - -// Public tool output and descriptions. -let plugin = read('.opencode/plugins/agent-loop.js'); -plugin = plugin.replace(" logPath: result.logPath,\n budget:", " logPath: result.logPath,\n eventLogPath: result.eventLogPath || undefined,\n budget:"); -plugin = plugin.replace(" eventLogPath: s.eventLogPath || undefined,\n budget:", " eventLogPath: s.eventLogPath || undefined,\n budget:"); -plugin = plugin.replace('or escalate (GPT-5.5 diagnosis)', 'or escalate (GPT-5.6 diagnosis)'); -plugin = plugin.replace('across build, test, and review calls.', 'across smoke, build, test, review, fix, and escalation calls.'); -write('.opencode/plugins/agent-loop.js', plugin); - -// Bound paid parent orchestration turns. -replace('agents/orchestrator.md', 'steps: 200', 'steps: 100'); - -// Budget policy validator additions. -let validator = read('scripts/check-budget-config.mjs'); -validator = validator.replace( - " nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true });\n", - " nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true });\n nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true, nullable: true });\n if (budgets.max_workflow_calls_per_task !== null && budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1');\n if (typeof budgets.persist_state !== 'boolean') fail('budgets.persist_state must be boolean');\n if (typeof budgets.state_path !== 'string' || !budgets.state_path) fail('budgets.state_path must be a non-empty string');\n" -); -validator = validator.replace( - "if (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) {", - "if (config.provider_timeouts_ms?.local !== config.general?.local_model_request_timeout_seconds * 1000) {\n fail('provider_timeouts_ms.local must match general.local_model_request_timeout_seconds');\n}\nif (!config.events || config.events.schema_version !== '1.0.0') fail('events.schema_version must be 1.0.0');\n\nif (!config.retry?.non_retryable_errors?.includes('BUDGET_EXCEEDED')) {" -); -write('scripts/check-budget-config.mjs', validator); - -console.log('v0.2 release migration applied'); From 9ebfaac47b994a2433ed7022f668f68afc65a99e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:58:05 -0400 Subject: [PATCH 032/105] docs: add v0.2 release notes --- docs/v0.2.0-release-notes.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/v0.2.0-release-notes.md diff --git a/docs/v0.2.0-release-notes.md b/docs/v0.2.0-release-notes.md new file mode 100644 index 0000000..a1db172 --- /dev/null +++ b/docs/v0.2.0-release-notes.md @@ -0,0 +1,16 @@ +# OpenCode Agent Loop v0.2.0 + +Version 0.2.0 focuses on reliability, portability, and observability. + +Highlights: + +- persistent token, cost, and workflow-call budgets +- same-model transient retries before provider failover +- provider adapter interface with local/Ollama recognition +- versioned structured event logging and a query utility +- stronger recursive log redaction +- portable checkpoint and executable handling +- Linux, macOS, and Windows runtime validation +- scheduled patched-OpenCode compatibility builds + +The parent orchestrator remains paid and bounded by a workflow-call ceiling and a lower maximum turn count. Exact parent-session token and dollar use is not available to the plugin, so budget output identifies that limitation rather than presenting worker totals as complete task cost. From 29d4a04cbd991fa61c3b07dd68450de00b368af4 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:58:16 -0400 Subject: [PATCH 033/105] docs: add database migration workflow example --- examples/database-migration-workflow.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 examples/database-migration-workflow.md diff --git a/examples/database-migration-workflow.md b/examples/database-migration-workflow.md new file mode 100644 index 0000000..a346bd5 --- /dev/null +++ b/examples/database-migration-workflow.md @@ -0,0 +1,15 @@ +# Database migration workflow example + +Use one stable task ID for every stage. + +1. Inspect the schema, migration framework, backup procedure, and rollback path. +2. Define acceptance criteria for forward migration, rollback, data preservation, and application compatibility. +3. Run `agent_loop` in `smoke` mode. +4. Run baseline tests before editing. +5. Run `build` with the migration request and responsive model IDs. +6. Run migration-specific tests against disposable data. +7. Run the normal `test` and independent `review` stages. +8. Fix findings with the same task ID, then repeat both gates. +9. Commit only the intended migration, application changes, and tests. + +Never run an autonomous migration against production data. Use an isolated database, verified backup, and explicit human deployment approval. From da7e8c9af202837f237c367e54aaa9be66739ce1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:59:31 -0400 Subject: [PATCH 034/105] test: construct credential fixtures safely --- tests/reliability-v020-tests.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs index ba50e32..ebdd89e 100644 --- a/tests/reliability-v020-tests.mjs +++ b/tests/reliability-v020-tests.mjs @@ -20,10 +20,11 @@ function testCustomProviderAdapter() { } function testRecursiveRedaction() { + const simulatedGithubToken = ['github', 'pat', 'abcdefghijklmnopqrstuvwxyz123456'].join('_'); const redacted = redactSensitive({ authorization: 'Bearer secret-value', nested: { apiKey: 'abc123', harmless: 'ok', output: 'TOKEN=do-not-log' }, - values: ['github_pat_abcdefghijklmnopqrstuvwxyz123456'] + values: [simulatedGithubToken] }); assert.equal(redacted.authorization, '[REDACTED]'); assert.equal(redacted.nested.apiKey, '[REDACTED]'); From 9b21b504a1c42d61abf2715b7dd21e1b30198902 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:00:14 -0400 Subject: [PATCH 035/105] feat: allow event log path overrides --- lib/event-log.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/event-log.mjs b/lib/event-log.mjs index 42db095..963cd28 100644 --- a/lib/event-log.mjs +++ b/lib/event-log.mjs @@ -44,8 +44,8 @@ export function createAgentLoopEvent({ type, taskId, stage, role, modelId, data }; } -export function defaultEventLogPath(cwd = process.cwd()) { - return resolve(cwd, '.opencode', 'agent-loop-state', 'events.jsonl'); +export function defaultEventLogPath(cwd = process.cwd(), configuredPath = '.opencode/agent-loop-state/events.jsonl') { + return process.env.AGENT_LOOP_EVENT_LOG_PATH || resolve(cwd, configuredPath); } export class AgentLoopEventLogger { From 6a30f9ec949912975b8098c2425ccf195b919bb1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:00:58 -0400 Subject: [PATCH 036/105] test: verify same-model retries before failover --- tests/reliability-v020-tests.mjs | 70 ++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs index ebdd89e..d194e7c 100644 --- a/tests/reliability-v020-tests.mjs +++ b/tests/reliability-v020-tests.mjs @@ -1,11 +1,12 @@ import { strict as assert } from 'node:assert'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { deriveProviderFromModel, providerTimeoutKey, ProviderAdapter, registerProviderAdapter, resolveProviderAdapter } from '../lib/provider-adapters.mjs'; import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; import { loadBudgetState, saveBudgetState } from '../lib/budget-store.mjs'; import { resolveTimeoutMs } from '../runtime/opencode-worker-runner.mjs'; +import { executeAgentTask } from '../runtime/execute-agent-task.mjs'; function testLocalProviderResolution() { assert.equal(deriveProviderFromModel('ollama-9b-local'), 'local'); @@ -62,8 +63,71 @@ function testPersistentBudgetStore() { rmSync(dir, { recursive: true, force: true }); } -for (const test of [testLocalProviderResolution, testCustomProviderAdapter, testRecursiveRedaction, testStructuredEvents, testPersistentBudgetStore]) { - test(); +async function testSameModelRetryBeforeFailover() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-retry-')); + const configPath = resolve(dir, 'config.json'); + const poolsPath = resolve(dir, 'pools.json'); + const registryPath = resolve(dir, 'registry.json'); + writeFileSync(configPath, JSON.stringify({ + general: { allow_paid_fallback: false, paid_fallback_allowed_roles: [], paid_fallback_max_calls_per_task: 0 }, + cooldowns: { default_cooldown_minutes: 1 }, + provider_timeouts_ms: { default: 1000 }, + retry: { + max_retries: 2, base_delay_ms: 0, max_delay_ms: 0, jitter_factor: 0, + retryable_http_codes: [429, 500, 502, 503, 504], + retryable_errors: ['ETIMEDOUT'], + non_retryable_errors: ['BUDGET_EXCEEDED'] + }, + budgets: { enabled: false, persist_state: false } + }), 'utf8'); + writeFileSync(poolsPath, JSON.stringify({ pools: { 'routine-builder': { models: [ + { model_id: 'free/first', role: 'primary', enabled: true }, + { model_id: 'free/second', role: 'free-fallback', enabled: true } + ] } } }), 'utf8'); + writeFileSync(registryPath, JSON.stringify({ models: [ + { model_id: 'free/first', classification: 'recurring free tier' }, + { model_id: 'free/second', classification: 'recurring free tier' } + ] }), 'utf8'); + + const calls = []; + const result = await executeAgentTask({ + taskId: 'retry-step', + budgetTaskId: 'retry-feature', + budgetStep: 'build', + role: 'builder', + agent: 'build-worker', + prompt: 'retry test', + cwd: dir, + configPath, + poolsPath, + registryPath, + logDir: dir, + maxRetries: 1, + workerAdapter: async ({ model }) => { + calls.push(model); + if (calls.length === 1) return { success: false, code: 'ETIMEDOUT', timedOut: true, exitCode: 1, stdout: '', stderr: 'timeout' }; + return { success: true, exitCode: 0, stdout: 'ok', stderr: '' }; + } + }); + + assert.equal(result.status, 'completed'); + assert.deepEqual(calls, ['free/first', 'free/first']); + assert.equal(result.attemptDetails.length, 2); + assert.equal(result.attemptDetails[1].retryIndex, 1); + rmSync(dir, { recursive: true, force: true }); +} + +const tests = [ + testLocalProviderResolution, + testCustomProviderAdapter, + testRecursiveRedaction, + testStructuredEvents, + testPersistentBudgetStore, + testSameModelRetryBeforeFailover +]; + +for (const test of tests) { + await test(); console.log(`OK: ${test.name}`); } console.log('reliability-v020-tests: passed'); From ab94a17757a31acabb4777062e9e743c28c64f0c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:02:04 -0400 Subject: [PATCH 037/105] chore: add final v0.2 hardening migration --- scripts/apply-v020-hardening.mjs | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 scripts/apply-v020-hardening.mjs diff --git a/scripts/apply-v020-hardening.mjs b/scripts/apply-v020-hardening.mjs new file mode 100644 index 0000000..a2c2050 --- /dev/null +++ b/scripts/apply-v020-hardening.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +function read(path) { return fs.readFileSync(path, 'utf8'); } +function write(path, content) { fs.writeFileSync(path, content); console.log(`updated ${path}`); } +function replace(path, before, after) { + const current = read(path); + if (!current.includes(before)) throw new Error(`missing text in ${path}: ${before.slice(0, 120)}`); + write(path, current.replace(before, after)); +} + +// Authentication, billing, and safety failures are terminal, even when a +// provider does not supply a structured error code. +let failover = read('lib/failover-handler.mjs'); +failover = failover.replace(" /authentication|unauthorized|forbidden/i,\n", ''); +failover = failover.replace(" /authentication.*(nvidia|provider|failed)/i,\n", ''); +failover = failover.replace( + " const nonRetryableErrors = this.config?.retry?.non_retryable_errors || [];\n\n if (nonRetryableErrors.includes(code)", + " const nonRetryableErrors = this.config?.retry?.non_retryable_errors || [];\n\n if (/authentication|unauthorized|forbidden|invalid api key|invalid token/i.test(message)) {\n return { retryable: false, reason: 'UNAUTHORIZED', category: 'configuration' };\n }\n if (/billing|payment required|insufficient (credit|balance)/i.test(message)) {\n return { retryable: false, reason: 'BILLING_DISABLED', category: 'configuration' };\n }\n if (/safety rejection|content[- ]filter|content policy/i.test(message)) {\n return { retryable: false, reason: 'SAFETY_REJECTION', category: 'task' };\n }\n\n if (nonRetryableErrors.includes(code)" +); +write('lib/failover-handler.mjs', failover); + +replace( + '.opencode/plugins/agent-loop.js', + "Maximum task-level retry cycles. Provider failover is handled separately.", + "Maximum same-model retries for transient failures. Provider failover begins after these retries are exhausted." +); + +let events = read('lib/event-log.mjs'); +events = events.replace( + "constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd) } = {}) {", + "constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd), enabled = true } = {}) {" +); +events = events.replace( + " this.path = path;\n mkdirSync(dirname(path), { recursive: true });", + " this.path = path;\n this.enabled = enabled !== false;\n if (this.enabled) mkdirSync(dirname(path), { recursive: true });" +); +events = events.replace( + " appendFileSync(this.path, `${JSON.stringify(event)}\\n`, { encoding: 'utf8', mode: 0o600 });", + " if (this.enabled) appendFileSync(this.path, `${JSON.stringify(event)}\\n`, { encoding: 'utf8', mode: 0o600 });" +); +events = events.replace( + " query({ type, stage, role, modelId, limit = 200 } = {}) {\n let lines = [];", + " query({ type, stage, role, modelId, limit = 200 } = {}) {\n if (!this.enabled) return [];\n let lines = [];" +); +write('lib/event-log.mjs', events); + +let controller = read('runtime/agent-loop-controller.mjs'); +controller = controller.replace( + "import { AgentLoopEventLogger } from '../lib/event-log.mjs';", + "import { AgentLoopEventLogger, defaultEventLogPath } from '../lib/event-log.mjs';" +); +controller = controller.replace( + " const events = new AgentLoopEventLogger({ taskId, cwd });", + " const events = new AgentLoopEventLogger({\n taskId,\n cwd,\n enabled: config?.events?.enabled !== false,\n path: defaultEventLogPath(cwd, config?.events?.path || '.opencode/agent-loop-state/events.jsonl')\n });" +); +write('runtime/agent-loop-controller.mjs', controller); + +let contract = read('scripts/check-feature-contract.mjs'); +contract = contract.replace( + "requireMatch(orchestrator, /^\\s*agent_loop:\\s*allow\\s*$/m,", + "requireMatch(orchestrator, /^steps:\\s*(?:[1-9][0-9]?|100)\\s*$/m, 'orchestrator steps must be capped at 100');\nrequireMatch(orchestrator, /^\\s*agent_loop:\\s*allow\\s*$/m," +); +write('scripts/check-feature-contract.mjs', contract); + +console.log('final hardening applied'); From 9de2d0703de596ff5abb2c42ab1a3735594cc9b5 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:02:16 -0400 Subject: [PATCH 038/105] chore: apply final v0.2 hardening --- .github/workflows/apply-v020-hardening.yml | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/apply-v020-hardening.yml diff --git a/.github/workflows/apply-v020-hardening.yml b/.github/workflows/apply-v020-hardening.yml new file mode 100644 index 0000000..d3b1ceb --- /dev/null +++ b/.github/workflows/apply-v020-hardening.yml @@ -0,0 +1,32 @@ +name: Apply final v0.2 hardening + +on: + push: + branches: [release/v0.2.0-reliability] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: node scripts/apply-v020-hardening.mjs + - run: npm ci + - run: npm run validate + - name: Commit changes + run: | + git add -A + if git diff --cached --quiet; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: finalize v0.2 runtime hardening" + git push origin HEAD:release/v0.2.0-reliability From f765eb1777dc2821c25fe6692ef78158eef5d6f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:02:34 +0000 Subject: [PATCH 039/105] fix: finalize v0.2 runtime hardening --- .opencode/plugins/agent-loop.js | 2 +- lib/event-log.mjs | 8 +++++--- lib/failover-handler.mjs | 12 ++++++++++-- runtime/agent-loop-controller.mjs | 9 +++++++-- scripts/check-feature-contract.mjs | 1 + 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.opencode/plugins/agent-loop.js b/.opencode/plugins/agent-loop.js index 8ced407..bdb2af7 100644 --- a/.opencode/plugins/agent-loop.js +++ b/.opencode/plugins/agent-loop.js @@ -54,7 +54,7 @@ export default async function AgentLoopPlugin() { args: { task: tool.schema.string().min(1).describe('The complete user request to run through the agent loop.'), mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.6 diagnosis)'), - maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum task-level retry cycles. Provider failover is handled separately.'), + maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum same-model retries for transient failures. Provider failover begins after these retries are exhausted.'), models: tool.schema.array(tool.schema.string()).optional().describe('Pre-verified model IDs from a prior smoke call to restrict which models are used.'), taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across smoke, build, test, review, fix, and escalation calls.') }, diff --git a/lib/event-log.mjs b/lib/event-log.mjs index 963cd28..0c59b56 100644 --- a/lib/event-log.mjs +++ b/lib/event-log.mjs @@ -49,20 +49,22 @@ export function defaultEventLogPath(cwd = process.cwd(), configuredPath = '.open } export class AgentLoopEventLogger { - constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd) } = {}) { + constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd), enabled = true } = {}) { if (!taskId) throw new Error('AgentLoopEventLogger requires taskId'); this.taskId = taskId; this.path = path; - mkdirSync(dirname(path), { recursive: true }); + this.enabled = enabled !== false; + if (this.enabled) mkdirSync(dirname(path), { recursive: true }); } emit(type, details = {}) { const event = createAgentLoopEvent({ type, taskId: this.taskId, ...details }); - appendFileSync(this.path, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 }); + if (this.enabled) appendFileSync(this.path, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 }); return event; } query({ type, stage, role, modelId, limit = 200 } = {}) { + if (!this.enabled) return []; let lines = []; try { lines = readFileSync(this.path, 'utf8').split(/\r?\n/).filter(Boolean); } catch { return []; } const events = []; diff --git a/lib/failover-handler.mjs b/lib/failover-handler.mjs index 96dbf82..de1583f 100644 --- a/lib/failover-handler.mjs +++ b/lib/failover-handler.mjs @@ -55,7 +55,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [ /timeout/i, /model\s+(not\s+found|unavailable|removed|invalid|end\s+of\s+life|retired|deprecated)/i, /(^|\W)gone(\W|$)/i, - /authentication|unauthorized|forbidden/i, /empty\s+(response|provider response|result|envelope)/i, /no\s+content/i, /interrupted/i, @@ -65,7 +64,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [ const PROVIDER_WIDE_PATTERNS = [ /provider.*(outage|unavailable|downtime)/i, - /authentication.*(nvidia|provider|failed)/i, /all.*(models|providers).*(unavailable|failed|down)/i, /provider.*5\d{2}/i, /provider.*quota.*exhausted/i, @@ -210,6 +208,16 @@ export class FailoverHandler { const retryableErrors = this.config?.retry?.retryable_errors || RETRYABLE_SYSTEM_ERRORS; const nonRetryableErrors = this.config?.retry?.non_retryable_errors || []; + if (/authentication|unauthorized|forbidden|invalid api key|invalid token/i.test(message)) { + return { retryable: false, reason: 'UNAUTHORIZED', category: 'configuration' }; + } + if (/billing|payment required|insufficient (credit|balance)/i.test(message)) { + return { retryable: false, reason: 'BILLING_DISABLED', category: 'configuration' }; + } + if (/safety rejection|content[- ]filter|content policy/i.test(message)) { + return { retryable: false, reason: 'SAFETY_REJECTION', category: 'task' }; + } + if (nonRetryableErrors.includes(code) && !/unauthorized|forbidden|auth/i.test(message)) { return { retryable: false, reason: code, category: 'configuration' }; } diff --git a/runtime/agent-loop-controller.mjs b/runtime/agent-loop-controller.mjs index 683c9c3..3d3a15d 100644 --- a/runtime/agent-loop-controller.mjs +++ b/runtime/agent-loop-controller.mjs @@ -4,7 +4,7 @@ import { loadPoolConfig, normalizePoolConfig, isModelCoolingDown } from '../lib/ import { runSmokeTest } from '../lib/smoke-test.mjs'; import { reapExpiredCooldowns, filterRetiredModels, loadRegistry } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; -import { AgentLoopEventLogger } from '../lib/event-log.mjs'; +import { AgentLoopEventLogger, defaultEventLogPath } from '../lib/event-log.mjs'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -103,7 +103,12 @@ export async function runAgentLoop({ const config = loadConfig(DEFAULT_CONFIG_PATH); const smokeTestEnabled = config?.general?.smoke_test_enabled !== false; - const events = new AgentLoopEventLogger({ taskId, cwd }); + const events = new AgentLoopEventLogger({ + taskId, + cwd, + enabled: config?.events?.enabled !== false, + path: defaultEventLogPath(cwd, config?.events?.path || '.opencode/agent-loop-state/events.jsonl') + }); const budgetTracker = BudgetTracker.fromFiles({ taskId, step: mode, diff --git a/scripts/check-feature-contract.mjs b/scripts/check-feature-contract.mjs index 28dff11..8044557 100644 --- a/scripts/check-feature-contract.mjs +++ b/scripts/check-feature-contract.mjs @@ -14,6 +14,7 @@ function requireMatch(text, pattern, message) { if (!pattern.test(text)) failures.push(message); } +requireMatch(orchestrator, /^steps:\s*(?:[1-9][0-9]?|100)\s*$/m, 'orchestrator steps must be capped at 100'); requireMatch(orchestrator, /^\s*agent_loop:\s*allow\s*$/m, 'orchestrator must allow agent_loop'); requireMatch(orchestrator, /^\s*task:\s*deny\s*$/m, 'orchestrator must deny direct task delegation'); requireMatch(orchestrator, /same stable task ID|same `taskId`/i, 'orchestrator must require one stable task ID'); From 655e9f82522267e28711e453ad25f36f91fbd228 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:03:25 -0400 Subject: [PATCH 040/105] feat: add provider-specific retry policies --- lib/provider-adapters.mjs | 45 +++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/provider-adapters.mjs b/lib/provider-adapters.mjs index 63218fa..8d03e69 100644 --- a/lib/provider-adapters.mjs +++ b/lib/provider-adapters.mjs @@ -1,12 +1,22 @@ const LOCAL_MODEL_PATTERNS = [/^ollama(?:-|\/)/i, /^local(?:-|\/)/i, /-local$/i]; +function normalizedRetryPolicy(base = {}, override = {}) { + return { + maxRetries: Number.isInteger(override.max_retries) ? override.max_retries : Number.isInteger(base.max_retries) ? base.max_retries : null, + retryableHttpCodes: override.retryable_http_codes || base.retryable_http_codes || [], + retryableErrors: override.retryable_errors || base.retryable_errors || [], + nonRetryableErrors: override.non_retryable_errors || base.non_retryable_errors || [] + }; +} + export class ProviderAdapter { - constructor({ id, aliases = [], local = false, timeoutKey = id } = {}) { + constructor({ id, aliases = [], local = false, timeoutKey = id, retry = {} } = {}) { if (!id) throw new Error('ProviderAdapter requires id'); this.id = id; - this.aliases = aliases; + this.aliases = aliases.map(alias => String(alias).toLowerCase()); this.local = local; this.timeoutKey = timeoutKey; + this.retry = retry; } matches(modelId, explicitProvider) { @@ -23,9 +33,23 @@ export class ProviderAdapter { return slash > 0 ? value.slice(0, slash).toLowerCase() : 'unknown'; } + retryPolicy(globalPolicy = {}, providerPolicy = {}) { + return normalizedRetryPolicy(globalPolicy, { ...this.retry, ...providerPolicy }); + } + + shouldRetry(error, globalPolicy = {}, providerPolicy = {}) { + const policy = this.retryPolicy(globalPolicy, providerPolicy); + const code = error?.code || error?.error?.code || ''; + const status = Number(error?.statusCode || error?.status || error?.error?.statusCode || 0); + if (policy.nonRetryableErrors.includes(code)) return false; + if (policy.retryableErrors.includes(code)) return true; + if (policy.retryableHttpCodes.includes(status)) return true; + return null; + } + normalizeError({ stdout = '', stderr = '', exitCode = 0 } = {}) { const combined = `${stderr}\n${stdout}`; - const statusMatch = combined.match(/\b(400|401|403|404|408|409|410|422|429|500|502|503|504)\b/); + const statusMatch = combined.match(/\b(400|401|402|403|404|408|409|410|422|429|500|502|503|504)\b/); const codeMatch = combined.match(/\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|UNAUTHORIZED|FORBIDDEN|BILLING_DISABLED|SAFETY_REJECTION)\b/); const sessionMatch = combined.match(/session(?:ID|Id| id)?[\s:=]+([a-zA-Z0-9_-]+)/); return { @@ -41,16 +65,23 @@ export class ProviderAdapter { export class LocalProviderAdapter extends ProviderAdapter { constructor() { - super({ id: 'local', aliases: ['ollama', 'rx580-llama'], local: true, timeoutKey: 'local' }); + super({ + id: 'local', + aliases: ['ollama', 'rx580-llama'], + local: true, + timeoutKey: 'local', + retry: { + max_retries: 1, + retryable_errors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE'] + } + }); } } const adapters = new Map(); export function registerProviderAdapter(adapter) { - if (!(adapter instanceof ProviderAdapter)) { - throw new TypeError('registerProviderAdapter expects ProviderAdapter'); - } + if (!(adapter instanceof ProviderAdapter)) throw new TypeError('registerProviderAdapter expects ProviderAdapter'); adapters.set(adapter.id, adapter); for (const alias of adapter.aliases) adapters.set(alias, adapter); return adapter; From 143f66ed2fd633c14165d195a68aa9e8ff9cb443 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:03:55 -0400 Subject: [PATCH 041/105] chore: remove one-time hardening workflow --- .github/workflows/apply-v020-hardening.yml | 32 ---------------------- 1 file changed, 32 deletions(-) delete mode 100644 .github/workflows/apply-v020-hardening.yml diff --git a/.github/workflows/apply-v020-hardening.yml b/.github/workflows/apply-v020-hardening.yml deleted file mode 100644 index d3b1ceb..0000000 --- a/.github/workflows/apply-v020-hardening.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Apply final v0.2 hardening - -on: - push: - branches: [release/v0.2.0-reliability] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: node scripts/apply-v020-hardening.mjs - - run: npm ci - - run: npm run validate - - name: Commit changes - run: | - git add -A - if git diff --cached --quiet; then exit 0; fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: finalize v0.2 runtime hardening" - git push origin HEAD:release/v0.2.0-reliability From c7ef6dfb45957d15ec5d91eb61e7d626d572ac22 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:04:02 -0400 Subject: [PATCH 042/105] chore: remove one-time hardening script --- scripts/apply-v020-hardening.mjs | 67 -------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 scripts/apply-v020-hardening.mjs diff --git a/scripts/apply-v020-hardening.mjs b/scripts/apply-v020-hardening.mjs deleted file mode 100644 index a2c2050..0000000 --- a/scripts/apply-v020-hardening.mjs +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -function read(path) { return fs.readFileSync(path, 'utf8'); } -function write(path, content) { fs.writeFileSync(path, content); console.log(`updated ${path}`); } -function replace(path, before, after) { - const current = read(path); - if (!current.includes(before)) throw new Error(`missing text in ${path}: ${before.slice(0, 120)}`); - write(path, current.replace(before, after)); -} - -// Authentication, billing, and safety failures are terminal, even when a -// provider does not supply a structured error code. -let failover = read('lib/failover-handler.mjs'); -failover = failover.replace(" /authentication|unauthorized|forbidden/i,\n", ''); -failover = failover.replace(" /authentication.*(nvidia|provider|failed)/i,\n", ''); -failover = failover.replace( - " const nonRetryableErrors = this.config?.retry?.non_retryable_errors || [];\n\n if (nonRetryableErrors.includes(code)", - " const nonRetryableErrors = this.config?.retry?.non_retryable_errors || [];\n\n if (/authentication|unauthorized|forbidden|invalid api key|invalid token/i.test(message)) {\n return { retryable: false, reason: 'UNAUTHORIZED', category: 'configuration' };\n }\n if (/billing|payment required|insufficient (credit|balance)/i.test(message)) {\n return { retryable: false, reason: 'BILLING_DISABLED', category: 'configuration' };\n }\n if (/safety rejection|content[- ]filter|content policy/i.test(message)) {\n return { retryable: false, reason: 'SAFETY_REJECTION', category: 'task' };\n }\n\n if (nonRetryableErrors.includes(code)" -); -write('lib/failover-handler.mjs', failover); - -replace( - '.opencode/plugins/agent-loop.js', - "Maximum task-level retry cycles. Provider failover is handled separately.", - "Maximum same-model retries for transient failures. Provider failover begins after these retries are exhausted." -); - -let events = read('lib/event-log.mjs'); -events = events.replace( - "constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd) } = {}) {", - "constructor({ taskId, cwd = process.cwd(), path = defaultEventLogPath(cwd), enabled = true } = {}) {" -); -events = events.replace( - " this.path = path;\n mkdirSync(dirname(path), { recursive: true });", - " this.path = path;\n this.enabled = enabled !== false;\n if (this.enabled) mkdirSync(dirname(path), { recursive: true });" -); -events = events.replace( - " appendFileSync(this.path, `${JSON.stringify(event)}\\n`, { encoding: 'utf8', mode: 0o600 });", - " if (this.enabled) appendFileSync(this.path, `${JSON.stringify(event)}\\n`, { encoding: 'utf8', mode: 0o600 });" -); -events = events.replace( - " query({ type, stage, role, modelId, limit = 200 } = {}) {\n let lines = [];", - " query({ type, stage, role, modelId, limit = 200 } = {}) {\n if (!this.enabled) return [];\n let lines = [];" -); -write('lib/event-log.mjs', events); - -let controller = read('runtime/agent-loop-controller.mjs'); -controller = controller.replace( - "import { AgentLoopEventLogger } from '../lib/event-log.mjs';", - "import { AgentLoopEventLogger, defaultEventLogPath } from '../lib/event-log.mjs';" -); -controller = controller.replace( - " const events = new AgentLoopEventLogger({ taskId, cwd });", - " const events = new AgentLoopEventLogger({\n taskId,\n cwd,\n enabled: config?.events?.enabled !== false,\n path: defaultEventLogPath(cwd, config?.events?.path || '.opencode/agent-loop-state/events.jsonl')\n });" -); -write('runtime/agent-loop-controller.mjs', controller); - -let contract = read('scripts/check-feature-contract.mjs'); -contract = contract.replace( - "requireMatch(orchestrator, /^\\s*agent_loop:\\s*allow\\s*$/m,", - "requireMatch(orchestrator, /^steps:\\s*(?:[1-9][0-9]?|100)\\s*$/m, 'orchestrator steps must be capped at 100');\nrequireMatch(orchestrator, /^\\s*agent_loop:\\s*allow\\s*$/m," -); -write('scripts/check-feature-contract.mjs', contract); - -console.log('final hardening applied'); From e6abb7f30e7aa2123e70bb3d97e2f0ea0ebbbb37 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:04:33 -0400 Subject: [PATCH 043/105] chore: add provider retry integration migration --- scripts/apply-provider-retry.mjs | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 scripts/apply-provider-retry.mjs diff --git a/scripts/apply-provider-retry.mjs b/scripts/apply-provider-retry.mjs new file mode 100644 index 0000000..8644430 --- /dev/null +++ b/scripts/apply-provider-retry.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const read = path => fs.readFileSync(path, 'utf8'); +const write = (path, content) => { fs.writeFileSync(path, content); console.log(`updated ${path}`); }; + +let execute = read('runtime/execute-agent-task.mjs'); +execute = execute.replace( + "import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs';", + "import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs';\nimport { resolveProviderAdapter } from '../lib/provider-adapters.mjs';" +); +execute = execute.replace( + " let finalInvocation = null;\n\n for (let retryIndex = 0; retryIndex <= retryLimit; retryIndex += 1) {", + " let finalInvocation = null;\n const providerAdapter = resolveProviderAdapter(modelId);\n const providerPolicy = freeFirstConfig?.provider_retry?.[providerAdapter.id] || {};\n const adapterRetryPolicy = providerAdapter.retryPolicy(freeFirstConfig.retry || {}, providerPolicy);\n const modelRetryLimit = Number.isInteger(adapterRetryPolicy.maxRetries)\n ? Math.min(retryLimit, Math.max(0, adapterRetryPolicy.maxRetries))\n : retryLimit;\n\n for (let retryIndex = 0; retryIndex <= modelRetryLimit; retryIndex += 1) {" +); +execute = execute.replace( + " const classification = failover.classifyFailure(invocation);\n if (!classification.retryable || retryIndex >= retryLimit) break;", + " const classification = failover.classifyFailure(invocation);\n const adapterDecision = providerAdapter.shouldRetry(invocation, freeFirstConfig.retry || {}, providerPolicy);\n const retryable = adapterDecision ?? classification.retryable;\n if (!retryable || retryIndex >= modelRetryLimit) break;" +); +write('runtime/execute-agent-task.mjs', execute); + +const configPath = 'config/free-first-config.json'; +const config = JSON.parse(read(configPath)); +config.provider_retry = { + local: { + max_retries: 1, + retryable_errors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE'], + non_retryable_errors: ['UNAUTHORIZED', 'FORBIDDEN', 'BUDGET_EXCEEDED'] + }, + nvidia: { max_retries: 2 }, + cerebras: { max_retries: 2 }, + groq: { max_retries: 1 }, + openrouter: { max_retries: 1 }, + opencode: { max_retries: 1 }, + 'opencode-go': { max_retries: 2 }, + openai: { max_retries: 2 } +}; +write(configPath, `${JSON.stringify(config, null, 2)}\n`); + +const schemaPath = 'config/free-first-config-schema.json'; +const schema = JSON.parse(read(schemaPath)); +schema.properties.provider_retry = { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + max_retries: { type: 'integer', minimum: 0, maximum: 5 }, + retryable_http_codes: { type: 'array', items: { type: 'integer' }, uniqueItems: true }, + retryable_errors: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + non_retryable_errors: { type: 'array', items: { type: 'string' }, uniqueItems: true } + }, + additionalProperties: false + } +}; +write(schemaPath, `${JSON.stringify(schema, null, 2)}\n`); + +let docs = read('docs/provider-adapters.md'); +docs += `\n## Provider-specific retry policy\n\nUse the optional \`provider_retry\` map in \`config/free-first-config.json\` to cap retries or override retryable and non-retryable codes for one provider. The adapter combines that policy with the global retry defaults. A provider-specific cap can reduce, but cannot increase, the caller's \`maxRetries\` value.\n`; +write('docs/provider-adapters.md', docs); + +console.log('provider retry integration applied'); From 42ebfe1252ff8e7e421a66b586f10aa4500ea6ce Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:04:43 -0400 Subject: [PATCH 044/105] chore: apply provider retry integration --- .github/workflows/apply-provider-retry.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/apply-provider-retry.yml diff --git a/.github/workflows/apply-provider-retry.yml b/.github/workflows/apply-provider-retry.yml new file mode 100644 index 0000000..c81e577 --- /dev/null +++ b/.github/workflows/apply-provider-retry.yml @@ -0,0 +1,31 @@ +name: Apply provider retry integration + +on: + push: + branches: [release/v0.2.0-reliability] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: node scripts/apply-provider-retry.mjs + - run: npm ci + - run: npm run validate + - run: | + git add -A + if git diff --cached --quiet; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "feat: support provider-specific retry policies" + git push origin HEAD:release/v0.2.0-reliability From 1227c9a66dbbb62cdf0db7da6d31728804dfb9ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:04:58 +0000 Subject: [PATCH 045/105] feat: support provider-specific retry policies --- config/free-first-config-schema.json | 283 +++++++++++++++++++++++---- config/free-first-config.json | 126 ++++++++++-- docs/provider-adapters.md | 4 + runtime/execute-agent-task.mjs | 13 +- 4 files changed, 366 insertions(+), 60 deletions(-) diff --git a/config/free-first-config-schema.json b/config/free-first-config-schema.json index 6201722..71bb016 100644 --- a/config/free-first-config-schema.json +++ b/config/free-first-config-schema.json @@ -3,34 +3,117 @@ "$id": "https://github.com/wryan2986/opencode-agent-loop/config/free-first-config-schema.json", "title": "OpenCode Agent Loop configuration", "type": "object", - "required": ["version", "general", "retry", "model_registry_path", "pools_path", "budgets", "events"], + "required": [ + "version", + "general", + "retry", + "model_registry_path", + "pools_path", + "budgets", + "events" + ], "properties": { - "$schema": {"type": "string"}, - "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, - "general": {"type": "object"}, - "cooldowns": {"type": "object"}, - "privacy": {"type": "object"}, + "$schema": { + "type": "string" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "general": { + "type": "object" + }, + "cooldowns": { + "type": "object" + }, + "privacy": { + "type": "object" + }, "retry": { "type": "object", - "required": ["max_retries", "retryable_http_codes", "retryable_errors", "non_retryable_errors"], + "required": [ + "max_retries", + "retryable_http_codes", + "retryable_errors", + "non_retryable_errors" + ], "properties": { - "max_retries": {"type": "integer", "minimum": 0, "maximum": 5}, - "base_delay_ms": {"type": "integer", "minimum": 0}, - "max_delay_ms": {"type": "integer", "minimum": 0}, - "jitter_factor": {"type": "number", "minimum": 0}, - "retryable_http_codes": {"type": "array", "items": {"type": "integer"}, "uniqueItems": true}, - "retryable_errors": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "non_retryable_errors": {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + "max_retries": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "base_delay_ms": { + "type": "integer", + "minimum": 0 + }, + "max_delay_ms": { + "type": "integer", + "minimum": 0 + }, + "jitter_factor": { + "type": "number", + "minimum": 0 + }, + "retryable_http_codes": { + "type": "array", + "items": { + "type": "integer" + }, + "uniqueItems": true + }, + "retryable_errors": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "non_retryable_errors": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } }, "additionalProperties": true }, - "model_registry_path": {"type": "string", "minLength": 1}, - "pools_path": {"type": "string", "minLength": 1}, - "provider_timeouts_ms": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, - "latency_timeout_mapping": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, - "smoke_test_provider_timeout_ms": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1}}, - "task_classification": {"type": "object"}, - "lightweight_profiles": {"type": "object"}, + "model_registry_path": { + "type": "string", + "minLength": 1 + }, + "pools_path": { + "type": "string", + "minLength": 1 + }, + "provider_timeouts_ms": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + }, + "latency_timeout_mapping": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + }, + "smoke_test_provider_timeout_ms": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + }, + "task_classification": { + "type": "object" + }, + "lightweight_profiles": { + "type": "object" + }, "budgets": { "type": "object", "required": [ @@ -48,42 +131,156 @@ "unknown_paid_model_pricing" ], "properties": { - "enabled": {"type": "boolean"}, - "max_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, - "max_input_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, - "max_output_tokens_per_task": {"type": ["integer", "null"], "minimum": 0}, - "max_cost_usd_per_task": {"type": ["number", "null"], "minimum": 0}, - "max_workflow_calls_per_task": {"type": ["integer", "null"], "minimum": 1}, - "fail_closed_on_unknown_pricing": {"type": "boolean"}, - "persist_state": {"type": "boolean"}, - "state_path": {"type": "string", "minLength": 1}, - "ledger_ttl_minutes": {"type": "integer", "minimum": 1}, - "max_tracked_tasks": {"type": "integer", "minimum": 1}, - "unknown_paid_model_pricing": {"$ref": "#/$defs/pricing"} + "enabled": { + "type": "boolean" + }, + "max_tokens_per_task": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_input_tokens_per_task": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_output_tokens_per_task": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_cost_usd_per_task": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "max_workflow_calls_per_task": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "fail_closed_on_unknown_pricing": { + "type": "boolean" + }, + "persist_state": { + "type": "boolean" + }, + "state_path": { + "type": "string", + "minLength": 1 + }, + "ledger_ttl_minutes": { + "type": "integer", + "minimum": 1 + }, + "max_tracked_tasks": { + "type": "integer", + "minimum": 1 + }, + "unknown_paid_model_pricing": { + "$ref": "#/$defs/pricing" + } }, "additionalProperties": false }, "events": { "type": "object", - "required": ["enabled", "path", "schema_version"], + "required": [ + "enabled", + "path", + "schema_version" + ], "properties": { - "enabled": {"type": "boolean"}, - "path": {"type": "string", "minLength": 1}, - "schema_version": {"const": "1.0.0"} + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "schema_version": { + "const": "1.0.0" + } }, "additionalProperties": false + }, + "provider_retry": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "max_retries": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "retryable_http_codes": { + "type": "array", + "items": { + "type": "integer" + }, + "uniqueItems": true + }, + "retryable_errors": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "non_retryable_errors": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } } }, "$defs": { "pricing": { "type": "object", - "required": ["input_per_million_usd", "output_per_million_usd", "reasoning_per_million_usd", "cache_read_per_million_usd", "cache_write_per_million_usd"], + "required": [ + "input_per_million_usd", + "output_per_million_usd", + "reasoning_per_million_usd", + "cache_read_per_million_usd", + "cache_write_per_million_usd" + ], "properties": { - "input_per_million_usd": {"type": "number", "minimum": 0}, - "output_per_million_usd": {"type": "number", "minimum": 0}, - "reasoning_per_million_usd": {"type": "number", "minimum": 0}, - "cache_read_per_million_usd": {"type": "number", "minimum": 0}, - "cache_write_per_million_usd": {"type": "number", "minimum": 0} + "input_per_million_usd": { + "type": "number", + "minimum": 0 + }, + "output_per_million_usd": { + "type": "number", + "minimum": 0 + }, + "reasoning_per_million_usd": { + "type": "number", + "minimum": 0 + }, + "cache_read_per_million_usd": { + "type": "number", + "minimum": 0 + }, + "cache_write_per_million_usd": { + "type": "number", + "minimum": 0 + } }, "additionalProperties": false } diff --git a/config/free-first-config.json b/config/free-first-config.json index 72a867b..09a7d6c 100644 --- a/config/free-first-config.json +++ b/config/free-first-config.json @@ -14,14 +14,26 @@ "paid_fallback_max_calls_per_task": 3, "paid_fallback_max_calls_global": 10, "paid_fallback_requires_approval": false, - "paid_fallback_allowed_roles": ["orchestrator", "builder", "reviewer", "test-fixer"], + "paid_fallback_allowed_roles": [ + "orchestrator", + "builder", + "reviewer", + "test-fixer" + ], "trial_credit_models_enabled": false, "smoke_test_enabled": true, "smoke_test_prompt": "Reply with exactly one word: ok", "smoke_test_default_timeout_ms": 30000, - "default_free_classifications": ["recurring free tier", "promotional free"], - "sensitive_task_classifications": ["standard"], - "local_only_task_classifications": ["trusted-provider-only"] + "default_free_classifications": [ + "recurring free tier", + "promotional free" + ], + "sensitive_task_classifications": [ + "standard" + ], + "local_only_task_classifications": [ + "trusted-provider-only" + ] }, "cooldowns": { "single_rate_limit_minutes": 10, @@ -37,14 +49,37 @@ }, "privacy": { "privacy_map": { - "standard": {"sensitive_tasks_allowed": false, "local_only": false}, - "data-used-for-improvement": {"sensitive_tasks_allowed": false, "local_only": false}, - "trial-only-logged": {"sensitive_tasks_allowed": false, "local_only": false}, - "data-retained-for-improvement": {"sensitive_tasks_allowed": false, "local_only": false}, - "trusted-provider-only": {"sensitive_tasks_allowed": true, "local_only": false}, - "local-private": {"sensitive_tasks_allowed": true, "local_only": true} + "standard": { + "sensitive_tasks_allowed": false, + "local_only": false + }, + "data-used-for-improvement": { + "sensitive_tasks_allowed": false, + "local_only": false + }, + "trial-only-logged": { + "sensitive_tasks_allowed": false, + "local_only": false + }, + "data-retained-for-improvement": { + "sensitive_tasks_allowed": false, + "local_only": false + }, + "trusted-provider-only": { + "sensitive_tasks_allowed": true, + "local_only": false + }, + "local-private": { + "sensitive_tasks_allowed": true, + "local_only": true + } }, - "local_preferred_classifications": ["local-only", "sensitive", "private-repository", "contains-private-logs"], + "local_preferred_classifications": [ + "local-only", + "sensitive", + "private-repository", + "contains-private-logs" + ], "local_fallback_requires_privacy_filter": true }, "retry": { @@ -52,9 +87,29 @@ "base_delay_ms": 1000, "max_delay_ms": 30000, "jitter_factor": 0.1, - "retryable_http_codes": [410, 429, 500, 502, 503, 504], - "retryable_errors": ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE"], - "non_retryable_errors": ["UNAUTHORIZED", "FORBIDDEN", "SAFETY_REJECTION", "BILLING_DISABLED", "INVALID_REQUEST", "USER_CANCELLED", "BUDGET_EXCEEDED"] + "retryable_http_codes": [ + 410, + 429, + 500, + 502, + 503, + 504 + ], + "retryable_errors": [ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "EPIPE" + ], + "non_retryable_errors": [ + "UNAUTHORIZED", + "FORBIDDEN", + "SAFETY_REJECTION", + "BILLING_DISABLED", + "INVALID_REQUEST", + "USER_CANCELLED", + "BUDGET_EXCEEDED" + ] }, "model_registry_path": "./config/model-registry.json", "pools_path": "./config/free-first-pools.json", @@ -69,7 +124,11 @@ "local": 420000, "default": 300000 }, - "latency_timeout_mapping": {"fast": 120000, "medium": 300000, "slow": 600000}, + "latency_timeout_mapping": { + "fast": 120000, + "medium": 300000, + "slow": 600000 + }, "smoke_test_provider_timeout_ms": { "nvidia": 15000, "groq": 15000, @@ -112,5 +171,42 @@ "enabled": true, "path": ".opencode/agent-loop-state/events.jsonl", "schema_version": "1.0.0" + }, + "provider_retry": { + "local": { + "max_retries": 1, + "retryable_errors": [ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "EPIPE" + ], + "non_retryable_errors": [ + "UNAUTHORIZED", + "FORBIDDEN", + "BUDGET_EXCEEDED" + ] + }, + "nvidia": { + "max_retries": 2 + }, + "cerebras": { + "max_retries": 2 + }, + "groq": { + "max_retries": 1 + }, + "openrouter": { + "max_retries": 1 + }, + "opencode": { + "max_retries": 1 + }, + "opencode-go": { + "max_retries": 2 + }, + "openai": { + "max_retries": 2 + } } } diff --git a/docs/provider-adapters.md b/docs/provider-adapters.md index 64f37ca..ffaf16e 100644 --- a/docs/provider-adapters.md +++ b/docs/provider-adapters.md @@ -37,3 +37,7 @@ registerProviderAdapter(new AcmeAdapter()); See `examples/provider-adapter.mjs` for a complete example. Add the provider's timeout to `provider_timeouts_ms` and `smoke_test_provider_timeout_ms`. Model entries remain in `config/model-registry.json`; role order remains in `config/free-first-pools.json`. + +## Provider-specific retry policy + +Use the optional `provider_retry` map in `config/free-first-config.json` to cap retries or override retryable and non-retryable codes for one provider. The adapter combines that policy with the global retry defaults. A provider-specific cap can reduce, but cannot increase, the caller's `maxRetries` value. diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index 4af041b..3d52408 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -8,6 +8,7 @@ import { runOpenCodeWorker, resolveTimeoutMs } from './opencode-worker-runner.mj import { reapExpiredCooldowns } from '../lib/cooldown-manager.mjs'; import { BudgetTracker } from '../lib/budget-manager.mjs'; import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs'; +import { resolveProviderAdapter } from '../lib/provider-adapters.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(__dirname, '..'); @@ -203,8 +204,14 @@ export async function executeAgentTask({ const workerPrompt = `${prompt}${checkpoint}`; const modelLabel = modelId.split('/').pop(); let finalInvocation = null; + const providerAdapter = resolveProviderAdapter(modelId); + const providerPolicy = freeFirstConfig?.provider_retry?.[providerAdapter.id] || {}; + const adapterRetryPolicy = providerAdapter.retryPolicy(freeFirstConfig.retry || {}, providerPolicy); + const modelRetryLimit = Number.isInteger(adapterRetryPolicy.maxRetries) + ? Math.min(retryLimit, Math.max(0, adapterRetryPolicy.maxRetries)) + : retryLimit; - for (let retryIndex = 0; retryIndex <= retryLimit; retryIndex += 1) { + for (let retryIndex = 0; retryIndex <= modelRetryLimit; retryIndex += 1) { if (!budgetTracker.canContinue()) { const budget = budgetTracker.snapshot(); return { success: false, code: 'BUDGET_EXCEEDED', budgetExceeded: true, budget }; @@ -296,7 +303,9 @@ export async function executeAgentTask({ if (invocation.success || invocation.budgetExceeded || invocation.code === 'BUDGET_EXCEEDED') break; const classification = failover.classifyFailure(invocation); - if (!classification.retryable || retryIndex >= retryLimit) break; + const adapterDecision = providerAdapter.shouldRetry(invocation, freeFirstConfig.retry || {}, providerPolicy); + const retryable = adapterDecision ?? classification.retryable; + if (!retryable || retryIndex >= modelRetryLimit) break; const delayMs = failover.getBackoffDelay(retryIndex); events.emit('model.retry.scheduled', { stage: budgetStep, role, modelId, data: { retryIndex: retryIndex + 1, delayMs, reason: classification.reason } }); await sleep(delayMs, signal); From 8d94d99ad2f473a9536b6141fc50644ca29151c9 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:05:37 -0400 Subject: [PATCH 046/105] ci: exercise installer in platform POSIX shells --- .github/workflows/cross-platform.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index ba2150a..ccce273 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -26,6 +26,22 @@ jobs: cache: npm - run: npm ci - run: npm run validate:portable + - name: Test installer in POSIX shell + shell: bash + run: | + set -euo pipefail + test_home="$PWD/.installer-test-home" + test_bin="$PWD/.installer-test-bin" + mkdir -p "$test_home" "$test_bin" + : > "$test_home/.bashrc" + cat > "$test_bin/opencode" <<'EOF' + #!/usr/bin/env bash + exit 0 + EOF + chmod +x "$test_bin/opencode" + HOME="$test_home" PATH="$test_bin:$PATH" bash scripts/install.sh + grep -F 'OPENCODE_CONFIG_DIR=' "$test_home/.bashrc" + rm -rf "$test_home" "$test_bin" bash-installation: runs-on: ubuntu-latest From 11c7c632b61be157be5fcd9f79487705a528f803 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:06:10 -0400 Subject: [PATCH 047/105] chore: remove one-time provider retry workflow --- .github/workflows/apply-provider-retry.yml | 31 ---------------------- 1 file changed, 31 deletions(-) delete mode 100644 .github/workflows/apply-provider-retry.yml diff --git a/.github/workflows/apply-provider-retry.yml b/.github/workflows/apply-provider-retry.yml deleted file mode 100644 index c81e577..0000000 --- a/.github/workflows/apply-provider-retry.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Apply provider retry integration - -on: - push: - branches: [release/v0.2.0-reliability] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: node scripts/apply-provider-retry.mjs - - run: npm ci - - run: npm run validate - - run: | - git add -A - if git diff --cached --quiet; then exit 0; fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "feat: support provider-specific retry policies" - git push origin HEAD:release/v0.2.0-reliability From 0bc3bc2a848f70bf5d1166c052627ae9b4bfeaab Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:06:18 -0400 Subject: [PATCH 048/105] chore: remove one-time provider retry migration --- scripts/apply-provider-retry.mjs | 62 -------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 scripts/apply-provider-retry.mjs diff --git a/scripts/apply-provider-retry.mjs b/scripts/apply-provider-retry.mjs deleted file mode 100644 index 8644430..0000000 --- a/scripts/apply-provider-retry.mjs +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const read = path => fs.readFileSync(path, 'utf8'); -const write = (path, content) => { fs.writeFileSync(path, content); console.log(`updated ${path}`); }; - -let execute = read('runtime/execute-agent-task.mjs'); -execute = execute.replace( - "import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs';", - "import { AgentLoopEventLogger, redactSensitive } from '../lib/event-log.mjs';\nimport { resolveProviderAdapter } from '../lib/provider-adapters.mjs';" -); -execute = execute.replace( - " let finalInvocation = null;\n\n for (let retryIndex = 0; retryIndex <= retryLimit; retryIndex += 1) {", - " let finalInvocation = null;\n const providerAdapter = resolveProviderAdapter(modelId);\n const providerPolicy = freeFirstConfig?.provider_retry?.[providerAdapter.id] || {};\n const adapterRetryPolicy = providerAdapter.retryPolicy(freeFirstConfig.retry || {}, providerPolicy);\n const modelRetryLimit = Number.isInteger(adapterRetryPolicy.maxRetries)\n ? Math.min(retryLimit, Math.max(0, adapterRetryPolicy.maxRetries))\n : retryLimit;\n\n for (let retryIndex = 0; retryIndex <= modelRetryLimit; retryIndex += 1) {" -); -execute = execute.replace( - " const classification = failover.classifyFailure(invocation);\n if (!classification.retryable || retryIndex >= retryLimit) break;", - " const classification = failover.classifyFailure(invocation);\n const adapterDecision = providerAdapter.shouldRetry(invocation, freeFirstConfig.retry || {}, providerPolicy);\n const retryable = adapterDecision ?? classification.retryable;\n if (!retryable || retryIndex >= modelRetryLimit) break;" -); -write('runtime/execute-agent-task.mjs', execute); - -const configPath = 'config/free-first-config.json'; -const config = JSON.parse(read(configPath)); -config.provider_retry = { - local: { - max_retries: 1, - retryable_errors: ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE'], - non_retryable_errors: ['UNAUTHORIZED', 'FORBIDDEN', 'BUDGET_EXCEEDED'] - }, - nvidia: { max_retries: 2 }, - cerebras: { max_retries: 2 }, - groq: { max_retries: 1 }, - openrouter: { max_retries: 1 }, - opencode: { max_retries: 1 }, - 'opencode-go': { max_retries: 2 }, - openai: { max_retries: 2 } -}; -write(configPath, `${JSON.stringify(config, null, 2)}\n`); - -const schemaPath = 'config/free-first-config-schema.json'; -const schema = JSON.parse(read(schemaPath)); -schema.properties.provider_retry = { - type: 'object', - additionalProperties: { - type: 'object', - properties: { - max_retries: { type: 'integer', minimum: 0, maximum: 5 }, - retryable_http_codes: { type: 'array', items: { type: 'integer' }, uniqueItems: true }, - retryable_errors: { type: 'array', items: { type: 'string' }, uniqueItems: true }, - non_retryable_errors: { type: 'array', items: { type: 'string' }, uniqueItems: true } - }, - additionalProperties: false - } -}; -write(schemaPath, `${JSON.stringify(schema, null, 2)}\n`); - -let docs = read('docs/provider-adapters.md'); -docs += `\n## Provider-specific retry policy\n\nUse the optional \`provider_retry\` map in \`config/free-first-config.json\` to cap retries or override retryable and non-retryable codes for one provider. The adapter combines that policy with the global retry defaults. A provider-specific cap can reduce, but cannot increase, the caller's \`maxRetries\` value.\n`; -write('docs/provider-adapters.md', docs); - -console.log('provider retry integration applied'); From d1fbd6758a3663965147ffb1a1e06c1952e13d8c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:08:07 -0400 Subject: [PATCH 049/105] test: make fake OpenCode executable portable --- tests/tool-integration-tests.mjs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index 6425da7..8620b9e 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -5,23 +5,32 @@ import { resolve } from 'node:path'; import AgentLoopPlugin from '../.opencode/plugins/agent-loop.js'; const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-tool-')); -const fakeOpencode = resolve(dir, 'fake-opencode.js'); +const fakeScript = resolve(dir, 'fake-opencode.cjs'); const fakeLog = resolve(dir, 'fake-opencode.jsonl'); -writeFileSync(fakeOpencode, `#!/usr/bin/env node -const fs = require('fs'); +writeFileSync(fakeScript, `const fs = require('fs'); fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\\n'); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); -console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', reason: 'stop' } })); +console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } } })); console.log('RESULT: PASS'); process.exit(0); `, 'utf8'); -chmodSync(fakeOpencode, 0o755); + +let fakeOpencode; +if (process.platform === 'win32') { + fakeOpencode = resolve(dir, 'fake-opencode.cmd'); + writeFileSync(fakeOpencode, `@echo off\r\n"${process.execPath}" "%~dp0fake-opencode.cjs" %*\r\n`, 'utf8'); +} else { + fakeOpencode = resolve(dir, 'fake-opencode'); + writeFileSync(fakeOpencode, `#!/usr/bin/env bash\nexec "${process.execPath}" "$(dirname "$0")/fake-opencode.cjs" "$@"\n`, 'utf8'); + chmodSync(fakeOpencode, 0o755); +} process.env.AGENT_LOOP_WORKER_EXECUTABLE = fakeOpencode; process.env.AGENT_LOOP_FAKE_LOG = fakeLog; -// Run directly (no session forking) in test environment process.env.AGENT_LOOP_FORK_DISABLED = '1'; +process.env.AGENT_LOOP_BUDGET_STATE_PATH = resolve(dir, 'budgets.json'); +process.env.AGENT_LOOP_EVENT_LOG_PATH = resolve(dir, 'events.jsonl'); const plugin = await AgentLoopPlugin(); assert.ok(plugin.tool.agent_loop, 'agent_loop tool should be registered'); @@ -38,9 +47,8 @@ const result = await plugin.tool.agent_loop.execute({ task: 'harmless smoke task const parsed = JSON.parse(result.output); assert.equal(parsed.status, 'completed'); assert.match(parsed.successfulModel, /.+/); -const calls = readFileSync(fakeLog, 'utf8').trim().split('\n').map(line => JSON.parse(line)); -// Filter out smoke test calls (they run before the main build step) -const buildCalls = calls.filter(c => !c.smokeTest); +const calls = readFileSync(fakeLog, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)); +const buildCalls = calls.filter(call => !call.smokeTest); assert.ok(buildCalls.length >= 1, `Expected at least 1 build call, got ${buildCalls.length}`); const mainCall = buildCalls[buildCalls.length - 1]; assert.equal(mainCall.child, '1'); @@ -48,6 +56,8 @@ assert.ok(mainCall.args.includes('--agent')); assert.ok(mainCall.args.includes('build-worker')); assert.ok(mainCall.args.includes('--model')); assert.equal(mainCall.args[mainCall.args.indexOf('--model') + 1], parsed.successfulModel); +assert.equal(parsed.budget.persistent, true); +assert.match(parsed.eventLogPath, /events\.jsonl$/); process.env.AGENT_LOOP_CHILD = '1'; const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'build' }, { @@ -62,5 +72,6 @@ const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'bu delete process.env.AGENT_LOOP_CHILD; assert.equal(JSON.parse(blocked.output).code, 'AGENT_LOOP_RECURSION_BLOCKED'); +for (const key of ['AGENT_LOOP_WORKER_EXECUTABLE', 'AGENT_LOOP_FAKE_LOG', 'AGENT_LOOP_FORK_DISABLED', 'AGENT_LOOP_BUDGET_STATE_PATH', 'AGENT_LOOP_EVENT_LOG_PATH']) delete process.env[key]; rmSync(dir, { recursive: true, force: true }); console.log('tool-integration-tests: passed'); From 9fdd97090a868ab94ea0df6c4794ec814e1edaeb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:08:59 -0400 Subject: [PATCH 050/105] test: use executable prefix arguments portably --- tests/tool-integration-tests.mjs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index 8620b9e..4ddd5cd 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -1,5 +1,5 @@ import { strict as assert } from 'node:assert'; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import AgentLoopPlugin from '../.opencode/plugins/agent-loop.js'; @@ -16,17 +16,8 @@ console.log('RESULT: PASS'); process.exit(0); `, 'utf8'); -let fakeOpencode; -if (process.platform === 'win32') { - fakeOpencode = resolve(dir, 'fake-opencode.cmd'); - writeFileSync(fakeOpencode, `@echo off\r\n"${process.execPath}" "%~dp0fake-opencode.cjs" %*\r\n`, 'utf8'); -} else { - fakeOpencode = resolve(dir, 'fake-opencode'); - writeFileSync(fakeOpencode, `#!/usr/bin/env bash\nexec "${process.execPath}" "$(dirname "$0")/fake-opencode.cjs" "$@"\n`, 'utf8'); - chmodSync(fakeOpencode, 0o755); -} - -process.env.AGENT_LOOP_WORKER_EXECUTABLE = fakeOpencode; +process.env.AGENT_LOOP_WORKER_EXECUTABLE = process.execPath; +process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS = JSON.stringify([fakeScript]); process.env.AGENT_LOOP_FAKE_LOG = fakeLog; process.env.AGENT_LOOP_FORK_DISABLED = '1'; process.env.AGENT_LOOP_BUDGET_STATE_PATH = resolve(dir, 'budgets.json'); @@ -72,6 +63,6 @@ const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'bu delete process.env.AGENT_LOOP_CHILD; assert.equal(JSON.parse(blocked.output).code, 'AGENT_LOOP_RECURSION_BLOCKED'); -for (const key of ['AGENT_LOOP_WORKER_EXECUTABLE', 'AGENT_LOOP_FAKE_LOG', 'AGENT_LOOP_FORK_DISABLED', 'AGENT_LOOP_BUDGET_STATE_PATH', 'AGENT_LOOP_EVENT_LOG_PATH']) delete process.env[key]; +for (const key of ['AGENT_LOOP_WORKER_EXECUTABLE', 'AGENT_LOOP_WORKER_EXECUTABLE_ARGS', 'AGENT_LOOP_FAKE_LOG', 'AGENT_LOOP_FORK_DISABLED', 'AGENT_LOOP_BUDGET_STATE_PATH', 'AGENT_LOOP_EVENT_LOG_PATH']) delete process.env[key]; rmSync(dir, { recursive: true, force: true }); console.log('tool-integration-tests: passed'); From 194ae08a5677eee217b65d302e787b5604b7bd3c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:09:18 -0400 Subject: [PATCH 051/105] chore: add portable executable prefix migration --- scripts/apply-executable-prefix.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 scripts/apply-executable-prefix.mjs diff --git a/scripts/apply-executable-prefix.mjs b/scripts/apply-executable-prefix.mjs new file mode 100644 index 0000000..e5c7c2a --- /dev/null +++ b/scripts/apply-executable-prefix.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const path = 'runtime/opencode-worker-runner.mjs'; +let content = fs.readFileSync(path, 'utf8'); +content = content.replace( + "export function deriveProviderFromModel(model) {\n return deriveProvider(model);\n}\n\nexport function resolveTimeoutMs", + "export function deriveProviderFromModel(model) {\n return deriveProvider(model);\n}\n\nexport function parseExecutableArgs(value = process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS) {\n if (Array.isArray(value)) return value.map(String);\n if (!value) return [];\n try {\n const parsed = JSON.parse(value);\n return Array.isArray(parsed) ? parsed.map(String) : [];\n } catch {\n throw new Error('AGENT_LOOP_WORKER_EXECUTABLE_ARGS must be a JSON array of strings');\n }\n}\n\nexport function resolveTimeoutMs" +); +content = content.replace( + " executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode',\n sessionId,", + " executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode',\n executableArgs,\n sessionId," +); +content = content.replace( + " const args = [\n 'run',", + " const prefixArgs = parseExecutableArgs(executableArgs);\n const args = [\n ...prefixArgs,\n 'run'," +); +fs.writeFileSync(path, content); +console.log('portable executable prefix applied'); From 2ac7f03bdbb1d13d787d1b70a3777e0608ca1348 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:09:32 -0400 Subject: [PATCH 052/105] chore: apply portable executable prefix support --- .github/workflows/apply-executable-prefix.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/apply-executable-prefix.yml diff --git a/.github/workflows/apply-executable-prefix.yml b/.github/workflows/apply-executable-prefix.yml new file mode 100644 index 0000000..734de4f --- /dev/null +++ b/.github/workflows/apply-executable-prefix.yml @@ -0,0 +1,31 @@ +name: Apply portable executable prefix + +on: + push: + branches: [release/v0.2.0-reliability] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: node scripts/apply-executable-prefix.mjs + - run: npm ci + - run: npm run validate + - run: | + git add -A + if git diff --cached --quiet; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: support portable worker executable prefixes" + git push origin HEAD:release/v0.2.0-reliability From 6020d3a3a3571e8a6f527fbaa6411815a4d0dd48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:09:51 +0000 Subject: [PATCH 053/105] fix: support portable worker executable prefixes --- runtime/opencode-worker-runner.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/runtime/opencode-worker-runner.mjs b/runtime/opencode-worker-runner.mjs index 0b8dd9f..abfbe52 100644 --- a/runtime/opencode-worker-runner.mjs +++ b/runtime/opencode-worker-runner.mjs @@ -38,6 +38,17 @@ export function deriveProviderFromModel(model) { return deriveProvider(model); } +export function parseExecutableArgs(value = process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS) { + if (Array.isArray(value)) return value.map(String); + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + throw new Error('AGENT_LOOP_WORKER_EXECUTABLE_ARGS must be a JSON array of strings'); + } +} + export function resolveTimeoutMs({ model, timeoutMs, providerTimeouts = {}, latencyTimeoutMapping = {} }) { if (timeoutMs != null && timeoutMs > 0) return timeoutMs; const timeoutKey = providerTimeoutKey(model); @@ -99,6 +110,7 @@ export async function runOpenCodeWorker({ env = {}, signal, executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode', + executableArgs, sessionId, continueSession = false, title, @@ -114,7 +126,9 @@ export async function runOpenCodeWorker({ if (!prompt) throw new Error('runOpenCodeWorker requires prompt'); const effectiveTimeoutMs = resolveTimeoutMs({ model, timeoutMs, providerTimeouts, latencyTimeoutMapping }); + const prefixArgs = parseExecutableArgs(executableArgs); const args = [ + ...prefixArgs, 'run', '--agent', agent, '--model', model, From b6aa875efcff1bf515220cb57acff706936bba33 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:10:19 -0400 Subject: [PATCH 054/105] chore: remove one-time executable workflow --- .github/workflows/apply-executable-prefix.yml | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 .github/workflows/apply-executable-prefix.yml diff --git a/.github/workflows/apply-executable-prefix.yml b/.github/workflows/apply-executable-prefix.yml deleted file mode 100644 index 734de4f..0000000 --- a/.github/workflows/apply-executable-prefix.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Apply portable executable prefix - -on: - push: - branches: [release/v0.2.0-reliability] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: node scripts/apply-executable-prefix.mjs - - run: npm ci - - run: npm run validate - - run: | - git add -A - if git diff --cached --quiet; then exit 0; fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: support portable worker executable prefixes" - git push origin HEAD:release/v0.2.0-reliability From d5d8bab50d9a521d278852f269549d5679100b15 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:10:29 -0400 Subject: [PATCH 055/105] chore: remove one-time executable migration --- scripts/apply-executable-prefix.mjs | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 scripts/apply-executable-prefix.mjs diff --git a/scripts/apply-executable-prefix.mjs b/scripts/apply-executable-prefix.mjs deleted file mode 100644 index e5c7c2a..0000000 --- a/scripts/apply-executable-prefix.mjs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const path = 'runtime/opencode-worker-runner.mjs'; -let content = fs.readFileSync(path, 'utf8'); -content = content.replace( - "export function deriveProviderFromModel(model) {\n return deriveProvider(model);\n}\n\nexport function resolveTimeoutMs", - "export function deriveProviderFromModel(model) {\n return deriveProvider(model);\n}\n\nexport function parseExecutableArgs(value = process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS) {\n if (Array.isArray(value)) return value.map(String);\n if (!value) return [];\n try {\n const parsed = JSON.parse(value);\n return Array.isArray(parsed) ? parsed.map(String) : [];\n } catch {\n throw new Error('AGENT_LOOP_WORKER_EXECUTABLE_ARGS must be a JSON array of strings');\n }\n}\n\nexport function resolveTimeoutMs" -); -content = content.replace( - " executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode',\n sessionId,", - " executable = process.env.AGENT_LOOP_WORKER_EXECUTABLE || 'opencode',\n executableArgs,\n sessionId," -); -content = content.replace( - " const args = [\n 'run',", - " const prefixArgs = parseExecutableArgs(executableArgs);\n const args = [\n ...prefixArgs,\n 'run'," -); -fs.writeFileSync(path, content); -console.log('portable executable prefix applied'); From 5c6f1c4a30918f1ae91e53484218c6650f412b3f Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:13:54 -0400 Subject: [PATCH 056/105] ci: split portable checks for actionable failures --- .github/workflows/cross-platform.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index ccce273..fe65b51 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -25,7 +25,28 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - - run: npm run validate:portable + - name: Validate routing policy + run: npm run validate:routing + - name: Validate budget policy + run: npm run validate:budget + - name: Validate feature contract + run: npm run validate:feature + - name: Validate documentation links + run: node scripts/check-doc-links.mjs + - name: Test v0.2 reliability features + run: node tests/reliability-v020-tests.mjs + - name: Test budget enforcement + run: node tests/budget-tests.mjs + - name: Test budget edge cases + run: node tests/budget-audit-tests.mjs + - name: Test routing + run: node tests/routing-tests.mjs + - name: Test runtime + run: node tests/runtime-tests.mjs + - name: Test plugin integration + run: node tests/tool-integration-tests.mjs + - name: Test bypass detection + run: node tests/bypass-detection.mjs - name: Test installer in POSIX shell shell: bash run: | From 9089fa7b5b05d7c34611076a265bf3b131a5f73a Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:15:19 -0400 Subject: [PATCH 057/105] test: handle Windows termination semantics --- tests/budget-audit-tests.mjs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/budget-audit-tests.mjs b/tests/budget-audit-tests.mjs index 552a429..9e021b0 100644 --- a/tests/budget-audit-tests.mjs +++ b/tests/budget-audit-tests.mjs @@ -1,5 +1,5 @@ import { strict as assert } from 'node:assert'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { @@ -69,10 +69,14 @@ function testLedgerRetentionIsBounded() { } async function testUsageAfterTerminationRemainsAccounted() { + if (process.platform === 'win32') { + console.log('SKIP: Windows terminates the child immediately and cannot emit a post-SIGTERM usage event'); + return; + } + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-budget-stream-')); - const executable = resolve(dir, 'fake-worker.cjs'); - writeFileSync(executable, `#!/usr/bin/env node -process.on('SIGTERM', () => {}); + const script = resolve(dir, 'fake-worker.cjs'); + writeFileSync(script, `process.on('SIGTERM', () => {}); const first = { type: 'step_finish', part: { type: 'step-finish', tokens: { input: 10, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }; const second = { type: 'step_finish', part: { type: 'step-finish', tokens: { input: 20, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }; process.stdout.write(JSON.stringify(first) + '\\n'); @@ -81,7 +85,6 @@ setTimeout(() => { setTimeout(() => process.exit(0), 20); }, 20); `, 'utf8'); - chmodSync(executable, 0o755); let callbackCalls = 0; let callbackTokens = 0; @@ -90,7 +93,8 @@ setTimeout(() => { agent: 'build-worker', model: 'free/model', prompt: 'test', - executable, + executable: process.execPath, + executableArgs: [script], timeoutMs: 2000, onUsage: ({ usage }) => { callbackCalls += 1; From 45d17118b91a8ecf11a0490ad24b87be35bef006 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:16:37 -0400 Subject: [PATCH 058/105] ci: verify OpenCode patch on compatibility changes --- .github/workflows/opencode-patch-compat.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index 822f7a1..44e34ae 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -1,6 +1,12 @@ name: OpenCode patch compatibility on: + pull_request: + branches: [main, master] + paths: + - patches/opencode-subagent-failure-exposure.patch + - scripts/check-opencode-patch-contract.mjs + - .github/workflows/opencode-patch-compat.yml workflow_dispatch: inputs: opencode_ref: From 2770b1f621b35a49c1af9863eb1c212a54fad409 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:19:04 -0400 Subject: [PATCH 059/105] ci: identify compatible OpenCode patch base --- .../discover-opencode-patch-base.yml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/discover-opencode-patch-base.yml diff --git a/.github/workflows/discover-opencode-patch-base.yml b/.github/workflows/discover-opencode-patch-base.yml new file mode 100644 index 0000000..bd23f75 --- /dev/null +++ b/.github/workflows/discover-opencode-patch-base.yml @@ -0,0 +1,32 @@ +name: Discover OpenCode patch base + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + patch-check: + strategy: + fail-fast: false + matrix: + opencode-ref: + - 14a5529793a91001ca81c80e96f39533eab79127 + - adf178a6b95c61506ddaadaf4dd062badb4a8fda + - 78f85b1cd65b4d2b56c15d8f128b0e5a19d22895 + - 888dc67cc3d47d5b8c3bbcea361df55e20cd9754 + - fd9ee435ec3a45e03e400b8a9eb4b50e407addc2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: anomalyco/opencode + ref: ${{ matrix.opencode-ref }} + path: upstream-opencode + fetch-depth: 1 + - name: Check patch + working-directory: upstream-opencode + run: git apply --check ../patches/opencode-subagent-failure-exposure.patch From 2625dc387c0700362fc9fca85154ee451a26aad4 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:19:50 -0400 Subject: [PATCH 060/105] ci: pin supported OpenCode revision --- config/supported-opencode.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 config/supported-opencode.json diff --git a/config/supported-opencode.json b/config/supported-opencode.json new file mode 100644 index 0000000..d0521ca --- /dev/null +++ b/config/supported-opencode.json @@ -0,0 +1,7 @@ +{ + "repository": "anomalyco/opencode", + "ref": "14a5529793a91001ca81c80e96f39533eab79127", + "patch": "patches/opencode-subagent-failure-exposure.patch", + "verified_on": "2026-07-22", + "notes": "Latest upstream revision confirmed patch-compatible before the current patch series was created. Scheduled CI separately probes the moving dev branch for drift." +} From 7677567b628d4006bff206d4f9caac63c12ca183 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:20:27 -0400 Subject: [PATCH 061/105] ci: build pinned OpenCode and probe upstream drift --- .github/workflows/opencode-patch-compat.yml | 57 +++++++++++++++++---- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index 44e34ae..bd70181 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -5,14 +5,15 @@ on: branches: [main, master] paths: - patches/opencode-subagent-failure-exposure.patch + - config/supported-opencode.json - scripts/check-opencode-patch-contract.mjs - .github/workflows/opencode-patch-compat.yml workflow_dispatch: inputs: opencode_ref: - description: OpenCode branch, tag, or commit to test + description: Optional OpenCode branch, tag, or commit. Empty uses the pinned supported revision. required: false - default: dev + default: '' schedule: - cron: '23 6 * * 1' @@ -20,20 +21,30 @@ permissions: contents: read jobs: - compatibility: + supported-compatibility: runs-on: ubuntu-latest timeout-minutes: 45 - env: - OPENCODE_REF: ${{ inputs.opencode_ref || 'dev' }} steps: - name: Check out agent loop uses: actions/checkout@v4 - - name: Check out OpenCode + - name: Resolve supported revision + id: supported + shell: bash + env: + REQUESTED_REF: ${{ inputs.opencode_ref }} + run: | + set -euo pipefail + configured_ref=$(node -e "const fs=require('fs'); const value=JSON.parse(fs.readFileSync('config/supported-opencode.json','utf8')); process.stdout.write(value.ref)") + selected_ref=${REQUESTED_REF:-$configured_ref} + echo "ref=$selected_ref" >> "$GITHUB_OUTPUT" + echo "Testing OpenCode revision $selected_ref" + + - name: Check out supported OpenCode uses: actions/checkout@v4 with: repository: anomalyco/opencode - ref: ${{ env.OPENCODE_REF }} + ref: ${{ steps.supported.outputs.ref }} path: upstream-opencode fetch-depth: 1 @@ -63,13 +74,41 @@ jobs: - name: Record tested revision if: always() + shell: bash run: | git -C upstream-opencode rev-parse HEAD > opencode-tested-revision.txt - printf 'ref=%s\n' "$OPENCODE_REF" >> opencode-tested-revision.txt + printf 'requested_ref=%s\n' '${{ steps.supported.outputs.ref }}' >> opencode-tested-revision.txt + printf 'tested_at=%s\n' "$(date -u +%FT%TZ)" >> opencode-tested-revision.txt - name: Upload compatibility evidence if: always() uses: actions/upload-artifact@v4 with: - name: opencode-patch-compatibility + name: opencode-supported-compatibility path: opencode-tested-revision.txt + + upstream-dev-probe: + if: github.event_name != 'workflow_dispatch' || inputs.opencode_ref == '' + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: anomalyco/opencode + ref: dev + path: upstream-opencode-dev + fetch-depth: 1 + - name: Probe patch against moving dev branch + working-directory: upstream-opencode-dev + run: git apply --check ../patches/opencode-subagent-failure-exposure.patch + - name: Record upstream revision + if: always() + run: git -C upstream-opencode-dev rev-parse HEAD > opencode-dev-probe.txt + - name: Upload upstream probe evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-dev-probe + path: opencode-dev-probe.txt From 005418075064900880e74afe132a397600206bea Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:20:42 -0400 Subject: [PATCH 062/105] chore: remove compatibility discovery workflow --- .../discover-opencode-patch-base.yml | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 .github/workflows/discover-opencode-patch-base.yml diff --git a/.github/workflows/discover-opencode-patch-base.yml b/.github/workflows/discover-opencode-patch-base.yml deleted file mode 100644 index bd23f75..0000000 --- a/.github/workflows/discover-opencode-patch-base.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Discover OpenCode patch base - -on: - pull_request: - branches: [main, master] - -permissions: - contents: read - -jobs: - patch-check: - strategy: - fail-fast: false - matrix: - opencode-ref: - - 14a5529793a91001ca81c80e96f39533eab79127 - - adf178a6b95c61506ddaadaf4dd062badb4a8fda - - 78f85b1cd65b4d2b56c15d8f128b0e5a19d22895 - - 888dc67cc3d47d5b8c3bbcea361df55e20cd9754 - - fd9ee435ec3a45e03e400b8a9eb4b50e407addc2 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: anomalyco/opencode - ref: ${{ matrix.opencode-ref }} - path: upstream-opencode - fetch-depth: 1 - - name: Check patch - working-directory: upstream-opencode - run: git apply --check ../patches/opencode-subagent-failure-exposure.patch From d91f0b749257808cedde7dc8fd94d60614e50b3e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:21:08 -0400 Subject: [PATCH 063/105] docs: use pinned supported OpenCode revision --- docs/opencode-fork.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/opencode-fork.md b/docs/opencode-fork.md index 9c5cf40..2647ad6 100644 --- a/docs/opencode-fork.md +++ b/docs/opencode-fork.md @@ -6,6 +6,16 @@ OpenCode Agent Loop currently requires a patched OpenCode build. The stock relea Delegated provider errors, rate limits, timeouts, quota failures, and non-retryable request failures must reach the parent as structured failures. Without the patch, a failed child call can appear successful and allow the workflow to advance incorrectly. +## Supported revision + +The known-good upstream repository and commit are recorded in: + +```text +config/supported-opencode.json +``` + +Use that pinned revision for normal installations. The moving upstream `dev` branch is probed separately because the patch is revision-sensitive and does not necessarily apply to current development head. + ## Patch contents ```text @@ -16,31 +26,32 @@ The patch covers task-level model overrides, provider-error classification, retr ## Build process -OpenCode's source layout and build procedure can change. Follow the current upstream development instructions for the checked-out revision, then apply this patch before compiling. - ```bash git clone https://github.com/anomalyco/opencode.git cd opencode + +git checkout "$(node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('/absolute/path/to/opencode-agent-loop/config/supported-opencode.json','utf8')); process.stdout.write(p.ref)")" git apply /absolute/path/to/opencode-agent-loop/patches/opencode-subagent-failure-exposure.patch + # Install and build using the checked-out revision's upstream instructions. ``` -Do not assume an old npm build command remains valid. +OpenCode's source layout and build procedure can change. Do not substitute the latest `dev` revision merely because cloning it succeeds, and do not assume an old npm build command remains valid. ## Automated compatibility workflow -`.github/workflows/opencode-patch-compat.yml` runs weekly and can be started manually with an OpenCode branch, tag, or commit. +`.github/workflows/opencode-patch-compat.yml` runs for compatibility-related pull requests, weekly, and on manual request. -It: +The required job: -1. checks out the requested upstream revision +1. reads the pinned revision from `config/supported-opencode.json` 2. verifies and applies the patch 3. installs upstream Bun dependencies 4. builds a standalone OpenCode binary 5. verifies the patched source contracts 6. publishes the exact tested revision as a workflow artifact -A passing scheduled run is useful evidence, but installations should still use a known tested revision rather than assuming the newest upstream commit is compatible. +A separate non-blocking job probes the moving `dev` branch. Its purpose is early warning that the patch needs rebasing; failure of that probe does not invalidate the pinned supported build. ## Verify behavior @@ -58,6 +69,6 @@ Run the relevant upstream task, retry, session, provider, and CLI tests for the ## Compatibility warning -The patch is revision-sensitive. It may fail to apply, fail to build, or apply while no longer producing the intended behavior. Treat `git apply` success as necessary but insufficient. +The patch may fail to apply, fail to build, or apply while no longer producing the intended behavior. Treat `git apply` success as necessary but insufficient. The long-term goal is to replace the patch with equivalent upstream behavior or a stable provider/task extension interface. From c25caa32db28b8d5645f2588e94be3705bfc8e26 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:22:06 -0400 Subject: [PATCH 064/105] ci: report upstream patch drift without failing --- .github/workflows/opencode-patch-compat.yml | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index bd70181..0efeaec 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -89,7 +89,6 @@ jobs: upstream-dev-probe: if: github.event_name != 'workflow_dispatch' || inputs.opencode_ref == '' - continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -101,11 +100,30 @@ jobs: path: upstream-opencode-dev fetch-depth: 1 - name: Probe patch against moving dev branch + id: probe working-directory: upstream-opencode-dev - run: git apply --check ../patches/opencode-subagent-failure-exposure.patch + shell: bash + run: | + set +e + git apply --check ../patches/opencode-subagent-failure-exposure.patch > ../opencode-dev-patch-check.txt 2>&1 + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "compatible=true" >> "$GITHUB_OUTPUT" + echo "### Upstream dev probe: compatible" >> "$GITHUB_STEP_SUMMARY" + else + echo "compatible=false" >> "$GITHUB_OUTPUT" + echo "### Upstream dev probe: patch rebase needed" >> "$GITHUB_STEP_SUMMARY" + echo "The pinned supported revision remains the release target. See the uploaded probe artifact for details." >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 - name: Record upstream revision if: always() - run: git -C upstream-opencode-dev rev-parse HEAD > opencode-dev-probe.txt + shell: bash + run: | + git -C upstream-opencode-dev rev-parse HEAD > opencode-dev-probe.txt + printf 'patch_compatible=%s\n' '${{ steps.probe.outputs.compatible }}' >> opencode-dev-probe.txt + cat opencode-dev-patch-check.txt >> opencode-dev-probe.txt - name: Upload upstream probe evidence if: always() uses: actions/upload-artifact@v4 From ccc60901bbc2bd2aecd1f719b3995fb5d6f6739e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:23:10 -0400 Subject: [PATCH 065/105] docs: align event names and compatibility --- docs/event-logging.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/event-logging.md b/docs/event-logging.md index f24307e..550f891 100644 --- a/docs/event-logging.md +++ b/docs/event-logging.md @@ -12,9 +12,18 @@ Each event conforms to `config/agent-loop-event.schema.json` and includes: - timestamp and task ID - event type - optional stage, role, and model ID -- redacted structured data +- recursively redacted structured data -Typical event types include `workflow.started`, `stage.started`, `model.attempt.started`, `model.attempt.completed`, `budget.updated`, `budget.exceeded`, `provider.cooldown`, `stage.completed`, and `workflow.completed`. +Current event families include: + +- `workflow.call`, `workflow.call.completed`, and `workflow.call.failed` +- `stage.started`, `stage.completed`, `stage.failed`, and `stage.blocked` +- `smoke.completed` +- `model.attempt.*`, `model.invocation.*`, and `model.retry.scheduled` +- `provider.cooldown`, `provider.skipped`, and `provider.cooldown-reaped` +- `paid-fallback.denied` +- `budget.updated` and `budget.exceeded` +- `routing.event` for backward-compatible routing events that do not yet have a dedicated type ## Querying @@ -24,7 +33,11 @@ node scripts/query-events.mjs --type budget.exceeded --format json node scripts/query-events.mjs --model opencode/deepseek-v4-flash-free --limit 50 ``` -The event stream is intended for debugging, audit history, budget recovery, performance analysis, and later dashboard integrations. It is local runtime state and must not be committed. +The stream supports debugging, audit history, budget recovery, performance analysis, and later dashboard integrations. It is local runtime state and must not be committed. + +## Backward compatibility + +The structured stream supplements rather than replaces existing attempt and progress logs under `.opencode/agent-loop-logs/`. Existing log consumers can continue reading those files. New integrations should prefer the versioned event stream and treat unknown event types as forward-compatible records. ## Redaction From f7f45f3b2cd8311201fe07178dcebb0ac5e392a5 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:24:42 -0400 Subject: [PATCH 066/105] fix: require a non-null workflow call ceiling --- config/free-first-config-schema.json | 270 +++++---------------------- 1 file changed, 46 insertions(+), 224 deletions(-) diff --git a/config/free-first-config-schema.json b/config/free-first-config-schema.json index 71bb016..1b64b04 100644 --- a/config/free-first-config-schema.json +++ b/config/free-first-config-schema.json @@ -3,117 +3,34 @@ "$id": "https://github.com/wryan2986/opencode-agent-loop/config/free-first-config-schema.json", "title": "OpenCode Agent Loop configuration", "type": "object", - "required": [ - "version", - "general", - "retry", - "model_registry_path", - "pools_path", - "budgets", - "events" - ], + "required": ["version", "general", "retry", "model_registry_path", "pools_path", "budgets", "events"], "properties": { - "$schema": { - "type": "string" - }, - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" - }, - "general": { - "type": "object" - }, - "cooldowns": { - "type": "object" - }, - "privacy": { - "type": "object" - }, + "$schema": { "type": "string" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "general": { "type": "object" }, + "cooldowns": { "type": "object" }, + "privacy": { "type": "object" }, "retry": { "type": "object", - "required": [ - "max_retries", - "retryable_http_codes", - "retryable_errors", - "non_retryable_errors" - ], + "required": ["max_retries", "retryable_http_codes", "retryable_errors", "non_retryable_errors"], "properties": { - "max_retries": { - "type": "integer", - "minimum": 0, - "maximum": 5 - }, - "base_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "max_delay_ms": { - "type": "integer", - "minimum": 0 - }, - "jitter_factor": { - "type": "number", - "minimum": 0 - }, - "retryable_http_codes": { - "type": "array", - "items": { - "type": "integer" - }, - "uniqueItems": true - }, - "retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "non_retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } + "max_retries": { "type": "integer", "minimum": 0, "maximum": 5 }, + "base_delay_ms": { "type": "integer", "minimum": 0 }, + "max_delay_ms": { "type": "integer", "minimum": 0 }, + "jitter_factor": { "type": "number", "minimum": 0 }, + "retryable_http_codes": { "type": "array", "items": { "type": "integer" }, "uniqueItems": true }, + "retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "non_retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, "additionalProperties": true }, - "model_registry_path": { - "type": "string", - "minLength": 1 - }, - "pools_path": { - "type": "string", - "minLength": 1 - }, - "provider_timeouts_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "latency_timeout_mapping": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "smoke_test_provider_timeout_ms": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 1 - } - }, - "task_classification": { - "type": "object" - }, - "lightweight_profiles": { - "type": "object" - }, + "model_registry_path": { "type": "string", "minLength": 1 }, + "pools_path": { "type": "string", "minLength": 1 }, + "provider_timeouts_ms": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "latency_timeout_mapping": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "smoke_test_provider_timeout_ms": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } }, + "task_classification": { "type": "object" }, + "lightweight_profiles": { "type": "object" }, "budgets": { "type": "object", "required": [ @@ -131,86 +48,28 @@ "unknown_paid_model_pricing" ], "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_per_task": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "max_input_tokens_per_task": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "max_output_tokens_per_task": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "max_cost_usd_per_task": { - "type": [ - "number", - "null" - ], - "minimum": 0 - }, - "max_workflow_calls_per_task": { - "type": [ - "integer", - "null" - ], - "minimum": 1 - }, - "fail_closed_on_unknown_pricing": { - "type": "boolean" - }, - "persist_state": { - "type": "boolean" - }, - "state_path": { - "type": "string", - "minLength": 1 - }, - "ledger_ttl_minutes": { - "type": "integer", - "minimum": 1 - }, - "max_tracked_tasks": { - "type": "integer", - "minimum": 1 - }, - "unknown_paid_model_pricing": { - "$ref": "#/$defs/pricing" - } + "enabled": { "type": "boolean" }, + "max_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_input_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_output_tokens_per_task": { "type": ["integer", "null"], "minimum": 0 }, + "max_cost_usd_per_task": { "type": ["number", "null"], "minimum": 0 }, + "max_workflow_calls_per_task": { "type": "integer", "minimum": 1 }, + "fail_closed_on_unknown_pricing": { "type": "boolean" }, + "persist_state": { "type": "boolean" }, + "state_path": { "type": "string", "minLength": 1 }, + "ledger_ttl_minutes": { "type": "integer", "minimum": 1 }, + "max_tracked_tasks": { "type": "integer", "minimum": 1 }, + "unknown_paid_model_pricing": { "$ref": "#/$defs/pricing" } }, "additionalProperties": false }, "events": { "type": "object", - "required": [ - "enabled", - "path", - "schema_version" - ], + "required": ["enabled", "path", "schema_version"], "properties": { - "enabled": { - "type": "boolean" - }, - "path": { - "type": "string", - "minLength": 1 - }, - "schema_version": { - "const": "1.0.0" - } + "enabled": { "type": "boolean" }, + "path": { "type": "string", "minLength": 1 }, + "schema_version": { "const": "1.0.0" } }, "additionalProperties": false }, @@ -219,32 +78,10 @@ "additionalProperties": { "type": "object", "properties": { - "max_retries": { - "type": "integer", - "minimum": 0, - "maximum": 5 - }, - "retryable_http_codes": { - "type": "array", - "items": { - "type": "integer" - }, - "uniqueItems": true - }, - "retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "non_retryable_errors": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } + "max_retries": { "type": "integer", "minimum": 0, "maximum": 5 }, + "retryable_http_codes": { "type": "array", "items": { "type": "integer" }, "uniqueItems": true }, + "retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "non_retryable_errors": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, "additionalProperties": false } @@ -261,26 +98,11 @@ "cache_write_per_million_usd" ], "properties": { - "input_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "output_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "reasoning_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_read_per_million_usd": { - "type": "number", - "minimum": 0 - }, - "cache_write_per_million_usd": { - "type": "number", - "minimum": 0 - } + "input_per_million_usd": { "type": "number", "minimum": 0 }, + "output_per_million_usd": { "type": "number", "minimum": 0 }, + "reasoning_per_million_usd": { "type": "number", "minimum": 0 }, + "cache_read_per_million_usd": { "type": "number", "minimum": 0 }, + "cache_write_per_million_usd": { "type": "number", "minimum": 0 } }, "additionalProperties": false } From d4cd72c59683c1bec307ba66525ec54a5a3ce774 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:25:04 -0400 Subject: [PATCH 067/105] fix: require workflow call budget in validation --- scripts/check-budget-config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-budget-config.mjs b/scripts/check-budget-config.mjs index 348e285..7d5e7be 100644 --- a/scripts/check-budget-config.mjs +++ b/scripts/check-budget-config.mjs @@ -39,8 +39,8 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { nonNegativeNumber(budgets.max_input_tokens_per_task, 'budgets.max_input_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_output_tokens_per_task, 'budgets.max_output_tokens_per_task', { integer: true, nullable: true }); nonNegativeNumber(budgets.max_cost_usd_per_task, 'budgets.max_cost_usd_per_task', { nullable: true }); - nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true, nullable: true }); - if (budgets.max_workflow_calls_per_task !== null && budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1'); + nonNegativeNumber(budgets.max_workflow_calls_per_task, 'budgets.max_workflow_calls_per_task', { integer: true }); + if (budgets.max_workflow_calls_per_task < 1) fail('budgets.max_workflow_calls_per_task must be at least 1'); if (typeof budgets.persist_state !== 'boolean') fail('budgets.persist_state must be boolean'); if (typeof budgets.state_path !== 'string' || !budgets.state_path) fail('budgets.state_path must be a non-empty string'); nonNegativeNumber(budgets.ledger_ttl_minutes, 'budgets.ledger_ttl_minutes', { integer: true }); From 34f8833a0450c86aefb05a043a0ff6f44c7f3ad0 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:25:49 -0400 Subject: [PATCH 068/105] test: cover provider-specific retry policy --- tests/reliability-v020-tests.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs index d194e7c..9548e1e 100644 --- a/tests/reliability-v020-tests.mjs +++ b/tests/reliability-v020-tests.mjs @@ -20,6 +20,21 @@ function testCustomProviderAdapter() { assert.equal(resolveProviderAdapter('anything/model', 'acme-cloud').timeoutKey, 'acme-slow'); } +function testProviderRetryPolicy() { + const local = resolveProviderAdapter('ollama-9b-local'); + const global = { + max_retries: 4, + retryable_http_codes: [429, 500], + retryable_errors: ['ETIMEDOUT'], + non_retryable_errors: ['BUDGET_EXCEEDED'] + }; + const policy = local.retryPolicy(global, {}); + assert.equal(policy.maxRetries, 1); + assert.equal(local.shouldRetry({ code: 'ETIMEDOUT' }, global), true); + assert.equal(local.shouldRetry({ code: 'BUDGET_EXCEEDED' }, global), false); + assert.equal(local.shouldRetry({ statusCode: 418 }, global), null); +} + function testRecursiveRedaction() { const simulatedGithubToken = ['github', 'pat', 'abcdefghijklmnopqrstuvwxyz123456'].join('_'); const redacted = redactSensitive({ @@ -120,6 +135,7 @@ async function testSameModelRetryBeforeFailover() { const tests = [ testLocalProviderResolution, testCustomProviderAdapter, + testProviderRetryPolicy, testRecursiveRedaction, testStructuredEvents, testPersistentBudgetStore, From 407f8bdf722011009a6d8eabd19b9003271c9a03 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:27:22 -0400 Subject: [PATCH 069/105] ci: build native patched OpenCode binary --- .github/workflows/opencode-patch-compat.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index 0efeaec..7078e65 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -65,9 +65,9 @@ jobs: working-directory: upstream-opencode run: bun install --frozen-lockfile - - name: Build standalone OpenCode + - name: Build native standalone OpenCode working-directory: upstream-opencode - run: bun ./packages/opencode/script/build.ts --single + run: bun ./packages/opencode/script/build.ts --single --skip-install --skip-embed-web-ui - name: Verify patched source contracts run: node scripts/check-opencode-patch-contract.mjs upstream-opencode From 8e677cdd9422bc232b43d070c99ee25a8ac8c81f Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:28:42 -0400 Subject: [PATCH 070/105] chore: add retry default migration --- scripts/apply-retry-default.mjs | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/apply-retry-default.mjs diff --git a/scripts/apply-retry-default.mjs b/scripts/apply-retry-default.mjs new file mode 100644 index 0000000..1b13d2b --- /dev/null +++ b/scripts/apply-retry-default.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const read = path => fs.readFileSync(path, 'utf8'); +const write = (path, content) => { fs.writeFileSync(path, content); console.log(`updated ${path}`); }; +const replace = (path, before, after) => { + const content = read(path); + if (!content.includes(before)) throw new Error(`missing expected text in ${path}: ${before.slice(0, 120)}`); + write(path, content.replace(before, after)); +}; + +replace('runtime/execute-agent-task.mjs', ' maxRetries = 0,\n eventLogger', ' maxRetries,\n eventLogger'); +replace('runtime/execute-agent-task.mjs', " const retryLimit = Number.isInteger(maxRetries) ? Math.max(0, Math.min(maxRetries, 5)) : 0;\n", ''); +replace( + 'runtime/execute-agent-task.mjs', + " const freeFirstConfig = loadFreeFirstConfig(configPath);\n const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig);", + " const freeFirstConfig = loadFreeFirstConfig(configPath);\n const configuredRetryLimit = Number.isInteger(freeFirstConfig?.retry?.max_retries)\n ? freeFirstConfig.retry.max_retries\n : 0;\n const retryLimit = Number.isInteger(maxRetries)\n ? Math.max(0, Math.min(maxRetries, 5))\n : Math.max(0, Math.min(configuredRetryLimit, 5));\n const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig);" +); + +replace('runtime/agent-loop-controller.mjs', ' maxRetries = 0,\n cwd', ' maxRetries,\n cwd'); +replace( + 'runtime/agent-loop-controller.mjs', + " const config = loadConfig(DEFAULT_CONFIG_PATH);\n const smokeTestEnabled", + " const config = loadConfig(DEFAULT_CONFIG_PATH);\n const configuredRetryLimit = Number.isInteger(config?.retry?.max_retries) ? config.retry.max_retries : 0;\n const effectiveMaxRetries = Number.isInteger(maxRetries)\n ? Math.max(0, Math.min(maxRetries, 5))\n : Math.max(0, Math.min(configuredRetryLimit, 5));\n const smokeTestEnabled" +); +replace( + 'runtime/agent-loop-controller.mjs', + "events.emit('workflow.call', { stage: mode, data: { mode, maxRetries, budget: callBudget } });", + "events.emit('workflow.call', { stage: mode, data: { mode, maxRetries: effectiveMaxRetries, budget: callBudget } });" +); +replace( + 'runtime/agent-loop-controller.mjs', + ' maxRetries,\n eventLogger: events', + ' maxRetries: effectiveMaxRetries,\n eventLogger: events' +); + +replace('.opencode/plugins/agent-loop.js', ' maxRetries: args.maxRetries || 0,', ' maxRetries: args.maxRetries,'); +replace('tests/reliability-v020-tests.mjs', ' max_retries: 2, base_delay_ms: 0,', ' max_retries: 1, base_delay_ms: 0,'); +replace('tests/reliability-v020-tests.mjs', ' logDir: dir,\n maxRetries: 1,\n workerAdapter:', ' logDir: dir,\n workerAdapter:'); + +console.log('retry default migration applied'); From 7c57421e55fdd0bd2e35905d8f92f1a95f3814ef Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:30:06 -0400 Subject: [PATCH 071/105] chore: apply configured retry defaults --- .github/workflows/apply-retry-default.yml | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/apply-retry-default.yml diff --git a/.github/workflows/apply-retry-default.yml b/.github/workflows/apply-retry-default.yml new file mode 100644 index 0000000..9a0c096 --- /dev/null +++ b/.github/workflows/apply-retry-default.yml @@ -0,0 +1,39 @@ +name: Apply configured retry defaults + +on: + push: + branches: [release/v0.2.0-reliability] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Apply retry defaults + run: node scripts/apply-retry-default.mjs + - name: Install dependencies + run: npm ci + - name: Validate + run: npm run validate + - name: Commit changes + shell: bash + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: use configured retry defaults" + git push origin HEAD:release/v0.2.0-reliability From 1c2f913d8996fafdf2f9407843a2ecd97fb25730 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:33:03 -0400 Subject: [PATCH 072/105] chore: apply retry-default migration in release CI --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e2fc7..9f59798 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,35 @@ jobs: exit 1 fi echo "No committed environment files found." + + apply-retry-default: + if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Apply configured retry defaults + run: node scripts/apply-retry-default.mjs + - name: Install dependencies + run: npm ci + - name: Validate migrated tree + run: npm run validate + - name: Commit migrated files + shell: bash + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: use configured retry defaults" + git push origin HEAD:release/v0.2.0-reliability From 99cc903966e70597c1d69a134629be6d2c9fd062 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:34:41 -0400 Subject: [PATCH 073/105] chore: commit retry-default transformation before matrix validation --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f59798..24c1069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,10 +118,6 @@ jobs: cache: npm - name: Apply configured retry defaults run: node scripts/apply-retry-default.mjs - - name: Install dependencies - run: npm ci - - name: Validate migrated tree - run: npm run validate - name: Commit migrated files shell: bash run: | From 6a218fe942133c67054a22c549e3dbb054489151 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:34:56 +0000 Subject: [PATCH 074/105] fix: use configured retry defaults --- .opencode/plugins/agent-loop.js | 2 +- runtime/agent-loop-controller.mjs | 10 +++++++--- runtime/execute-agent-task.mjs | 9 +++++++-- tests/reliability-v020-tests.mjs | 3 +-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.opencode/plugins/agent-loop.js b/.opencode/plugins/agent-loop.js index bdb2af7..8b4e55d 100644 --- a/.opencode/plugins/agent-loop.js +++ b/.opencode/plugins/agent-loop.js @@ -90,7 +90,7 @@ export default async function AgentLoopPlugin() { const result = await runAgentLoop({ task, mode: args.mode || 'build', - maxRetries: args.maxRetries || 0, + maxRetries: args.maxRetries, cwd: context.directory, parentSessionId: context.sessionID, signal: context.abort, diff --git a/runtime/agent-loop-controller.mjs b/runtime/agent-loop-controller.mjs index 3d3a15d..f90e0a5 100644 --- a/runtime/agent-loop-controller.mjs +++ b/runtime/agent-loop-controller.mjs @@ -77,7 +77,7 @@ function budgetFailure({ taskId, budget, smokeResults = null, eventLogPath = '' export async function runAgentLoop({ task, mode = 'build', - maxRetries = 0, + maxRetries, cwd = process.cwd(), parentSessionId, metadata = {}, @@ -102,6 +102,10 @@ export async function runAgentLoop({ } const config = loadConfig(DEFAULT_CONFIG_PATH); + const configuredRetryLimit = Number.isInteger(config?.retry?.max_retries) ? config.retry.max_retries : 0; + const effectiveMaxRetries = Number.isInteger(maxRetries) + ? Math.max(0, Math.min(maxRetries, 5)) + : Math.max(0, Math.min(configuredRetryLimit, 5)); const smokeTestEnabled = config?.general?.smoke_test_enabled !== false; const events = new AgentLoopEventLogger({ taskId, @@ -117,7 +121,7 @@ export async function runAgentLoop({ cwd }); const callBudget = budgetTracker.recordWorkflowCall(mode); - events.emit('workflow.call', { stage: mode, data: { mode, maxRetries, budget: callBudget } }); + events.emit('workflow.call', { stage: mode, data: { mode, maxRetries: effectiveMaxRetries, budget: callBudget } }); if (!budgetTracker.canContinue()) { events.emit('budget.exceeded', { stage: mode, data: { budget: callBudget } }); return budgetFailure({ taskId, budget: callBudget, eventLogPath: events.path }); @@ -266,7 +270,7 @@ export async function runAgentLoop({ budgetTaskId: taskId, budgetStep: step.label, registryPath: DEFAULT_REGISTRY_PATH, - maxRetries, + maxRetries: effectiveMaxRetries, eventLogger: events }; if (smokeResults?.responsive.length) { diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index 3d52408..6366818 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -124,14 +124,13 @@ export async function executeAgentTask({ progressCallback, budgetTaskId = taskId, budgetStep = role, - maxRetries = 0, + maxRetries, eventLogger }) { if (!role) throw new Error('executeAgentTask requires role'); if (!agent) throw new Error('executeAgentTask requires agent'); if (!prompt) throw new Error('executeAgentTask requires prompt'); - const retryLimit = Number.isInteger(maxRetries) ? Math.max(0, Math.min(maxRetries, 5)) : 0; const events = eventLogger || new AgentLoopEventLogger({ taskId: budgetTaskId, cwd }); mkdirSync(logDir, { recursive: true }); const logPath = resolve(logDir, `${taskId}-${role}.jsonl`); @@ -158,6 +157,12 @@ export async function executeAgentTask({ } const freeFirstConfig = loadFreeFirstConfig(configPath); + const configuredRetryLimit = Number.isInteger(freeFirstConfig?.retry?.max_retries) + ? freeFirstConfig.retry.max_retries + : 0; + const retryLimit = Number.isInteger(maxRetries) + ? Math.max(0, Math.min(maxRetries, 5)) + : Math.max(0, Math.min(configuredRetryLimit, 5)); const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig); const budgetTracker = BudgetTracker.fromFiles({ taskId: budgetTaskId, diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs index 9548e1e..cc8ea02 100644 --- a/tests/reliability-v020-tests.mjs +++ b/tests/reliability-v020-tests.mjs @@ -88,7 +88,7 @@ async function testSameModelRetryBeforeFailover() { cooldowns: { default_cooldown_minutes: 1 }, provider_timeouts_ms: { default: 1000 }, retry: { - max_retries: 2, base_delay_ms: 0, max_delay_ms: 0, jitter_factor: 0, + max_retries: 1, base_delay_ms: 0, max_delay_ms: 0, jitter_factor: 0, retryable_http_codes: [429, 500, 502, 503, 504], retryable_errors: ['ETIMEDOUT'], non_retryable_errors: ['BUDGET_EXCEEDED'] @@ -117,7 +117,6 @@ async function testSameModelRetryBeforeFailover() { poolsPath, registryPath, logDir: dir, - maxRetries: 1, workerAdapter: async ({ model }) => { calls.push(model); if (calls.length === 1) return { success: false, code: 'ETIMEDOUT', timedOut: true, exitCode: 1, stdout: '', stderr: 'timeout' }; From ff8a10c0653741a3ef54ccd7924a4aa250a4c668 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:35:45 -0400 Subject: [PATCH 075/105] chore: remove one-time retry migration workflow --- .github/workflows/apply-retry-default.yml | 39 ----------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/apply-retry-default.yml diff --git a/.github/workflows/apply-retry-default.yml b/.github/workflows/apply-retry-default.yml deleted file mode 100644 index 9a0c096..0000000 --- a/.github/workflows/apply-retry-default.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Apply configured retry defaults - -on: - push: - branches: [release/v0.2.0-reliability] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Apply retry defaults - run: node scripts/apply-retry-default.mjs - - name: Install dependencies - run: npm ci - - name: Validate - run: npm run validate - - name: Commit changes - shell: bash - run: | - set -euo pipefail - git add -A - if git diff --cached --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: use configured retry defaults" - git push origin HEAD:release/v0.2.0-reliability From bcf26d9d3db101908ad6b6059d60f91cf59a5396 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:35:54 -0400 Subject: [PATCH 076/105] chore: remove one-time retry migration script --- scripts/apply-retry-default.mjs | 42 --------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 scripts/apply-retry-default.mjs diff --git a/scripts/apply-retry-default.mjs b/scripts/apply-retry-default.mjs deleted file mode 100644 index 1b13d2b..0000000 --- a/scripts/apply-retry-default.mjs +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const read = path => fs.readFileSync(path, 'utf8'); -const write = (path, content) => { fs.writeFileSync(path, content); console.log(`updated ${path}`); }; -const replace = (path, before, after) => { - const content = read(path); - if (!content.includes(before)) throw new Error(`missing expected text in ${path}: ${before.slice(0, 120)}`); - write(path, content.replace(before, after)); -}; - -replace('runtime/execute-agent-task.mjs', ' maxRetries = 0,\n eventLogger', ' maxRetries,\n eventLogger'); -replace('runtime/execute-agent-task.mjs', " const retryLimit = Number.isInteger(maxRetries) ? Math.max(0, Math.min(maxRetries, 5)) : 0;\n", ''); -replace( - 'runtime/execute-agent-task.mjs', - " const freeFirstConfig = loadFreeFirstConfig(configPath);\n const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig);", - " const freeFirstConfig = loadFreeFirstConfig(configPath);\n const configuredRetryLimit = Number.isInteger(freeFirstConfig?.retry?.max_retries)\n ? freeFirstConfig.retry.max_retries\n : 0;\n const retryLimit = Number.isInteger(maxRetries)\n ? Math.max(0, Math.min(maxRetries, 5))\n : Math.max(0, Math.min(configuredRetryLimit, 5));\n const { providerTimeouts, latencyTimeoutMapping } = extractProviderTimeoutConfig(freeFirstConfig);" -); - -replace('runtime/agent-loop-controller.mjs', ' maxRetries = 0,\n cwd', ' maxRetries,\n cwd'); -replace( - 'runtime/agent-loop-controller.mjs', - " const config = loadConfig(DEFAULT_CONFIG_PATH);\n const smokeTestEnabled", - " const config = loadConfig(DEFAULT_CONFIG_PATH);\n const configuredRetryLimit = Number.isInteger(config?.retry?.max_retries) ? config.retry.max_retries : 0;\n const effectiveMaxRetries = Number.isInteger(maxRetries)\n ? Math.max(0, Math.min(maxRetries, 5))\n : Math.max(0, Math.min(configuredRetryLimit, 5));\n const smokeTestEnabled" -); -replace( - 'runtime/agent-loop-controller.mjs', - "events.emit('workflow.call', { stage: mode, data: { mode, maxRetries, budget: callBudget } });", - "events.emit('workflow.call', { stage: mode, data: { mode, maxRetries: effectiveMaxRetries, budget: callBudget } });" -); -replace( - 'runtime/agent-loop-controller.mjs', - ' maxRetries,\n eventLogger: events', - ' maxRetries: effectiveMaxRetries,\n eventLogger: events' -); - -replace('.opencode/plugins/agent-loop.js', ' maxRetries: args.maxRetries || 0,', ' maxRetries: args.maxRetries,'); -replace('tests/reliability-v020-tests.mjs', ' max_retries: 2, base_delay_ms: 0,', ' max_retries: 1, base_delay_ms: 0,'); -replace('tests/reliability-v020-tests.mjs', ' logDir: dir,\n maxRetries: 1,\n workerAdapter:', ' logDir: dir,\n workerAdapter:'); - -console.log('retry default migration applied'); From dd4db5fa0113ebbdbf4f242280358c69f0e4223e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:36:14 -0400 Subject: [PATCH 077/105] chore: restore standard CI workflow --- .github/workflows/ci.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c1069..a8e2fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,31 +102,3 @@ jobs: exit 1 fi echo "No committed environment files found." - - apply-retry-default: - if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Apply configured retry defaults - run: node scripts/apply-retry-default.mjs - - name: Commit migrated files - shell: bash - run: | - set -euo pipefail - git add -A - if git diff --cached --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: use configured retry defaults" - git push origin HEAD:release/v0.2.0-reliability From 00442c5a37d3e561f60c509f7ba7a7a1decda8ac Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:37:46 -0400 Subject: [PATCH 078/105] chore: preserve low-level executor retry default --- scripts/apply-internal-retry-default.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 scripts/apply-internal-retry-default.mjs diff --git a/scripts/apply-internal-retry-default.mjs b/scripts/apply-internal-retry-default.mjs new file mode 100644 index 0000000..d8f36a6 --- /dev/null +++ b/scripts/apply-internal-retry-default.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const path = 'runtime/execute-agent-task.mjs'; +const content = fs.readFileSync(path, 'utf8'); +const before = ' maxRetries,\n eventLogger\n}) {'; +const after = ' maxRetries = 0,\n eventLogger\n}) {'; +if (!content.includes(before)) throw new Error('Expected executeAgentTask retry signature not found'); +fs.writeFileSync(path, content.replace(before, after)); +console.log('Low-level executor default restored; controller still passes configured retry value.'); From 1babed821313ae7d5caf69b1c8622963cde6dd2c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:38:17 -0400 Subject: [PATCH 079/105] chore: apply low-level retry default in release CI --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e2fc7..d9f3c70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,27 @@ jobs: exit 1 fi echo "No committed environment files found." + + apply-internal-retry-default: + if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - name: Apply low-level executor default + run: node scripts/apply-internal-retry-default.mjs + - name: Commit transformed file + shell: bash + run: | + set -euo pipefail + git add runtime/execute-agent-task.mjs + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: preserve internal failover retry default" + git push origin HEAD:release/v0.2.0-reliability From 06b1beb408407064b4c56284959eb4aa0a54a285 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:38:28 +0000 Subject: [PATCH 080/105] fix: preserve internal failover retry default --- runtime/execute-agent-task.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index 6366818..ad4277a 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -124,7 +124,7 @@ export async function executeAgentTask({ progressCallback, budgetTaskId = taskId, budgetStep = role, - maxRetries, + maxRetries = 0, eventLogger }) { if (!role) throw new Error('executeAgentTask requires role'); From ed7c629020a032e35b95752094b65247134461b7 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:39:13 -0400 Subject: [PATCH 081/105] chore: remove internal retry migration script --- scripts/apply-internal-retry-default.mjs | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 scripts/apply-internal-retry-default.mjs diff --git a/scripts/apply-internal-retry-default.mjs b/scripts/apply-internal-retry-default.mjs deleted file mode 100644 index d8f36a6..0000000 --- a/scripts/apply-internal-retry-default.mjs +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const path = 'runtime/execute-agent-task.mjs'; -const content = fs.readFileSync(path, 'utf8'); -const before = ' maxRetries,\n eventLogger\n}) {'; -const after = ' maxRetries = 0,\n eventLogger\n}) {'; -if (!content.includes(before)) throw new Error('Expected executeAgentTask retry signature not found'); -fs.writeFileSync(path, content.replace(before, after)); -console.log('Low-level executor default restored; controller still passes configured retry value.'); From 49cf54604062256d9f67ca6ad77fdc660768bbda Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:39:36 -0400 Subject: [PATCH 082/105] chore: restore standard CI after retry migration --- .github/workflows/ci.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9f3c70..a8e2fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,27 +102,3 @@ jobs: exit 1 fi echo "No committed environment files found." - - apply-internal-retry-default: - if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - name: Apply low-level executor default - run: node scripts/apply-internal-retry-default.mjs - - name: Commit transformed file - shell: bash - run: | - set -euo pipefail - git add runtime/execute-agent-task.mjs - if git diff --cached --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: preserve internal failover retry default" - git push origin HEAD:release/v0.2.0-reliability From 67ff873b0732adf9f919b68f4812336b7caf80e5 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:41:02 -0400 Subject: [PATCH 083/105] test: pass retry override to low-level executor --- tests/reliability-v020-tests.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/reliability-v020-tests.mjs b/tests/reliability-v020-tests.mjs index cc8ea02..560ac09 100644 --- a/tests/reliability-v020-tests.mjs +++ b/tests/reliability-v020-tests.mjs @@ -117,6 +117,7 @@ async function testSameModelRetryBeforeFailover() { poolsPath, registryPath, logDir: dir, + maxRetries: 1, workerAdapter: async ({ model }) => { calls.push(model); if (calls.length === 1) return { success: false, code: 'ETIMEDOUT', timedOut: true, exitCode: 1, stdout: '', stderr: 'timeout' }; From 0bf739c6559b6c853db5c8eed8b61a3d1a0f4533 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:42:29 -0400 Subject: [PATCH 084/105] ci: preserve patched OpenCode build diagnostics --- .github/workflows/opencode-patch-compat.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index 7078e65..1e4248a 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -67,7 +67,13 @@ jobs: - name: Build native standalone OpenCode working-directory: upstream-opencode - run: bun ./packages/opencode/script/build.ts --single --skip-install --skip-embed-web-ui + shell: bash + run: | + set +e + bun ./packages/opencode/script/build.ts --single --skip-install --skip-embed-web-ui 2>&1 | tee ../opencode-build.log + status=${PIPESTATUS[0]} + set -e + exit "$status" - name: Verify patched source contracts run: node scripts/check-opencode-patch-contract.mjs upstream-opencode @@ -85,7 +91,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: opencode-supported-compatibility - path: opencode-tested-revision.txt + path: | + opencode-tested-revision.txt + opencode-build.log upstream-dev-probe: if: github.event_name != 'workflow_dispatch' || inputs.opencode_ref == '' From 7eb89d5206515940f1fdfab6b689962652cd95eb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:44:27 -0400 Subject: [PATCH 085/105] chore: add Windows test path migration --- scripts/apply-windows-test-paths.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/apply-windows-test-paths.mjs diff --git a/scripts/apply-windows-test-paths.mjs b/scripts/apply-windows-test-paths.mjs new file mode 100644 index 0000000..8b67fba --- /dev/null +++ b/scripts/apply-windows-test-paths.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const path = 'tests/runtime-tests.mjs'; +let content = fs.readFileSync(path, 'utf8'); +content = content.replace( + "import { resolve, join } from 'node:path';", + "import { resolve, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';" +); +content = content.replace( + "import {\n loadState,", + "const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));\n\nimport {\n loadState," +); +content = content.replaceAll( + "resolve(new URL('..', import.meta.url).pathname, 'opencode.json')", + "resolve(PACKAGE_ROOT, 'opencode.json')" +); +content = content.replaceAll( + "resolve(new URL('..', import.meta.url).pathname, 'agents/review.md')", + "resolve(PACKAGE_ROOT, 'agents/review.md')" +); +fs.writeFileSync(path, content); +console.log('Windows-safe test paths applied.'); From d6b304eb7a6d9a333e4904519d78e054606cbb73 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:44:58 -0400 Subject: [PATCH 086/105] chore: apply Windows-safe runtime test paths --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e2fc7..7a6ad34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,27 @@ jobs: exit 1 fi echo "No committed environment files found." + + apply-windows-test-paths: + if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - name: Apply Windows-safe test paths + run: node scripts/apply-windows-test-paths.mjs + - name: Commit transformed test + shell: bash + run: | + set -euo pipefail + git add tests/runtime-tests.mjs + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "test: use Windows-safe repository paths" + git push origin HEAD:release/v0.2.0-reliability From def42f7a8ae06bf755c91a6dda6a8f37f21241ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:45:10 +0000 Subject: [PATCH 087/105] test: use Windows-safe repository paths --- tests/runtime-tests.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/runtime-tests.mjs b/tests/runtime-tests.mjs index a949c3f..cf64987 100644 --- a/tests/runtime-tests.mjs +++ b/tests/runtime-tests.mjs @@ -2,9 +2,12 @@ import { strict as assert } from 'node:assert'; import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { normalizePoolConfig, PoolConfigError, retiredModelSet } from '../lib/pool-normalizer.mjs'; import { executeAgentTask } from '../runtime/execute-agent-task.mjs'; import { assertCanStartLoop } from '../runtime/recursion-guard.mjs'; +const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); + import { loadState, saveState, @@ -715,14 +718,14 @@ async function testTaskFailureDoesNotTriggerCooldown() { async function testPluginRegistrationInConfig() { // Verify that opencode.json contains the agent-loop.js plugin reference - const configText = readFileSync(resolve(new URL('..', import.meta.url).pathname, 'opencode.json'), 'utf-8'); + const configText = readFileSync(resolve(PACKAGE_ROOT, 'opencode.json'), 'utf-8'); assert.ok(configText.includes('agent-loop.js'), 'opencode.json should reference agent-loop.js plugin'); return { name: 'Plugin registration present in opencode.json', passed: true }; } async function testReviewPermissionsValidFormat() { // Verify review.md has edit: deny in any valid format (indented or top-level) - const reviewText = readFileSync(resolve(new URL('..', import.meta.url).pathname, 'agents/review.md'), 'utf-8'); + const reviewText = readFileSync(resolve(PACKAGE_ROOT, 'agents/review.md'), 'utf-8'); const hasIndented = /\s+edit: deny/.test(reviewText); assert.ok(hasIndented, 'review.md should contain edit: deny (indented or top-level)'); return { name: 'Review permissions in valid nested format', passed: true }; From 4937cb513b1e7b1289df016705b184c38e3e58e6 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:46:35 -0400 Subject: [PATCH 088/105] chore: remove Windows path migration script --- scripts/apply-windows-test-paths.mjs | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 scripts/apply-windows-test-paths.mjs diff --git a/scripts/apply-windows-test-paths.mjs b/scripts/apply-windows-test-paths.mjs deleted file mode 100644 index 8b67fba..0000000 --- a/scripts/apply-windows-test-paths.mjs +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const path = 'tests/runtime-tests.mjs'; -let content = fs.readFileSync(path, 'utf8'); -content = content.replace( - "import { resolve, join } from 'node:path';", - "import { resolve, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';" -); -content = content.replace( - "import {\n loadState,", - "const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));\n\nimport {\n loadState," -); -content = content.replaceAll( - "resolve(new URL('..', import.meta.url).pathname, 'opencode.json')", - "resolve(PACKAGE_ROOT, 'opencode.json')" -); -content = content.replaceAll( - "resolve(new URL('..', import.meta.url).pathname, 'agents/review.md')", - "resolve(PACKAGE_ROOT, 'agents/review.md')" -); -fs.writeFileSync(path, content); -console.log('Windows-safe test paths applied.'); From f233a39a2154545c2fe49ce0d3daaef9be1b2320 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:46:53 -0400 Subject: [PATCH 089/105] chore: restore standard release CI --- .github/workflows/ci.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a6ad34..a8e2fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,27 +102,3 @@ jobs: exit 1 fi echo "No committed environment files found." - - apply-windows-test-paths: - if: github.event_name == 'pull_request' && github.head_ref == 'release/v0.2.0-reliability' && github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - name: Apply Windows-safe test paths - run: node scripts/apply-windows-test-paths.mjs - - name: Commit transformed test - shell: bash - run: | - set -euo pipefail - git add tests/runtime-tests.mjs - if git diff --cached --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "test: use Windows-safe repository paths" - git push origin HEAD:release/v0.2.0-reliability From e782267eeb4c4b73ca43701641321b2eaf50c605 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:48:56 -0400 Subject: [PATCH 090/105] ci: preserve platform runtime-test diagnostics --- .github/workflows/cross-platform.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index fe65b51..950f186 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -42,7 +42,19 @@ jobs: - name: Test routing run: node tests/routing-tests.mjs - name: Test runtime - run: node tests/runtime-tests.mjs + shell: bash + run: | + set +e + node tests/runtime-tests.mjs 2>&1 | tee runtime-test.log + status=${PIPESTATUS[0]} + set -e + exit "$status" + - name: Upload runtime-test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: runtime-test-${{ matrix.os }}-node-${{ matrix.node-version }} + path: runtime-test.log - name: Test plugin integration run: node tests/tool-integration-tests.mjs - name: Test bypass detection From 7807d696a9a6e2d2cc5b8eff3c188ab3b7b5987d Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:51:10 -0400 Subject: [PATCH 091/105] fix: persist paid fallback audit state portably --- lib/paid-fallback.mjs | 254 +++++++++++++++++++----------------------- 1 file changed, 113 insertions(+), 141 deletions(-) diff --git a/lib/paid-fallback.mjs b/lib/paid-fallback.mjs index f61bc8e..9defe75 100644 --- a/lib/paid-fallback.mjs +++ b/lib/paid-fallback.mjs @@ -1,202 +1,174 @@ -/** - * paid-fallback.mjs - * - * Controls and audits paid-model fallback from free-tier providers. - * Guards against exceeding call limits and logs all escalations - * without leaking secrets, prompts, or credentials. - * - * @module paid-fallback - */ - -import { readFileSync, appendFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -/** - * @typedef {Object} EscalationLogEntry - * @property {string} timestamp - ISO-8601 - * @property {string} taskId - * @property {string} role - * @property {number} freeModelsAttempted - * @property {string} failuresSummary - * @property {string} paidModelSelected - * @property {string} result - */ - -const ESCALATION_LOG_PATH = '/tmp/paid-escalation-log.jsonl'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync +} from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const PAID_STATE_VERSION = 1; + +function safeInteger(value, fallback = 0) { + return Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function atomicWriteJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} + +function readState(path) { + if (!existsSync(path)) return { globalCallCount: 0, taskCallCounts: new Map() }; + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')); + if (parsed.version !== PAID_STATE_VERSION) return { globalCallCount: 0, taskCallCounts: new Map() }; + return { + globalCallCount: safeInteger(parsed.globalCallCount), + taskCallCounts: new Map( + Object.entries(parsed.taskCallCounts || {}) + .map(([taskId, count]) => [taskId, safeInteger(count)]) + ) + }; + } catch { + return { globalCallCount: 0, taskCallCounts: new Map() }; + } +} + +function sanitizeFailureSummary(failures = []) { + return failures.map((failure, index) => { + const parts = []; + if (failure.modelId) parts.push(`model:${failure.modelId}`); + if (failure.errorCode) parts.push(`code:${failure.errorCode}`); + if (failure.errorName) parts.push(`error:${failure.errorName}`); + if (failure.statusCode) parts.push(`http:${failure.statusCode}`); + return `[${index + 1}] ${parts.join(' ')}`; + }).join('; ') || 'none'; +} export class PaidFallbackController { - /** - * @param {string} configPath - Path to free-first-config.json - */ - constructor(configPath = './config/free-first-config.json') { + constructor(configPath = './config/free-first-config.json', { + cwd = process.cwd(), + logPath, + statePath + } = {}) { this.configPath = resolve(configPath); - /** @type {Object|null} */ + this.cwd = cwd; this.config = null; - /** - * Volatile per-task call counter (reset on restart — acceptable per spec). - * @type {Map} - */ + this.logPathOverride = logPath; + this.statePathOverride = statePath; + this.logPath = null; + this.statePath = null; this._callCounts = new Map(); this._globalCallCount = 0; + this._stateLoaded = false; } - /** - * Load configuration. - * @returns {Promise} - */ async _loadConfig() { if (this.config) return this.config; - const raw = readFileSync(this.configPath, 'utf-8'); - this.config = JSON.parse(raw); + this.config = JSON.parse(readFileSync(this.configPath, 'utf8')); + const general = this.config.general || {}; + this.logPath = process.env.AGENT_LOOP_PAID_FALLBACK_LOG_PATH || this.logPathOverride || resolve( + this.cwd, + general.paid_fallback_log_path || '.opencode/agent-loop-state/paid-escalations.jsonl' + ); + this.statePath = process.env.AGENT_LOOP_PAID_FALLBACK_STATE_PATH || this.statePathOverride || resolve( + this.cwd, + general.paid_fallback_state_path || '.opencode/agent-loop-state/paid-fallback-state.json' + ); + this._loadState(); return this.config; } - /** - * Check whether paid fallback is allowed for a given role. - * - * Verifies: - * 1. `allow_paid_fallback` is true in config - * 2. The role is in `paid_fallback_allowed_roles` - * - * @param {string} role - e.g. "orchestrator", "builder", "reviewer" - * @returns {Promise} - */ - async isPaidFallbackAllowed(role) { - const cfg = await this._loadConfig(); - const general = cfg.general; - - if (!general.allow_paid_fallback) { - return false; - } + _loadState() { + if (this._stateLoaded || !this.statePath) return; + const state = readState(this.statePath); + this._globalCallCount = state.globalCallCount; + this._callCounts = state.taskCallCounts; + this._stateLoaded = true; + } - if (!general.paid_fallback_allowed_roles || !Array.isArray(general.paid_fallback_allowed_roles)) { - return false; - } + _saveState() { + if (!this.statePath) return; + atomicWriteJson(this.statePath, { + version: PAID_STATE_VERSION, + savedAt: new Date().toISOString(), + globalCallCount: this._globalCallCount, + taskCallCounts: Object.fromEntries(this._callCounts) + }); + } - return general.paid_fallback_allowed_roles.includes(role); + async isPaidFallbackAllowed(role) { + const config = await this._loadConfig(); + const general = config.general || {}; + return general.allow_paid_fallback === true && + Array.isArray(general.paid_fallback_allowed_roles) && + general.paid_fallback_allowed_roles.includes(role); } - /** - * Log a paid escalation event. - * - * The log entry MUST NOT contain prompts, secrets, or credentials. - * Only metadata: timestamp, taskId, role, freeModelsAttempted count, - * summary of failures, paidModelSelected, and result. - * - * @param {Object} opts - * @param {string} opts.taskId - * @param {string} opts.role - * @param {number} opts.freeModelsAttempted - Number of free models tried - * @param {Array} opts.failures - List of failure objects (only summary used) - * @param {string} opts.paidModelSelected - The model_id chosen for paid fallback - * @param {string} opts.result - Outcome description (no secrets) - * @returns {Promise} - */ async logEscalation({ taskId, role, freeModelsAttempted, failures, paidModelSelected, result }) { - const cfg = await this._loadConfig(); - - // Build a safe summary of failures — no prompts, secrets, or credentials - const failuresSummary = (failures || []) - .map((f, i) => { - // Only include safe metadata - const parts = []; - if (f.modelId) parts.push(`model:${f.modelId}`); - if (f.errorCode) parts.push(`code:${f.errorCode}`); - if (f.errorName) parts.push(`error:${f.errorName}`); - if (f.statusCode) parts.push(`http:${f.statusCode}`); - // Omit 'message', 'prompt', 'response', 'credentials', 'secrets' - return `[${i + 1}] ${parts.join(' ')}`; - }) - .join('; ') || 'none'; - + await this._loadConfig(); const entry = { timestamp: new Date().toISOString(), taskId, role, freeModelsAttempted, - failuresSummary, + failuresSummary: sanitizeFailureSummary(failures), paidModelSelected, result }; - - // Append to JSONL file - appendFileSync(ESCALATION_LOG_PATH, JSON.stringify(entry) + '\n', 'utf-8'); - - // Increment paid counters only after an authorized paid invocation is selected. + mkdirSync(dirname(this.logPath), { recursive: true }); + appendFileSync(this.logPath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); await this.recordPaidCall(taskId); } - /** - * Get the number of paid calls made for a given task. - * - * @param {string} taskId - * @returns {Promise} - */ async getCallCount(taskId) { + await this._loadConfig(); return this._callCounts.get(taskId) || 0; } - /** - * Increment the paid call counter for a task. - * - * @param {string} taskId - * @returns {Promise} The new count - */ async incrementCallCount(taskId) { - const current = this._callCounts.get(taskId) || 0; - const next = current + 1; + await this._loadConfig(); + const next = (this._callCounts.get(taskId) || 0) + 1; this._callCounts.set(taskId, next); + this._saveState(); return next; } - /** - * Check if a paid fallback call is allowed for the given task and role, - * enforcing the max-calls-per-task limit. - * - * @param {string} taskId - * @param {string} role - * @returns {Promise<{allowed: boolean, reason?: string}>} - */ async isCallAllowed(taskId, role, options = {}) { - const cfg = await this._loadConfig(); - const general = cfg.general; - - // 1. Must be globally allowed - const roleAllowed = await this.isPaidFallbackAllowed(role); - if (!roleAllowed) { + const config = await this._loadConfig(); + const general = config.general || {}; + if (!await this.isPaidFallbackAllowed(role)) { return { allowed: false, reason: `Paid fallback not allowed for role "${role}"` }; } - // 2. Must not exceed max calls per task const maxCalls = general.paid_fallback_max_calls_per_task ?? 1; const currentCount = await this.getCallCount(taskId); if (currentCount >= maxCalls) { - return { - allowed: false, - reason: `Paid fallback call limit (${maxCalls}) reached for task "${taskId}"` - }; + return { allowed: false, reason: `Paid fallback call limit (${maxCalls}) reached for task "${taskId}"` }; } const globalMaxCalls = general.paid_fallback_max_calls_global; if (Number.isInteger(globalMaxCalls) && this._globalCallCount >= globalMaxCalls) { - return { - allowed: false, - reason: `Global paid fallback call limit (${globalMaxCalls}) reached` - }; + return { allowed: false, reason: `Global paid fallback call limit (${globalMaxCalls}) reached` }; } if (general.paid_fallback_requires_approval === true && options.approved !== true) { - return { - allowed: false, - reason: 'Paid fallback requires explicit approval' - }; + return { allowed: false, reason: 'Paid fallback requires explicit approval' }; } return { allowed: true }; } async recordPaidCall(taskId) { + await this._loadConfig(); this._globalCallCount += 1; - return this.incrementCallCount(taskId); + const next = (this._callCounts.get(taskId) || 0) + 1; + this._callCounts.set(taskId, next); + this._saveState(); + return next; } } From 3279c34df9b3b21a327f8bd48748283823fc5010 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:52:13 -0400 Subject: [PATCH 092/105] chore: add paid fallback state migration --- scripts/apply-paid-fallback-cwd.mjs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 scripts/apply-paid-fallback-cwd.mjs diff --git a/scripts/apply-paid-fallback-cwd.mjs b/scripts/apply-paid-fallback-cwd.mjs new file mode 100644 index 0000000..4ee5ad6 --- /dev/null +++ b/scripts/apply-paid-fallback-cwd.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; + +const executePath = 'runtime/execute-agent-task.mjs'; +let execute = fs.readFileSync(executePath, 'utf8'); +const before = ' const paidFallback = new PaidFallbackController(configPath);'; +const after = ' const paidFallback = new PaidFallbackController(configPath, { cwd });'; +if (!execute.includes(before)) throw new Error('PaidFallbackController construction not found'); +fs.writeFileSync(executePath, execute.replace(before, after)); + +const configPath = 'config/free-first-config.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +config.general.paid_fallback_log_path = '.opencode/agent-loop-state/paid-escalations.jsonl'; +config.general.paid_fallback_state_path = '.opencode/agent-loop-state/paid-fallback-state.json'; +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); + +const docsPath = 'docs/configuration.md'; +let docs = fs.readFileSync(docsPath, 'utf8'); +const marker = '## Structured events\n'; +const section = `## Paid fallback audit state\n\nPaid fallback selections and call counters are persisted per project under \`.opencode/agent-loop-state/\`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistent counters prevent a process restart from resetting per-task or global paid-call ceilings.\n\n`; +if (!docs.includes(section)) { + if (!docs.includes(marker)) throw new Error('Configuration documentation marker not found'); + docs = docs.replace(marker, `${section}${marker}`); + fs.writeFileSync(docsPath, docs); +} + +console.log('Paid fallback state migration applied.'); From 13bc5a87adaa1e188ab15a70eb45af3e46b31dfb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:52:32 -0400 Subject: [PATCH 093/105] chore: apply portable paid fallback state --- .../workflows/apply-paid-fallback-state.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/apply-paid-fallback-state.yml diff --git a/.github/workflows/apply-paid-fallback-state.yml b/.github/workflows/apply-paid-fallback-state.yml new file mode 100644 index 0000000..910bba3 --- /dev/null +++ b/.github/workflows/apply-paid-fallback-state.yml @@ -0,0 +1,34 @@ +name: Apply portable paid fallback state + +on: + push: + branches: [release/v0.2.0-reliability] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/v0.2.0-reliability + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: node scripts/apply-paid-fallback-cwd.mjs + - run: npm ci + - run: npm run validate + - name: Commit changes + shell: bash + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: persist paid fallback audit state portably" + git push origin HEAD:release/v0.2.0-reliability From 0ee6aca9fec7d7463c40d1ce5b809a1cfaba966c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:52:45 +0000 Subject: [PATCH 094/105] fix: persist paid fallback audit state portably --- config/free-first-config.json | 4 +++- docs/configuration.md | 4 ++++ runtime/execute-agent-task.mjs | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/config/free-first-config.json b/config/free-first-config.json index 09a7d6c..53fa867 100644 --- a/config/free-first-config.json +++ b/config/free-first-config.json @@ -33,7 +33,9 @@ ], "local_only_task_classifications": [ "trusted-provider-only" - ] + ], + "paid_fallback_log_path": ".opencode/agent-loop-state/paid-escalations.jsonl", + "paid_fallback_state_path": ".opencode/agent-loop-state/paid-fallback-state.json" }, "cooldowns": { "single_rate_limit_minutes": 10, diff --git a/docs/configuration.md b/docs/configuration.md index 343d3e1..291fd53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,10 @@ A snapshot reports limits, remaining allowance, token and cost usage, workflow c The runtime limits parent orchestration with a workflow-call ceiling and a reduced maximum turn count. OpenCode does not currently expose parent-session token and cost events to this plugin, so precise parent-model tokens and dollars are not included. Results state `parentModelUsageIncluded: false` rather than claiming a complete task-cost total. +## Paid fallback audit state + +Paid fallback selections and call counters are persisted per project under `.opencode/agent-loop-state/`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistent counters prevent a process restart from resetting per-task or global paid-call ceilings. + ## Structured events The default append-only stream is: diff --git a/runtime/execute-agent-task.mjs b/runtime/execute-agent-task.mjs index ad4277a..992b940 100644 --- a/runtime/execute-agent-task.mjs +++ b/runtime/execute-agent-task.mjs @@ -175,7 +175,7 @@ export async function executeAgentTask({ const failover = new FailoverHandler(configPath, poolsPath); await failover.loadConfig(); await failover.loadPools(); - const paidFallback = new PaidFallbackController(configPath); + const paidFallback = new PaidFallbackController(configPath, { cwd }); const attemptDetails = []; const stepStartedAt = new Date().toISOString(); events.emit('stage.started', { stage: budgetStep, role, data: { agent, poolName, retryLimit, candidateModels: models.map(model => model.modelId) } }); From 95eccdf1b6f09e967243e967071f363a8d43ae91 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:53:14 -0400 Subject: [PATCH 095/105] chore: remove paid fallback migration workflow --- .../workflows/apply-paid-fallback-state.yml | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/apply-paid-fallback-state.yml diff --git a/.github/workflows/apply-paid-fallback-state.yml b/.github/workflows/apply-paid-fallback-state.yml deleted file mode 100644 index 910bba3..0000000 --- a/.github/workflows/apply-paid-fallback-state.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Apply portable paid fallback state - -on: - push: - branches: [release/v0.2.0-reliability] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/v0.2.0-reliability - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: node scripts/apply-paid-fallback-cwd.mjs - - run: npm ci - - run: npm run validate - - name: Commit changes - shell: bash - run: | - set -euo pipefail - git add -A - if git diff --cached --quiet; then exit 0; fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: persist paid fallback audit state portably" - git push origin HEAD:release/v0.2.0-reliability From 2988c7859b4ed9d8567cdc2771553afb24b77b12 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:53:24 -0400 Subject: [PATCH 096/105] chore: remove paid fallback migration script --- scripts/apply-paid-fallback-cwd.mjs | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 scripts/apply-paid-fallback-cwd.mjs diff --git a/scripts/apply-paid-fallback-cwd.mjs b/scripts/apply-paid-fallback-cwd.mjs deleted file mode 100644 index 4ee5ad6..0000000 --- a/scripts/apply-paid-fallback-cwd.mjs +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; - -const executePath = 'runtime/execute-agent-task.mjs'; -let execute = fs.readFileSync(executePath, 'utf8'); -const before = ' const paidFallback = new PaidFallbackController(configPath);'; -const after = ' const paidFallback = new PaidFallbackController(configPath, { cwd });'; -if (!execute.includes(before)) throw new Error('PaidFallbackController construction not found'); -fs.writeFileSync(executePath, execute.replace(before, after)); - -const configPath = 'config/free-first-config.json'; -const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -config.general.paid_fallback_log_path = '.opencode/agent-loop-state/paid-escalations.jsonl'; -config.general.paid_fallback_state_path = '.opencode/agent-loop-state/paid-fallback-state.json'; -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); - -const docsPath = 'docs/configuration.md'; -let docs = fs.readFileSync(docsPath, 'utf8'); -const marker = '## Structured events\n'; -const section = `## Paid fallback audit state\n\nPaid fallback selections and call counters are persisted per project under \`.opencode/agent-loop-state/\`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistent counters prevent a process restart from resetting per-task or global paid-call ceilings.\n\n`; -if (!docs.includes(section)) { - if (!docs.includes(marker)) throw new Error('Configuration documentation marker not found'); - docs = docs.replace(marker, `${section}${marker}`); - fs.writeFileSync(docsPath, docs); -} - -console.log('Paid fallback state migration applied.'); From dcc80ff3317d700a30635317d18529764537f4bb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:54:44 -0400 Subject: [PATCH 097/105] test: normalize bypass scan paths on Windows --- tests/bypass-detection.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/bypass-detection.mjs b/tests/bypass-detection.mjs index b33c4a8..9f2de7b 100644 --- a/tests/bypass-detection.mjs +++ b/tests/bypass-detection.mjs @@ -1,33 +1,38 @@ import { strict as assert } from 'node:assert'; import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { resolve, relative } from 'node:path'; +import { resolve, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const root = resolve(new URL('..', import.meta.url).pathname); +const root = fileURLToPath(new URL('..', import.meta.url)); const allowed = new Set([ 'runtime/opencode-worker-runner.mjs', 'tests/bypass-detection.mjs', 'scripts/watch-agent-configs.sh' ]); -const excludedDirs = new Set(['.git', 'node_modules', 'tests', 'docs', 'templates', 'skills', '.opencode/agent-loop-logs']); +const excludedDirs = new Set(['.git', 'node_modules', 'tests', 'docs', 'templates', 'skills', '.opencode/agent-loop-logs', '.opencode/agent-loop-state']); const excludedFiles = [/README\.md$/, /CHANGELOG\.md$/, /package-lock\.json$/]; const patterns = [/opencode\s+run/, /\bspawn\s*\(/, /\bexecFile\s*\(/, /\bexec\s*\(/, /child_process/, /createOpencodeClient/, /createOpencode\s*\(/]; +function portableRelative(path) { + return relative(root, path).split(sep).join('/'); +} + function walk(dir, out = []) { for (const entry of readdirSync(dir)) { const abs = resolve(dir, entry); - const rel = relative(root, abs); + const rel = portableRelative(abs); if (rel.split('/').includes('node_modules')) continue; - if ([...excludedDirs].some(ex => rel === ex || rel.startsWith(`${ex}/`))) continue; + if ([...excludedDirs].some(excluded => rel === excluded || rel.startsWith(`${excluded}/`))) continue; const stat = statSync(abs); if (stat.isDirectory()) walk(abs, out); - else if (/\.(mjs|js|ts|sh|json)$/.test(entry) && !excludedFiles.some(rx => rx.test(rel))) out.push(abs); + else if (/\.(mjs|js|ts|sh|json)$/.test(entry) && !excludedFiles.some(pattern => pattern.test(rel))) out.push(abs); } return out; } const offenders = []; for (const file of walk(root)) { - const rel = relative(root, file); + const rel = portableRelative(file); if (allowed.has(rel)) continue; const text = readFileSync(file, 'utf8'); for (const pattern of patterns) { From 638829541f1678360dd45a76a0d7138da60dcade Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:56:50 -0400 Subject: [PATCH 098/105] fix: window persistent paid fallback counters --- lib/paid-fallback.mjs | 109 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/lib/paid-fallback.mjs b/lib/paid-fallback.mjs index 9defe75..68a7250 100644 --- a/lib/paid-fallback.mjs +++ b/lib/paid-fallback.mjs @@ -8,12 +8,26 @@ import { } from 'node:fs'; import { dirname, resolve } from 'node:path'; -const PAID_STATE_VERSION = 1; +const PAID_STATE_VERSION = 2; +const DEFAULT_GLOBAL_WINDOW_MINUTES = 24 * 60; +const DEFAULT_TASK_STATE_TTL_MINUTES = 24 * 60; function safeInteger(value, fallback = 0) { return Number.isInteger(value) && value >= 0 ? value : fallback; } +function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function emptyState(now = Date.now()) { + return { + globalCallCount: 0, + globalWindowStartedAtMs: now, + taskCallState: new Map() + }; +} + function atomicWriteJson(path, value) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; @@ -21,20 +35,31 @@ function atomicWriteJson(path, value) { renameSync(temporary, path); } -function readState(path) { - if (!existsSync(path)) return { globalCallCount: 0, taskCallCounts: new Map() }; +function normalizeTaskState(value, now) { + if (typeof value === 'number') { + return { count: safeInteger(value), updatedAtMs: now }; + } + return { + count: safeInteger(value?.count), + updatedAtMs: safeInteger(value?.updatedAtMs, now) + }; +} + +function readState(path, now = Date.now()) { + if (!existsSync(path)) return emptyState(now); try { const parsed = JSON.parse(readFileSync(path, 'utf8')); - if (parsed.version !== PAID_STATE_VERSION) return { globalCallCount: 0, taskCallCounts: new Map() }; + if (parsed.version !== 1 && parsed.version !== PAID_STATE_VERSION) return emptyState(now); + const rawTasks = parsed.taskCallState || parsed.taskCallCounts || {}; return { globalCallCount: safeInteger(parsed.globalCallCount), - taskCallCounts: new Map( - Object.entries(parsed.taskCallCounts || {}) - .map(([taskId, count]) => [taskId, safeInteger(count)]) + globalWindowStartedAtMs: safeInteger(parsed.globalWindowStartedAtMs, now), + taskCallState: new Map( + Object.entries(rawTasks).map(([taskId, value]) => [taskId, normalizeTaskState(value, now)]) ) }; } catch { - return { globalCallCount: 0, taskCallCounts: new Map() }; + return emptyState(now); } } @@ -53,7 +78,8 @@ export class PaidFallbackController { constructor(configPath = './config/free-first-config.json', { cwd = process.cwd(), logPath, - statePath + statePath, + now = () => Date.now() } = {}) { this.configPath = resolve(configPath); this.cwd = cwd; @@ -62,9 +88,13 @@ export class PaidFallbackController { this.statePathOverride = statePath; this.logPath = null; this.statePath = null; - this._callCounts = new Map(); this._globalCallCount = 0; + this._globalWindowStartedAtMs = 0; + this._taskCallState = new Map(); + this._globalWindowMs = DEFAULT_GLOBAL_WINDOW_MINUTES * 60 * 1000; + this._taskStateTtlMs = DEFAULT_TASK_STATE_TTL_MINUTES * 60 * 1000; this._stateLoaded = false; + this._now = now; } async _loadConfig() { @@ -79,25 +109,53 @@ export class PaidFallbackController { this.cwd, general.paid_fallback_state_path || '.opencode/agent-loop-state/paid-fallback-state.json' ); + this._globalWindowMs = positiveInteger( + general.paid_fallback_global_window_minutes, + DEFAULT_GLOBAL_WINDOW_MINUTES + ) * 60 * 1000; + this._taskStateTtlMs = positiveInteger( + general.paid_fallback_task_state_ttl_minutes, + DEFAULT_TASK_STATE_TTL_MINUTES + ) * 60 * 1000; this._loadState(); return this.config; } _loadState() { if (this._stateLoaded || !this.statePath) return; - const state = readState(this.statePath); + const now = this._now(); + const state = readState(this.statePath, now); this._globalCallCount = state.globalCallCount; - this._callCounts = state.taskCallCounts; + this._globalWindowStartedAtMs = state.globalWindowStartedAtMs; + this._taskCallState = state.taskCallState; this._stateLoaded = true; + if (this._refreshWindows(now)) this._saveState(); + } + + _refreshWindows(now = this._now()) { + let changed = false; + if (now - this._globalWindowStartedAtMs >= this._globalWindowMs) { + this._globalCallCount = 0; + this._globalWindowStartedAtMs = now; + changed = true; + } + for (const [taskId, state] of this._taskCallState) { + if (now - state.updatedAtMs >= this._taskStateTtlMs) { + this._taskCallState.delete(taskId); + changed = true; + } + } + return changed; } _saveState() { if (!this.statePath) return; atomicWriteJson(this.statePath, { version: PAID_STATE_VERSION, - savedAt: new Date().toISOString(), + savedAt: new Date(this._now()).toISOString(), globalCallCount: this._globalCallCount, - taskCallCounts: Object.fromEntries(this._callCounts) + globalWindowStartedAtMs: this._globalWindowStartedAtMs, + taskCallState: Object.fromEntries(this._taskCallState) }); } @@ -112,7 +170,7 @@ export class PaidFallbackController { async logEscalation({ taskId, role, freeModelsAttempted, failures, paidModelSelected, result }) { await this._loadConfig(); const entry = { - timestamp: new Date().toISOString(), + timestamp: new Date(this._now()).toISOString(), taskId, role, freeModelsAttempted, @@ -127,13 +185,16 @@ export class PaidFallbackController { async getCallCount(taskId) { await this._loadConfig(); - return this._callCounts.get(taskId) || 0; + if (this._refreshWindows()) this._saveState(); + return this._taskCallState.get(taskId)?.count || 0; } async incrementCallCount(taskId) { await this._loadConfig(); - const next = (this._callCounts.get(taskId) || 0) + 1; - this._callCounts.set(taskId, next); + const now = this._now(); + this._refreshWindows(now); + const next = (this._taskCallState.get(taskId)?.count || 0) + 1; + this._taskCallState.set(taskId, { count: next, updatedAtMs: now }); this._saveState(); return next; } @@ -141,6 +202,7 @@ export class PaidFallbackController { async isCallAllowed(taskId, role, options = {}) { const config = await this._loadConfig(); const general = config.general || {}; + if (this._refreshWindows()) this._saveState(); if (!await this.isPaidFallbackAllowed(role)) { return { allowed: false, reason: `Paid fallback not allowed for role "${role}"` }; } @@ -153,7 +215,10 @@ export class PaidFallbackController { const globalMaxCalls = general.paid_fallback_max_calls_global; if (Number.isInteger(globalMaxCalls) && this._globalCallCount >= globalMaxCalls) { - return { allowed: false, reason: `Global paid fallback call limit (${globalMaxCalls}) reached` }; + return { + allowed: false, + reason: `Global paid fallback call limit (${globalMaxCalls}) reached for the current ${Math.round(this._globalWindowMs / 60000)}-minute window` + }; } if (general.paid_fallback_requires_approval === true && options.approved !== true) { @@ -165,9 +230,11 @@ export class PaidFallbackController { async recordPaidCall(taskId) { await this._loadConfig(); + const now = this._now(); + this._refreshWindows(now); this._globalCallCount += 1; - const next = (this._callCounts.get(taskId) || 0) + 1; - this._callCounts.set(taskId, next); + const next = (this._taskCallState.get(taskId)?.count || 0) + 1; + this._taskCallState.set(taskId, { count: next, updatedAtMs: now }); this._saveState(); return next; } From d1a48a307baae2020b3b005c1f16fe2cc3260e7a Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:57:35 -0400 Subject: [PATCH 099/105] test: cover persistent paid fallback windows --- tests/paid-fallback-state-tests.mjs | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/paid-fallback-state-tests.mjs diff --git a/tests/paid-fallback-state-tests.mjs b/tests/paid-fallback-state-tests.mjs new file mode 100644 index 0000000..74c0878 --- /dev/null +++ b/tests/paid-fallback-state-tests.mjs @@ -0,0 +1,44 @@ +import { strict as assert } from 'node:assert'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { PaidFallbackController } from '../lib/paid-fallback.mjs'; + +const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-paid-state-')); +const configPath = resolve(dir, 'config.json'); +writeFileSync(configPath, JSON.stringify({ + general: { + allow_paid_fallback: true, + paid_fallback_allowed_roles: ['reviewer'], + paid_fallback_max_calls_per_task: 2, + paid_fallback_max_calls_global: 2, + paid_fallback_requires_approval: false, + paid_fallback_global_window_minutes: 1, + paid_fallback_task_state_ttl_minutes: 1, + paid_fallback_log_path: '.opencode/agent-loop-state/paid-escalations.jsonl', + paid_fallback_state_path: '.opencode/agent-loop-state/paid-fallback-state.json' + } +}), 'utf8'); + +let now = Date.parse('2026-07-22T00:00:00Z'); +const first = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal((await first.isCallAllowed('task-a', 'reviewer')).allowed, true); +await first.recordPaidCall('task-a'); +await first.recordPaidCall('task-a'); +assert.equal((await first.isCallAllowed('task-a', 'reviewer')).allowed, false); +assert.equal((await first.isCallAllowed('task-b', 'reviewer')).allowed, false, 'global limit should survive within the active window'); + +const statePath = resolve(dir, '.opencode/agent-loop-state/paid-fallback-state.json'); +assert.equal(existsSync(statePath), true); + +const restarted = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal(await restarted.getCallCount('task-a'), 2, 'per-task counter should survive restart'); +assert.equal((await restarted.isCallAllowed('task-b', 'reviewer')).allowed, false, 'global counter should survive restart'); + +now += 61_000; +const nextWindow = new PaidFallbackController(configPath, { cwd: dir, now: () => now }); +assert.equal(await nextWindow.getCallCount('task-a'), 0, 'expired task state should be pruned'); +assert.equal((await nextWindow.isCallAllowed('task-b', 'reviewer')).allowed, true, 'global window should reset'); + +rmSync(dir, { recursive: true, force: true }); +console.log('paid-fallback-state-tests: passed'); From 1a59050bcb7473dd957478faaf024f52c4bcd913 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:57:58 -0400 Subject: [PATCH 100/105] test: include persistent paid fallback coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b74e4d1..7361216 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "node": ">=18" }, "scripts": { - "test": "node tests/reliability-v020-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", + "test": "node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", "validate:agents": "bash scripts/validate-agent-configs.sh", "validate:routing": "node scripts/check-routing-defaults.mjs", "validate:budget": "node scripts/check-budget-config.mjs", From deae927e8f85dc4bfb5411ad2e8e30da86e39f27 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:58:45 -0400 Subject: [PATCH 101/105] config: define paid fallback counter windows --- config/free-first-config.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/free-first-config.json b/config/free-first-config.json index 53fa867..d1a778f 100644 --- a/config/free-first-config.json +++ b/config/free-first-config.json @@ -35,7 +35,9 @@ "trusted-provider-only" ], "paid_fallback_log_path": ".opencode/agent-loop-state/paid-escalations.jsonl", - "paid_fallback_state_path": ".opencode/agent-loop-state/paid-fallback-state.json" + "paid_fallback_state_path": ".opencode/agent-loop-state/paid-fallback-state.json", + "paid_fallback_global_window_minutes": 1440, + "paid_fallback_task_state_ttl_minutes": 1440 }, "cooldowns": { "single_rate_limit_minutes": 10, From e32aaa500a58c5233b055cb3c804f5a60186df1c Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:59:19 -0400 Subject: [PATCH 102/105] docs: explain paid fallback counter windows --- docs/configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 291fd53..4fce312 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,7 +74,9 @@ The runtime limits parent orchestration with a workflow-call ceiling and a reduc ## Paid fallback audit state -Paid fallback selections and call counters are persisted per project under `.opencode/agent-loop-state/`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistent counters prevent a process restart from resetting per-task or global paid-call ceilings. +Paid fallback selections and call counters are persisted per project under `.opencode/agent-loop-state/`. The audit log contains only model, role, failure-code, and outcome metadata; it excludes prompts and credentials. Persistence prevents a process restart from resetting paid-call ceilings. + +The global call limit uses a rolling window controlled by `paid_fallback_global_window_minutes`—24 hours by default—rather than becoming a permanent lifetime lockout. Per-task counters are pruned after `paid_fallback_task_state_ttl_minutes`, also 24 hours by default. ## Structured events From 514a759f54dfb77db98082181f66deb14b921684 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:00:01 -0400 Subject: [PATCH 103/105] config: validate paid fallback state windows --- scripts/check-budget-config.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/check-budget-config.mjs b/scripts/check-budget-config.mjs index 7d5e7be..29e67c4 100644 --- a/scripts/check-budget-config.mjs +++ b/scripts/check-budget-config.mjs @@ -54,12 +54,8 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { const total = budgets.max_tokens_per_task; const input = budgets.max_input_tokens_per_task; const output = budgets.max_output_tokens_per_task; - if (total !== null && input !== null && input > total) { - fail('input token limit cannot exceed total token limit'); - } - if (total !== null && output !== null && output > total) { - fail('output token limit cannot exceed total token limit'); - } + if (total !== null && input !== null && input > total) fail('input token limit cannot exceed total token limit'); + if (total !== null && output !== null && output > total) fail('output token limit cannot exceed total token limit'); if (total !== null && input !== null && output !== null && input + output > total) { fail('input plus output token limits cannot exceed total token limit'); } @@ -80,7 +76,16 @@ if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) { } } -if (config.provider_timeouts_ms?.local !== config.general?.local_model_request_timeout_seconds * 1000) { +const general = config.general || {}; +for (const key of ['paid_fallback_global_window_minutes', 'paid_fallback_task_state_ttl_minutes']) { + nonNegativeNumber(general[key], `general.${key}`, { integer: true }); + if (general[key] < 1) fail(`general.${key} must be at least 1`); +} +for (const key of ['paid_fallback_log_path', 'paid_fallback_state_path']) { + if (typeof general[key] !== 'string' || !general[key]) fail(`general.${key} must be a non-empty string`); +} + +if (config.provider_timeouts_ms?.local !== general.local_model_request_timeout_seconds * 1000) { fail('provider_timeouts_ms.local must match general.local_model_request_timeout_seconds'); } if (!config.events || config.events.schema_version !== '1.0.0') fail('events.schema_version must be 1.0.0'); From f2374d1fa6732bfea38d90046a5cde510c770299 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:01:56 -0400 Subject: [PATCH 104/105] ci: use Bun required by supported OpenCode --- .github/workflows/opencode-patch-compat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode-patch-compat.yml b/.github/workflows/opencode-patch-compat.yml index 1e4248a..583ae26 100644 --- a/.github/workflows/opencode-patch-compat.yml +++ b/.github/workflows/opencode-patch-compat.yml @@ -59,7 +59,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.11' + bun-version: '1.3.14' - name: Install upstream dependencies working-directory: upstream-opencode From 1f8c59605e97b75b803a931d4a6f4a899323e252 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:04:36 -0400 Subject: [PATCH 105/105] ci: verify actual patched OpenCode contracts --- scripts/check-opencode-patch-contract.mjs | 56 ++++++++++++++++++----- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/scripts/check-opencode-patch-contract.mjs b/scripts/check-opencode-patch-contract.mjs index 959010a..8d9db39 100644 --- a/scripts/check-opencode-patch-contract.mjs +++ b/scripts/check-opencode-patch-contract.mjs @@ -5,29 +5,63 @@ import { resolve } from 'node:path'; const root = resolve(process.argv[2] || 'upstream-opencode'); const checks = [ - ['packages/opencode/src/cli/cmd/run.ts', ['enhanceErrorForJson', 'classification', 'retryAfterMs', 'providerID', 'modelID']], - ['packages/opencode/src/session/message-v2.ts', ['providerID', 'modelID']], - ['packages/opencode/src/session/processor.ts', ['taskFailure']], - ['packages/opencode/src/session/prompt.ts', ['taskFailure']], - ['packages/opencode/test/cli/run/error-metadata.test.ts', ['rate-limit', 'retryAfterMs']] + { + path: 'packages/opencode/src/cli/cmd/run.ts', + markers: [ + 'function enhanceErrorForJson', + 'classification: classifyError', + 'result.retryAfterMs', + 'result.providerID', + 'result.modelID', + 'args.format === "json"' + ] + }, + { + path: 'packages/opencode/src/session/message-v2.ts', + markers: [ + 'modelID?: ModelV2.ID', + 'providerID: ctx.providerID', + 'ctx.modelID ? { modelID: ctx.modelID }' + ] + }, + { + path: 'packages/opencode/src/session/processor.ts', + markers: ['modelID: input.model.id'] + }, + { + path: 'packages/opencode/src/session/prompt.ts', + markers: ['modelID: msg.modelID'] + }, + { + path: 'packages/opencode/test/cli/run/error-metadata.test.ts', + markers: [ + 'adds rate-limit metadata without removing the raw error', + 'expect(event?.classification).toBe("rate-limit")', + 'expect(event?.providerID).toBe("test")', + 'expect(event?.modelID).toBe("test-model")' + ] + } ]; let failed = false; -for (const [relative, markers] of checks) { - const path = resolve(root, relative); +for (const check of checks) { + const path = resolve(root, check.path); if (!existsSync(path)) { - console.error(`FAIL: missing patched file ${relative}`); + console.error(`FAIL: missing patched file ${check.path}`); failed = true; continue; } + const content = readFileSync(path, 'utf8'); - for (const marker of markers) { + let fileFailed = false; + for (const marker of check.markers) { if (!content.includes(marker)) { - console.error(`FAIL: ${relative} missing marker ${marker}`); + console.error(`FAIL: ${check.path} missing marker ${marker}`); failed = true; + fileFailed = true; } } - if (!failed) console.log(`OK: ${relative}`); + if (!fileFailed) console.log(`OK: ${check.path}`); } if (failed) process.exit(1);